Use RPC to communicate easily across contexts in any JavaScript environment.
npm install comctxUse RPC to communicate easily across contexts in any JavaScript environment.
    
``shell`
$ pnpm install comctx
Comctx aims to solve the communication problem between different contexts in a JavaScript environment. Comctx has a similar goal to Comlink, but it's not reinventing the wheel, as Comlink relies on MessagePort, which is limited in some environments (Issues: 438).
Comctx enables flexible adaptation to different environments, such as: Web Workers, Browser Extensions, iframes, Electron, etc., making cross-context communication never easier.
https://github.com/user-attachments/assets/d1601b54-2669-45d7-b1e5-9bbde1186856
- 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 Shared Service
`typescript
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(() => new Counter(), {
namespace: '__comctx-example__'
})
`
Define Adapter
`typescript
import type { Adapter, SendMessage, OnMessage } from 'comctx'
export default class CustomAdapter 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)
}
}
`
Provider (Service Provider)
`typescript
// Provider side, typically for web-workers, background, etc.
import CustomAdapter from 'CustomAdapter'
import { provideCounter } from './shared'
const originCounter = provideCounter(new CustomAdapter())
originCounter.onChange(console.log)
`
Injector (Service Injector)
`typescript
// Injector side, typically for the main page, content-script, etc.
import CustomAdapter from 'CustomAdapter'
import { injectCounter } from './shared'
const proxyCounter = injectCounter(new CustomAdapter())
// Support for callbacks
proxyCounter.onChange(console.log)
// Transparently call remote methods
await proxyCounter.increment()
const count = await proxyCounter.getValue()
`
- originCounter and proxyCounter share the same Counter instance. proxyCounter is a virtual proxy that forwards requests to the Counter on the provider side, while originCounter directly references the Counter itself.
- The injector side cannot directly use get and set; it must interact with Counter via asynchronous methods, but callbacks are supported.
- Since the injector is a virtual proxy, to support operations like Reflect.has(proxyCounter, 'key'), you can set backup to true, which creates a static copy on the injector side that serves as a template without actually running.
- provideCounter and injectCounter require user-defined adapters for different environments that implement onMessage and sendMessage 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:
`typescript
// packages/provider/src/index.ts
import { defineProxy } from 'comctx'
import { Counter } from './shared'
export const [provideCounter] = defineProxy(() => new Counter(), {
namespace: '__comctx-example__'
})
`
`typescript
// packages/injector/src/index.ts
import { defineProxy } from 'comctx'
import type { Counter } from './shared'
// Since the injector side is a virtual proxy that doesn't actually run, we can pass an empty object
export const [, injectCounter] = defineProxy(() => ({}) as 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:
`typescript
import { defineProxy } from 'comctx'
import { streamText } from 'ai'
import { openai } from '@ai-sdk/openai'
class AiService {
async translate(text: string, targetLanguage: string) {
const result = await streamText({
model: openai('gpt-4o-mini'),
prompt: Translate to ${targetLanguage}:\n${text}
})
return result.textStream // ReadableStream is transferable when transfer is enabled
}
}
export const [provideAi, injectAi] = defineProxy(() => new AiService(), {
namespace: '__worker-transfer-example__',
transfer: true // Automatically extract and transfer transferable objects
})
// Usage - receive transferred ReadableStream from an AI translation
const ai = injectAi(adapter)
const stream = await ai.translate('Hello world', 'zh-CN')
for await (const chunk of stream) {
console.log(chunk)
}
`
#### Adapter Implementation
When transfer is enabled, transferable objects are automatically extracted from messages and passed as the transfer parameter to SendMessage:
`typescript`
// 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:
`typescript
interface Adapter
/* Send a message to the other side /
sendMessage: (message: Message
/* Register a message listener /
onMessage: (callback: (message?: Partial
}
`
Note: AsyncIterable is not a Transferable and cannot be sent across workers. Wrap it with ReadableStream.from before returning or sending it so it can be transferred.
- 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
`typescript
import { Adapter, SendMessage, OnMessage } 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) => callback(event.data)
this.worker.addEventListener('message', handler)
return () => this.worker.removeEventListener('message', handler)
}
}
`
ProvideAdapter.ts
`typescript
import { Adapter, SendMessage, OnMessage } 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) => callback(event.data)
self.addEventListener('message', handler)
return () => self.removeEventListener('message', handler)
}
}
`
web-worker.ts
`typescript
import { provideCounter } from './shared'
import ProvideAdapter from './ProvideAdapter'
const counter = provideCounter(new ProvideAdapter())
counter.onChange((value) => {
console.log('WebWorker Value:', value)
})
`
main.ts
`typescript
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
`typescript
import browser from 'webextension-polyfill'
import { Adapter, Message, SendMessage, OnMessage } from 'comctx'
export interface MessageMeta {
url: string
}
export default class InjectAdapter implements Adapter
sendMessage: SendMessage
browser.runtime.sendMessage(browser.runtime.id, { ...message, meta: { url: document.location.href } })
}
onMessage: OnMessage
const handler = (message?: Partial
callback(message)
}
browser.runtime.onMessage.addListener(handler)
return () => browser.runtime.onMessage.removeListener(handler)
}
}
`
ProvideAdapter.ts
`typescript
import browser from 'webextension-polyfill'
import { Adapter, Message, SendMessage, OnMessage } from 'comctx'
export interface MessageMeta {
url: string
}
export default class ProvideAdapter implements Adapter
sendMessage: SendMessage
const tabs = await browser.tabs.query({ url: message.meta.url })
tabs.map((tab) => browser.tabs.sendMessage(tab.id!, message))
}
onMessage: OnMessage
const handler = (message?: Partial
callback(message)
}
browser.runtime.onMessage.addListener(handler)
return () => browser.runtime.onMessage.removeListener(handler)
}
}
`
background.ts
`typescript
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
`typescript
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
`typescript
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
`typescript
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
`typescript
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
`typescript
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
``
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