Cross-context RPC solution with type safety and flexible adapters.
$ 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.
- Environment Agnostic - Works across Web Workers, Browser Extensions, iframes, Electron, and more
- Bidirectional Communication - Method calls & callback support
- Zero Copy - Automatic extraction and zero-copy transfer of transferable objects
- Type Safety - Full TypeScript integration
- Lightweight - 1KB gzipped core
- Fault Tolerance - Backup implementations & connection heartbeat checks
Define a Shared Service
import { defineProxy } from 'comctx'
class Counter {
public value: number
constructor(initialValue: number = 0) {
this.value = initialValue
}
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((initialValue: number = 0) => new Counter(initialValue), {
namespace: '__comctx-example__'
})
Provider (Service Provider)
// Provider side, 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(), 10)
originCounter.onChange(console.log)
Injector (Service Injector)
// Injector side, 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
share the sameCounter
instance.proxyCounter
is a virtual proxy that forwards requests to theCounter
on the provider side, whileoriginCounter
directly references theCounter
itself. -
The injector side cannot directly use
get
andset
; it must interact withCounter
via asynchronous methods, but callbacks are supported. -
Since the injector is a virtual proxy, to support operations like
Reflect.has(proxyCounter, 'value')
, you can setbackup
totrue
, which creates a static copy on the injector side that serves as a template without actually running. -
provideCounter
andinjectCounter
require user-defined adapters for different environments that implementonMessage
andsendMessage
methods.
For multi-package architectures, you can define inject and provide proxies separately to avoid bundling shared code in both packages.
By default, both provider and injector would bundle the same implementation code, but the injector only needs it for type safety:
// packages/provider/src/index.ts
import { defineProxy } from 'comctx'
class Counter {
public value = 0
async increment() {
return ++this.value
}
}
const counter = new Counter()
export const [provideCounter] = defineProxy(() => counter, {
namespace: '__comctx-example__'
})
// packages/injector/src/index.ts
import { defineProxy } from 'comctx'
// Define type-only counter for type safety
interface Counter {
value: number
increment(): Promise<number>
}
// Since the injector side is a virtual proxy that doesn't actually run, we can pass an empty object
const counter = {} as Counter
export const [, injectCounter] = defineProxy(() => counter, {
namespace: '__comctx-example__'
})
By default, every method parameter, return value and object property value is copied (structured cloning). Comctx performs no internal serialization and natively supports transferable objects.
If you want a value to be transferred rather than copied — provided the value is or contains a Transferable — you can enable the transfer
option. When enabled, transferable objects are automatically extracted and transferred using zero-copy semantics:
class Counter {
public value = new ArrayBuffer(4)
async transfer() {
return this.value // Zero-copy transfer, this.value becomes detached
}
async increment() {
// Error: Cannot access this.value after transfer (detached ArrayBuffer)
new Int32Array(this.value)[0]++
}
}
export const [provideCounter, injectCounter] = defineProxy(() => new Counter(), {
namespace: '__worker-transfer-example__',
transfer: true // Automatically extract and transfer transferable objects
})
// Usage - receive transferred ArrayBuffer
const counter = injectCounter(adapter)
const value = await counter.transfer() // ✅ Return: zero-copy ArrayBuffer
new Int32Array(value)[0]++ // ✅ Modify transferred ArrayBuffer directly
await counter.increment() // ❌ Error: Cannot perform Construct on a detached ArrayBuffer
await counter.transfer() // ❌ Error: Failed to execute 'postMessage' on 'DedicatedWorkerGlobalScope': ArrayBuffer at index 0 is already detached.
When transfer is enabled, transferable objects are automatically extracted from messages and passed as the transfer parameter to SendMessage
:
// Transfer-enabled adapter
export default class TransferAdapter implements Adapter {
sendMessage: SendMessage = (message, transfer) => {
this.worker.postMessage(message, transfer)
}
// ... rest of implementation
}
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, transfer: Transferable[]) => MaybePromise<void>
/** Register a message listener */
onMessage: (callback: (message?: Partial<M>) => void) => MaybePromise<OffMessage | void>
}
- web-worker-example
- shared-worker-example
- service-worker-example
- worker-transfer-example
- browser-extension-example
- iframe-example
This is an example of communication between the main page and a web worker.
see: web-worker-example
InjectAdapter.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)
}
}
ProvideAdapter.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
This is an example of communication between the content-script and background script.
see: browser-extension-example
InjectAdapter.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
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
- Comctx: A Better Cross-Context Communication Library Than Comlink (English)
- Comctx:比 Comlink 更好的跨上下文通信库 (中文)
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