From 43429314c0989b423e116be3e9f222eba5b636c3 Mon Sep 17 00:00:00 2001 From: vnugent Date: Sun, 19 Nov 2023 14:54:18 -0500 Subject: include webext-bridge modified source --- extension/src/webext-bridge/LICENSE.txt | 21 ++ extension/src/webext-bridge/README.md | 38 ++ extension/src/webext-bridge/index.ts | 4 + .../src/webext-bridge/internal/connection-args.ts | 31 ++ .../src/webext-bridge/internal/delivery-logger.ts | 28 ++ .../webext-bridge/internal/endpoint-fingerprint.ts | 5 + .../src/webext-bridge/internal/endpoint-runtime.ts | 187 ++++++++++ extension/src/webext-bridge/internal/endpoint.ts | 20 ++ .../webext-bridge/internal/is-internal-endpoint.ts | 5 + .../src/webext-bridge/internal/message-port.ts | 52 +++ .../src/webext-bridge/internal/persistent-port.ts | 126 +++++++ .../src/webext-bridge/internal/port-message.ts | 48 +++ .../src/webext-bridge/internal/post-message.ts | 52 +++ extension/src/webext-bridge/internal/stream.ts | 179 ++++++++++ extension/src/webext-bridge/internal/types.ts | 6 + extension/src/webext-bridge/ports.ts | 391 +++++++++++++++++++++ extension/src/webext-bridge/types.ts | 100 ++++++ 17 files changed, 1293 insertions(+) create mode 100644 extension/src/webext-bridge/LICENSE.txt create mode 100644 extension/src/webext-bridge/README.md create mode 100644 extension/src/webext-bridge/index.ts create mode 100644 extension/src/webext-bridge/internal/connection-args.ts create mode 100644 extension/src/webext-bridge/internal/delivery-logger.ts create mode 100644 extension/src/webext-bridge/internal/endpoint-fingerprint.ts create mode 100644 extension/src/webext-bridge/internal/endpoint-runtime.ts create mode 100644 extension/src/webext-bridge/internal/endpoint.ts create mode 100644 extension/src/webext-bridge/internal/is-internal-endpoint.ts create mode 100644 extension/src/webext-bridge/internal/message-port.ts create mode 100644 extension/src/webext-bridge/internal/persistent-port.ts create mode 100644 extension/src/webext-bridge/internal/port-message.ts create mode 100644 extension/src/webext-bridge/internal/post-message.ts create mode 100644 extension/src/webext-bridge/internal/stream.ts create mode 100644 extension/src/webext-bridge/internal/types.ts create mode 100644 extension/src/webext-bridge/ports.ts create mode 100644 extension/src/webext-bridge/types.ts diff --git a/extension/src/webext-bridge/LICENSE.txt b/extension/src/webext-bridge/LICENSE.txt new file mode 100644 index 0000000..b3236f3 --- /dev/null +++ b/extension/src/webext-bridge/LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 Neek Sandhu + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/extension/src/webext-bridge/README.md b/extension/src/webext-bridge/README.md new file mode 100644 index 0000000..de14bac --- /dev/null +++ b/extension/src/webext-bridge/README.md @@ -0,0 +1,38 @@ +# zikaari/webext-bridge + +The source code in this directory/subdirectory is mostly not my own. The original project can be found here [webext-bridge](https://github.com/zikaari/webext-bridge). + +Because of bundling [issues](https://github.com/zikaari/webext-bridge/issues/69), I had to copy the source code here and make some changes to it. The original project is licensed under the MIT license. + +## License +As per AGPLv3 the original license and copyright can be found in this directory as this ia a dirived work. + +This file is part of NVault, licensed under AGPLv3. +NVault is a derivative work of webext-bridge, which is licensed under the MIT license. + +The MIT License (MIT) + +Copyright (c) 2017 Neek Sandhu + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +### Changes + - Removed all npm package related files + - Changed port creation and local state to be function scoped + - removed files for file-based-exports such as options.ts and popup.ts diff --git a/extension/src/webext-bridge/index.ts b/extension/src/webext-bridge/index.ts new file mode 100644 index 0000000..6e87f07 --- /dev/null +++ b/extension/src/webext-bridge/index.ts @@ -0,0 +1,4 @@ +export * from './types' +export { isInternalEndpoint } from './internal/is-internal-endpoint' +export { parseEndpoint } from './internal/endpoint' +export { createPort, createBackgroundPort } from './ports' \ No newline at end of file diff --git a/extension/src/webext-bridge/internal/connection-args.ts b/extension/src/webext-bridge/internal/connection-args.ts new file mode 100644 index 0000000..9b93e19 --- /dev/null +++ b/extension/src/webext-bridge/internal/connection-args.ts @@ -0,0 +1,31 @@ +import type { EndpointFingerprint } from './endpoint-fingerprint' + +export interface ConnectionArgs { + endpointName: string + fingerprint: EndpointFingerprint +} + +const isValidConnectionArgs = ( + args: unknown, + requiredKeys: (keyof ConnectionArgs)[] = ['endpointName', 'fingerprint'], +): args is ConnectionArgs => + typeof args === 'object' + && args !== null + && requiredKeys.every(k => k in args) + +export const encodeConnectionArgs = (args: ConnectionArgs) => { + if (!isValidConnectionArgs(args)) + throw new TypeError('Invalid connection args') + + return JSON.stringify(args) +} + +export const decodeConnectionArgs = (encodedArgs: string): ConnectionArgs => { + try { + const args = JSON.parse(encodedArgs) + return isValidConnectionArgs(args) ? args : null + } + catch (error) { + return null + } +} diff --git a/extension/src/webext-bridge/internal/delivery-logger.ts b/extension/src/webext-bridge/internal/delivery-logger.ts new file mode 100644 index 0000000..395f035 --- /dev/null +++ b/extension/src/webext-bridge/internal/delivery-logger.ts @@ -0,0 +1,28 @@ +import type { InternalMessage } from '../types' +import type { EndpointFingerprint } from './endpoint-fingerprint' + +export interface DeliveryReceipt { + message: InternalMessage + to: EndpointFingerprint + from: { + endpointId: string + fingerprint: EndpointFingerprint + } +} + +export const createDeliveryLogger = () => { + let logs: ReadonlyArray = [] + + return { + add: (...receipts: DeliveryReceipt[]) => { + logs = [...logs, ...receipts] + }, + remove: (message: string | DeliveryReceipt[]) => { + logs + = typeof message === 'string' + ? logs.filter(receipt => receipt.message.transactionId !== message) + : logs.filter(receipt => !message.includes(receipt)) + }, + entries: () => logs, + } +} diff --git a/extension/src/webext-bridge/internal/endpoint-fingerprint.ts b/extension/src/webext-bridge/internal/endpoint-fingerprint.ts new file mode 100644 index 0000000..fe3cc24 --- /dev/null +++ b/extension/src/webext-bridge/internal/endpoint-fingerprint.ts @@ -0,0 +1,5 @@ +import uid from 'tiny-uid' + +export type EndpointFingerprint = `uid::${string}` + +export const createFingerprint = (): EndpointFingerprint => `uid::${uid(7)}` diff --git a/extension/src/webext-bridge/internal/endpoint-runtime.ts b/extension/src/webext-bridge/internal/endpoint-runtime.ts new file mode 100644 index 0000000..67b4fe0 --- /dev/null +++ b/extension/src/webext-bridge/internal/endpoint-runtime.ts @@ -0,0 +1,187 @@ +import type { JsonValue } from 'type-fest' +import uuid from 'tiny-uid' +import { serializeError } from 'serialize-error' +import type { + BridgeMessage, + DataTypeKey, + Destination, + GetDataType, + GetReturnType, + InternalMessage, + OnMessageCallback, + RuntimeContext, +} from '../types' +import { parseEndpoint } from './endpoint' + +export interface EndpointRuntime { + sendMessage: < + ReturnType extends JsonValue, + K extends DataTypeKey = DataTypeKey, + >( + messageID: K, + data: GetDataType, + destination?: Destination + ) => Promise> + onMessage: ( + messageID: K, + callback: OnMessageCallback, GetReturnType> + ) => (() => void) + /** + * @internal + */ + handleMessage: (message: InternalMessage) => void + endTransaction: (transactionID: string) => void +} + +export const createEndpointRuntime = ( + thisContext: RuntimeContext, + routeMessage: (msg: InternalMessage) => void, + localMessage?: (msg: InternalMessage) => void, +): EndpointRuntime => { + const runtimeId = uuid() + const openTransactions = new Map< + string, + { resolve: (v: unknown) => void; reject: (e: unknown) => void } + >() + const onMessageListeners = new Map>() + + const handleMessage = (message: InternalMessage) => { + if ( + message.destination.context === thisContext + && !message.destination.frameId + && !message.destination.tabId + ) { + localMessage?.(message) + + const { transactionId, messageID, messageType } = message + + const handleReply = () => { + const transactionP = openTransactions.get(transactionId) + if (transactionP) { + const { err, data } = message + if (err) { + const dehydratedErr = err as Record + const errCtr = self[dehydratedErr.name] as any + const hydratedErr = new ( + typeof errCtr === 'function' ? errCtr : Error + )(dehydratedErr.message) + + // eslint-disable-next-line no-restricted-syntax + for (const prop in dehydratedErr) + hydratedErr[prop] = dehydratedErr[prop] + + transactionP.reject(hydratedErr) + } + else { + transactionP.resolve(data) + } + openTransactions.delete(transactionId) + } + } + + const handleNewMessage = async() => { + let reply: JsonValue | void + let err: Error + let noHandlerFoundError = false + + try { + const cb = onMessageListeners.get(messageID) + if (typeof cb === 'function') { + // eslint-disable-next-line n/no-callback-literal + reply = await cb({ + sender: message.origin, + id: messageID, + data: message.data, + timestamp: message.timestamp, + } as BridgeMessage) + } + else { + noHandlerFoundError = true + throw new Error( + `[webext-bridge] No handler registered in '${thisContext}' to accept messages with id '${messageID}'`, + ) + } + } + catch (error) { + err = error + } + finally { + if (err) message.err = serializeError(err) + + handleMessage({ + ...message, + messageType: 'reply', + data: reply, + origin: { context: thisContext, tabId: null }, + destination: message.origin, + hops: [], + }) + + if (err && !noHandlerFoundError) + // eslint-disable-next-line no-unsafe-finally + throw reply + } + } + + switch (messageType) { + case 'reply': + return handleReply() + case 'message': + return handleNewMessage() + } + } + + message.hops.push(`${thisContext}::${runtimeId}`) + + return routeMessage(message) + } + + return { + handleMessage, + endTransaction: (transactionID) => { + const transactionP = openTransactions.get(transactionID) + transactionP?.reject('Transaction was ended before it could complete') + openTransactions.delete(transactionID) + }, + sendMessage: (messageID, data, destination = 'background') => { + const endpoint + = typeof destination === 'string' + ? parseEndpoint(destination) + : destination + const errFn = 'Bridge#sendMessage ->' + + if (!endpoint.context) { + throw new TypeError( + `${errFn} Destination must be any one of known destinations`, + ) + } + + return new Promise((resolve, reject) => { + const payload: InternalMessage = { + messageID, + data, + destination: endpoint, + messageType: 'message', + transactionId: uuid(), + origin: { context: thisContext, tabId: null }, + hops: [], + timestamp: Date.now(), + } + + openTransactions.set(payload.transactionId, { resolve, reject }) + + try { + handleMessage(payload) + } + catch (error) { + openTransactions.delete(payload.transactionId) + reject(error) + } + }) + }, + onMessage: (messageID, callback) => { + onMessageListeners.set(messageID, callback) + return () => onMessageListeners.delete(messageID) + }, + } +} diff --git a/extension/src/webext-bridge/internal/endpoint.ts b/extension/src/webext-bridge/internal/endpoint.ts new file mode 100644 index 0000000..0c271f2 --- /dev/null +++ b/extension/src/webext-bridge/internal/endpoint.ts @@ -0,0 +1,20 @@ +import type { Endpoint, RuntimeContext } from '../types' + +const ENDPOINT_RE = /^((?:background$)|devtools|popup|options|content-script|window)(?:@(\d+)(?:\.(\d+))?)?$/ + +export const parseEndpoint = (endpoint: string): Endpoint => { + const [, context, tabId, frameId] = endpoint.match(ENDPOINT_RE) || [] + + return { + context: context as RuntimeContext, + tabId: +tabId, + frameId: frameId ? +frameId : undefined, + } +} + +export const formatEndpoint = ({ context, tabId, frameId }: Endpoint): string => { + if (['background', 'popup', 'options'].includes(context)) + return context + + return `${context}@${tabId}${frameId ? `.${frameId}` : ''}` +} diff --git a/extension/src/webext-bridge/internal/is-internal-endpoint.ts b/extension/src/webext-bridge/internal/is-internal-endpoint.ts new file mode 100644 index 0000000..5e6ab4c --- /dev/null +++ b/extension/src/webext-bridge/internal/is-internal-endpoint.ts @@ -0,0 +1,5 @@ +import type { Endpoint, RuntimeContext } from '../types' + +const internalEndpoints: RuntimeContext[] = ['background', 'devtools', 'content-script', 'options', 'popup'] + +export const isInternalEndpoint = ({ context: ctx }: Endpoint): boolean => internalEndpoints.includes(ctx) diff --git a/extension/src/webext-bridge/internal/message-port.ts b/extension/src/webext-bridge/internal/message-port.ts new file mode 100644 index 0000000..204c11a --- /dev/null +++ b/extension/src/webext-bridge/internal/message-port.ts @@ -0,0 +1,52 @@ +let promise: Promise + +/** + * Returns a MessagePort for one-on-one communication + * + * Depending on which context's code runs first, either an incoming port from the other side + * is accepted OR a port will be offered, which the other side will then accept. + */ +export const getMessagePort = ( + thisContext: 'window' | 'content-script', + namespace: string, + onMessage: (e: MessageEvent) => void, +): Promise => ( + promise ??= new Promise((resolve) => { + const acceptMessagingPort = (event: MessageEvent) => { + const { data: { cmd, scope, context }, ports } = event + if (cmd === 'webext-port-offer' && scope === namespace && context !== thisContext) { + window.removeEventListener('message', acceptMessagingPort) + ports[0].onmessage = onMessage + ports[0].postMessage('port-accepted') + return resolve(ports[0]) + } + } + + const offerMessagingPort = () => { + const channel = new MessageChannel() + channel.port1.onmessage = (event: MessageEvent) => { + if (event.data === 'port-accepted') { + window.removeEventListener('message', acceptMessagingPort) + return resolve(channel.port1) + } + + onMessage?.(event) + } + + window.postMessage({ + cmd: 'webext-port-offer', + scope: namespace, + context: thisContext, + }, '*', [channel.port2]) + } + + window.addEventListener('message', acceptMessagingPort) + + // one of the contexts needs to be offset by at least 1 tick to prevent a race condition + // where both of them are offering, and then also accepting the port at the same time + if (thisContext === 'window') + setTimeout(offerMessagingPort, 0) + else + offerMessagingPort() + }) +) diff --git a/extension/src/webext-bridge/internal/persistent-port.ts b/extension/src/webext-bridge/internal/persistent-port.ts new file mode 100644 index 0000000..2281c68 --- /dev/null +++ b/extension/src/webext-bridge/internal/persistent-port.ts @@ -0,0 +1,126 @@ +import browser from 'webextension-polyfill' +import type { Runtime } from 'webextension-polyfill' +import type { InternalMessage } from '../types' +import { createFingerprint } from './endpoint-fingerprint' +import type { QueuedMessage } from './types' +import { encodeConnectionArgs } from './connection-args' +import { createDeliveryLogger } from './delivery-logger' +import type { StatusMessage } from './port-message' +import { PortMessage } from './port-message' + +/** + * Manfiest V3 extensions can have their service worker terminated at any point + * by the browser. That termination of service worker also terminates any messaging + * porta created by other parts of the extension. This class is a wrapper around the + * built-in Port object that re-instantiates the port connection everytime it gets + * suspended + */ +export const createPersistentPort = (name = '') => { + const fingerprint = createFingerprint() + let port: Runtime.Port + let undeliveredQueue: ReadonlyArray = [] + const pendingResponses = createDeliveryLogger() + const onMessageListeners = new Set< + (message: InternalMessage, port: Runtime.Port) => void + >() + const onFailureListeners = new Set<(message: InternalMessage) => void>() + + const handleMessage = (msg: StatusMessage, port: Runtime.Port) => { + switch (msg.status) { + case 'undeliverable': + if ( + !undeliveredQueue.some( + m => m.message.messageID === msg.message.messageID, + ) + ) { + undeliveredQueue = [ + ...undeliveredQueue, + { + message: msg.message, + resolvedDestination: msg.resolvedDestination, + }, + ] + } + + return + + case 'deliverable': + undeliveredQueue = undeliveredQueue.reduce((acc, queuedMsg) => { + if (queuedMsg.resolvedDestination === msg.deliverableTo) { + PortMessage.toBackground(port, { + type: 'deliver', + message: queuedMsg.message, + }) + + return acc + } + + return [...acc, queuedMsg] + }, [] as ReadonlyArray) + + return + + case 'delivered': + if (msg.receipt.message.messageType === 'message') + pendingResponses.add(msg.receipt) + + return + + case 'incoming': + if (msg.message.messageType === 'reply') + pendingResponses.remove(msg.message.messageID) + + onMessageListeners.forEach(cb => cb(msg.message, port)) + + return + + case 'terminated': { + const rogueMsgs = pendingResponses + .entries() + .filter(receipt => msg.fingerprint === receipt.to) + pendingResponses.remove(rogueMsgs) + rogueMsgs.forEach(({ message }) => + onFailureListeners.forEach(cb => cb(message)), + ) + } + } + } + + const connect = () => { + port = browser.runtime.connect({ + name: encodeConnectionArgs({ + endpointName: name, + fingerprint, + }), + }) + port.onMessage.addListener(handleMessage) + port.onDisconnect.addListener(connect) + + PortMessage.toBackground(port, { + type: 'sync', + pendingResponses: pendingResponses.entries(), + pendingDeliveries: [ + ...new Set( + undeliveredQueue.map(({ resolvedDestination }) => resolvedDestination), + ), + ], + }) + } + + connect() + + return { + onFailure(cb: (message: InternalMessage) => void) { + onFailureListeners.add(cb) + }, + onMessage(cb: (message: InternalMessage) => void): void { + onMessageListeners.add(cb) + }, + postMessage(message: any): void { + PortMessage.toBackground(port, { + type: 'deliver', + message, + }) + }, + } +} diff --git a/extension/src/webext-bridge/internal/port-message.ts b/extension/src/webext-bridge/internal/port-message.ts new file mode 100644 index 0000000..056e219 --- /dev/null +++ b/extension/src/webext-bridge/internal/port-message.ts @@ -0,0 +1,48 @@ +import type { Runtime } from 'webextension-polyfill' +import type { InternalMessage } from '../types' +import type { DeliveryReceipt } from './delivery-logger' +import type { EndpointFingerprint } from './endpoint-fingerprint' + +export type StatusMessage = + | { + status: 'undeliverable' + message: InternalMessage + resolvedDestination: string + } + | { + status: 'deliverable' + deliverableTo: string + } + | { + status: 'delivered' + receipt: DeliveryReceipt + } + | { + status: 'incoming' + message: InternalMessage + } + | { + status: 'terminated' + fingerprint: EndpointFingerprint + } + +export type RequestMessage = + | { + type: 'sync' + pendingResponses: ReadonlyArray + pendingDeliveries: ReadonlyArray + } + | { + type: 'deliver' + message: InternalMessage + } + +export class PortMessage { + static toBackground(port: Runtime.Port, message: RequestMessage) { + return port.postMessage(message) + } + + static toExtensionContext(port: Runtime.Port, message: StatusMessage) { + return port.postMessage(message) + } +} diff --git a/extension/src/webext-bridge/internal/post-message.ts b/extension/src/webext-bridge/internal/post-message.ts new file mode 100644 index 0000000..9db4424 --- /dev/null +++ b/extension/src/webext-bridge/internal/post-message.ts @@ -0,0 +1,52 @@ +import type { InternalMessage } from '../types' +import { getMessagePort } from './message-port' + +export interface EndpointWontRespondError { + type: 'error' + transactionID: string +} + +export const usePostMessaging = (thisContext: 'window' | 'content-script') => { + let allocatedNamespace: string + let messagingEnabled = false + let onMessageCallback: ( + msg: InternalMessage | EndpointWontRespondError + ) => void + let portP: Promise + + return { + enable: () => (messagingEnabled = true), + onMessage: (cb: typeof onMessageCallback) => (onMessageCallback = cb), + postMessage: async(msg: InternalMessage | EndpointWontRespondError) => { + if (thisContext !== 'content-script' && thisContext !== 'window') + throw new Error('Endpoint does not use postMessage') + + if (!messagingEnabled) + throw new Error('Communication with window has not been allowed') + + ensureNamespaceSet(allocatedNamespace) + + return (await portP).postMessage(msg) + }, + setNamespace: (nsps: string) => { + if (allocatedNamespace) + throw new Error('Namespace once set cannot be changed') + + allocatedNamespace = nsps + portP = getMessagePort(thisContext, nsps, ({ data }) => + onMessageCallback?.(data), + ) + }, + } +} + +function ensureNamespaceSet(namespace: string) { + if (typeof namespace !== 'string' || namespace.trim().length === 0) { + throw new Error( + 'webext-bridge uses window.postMessage to talk with other "window"(s) for message routing' + + 'which is global/conflicting operation in case there are other scripts using webext-bridge. ' + + 'Call Bridge#setNamespace(nsps) to isolate your app. Example: setNamespace(\'com.facebook.react-devtools\'). ' + + 'Make sure to use same namespace across all your scripts whereever window.postMessage is likely to be used`', + ) + } +} diff --git a/extension/src/webext-bridge/internal/stream.ts b/extension/src/webext-bridge/internal/stream.ts new file mode 100644 index 0000000..54cee9d --- /dev/null +++ b/extension/src/webext-bridge/internal/stream.ts @@ -0,0 +1,179 @@ +import { createNanoEvents } from 'nanoevents' +import uuid from 'tiny-uid' +import type { Emitter } from 'nanoevents' +import type { JsonValue } from 'type-fest' +import type { Endpoint, HybridUnsubscriber, RuntimeContext, StreamInfo } from '../types' +import type { EndpointRuntime } from './endpoint-runtime' +import { parseEndpoint } from './endpoint' + +/** + * Built on top of Bridge. Nothing much special except that Stream allows + * you to create a namespaced scope under a channel name of your choice + * and allows continuous e2e communication, with less possibility of + * conflicting messageId's, since streams are strictly scoped. + */ +export class Stream { + private static initDone = false + private static openStreams: Map = new Map() + + private emitter: Emitter = createNanoEvents() + private isClosed = false + constructor(private endpointRuntime: EndpointRuntime, private streamInfo: StreamInfo) { + if (!Stream.initDone) { + endpointRuntime.onMessage<{ streamId: string; action: 'transfer' | 'close'; streamTransfer: JsonValue }, string>('__crx_bridge_stream_transfer__', (msg) => { + const { streamId, streamTransfer, action } = msg.data + const stream = Stream.openStreams.get(streamId) + if (stream && !stream.isClosed) { + if (action === 'transfer') + stream.emitter.emit('message', streamTransfer) + + if (action === 'close') { + Stream.openStreams.delete(streamId) + stream.handleStreamClose() + } + } + }) + Stream.initDone = true + } + + Stream.openStreams.set(this.streamInfo.streamId, this) + } + + /** + * Returns stream info + */ + public get info(): StreamInfo { + return this.streamInfo + } + + /** + * Sends a message to other endpoint. + * Will trigger onMessage on the other side. + * + * Warning: Before sending sensitive data, verify the endpoint using `stream.info.endpoint.isInternal()` + * The other side could be malicious webpage speaking same language as webext-bridge + * @param msg + */ + public send(msg?: JsonValue): void { + if (this.isClosed) + throw new Error('Attempting to send a message over closed stream. Use stream.onClose() to keep an eye on stream status') + + this.endpointRuntime.sendMessage('__crx_bridge_stream_transfer__', { + streamId: this.streamInfo.streamId, + streamTransfer: msg, + action: 'transfer', + }, this.streamInfo.endpoint) + } + + /** + * Closes the stream. + * Will trigger stream.onClose() on both endpoints. + * If needed again, spawn a new Stream, as this instance cannot be re-opened + * @param msg + */ + public close(msg?: JsonValue): void { + if (msg) + this.send(msg) + + this.handleStreamClose() + + this.endpointRuntime.sendMessage('__crx_bridge_stream_transfer__', { + streamId: this.streamInfo.streamId, + streamTransfer: null, + action: 'close', + }, this.streamInfo.endpoint) + } + + /** + * Registers a callback to fire whenever other endpoint sends a message + * @param callback + */ + public onMessage(callback: (msg?: T) => void): HybridUnsubscriber { + return this.getDisposable('message', callback) + } + + /** + * Registers a callback to fire whenever stream.close() is called on either endpoint + * @param callback + */ + public onClose(callback: (msg?: T) => void): HybridUnsubscriber { + return this.getDisposable('closed', callback) + } + + private handleStreamClose = () => { + if (!this.isClosed) { + this.isClosed = true + this.emitter.emit('closed', true) + this.emitter.events = {} + } + } + + private getDisposable(event: string, callback: () => void): HybridUnsubscriber { + const off = this.emitter.on(event, callback) + + return Object.assign(off, { + dispose: off, + close: off, + }) + } +} + +export const createStreamWirings = (endpointRuntime: EndpointRuntime) => { + const openStreams = new Map() + const onOpenStreamCallbacks = new Map void>() + const streamyEmitter = createNanoEvents() + + endpointRuntime.onMessage<{ channel: string; streamId: string }, string>('__crx_bridge_stream_open__', (message) => { + return new Promise((resolve) => { + const { sender, data } = message + const { channel } = data + let watching = false + let off = () => { } + + const readyup = () => { + const callback = onOpenStreamCallbacks.get(channel) + + if (typeof callback === 'function') { + callback(new Stream(endpointRuntime, { ...data, endpoint: sender })) + if (watching) + off() + + resolve(true) + } + else if (!watching) { + watching = true + off = streamyEmitter.on('did-change-stream-callbacks', readyup) + } + } + + readyup() + }) + }) + + async function openStream(channel: string, destination: RuntimeContext | Endpoint | string): Promise { + if (openStreams.has(channel)) + throw new Error('webext-bridge: A Stream is already open at this channel') + + const endpoint = typeof destination === 'string' ? parseEndpoint(destination) : destination + + const streamInfo: StreamInfo = { streamId: uuid(), channel, endpoint } + const stream = new Stream(endpointRuntime, streamInfo) + stream.onClose(() => openStreams.delete(channel)) + await endpointRuntime.sendMessage('__crx_bridge_stream_open__', streamInfo as unknown as JsonValue, endpoint) + openStreams.set(channel, stream) + return stream + } + + function onOpenStreamChannel(channel: string, callback: (stream: Stream) => void): void { + if (onOpenStreamCallbacks.has(channel)) + throw new Error('webext-bridge: This channel has already been claimed. Stream allows only one-on-one communication') + + onOpenStreamCallbacks.set(channel, callback) + streamyEmitter.emit('did-change-stream-callbacks') + } + + return { + openStream, + onOpenStreamChannel, + } +} diff --git a/extension/src/webext-bridge/internal/types.ts b/extension/src/webext-bridge/internal/types.ts new file mode 100644 index 0000000..2063adb --- /dev/null +++ b/extension/src/webext-bridge/internal/types.ts @@ -0,0 +1,6 @@ +import type { InternalMessage } from '../types' + +export interface QueuedMessage { + resolvedDestination: string + message: InternalMessage +} diff --git a/extension/src/webext-bridge/ports.ts b/extension/src/webext-bridge/ports.ts new file mode 100644 index 0000000..8edd04e --- /dev/null +++ b/extension/src/webext-bridge/ports.ts @@ -0,0 +1,391 @@ +import browser, { type Runtime } from 'webextension-polyfill' +import { formatEndpoint, parseEndpoint } from './internal/endpoint' +import { decodeConnectionArgs } from './internal/connection-args' +import { PortMessage } from './internal/port-message' +import { createEndpointRuntime } from './internal/endpoint-runtime' +import { createStreamWirings } from './internal/stream' +import { createPersistentPort } from './internal/persistent-port' +import { usePostMessaging } from './internal/post-message' +import { createFingerprint, type EndpointFingerprint } from './internal/endpoint-fingerprint' +import { createDeliveryLogger, type DeliveryReceipt } from './internal/delivery-logger' +import type { RequestMessage } from './internal/port-message' +import type { InternalMessage, RuntimeContext } from './types' + +const createContentScriptPort = () => { + + const win = usePostMessaging('content-script') + const port = createPersistentPort() + const endpointRuntime = createEndpointRuntime('content-script', (message) => { + if (message.destination.context === 'window') win.postMessage(message) + else port.postMessage(message) + }) + + win.onMessage((message: InternalMessage) => { + // a message event inside `content-script` means a script inside `window` dispatched it to be forwarded + // so we're making sure that the origin is not tampered (i.e script is not masquerading it's true identity) + message.origin = { + context: 'window', + tabId: null, + } + + endpointRuntime.handleMessage(message) + }) + + port.onMessage(endpointRuntime.handleMessage) + + port.onFailure((message) => { + if (message.origin.context === 'window') { + win.postMessage({ + type: 'error', + transactionID: message.transactionId, + }) + + return + } + + endpointRuntime.endTransaction(message.transactionId) + }) + + return { + sendMessage: endpointRuntime.sendMessage, + } +} + +export const createPort = (name: RuntimeContext) => { + + switch (name) { + case 'content-script': + return createContentScriptPort(); + default: + break; + } + + const port = createPersistentPort(name) + + const endpointRuntime = createEndpointRuntime( + name, + message => port.postMessage(message), + ) + + port.onMessage(endpointRuntime.handleMessage) + + return { + ...endpointRuntime, + ...createStreamWirings(endpointRuntime) + } +} + +interface PortConnection { + port: Runtime.Port + fingerprint: EndpointFingerprint +} + +export const createBackgroundPort = () =>{ + const pendingResponses = createDeliveryLogger() + const connMap = new Map() + const oncePortConnectedCbs = new Map void>>() + const onceSessionEndCbs = new Map void>>() + + const oncePortConnected = (endpointName: string, cb: () => void) => { + oncePortConnectedCbs.set( + endpointName, + (oncePortConnectedCbs.get(endpointName) || new Set()).add(cb), + ) + + return () => { + const su = oncePortConnectedCbs.get(endpointName) + if (su?.delete(cb) && su?.size === 0) + oncePortConnectedCbs.delete(endpointName) + } + } + + const onceSessionEnded = ( + sessionFingerprint: EndpointFingerprint, + cb: () => void, + ) => { + onceSessionEndCbs.set( + sessionFingerprint, + (onceSessionEndCbs.get(sessionFingerprint) || new Set()).add(cb), + ) + } + + const notifyEndpoint = (endpoint: string) => ({ + withFingerprint: (fingerprint: EndpointFingerprint) => { + const nextChain = (v: T) => ({ and: () => v }) + + const notifications = { + aboutIncomingMessage: (message: InternalMessage) => { + const recipient = connMap.get(endpoint) + + PortMessage.toExtensionContext(recipient.port, { + status: 'incoming', + message, + }) + + return nextChain(notifications) + }, + + aboutSuccessfulDelivery: (receipt: DeliveryReceipt) => { + const sender = connMap.get(endpoint) + PortMessage.toExtensionContext(sender.port, { + status: 'delivered', + receipt, + }) + + return nextChain(notifications) + }, + + aboutMessageUndeliverability: ( + resolvedDestination: string, + message: InternalMessage, + ) => { + const sender = connMap.get(endpoint) + if (sender?.fingerprint === fingerprint) { + PortMessage.toExtensionContext(sender.port, { + status: 'undeliverable', + resolvedDestination, + message, + }) + } + + return nextChain(notifications) + }, + + whenDeliverableTo: (targetEndpoint: string) => { + const notifyDeliverability = () => { + const origin = connMap.get(endpoint) + if ( + origin?.fingerprint === fingerprint + && connMap.has(targetEndpoint) + ) { + PortMessage.toExtensionContext(origin.port, { + status: 'deliverable', + deliverableTo: targetEndpoint, + }) + + return true + } + } + + if (!notifyDeliverability()) { + const unsub = oncePortConnected(targetEndpoint, notifyDeliverability) + onceSessionEnded(fingerprint, unsub) + } + + return nextChain(notifications) + }, + + aboutSessionEnded: (endedSessionFingerprint: EndpointFingerprint) => { + const conn = connMap.get(endpoint) + if (conn?.fingerprint === fingerprint) { + PortMessage.toExtensionContext(conn.port, { + status: 'terminated', + fingerprint: endedSessionFingerprint, + }) + } + + return nextChain(notifications) + }, + } + + return notifications + }, + }) + + const sessFingerprint = createFingerprint() + + const endpointRuntime = createEndpointRuntime( + 'background', + (message) => { + if ( + message.origin.context === 'background' + && ['content-script', 'devtools '].includes(message.destination.context) + && !message.destination.tabId + ) { + throw new TypeError( + 'When sending messages from background page, use @tabId syntax to target specific tab', + ) + } + + const resolvedSender = formatEndpoint({ + ...message.origin, + ...(message.origin.context === 'window' && { context: 'content-script' }), + }) + + const resolvedDestination = formatEndpoint({ + ...message.destination, + ...(message.destination.context === 'window' && { + context: 'content-script', + }), + tabId: message.destination.tabId || message.origin.tabId, + }) + + // downstream endpoints are agnostic of these attributes, presence of these attrs will make them think the message is not intended for them + message.destination.tabId = null + message.destination.frameId = null + + const dest = () => connMap.get(resolvedDestination) + const sender = () => connMap.get(resolvedSender) + + const deliver = () => { + notifyEndpoint(resolvedDestination) + .withFingerprint(dest().fingerprint) + .aboutIncomingMessage(message) + + const receipt: DeliveryReceipt = { + message, + to: dest().fingerprint, + from: { + endpointId: resolvedSender, + fingerprint: sender()?.fingerprint, + }, + } + + if (message.messageType === 'message') pendingResponses.add(receipt) + + if (message.messageType === 'reply') + pendingResponses.remove(message.messageID) + + if (sender()) { + notifyEndpoint(resolvedSender) + .withFingerprint(sender().fingerprint) + .aboutSuccessfulDelivery(receipt) + } + } + + if (dest()?.port) { + deliver() + } + else if (message.messageType === 'message') { + if (message.origin.context === 'background') { + oncePortConnected(resolvedDestination, deliver) + } + else if (sender()) { + notifyEndpoint(resolvedSender) + .withFingerprint(sender().fingerprint) + .aboutMessageUndeliverability(resolvedDestination, message) + .and() + .whenDeliverableTo(resolvedDestination) + } + } + }, + (message) => { + const resolvedSender = formatEndpoint({ + ...message.origin, + ...(message.origin.context === 'window' && { context: 'content-script' }), + }) + + const sender = connMap.get(resolvedSender) + + const receipt: DeliveryReceipt = { + message, + to: sessFingerprint, + from: { + endpointId: resolvedSender, + fingerprint: sender.fingerprint, + }, + } + + notifyEndpoint(resolvedSender) + .withFingerprint(sender.fingerprint) + .aboutSuccessfulDelivery(receipt) + }, + ) + + browser.runtime.onConnect.addListener((incomingPort) => { + const connArgs = decodeConnectionArgs(incomingPort.name) + + if (!connArgs) return + + // all other contexts except 'content-script' are aware of, and pass their identity as name + connArgs.endpointName ||= formatEndpoint({ + context: 'content-script', + tabId: incomingPort.sender.tab.id, + frameId: incomingPort.sender.frameId, + }) + + // literal tab id in case of content script, however tab id of inspected page in case of devtools context + const { tabId: linkedTabId, frameId: linkedFrameId } = parseEndpoint( + connArgs.endpointName, + ) + + connMap.set(connArgs.endpointName, { + fingerprint: connArgs.fingerprint, + port: incomingPort, + }) + + oncePortConnectedCbs.get(connArgs.endpointName)?.forEach(cb => cb()) + oncePortConnectedCbs.delete(connArgs.endpointName) + + onceSessionEnded(connArgs.fingerprint, () => { + const rogueMsgs = pendingResponses + .entries() + .filter(pendingMessage => pendingMessage.to === connArgs.fingerprint) + pendingResponses.remove(rogueMsgs) + + rogueMsgs.forEach((rogueMessage) => { + if (rogueMessage.from.endpointId === 'background') { + endpointRuntime.endTransaction(rogueMessage.message.transactionId) + } + else { + notifyEndpoint(rogueMessage.from.endpointId) + .withFingerprint(rogueMessage.from.fingerprint) + .aboutSessionEnded(connArgs.fingerprint) + } + }) + }) + + incomingPort.onDisconnect.addListener(() => { + // sometimes previous content script's onDisconnect is called *after* the fresh content-script's + // onConnect. So without this fingerprint equality check, we would remove the new port from map + if ( + connMap.get(connArgs.endpointName)?.fingerprint === connArgs.fingerprint + ) + connMap.delete(connArgs.endpointName) + + onceSessionEndCbs.get(connArgs.fingerprint)?.forEach(cb => cb()) + onceSessionEndCbs.delete(connArgs.fingerprint) + }) + + incomingPort.onMessage.addListener((msg: RequestMessage) => { + if (msg.type === 'sync') { + const allActiveSessions = [...connMap.values()].map( + conn => conn.fingerprint, + ) + const stillPending = msg.pendingResponses.filter(fp => + allActiveSessions.includes(fp.to), + ) + + pendingResponses.add(...stillPending) + + msg.pendingResponses + .filter( + deliveryReceipt => !allActiveSessions.includes(deliveryReceipt.to), + ) + .forEach(deliveryReceipt => + notifyEndpoint(connArgs.endpointName) + .withFingerprint(connArgs.fingerprint) + .aboutSessionEnded(deliveryReceipt.to), + ) + + msg.pendingDeliveries.forEach(intendedDestination => + notifyEndpoint(connArgs.endpointName) + .withFingerprint(connArgs.fingerprint) + .whenDeliverableTo(intendedDestination), + ) + + return + } + + if (msg.type === 'deliver' && msg.message?.origin?.context) { + // origin tab ID is resolved from the port identifier (also prevent "MITM attacks" of extensions) + msg.message.origin.tabId = linkedTabId + msg.message.origin.frameId = linkedFrameId + + endpointRuntime.handleMessage(msg.message) + } + }) + }) + + return{ ...endpointRuntime } +} \ No newline at end of file diff --git a/extension/src/webext-bridge/types.ts b/extension/src/webext-bridge/types.ts new file mode 100644 index 0000000..d184a5c --- /dev/null +++ b/extension/src/webext-bridge/types.ts @@ -0,0 +1,100 @@ +import type { JsonValue, Jsonify } from 'type-fest' + +export type RuntimeContext = + | 'devtools' + | 'background' + | 'popup' + | 'options' + | 'content-script' + | 'window' + +export interface Endpoint { + context: RuntimeContext + tabId: number + frameId?: number +} + +export interface BridgeMessage { + sender: Endpoint + id: string + data: T + timestamp: number +} + +export type OnMessageCallback = ( + message: BridgeMessage +) => R | Promise + +export interface InternalMessage { + origin: Endpoint + destination: Endpoint + transactionId: string + hops: string[] + messageID: string + messageType: 'message' | 'reply' + err?: JsonValue + data?: JsonValue | void + timestamp: number +} + +export interface StreamInfo { + streamId: string + channel: string + endpoint: Endpoint +} + +export interface HybridUnsubscriber { + (): void + dispose: () => void + close: () => void +} + +export type Destination = Endpoint | RuntimeContext | string + +declare const ProtocolWithReturnSymbol: unique symbol + +export interface ProtocolWithReturn { + data: Jsonify + return: Jsonify + /** + * Type differentiator only. + */ + [ProtocolWithReturnSymbol]: true +} + +/** + * Extendable by user. + */ +export interface ProtocolMap { + // foo: { id: number, name: string } + // bar: ProtocolWithReturn +} + +export type DataTypeKey = keyof ProtocolMap extends never + ? string + : keyof ProtocolMap + +export type GetDataType< + K extends DataTypeKey, + Fallback extends JsonValue = undefined, +> = K extends keyof ProtocolMap + ? ProtocolMap[K] extends (...args: infer Args) => any + ? Args['length'] extends 0 + ? undefined + : Args[0] + : ProtocolMap[K] extends ProtocolWithReturn + ? Data + : ProtocolMap[K] + : Fallback; + + +export type GetReturnType< + K extends DataTypeKey, + Fallback extends JsonValue = undefined +> = K extends keyof ProtocolMap + ? ProtocolMap[K] extends (...args: any[]) => infer R + ? R + : ProtocolMap[K] extends ProtocolWithReturn + ? Return + : void + : Fallback; -- cgit