Skip to content

molvqingtai/comctx

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

44 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Comctx

Easily interoperate across different contexts.

version workflow download

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

Setup

import { defineProxy } from 'comctx'

class Counter {
  value = 0

  async getValue() {}

  async onChange(callback: (value: number) => void) {}

  async increment() {}

  async decrement() {}
}

export const [provideCounter, injectCounter] = defineProxy(() => new Counter(), {
  backup: false
})

// provide end, typically for service-workers, background, etc.
const originCounter = provideCounter({
  onMessage(message) {},
  sendMessage(message) {}
})
originCounter.onChange((value) => {})

// inject end, typically for the main page, content-script, etc.
const proxyCounter = injectCounter({
  onMessage(message) {},
  sendMessage(message) {}
})
proxyCounter.increment()
  • 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.

Examples

shared.ts

The Counter will be shared across different contexts.

import { defineProxy } from 'comctx'

class Counter {
  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(this.value)
        oldValue = newValue
      }
    })
  }

  async increment() {
    this.value++
    return this.value
  }

  async decrement() {
    this.value--
    return this.value
  }
}

export const [provideCounter, injectCounter] = defineProxy(() => new Counter(), {
  namespace: '__comctx-example__'
})

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, Message } 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(message: Message) {
    this.workbox.messageSW(message)
  }
  onMessage(callback: (message: Message) => void) {
    const handler = (event: WorkboxMessageEvent) => callback(event.data)

    this.workbox.addEventListener('message', handler)
    return () => this.workbox.removeEventListener('message', handler)
  }
}

ProvideAdpter.ts

import { Adapter, Message } from 'comctx'

declare const self: ServiceWorkerGlobalScope

export default class ProvideAdapter implements Adapter {
  sendMessage(message: Message) {
    self.clients.matchAll().then((clients) => {
      clients.forEach((client) => client.postMessage(message))
    })
  }
  onMessage(callback: (message: Message) => void) {
    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.decrement() // 1

await counter.increment() // 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 } from 'comctx'

export interface MessageExtra extends Message {
  url: string
}

export default class InjectAdapter implements Adapter<MessageExtra> {
  sendMessage(message: Message) {
    browser.runtime.sendMessage(browser.runtime.id, { ...message, url: document.location.href })
  }
  onMessage(callback: (message: MessageExtra) => void) {
    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 } from 'comctx'

export interface MessageExtra extends Message {
  url: string
}

export default class ProvideAdapter implements Adapter<MessageExtra> {
  async sendMessage(message: MessageExtra) {
    const tabs = await browser.tabs.query({ url: message.url })
    tabs.map((tab) => browser.tabs.sendMessage(tab.id!, message))
  }

  onMessage(callback: (message: MessageExtra) => void) {
    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.decrement() // 1

await counter.increment() // 0

IFrame

This is an example of communication between the main page and an iframe.

see: iframe-example

InjectAdapter.ts

import { Adapter, Message } from 'comctx'

export default class InjectAdapter implements Adapter {
  sendMessage(message: Message) {
    window.postMessage(message, '*')
  }
  onMessage(callback: (message: Message) => void) {
    const handler = (event: MessageEvent) => callback(event.data)
    window.addEventListener('message', handler)
    return () => window.removeEventListener('message', handler)
  }
}

ProvideAdapter.ts

import { Adapter, Message } from 'comctx'

export default class ProvideAdapter implements Adapter {
  sendMessage(message: Message) {
    window.parent.postMessage(message, '*')
  }
  onMessage(callback: (message: Message) => void) {
    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
})

index.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.decrement() // 1

await counter.increment() // 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

About

Easily interoperate across different contexts.

Resources

License

Stars

Watchers

Forks

Packages

No packages published