aboutsummaryrefslogtreecommitdiff
path: root/extension/src
diff options
context:
space:
mode:
authorLibravatar vnugent <public@vaughnnugent.com>2023-11-19 14:54:18 -0500
committerLibravatar vnugent <public@vaughnnugent.com>2023-11-19 14:54:18 -0500
commit43429314c0989b423e116be3e9f222eba5b636c3 (patch)
tree78d394b1a995fa137fc542050e730ee76b423561 /extension/src
parentbc7b86a242673d7831f6105d000995d9f4d63e09 (diff)
include webext-bridge modified source
Diffstat (limited to 'extension/src')
-rw-r--r--extension/src/webext-bridge/LICENSE.txt21
-rw-r--r--extension/src/webext-bridge/README.md38
-rw-r--r--extension/src/webext-bridge/index.ts4
-rw-r--r--extension/src/webext-bridge/internal/connection-args.ts31
-rw-r--r--extension/src/webext-bridge/internal/delivery-logger.ts28
-rw-r--r--extension/src/webext-bridge/internal/endpoint-fingerprint.ts5
-rw-r--r--extension/src/webext-bridge/internal/endpoint-runtime.ts187
-rw-r--r--extension/src/webext-bridge/internal/endpoint.ts20
-rw-r--r--extension/src/webext-bridge/internal/is-internal-endpoint.ts5
-rw-r--r--extension/src/webext-bridge/internal/message-port.ts52
-rw-r--r--extension/src/webext-bridge/internal/persistent-port.ts126
-rw-r--r--extension/src/webext-bridge/internal/port-message.ts48
-rw-r--r--extension/src/webext-bridge/internal/post-message.ts52
-rw-r--r--extension/src/webext-bridge/internal/stream.ts179
-rw-r--r--extension/src/webext-bridge/internal/types.ts6
-rw-r--r--extension/src/webext-bridge/ports.ts391
-rw-r--r--extension/src/webext-bridge/types.ts100
17 files changed, 1293 insertions, 0 deletions
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<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
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<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
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<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
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<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
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<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
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<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
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<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
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<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
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<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;