├── .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 | "oneline": "^1.0.3", 47 | "serialize-error": "^2.1.0", 48 | "tiny-uid": "^1.1.1", 49 | "webextension-polyfill-ts": "^0.20.0" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /.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 { EventEmitter } from 'events'; 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: EventEmitter = new EventEmitter(); 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 | const readyup = () => { 28 | const callback = onOpenStreamCallbacks.get(channel); 29 | 30 | if (typeof callback === 'function') { 31 | callback(new Stream({ ...data, endpoint: sender })); 32 | if (watching) { 33 | streamyEmitter.removeListener('did-change-stream-callbacks', readyup); 34 | } 35 | resolve(true); 36 | } else if (!watching) { 37 | watching = true; 38 | streamyEmitter.on('did-change-stream-callbacks', readyup); 39 | } 40 | }; 41 | 42 | readyup(); 43 | }); 44 | }); 45 | 46 | async function openStream(channel: string, destination: string | Endpoint): Promise { 47 | if (openStreams.has(channel)) { 48 | throw new Error('crx-bridge: A Stream is already open at this channel'); 49 | } 50 | 51 | const endpoint = typeof destination === 'string' ? parseEndpoint(destination) : destination; 52 | 53 | const streamInfo: StreamInfo = { streamId: uuid(), channel, endpoint }; 54 | const stream = new Stream(streamInfo); 55 | stream.onClose(() => openStreams.delete(channel)); 56 | await sendMessage('__crx_bridge_stream_open__', streamInfo, endpoint); 57 | openStreams.set(channel, stream); 58 | return stream; 59 | } 60 | 61 | function onOpenStreamChannel(channel: string, callback: (stream: Stream) => void): void { 62 | if (onOpenStreamCallbacks.has(channel)) { 63 | throw new Error('crx-bridge: This channel has already been claimed. Stream allows only one-on-one communication'); 64 | } 65 | 66 | onOpenStreamCallbacks.set(channel, callback); 67 | streamyEmitter.emit('did-change-stream-callbacks'); 68 | } 69 | 70 | export { 71 | IBridgeMessage, 72 | OnMessageCallback, 73 | isInternalEnpoint, 74 | sendMessage, 75 | onMessage, 76 | allowWindowMessaging, 77 | setNamespace, 78 | openStream, 79 | onOpenStreamChannel 80 | }; 81 | -------------------------------------------------------------------------------- /src/Stream.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 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: EventEmitter; 31 | private isClosed: boolean; 32 | constructor(t: StreamInfo) { 33 | this.internalInfo = t; 34 | this.emitter = new EventEmitter(); 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.removeAllListeners(); 125 | } 126 | } 127 | 128 | private getDisposable(event: string, callback: () => void): HybridUnsubscriber { 129 | this.emitter.on(event, callback); 130 | const unsub = () => { 131 | this.emitter.removeListener(event, callback); 132 | }; 133 | 134 | return Object.assign(unsub, { 135 | dispose: unsub, 136 | close: unsub, 137 | }); 138 | } 139 | } 140 | 141 | export { Stream }; 142 | -------------------------------------------------------------------------------- /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 | # crx-bridge 2 | 3 | Messaging in Chrome extensions made super easy. Out of the box. 4 | 5 | ## How much easy exactly? 6 | 7 | This much 8 | 9 | 10 | 11 | ```javascript 12 | // Inside devtools script 13 | 14 | import { sendMessage } from 'crx-bridge'; 15 | 16 | // ... 17 | 18 | button.addEventListener('click', () => { 19 | const res = await sendMessage('get-selection', { ignoreCasing: true }, 'content-script'); 20 | console.log(res); // > "The brown fox is alive and well" 21 | }) 22 | ``` 23 | 24 | ```javascript 25 | // Inside content script 26 | 27 | import { sendMessage, onMessage } from 'crx-bridge'; 28 | 29 | onMessage('get-selection', async (message) => { 30 | const { sender, data: { ignoreCasing } } = message; 31 | 32 | console.log(sender.context, sender.tabId); // > content-script 156 33 | 34 | const { selection } = await sendMessage('get-preferences', { sync: false }, 'background'); 35 | return calculateSelection(data.ignoreCasing, selection); 36 | }); 37 | ``` 38 | 39 | ```javascript 40 | // Inside background script 41 | 42 | import { onMessage } from 'crx-bridge'; 43 | 44 | onMessage('get-preferences', ({ data }) => { 45 | const { sync } = data; 46 | 47 | return loadUserPreferences(sync); 48 | }); 49 | ``` 50 | 51 | > Examples above require transpilation and/or bundling using `webpack`/`babel`/`rollup` 52 | 53 | `crx-bridge` handles everything for you as efficiently as possible. No more `chrome.runtime.sendMessage` or `chrome.runtime.onConnect` or `chrome.runtime.connect` .... 54 | 55 | # Contents 56 | 57 | - [Setup](#setup) 58 | - [API](#api) 59 | - [Behaviour](#behaviour) 60 | - [Security Note](#security) 61 | - [Troubleshooting](#troubleshooting) 62 | 63 | 64 | 65 | # Setup 66 | 67 | ### Install 68 | 69 | ```bash 70 | $ npm i crx-bridge 71 | ``` 72 | 73 | ### Light it up 74 | 75 | Just `import Bridge from 'crx-bridge'` wherever you need it and use as shown in [example above](#example) 76 | 77 | > Even if your extension doesn't need a background page or wont be sending/receiving messages in background script. 78 | >
`crx-bridge` uses background/event context as staging area for messages, therefore it **must** loaded in background/event page for it to work. 79 | >
(Attempting to send message from any context will fail silently if `crx-bridge` isn't available in background page). 80 | >
See [troubleshooting section](#troubleshooting) for more. 81 | 82 | 83 | 84 | # API 85 | 86 | ## `Bridge.sendMessage(messageId: string, data: any, destination: string)` 87 | 88 | Sends a message to some other part of your extension, out of the box. 89 | 90 | Notes: 91 | 92 | - If there is no listener on the other side an error will be thrown where `sendMessage` was called. 93 | 94 | - Listener on the other may want to reply. Get the reply by `await`ing the returned `Promise` 95 | 96 | - 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 97 | 98 | #### `messageId` 99 | 100 | > Required | `string` 101 | 102 | 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. 103 | 104 | #### `data` 105 | 106 | > Required | `any` 107 | 108 | 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. 109 | 110 | #### `destination` 111 | 112 | > Required | `string` 113 | 114 | The actual identifier of other endpoint. 115 | Example: `devtools` or `content-script` or `background` or `content-script@133` or `devtools@453` 116 | 117 | `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. 118 | 119 | Read `Behaviour` section to see how destinations (or endpoints) are treated. 120 | 121 | > 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) 122 | 123 | --- 124 | 125 | ## `Bridge.onMessage(messageId: string, callback: fn)` 126 | 127 | Register one and only one listener, per messageId per context. That will be called upon `Bridge.sendMessage` from other side. 128 | 129 | Optionally, send a response to sender by returning any value or if async a `Promise`. 130 | 131 | #### `messageId` 132 | 133 | > Required | `string` 134 | 135 | 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. 136 | 137 | #### `callback` 138 | 139 | > Required | `fn` 140 | 141 | 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. 142 | 143 | Optionally, this callback can return a value or a `Promise`, resolved value will sent as reply to sender. 144 | 145 | Read [security note](#security) before using this. 146 | 147 | --- 148 | 149 | ## `Bridge.allowWindowMessaging(namespace: string)` 150 | 151 | > Caution: Dangerous action 152 | 153 | Applicable to content scripts (noop if called from anywhere else) 154 | 155 | Unlocks the transmission of messages to and from `window` (top frame of loaded page) contexts in the tab where it is called. 156 | `crx-bridge` by default won't transmit any payload to or from `window` contexts for security reasons. 157 | This method can be called from a content script (in top frame of tab), which opens a gateway for messages. 158 | 159 | 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 160 | safety and privacy of your extension's users. 161 | 162 | #### `namespace` 163 | 164 | > Required | `string` 165 | 166 | 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. 167 | 168 | --- 169 | 170 | ## `Bridge.setNamespace(namespace: string)` 171 | 172 | Applicable to scripts in top frame of loaded remote page 173 | 174 | 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. 175 | 176 | #### `namespace` 177 | 178 | > Required | `string` 179 | 180 | 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. 181 | 182 | ## Extras 183 | 184 | 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. 185 | 186 | ### `Bridge.openStream(channel: string, destination: string)` 187 | 188 | Opens a `Stream` between caller and destination. 189 | 190 | Returns a `Promise` which resolves with `Stream` when the destination is ready (loaded and `Bridge.onOpenStreamChannel` callback registered). 191 | Example below illustrates a use case for `Stream` 192 | 193 | #### `channel` 194 | 195 | > Required | `string` 196 | 197 | `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. 198 | 199 | #### `destination` 200 | 201 | > Required | `string` 202 | 203 | Same as `destination` in `Bridge.sendMessage(msgId, data, destination)` 204 | 205 | --- 206 | 207 | ### `Bridge.onOpenStreamChannel(channel: string, callback: fn)` 208 | 209 | Registers a listener for when a `Stream` opens. 210 | Only one listener per channel per context 211 | 212 | #### `channel` 213 | 214 | > Required | `string` 215 | 216 | `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. 217 | 218 | #### `callback` 219 | 220 | > Required | `fn` 221 | 222 | 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. 223 | 224 | `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. 225 | 226 | ### Stream Example 227 | 228 | ```javascript 229 | // background.js 230 | 231 | // To-Do 232 | ``` 233 | 234 | 235 | 236 | # Behaviour 237 | 238 | > Following rules apply to `destination` being specified in `Bridge.sendMessage(msgId, data, destination)` and `Bridge.openStream(channelId, initialData, destination)` 239 | 240 | - Specifying `devtools` as destination from `content-script` will auto-route payload to inspecting `devtools` page if open and listening. 241 | 242 | - 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. 243 | 244 | - 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). 245 | 246 | - 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. 247 | 248 | 249 | 250 | # Serious security note 251 | 252 | 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. 253 | 254 | `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`. 255 | 256 | 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. 257 | 258 | So to be safe, if you will be interacting with `window` contexts, treat `crx-bridge` as you would treat `window.postMessage` API. 259 | 260 | Before you call `Bridge.allowWindowMessaging`, check if that page's `window.location.origin` is something you expect already. 261 | 262 | As an example if you plan on having something critical, **always** verify the `sender` before responding: 263 | 264 | ```javascript 265 | // background.js 266 | import { onMessage, isInternalEndpoint } from 'crx-bridge'; 267 | 268 | onMessage('getUserBrowsingHistory', (message) => { 269 | const { data, sender } = message; 270 | // Respond only if request is from 'devtools', 'content-script' or 'background' endpoint 271 | if (isInternalEndpoint(sender)) { 272 | const { range } = data; 273 | return getHistory(range); 274 | } 275 | }); 276 | ``` 277 | 278 | 279 | 280 | # Troubleshooting 281 | 282 | - Doesn't work? 283 |
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. 284 | 285 | ```javascript 286 | // background.js (requires transpilation/bundling using webpack(recommended)) 287 | 288 | import 'crx-bridge'; 289 | ``` 290 | 291 | ```javascript 292 | // manifest.json 293 | 294 | { 295 | "background": { 296 | "scripts": ["path/to/transpiled/background.js"] 297 | } 298 | } 299 | ``` 300 | 301 | - Can't send messages to `window`? 302 |
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 303 | 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) 304 | --------------------------------------------------------------------------------