UNPKG

@noxfly/noxus

Version:

Simulate lightweight HTTP-like requests between renderer and main process in Electron applications with MessagePort, with structured and modular design.

339 lines (261 loc) 10.5 kB
/** * @copyright 2025 NoxFly * @license MIT * @author NoxFly */ import { IBatchRequestItem, IBatchResponsePayload, IRequest, IResponse } from 'src/request'; import { RendererEventRegistry } from 'src/renderer-events'; export interface IPortRequester { requestPort(): void; } export interface RendererClientOptions { bridge?: IPortRequester | null; bridgeName?: string | string[]; initMessageType?: string; windowRef?: Window; generateRequestId?: () => string; } interface PendingRequest<T = unknown> { resolve: (value: T) => void; reject: (reason: IResponse<T>) => void; request: IRequest; submittedAt: number; } const DEFAULT_INIT_EVENT = 'init-port'; const DEFAULT_BRIDGE_NAMES = ['noxus', 'ipcRenderer']; function defaultRequestId(): string { if(typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { return crypto.randomUUID(); } return `${Date.now().toString(16)}-${Math.floor(Math.random() * 1e8).toString(16)}`; } function normalizeBridgeNames(preferred?: string | string[]): string[] { const names: string[] = []; const add = (name: string | undefined): void => { if(!name) return; if(!names.includes(name)) { names.push(name); } }; if(Array.isArray(preferred)) { for(const name of preferred) { add(name); } } else { add(preferred); } for(const fallback of DEFAULT_BRIDGE_NAMES) { add(fallback); } return names; } function resolveBridgeFromWindow(windowRef: Window, preferred?: string | string[]): IPortRequester | null { const names = normalizeBridgeNames(preferred); const globalRef = windowRef as unknown as Record<string, unknown> | null | undefined; if(!globalRef) { return null; } for(const name of names) { const candidate = globalRef[name]; if(candidate && typeof (candidate as IPortRequester).requestPort === 'function') { return candidate as IPortRequester; } } return null; } export class NoxRendererClient { public readonly events = new RendererEventRegistry(); protected readonly pendingRequests = new Map<string, PendingRequest>(); protected requestPort: MessagePort | undefined; protected socketPort: MessagePort | undefined; protected senderId: number | undefined; private readonly bridge: IPortRequester | null; private readonly initMessageType: string; private readonly windowRef: Window; private readonly generateRequestId: () => string; private isReady = false; private setupPromise: Promise<void> | undefined; private setupResolve: (() => void) | undefined; private setupReject: ((reason: Error) => void) | undefined; constructor(options: RendererClientOptions = {}) { this.windowRef = options.windowRef ?? window; const resolvedBridge = options.bridge ?? resolveBridgeFromWindow(this.windowRef, options.bridgeName); this.bridge = resolvedBridge ?? null; this.initMessageType = options.initMessageType ?? DEFAULT_INIT_EVENT; this.generateRequestId = options.generateRequestId ?? defaultRequestId; } public async setup(): Promise<void> { if(this.isReady) { return Promise.resolve(); } if(this.setupPromise) { return this.setupPromise; } if(!this.bridge || typeof this.bridge.requestPort !== 'function') { throw new Error('[Noxus] Renderer bridge is missing requestPort().'); } this.setupPromise = new Promise<void>((resolve, reject) => { this.setupResolve = resolve; this.setupReject = reject; }); this.windowRef.addEventListener('message', this.onWindowMessage); this.bridge.requestPort(); return this.setupPromise; } public dispose(): void { this.windowRef.removeEventListener('message', this.onWindowMessage); this.requestPort?.close(); this.socketPort?.close(); this.requestPort = undefined; this.socketPort = undefined; this.senderId = undefined; this.isReady = false; this.pendingRequests.clear(); } public async request<TResponse, TBody = unknown>(request: Omit<IRequest<TBody>, 'requestId' | 'senderId'>): Promise<TResponse> { const senderId = this.senderId; const requestId = this.generateRequestId(); if(senderId === undefined) { return Promise.reject(this.createErrorResponse(requestId, 'MessagePort is not available')); } const readinessError = this.validateReady(requestId); if(readinessError) { return Promise.reject(readinessError as IResponse<TResponse>); } const message: IRequest<TBody> = { requestId, senderId, ...request, }; return new Promise<TResponse>((resolve, reject) => { const pending: PendingRequest<TResponse> = { resolve, reject: (response: IResponse<TResponse>) => reject(response), request: message, submittedAt: Date.now(), }; this.pendingRequests.set(message.requestId, pending as PendingRequest); this.requestPort!.postMessage(message); }); } public async batch(requests: Omit<IBatchRequestItem<unknown>, 'requestId'>[]): Promise<IBatchResponsePayload> { return this.request<IBatchResponsePayload>({ method: 'BATCH', path: '', body: { requests, }, }); } public getSenderId(): number | undefined { return this.senderId; } private readonly onWindowMessage = (event: MessageEvent): void => { if(event.data?.type !== this.initMessageType) { return; } if(!Array.isArray(event.ports) || event.ports.length < 2) { const error = new Error('[Noxus] Renderer expected two MessagePorts (request + socket).'); console.error(error); this.setupReject?.(error); this.resetSetupState(); return; } this.windowRef.removeEventListener('message', this.onWindowMessage); this.requestPort = event.ports[0]; this.socketPort = event.ports[1]; this.senderId = event.data.senderId; if(this.requestPort === undefined || this.socketPort === undefined) { const error = new Error('[Noxus] Renderer failed to receive valid MessagePorts.'); console.error(error); this.setupReject?.(error); this.resetSetupState(); return; } this.attachRequestPort(this.requestPort); this.attachSocketPort(this.socketPort); this.isReady = true; this.setupResolve?.(); this.resetSetupState(true); }; private readonly onSocketMessage = (event: MessageEvent): void => { if(this.events.tryDispatchFromMessageEvent(event)) { return; } console.warn('[Noxus] Received a socket message that is not a renderer event payload.', event.data); }; private readonly onRequestMessage = (event: MessageEvent): void => { if(this.events.tryDispatchFromMessageEvent(event)) { return; } const response: IResponse = event.data; if(!response || typeof response.requestId !== 'string') { console.error('[Noxus] Renderer received an invalid response payload.', response); return; } const pending = this.pendingRequests.get(response.requestId); if(!pending) { console.error(`[Noxus] No pending handler found for request ${response.requestId}.`); return; } this.pendingRequests.delete(response.requestId); this.onRequestCompleted(pending, response); if(response.status >= 400) { pending.reject(response as IResponse<any>); return; } pending.resolve(response.body as unknown); }; protected onRequestCompleted(pending: PendingRequest, response: IResponse): void { if(typeof console.groupCollapsed === 'function') { console.groupCollapsed(`${response.status} ${pending.request.method} /${pending.request.path}`); } if(response.error) { console.error('error message:', response.error); } if(response.body !== undefined) { console.info('response:', response.body); } console.info('request:', pending.request); console.info(`Request duration: ${Date.now() - pending.submittedAt} ms`); if(typeof console.groupCollapsed === 'function') { console.groupEnd(); } } private attachRequestPort(port: MessagePort): void { port.onmessage = this.onRequestMessage; port.start(); } private attachSocketPort(port: MessagePort): void { port.onmessage = this.onSocketMessage; port.start(); } private validateReady(requestId: string): IResponse | undefined { if(!this.isElectronEnvironment()) { return this.createErrorResponse(requestId, 'Not running in Electron environment'); } if(!this.requestPort) { return this.createErrorResponse(requestId, 'MessagePort is not available'); } return undefined; } private createErrorResponse<T>(requestId: string, message: string): IResponse<T> { return { status: 500, requestId, error: message, }; } private resetSetupState(success = false): void { if(!success) { this.setupPromise = undefined; } this.setupResolve = undefined; this.setupReject = undefined; } public isElectronEnvironment(): boolean { return typeof window !== 'undefined' && /Electron/.test(window.navigator.userAgent); } }