๐Ÿ“„ 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