๐ src/downloader/client/worker-client.ts
D-OPEN SOVEREIGN
Documentation & Insights
Remove a handler previously added via listen() โ required for one-shot listeners.Send an RPC command and await the FileRpcResponse for the given key.1/*
2 * Copyright (c) 2026 DataPond D-Library Pty Ltd. ("The Sanctuary")
3 * Non-Profit Public Library โ The World Library
4 *
5 * D-SAFE Certification issued by POND Enterprise.
6 * Published under the D-Open Code Sovereign Licence v1.0 framework
7 * DataPond D-Library All exclusive Right reserved on modifiying the code - CC Attribution to data pond, Non modifiable, Non Commercial.
8 * https://registry.world.bibliotech.com/licence
9 * Source code donated to DataPond D-Library by it's author: data pond.
10 *
11 * Technical Guardian ("The Shield"): Pond Enterprise Pty Ltd. (ACN 694 747 987)
12 * All liability claims about D-SAFE certification issues coming from the published D-Safe direction must be addressed with Pond Enterprise Pty Ltd.
13 * Code Modification automatically voids the certified liability protection provided by Pond Enterprise.
14 */
15// Thread RPC abstraction โ hides Dedicated vs Shared worker from callers (ยง13.3).
16import type { WorkerCommand, WorkerMessage } from '../worker-protocol';
17
18type MessageHandler = (msg: WorkerMessage) => void;
19
20export type RpcResult = { ok: boolean; key: string; error?: string };
21
22// Reject an rpc() that never gets its FileRpcResponse (worker died / message lost).
23const RPC_TIMEOUT_MS = 90_000;
24
25export interface Thread {
26 // Accepts WorkerCommand or MuWorkerCommand โ worker boundaries are untyped at runtime.
27 send(cmd: unknown): void;
28 listen(cb: MessageHandler): void;
29 /** Remove a handler previously added via listen() โ required for one-shot listeners. */
30 unlisten(cb: MessageHandler): void;
31 /** Send an RPC command and await the FileRpcResponse for the given key. */
32 rpc(cmd: WorkerCommand & { txId: string }): Promise<RpcResult>;
33}
34
35// Shared dispatch/rpc bookkeeping for both worker transports below โ only how a
36// command is actually sent (and how the underlying worker/port is constructed)
37// differs between them.
38abstract class BaseThread implements Thread {
39 protected handlers: MessageHandler[] = [];
40 protected pending: Map<string, Array<(r: RpcResult) => void>> = new Map();
41
42 abstract send(cmd: unknown): void;
43
44 listen(cb: MessageHandler): void {
45 this.handlers.push(cb);
46 }
47
48 unlisten(cb: MessageHandler): void {
49 this.handlers = this.handlers.filter(h => h !== cb);
50 }
51
52 rpc(cmd: WorkerCommand & { txId: string }): Promise<RpcResult> {
53 return rpcWithTimeout(this.pending, cmd, (c) => this.send(c));
54 }
55
56 protected _dispatch(msg: WorkerMessage): void {
57 if (msg.type === 'FileRpcResponse') {
58 // Route to matching pending RPC resolver.
59 const list = this.pending.get(msg.key);
60 if (list && list.length > 0) {
61 const resolver = list.shift()!;
62 if (list.length === 0) this.pending.delete(msg.key);
63 resolver({ ok: msg.ok, key: msg.key, error: msg.error });
64 }
65 return;
66 }
67 for (const h of this.handlers) h(msg);
68 }
69}
70
71// Phase A: one dedicated Worker per tab.
72export class DedicatedThread extends BaseThread {
73 private w: Worker;
74
75 constructor(scriptUrl: string) {
76 super();
77 this.w = new Worker(scriptUrl, { type: 'module' });
78 this.w.onmessage = (e: MessageEvent<WorkerMessage>) => this._dispatch(e.data);
79 this.w.onerror = (e) => console.error('[DedicatedThread] worker error', e);
80 }
81
82 send(cmd: unknown): void {
83 this.w.postMessage(cmd);
84 }
85}
86
87// Distinguishes concurrent rpc()s for the same txId but different commands
88// (e.g. GetFile pdf vs. GetFile preview for the same book) โ without this,
89// FileRpcResponse.key === txId alone would let a preview's response resolve a
90// pending PDF rpc() call (or vice versa) if they complete out of arrival order.
91// Must match the `key` the worker echoes back in FileRpcResponse โ see
92// file-worker.ts's handleGetFile().
93export function rpcRequestKey(cmd: WorkerCommand & { txId: string }): string {
94 return 'mime' in cmd ? `${cmd.txId}:${cmd.mime}` : cmd.txId;
95}
96
97// Shared rpc implementation: resolves on FileRpcResponse routed by key ===
98// rpcRequestKey(cmd), rejects after RPC_TIMEOUT_MS so callers never hang
99// forever on a lost message.
100function rpcWithTimeout(
101 pending: Map<string, Array<(r: RpcResult) => void>>,
102 cmd: WorkerCommand & { txId: string },
103 send: (cmd: unknown) => void,
104): Promise<RpcResult> {
105 return new Promise((resolve, reject) => {
106 const key = rpcRequestKey(cmd);
107 const list = pending.get(key) ?? [];
108
109 const settle = (r: RpcResult) => {
110 clearTimeout(timer);
111 resolve(r);
112 };
113 const timer = setTimeout(() => {
114 const l = pending.get(key);
115 if (l) {
116 const i = l.indexOf(settle);
117 if (i >= 0) l.splice(i, 1);
118 if (l.length === 0) pending.delete(key);
119 }
120 reject(new Error(`[worker-client] rpc timeout after ${RPC_TIMEOUT_MS}ms for ${key}`));
121 }, RPC_TIMEOUT_MS);
122
123 list.push(settle);
124 pending.set(key, list);
125 send(cmd);
126 });
127}
128
129// Phase C stub โ SharedWorker via MessagePort.
130export class SharedThread extends BaseThread {
131 private sw: SharedWorker;
132
133 constructor(scriptUrl: string) {
134 super();
135 this.sw = new SharedWorker(scriptUrl, { type: 'module' });
136 this.sw.port.start();
137 this.sw.port.onmessage = (e: MessageEvent<WorkerMessage>) => this._dispatch(e.data);
138 }
139
140 send(cmd: unknown): void {
141 this.sw.port.postMessage(cmd);
142 }
143}
144
145// Phase A always returns DedicatedThread (ยง13.10).
146export function createFileThread(scriptUrl = '/file-worker-v2.js'): Thread {
147 return new DedicatedThread(scriptUrl);
148}
149
150export function createMuThread(scriptUrl = '/mu-worker-v2.js'): Thread {
151 // Always dedicated โ MuPDF rendering should be isolated per tab (ยง13.10).
152 return new DedicatedThread(scriptUrl);
153}
154