swarpc 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/client.ts CHANGED
@@ -3,16 +3,23 @@
3
3
  * @mergeModuleWith <project>
4
4
  */
5
5
 
6
- import { createLogger, type Logger, type LogLevel } from "./log.js"
6
+ import {
7
+ createLogger,
8
+ RequestBoundLogger,
9
+ type Logger,
10
+ type LogLevel,
11
+ } from "./log.js";
12
+ import { makeNodeId, whoToSendTo } from "./nodes.js";
7
13
  import {
8
14
  ClientMethod,
9
15
  Hooks,
10
16
  Payload,
11
17
  PayloadCore,
18
+ WorkerConstructor,
12
19
  zProcedures,
13
20
  type ProceduresMap,
14
- } from "./types.js"
15
- import { findTransferables } from "./utils.js"
21
+ } from "./types.js";
22
+ import { findTransferables } from "./utils.js";
16
23
 
17
24
  /**
18
25
  * The sw&rpc client instance, which provides {@link ClientMethod | methods to call procedures}.
@@ -20,38 +27,60 @@ import { findTransferables } from "./utils.js"
20
27
  * If you want to be able to cancel the request, you can set the request's ID yourself, and call `.abort(requestId, reason)` on the client instance to cancel it.
21
28
  */
22
29
  export type SwarpcClient<Procedures extends ProceduresMap> = {
23
- [zProcedures]: Procedures
30
+ [zProcedures]: Procedures;
24
31
  } & {
25
- [F in keyof Procedures]: ClientMethod<Procedures[F]>
26
- }
32
+ [F in keyof Procedures]: ClientMethod<Procedures[F]>;
33
+ };
34
+
35
+ /**
36
+ * Context for passing around data useful for requests
37
+ */
38
+ type Context<Procedures extends ProceduresMap> = {
39
+ /** A logger, bound to the client */
40
+ logger: Logger;
41
+ /** The node to use */
42
+ node: Worker | SharedWorker | undefined;
43
+ /** The ID of the node to use */
44
+ nodeId: string | undefined;
45
+ /** Hooks defined by the client */
46
+ hooks: Hooks<Procedures>;
47
+ /** Local storage data defined by the client for the faux local storage */
48
+ localStorage: Record<string, any>;
49
+ };
27
50
 
28
51
  /**
29
52
  * Pending requests are stored in a map, where the key is the request ID.
30
53
  * Each request has a set of handlers: resolve, reject, and onProgress.
31
54
  * This allows having a single listener for the client, and having multiple in-flight calls to the same procedure.
32
55
  */
33
- const pendingRequests = new Map<string, PendingRequest>()
34
- type PendingRequest = {
35
- functionName: string
36
- reject: (err: Error) => void
37
- onProgress: (progress: any) => void
38
- resolve: (result: any) => void
39
- }
56
+ const pendingRequests = new Map<string, PendingRequest>();
57
+ export type PendingRequest = {
58
+ /** ID of the node the request was sent to. udefined if running on a service worker */
59
+ nodeId?: string;
60
+ functionName: string;
61
+ reject: (err: Error) => void;
62
+ onProgress: (progress: any) => void;
63
+ resolve: (result: any) => void;
64
+ };
40
65
 
41
66
  // Have we started the client listener?
42
- let _clientListenerStarted = false
67
+ let _clientListenerStarted: Set<string> = new Set();
68
+
69
+ export type ClientOptions = Parameters<typeof Client>[1];
43
70
 
44
71
  /**
45
72
  *
46
73
  * @param procedures procedures the client will be able to call, see {@link ProceduresMap}
47
74
  * @param options various options
48
- * @param options.worker The instantiated worker object. If not provided, the client will use the service worker.
49
- * Example: `new Worker("./worker.js")`
75
+ * @param options.worker The worker class, **not instantiated**, or a path to the source code. If not provided, the client will use the service worker. If a string is provided, it'll instantiate a regular `Worker`, not a `SharedWorker`.
76
+ * Example: `"./worker.js"`
50
77
  * See {@link Worker} (used by both dedicated workers and service workers), {@link SharedWorker}, and
51
78
  * the different [worker types](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API#worker_types) that exist
52
79
  * @param options.hooks Hooks to run on messages received from the server. See {@link Hooks}
53
80
  * @param options.loglevel Maximum log level to use, defaults to "debug" (shows everything). "info" will not show debug messages, "warn" will only show warnings and errors, "error" will only show errors.
54
81
  * @param options.restartListener If true, will force the listener to restart even if it has already been started. You should probably leave this to false, unless you are testing and want to reset the client state.
82
+ * @param options.localStorage Define a in-memory localStorage with the given key-value pairs. Allows code called on the server to access localStorage (even though SharedWorkers don't have access to the browser's real localStorage)
83
+ * @param options.nodes the number of workers to use for the server, defaults to {@link navigator.hardwareConcurrency}.
55
84
  * @returns a sw&rpc client instance. Each property of the procedures map will be a method, that accepts an input and an optional onProgress callback, see {@link ClientMethod}
56
85
  *
57
86
  * An example of defining and using a client:
@@ -61,117 +90,179 @@ export function Client<Procedures extends ProceduresMap>(
61
90
  procedures: Procedures,
62
91
  {
63
92
  worker,
93
+ nodes: nodeCount,
64
94
  loglevel = "debug",
65
95
  restartListener = false,
66
96
  hooks = {},
97
+ localStorage = {},
67
98
  }: {
68
- worker?: Worker | SharedWorker
69
- hooks?: Hooks<Procedures>
70
- loglevel?: LogLevel
71
- restartListener?: boolean
72
- } = {}
99
+ worker?: WorkerConstructor | string;
100
+ nodes?: number;
101
+ hooks?: Hooks<Procedures>;
102
+ loglevel?: LogLevel;
103
+ restartListener?: boolean;
104
+ localStorage?: Record<string, any>;
105
+ } = {},
73
106
  ): SwarpcClient<Procedures> {
74
- const l = createLogger("client", loglevel)
107
+ const l = createLogger("client", loglevel);
75
108
 
76
- if (restartListener) _clientListenerStarted = false
109
+ if (restartListener) _clientListenerStarted.clear();
77
110
 
78
111
  // Store procedures on a symbol key, to avoid conflicts with procedure names
79
112
  const instance = { [zProcedures]: procedures } as Partial<
80
113
  SwarpcClient<Procedures>
81
- >
114
+ >;
115
+
116
+ nodeCount ??= navigator.hardwareConcurrency || 1;
117
+
118
+ let nodes: undefined | Record<string, Worker | SharedWorker>;
119
+ if (worker) {
120
+ nodes = {};
121
+ for (const _ of Array.from({ length: nodeCount })) {
122
+ const id = makeNodeId();
123
+ if (typeof worker === "string") {
124
+ nodes[id] = new Worker(worker, { name: id });
125
+ } else {
126
+ nodes[id] = new worker({ name: id });
127
+ }
128
+ }
129
+
130
+ l.info(
131
+ null,
132
+ `Started ${nodeCount} node${nodeCount > 1 ? "s" : ""}`,
133
+ Object.keys(nodes),
134
+ );
135
+ }
82
136
 
83
137
  for (const functionName of Object.keys(procedures) as Array<
84
138
  keyof Procedures
85
139
  >) {
86
140
  if (typeof functionName !== "string") {
87
141
  throw new Error(
88
- `[SWARPC Client] Invalid function name, don't use symbols`
89
- )
142
+ `[SWARPC Client] Invalid function name, don't use symbols`,
143
+ );
90
144
  }
91
145
 
92
146
  const send = async (
147
+ node: Worker | SharedWorker | undefined,
148
+ nodeId: string | undefined,
93
149
  requestId: string,
94
150
  msg: PayloadCore<Procedures, typeof functionName>,
95
- options?: StructuredSerializeOptions
151
+ options?: StructuredSerializeOptions,
96
152
  ) => {
97
- return postMessage(
98
- l,
99
- worker,
153
+ const ctx: Context<Procedures> = {
154
+ logger: l,
155
+ node,
156
+ nodeId,
100
157
  hooks,
158
+ localStorage,
159
+ };
160
+
161
+ return postMessage(
162
+ ctx,
101
163
  {
102
164
  ...msg,
103
165
  by: "sw&rpc",
104
166
  requestId,
105
167
  functionName,
106
168
  },
107
- options
108
- )
109
- }
169
+ options,
170
+ );
171
+ };
110
172
 
111
173
  // Set the method on the instance
112
174
  const _runProcedure = async (
113
175
  input: unknown,
114
176
  onProgress: (progress: unknown) => void | Promise<void> = () => {},
115
- reqid?: string
177
+ reqid?: string,
178
+ nodeId?: string,
116
179
  ) => {
117
180
  // Validate the input against the procedure's input schema
118
- procedures[functionName].input.assert(input)
181
+ procedures[functionName].input.assert(input);
182
+
183
+ const requestId = reqid ?? makeRequestId();
119
184
 
120
- const requestId = reqid ?? makeRequestId()
185
+ // Choose which node to use
186
+ nodeId ??= whoToSendTo(nodes, pendingRequests);
187
+ const node = nodes && nodeId ? nodes[nodeId] : undefined;
188
+
189
+ const l = createLogger("client", loglevel, nodeId ?? "(SW)", requestId);
121
190
 
122
191
  return new Promise((resolve, reject) => {
123
192
  // Store promise handlers (as well as progress updates handler)
124
193
  // so the client listener can resolve/reject the promise (and react to progress updates)
125
194
  // when the server sends messages back
126
195
  pendingRequests.set(requestId, {
196
+ nodeId,
127
197
  functionName,
128
198
  resolve,
129
199
  onProgress,
130
200
  reject,
131
- })
201
+ });
132
202
 
133
203
  const transfer =
134
204
  procedures[functionName].autotransfer === "always"
135
205
  ? findTransferables(input)
136
- : []
206
+ : [];
137
207
 
138
208
  // Post the message to the server
139
- l.debug(requestId, `Requesting ${functionName} with`, input)
140
- return send(requestId, { input }, { transfer })
209
+ l.debug(`Requesting ${functionName} with`, input);
210
+ return send(node, nodeId, requestId, { input }, { transfer })
141
211
  .then(() => {})
142
- .catch(reject)
143
- })
144
- }
212
+ .catch(reject);
213
+ });
214
+ };
145
215
 
146
216
  // @ts-expect-error
147
- instance[functionName] = _runProcedure
217
+ instance[functionName] = _runProcedure;
218
+ instance[functionName]!.broadcast = async (
219
+ input,
220
+ onProgress,
221
+ nodesCount,
222
+ ) => {
223
+ let nodesToUse: Array<string | undefined> = [undefined];
224
+ if (nodes) nodesToUse = Object.keys(nodes);
225
+ if (nodesCount) nodesToUse = nodesToUse.slice(0, nodesCount);
226
+
227
+ const results = await Promise.allSettled(
228
+ nodesToUse.map(async (id) =>
229
+ _runProcedure(input, onProgress, undefined, id),
230
+ ),
231
+ );
232
+
233
+ return results.map((r, i) => ({ ...r, node: nodesToUse[i] ?? "(SW)" }));
234
+ };
148
235
  instance[functionName]!.cancelable = (input, onProgress) => {
149
- const requestId = makeRequestId()
236
+ const requestId = makeRequestId();
237
+ const nodeId = whoToSendTo(nodes, pendingRequests);
238
+
239
+ const l = createLogger("client", loglevel, nodeId ?? "(SW)", requestId);
240
+
150
241
  return {
151
- request: _runProcedure(input, onProgress, requestId),
242
+ request: _runProcedure(input, onProgress, requestId, nodeId),
152
243
  cancel(reason: string) {
153
244
  if (!pendingRequests.has(requestId)) {
154
245
  l.warn(
155
246
  requestId,
156
- `Cannot cancel ${functionName} request, it has already been resolved or rejected`
157
- )
158
- return
247
+ `Cannot cancel ${functionName} request, it has already been resolved or rejected`,
248
+ );
249
+ return;
159
250
  }
160
251
 
161
- l.debug(requestId, `Cancelling ${functionName} with`, reason)
162
- postMessageSync(l, worker, {
252
+ l.debug(requestId, `Cancelling ${functionName} with`, reason);
253
+ postMessageSync(l, nodeId ? nodes?.[nodeId] : undefined, {
163
254
  by: "sw&rpc",
164
255
  requestId,
165
256
  functionName,
166
257
  abort: { reason },
167
- })
168
- pendingRequests.delete(requestId)
258
+ });
259
+ pendingRequests.delete(requestId);
169
260
  },
170
- }
171
- }
261
+ };
262
+ };
172
263
  }
173
264
 
174
- return instance as SwarpcClient<Procedures>
265
+ return instance as SwarpcClient<Procedures>;
175
266
  }
176
267
 
177
268
  /**
@@ -179,16 +270,16 @@ export function Client<Procedures extends ProceduresMap>(
179
270
  * @returns the worker to use
180
271
  */
181
272
  async function postMessage<Procedures extends ProceduresMap>(
182
- l: Logger,
183
- worker: Worker | SharedWorker | undefined,
184
- hooks: Hooks<Procedures>,
273
+ ctx: Context<Procedures>,
185
274
  message: Payload<Procedures>,
186
- options?: StructuredSerializeOptions
275
+ options?: StructuredSerializeOptions,
187
276
  ) {
188
- await startClientListener(l, worker, hooks)
277
+ await startClientListener(ctx);
278
+
279
+ const { logger: l, node: worker } = ctx;
189
280
 
190
281
  if (!worker && !navigator.serviceWorker.controller)
191
- l.warn("", "Service Worker is not controlling the page")
282
+ l.warn("", "Service Worker is not controlling the page");
192
283
 
193
284
  // If no worker is provided, we use the service worker
194
285
  const w =
@@ -196,13 +287,13 @@ async function postMessage<Procedures extends ProceduresMap>(
196
287
  ? worker.port
197
288
  : worker === undefined
198
289
  ? await navigator.serviceWorker.ready.then((r) => r.active)
199
- : worker
290
+ : worker;
200
291
 
201
292
  if (!w) {
202
- throw new Error("[SWARPC Client] No active service worker found")
293
+ throw new Error("[SWARPC Client] No active service worker found");
203
294
  }
204
295
 
205
- w.postMessage(message, options)
296
+ w.postMessage(message, options);
206
297
  }
207
298
 
208
299
  /**
@@ -214,13 +305,13 @@ async function postMessage<Procedures extends ProceduresMap>(
214
305
  * @param options
215
306
  */
216
307
  export function postMessageSync<Procedures extends ProceduresMap>(
217
- l: Logger,
308
+ l: RequestBoundLogger,
218
309
  worker: Worker | SharedWorker | undefined,
219
310
  message: Payload<Procedures>,
220
- options?: StructuredSerializeOptions
311
+ options?: StructuredSerializeOptions,
221
312
  ): void {
222
313
  if (!worker && !navigator.serviceWorker.controller)
223
- l.warn("", "Service Worker is not controlling the page")
314
+ l.warn("Service Worker is not controlling the page");
224
315
 
225
316
  // If no worker is provided, we use the service worker
226
317
  const w =
@@ -228,91 +319,105 @@ export function postMessageSync<Procedures extends ProceduresMap>(
228
319
  ? worker.port
229
320
  : worker === undefined
230
321
  ? navigator.serviceWorker.controller
231
- : worker
322
+ : worker;
232
323
 
233
324
  if (!w) {
234
- throw new Error("[SWARPC Client] No active service worker found")
325
+ throw new Error("[SWARPC Client] No active service worker found");
235
326
  }
236
327
 
237
- w.postMessage(message, options)
328
+ w.postMessage(message, options);
238
329
  }
239
330
 
240
331
  /**
241
332
  * Starts the client listener, which listens for messages from the sw&rpc server.
242
- * @param worker if provided, the client will use this worker to listen for messages, instead of using the service worker
243
- * @param force if true, will force the listener to restart even if it has already been started
333
+ * @param ctx.worker if provided, the client will use this worker to listen for messages, instead of using the service worker
244
334
  * @returns
245
335
  */
246
336
  export async function startClientListener<Procedures extends ProceduresMap>(
247
- l: Logger,
248
- worker?: Worker | SharedWorker,
249
- hooks: Hooks<Procedures> = {}
337
+ ctx: Context<Procedures>,
250
338
  ) {
251
- if (_clientListenerStarted) return
339
+ if (_clientListenerStarted.has(ctx.nodeId ?? "(SW)")) return;
340
+
341
+ const { logger: l, node: worker } = ctx;
252
342
 
253
343
  // Get service worker registration if no worker is provided
254
344
  if (!worker) {
255
- const sw = await navigator.serviceWorker.ready
345
+ const sw = await navigator.serviceWorker.ready;
256
346
  if (!sw?.active) {
257
- throw new Error("[SWARPC Client] Service Worker is not active")
347
+ throw new Error("[SWARPC Client] Service Worker is not active");
258
348
  }
259
349
 
260
350
  if (!navigator.serviceWorker.controller) {
261
- l.warn("", "Service Worker is not controlling the page")
351
+ l.warn("", "Service Worker is not controlling the page");
262
352
  }
263
353
  }
264
354
 
265
- const w = worker ?? navigator.serviceWorker
355
+ const w = worker ?? navigator.serviceWorker;
266
356
 
267
357
  // Start listening for messages
268
- l.debug(null, "Starting client listener", { worker, w, hooks })
358
+ l.debug(null, "Starting client listener", { w, ...ctx });
269
359
  const listener = (event: Event): void => {
270
360
  // Get the data from the event
271
- const eventData = (event as MessageEvent).data || {}
361
+ const eventData = (event as MessageEvent).data || {};
272
362
 
273
363
  // Ignore other messages that aren't for us
274
- if (eventData?.by !== "sw&rpc") return
364
+ if (eventData?.by !== "sw&rpc") return;
275
365
 
276
366
  // We don't use a arktype schema here, we trust the server to send valid data
277
- const { requestId, ...data } = eventData as Payload<Procedures>
367
+ const payload = eventData as Payload<Procedures>;
368
+
369
+ // Ignore #initialize request, it's client->server only
370
+ if ("localStorageData" in payload) {
371
+ l.warn(null, "Ignoring unexpected #initialize from server", payload);
372
+ return;
373
+ }
374
+
375
+ const { requestId, ...data } = payload;
278
376
 
279
377
  // Sanity check in case we somehow receive a message without requestId
280
378
  if (!requestId) {
281
- throw new Error("[SWARPC Client] Message received without requestId")
379
+ throw new Error("[SWARPC Client] Message received without requestId");
282
380
  }
283
381
 
284
382
  // Get the associated pending request handlers
285
- const handlers = pendingRequests.get(requestId)
383
+ const handlers = pendingRequests.get(requestId);
286
384
  if (!handlers) {
287
385
  throw new Error(
288
- `[SWARPC Client] ${requestId} has no active request handlers, cannot process ${JSON.stringify(data)}`
289
- )
386
+ `[SWARPC Client] ${requestId} has no active request handlers, cannot process ${JSON.stringify(data)}`,
387
+ );
290
388
  }
291
389
 
292
390
  // React to the data received: call hook, call handler,
293
391
  // and remove the request from pendingRequests (unless it's a progress update)
294
392
  if ("error" in data) {
295
- hooks.error?.(data.functionName, new Error(data.error.message))
296
- handlers.reject(new Error(data.error.message))
297
- pendingRequests.delete(requestId)
393
+ ctx.hooks.error?.(data.functionName, new Error(data.error.message));
394
+ handlers.reject(new Error(data.error.message));
395
+ pendingRequests.delete(requestId);
298
396
  } else if ("progress" in data) {
299
- hooks.progress?.(data.functionName, data.progress)
300
- handlers.onProgress(data.progress)
397
+ ctx.hooks.progress?.(data.functionName, data.progress);
398
+ handlers.onProgress(data.progress);
301
399
  } else if ("result" in data) {
302
- hooks.success?.(data.functionName, data.result)
303
- handlers.resolve(data.result)
304
- pendingRequests.delete(requestId)
400
+ ctx.hooks.success?.(data.functionName, data.result);
401
+ handlers.resolve(data.result);
402
+ pendingRequests.delete(requestId);
305
403
  }
306
- }
404
+ };
307
405
 
308
406
  if (w instanceof SharedWorker) {
309
- w.port.addEventListener("message", listener)
310
- w.port.start()
407
+ w.port.addEventListener("message", listener);
408
+ w.port.start();
311
409
  } else {
312
- w.addEventListener("message", listener)
410
+ w.addEventListener("message", listener);
313
411
  }
314
412
 
315
- _clientListenerStarted = true
413
+ _clientListenerStarted.add(ctx.nodeId ?? "(SW)");
414
+
415
+ // Recursive terminal case is ensured by calling this *after* _clientListenerStarted is set to true: startClientListener() will therefore not be called in postMessage() again.
416
+ await postMessage(ctx, {
417
+ by: "sw&rpc",
418
+ functionName: "#initialize",
419
+ localStorageData: ctx.localStorage,
420
+ });
316
421
  }
317
422
 
318
423
  /**
@@ -321,5 +426,5 @@ export async function startClientListener<Procedures extends ProceduresMap>(
321
426
  * @returns a 6-character hexadecimal string
322
427
  */
323
428
  export function makeRequestId(): string {
324
- return Math.random().toString(16).substring(2, 8).toUpperCase()
429
+ return Math.random().toString(16).substring(2, 8).toUpperCase();
325
430
  }
package/src/index.ts CHANGED
@@ -1,8 +1,9 @@
1
- /**
2
- * @module
3
- * @mergeModuleWith <project>
4
- */
5
-
6
- export * from "./client.js"
7
- export * from "./server.js"
8
- export type { ProceduresMap, CancelablePromise } from "./types.js"
1
+ /**
2
+ * @module
3
+ * @mergeModuleWith <project>
4
+ */
5
+
6
+ import "./polyfills.js";
7
+ export * from "./client.js";
8
+ export * from "./server.js";
9
+ export type { ProceduresMap, CancelablePromise } from "./types.js";
@@ -0,0 +1,46 @@
1
+ export class FauxLocalStorage {
2
+ data: Record<string, any>;
3
+ keysOrder: string[];
4
+
5
+ constructor(data: Record<string, any>) {
6
+ this.data = data;
7
+ this.keysOrder = Object.keys(data);
8
+ }
9
+
10
+ setItem(key: string, value: string) {
11
+ if (!this.hasItem(key)) this.keysOrder.push(key);
12
+ this.data[key] = value;
13
+ }
14
+
15
+ getItem(key: string) {
16
+ return this.data[key];
17
+ }
18
+
19
+ hasItem(key: string) {
20
+ return Object.hasOwn(this.data, key);
21
+ }
22
+
23
+ removeItem(key: string) {
24
+ if (!this.hasItem(key)) return;
25
+ delete this.data[key];
26
+ this.keysOrder = this.keysOrder.filter((k) => k !== key);
27
+ }
28
+
29
+ clear() {
30
+ this.data = {};
31
+ this.keysOrder = [];
32
+ }
33
+
34
+ key(index: number) {
35
+ return this.keysOrder[index];
36
+ }
37
+
38
+ get length() {
39
+ return this.keysOrder.length;
40
+ }
41
+
42
+ register(subject: WorkerGlobalScope | SharedWorkerGlobalScope) {
43
+ // @ts-expect-error
44
+ subject.localStorage = this;
45
+ }
46
+ }