comctx
v1.1.4
Published
Cross-context RPC solution with type safety and flexible adapters.
Downloads
414
Maintainers
Readme
Comctx
Cross-context RPC solution with type safety and flexible adapters.
$ pnpm install comctx
✨Introduction
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.
💡Features
Environment Agnostic - Works across Web Workers, Browser Extensions, iframes, Electron, and more
Bidirectional Communication - Method calls & callback support
Type Safety - Full TypeScript integration
Lightweight - 1KB gzipped core
Fault Tolerance - Backup implementations & connection heartbeat checks
🚀 Quick Start
Define a Shared Service
import { defineProxy } from 'comctx'
class Counter {
public 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(newValue)
oldValue = newValue
}
})
}
async increment() {
return ++this.value
}
async decrement() {
return --this.value
}
}
export const [provideCounter, injectCounter] = defineProxy(() => new Counter(), {
namespace: '__comctx-example__'
})
Provider (Service Provider)
// provide end, typically for web-workers, background, etc.
import type { Adapter, SendMessage, OnMessage } from 'comctx'
import { provideCounter } from './shared'
export default class ProvideAdapter implements Adapter {
// Implement message sending
sendMessage: SendMessage = (message) => {
postMessage(message)
}
// Implement message listener
onMessage: OnMessage = (callback) => {
const handler = (event: MessageEvent) => callback(event.data)
addEventListener('message', handler)
return () => removeEventListener('message', handler)
}
}
const originCounter = provideCounter(new ProvideAdapter())
originCounter.onChange(console.log)
Injector (Service Injector)
// inject end, typically for the main page, content-script, etc.
import type { Adapter, SendMessage, OnMessage } from 'comctx'
import { injectCounter } from './shared'
export default class InjectAdapter implements Adapter {
// Implement message sending
sendMessage: SendMessage = (message) => {
postMessage(message)
}
// Implement message listener
onMessage: OnMessage = (callback) => {
const handler = (event: MessageEvent) => callback(event.data)
addEventListener('message', handler)
return () => removeEventListener('message', handler)
}
}
const proxyCounter = injectCounter(new InjectAdapter())
// Support for callbacks
proxyCounter.onChange(console.log)
// Transparently call remote methods
await proxyCounter.increment()
const count = await proxyCounter.getValue()
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.
🔌 Adapter Interface
To adapt to different communication channels, implement the following interface:
interface Adapter<M extends Message = Message> {
/** Send a message to the other side */
sendMessage: (message: M) => MaybePromise<void>
/** Register a message listener */
onMessage: (callback: (message?: Partial<M>) => void) => MaybePromise<OffMessage | void>
}
📖Examples
- web-worker-example
- shared-worker-example
- service-worker-example
- browser-extension-example
- iframe-example
Web Worker
This is an example of communication between the main page and an web-worker.
see: web-worker-example
InjectAdpter.ts
import { Adapter, SendMessage, OnMessage, Message } from 'comctx'
export default class InjectAdapter implements Adapter {
worker: Worker
constructor(path: string | URL) {
this.worker = new Worker(path, { type: 'module' })
}
sendMessage: SendMessage = (message) => {
this.worker.postMessage(message)
}
onMessage: OnMessage = (callback) => {
const handler = (event: MessageEvent<Message>) => callback(event.data)
this.worker.addEventListener('message', handler)
return () => this.worker.removeEventListener('message', handler)
}
}
ProvideAdpter.ts
import { Adapter, SendMessage, OnMessage, Message } from 'comctx'
declare const self: DedicatedWorkerGlobalScope
export default class ProvideAdapter implements Adapter {
sendMessage: SendMessage = (message) => {
self.postMessage(message)
}
onMessage: OnMessage = (callback) => {
const handler = (event: MessageEvent<Message>) => callback(event.data)
self.addEventListener('message', handler)
return () => self.removeEventListener('message', handler)
}
}
web-worker.ts
import { provideCounter } from './shared'
import ProvideAdapter from './ProvideAdapter'
const counter = provideCounter(new ProvideAdapter())
counter.onChange((value) => {
console.log('WebWorker Value:', value)
})
main.ts
import { injectCounter } from './shared'
import InjectAdapter from './InjectAdapter'
const counter = injectCounter(new InjectAdapter(new URL('./web-worker.ts', import.meta.url)))
counter.onChange((value) => {
console.log('WebWorker Value:', value) // 1,0
})
await counter.getValue() // 0
await counter.increment() // 1
await counter.decrement() // 0
Browser Extension
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, SendMessage, OnMessage } from 'comctx'
export interface MessageExtra extends Message {
url: string
}
export default class InjectAdapter implements Adapter<MessageExtra> {
sendMessage: SendMessage<MessageExtra> = (message) => {
browser.runtime.sendMessage(browser.runtime.id, { ...message, url: document.location.href })
}
onMessage: OnMessage<MessageExtra> = (callback) => {
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, SendMessage, OnMessage } from 'comctx'
export interface MessageExtra extends Message {
url: string
}
export default class ProvideAdapter implements Adapter<MessageExtra> {
sendMessage: SendMessage<MessageExtra> = async (message) => {
const tabs = await browser.tabs.query({ url: message.url })
tabs.map((tab) => browser.tabs.sendMessage(tab.id!, message))
}
onMessage: OnMessage<MessageExtra> = (callback) => {
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.increment() // 1
await counter.decrement() // 0
IFrame
This is an example of communication between the main page and an iframe.
see: iframe-example
InjectAdapter.ts
import { Adapter, SendMessage, OnMessage } from 'comctx'
export default class InjectAdapter implements Adapter {
sendMessage: SendMessage = (message) => {
window.postMessage(message, '*')
}
onMessage: OnMessage = (callback) => {
const handler = (event: MessageEvent) => callback(event.data)
window.addEventListener('message', handler)
return () => window.removeEventListener('message', handler)
}
}
ProvideAdapter.ts
import { Adapter, SendMessage, OnMessage } from 'comctx'
export default class ProvideAdapter implements Adapter {
sendMessage: SendMessage = (message) => {
window.parent.postMessage(message, '*')
}
onMessage: OnMessage = (callback) => {
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
})
main.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.increment() // 1
await counter.decrement() // 0
🩷Thanks
The inspiration for this project comes from @webext-core/proxy-service, but Comctx aims to be a better version of it.
📃License
This project is licensed under the MIT License - see the LICENSE file for details