API Reference
Reference for `defineProxy`, the adapter interface, and documented Comctx options.
defineProxy
defineProxy creates a Provider/Injector pair for 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(
() => new Counter(),
{
namespace: '__comctx-example__',
},
)Provider and Injector
Provider
The Provider side owns the real service implementation.
import CustomAdapter from 'CustomAdapter'
import { provideCounter } from './shared'
const originCounter = provideCounter(new CustomAdapter())
originCounter.onChange(console.log)originCounter directly references the real Counter instance.
Typical Provider environments include:
- web workers
- background scripts
- iframe pages
Injector
The Injector side receives a virtual proxy.
import CustomAdapter from 'CustomAdapter'
import { injectCounter } from './shared'
const proxyCounter = injectCounter(new CustomAdapter())
proxyCounter.onChange(console.log)
await proxyCounter.increment()
const count = await proxyCounter.getValue()proxyCounter forwards requests to the Provider side.
Typical Injector environments include:
- main pages
- content scripts
- host pages
Important behavior
Async methods
The Injector side cannot directly use get and set.
It should interact with the remote service through asynchronous methods.
Prefer this:
await proxyCounter.increment()
const count = await proxyCounter.getValue()Over this:
proxyCounter.value
proxyCounter.value = 1Callback support
Callbacks are supported across the boundary.
proxyCounter.onChange(console.log)backup
Because the Injector is a virtual proxy, operations such as Reflect.has(proxyCounter, 'key') may need a static template on the Injector side.
You can enable backup for that:
const [provideCounter, injectCounter] = defineProxy(() => new Counter(), {
namespace: '__comctx-example__',
backup: true,
})Documented options
namespace
namespace identifies the service channel.
const [provideCounter, injectCounter] = defineProxy(() => new Counter(), {
namespace: '__comctx-example__',
})Rules:
- the Provider and Injector must use the same
namespace - different services should use different namespaces
- examples in this repository use explicit namespace strings such as
__comctx-example__
transfer / Transferable Objects
By default, method parameters, return values, and object property values are copied with structured cloning.
If you want a value to be transferred rather than copied, you can enable transfer, provided the value is or contains a transferable object.
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
}
}
export const [provideAi, injectAi] = defineProxy(() => new AiService(), {
namespace: '__worker-transfer-example__',
transfer: true,
})When transfer is enabled:
- transferable objects are automatically extracted
- they are passed to the adapter as the
transferargument - zero-copy behavior depends on runtime support
Supported transferable objects are listed on MDN:
ArrayBufferAudioDataImageBitmapMediaSourceHandleMediaStreamTrackMessagePortMIDIAccessOffscreenCanvasReadableStreamRTCDataChannelTransformStreamVideoFrameWebTransportReceiveStreamWebTransportSendStreamWritableStream
See MDN for the authoritative list of Transferable objects. The list may not be exhaustive, and support depends on the runtime and platform.
Transfer usage
const ai = injectAi(adapter)
const stream = await ai.translate('Hello world', 'zh-CN')
for await (const chunk of stream) {
console.log(chunk)
}Transfer-enabled adapter
import type { Adapter, SendMessage } from 'comctx'
export default class TransferAdapter implements Adapter {
sendMessage: SendMessage = (message, transfer) => {
this.worker.postMessage(message, transfer)
}
}Transfer note
AsyncIterable is not a transferable object and cannot be sent across workers.
Wrap it with ReadableStream.from before returning or sending it so it can be transferred.
heartbeatCheck
heartbeatCheck enables a provider readiness check.
const [, inject] = defineProxy(() => ({}) as T, {
namespace: '__comlink_like__',
heartbeatCheck: false,
transfer: true,
})const [provide] = defineProxy(() => target, {
namespace: '__comlink_like__',
heartbeatCheck: false,
transfer: true,
})When enabled, the Injector sends heartbeat messages and waits for the Provider to respond before continuing.
This is useful when the Provider side may not be ready immediately, such as:
- asynchronous worker startup
- iframe loading
- other cases where the Provider is initialized after the Injector starts
If the Provider does not respond within the configured timeout, the call fails with a provider unavailable error.
debug
debug enables console diagnostics for adapter integration and cross-context message flow.
const [provideCounter, injectCounter] = defineProxy(() => new Counter(), {
namespace: '__comctx-example__',
debug: import.meta.env.DEV,
})The option accepts:
debug?: boolean | 'message' | 'event'Values:
falsedisables debug output.truelogs bothcomctx:messageandcomctx:event.messagelogs onlycomctx:message.eventlogs onlycomctx:event.
Log categories:
comctx:messageshows messages seen by the adapter. These logs can include repeated listener notifications and messages that Comctx does not ultimately handle.comctx:eventshows messages accepted by Comctx and routed into send or receive handling.
Examples:
comctx:message provider onMessage apply
comctx:event provider onMessage apply
comctx:event injector sendMessage pingThe second label is the current actor. For sendMessage, it is the side sending the message. For onMessage, it is the side currently receiving or handling the message.
Adapter Interface
To adapt Comctx to different communication channels, implement this interface:
interface Adapter<T extends MessageMeta = MessageMeta> {
/** Optional adapter name for debug logs and message diagnostics */
name?: string
/** Send a message to the other side */
sendMessage: (
message: Message<T>,
transfer: Transferable[],
) => MaybePromise<void>
/** Register a message listener */
onMessage: (
callback: (message?: Partial<Message<T>>) => void,
) => MaybePromise<OffMessage | void>
}sendMessage
sendMessage is responsible for delivering the outgoing message to the other side.
sendMessage: (message, transfer) => {
postMessage(message, transfer)
}Responsibilities:
- send the message to the correct target
- forward
transferwhen transfer is enabled and supported - preserve the message payload shape
onMessage
onMessage registers a listener for incoming messages.
onMessage: (callback) => {
const handler = (event: MessageEvent) => callback(event.data)
addEventListener('message', handler)
return () => removeEventListener('message', handler)
}Responsibilities:
- listen on the correct runtime object
- forward the incoming payload to
callback - return cleanup logic when possible
Separate Inject and Provide Definitions
For multi-package architectures, you can define inject and provide proxies separately to avoid bundling shared implementation code in both packages.
Provider side
import { defineProxy } from 'comctx'
import { Counter } from './shared'
export const [provideCounter] = defineProxy(() => new Counter(), {
namespace: '__comctx-example__',
})Injector side
import { defineProxy } from 'comctx'
import type { Counter } from './shared'
export const [, injectCounter] = defineProxy(() => ({}) as Counter, {
namespace: '__comctx-example__',
})Since the Injector side is a virtual proxy that does not actually run the implementation, it can pass an empty object cast to the service type.
Comlink-style wrapper example
Comctx can be used to build a Comlink-style wrapper, inspired by @wordpress/worker-threads.
import {
defineProxy,
type Adapter,
type OnMessage,
type SendMessage,
} from 'comctx'
type WorkerEndpoint = Pick<Worker, 'postMessage' | 'addEventListener' | 'removeEventListener'>
class WorkerAdapter implements Adapter {
constructor(
private worker: WorkerEndpoint,
public name?: string
) {}
sendMessage: SendMessage = (message, transfer) => {
this.worker.postMessage(message, transfer)
}
onMessage: OnMessage = (callback) => {
const handler = (event: MessageEvent) => callback(event.data)
this.worker.addEventListener('message', handler)
return () => this.worker.removeEventListener('message', handler)
}
}
export function wrap<T extends object>(worker: Worker) {
const [, inject] = defineProxy(() => ({}) as T, {
namespace: '__comlink_like__',
heartbeatCheck: false,
transfer: true,
})
return inject(new WorkerAdapter(worker))
}
export function expose<T extends object>(target: T) {
const [provide] = defineProxy(() => target, {
namespace: '__comlink_like__',
heartbeatCheck: false,
transfer: true,
})
provide(new WorkerAdapter(self))
}