Skip to content

molvqingtai/comctx

Repository files navigation

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

  • 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 = 0
  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 (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())

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.

🔌 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) => 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) => {
    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)
  }
}

ProvideAdpter.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

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

Packages

No packages published

Contributors 2

  •  
  •