Easily interoperate across different contexts.
$ pnpm install comctx
Comctx shares the same goal as Comlink, but it is not reinventing the wheel. Since Comlink relies on MessagePort, which is not supported in all environments, this project implements a more flexible RPC approach that can more easily and effectively adapt to different runtime environments.
import { defineProxy } from 'comctx'
class Counter {
value = 0
async getValue() {}
async onChange(callback: (value: number) => void) {}
async increment() {}
async decrement() {}
}
export const [provideCounter, injectCounter] = defineProxy(() => new Counter(), {
backup: false
})
// provide end, typically for service-workers, background, etc.
const originCounter = provideCounter({
onMessage(message) {},
sendMessage(message) {}
})
originCounter.onChange((value) => {})
// inject end, typically for the main page, content-script, etc.
const proxyCounter = injectCounter({
onMessage(message) {},
sendMessage(message) {}
})
proxyCounter.increment()
-
originCounter
andproxyCounter
will share the sameCounter
.proxyCounter
is a virtual proxy, and accessingproxyCounter
will forward requests to theCounter
on the provide side, whereasoriginCounter
directly refers to theCounter
itself. -
The inject side cannot directly use
get
andset
; it must interact withCounter
via asynchronous methods, but it supports callbacks. -
Since
inject
is a virtual proxy, to support operations likeReflect.has(proxyCounter, 'value')
, you can setbackup
totrue
, which will create a static copy on the inject side that doesn't actually run but serves as a template. -
provideCounter
andinjectCounter
require user-defined adapters for different environments that implementonMessage
andsendMessage
methods.
shared.ts
The Counter
will be shared across different contexts.
import { defineProxy } from 'comctx'
class Counter {
value = 0
async getValue() {
return this.value
}
async onChange(callback: (value: number) => void) {
let oldValue = this.value
setInterval(() => {
const newValue = this.value
if (oldValue !== newValue) {
callback(this.value)
oldValue = newValue
}
})
}
async increment() {
this.value++
return this.value
}
async decrement() {
this.value--
return this.value
}
}
export const [provideCounter, injectCounter] = defineProxy(() => new Counter(), {
namespace: '__comctx-example__'
})
This is an example of communication between the main page and an service-worker.
InjectAdpter.ts
import { Workbox, WorkboxMessageEvent } from 'workbox-window'
import { Adapter, Message } from 'comctx'
export default class InjectAdapter implements Adapter {
workbox: Workbox
constructor(path: string) {
this.workbox = new Workbox(path, { type: import.meta.env.MODE === 'production' ? 'classic' : 'module' })
this.workbox.register()
}
sendMessage(message: Message) {
this.workbox.messageSW(message)
}
onMessage(callback: (message: Message) => void) {
const handler = (event: WorkboxMessageEvent) => callback(event.data)
this.workbox.addEventListener('message', handler)
return () => this.workbox.removeEventListener('message', handler)
}
}
ProvideAdpter.ts
import { Adapter, Message } from 'comctx'
declare const self: ServiceWorkerGlobalScope
export default class ProvideAdapter implements Adapter {
sendMessage(message: Message) {
self.clients.matchAll().then((clients) => {
clients.forEach((client) => client.postMessage(message))
})
}
onMessage(callback: (message: Message) => void) {
const handler = (event: ExtendableMessageEvent) => callback(event.data)
self.addEventListener('message', handler)
return () => self.removeEventListener('message', handler)
}
}
servie-worker.ts
import { provideCounter } from './shared'
import ProvideAdapter from './ProvideAdapter'
declare const self: ServiceWorkerGlobalScope
self.addEventListener('install', () => {
console.log('ServiceWorker installed')
self.skipWaiting()
})
self.addEventListener('activate', (event) => {
console.log('ServiceWorker activated')
event.waitUntil(self.clients.claim())
})
const counter = provideCounter(new ProvideAdapter())
counter.onChange((value) => {
console.log('ServiceWorker Value:', value) // 1,0
})
main.ts
import { injectCounter } from './shared'
import InjectAdapter from './InjectAdapter'
const counter = injectCounter(
new InjectAdapter(import.meta.env.MODE === 'production' ? '/service-worker.js' : '/dev-sw.js?dev-sw')
)
counter.onChange((value) => {
console.log('ServiceWorker Value:', value) // 1,0
})
await counter.getValue() // 0
await counter.decrement() // 1
await counter.increment() // 0
This is an example of communication between the content-script page and an background.
see: browser-extension-example
InjectAdpter.ts
import browser from 'webextension-polyfill'
import { Adapter, Message } from 'comctx'
export interface MessageExtra extends Message {
url: string
}
export default class InjectAdapter implements Adapter<MessageExtra> {
sendMessage(message: Message) {
browser.runtime.sendMessage(browser.runtime.id, { ...message, url: document.location.href })
}
onMessage(callback: (message: MessageExtra) => void) {
const handler = (message: any): undefined => {
callback(message)
}
browser.runtime.onMessage.addListener(handler)
return () => browser.runtime.onMessage.removeListener(handler)
}
}
ProvideAdapter.ts
import browser from 'webextension-polyfill'
import { Adapter, Message } from 'comctx'
export interface MessageExtra extends Message {
url: string
}
export default class ProvideAdapter implements Adapter<MessageExtra> {
async sendMessage(message: MessageExtra) {
const tabs = await browser.tabs.query({ url: message.url })
tabs.map((tab) => browser.tabs.sendMessage(tab.id!, message))
}
onMessage(callback: (message: MessageExtra) => void) {
const handler = (message: any): undefined => {
callback(message)
}
browser.runtime.onMessage.addListener(handler)
return () => browser.runtime.onMessage.removeListener(handler)
}
}
background.ts
import { provideCounter } from './shared'
import ProvideAdapter from './ProvideAdapter'
const counter = provideCounter(new ProvideAdapter())
counter.onChange((value) => {
console.log('Background Value:', value) // 1,0
})
content-script.ts
import { injectCounter } from './shared'
import InjectAdapter from './InjectAdapter'
const counter = injectCounter(new InjectAdapter())
counter.onChange((value) => {
console.log('Background Value:', value) // 1,0
})
await counter.getValue() // 0
await counter.decrement() // 1
await counter.increment() // 0
This is an example of communication between the main page and an iframe.
see: iframe-example
InjectAdapter.ts
import { Adapter, Message } from 'comctx'
export default class InjectAdapter implements Adapter {
sendMessage(message: Message) {
window.postMessage(message, '*')
}
onMessage(callback: (message: Message) => void) {
const handler = (event: MessageEvent) => callback(event.data)
window.addEventListener('message', handler)
return () => window.removeEventListener('message', handler)
}
}
ProvideAdapter.ts
import { Adapter, Message } from 'comctx'
export default class ProvideAdapter implements Adapter {
sendMessage(message: Message) {
window.parent.postMessage(message, '*')
}
onMessage(callback: (message: Message) => void) {
const handler = (event: MessageEvent) => callback(event.data)
window.parent.addEventListener('message', handler)
return () => window.parent.removeEventListener('message', handler)
}
}
iframe.ts
import { provideCounter } from './shared'
import ProvideAdapter from './ProvideAdapter'
const counter = provideCounter(new ProvideAdapter())
counter.onChange((value) => {
console.log('iframe Value:', value) // 1,0
})
index.ts
import { injectCounter } from './shared'
import InjectAdapter from './InjectAdapter'
const counter = injectCounter(new InjectAdapter())
counter.onChange((value) => {
console.log('iframe Value:', value) // 1,0
})
await counter.getValue() // 0
await counter.decrement() // 1
await counter.increment() // 0
The inspiration for this project comes from @webext-core/proxy-service, but Comctx aims to be a better version of it.
This project is licensed under the MIT License - see the LICENSE file for details