Comctx
Reference

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 = 1

Callback 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 transfer argument
  • zero-copy behavior depends on runtime support

Supported transferable objects are listed on MDN:

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.

Adapter Interface

To adapt Comctx to different communication channels, implement this interface:

interface Adapter<T extends MessageMeta = MessageMeta> {
  /** 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 transfer when 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.

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'

class WorkerInjectAdapter implements Adapter {
  constructor(private worker: Worker) {}

  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)
  }
}

class WorkerProvideAdapter implements Adapter {
  sendMessage: SendMessage = (message, transfer) => {
    self.postMessage(message, transfer)
  }

  onMessage: OnMessage = (callback) => {
    const handler = (event: MessageEvent) => callback(event.data)
    self.addEventListener('message', handler)
    return () => self.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 WorkerInjectAdapter(worker))
}

export function expose<T extends object>(target: T) {
  const [provide] = defineProxy(() => target, {
    namespace: '__comlink_like__',
    heartbeatCheck: false,
    transfer: true,
  })

  provide(new WorkerProvideAdapter())
}

See also

On this page