├── .npmignore ├── src ├── index.ts ├── Bridge.ts ├── Stream.ts └── internal.ts ├── tsconfig.json ├── LICENSE.txt ├── .gitignore ├── package.json ├── .eslintrc.json └── README.md /.npmignore: -------------------------------------------------------------------------------- 1 | tsconfig.json 2 | tslint.json 3 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import * as Bridge from './Bridge'; 2 | 3 | export { Stream } from './Stream'; 4 | export * from './Bridge'; 5 | export default Bridge; 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es2018", 5 | "outDir": "./dist", 6 | "jsx": "react", 7 | "sourceMap": true, 8 | "declaration": true, 9 | "esModuleInterop": true, 10 | "declarationDir": "./dist" 11 | }, 12 | "exclude": [ 13 | "node_modules", 14 | "dist" 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Neek Sandhu 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # Typescript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # lock files 61 | package-lock.json 62 | 63 | # transpiled output 64 | dist 65 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "crx-bridge", 3 | "version": "3.0.1", 4 | "description": "Messaging in Chrome extensions made easy. Out of the box.", 5 | "main": "dist/index.js", 6 | "typings": "dist/index.d.ts", 7 | "scripts": { 8 | "build": "tsc", 9 | "watch": "tsc -w" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/NeekSandhu/crx-bridge.git" 14 | }, 15 | "keywords": [ 16 | "chrome", 17 | "extension", 18 | "messaging", 19 | "communication", 20 | "protocol", 21 | "content", 22 | "background", 23 | "devtools", 24 | "script", 25 | "crx", 26 | "bridge" 27 | ], 28 | "author": "Neek Sandhu ", 29 | "license": "MIT", 30 | "bugs": { 31 | "url": "https://github.com/NeekSandhu/crx-bridge/issues" 32 | }, 33 | "homepage": "https://github.com/NeekSandhu/crx-bridge#readme", 34 | "devDependencies": { 35 | "@types/node": "^8.0.46", 36 | "@types/serialize-error": "^4.0.1", 37 | "@typescript-eslint/eslint-plugin": "^4.4.1", 38 | "@typescript-eslint/parser": "^4.4.1", 39 | "eslint": "^7.11.0", 40 | "eslint-import-resolver-typescript": "^2.3.0", 41 | "eslint-plugin-import": "^2.22.1", 42 | "type-fest": "^0.18.0", 43 | "typescript": "^4.0.3" 44 | }, 45 | "dependencies": { 46 | "nanoevents": "^6.0.0", 47 | "oneline": "^1.0.3", 48 | "serialize-error": "^2.1.0", 49 | "tiny-uid": "^1.1.1", 50 | "webextension-polyfill-ts": "^0.20.0" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "extends": [ 4 | "plugin:@typescript-eslint/recommended", 5 | "plugin:import/errors", 6 | "plugin:import/warnings", 7 | "plugin:import/typescript" 8 | ], 9 | "plugins": [ 10 | "@typescript-eslint", 11 | "import" 12 | ], 13 | "rules": { 14 | "semi": "error", 15 | "quotes": ["error", "single", { "avoidEscape": true }], 16 | "comma-dangle": ["error", { 17 | "imports": "never", 18 | "exports": "never", 19 | "arrays": "only-multiline", 20 | "objects": "only-multiline", 21 | "functions": "never" 22 | }], 23 | "import/order": ["error", { 24 | "groups": [["builtin", "external"], ["internal", "parent", "sibling", "index"]] 25 | }], 26 | "import/newline-after-import": "error", 27 | "import/named": "off", 28 | "import/default": "off", 29 | "no-shadow": "error", 30 | "no-multiple-empty-lines": 2, 31 | "no-multi-spaces": "error", 32 | "no-trailing-spaces": "error", 33 | "no-extra-semi": "error", 34 | "no-extend-native": "error", 35 | "no-unneeded-ternary": "error", 36 | "arrow-spacing": "error", 37 | "prefer-object-spread": "error", 38 | "prefer-const": "error", 39 | "prefer-destructuring": ["error", { "object": true, "array": false }], 40 | "prefer-spread": "error", 41 | "prefer-rest-params": "error", 42 | "indent": "off", 43 | "@typescript-eslint/indent": ["error", "tab"] 44 | }, 45 | "settings": { 46 | "import/resolver": { 47 | "typescript": {} 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Bridge.ts: -------------------------------------------------------------------------------- 1 | import { createNanoEvents } from 'nanoevents'; 2 | import uuid from 'tiny-uid'; 3 | 4 | import { StreamInfo, Stream } from './Stream'; 5 | import { 6 | IBridgeMessage, 7 | OnMessageCallback, 8 | sendMessage, 9 | onMessage, 10 | allowWindowMessaging, 11 | setNamespace, 12 | Endpoint, 13 | parseEndpoint, 14 | isInternalEnpoint 15 | } from './internal'; 16 | 17 | 18 | const openStreams = new Map(); 19 | const onOpenStreamCallbacks = new Map void>(); 20 | const streamyEmitter = createNanoEvents(); 21 | 22 | onMessage<{ channel: string; streamId: string }>('__crx_bridge_stream_open__', (message) => { 23 | return new Promise((resolve) => { 24 | const { sender, data } = message; 25 | const { channel } = data; 26 | let watching = false; 27 | let off = () => void 0; 28 | 29 | const readyup = () => { 30 | const callback = onOpenStreamCallbacks.get(channel); 31 | 32 | if (typeof callback === 'function') { 33 | callback(new Stream({ ...data, endpoint: sender })); 34 | if (watching) { 35 | off(); 36 | } 37 | resolve(true); 38 | } else if (!watching) { 39 | watching = true; 40 | off = streamyEmitter.on('did-change-stream-callbacks', readyup); 41 | } 42 | }; 43 | 44 | readyup(); 45 | }); 46 | }); 47 | 48 | async function openStream(channel: string, destination: string | Endpoint): Promise { 49 | if (openStreams.has(channel)) { 50 | throw new Error('crx-bridge: A Stream is already open at this channel'); 51 | } 52 | 53 | const endpoint = typeof destination === 'string' ? parseEndpoint(destination) : destination; 54 | 55 | const streamInfo: StreamInfo = { streamId: uuid(), channel, endpoint }; 56 | const stream = new Stream(streamInfo); 57 | stream.onClose(() => openStreams.delete(channel)); 58 | await sendMessage('__crx_bridge_stream_open__', streamInfo, endpoint); 59 | openStreams.set(channel, stream); 60 | return stream; 61 | } 62 | 63 | function onOpenStreamChannel(channel: string, callback: (stream: Stream) => void): void { 64 | if (onOpenStreamCallbacks.has(channel)) { 65 | throw new Error('crx-bridge: This channel has already been claimed. Stream allows only one-on-one communication'); 66 | } 67 | 68 | onOpenStreamCallbacks.set(channel, callback); 69 | streamyEmitter.emit('did-change-stream-callbacks'); 70 | } 71 | 72 | export { 73 | IBridgeMessage, 74 | OnMessageCallback, 75 | isInternalEnpoint, 76 | sendMessage, 77 | onMessage, 78 | allowWindowMessaging, 79 | setNamespace, 80 | openStream, 81 | onOpenStreamChannel 82 | }; 83 | -------------------------------------------------------------------------------- /src/Stream.ts: -------------------------------------------------------------------------------- 1 | import { createNanoEvents, Emitter } from 'nanoevents'; 2 | import { JsonValue } from 'type-fest'; 3 | 4 | import { Endpoint, onMessage, sendMessage } from './internal'; 5 | 6 | 7 | export type StreamInfo = { 8 | streamId: string; 9 | channel: string; 10 | endpoint: Endpoint; 11 | } 12 | 13 | export type HybridUnsubscriber = { 14 | (): void; 15 | dispose: () => void; 16 | close: () => void; 17 | } 18 | 19 | /** 20 | * Built on top of Bridge. Nothing much special except that Stream allows 21 | * you to create a namespaced scope under a channel name of your choice 22 | * and allows continuous e2e communication, with less possibility of 23 | * conflicting messageId's, since streams are strictly scoped. 24 | */ 25 | class Stream { 26 | private static initDone = false 27 | private static openStreams: Map = new Map(); 28 | 29 | private internalInfo: StreamInfo; 30 | private emitter: Emitter; 31 | private isClosed: boolean; 32 | constructor(t: StreamInfo) { 33 | this.internalInfo = t; 34 | this.emitter = createNanoEvents(); 35 | this.isClosed = false; 36 | 37 | if (!Stream.initDone) { 38 | onMessage<{ streamId: string; action: 'transfer' | 'close'; streamTransfer: JsonValue }>('__crx_bridge_stream_transfer__', (msg) => { 39 | const { streamId, streamTransfer, action } = msg.data; 40 | const stream = Stream.openStreams.get(streamId); 41 | if (stream && !stream.isClosed) { 42 | if (action === 'transfer') { 43 | stream.emitter.emit('message', streamTransfer); 44 | } 45 | 46 | if (action === 'close') { 47 | Stream.openStreams.delete(streamId); 48 | stream.handleStreamClose(); 49 | } 50 | } 51 | }); 52 | Stream.initDone = true; 53 | } 54 | 55 | Stream.openStreams.set(t.streamId, this); 56 | } 57 | 58 | /** 59 | * Returns stream info 60 | */ 61 | public get info(): StreamInfo { 62 | return this.internalInfo; 63 | } 64 | 65 | /** 66 | * Sends a message to other endpoint. 67 | * Will trigger onMessage on the other side. 68 | * 69 | * Warning: Before sending sensitive data, verify the endpoint using `stream.info.endpoint.isInternal()` 70 | * The other side could be malicious webpage speaking same language as crx-bridge 71 | * @param msg 72 | */ 73 | public send(msg?: JsonValue): void { 74 | if (this.isClosed) { 75 | throw new Error('Attempting to send a message over closed stream. Use stream.onClose() to keep an eye on stream status'); 76 | } 77 | 78 | sendMessage('__crx_bridge_stream_transfer__', { 79 | streamId: this.internalInfo.streamId, 80 | streamTransfer: msg, 81 | action: 'transfer', 82 | }, this.internalInfo.endpoint); 83 | } 84 | 85 | /** 86 | * Closes the stream. 87 | * Will trigger stream.onClose() on both endpoints. 88 | * If needed again, spawn a new Stream, as this instance cannot be re-opened 89 | * @param msg 90 | */ 91 | public close(msg?: JsonValue): void { 92 | if (msg) { 93 | this.send(msg); 94 | } 95 | this.handleStreamClose(); 96 | 97 | sendMessage('__crx_bridge_stream_transfer__', { 98 | streamId: this.internalInfo.streamId, 99 | streamTransfer: null, 100 | action: 'close', 101 | }, this.internalInfo.endpoint); 102 | } 103 | 104 | /** 105 | * Registers a callback to fire whenever other endpoint sends a message 106 | * @param callback 107 | */ 108 | public onMessage(callback: (msg?: T) => void): HybridUnsubscriber { 109 | return this.getDisposable('message', callback); 110 | } 111 | 112 | /** 113 | * Registers a callback to fire whenever stream.close() is called on either endpoint 114 | * @param callback 115 | */ 116 | public onClose(callback: (msg?: T) => void): HybridUnsubscriber { 117 | return this.getDisposable('closed', callback); 118 | } 119 | 120 | private handleStreamClose = () => { 121 | if (!this.isClosed) { 122 | this.isClosed = true; 123 | this.emitter.emit('closed', true); 124 | this.emitter.events = {}; 125 | } 126 | } 127 | 128 | private getDisposable(event: string, callback: () => void): HybridUnsubscriber { 129 | const unsub = this.emitter.on(event, callback); 130 | 131 | return Object.assign(unsub, { 132 | dispose: unsub, 133 | close: unsub, 134 | }); 135 | } 136 | } 137 | 138 | export { Stream }; 139 | -------------------------------------------------------------------------------- /src/internal.ts: -------------------------------------------------------------------------------- 1 | import { JsonValue } from 'type-fest'; 2 | import { browser, Runtime } from 'webextension-polyfill-ts'; 3 | import * as serializeError from 'serialize-error'; 4 | import oneline from 'oneline'; 5 | import uuid from 'tiny-uid'; 6 | 7 | 8 | // eslint-disable-next-line no-shadow 9 | enum RuntimeContext { 10 | Devtools = 'devtools', 11 | Background = 'background', 12 | ContentScript = 'content-script', 13 | Window = 'window' 14 | } 15 | 16 | export type Endpoint = { 17 | context: RuntimeContext; 18 | tabId: number; 19 | } 20 | 21 | export interface IBridgeMessage { 22 | sender: Endpoint; 23 | id: string; 24 | data: T; 25 | timestamp: number; 26 | } 27 | 28 | export type OnMessageCallback = (message: IBridgeMessage) => void | JsonValue | Promise; 29 | 30 | interface IInternalMessage { 31 | origin: Endpoint; 32 | destination: Endpoint; 33 | transactionId: string; 34 | hops: string[]; 35 | messageID: string; 36 | messageType: 'message' | 'reply'; 37 | err?: JsonValue; 38 | data?: JsonValue | void; 39 | timestamp: number; 40 | } 41 | 42 | interface IQueuedMessage { 43 | resolvedDestination: string; 44 | message: IInternalMessage; 45 | } 46 | 47 | const ENDPOINT_RE = /^((?:background$)|devtools|content-script|window)(?:@(\d+))?$/; 48 | 49 | export const parseEndpoint = (endpoint: string): Endpoint => { 50 | const [, context, tabId] = endpoint.match(ENDPOINT_RE); 51 | 52 | return { 53 | context: context as RuntimeContext, 54 | tabId: +tabId 55 | }; 56 | }; 57 | 58 | export const isInternalEnpoint = ({ context: ctx }: Endpoint): boolean => 59 | [RuntimeContext.ContentScript, RuntimeContext.Background, RuntimeContext.Devtools].some(internalCtx => internalCtx === ctx); 60 | 61 | // Return true if the `browser` object has a specific namespace 62 | const hasAPI = (nsps: string): boolean => browser[nsps]; 63 | 64 | const context: RuntimeContext = 65 | hasAPI('devtools') ? RuntimeContext.Devtools 66 | : hasAPI('tabs') ? RuntimeContext.Background 67 | : hasAPI('extension') ? RuntimeContext.ContentScript 68 | : (typeof document !== 'undefined') ? RuntimeContext.Window : null; 69 | 70 | const runtimeId: string = uuid(); 71 | const openTransactions = new Map) => void; reject: (e: JsonValue) => void }>(); 72 | const onMessageListeners = new Map>(); 73 | const messageQueue = new Set(); 74 | const portMap = new Map(); 75 | let port: Runtime.Port = null; 76 | 77 | // these facilitate communication with window contexts ("injected scripts") 78 | let namespace: string; 79 | let isWindowMessagingAllowed: boolean; 80 | 81 | initIntercoms(); 82 | 83 | /** 84 | * Sends a message to some other endpoint, to which only one listener can send response. 85 | * Returns Promise. Use `then` or `await` to wait for the response. 86 | * If destination is `window` message will routed using window.postMessage. 87 | * Which requires a shared namespace to be set between `content-script` and `window` 88 | * that way they can recognize each other when global window.postMessage happens and there are other 89 | * extensions using crx-bridge as well 90 | * @param messageID 91 | * @param data 92 | * @param destination 93 | */ 94 | export async function sendMessage(messageID: string, data: JsonValue, destination: string | Endpoint): Promise { 95 | const endpoint = typeof destination === 'string' ? parseEndpoint(destination) : destination; 96 | const errFn = 'Bridge#sendMessage ->'; 97 | 98 | if (!endpoint.context) { 99 | throw new TypeError(`${errFn} Destination must be any one of known destinations`); 100 | } 101 | 102 | if (context === RuntimeContext.Background) { 103 | const { context: dest, tabId: destTabId } = endpoint; 104 | if (dest !== 'background' && !destTabId) { 105 | throw new TypeError(`${errFn} When sending messages from background page, use @tabId syntax to target specific tab`); 106 | } 107 | } 108 | 109 | return new Promise((resolve, reject) => { 110 | const payload: IInternalMessage = { 111 | messageID, 112 | data, 113 | destination: endpoint, 114 | messageType: 'message', 115 | transactionId: uuid(), 116 | origin: { context, tabId: null }, 117 | hops: [], 118 | timestamp: Date.now(), 119 | }; 120 | 121 | openTransactions.set(payload.transactionId, { resolve, reject }); 122 | routeMessage(payload); 123 | }); 124 | } 125 | 126 | export function onMessage(messageID: string, callback: OnMessageCallback): void { 127 | onMessageListeners.set(messageID, callback); 128 | } 129 | 130 | export function setNamespace(nsps: string): void { 131 | namespace = nsps; 132 | } 133 | 134 | export function allowWindowMessaging(nsps: string): void { 135 | isWindowMessagingAllowed = true; 136 | namespace = nsps; 137 | } 138 | 139 | 140 | function initIntercoms() { 141 | if (context === null) { 142 | throw new Error('Unable to detect runtime context i.e crx-bridge can\'t figure out what to do'); 143 | } 144 | 145 | if (context === RuntimeContext.Window || context === RuntimeContext.ContentScript) { 146 | window.addEventListener('message', handleWindowOnMessage); 147 | } 148 | 149 | if (context === RuntimeContext.ContentScript && top === window) { 150 | port = browser.runtime.connect(); 151 | port.onMessage.addListener((message: IInternalMessage) => { 152 | routeMessage(message); 153 | }); 154 | } 155 | 156 | if (context === RuntimeContext.Devtools) { 157 | const { tabId } = browser.devtools.inspectedWindow; 158 | const name = `devtools@${tabId}`; 159 | 160 | port = browser.runtime.connect(void 0, { name }); 161 | 162 | port.onMessage.addListener((message: IInternalMessage) => { 163 | routeMessage(message); 164 | }); 165 | 166 | port.onDisconnect.addListener(() => { 167 | port = null; 168 | }); 169 | } 170 | 171 | if (context === RuntimeContext.Background) { 172 | browser.runtime.onConnect.addListener(incomingPort => { 173 | // when coming from devtools, it's should pre-fabricated with inspected tab as linked tab id 174 | const portId = incomingPort.name || `content-script@${incomingPort.sender.tab.id}`; 175 | // literal tab id in case of content script, however tab id of inspected page in case of devtools context 176 | const { tabId: linkedTabId } = parseEndpoint(portId); 177 | 178 | // in-case the port handshake is from something else 179 | if (!linkedTabId) { 180 | return; 181 | } 182 | 183 | portMap.set(portId, incomingPort); 184 | 185 | messageQueue.forEach((queuedMsg) => { 186 | if (queuedMsg.resolvedDestination === portId) { 187 | incomingPort.postMessage(queuedMsg.message); 188 | messageQueue.delete(queuedMsg); 189 | } 190 | }); 191 | 192 | incomingPort.onDisconnect.addListener(() => { 193 | portMap.delete(portId); 194 | }); 195 | 196 | incomingPort.onMessage.addListener((message: IInternalMessage) => { 197 | if (message?.origin?.context) { 198 | // origin tab ID is resolved from the port identifier (also prevent "MITM attacks" of extensions) 199 | message.origin.tabId = linkedTabId; 200 | 201 | routeMessage(message); 202 | } 203 | }); 204 | }); 205 | } 206 | } 207 | 208 | function routeMessage(message: IInternalMessage): void | Promise { 209 | const { origin, destination } = message; 210 | 211 | if (message.hops.indexOf(runtimeId) > -1) { 212 | return; 213 | } 214 | 215 | message.hops.push(runtimeId); 216 | 217 | if (context === RuntimeContext.ContentScript 218 | && [destination, origin].some(endpoint => endpoint?.context === RuntimeContext.Window) 219 | && !isWindowMessagingAllowed) { 220 | return; 221 | } 222 | 223 | // if previous hop removed the destination before forwarding the message, then this itself is the recipient 224 | if (!destination) { 225 | return handleInboundMessage(message); 226 | } 227 | 228 | if (destination.context) { 229 | if (context === RuntimeContext.Window) { 230 | return routeMessageThroughWindow(window, message); 231 | } 232 | 233 | else if (context === RuntimeContext.ContentScript && destination.context === RuntimeContext.Window) { 234 | message.destination = null; 235 | return routeMessageThroughWindow(window, message); 236 | } 237 | 238 | else if (context === RuntimeContext.Devtools || context === RuntimeContext.ContentScript) { 239 | if (destination.context === RuntimeContext.Background) { 240 | message.destination = null; 241 | } 242 | // Just hand it over to background page 243 | return port.postMessage(message); 244 | } 245 | 246 | else if (context === RuntimeContext.Background) { 247 | const { context: destName, tabId: destTabId } = destination; 248 | const { tabId: srcTabId } = origin; 249 | 250 | // remove the destination in case the message isn't going to `window`; it'll be forwarded to either `content-script` or `devtools`... 251 | if (destName !== RuntimeContext.Window) { 252 | message.destination = null; 253 | } else { 254 | // ...however if it is directed towards window, then patch the destination before handing the message off to the `content-script` in 255 | // the same tab. since `content-script` itself doesn't know it's own tab id, a destination like `window@144` is meaningless to the 256 | // `content-script` and it'll crap out as it would think it belongs to some other window and will pass it back to background page 257 | message.destination.tabId = null; 258 | } 259 | 260 | // As far as background page is concerned, it just needs to know the which `content-script` or `devtools` should it forward to 261 | const resolvedDestination = `${(destName === 'window' ? 'content-script' : destName)}@${(destTabId || srcTabId)}`; 262 | const destPort = portMap.get(resolvedDestination); 263 | 264 | if (destPort) { 265 | destPort.postMessage(message); 266 | } else { 267 | messageQueue.add({ resolvedDestination, message }); 268 | } 269 | } 270 | } 271 | } 272 | 273 | async function handleInboundMessage(message: IInternalMessage) { 274 | const { transactionId, messageID, messageType } = message; 275 | 276 | const handleReply = () => { 277 | const transactionP = openTransactions.get(transactionId); 278 | if (transactionP) { 279 | const { err, data } = message; 280 | if (err) { 281 | const dehydratedErr = err as Record; 282 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 283 | const errCtr = self[dehydratedErr.name] as any; 284 | const hydratedErr = new (typeof errCtr === 'function' ? errCtr : Error)(dehydratedErr.message); 285 | for (const prop in dehydratedErr) { 286 | hydratedErr[prop] = dehydratedErr[prop]; 287 | } 288 | transactionP.reject(hydratedErr); 289 | } else { 290 | transactionP.resolve(data); 291 | } 292 | openTransactions.delete(transactionId); 293 | } 294 | }; 295 | 296 | const handleNewMessage = async () => { 297 | let reply: JsonValue | void; 298 | let err: Error; 299 | let noHandlerFoundError = false; 300 | 301 | try { 302 | const cb = onMessageListeners.get(messageID); 303 | if (typeof cb === 'function') { 304 | reply = await cb({ 305 | sender: message.origin, 306 | id: messageID, 307 | data: message.data, 308 | timestamp: message.timestamp, 309 | } as IBridgeMessage); 310 | } else { 311 | noHandlerFoundError = true; 312 | throw new Error(`[crx-bridge] No handler registered in '${context}' to accept messages with id '${messageID}'`); 313 | } 314 | } catch (error) { 315 | err = error; 316 | } finally { 317 | if (err) { message.err = serializeError(err); } 318 | 319 | routeMessage({ 320 | ...message, 321 | messageType: 'reply', 322 | data: reply, 323 | origin: { context, tabId: null }, 324 | destination: message.origin, 325 | hops: [], 326 | }); 327 | 328 | if (err && !noHandlerFoundError) { 329 | throw reply; 330 | } 331 | } 332 | }; 333 | 334 | switch (messageType) { 335 | case 'reply': return handleReply(); 336 | case 'message': return handleNewMessage(); 337 | } 338 | } 339 | 340 | async function handleWindowOnMessage({ data, ports }: MessageEvent) { 341 | if (context === RuntimeContext.ContentScript && !isWindowMessagingAllowed) { 342 | return; 343 | } 344 | 345 | if (data.cmd === '__crx_bridge_verify_listening' && data.scope === namespace && data.context !== context) { 346 | const msgPort: MessagePort = ports[0]; 347 | msgPort.postMessage(true); 348 | return; 349 | } else if (data.cmd === '__crx_bridge_route_message' && data.scope === namespace && data.context !== context) { 350 | // a message event insdide `content-script` means a script inside `window` dispactched it 351 | // so we're making sure that the origin is not tampered (i.e script is not masquerading it's true identity) 352 | if (context === RuntimeContext.ContentScript) { 353 | data.payload.origin = 'window'; 354 | } 355 | 356 | routeMessage(data.payload); 357 | } 358 | } 359 | 360 | function routeMessageThroughWindow(win: Window, msg: IInternalMessage) { 361 | ensureNamespaceSet(); 362 | 363 | const channel = new MessageChannel(); 364 | const retry = setTimeout(() => { 365 | channel.port1.onmessage = null; 366 | routeMessageThroughWindow(win, msg); 367 | }, 300); 368 | channel.port1.onmessage = () => { 369 | clearTimeout(retry); 370 | win.postMessage({ 371 | cmd: '__crx_bridge_route_message', 372 | scope: namespace, 373 | context: context, 374 | payload: msg, 375 | }, '*'); 376 | }; 377 | win.postMessage({ 378 | cmd: '__crx_bridge_verify_listening', 379 | scope: namespace, 380 | context: context 381 | }, '*', [channel.port2]); 382 | } 383 | 384 | function ensureNamespaceSet() { 385 | if (typeof namespace !== 'string' || namespace.length === 0) { 386 | throw new Error(oneline` 387 | crx-bridge uses window.postMessage to talk with other "window"(s), for message routing and stuff, 388 | which is global/conflicting operation in case there are other scripts using crx-bridge. 389 | Call Bridge#setNamespace(nsps) to isolate your app. Example: Bridge.setNamespace('com.facebook.react-devtools'). 390 | Make sure to use same namespace across all your scripts whereever window.postMessage is likely to be used 391 | `); 392 | } 393 | } 394 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ⚠ Deprecation notice ⚠ 2 | **This package is no longer in service. Use [`webext-bridge`](https://www.npmjs.com/package/webext-bridge) instead.** 3 | 4 | # crx-bridge 5 | 6 | Messaging in Chrome extensions made super easy. Out of the box. 7 | 8 | ## How much easy exactly? 9 | 10 | This much 11 | 12 | 13 | 14 | ```javascript 15 | // Inside devtools script 16 | 17 | import { sendMessage } from 'crx-bridge'; 18 | 19 | // ... 20 | 21 | button.addEventListener('click', () => { 22 | const res = await sendMessage('get-selection', { ignoreCasing: true }, 'content-script'); 23 | console.log(res); // > "The brown fox is alive and well" 24 | }) 25 | ``` 26 | 27 | ```javascript 28 | // Inside content script 29 | 30 | import { sendMessage, onMessage } from 'crx-bridge'; 31 | 32 | onMessage('get-selection', async (message) => { 33 | const { sender, data: { ignoreCasing } } = message; 34 | 35 | console.log(sender.context, sender.tabId); // > content-script 156 36 | 37 | const { selection } = await sendMessage('get-preferences', { sync: false }, 'background'); 38 | return calculateSelection(data.ignoreCasing, selection); 39 | }); 40 | ``` 41 | 42 | ```javascript 43 | // Inside background script 44 | 45 | import { onMessage } from 'crx-bridge'; 46 | 47 | onMessage('get-preferences', ({ data }) => { 48 | const { sync } = data; 49 | 50 | return loadUserPreferences(sync); 51 | }); 52 | ``` 53 | 54 | > Examples above require transpilation and/or bundling using `webpack`/`babel`/`rollup` 55 | 56 | `crx-bridge` handles everything for you as efficiently as possible. No more `chrome.runtime.sendMessage` or `chrome.runtime.onConnect` or `chrome.runtime.connect` .... 57 | 58 | # Contents 59 | 60 | - [Setup](#setup) 61 | - [API](#api) 62 | - [Behaviour](#behaviour) 63 | - [Security Note](#security) 64 | - [Troubleshooting](#troubleshooting) 65 | 66 | 67 | 68 | # Setup 69 | 70 | ### Install 71 | 72 | ```bash 73 | $ npm i crx-bridge 74 | ``` 75 | 76 | ### Light it up 77 | 78 | Just `import Bridge from 'crx-bridge'` wherever you need it and use as shown in [example above](#example) 79 | 80 | > Even if your extension doesn't need a background page or wont be sending/receiving messages in background script. 81 | >
`crx-bridge` uses background/event context as staging area for messages, therefore it **must** loaded in background/event page for it to work. 82 | >
(Attempting to send message from any context will fail silently if `crx-bridge` isn't available in background page). 83 | >
See [troubleshooting section](#troubleshooting) for more. 84 | 85 | 86 | 87 | # API 88 | 89 | ## `Bridge.sendMessage(messageId: string, data: any, destination: string)` 90 | 91 | Sends a message to some other part of your extension, out of the box. 92 | 93 | Notes: 94 | 95 | - If there is no listener on the other side an error will be thrown where `sendMessage` was called. 96 | 97 | - Listener on the other may want to reply. Get the reply by `await`ing the returned `Promise` 98 | 99 | - An error thrown in listener callback (in the destination context) will behave as usual, that is, bubble up, but the same error will also be thrown where `sendMessage` was called 100 | 101 | #### `messageId` 102 | 103 | > Required | `string` 104 | 105 | Any `string` that both sides of your extension agree on. Could be `get-flag-count` or `getFlagCount`, as long as it's same on receiver's `onMessage` listener. 106 | 107 | #### `data` 108 | 109 | > Required | `any` 110 | 111 | Any serializable value you want to pass to other side, latter can access this value by refering to `data` property of first argument to `onMessage` callback function. 112 | 113 | #### `destination` 114 | 115 | > Required | `string` 116 | 117 | The actual identifier of other endpoint. 118 | Example: `devtools` or `content-script` or `background` or `content-script@133` or `devtools@453` 119 | 120 | `content-script`, `window` and `devtools` destinations can be suffixed with `@` to target specific tab. Example: `devtools@351`, points to devtools panel inspecting tab with id 351. 121 | 122 | Read `Behaviour` section to see how destinations (or endpoints) are treated. 123 | 124 | > Note: For security reasons, if you want to receive or send messages to or from `window` context, one of your extension's content script must call `Bridge.allowWindowMessaging()` to unlock message routing. Also call `Bridge.setNamespace()` in those `window` contexts. Use same namespace string in those two calls, so `crx-bridge` knows which message belongs to which extension (in case multiple extensions are using `crx-bride` in one page) 125 | 126 | --- 127 | 128 | ## `Bridge.onMessage(messageId: string, callback: fn)` 129 | 130 | Register one and only one listener, per messageId per context. That will be called upon `Bridge.sendMessage` from other side. 131 | 132 | Optionally, send a response to sender by returning any value or if async a `Promise`. 133 | 134 | #### `messageId` 135 | 136 | > Required | `string` 137 | 138 | Any `string` that both sides of your extension agree on. Could be `get-flag-count` or `getFlagCount`, as long as it's same in sender's `sendMessage` call. 139 | 140 | #### `callback` 141 | 142 | > Required | `fn` 143 | 144 | A callback function `Bridge` should call when a message is received with same `messageId`. The callback function will be called with one argument, a `BridgeMessage` which has `sender`, `data` and `timestamp` as its properties. 145 | 146 | Optionally, this callback can return a value or a `Promise`, resolved value will sent as reply to sender. 147 | 148 | Read [security note](#security) before using this. 149 | 150 | --- 151 | 152 | ## `Bridge.allowWindowMessaging(namespace: string)` 153 | 154 | > Caution: Dangerous action 155 | 156 | Applicable to content scripts (noop if called from anywhere else) 157 | 158 | Unlocks the transmission of messages to and from `window` (top frame of loaded page) contexts in the tab where it is called. 159 | `crx-bridge` by default won't transmit any payload to or from `window` contexts for security reasons. 160 | This method can be called from a content script (in top frame of tab), which opens a gateway for messages. 161 | 162 | Once again, `window` = the top frame of any tab. That means **allowing window messaging without checking origin first** will let JavaScript loaded at `https://evil.com` talk with your extension and possibly give indirect access to things you won't want to, like `history` API. You're expected to ensure the 163 | safety and privacy of your extension's users. 164 | 165 | #### `namespace` 166 | 167 | > Required | `string` 168 | 169 | Can be a domain name reversed like `com.github.facebook.react_devtools` or any `uuid`. Call `Bridge.setNamespace` in `window` context with same value, so that `crx-bridge` knows which payload belongs to which extension (in case there are other extensions using `crx-bridge` in a tab). Make sure namespace string is unique enough to ensure no collisions happen. 170 | 171 | --- 172 | 173 | ## `Bridge.setNamespace(namespace: string)` 174 | 175 | Applicable to scripts in top frame of loaded remote page 176 | 177 | Sets the namespace `Bridge` should use when relaying messages to and from `window` context. In a sense, it connects the callee context to the extension which called `Bridge.allowWindowMessaging()` in it's content script with same namespace. 178 | 179 | #### `namespace` 180 | 181 | > Required | `string` 182 | 183 | Can be a domain name reversed like `com.github.facebook.react_devtools` or any `uuid`. Call `Bridge.setNamespace` in `window` context with same value, so that `crx-bridge` knows which payload belongs to which extension (in case there are other extensions using `crx-bridge` in a tab). Make sure namespace string is unique enough to ensure no collisions happen. 184 | 185 | ## Extras 186 | 187 | The following API is built on top of `Bridge.sendMessage` and `Bridge.onMessage`, basically, it's just a wrapper, the routing and security rules still apply the same way. 188 | 189 | ### `Bridge.openStream(channel: string, destination: string)` 190 | 191 | Opens a `Stream` between caller and destination. 192 | 193 | Returns a `Promise` which resolves with `Stream` when the destination is ready (loaded and `Bridge.onOpenStreamChannel` callback registered). 194 | Example below illustrates a use case for `Stream` 195 | 196 | #### `channel` 197 | 198 | > Required | `string` 199 | 200 | `Stream`(s) are strictly scoped `Bridge.sendMessage`(s). Scopes could be different features of your extension that need to talk to the other side, and those scopes are named using a channel id. 201 | 202 | #### `destination` 203 | 204 | > Required | `string` 205 | 206 | Same as `destination` in `Bridge.sendMessage(msgId, data, destination)` 207 | 208 | --- 209 | 210 | ### `Bridge.onOpenStreamChannel(channel: string, callback: fn)` 211 | 212 | Registers a listener for when a `Stream` opens. 213 | Only one listener per channel per context 214 | 215 | #### `channel` 216 | 217 | > Required | `string` 218 | 219 | `Stream`(s) are strictly scoped `Bridge.sendMessage`(s). Scopes could be different features of your extension that need to talk to the other side, and those scopes are named using a channel id. 220 | 221 | #### `callback` 222 | 223 | > Required | `fn` 224 | 225 | Callback that should be called whenever `Stream` is opened from the other side. Callback will be called with one argument, the `Stream` object, documented below. 226 | 227 | `Stream`(s) can be opened by a malicious webpage(s) if your extension's content script in that tab has called `Bridge.allowWindowMessaging`, if working with sensitive information use `isInternalEndpoint(stream.info.endpoint)` to check, if `false` call `stream.close()` immediately. 228 | 229 | ### Stream Example 230 | 231 | ```javascript 232 | // background.js 233 | 234 | // To-Do 235 | ``` 236 | 237 | 238 | 239 | # Behaviour 240 | 241 | > Following rules apply to `destination` being specified in `Bridge.sendMessage(msgId, data, destination)` and `Bridge.openStream(channelId, initialData, destination)` 242 | 243 | - Specifying `devtools` as destination from `content-script` will auto-route payload to inspecting `devtools` page if open and listening. 244 | 245 | - Specifying `content-script` as destination from `devtools` will auto-route the message to inspected window's top `content-script` page if listening. If page is loading, message will be queued up and deliverd when page is ready and listening. 246 | 247 | - If `window` context (which could be a script injected by content script) are source or destination of any payload, transmission must be first unlocked by calling `Bridge.allowWindowMessaging()` inside that page's top content script, since `Bridge` will first deliver the payload to `content-script` using rules above, and latter will take over and forward accordingly. `content-script` <-> `window` messaging happens using `window.postMessage` API. Therefore to avoid conflicts, `Bridge` requires you to call `Bridge.setNamespace(uuidOrReverseDomain)` inside the said window script (injected or remote, doesn't matter). 248 | 249 | - Specifying `devtools` or `content-script` or `window` from `background` will throw an error. When calling from `background`, destination must be suffixed with tab id. Like `devtools@745` for `devtools` inspecting tab id 745 or `content-script@351` for top `content-script` at tab id 351. 250 | 251 | 252 | 253 | # Serious security note 254 | 255 | The following note only applies if and only if, you will be sending/receiving messages to/from `window` contexts. There's no security concern if you will be only working with `content-script`, `background` or `devtools` scope, which is default setting. 256 | 257 | `window` context(s) in tab `A` get unlocked the moment you call `Bridge.allowWindowMessaging(namespace)` somewhere in your extenion's content script(s) that's also loaded in tab `A`. 258 | 259 | Unlike `chrome.runtime.sendMessage` and `chrome.runtime.connect`, which requires extension's manifest to specify sites allowed to talk with the extension, `crx-bridge` has no such measure by design, which means any webpage whether you intended or not, can do `Bridge.sendMessage(msgId, data, 'background')` or something similar that produces same effect, as long as it uses same protocol used by `crx-bridge` and namespace set to same as yours. 260 | 261 | So to be safe, if you will be interacting with `window` contexts, treat `crx-bridge` as you would treat `window.postMessage` API. 262 | 263 | Before you call `Bridge.allowWindowMessaging`, check if that page's `window.location.origin` is something you expect already. 264 | 265 | As an example if you plan on having something critical, **always** verify the `sender` before responding: 266 | 267 | ```javascript 268 | // background.js 269 | import { onMessage, isInternalEndpoint } from 'crx-bridge'; 270 | 271 | onMessage('getUserBrowsingHistory', (message) => { 272 | const { data, sender } = message; 273 | // Respond only if request is from 'devtools', 'content-script' or 'background' endpoint 274 | if (isInternalEndpoint(sender)) { 275 | const { range } = data; 276 | return getHistory(range); 277 | } 278 | }); 279 | ``` 280 | 281 | 282 | 283 | # Troubleshooting 284 | 285 | - Doesn't work? 286 |
If `window` contexts are not part of the puzzle, `crx-bridge` works out of the box for messaging between `devtools` <-> `background` <-> `content-script`(s). If even that is not working, it's likely that `crx-bridge` hasn't been loaded in background page of your extension, which is used by `crx-bridge` as a staging area. If you don't need a background page for yourself, here's bare minimum to get `crx-bridge` going. 287 | 288 | ```javascript 289 | // background.js (requires transpilation/bundling using webpack(recommended)) 290 | 291 | import 'crx-bridge'; 292 | ``` 293 | 294 | ```javascript 295 | // manifest.json 296 | 297 | { 298 | "background": { 299 | "scripts": ["path/to/transpiled/background.js"] 300 | } 301 | } 302 | ``` 303 | 304 | - Can't send messages to `window`? 305 |
Sending or receiving messages from or to `window` requires you to open the messaging gateway in content script(s) for that particular tab. Call `Bridge.allowWindowMessaging()` in any of your content script(s) in that tab and call `Bridge.setNamespace()` in the 306 | script loaded in top frame i.e the `window` context. Make sure that `namespaceA === namespaceB`. If you're doing this, read the [security note above](#security) 307 | --------------------------------------------------------------------------------