UNPKG

comctx

Version:

Cross-context RPC solution with type safety and flexible adapters.

291 lines (263 loc) 9.84 kB
import uuid from '@/utils/uuid' import setIntervalImmediate from '@/utils/setIntervalImmediate' type MaybePromise<T> = T | Promise<T> export type Context<T extends Record<string, any> = Record<string, any>> = (...args: any[]) => T export interface Options { namespace?: string heartbeatCheck?: boolean heartbeatInterval?: number heartbeatTimeout?: number backup?: boolean } export interface Message { type: 'apply' | 'callback' | 'ping' | 'pong' id: string path: string[] sender: 'provide' | 'inject' callbackIds?: string[] args: any[] error?: string data?: any namespace: string timeStamp: number } export type OffMessage = () => MaybePromise<void> export type SendMessage<M extends Message = Message> = (message: M) => MaybePromise<void> export type OnMessage<M extends Message = Message> = ( callback: (message?: Partial<M>) => void ) => MaybePromise<OffMessage | void> export interface Adapter<M extends Message = Message> { sendMessage: SendMessage<M> onMessage: OnMessage<M> } const isInvalidMessage = (message?: Partial<Message>) => { return ( !message || !message.type || !message.id || !message.path || !message.sender || !message.args || !message.namespace || !message.timeStamp ) } const heartbeatCheck = async (adapter: Adapter, options: Required<Options>) => { let clearHeartbeatInterval: () => void let clearHeartbeatTimeout: () => void const offMessages = new Set<OffMessage>() const heartbeatInterval = new Promise<void>((resolve, reject) => { clearHeartbeatInterval = setIntervalImmediate(async () => { try { const messageId = uuid() const offMessage = await adapter.onMessage((message) => { if (isInvalidMessage(message)) return const _message = message as Message if (_message.namespace !== options.namespace) return if (_message.sender !== 'provide') return if (_message.type !== 'pong') return if (_message.id !== messageId) return resolve() }) offMessage && offMessages.add(offMessage) adapter.sendMessage({ type: 'ping', id: messageId, path: [], sender: 'inject', args: [], namespace: options.namespace, timeStamp: Date.now() }) } catch (error) { reject(error) } }, options.heartbeatInterval) }) const heartbeatTimeout = new Promise<void>((_, reject) => { const timer = setTimeout( () => reject(new Error(`Provider unavailable: heartbeat check timeout ${options.heartbeatTimeout}ms.`)), options.heartbeatTimeout ) clearHeartbeatTimeout = () => clearTimeout(timer) }) await Promise.race([heartbeatInterval, heartbeatTimeout]).finally(() => { clearHeartbeatInterval() clearHeartbeatTimeout() offMessages.forEach((offMessage) => offMessage()) offMessages.clear() }) } const createProvide = <T extends Record<string, any>>(target: T, adapter: Adapter, options: Required<Options>) => { adapter.onMessage(async (message) => { if (isInvalidMessage(message)) return const _message = message as Message if (_message.namespace !== options.namespace) return if (_message.sender !== 'inject') return switch (_message!.type) { case 'ping': { adapter.sendMessage({ ..._message!, type: 'pong', sender: 'provide', namespace: options.namespace, timeStamp: Date.now() }) break } case 'apply': { try { const mapArgs = _message.args?.map((arg) => { if (_message.callbackIds?.includes(arg)) { return (...args: any[]) => { adapter.sendMessage({ ..._message, id: arg, data: args, type: 'callback', sender: 'provide', namespace: options.namespace, timeStamp: Date.now() }) } } else { return arg } }) _message.data = await ( _message.path?.reduce((acc, key) => acc[key], target) as unknown as (...args: any[]) => any ).apply(target, mapArgs) } catch (error) { _message.error = (error as Error).message } adapter.sendMessage({ ..._message, type: 'apply', sender: 'provide', namespace: options.namespace, timeStamp: Date.now() }) break } } }) return target } const createInject = <T extends Record<string, any>>(source: T, adapter: Adapter, options: Required<Options>) => { const createProxy = (target: T, path: string[]) => { const proxy = new Proxy<T>(target, { get(_target, key: string) { return createProxy((() => {}) as unknown as T, [...path, key] as string[]) }, apply(_target, _thisArg, args) { return new Promise<Message>(async (resolve, reject) => { try { options.heartbeatCheck && (await heartbeatCheck(adapter, options)) const callbackIds: string[] = [] const mapArgs = args.map((arg) => { if (typeof arg === 'function') { const callbackId = uuid() callbackIds.push(callbackId) adapter.onMessage((message) => { if (isInvalidMessage(message)) return const _message = message as Message if (_message.namespace !== options.namespace) return if (_message.sender !== 'provide') return if (_message.type !== 'callback') return if (_message.id !== callbackId) return arg(..._message.data) }) return callbackId } else { return arg } }) const messageId = uuid() const offMessage = await adapter.onMessage((message) => { if (isInvalidMessage(message)) return const _message = message as Message if (_message.namespace !== options.namespace) return if (_message.sender !== 'provide') return if (_message.type !== 'apply') return if (_message.id !== messageId) return _message.error ? reject(new Error(_message.error)) : resolve(_message.data) offMessage?.() }) adapter.sendMessage({ type: 'apply', id: messageId, path, sender: 'inject', callbackIds, args: mapArgs, timeStamp: Date.now(), namespace: options.namespace }) } catch (error) { reject(error) } }) } }) return proxy } return createProxy(source, []) } const provideProxy = <T extends Context>(context: T, options: Required<Options>) => { let target: ReturnType<T> return <M extends Message = Message>(adapter: Adapter<M>, ...args: Parameters<T>) => (target ??= createProvide(context(...args) as ReturnType<T>, adapter as Adapter, options)) } const injectProxy = <T extends Context>(context: T, options: Required<Options>) => { let target: ReturnType<T> return <M extends Message = Message>(adapter: Adapter<M>) => (target ??= createInject<ReturnType<T>>( (options.backup ? Object.freeze(context()) : {}) as ReturnType<T>, adapter as Adapter, options )) } /** * Creates a pair of proxies for the provider (provide) and injector (inject) to facilitate method calls and callbacks across communication layers. * * @param context - A factory function for the context that returns the target object to be proxied: * - For the provider: This object directly handles remote calls. * - For the injector: When the backup option is enabled, it serves as a local fallback implementation. * @param options - Configuration options: * - namespace: The communication namespace used to isolate messages between different proxy instances (default is '__comctx__'). * - heartbeatCheck: Enable provider readiness check (default: true). * - heartbeatInterval: The frequency at which to request heartbeats in milliseconds (default: 300). * - heartbeatTimeout: Max wait time for heartbeat response in milliseconds (default: 1000). * - backup: Whether to use a backup implementation of the original object in the injector (default is false). * @returns Returns a tuple containing two elements: * - [0] provideProxy: Accepts an adapter and creates a provider proxy. * - [1] injectProxy: Accepts an adapter and creates an injector proxy. * * @example * const [provide, inject] = defineProxy(() => ({ * add: (a, b) => a + b * }), { namespace: 'math' }) * * // Provider * provide(providerAdapter) * * // Injector * const math = inject(injectorAdapter) * await math.add(2, 3) // 5 */ export const defineProxy = <T extends Context>(context: T, options?: Options) => { const mergedOptions = { namespace: options?.namespace ?? '__comctx__', heartbeatCheck: options?.heartbeatCheck ?? true, heartbeatInterval: options?.heartbeatInterval ?? 300, heartbeatTimeout: options?.heartbeatTimeout ?? 1000, backup: options?.backup ?? false } if (mergedOptions.heartbeatTimeout <= mergedOptions.heartbeatInterval) { throw new Error( `Invalid heartbeat config: timeout (${mergedOptions.heartbeatTimeout}ms) must exceed interval (${mergedOptions.heartbeatInterval}ms).` ) } return [provideProxy(context, mergedOptions), injectProxy(context, mergedOptions)] as const } export default defineProxy