1.0.9 • Published 5 months ago

comctx v1.0.9

Weekly downloads
-
License
MIT
Repository
github
Last release
5 months ago

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 health 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 service-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)

Consumer (Service Consumer)

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

📖Examples

Service Worker

This is an example of communication between the main page and an service-worker.

see: service-worker-example

InjectAdpter.ts

import { Workbox, WorkboxMessageEvent } from 'workbox-window'
import { Adapter, SendMessage, OnMessage } from 'comctx'

export default class InjectAdapter implements Adapter {
  workbox: Workbox
  constructor(path: string) {
    this.workbox = new Workbox(path, { type: import.meta.env.MODE === 'production' ? 'classic' : 'module' })
    this.workbox.register()
  }
  sendMessage: SendMessage = (message) => {
    this.workbox.messageSW(message)
  }
  onMessage: OnMessage = (callback) => {
    const handler = (event: WorkboxMessageEvent) => callback(event.data)
    this.workbox.addEventListener('message', handler)
    return () => this.workbox.removeEventListener('message', handler)
  }
}

ProvideAdpter.ts

import { Adapter, SendMessage, OnMessage } from 'comctx'

declare const self: ServiceWorkerGlobalScope

export default class ProvideAdapter implements Adapter {
  sendMessage: SendMessage = (message) => {
    self.clients.matchAll().then((clients) => {
      clients.forEach((client) => client.postMessage(message))
    })
  }
  onMessage: OnMessage = (callback) => {
    const handler = (event: ExtendableMessageEvent) => callback(event.data)
    self.addEventListener('message', handler)
    return () => self.removeEventListener('message', handler)
  }
}

servie-worker.ts

import { provideCounter } from './shared'
import ProvideAdapter from './ProvideAdapter'

declare const self: ServiceWorkerGlobalScope

self.addEventListener('install', () => {
  console.log('ServiceWorker installed')
  self.skipWaiting()
})
self.addEventListener('activate', (event) => {
  console.log('ServiceWorker activated')
  event.waitUntil(self.clients.claim())
})

const counter = provideCounter(new ProvideAdapter())

counter.onChange((value) => {
  console.log('ServiceWorker Value:', value) // 1,0
})

main.ts

import { injectCounter } from './shared'
import InjectAdapter from './InjectAdapter'

const counter = injectCounter(
  new InjectAdapter(import.meta.env.MODE === 'production' ? '/service-worker.js' : '/dev-sw.js?dev-sw')
)

counter.onChange((value) => {
  console.log('ServiceWorker 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

1.0.9

5 months ago

1.0.8

5 months ago

1.0.7

5 months ago

1.0.6

5 months ago

1.0.5

5 months ago

1.0.4

5 months ago

1.0.3

5 months ago

1.0.2

5 months ago

1.0.1

5 months ago

1.0.0

5 months ago