swarpc 0.15.1 → 0.16.1

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