├── .gitignore ├── src ├── index.ts ├── backgroundEndpoint.ts └── adapter.ts ├── package.json ├── tsconfig.json ├── yarn.lock └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./adapter"; 2 | export * from "./backgroundEndpoint"; 3 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "comlink-extension", 3 | "version": "1.0.8", 4 | "main": "dist/index.js", 5 | "types": "dist/index.d.ts", 6 | "repository": "https://github.com/samdenty/comlink-extension", 7 | "author": "Sam Denty ", 8 | "license": "MIT", 9 | "files": [ 10 | "dist" 11 | ], 12 | "scripts": { 13 | "build": "tsc" 14 | }, 15 | "dependencies": { 16 | "webextension-polyfill": "^0.10.0" 17 | }, 18 | "devDependencies": { 19 | "@types/webextension-polyfill": "^0.10.1", 20 | "comlink": "^4.4.1", 21 | "typescript": "^5.1.6" 22 | } 23 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "alwaysStrict": false, 4 | "forceConsistentCasingInFileNames": true, 5 | "module": "CommonJS", 6 | "moduleResolution": "node", 7 | "strict": true, 8 | "strictNullChecks": true, 9 | "strictFunctionTypes": true, 10 | "downlevelIteration": true, 11 | "sourceMap": true, 12 | "experimentalDecorators": true, 13 | "esModuleInterop": true, 14 | "noImplicitAny": true, 15 | "noImplicitReturns": true, 16 | "noImplicitThis": true, 17 | "skipLibCheck": true, 18 | "noFallthroughCasesInSwitch": true, 19 | "noUnusedLocals": false, 20 | "noUnusedParameters": false, 21 | "target": "ES2019", 22 | "jsx": "react", 23 | "lib": ["esnext", "webworker", "dom"], 24 | "declaration": true, 25 | "rootDir": "src", 26 | "outDir": "dist" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/backgroundEndpoint.ts: -------------------------------------------------------------------------------- 1 | import browser, { Runtime } from "webextension-polyfill"; 2 | import { forward, isMessagePort, createEndpoint } from "./adapter"; 3 | 4 | const portCallbacks = new Map void)[]>(); 5 | const ports = new Map(); 6 | 7 | async function serializePort(id: string) { 8 | if (!portCallbacks.has(id)) { 9 | portCallbacks.set(id, []); 10 | } 11 | const callbacks = portCallbacks.get(id)!; 12 | return new Promise((resolve) => { 13 | callbacks.push((port) => resolve(port)); 14 | }); 15 | } 16 | 17 | function deserializePort(id: string) { 18 | const port = ports.get(id)!; 19 | const { port1, port2 } = new MessageChannel(); 20 | forward(port2, port, serializePort, deserializePort); 21 | return port1; 22 | } 23 | 24 | browser.runtime.onConnect.addListener((port) => { 25 | if (!isMessagePort(port)) return; 26 | ports.set(port.name, port); 27 | portCallbacks.get(port.name)?.forEach((cb) => cb(port)); 28 | }); 29 | 30 | export function createBackgroundEndpoint(port: Runtime.Port) { 31 | return createEndpoint(port, serializePort, deserializePort); 32 | } 33 | -------------------------------------------------------------------------------- /yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | "@types/webextension-polyfill@^0.10.1": 6 | version "0.10.1" 7 | resolved "https://registry.yarnpkg.com/@types/webextension-polyfill/-/webextension-polyfill-0.10.1.tgz#63698f0ef78a069d2d307be3caaee5e70c12e09d" 8 | integrity sha512-Sdg+E2F5JUbhkE1qX15QUxpyhfMFKRGJqND9nb1C0gNN4NR7kCV31/1GvNbg6Xe+m/JElJ9/lG5kepMzjGPuQw== 9 | 10 | comlink@^4.4.1: 11 | version "4.4.1" 12 | resolved "https://registry.yarnpkg.com/comlink/-/comlink-4.4.1.tgz#e568b8e86410b809e8600eb2cf40c189371ef981" 13 | integrity sha512-+1dlx0aY5Jo1vHy/tSsIGpSkN4tS9rZSW8FIhG0JH/crs9wwweswIo/POr451r7bZww3hFbPAKnTpimzL/mm4Q== 14 | 15 | typescript@^5.1.6: 16 | version "5.7.2" 17 | resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.7.2.tgz#3169cf8c4c8a828cde53ba9ecb3d2b1d5dd67be6" 18 | integrity sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg== 19 | 20 | webextension-polyfill@^0.10.0: 21 | version "0.10.0" 22 | resolved "https://registry.yarnpkg.com/webextension-polyfill/-/webextension-polyfill-0.10.0.tgz#ccb28101c910ba8cf955f7e6a263e662d744dbb8" 23 | integrity sha512-c5s35LgVa5tFaHhrZDnr3FpQpjj1BB+RXhLTYUxGqBVN460HkbM8TBtEqdXWbpTKfzwCcjAZVF7zXCYSKtcp9g== 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Comlink for Web Extensions [![Sponsors](https://img.shields.io/github/sponsors/samdenty?label=Sponsors)](https://github.com/sponsors/samdenty) 2 | 3 | [Sponsor this project](https://github.com/sponsors/samdenty) 4 | 5 | This module allows you to use [Comlink](https://github.com/GoogleChromeLabs/comlink) for `Background <-> Content/Popup` communication, in Chrome/Firefox/Safari extensions. 6 | 7 | ## Usage 8 | 9 | Background-script: 10 | 11 | ```ts 12 | import { createBackgroundEndpoint, isMessagePort } from "comlink-extension"; 13 | import * as Comlink from "comlink"; 14 | 15 | chrome.runtime.onConnect.addListener((port) => { 16 | if (isMessagePort(port)) return; 17 | 18 | Comlink.expose( 19 | { 20 | test() { 21 | console.log("called"); 22 | }, 23 | }, 24 | createBackgroundEndpoint(port) 25 | ); 26 | }); 27 | ``` 28 | 29 | Content / popup / devtool script: 30 | 31 | ```ts 32 | import { createEndpoint, forward } from "comlink-extension"; 33 | import * as Comlink from "comlink"; 34 | 35 | // Wrap a chrome.runtime.Port 36 | const obj = Comlink.wrap(createEndpoint(chrome.runtime.connect())); 37 | obj.test(); 38 | 39 | // Or, wrap an existing Message Channel: 40 | const { port1, port2 } = new MessageChannel(); 41 | forward(port1, chrome.runtime.connect()); 42 | 43 | const obj = Comlink.wrap(port2); 44 | obj.test(); 45 | ``` 46 | -------------------------------------------------------------------------------- /src/adapter.ts: -------------------------------------------------------------------------------- 1 | import browser, { Runtime } from "webextension-polyfill"; 2 | import * as Comlink from "comlink"; 3 | 4 | const SYMBOL = "__PORT__@"; 5 | 6 | export type PortResolver = (id: string) => ResolvablePort; 7 | export type PortDeserializer = (id: string) => MessagePort; 8 | 9 | export type ResolvablePort = Promise | Runtime.Port | string; 10 | 11 | function _resolvePort(id: string) { 12 | return id; 13 | } 14 | 15 | function _deserializePort(id: string) { 16 | const { port1, port2 } = new MessageChannel(); 17 | forward(port1, id, _resolvePort, _deserializePort); 18 | return port2; 19 | } 20 | 21 | export function createEndpoint( 22 | port: Runtime.Port, 23 | resolvePort: PortResolver = _resolvePort, 24 | deserializePort: PortDeserializer = _deserializePort 25 | ): Comlink.Endpoint { 26 | const listeners = new WeakMap(); 27 | 28 | function serialize(data: any): void { 29 | if (Array.isArray(data)) { 30 | data.forEach((value) => { 31 | serialize(value); 32 | }); 33 | } else if (data && typeof data === "object") { 34 | if (data instanceof MessagePort) { 35 | const id = SYMBOL + `${+new Date()}${Math.random()}`; 36 | (data as any)[SYMBOL] = "port"; 37 | (data as any).port = id; 38 | forward(data, resolvePort(id), resolvePort, deserializePort); 39 | } else if (data instanceof ArrayBuffer) { 40 | (data as any)[SYMBOL] = 41 | data instanceof Uint8Array 42 | ? "uint8" 43 | : data instanceof Uint16Array 44 | ? "uint16" 45 | : data instanceof Uint32Array 46 | ? "uint32" 47 | : "buffer"; 48 | 49 | (data as any).blob = URL.createObjectURL(new Blob([data])); 50 | } else { 51 | for (const key in data) { 52 | serialize(data[key]); 53 | } 54 | } 55 | } 56 | } 57 | 58 | async function deserialize(data: any, ports: any[]): Promise { 59 | if (Array.isArray(data)) { 60 | await Promise.all( 61 | data.map(async (value, i) => { 62 | data[i] = await deserialize(value, ports); 63 | }) 64 | ); 65 | } else if (data && typeof data === "object") { 66 | const type = data[SYMBOL]; 67 | 68 | if (type === "port") { 69 | const port = deserializePort(data.port); 70 | ports.push(port); 71 | return port; 72 | } else if (type) { 73 | const url = new URL(data.blob); 74 | if (url.protocol === "blob:") { 75 | const buffer = await (await fetch(url.href)).arrayBuffer(); 76 | switch (type) { 77 | case "uint16=": 78 | return new Uint16Array(buffer); 79 | case "uint8": 80 | return new Uint8Array(buffer); 81 | case "uint32": 82 | return new Uint32Array(buffer); 83 | case "buffer": 84 | return buffer; 85 | } 86 | } 87 | } 88 | 89 | await Promise.all( 90 | Object.keys(data).map(async (key) => { 91 | data[key] = await deserialize(data[key], ports); 92 | }) 93 | ); 94 | } 95 | 96 | return data; 97 | } 98 | 99 | return { 100 | postMessage: (message) => { 101 | serialize(message); 102 | port.postMessage(message); 103 | }, 104 | addEventListener: (_, handler) => { 105 | const listener = async (data: any) => { 106 | const ports: MessagePort[] = []; 107 | const event = new MessageEvent("message", { 108 | data: await deserialize(data, ports), 109 | ports, 110 | }); 111 | 112 | if ("handleEvent" in handler) { 113 | handler.handleEvent(event); 114 | } else { 115 | handler(event); 116 | } 117 | }; 118 | port.onMessage.addListener(listener); 119 | listeners.set(handler, listener); 120 | }, 121 | removeEventListener: (_, handler) => { 122 | const listener = listeners.get(handler); 123 | if (!listener) { 124 | return; 125 | } 126 | port.onMessage.removeListener(listener); 127 | listeners.delete(handler); 128 | }, 129 | }; 130 | } 131 | 132 | export async function forward( 133 | messagePort: MessagePort, 134 | extensionPort: ResolvablePort, 135 | resolvePort: PortResolver = _resolvePort, 136 | deserializePort: PortDeserializer = _deserializePort 137 | ) { 138 | if (typeof extensionPort === "string") { 139 | extensionPort = browser.runtime.connect(undefined, { name: extensionPort }); 140 | } 141 | 142 | const port = Promise.resolve(extensionPort).then((port) => 143 | createEndpoint(port, resolvePort, deserializePort) 144 | ); 145 | 146 | messagePort.onmessage = async ({ data, ports }) => { 147 | (await port).postMessage(data, ports as any); 148 | }; 149 | 150 | (await port).addEventListener("message", ({ data, ports }: any) => { 151 | messagePort.postMessage(data, ports as any); 152 | }); 153 | } 154 | 155 | export function isMessagePort(port: { name: string }) { 156 | return port.name.startsWith(SYMBOL); 157 | } 158 | --------------------------------------------------------------------------------