bidc
Version:
Bidirectional Channel for JavaScript
1 lines • 24.6 kB
Source Map (JSON)
{"version":3,"file":"index.d.ts","sources":["../src/index.ts"],"sourcesContent":["// Simplified Promise Stream Library with compact protocol\nimport * as devalue from 'devalue'\n\n// A global store to track function references and their IDs\n// TODO: Solve this with WeakRef maybe? Track refs on the receiver side and\n// send back removed functions to the sender side along with the return value.\nconst functionRefIds = new Map<Function, string>()\nconst functionRefsById = new Map<string, Function>()\n\n// Custom stringify and parse functions using devalue\nfunction stringify(value: any, promiseMap: Map<Promise<any>, string>): string {\n return devalue.stringify(value, {\n F: (fn: Function) => {\n if (typeof fn === 'function') {\n // Use devalue's Function serializer to handle function detection and replacement\n if (!functionRefIds.has(fn)) {\n const id = functionRefIds.size.toString()\n functionRefIds.set(fn, id)\n functionRefsById.set(id, fn)\n if (functionRefIds.size > 50000) {\n console.warn(\n 'Function reference store is getting large, it is not recommended to send anonymous and inline functions through the channel as they cannot be cached.'\n )\n }\n }\n\n return functionRefIds.get(fn)\n }\n\n return undefined\n },\n P: (promise) => {\n // Use devalue's Promise serializer to handle promise detection and replacement\n if (\n promise &&\n typeof promise === 'object' &&\n typeof promise.then === 'function'\n ) {\n if (!promiseMap.has(promise))\n promiseMap.set(promise, promiseMap.size.toString())\n return promiseMap.get(promise)\n }\n return undefined\n },\n })\n}\n\nfunction parse(\n text: string,\n promiseResolvers: Map<\n string,\n { resolve: Function; reject: Function; promise: Promise<any> }\n >,\n send?: (data: any) => Promise<any>\n): any {\n return devalue.parse(text, {\n F: (id: string) => {\n if (!send) return null\n\n // Resolve function references by ID\n return async function (...args: any[]) {\n return send({ $$type: `bidc-fn:${id}`, args })\n }\n },\n P: (promiseId: string) => {\n // Check if promise already exists to avoid creating duplicates\n if (promiseResolvers.has(promiseId)) {\n return promiseResolvers.get(promiseId)!.promise\n }\n\n let resolvePromise: Function\n let rejectPromise: Function\n\n const promise = new Promise((resolve, reject) => {\n resolvePromise = resolve\n rejectPromise = reject\n })\n\n promiseResolvers.set(promiseId, {\n resolve: resolvePromise!,\n reject: rejectPromise!,\n promise: promise, // Store the promise instance for reuse\n })\n\n return promise\n },\n })\n}\n\nexport type SerializableValue =\n | void\n | string\n | number\n | boolean\n | null\n | undefined\n | RegExp\n | Date\n | Map<any, any>\n | Set<any>\n | bigint\n | ArrayBuffer\n | TypedArray\n | SerializableObject\n | SerializableArray\n | SerializableFunction\n | Promise<SerializableValue>\n\n// TypedArray type definition\ntype TypedArray =\n | Int8Array\n | Uint8Array\n | Uint8ClampedArray\n | Int16Array\n | Uint16Array\n | Int32Array\n | Uint32Array\n | Float32Array\n | Float64Array\n | BigInt64Array\n | BigUint64Array\n\nexport interface SerializableObject {\n [key: string]: SerializableValue\n}\n\nexport interface SerializableArray extends Array<SerializableValue> {}\n\nexport interface SerializableFunction {\n <Args extends SerializableValue[]>(\n ...args: Args\n ): void | Promise<SerializableValue>\n}\n\n/**\n * Encode a value with promises to an async iterator of string chunks\n * Uses r[id]: for return data and p0:, p1: etc for promise data\n */\nexport async function* encode<T extends SerializableValue>(\n value: T\n): AsyncGenerator<string> {\n const pendingPromises = new Map<string, Promise<any>>()\n const promiseMap = new Map<Promise<any>, string>()\n // Track resolved promise IDs to prevent re-adding them\n const resolvedPromiseIds = new Set<string>()\n\n // First, yield the main structure with promise placeholders using devalue's serializer\n const serializedValue = stringify(value, promiseMap)\n yield `r:${serializedValue}\\n`\n\n // Build pendingPromises map from the promiseMap created during stringify\n for (const [promise, promiseId] of promiseMap.entries()) {\n pendingPromises.set(promiseId, promise)\n }\n\n // Then process and yield promise resolutions\n while (pendingPromises.size > 0) {\n const promiseEntries = Array.from(pendingPromises.entries())\n\n // Replace Promise.allSettled with Promise.race for immediate chunk sending when any promise resolves\n // Create promises that resolve with their ID and result\n const racingPromises = promiseEntries.map(async ([promiseId, promise]) => {\n try {\n const resolvedValue = await promise\n return { promiseId, status: 'fulfilled', value: resolvedValue }\n } catch (error) {\n return { promiseId, status: 'rejected', reason: error }\n }\n })\n\n // Race all promises and handle the first one that completes\n const result = await Promise.race(racingPromises)\n\n // Remove the completed promise from pending\n pendingPromises.delete(result.promiseId)\n // Mark this promise ID as resolved to prevent re-adding\n resolvedPromiseIds.add(result.promiseId)\n\n if (result.status === 'fulfilled') {\n // Use stringify with promiseMap for resolved values that might contain more promises\n const processedValue = stringify(result.value, promiseMap)\n yield `p${result.promiseId}:${processedValue}\\n`\n\n // Add any new promises found in resolved value to pending promises\n // Only add promises that haven't been resolved yet\n for (const [promise, newPromiseId] of promiseMap.entries()) {\n if (\n !pendingPromises.has(newPromiseId) &&\n !resolvedPromiseIds.has(newPromiseId)\n ) {\n pendingPromises.set(newPromiseId, promise)\n }\n }\n } else {\n const errorMessage =\n result.reason instanceof Error\n ? result.reason.message\n : String(result.reason)\n yield `e${result.promiseId}:${stringify(errorMessage, promiseMap)}\\n`\n }\n }\n}\n\n/**\n * Decode an async iterator of string chunks back to the original value with promises\n * Returns immediately with unresolved promises that will resolve as chunks arrive\n */\nexport async function decode<T = any>(\n chunks: AsyncIterable<string>,\n options?: {\n send?: (key: string, ...args: any[]) => Promise<any>\n }\n): Promise<T> {\n let result: any = undefined\n // Updated type to include promise instance\n const promiseResolvers = new Map<\n string,\n { resolve: Function; reject: Function; promise: Promise<any> }\n >()\n\n // Process chunks asynchronously after receiving the first \"r:\" chunk\n const chunkIterator = chunks[Symbol.asyncIterator]()\n\n // Get the first chunk and validate it starts with \"r\"\n const firstIteratorResult = await chunkIterator.next()\n if (firstIteratorResult.done) {\n throw new Error('Stream ended without any chunks')\n }\n\n const firstChunk = firstIteratorResult.value\n\n // Validate that the first chunk starts with \"r\" (could be \"r:\" or \"r123:\")\n if (!firstChunk.startsWith('r')) {\n throw new Error(\"First chunk must start with 'r' (return data)\")\n }\n\n // Extract data from first chunk (everything after the first colon)\n const colonIndex = firstChunk.indexOf(':')\n if (colonIndex === -1) {\n throw new Error('Invalid first chunk format - missing colon')\n }\n\n // Parse using devalue with custom Promise deserializer\n const serializedData = firstChunk.slice(colonIndex + 1)\n result = parse(serializedData, promiseResolvers, options?.send)\n\n // Continue with remaining chunks asynchronously\n const processRemainingChunks = async () => {\n try {\n // Continue processing remaining chunks\n let iteratorResult = await chunkIterator.next()\n while (!iteratorResult.done) {\n const line = iteratorResult.value\n if (line.startsWith('p')) {\n // Promise resolution data\n const colonIndex = line.indexOf(':')\n const promiseId = line.slice(1, colonIndex)\n const data = parse(\n line.slice(colonIndex + 1),\n promiseResolvers,\n options?.send\n )\n\n const resolver = promiseResolvers.get(promiseId)\n if (resolver) {\n resolver.resolve(data)\n }\n } else if (line.startsWith('e')) {\n // Promise error data\n const colonIndex = line.indexOf(':')\n const promiseId = line.slice(1, colonIndex)\n const errorMessage = parse(\n line.slice(colonIndex + 1),\n promiseResolvers,\n options?.send\n )\n\n const resolver = promiseResolvers.get(promiseId)\n if (resolver) {\n resolver.reject(new Error(errorMessage))\n }\n }\n\n iteratorResult = await chunkIterator.next()\n }\n } catch (error) {\n // Reject any remaining promises if there's an error\n for (const resolver of promiseResolvers.values()) {\n resolver.reject(error)\n }\n }\n }\n\n // Start processing remaining chunks asynchronously (don't await)\n processRemainingChunks()\n\n return result as T\n}\n\n// Target interface that can postMessage\ntype MessageTarget =\n | Window\n | Worker\n | MessagePort\n | ServiceWorker\n | BroadcastChannel\n | MessageEventSource\n\n// We only make one message channel per target. This maximizes performance\n// and avoids issues with multiple connections to the same target, as well as\n// supporting initialization inside useEffect hooks.\nconst connectionCache = new WeakMap<MessageTarget, Promise<MessagePort>>()\n\ntype TChannel = {\n send: <\n TReceiver,\n T = TReceiver extends (data: infer TT) => any ? TT : never,\n R = TReceiver extends (data: any) => infer TR\n ? TR\n : TReceiver extends (data: any) => Promise<infer TR>\n ? TR\n : never\n >(\n data: T\n ) => Promise<R>\n receive: <T extends SerializableValue, R extends SerializableValue>(\n callback: (data: T) => R\n ) => Promise<void>\n cleanup: () => void\n onResetPort: (callback: (port: MessagePort) => void) => void\n}\n\n/**\n * Create a bidirectional channel between self and target using a channelId for handshake\n */\nfunction createChannel(): TChannel\nfunction createChannel(targetOrChannelId: MessageTarget | string): TChannel\nfunction createChannel(maybeTarget: MessageTarget, channelId: string): TChannel\nfunction createChannel(\n targetOrChannelId?: MessageTarget | string,\n channelId?: string\n) {\n let maybeTarget: MessageTarget | undefined = undefined\n if (\n typeof channelId === 'undefined' &&\n typeof targetOrChannelId === 'string'\n ) {\n // The first argument is channelId\n channelId = targetOrChannelId\n maybeTarget = undefined\n } else if (typeof targetOrChannelId === 'object') {\n // The first argument is a target\n maybeTarget = targetOrChannelId\n }\n\n // Namespaced channelId to avoid conflicts with other libraries / multiple\n // connections.\n channelId = 'bidc_' + (channelId ?? 'default')\n\n // Cache key for the message port of the connection\n const cacheKey = maybeTarget || self\n\n const onResetPortCallbacks: ((port: MessagePort) => void)[] = []\n function onResetPort(callback: (port: MessagePort) => void) {\n onResetPortCallbacks.push(callback)\n }\n\n function initPort() {\n let connected = false\n let connectionResolver: ((port: MessagePort) => void) | null = null\n const connectionPromise = new Promise<MessagePort>((resolve) => {\n connectionResolver = resolve\n })\n\n function sendMessageWithTransfer(message: any, transfer: MessagePort) {\n if (maybeTarget) {\n if ('self' in maybeTarget && maybeTarget.self === maybeTarget) {\n // It's an iframe contentWindow\n maybeTarget.postMessage(message, '*', [transfer])\n } else {\n ;(maybeTarget as Exclude<MessageTarget, Window>).postMessage(\n message,\n [transfer]\n )\n }\n } else {\n // If no target is provided, this might be an iframe or worker context\n\n if (typeof window === 'undefined' && typeof self !== 'undefined') {\n // In a worker context, we can use self directly\n ;(self as unknown as Worker).postMessage(message, [transfer])\n } else if (\n typeof window !== 'undefined' &&\n window.parent &&\n window.parent !== window\n ) {\n // Inside an iframe, we can use window.parent\n window.parent.postMessage(message, '*', [transfer])\n } else {\n throw new Error('No target provided and no global context available')\n }\n }\n }\n\n const messageChannel = new MessageChannel()\n\n const connectMessage = {\n type: 'bidc-connect',\n channelId,\n timestamp: Date.now(),\n } as const\n const confirmMessage = {\n type: 'bidc-confirm',\n channelId,\n } as const\n\n // Handle handshake requests\n function handleConnect(event: MessageEvent) {\n const port = event.ports[0] as MessagePort | undefined\n if (!port) return\n\n const data = event.data as typeof connectMessage | typeof confirmMessage\n if (data?.channelId !== channelId) return\n if (data.type !== connectMessage.type) return\n\n // If the received connect message is older, ignore it. This is because\n // our connect message should be received already.\n // Let's wait for the confirmation message instead.\n if (connectMessage.timestamp <= data.timestamp) {\n // Send confirmation back to the other side via the port\n port.postMessage(confirmMessage)\n\n if (connected) {\n // The other side refreshed, we need to reinitialize the connection\n connectionCache.set(cacheKey, Promise.resolve(port))\n onResetPortCallbacks.forEach((callback) => callback(port))\n } else {\n // Connection sent from the other side\n connectionResolver!(port)\n connected = true\n }\n }\n }\n\n function handleConfirm(event: MessageEvent) {\n if (\n event.data?.type === confirmMessage.type &&\n event.data.channelId === channelId\n ) {\n // Confirm connection established\n connectionResolver!(messageChannel.port1)\n connected = true\n\n // Remove confirmation listener\n messageChannel.port1.removeEventListener('message', handleConfirm)\n }\n }\n\n // Listen for connect messages\n // These are registered once per target, but we can't unregister them\n // because we don't know when the target will be restarted or refreshed.\n if (\n maybeTarget &&\n typeof Worker !== 'undefined' &&\n maybeTarget instanceof Worker\n ) {\n maybeTarget.addEventListener('message', handleConnect)\n } else if (typeof window !== 'undefined') {\n window.addEventListener('message', handleConnect)\n } else if (typeof self !== 'undefined') {\n self.addEventListener('message', handleConnect)\n }\n\n // Listen for confirmation responses\n messageChannel.port1.addEventListener('message', handleConfirm)\n messageChannel.port1.start()\n\n // Try to connect to the other side\n sendMessageWithTransfer(connectMessage, messageChannel.port2)\n\n return connectionPromise\n }\n\n function getPort() {\n if (!connectionCache.has(cacheKey)) {\n connectionCache.set(cacheKey, initPort())\n }\n\n return connectionCache.get(cacheKey)!\n }\n\n const responses = new Map<\n string,\n [resolve: (value: any) => void, promise: Promise<any>]\n >()\n\n // Send function\n const send = async function <\n TReceiver,\n T = TReceiver extends (data: infer TT) => any ? TT : never,\n R = TReceiver extends (data: any) => infer TR\n ? TR\n : TReceiver extends (data: any) => Promise<infer TR>\n ? TR\n : never\n >(data: T): Promise<R> {\n // Wait for connection to be established\n const port = await getPort()\n\n // Generate a unique ID for this message\n // Ensure that fast concurrent messages don't collide\n const id =\n Date.now().toString(36) + Math.random().toString(36).substring(2, 5)\n\n // Send chunks with ID prefix for concurrent message support\n for await (const chunk of encode(data as any)) {\n // Prefix each chunk with <id>@ to support concurrent messages\n const prefixedChunk = `${id}@${chunk}`\n\n port.postMessage(prefixedChunk)\n }\n\n // Wait for a response from receive\n let resolve: (value: R) => void\n const response = new Promise<R>((r) => {\n // Store the response resolver\n resolve = r\n })\n responses.set(id, [resolve!, response])\n\n return response\n }\n\n // Track multiple concurrent decodings by message ID\n const activeDecodings = new Map<\n string,\n {\n pendingChunks: string[]\n chunkResolver: null | (() => void)\n }\n >()\n\n // Things to clean up when the channel is closed\n let canceled = false\n const disposables: (() => void)[] = []\n\n let globalReceiveCallback:\n | ((data: SerializableValue) => SerializableValue)\n | null = null\n\n // Receive function\n const receive = async function <\n T extends SerializableValue,\n R extends SerializableValue\n >(callback: (data: T) => R) {\n // Wait for connection to be established\n await getPort()\n if (canceled) return\n globalReceiveCallback = callback as any\n }\n\n // Automatically set up the message handler for the port\n getPort().then((activePort) => {\n if (canceled) return\n\n const messageHandler = async (event: MessageEvent) => {\n const rawChunk = event.data as string\n\n // Skip handshake messages\n if (typeof rawChunk !== 'string') {\n return\n }\n\n // Parse ID from chunk prefix: <id>@<chunk>\n const atIndex = rawChunk.indexOf('@')\n if (atIndex === -1) {\n console.error('Invalid chunk format - missing @ delimiter:', rawChunk)\n return\n }\n\n const messageId = rawChunk.slice(0, atIndex)\n const chunk = rawChunk.slice(atIndex + 1)\n\n // Check if this is the first chunk of a new message (starts with \"r:\")\n if (chunk.startsWith('r:')) {\n // Initialize tracking for this message ID\n activeDecodings?.set(messageId, {\n pendingChunks: [],\n chunkResolver: null,\n })\n\n // Start decoding immediately with async generator\n const processDecoding = async () => {\n try {\n const decoded = await decode(\n (async function* () {\n // Yield the first chunk immediately\n yield chunk\n\n // Wait for and yield subsequent chunks for this message ID\n while (true) {\n const newChunk = await waitForNewChunkFromMessageEvent(\n messageId\n )\n if (newChunk === null) {\n break // End of stream\n }\n yield newChunk\n }\n\n // Clean up tracking for this message ID\n activeDecodings?.delete(messageId)\n })(),\n {\n send,\n }\n )\n\n // If the decoded data is a function call, we need to handle it\n // differently.\n if (\n typeof decoded === 'object' &&\n decoded !== null &&\n typeof decoded.$$type === 'string' &&\n decoded.$$type.startsWith('bidc-fn:')\n ) {\n // This is a function call, we need to resolve it\n const fnId = decoded.$$type.slice(8)\n const fn = functionRefsById.get(fnId)\n if (fn) {\n // Call the function with the provided arguments\n const response = fn(...decoded.args)\n void send({ $$type: `bidc-res:${messageId}`, response })\n } else {\n console.error(`Function reference not found for ID: ${fnId}`)\n }\n } else if (\n typeof decoded === 'object' &&\n decoded !== null &&\n typeof decoded.$$type === 'string' &&\n decoded.$$type.startsWith('bidc-res:')\n ) {\n const resposneMessageId = decoded.$$type.slice(9)\n const response = decoded.response\n const responseResolver = responses.get(resposneMessageId)\n if (responseResolver) {\n // Resolve the response promise with the decoded data\n responseResolver[0](response)\n responses.delete(resposneMessageId)\n }\n } else {\n // Call the callback with the ID and decoded data\n if (!globalReceiveCallback) {\n throw new Error(\n 'Global receive callback is not set. This is a bug in BIDC.'\n )\n }\n try {\n const response = globalReceiveCallback(decoded)\n void send({ $$type: `bidc-res:${messageId}`, response })\n } catch (error) {\n console.error(error)\n }\n }\n } catch (error) {\n console.error(`Error decoding stream for ID ${messageId}:`, error)\n\n // Clean up tracking for this message ID\n activeDecodings?.delete(messageId)\n }\n }\n\n processDecoding()\n } else {\n // This is a continuation chunk for an existing message\n const decoding = activeDecodings?.get(messageId)\n if (decoding) {\n // Add chunk to pending queue and notify any waiting generators\n decoding.pendingChunks.push(chunk)\n decoding.chunkResolver?.()\n } else {\n console.warn(`No active decoding found for ID: ${messageId}`)\n }\n }\n }\n\n // Helper function to wait for new chunks from MessageEvents for a specific message ID\n async function waitForNewChunkFromMessageEvent(\n messageId: string\n ): Promise<string | null> {\n const decoding = activeDecodings?.get(messageId)\n if (!decoding) {\n return null\n }\n\n // If we have pending chunks, return the next one\n if (decoding.pendingChunks.length > 0) {\n const nextChunk = decoding.pendingChunks.shift()!\n return nextChunk\n }\n\n // Otherwise, wait for a new chunk to arrive\n await new Promise<void>((resolve) => {\n decoding.chunkResolver = resolve\n })\n\n if (decoding.pendingChunks.length === 0) {\n // If no chunks are pending, return null to indicate end of stream\n return null\n }\n return decoding.pendingChunks.shift()!\n }\n\n activePort.addEventListener('message', messageHandler)\n disposables.push(() => {\n activePort.removeEventListener('message', messageHandler)\n })\n\n // Start the MessagePort to begin receiving messages\n activePort.start()\n\n onResetPort((newPort) => {\n // If the port is reset, we need to reinitialize the connection\n if (canceled) return\n\n activePort.removeEventListener('message', messageHandler)\n activePort = newPort\n activePort.addEventListener('message', messageHandler)\n\n // Start the new port to begin receiving messages\n activePort.start()\n })\n })\n\n // Cleanup function to remove listeners and close ports\n const cleanup = () => {\n canceled = true\n disposables.forEach((dispose) => dispose())\n disposables.length = 0\n }\n\n return { send, receive, cleanup }\n}\n\nexport { createChannel }\n"],"names":[],"mappings":"AAAO;AACP;AACO;AACP;AACA;AACO;AACP;AACO;AACP;AACA;AACO;AACA;AACP;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;;"}