forked from
tokono.ma/diffuse
A music player that connects to your cloud/distributed storage.
1/**
2 * @import {MessengerRealm, ProxiedActions} from "../worker.d.ts"
3 */
4
5/**
6 * Lightweight RPC channel over postMessage using structured clone.
7 *
8 * Protocol:
9 * - Request: { __rpc: true, id, method, args, type: "request" }
10 * - Response: { __rpc: true, id, type: "response", result } or { ..., error }
11 *
12 * @template {Record<string, (...args: any[]) => any>} LocalAPI
13 * @template {Record<string, (...args: any[]) => any>} RemoteAPI
14 */
15export class RpcChannel {
16 /** @type {LocalAPI | undefined} */
17 #actions;
18
19 /** @type {Record<string, { resolve: (v: any) => void, reject: (e: any) => void }>} */
20 #pending = {};
21
22 #port;
23
24 /** @type {(event: MessageEvent) => void} */
25 #listener;
26
27 /**
28 * @param {MessagePort | Worker | MessengerRealm} port
29 * @param {{ expose?: LocalAPI }} [options]
30 */
31 constructor(port, options) {
32 this.#port = port;
33 this.#actions = options?.expose;
34
35 this.#listener = (/** @type {MessageEvent} */ event) => {
36 const msg = event.data;
37 if (!msg || msg.__rpc !== true) return;
38
39 if (msg.type === "request") {
40 if (this.#actions) this.#handleRequest(msg);
41 } else if (msg.type === "response") {
42 this.#handleResponse(msg);
43 }
44 };
45
46 port.addEventListener(
47 "message",
48 /** @type {EventListener} */ (this.#listener),
49 );
50 }
51
52 /**
53 * @param {LocalAPI} actions
54 */
55 expose(actions) {
56 this.#actions = actions;
57 }
58
59 /**
60 * @template {keyof RemoteAPI & string} M
61 * @param {M} method
62 * @param {any[]} args
63 * @returns {Promise<any>}
64 */
65 callMethod(method, args) {
66 return new Promise((resolve, reject) => {
67 const id = crypto.randomUUID();
68 this.#pending[id] = { resolve, reject };
69 this.#port.postMessage({
70 __rpc: true,
71 id,
72 method,
73 args,
74 type: "request",
75 });
76 });
77 }
78
79 /**
80 * @returns {ProxiedActions<RemoteAPI>}
81 */
82 getAPI() {
83 return /** @type {ProxiedActions<RemoteAPI>} */ (
84 new Proxy(/** @type {any} */ ({}), {
85 get: (_target, /** @type {string} */ prop) => {
86 return (/** @type {any[]} */ ...args) => this.callMethod(prop, args);
87 },
88 })
89 );
90 }
91
92 destroy() {
93 this.#port.removeEventListener(
94 "message",
95 /** @type {EventListener} */ (this.#listener),
96 );
97
98 if ("close" in this.#port && typeof this.#port.close === "function") {
99 this.#port.close();
100 }
101
102 if (
103 "terminate" in this.#port && typeof this.#port.terminate === "function"
104 ) {
105 /** @type {any} */ (this.#port).terminate();
106 }
107
108 // Reject all pending requests
109 for (const [id, { reject }] of Object.entries(this.#pending)) {
110 reject(new Error("RPC channel destroyed"));
111 delete this.#pending[id];
112 }
113 }
114
115 /**
116 * @param {{ id: string, method: string, args: any[] }} msg
117 */
118 #handleRequest(msg) {
119 const { id, method, args } = msg;
120 const fn = this.#actions?.[/** @type {keyof LocalAPI} */ (method)];
121
122 if (typeof fn !== "function") {
123 this.#port.postMessage({
124 __rpc: true,
125 id,
126 type: "response",
127 error: `Method "${method}" not found`,
128 });
129 return;
130 }
131
132 try {
133 Promise.resolve(fn(...args)).then(
134 (result) => {
135 this.#port.postMessage({ __rpc: true, id, type: "response", result });
136 },
137 (err) => {
138 console.error(err);
139 this.#port.postMessage({
140 __rpc: true,
141 id,
142 type: "response",
143 error: err?.message ?? String(err),
144 });
145 },
146 );
147 } catch (err) {
148 console.error(err);
149 this.#port.postMessage({
150 __rpc: true,
151 id,
152 type: "response",
153 error: /** @type {Error} */ (err)?.message ?? String(err),
154 });
155 }
156 }
157
158 /**
159 * @param {{ id: string, result?: any, error?: string }} msg
160 */
161 #handleResponse(msg) {
162 const pending = this.#pending[msg.id];
163 if (!pending) return;
164 delete this.#pending[msg.id];
165
166 if (msg.error !== undefined) {
167 pending.reject(new Error(msg.error));
168 } else {
169 pending.resolve(msg.result);
170 }
171 }
172}