diff options
Diffstat (limited to 'extension/src/webext-bridge')
17 files changed, 0 insertions, 1293 deletions
diff --git a/extension/src/webext-bridge/LICENSE.txt b/extension/src/webext-bridge/LICENSE.txt deleted file mode 100644 index b3236f3..0000000 --- a/extension/src/webext-bridge/LICENSE.txt +++ /dev/null @@ -1,21 +0,0 @@ -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 deleted file mode 100644 index de14bac..0000000 --- a/extension/src/webext-bridge/README.md +++ /dev/null @@ -1,38 +0,0 @@ -# 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 deleted file mode 100644 index 6e87f07..0000000 --- a/extension/src/webext-bridge/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -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 deleted file mode 100644 index 9b93e19..0000000 --- a/extension/src/webext-bridge/internal/connection-args.ts +++ /dev/null @@ -1,31 +0,0 @@ -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 deleted file mode 100644 index 395f035..0000000 --- a/extension/src/webext-bridge/internal/delivery-logger.ts +++ /dev/null @@ -1,28 +0,0 @@ -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<DeliveryReceipt> = [] - - 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 deleted file mode 100644 index fe3cc24..0000000 --- a/extension/src/webext-bridge/internal/endpoint-fingerprint.ts +++ /dev/null @@ -1,5 +0,0 @@ -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 deleted file mode 100644 index 67b4fe0..0000000 --- a/extension/src/webext-bridge/internal/endpoint-runtime.ts +++ /dev/null @@ -1,187 +0,0 @@ -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<K, JsonValue>, - destination?: Destination - ) => Promise<GetReturnType<K, ReturnType>> - onMessage: <Data extends JsonValue, K extends DataTypeKey = DataTypeKey>( - messageID: K, - callback: OnMessageCallback<GetDataType<K, Data>, GetReturnType<K, any>> - ) => (() => 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<string, OnMessageCallback<JsonValue>>() - - 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<string, string> - 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<JsonValue>) - } - 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 deleted file mode 100644 index 0c271f2..0000000 --- a/extension/src/webext-bridge/internal/endpoint.ts +++ /dev/null @@ -1,20 +0,0 @@ -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 deleted file mode 100644 index 5e6ab4c..0000000 --- a/extension/src/webext-bridge/internal/is-internal-endpoint.ts +++ /dev/null @@ -1,5 +0,0 @@ -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 deleted file mode 100644 index 204c11a..0000000 --- a/extension/src/webext-bridge/internal/message-port.ts +++ /dev/null @@ -1,52 +0,0 @@ -let promise: Promise<MessagePort> - -/** - * 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<any>) => void, -): Promise<MessagePort> => ( - 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 deleted file mode 100644 index 2281c68..0000000 --- a/extension/src/webext-bridge/internal/persistent-port.ts +++ /dev/null @@ -1,126 +0,0 @@ -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<QueuedMessage> = [] - 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<QueuedMessage>) - - 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 deleted file mode 100644 index 056e219..0000000 --- a/extension/src/webext-bridge/internal/port-message.ts +++ /dev/null @@ -1,48 +0,0 @@ -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<DeliveryReceipt> - pendingDeliveries: ReadonlyArray<string> - } - | { - 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 deleted file mode 100644 index 9db4424..0000000 --- a/extension/src/webext-bridge/internal/post-message.ts +++ /dev/null @@ -1,52 +0,0 @@ -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<MessagePort> - - 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 deleted file mode 100644 index 54cee9d..0000000 --- a/extension/src/webext-bridge/internal/stream.ts +++ /dev/null @@ -1,179 +0,0 @@ -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<string, Stream> = 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(<callback>) 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(<callback>) 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<T extends JsonValue>(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<T extends JsonValue>(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<string, Stream>() - const onOpenStreamCallbacks = new Map<string, (stream: Stream) => 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<Stream> { - 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 deleted file mode 100644 index 2063adb..0000000 --- a/extension/src/webext-bridge/internal/types.ts +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 8edd04e..0000000 --- a/extension/src/webext-bridge/ports.ts +++ /dev/null @@ -1,391 +0,0 @@ -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<string, PortConnection>() - const oncePortConnectedCbs = new Map<string, Set<() => void>>() - const onceSessionEndCbs = new Map<EndpointFingerprint, Set<() => 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 = <T>(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 deleted file mode 100644 index d184a5c..0000000 --- a/extension/src/webext-bridge/types.ts +++ /dev/null @@ -1,100 +0,0 @@ -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<T extends JsonValue> { - sender: Endpoint - id: string - data: T - timestamp: number -} - -export type OnMessageCallback<T extends JsonValue, R = void | JsonValue> = ( - message: BridgeMessage<T> -) => R | Promise<R> - -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, Return> { - data: Jsonify<Data> - return: Jsonify<Return> - /** - * Type differentiator only. - */ - [ProtocolWithReturnSymbol]: true -} - -/** - * Extendable by user. - */ -export interface ProtocolMap { - // foo: { id: number, name: string } - // bar: ProtocolWithReturn<string, number> -} - -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<infer Data, any> - ? 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<any, infer Return> - ? Return - : void - : Fallback; |