comctx
TypeScript icon, indicating that this package has built-in type declarations

1.3.0 • Public • Published

Comctx

Cross-context RPC solution with type safety and flexible adapters.

version workflow download npm package minimized gzipped size

$ 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
  • Transferable Objects - Automatic extraction and zero-copy transfer of transferable objects
  • 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: 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)

// 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(), 10)

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 and proxyCounter will share the same Counter. proxyCounter is a virtual proxy, and accessing proxyCounter will forward requests to the Counter on the provide side, whereas originCounter directly refers to the Counter itself.

  • The inject side cannot directly use get and set; it must interact with Counter via asynchronous methods, but it supports callbacks.

  • Since inject is a virtual proxy, to support operations like Reflect.has(proxyCounter, 'value'), you can set backup to true, which will create a static copy on the inject side that doesn't actually run but serves as a template.

  • provideCounter and injectCounter require user-defined adapters for different environments that implement onMessage and sendMessage methods.

🧩 Advanced Usage

Separate Inject and Provide Definitions

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 inject 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__'
})

Zero-Copy Transfer

Comctx supports zero-copy transfer as an optimization over the default structured cloning:

Zero-Copy (transfer: true): Uses Transferable Objects for zero-copy transfer

class Counter {
  public value = new ArrayBuffer(4)

  async transfer() {
    return 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: '__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

await counter.transfer() // DataCloneError: Failed to execute 'postMessage' on 'DedicatedWorkerGlobalScope': ArrayBuffer at index 0 is already detached.
await counter.increment() // Error: Cannot perform Construct on a detached ArrayBuffer

new Int32Array(value)[0]++ // Modify transferred ArrayBuffer directly

Key Differences:

  • Locked streams are automatically filtered out to prevent DataCloneError
  • For transfer-enabled adapters, implement SendMessage with the transfer parameter:
// Transfer-enabled adapter
export default class TransferAdapter implements Adapter {
  sendMessage: SendMessage = (message, transfer) => {
    this.worker.postMessage(message, transfer)
  }
  // ... rest of implementation
}

🔌 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, transfer: Transferable[]) => MaybePromise<void>

  /** Register a message listener */
  onMessage: (callback: (message?: Partial<M>) => void) => MaybePromise<OffMessage | void>
}

📖Examples

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, transfer) => {
    this.worker.postMessage(message, transfer)
  }
  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, transfer) => {
    self.postMessage(message, transfer)
  }
  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

Package Sidebar

Install

npm i comctx

Weekly Downloads

83

Version

1.3.0

License

MIT

Unpacked Size

145 kB

Total Files

125

Last publish

Collaborators

  • molvqingtai