swarpc 0.4.0 → 0.6.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/dist/swarpc.d.ts CHANGED
@@ -1,9 +1,25 @@
1
- import { type ProceduresMap, type SwarpcClient, type SwarpcServer } from "./types.js";
1
+ import { Hooks, type ProceduresMap, type SwarpcClient, type SwarpcServer } from "./types.js";
2
2
  export type { ProceduresMap, SwarpcClient, SwarpcServer } from "./types.js";
3
+ /**
4
+ * Creates a sw&rpc server instance.
5
+ * @param procedures procedures the server will implement
6
+ * @param param1 various options
7
+ * @param param1.worker if provided, the server will use this worker to post messages, instead of sending it to all clients
8
+ * @returns a SwarpcServer instance. Each property of the procedures map will be a method, that accepts a function implementing the procedure. There is also .start(), to be called after implementing all procedures.
9
+ */
3
10
  export declare function Server<Procedures extends ProceduresMap>(procedures: Procedures, { worker }?: {
4
11
  worker?: Worker;
5
12
  }): SwarpcServer<Procedures>;
6
- export declare function Client<Procedures extends ProceduresMap>(procedures: Procedures, { worker }?: {
13
+ /**
14
+ *
15
+ * @param procedures procedures the client will be able to call
16
+ * @param param1 various options
17
+ * @param param1.worker if provided, the client will use this worker to post messages.
18
+ * @param param1.hooks hooks to run on messages received from the server
19
+ * @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.
20
+ */
21
+ export declare function Client<Procedures extends ProceduresMap>(procedures: Procedures, { worker, hooks }?: {
7
22
  worker?: Worker;
23
+ hooks?: Hooks<Procedures>;
8
24
  }): SwarpcClient<Procedures>;
9
25
  //# sourceMappingURL=swarpc.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"swarpc.d.ts","sourceRoot":"","sources":["../src/swarpc.ts"],"names":[],"mappings":"AACA,OAAO,EAIL,KAAK,aAAa,EAClB,KAAK,YAAY,EACjB,KAAK,YAAY,EAClB,MAAM,YAAY,CAAA;AAEnB,YAAY,EAAE,aAAa,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,YAAY,CAAA;AAE3E,wBAAgB,MAAM,CAAC,UAAU,SAAS,aAAa,EACrD,UAAU,EAAE,UAAU,EACtB,EAAE,MAAM,EAAE,GAAE;IAAE,MAAM,CAAC,EAAE,MAAM,CAAA;CAAO,GACnC,YAAY,CAAC,UAAU,CAAC,CA+E1B;AA4DD,wBAAgB,MAAM,CAAC,UAAU,SAAS,aAAa,EACrD,UAAU,EAAE,UAAU,EACtB,EAAE,MAAM,EAAE,GAAE;IAAE,MAAM,CAAC,EAAE,MAAM,CAAA;CAAO,GACnC,YAAY,CAAC,UAAU,CAAC,CAyC1B"}
1
+ {"version":3,"file":"swarpc.d.ts","sourceRoot":"","sources":["../src/swarpc.ts"],"names":[],"mappings":"AACA,OAAO,EACL,KAAK,EAML,KAAK,aAAa,EAClB,KAAK,YAAY,EACjB,KAAK,YAAY,EAClB,MAAM,YAAY,CAAA;AAGnB,YAAY,EAAE,aAAa,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,YAAY,CAAA;AAE3E;;;;;;GAMG;AACH,wBAAgB,MAAM,CAAC,UAAU,SAAS,aAAa,EACrD,UAAU,EAAE,UAAU,EACtB,EAAE,MAAM,EAAE,GAAE;IAAE,MAAM,CAAC,EAAE,MAAM,CAAA;CAAO,GACnC,YAAY,CAAC,UAAU,CAAC,CAuG1B;AA+FD;;;;;;;GAOG;AACH,wBAAgB,MAAM,CAAC,UAAU,SAAS,aAAa,EACrD,UAAU,EAAE,UAAU,EACtB,EAAE,MAAM,EAAE,KAAU,EAAE,GAAE;IAAE,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,KAAK,CAAC,UAAU,CAAC,CAAA;CAAO,GAC1E,YAAY,CAAC,UAAU,CAAC,CA0D1B"}
package/dist/swarpc.js CHANGED
@@ -1,11 +1,23 @@
1
1
  import { type } from "arktype";
2
2
  import { zImplementations, zProcedures, } from "./types.js";
3
+ import { findTransferables } from "./utils.js";
4
+ /**
5
+ * Creates a sw&rpc server instance.
6
+ * @param procedures procedures the server will implement
7
+ * @param param1 various options
8
+ * @param param1.worker if provided, the server will use this worker to post messages, instead of sending it to all clients
9
+ * @returns a SwarpcServer instance. Each property of the procedures map will be a method, that accepts a function implementing the procedure. There is also .start(), to be called after implementing all procedures.
10
+ */
3
11
  export function Server(procedures, { worker } = {}) {
12
+ // Initialize the instance.
13
+ // Procedures and implementations are stored on properties with symbol keys,
14
+ // to avoid any conflicts with procedure names, and also discourage direct access to them.
4
15
  const instance = {
5
16
  [zProcedures]: procedures,
6
17
  [zImplementations]: {},
7
18
  start: (self) => { },
8
19
  };
20
+ // Set all implementation-setter methods
9
21
  for (const functionName in procedures) {
10
22
  instance[functionName] = ((implementation) => {
11
23
  if (!instance[zProcedures][functionName]) {
@@ -14,61 +26,95 @@ export function Server(procedures, { worker } = {}) {
14
26
  instance[zImplementations][functionName] = implementation;
15
27
  });
16
28
  }
29
+ // Define payload schema for incoming messages
17
30
  const PayloadSchema = type.or(...Object.entries(procedures).map(([functionName, { input }]) => ({
18
31
  functionName: type(`"${functionName}"`),
19
32
  requestId: type("string >= 1"),
20
33
  input,
21
34
  })));
22
35
  instance.start = (self) => {
36
+ // Used to post messages back to the client
23
37
  const postMessage = async (data) => {
38
+ const transfer = data.autotransfer === "never" ? [] : findTransferables(data);
24
39
  if (worker) {
25
- self.postMessage(data);
40
+ self.postMessage(data, { transfer });
26
41
  }
27
42
  else {
28
43
  await self.clients.matchAll().then((clients) => {
29
- clients.forEach((client) => client.postMessage(data));
44
+ clients.forEach((client) => client.postMessage(data, { transfer }));
30
45
  });
31
46
  }
32
47
  };
48
+ // Listen for messages from the client
33
49
  self.addEventListener("message", async (event) => {
50
+ // Decode the payload
34
51
  const { functionName, requestId, input } = PayloadSchema.assert(event.data);
35
52
  l.server.debug(requestId, `Received request for ${functionName}`, input);
36
- const postError = async (error) => postMessage({
53
+ // Get autotransfer preference from the procedure definition
54
+ const { autotransfer = "output-only" } = instance[zProcedures][functionName];
55
+ // Shorthand function with functionName, requestId, etc. set
56
+ const postMsg = async (data) => postMessage({
57
+ by: "sw&rpc",
37
58
  functionName,
38
59
  requestId,
60
+ autotransfer,
61
+ ...data,
62
+ });
63
+ // Prepare a function to post errors back to the client
64
+ const postError = async (error) => postMsg({
39
65
  error: {
40
66
  message: "message" in error ? error.message : String(error),
41
67
  },
42
68
  });
69
+ // Retrieve the implementation for the requested function
43
70
  const implementation = instance[zImplementations][functionName];
44
71
  if (!implementation) {
45
72
  await postError("No implementation found");
46
73
  return;
47
74
  }
75
+ // Call the implementation with the input and a progress callback
48
76
  await implementation(input, async (progress) => {
49
77
  l.server.debug(requestId, `Progress for ${functionName}`, progress);
50
- await postMessage({ functionName, requestId, progress });
78
+ await postMsg({ progress });
51
79
  })
80
+ // Send errors
52
81
  .catch(async (error) => {
53
82
  l.server.error(requestId, `Error in ${functionName}`, error);
54
83
  await postError(error);
55
84
  })
85
+ // Send results
56
86
  .then(async (result) => {
57
87
  l.server.debug(requestId, `Result for ${functionName}`, result);
58
- await postMessage({ functionName, requestId, result });
88
+ await postMsg({ result });
59
89
  });
60
90
  });
61
91
  };
62
92
  return instance;
63
93
  }
94
+ /**
95
+ * Generate a random request ID, used to identify requests between client and server.
96
+ * @returns a 6-character hexadecimal string
97
+ */
64
98
  function generateRequestId() {
65
99
  return Math.random().toString(16).substring(2, 8).toUpperCase();
66
100
  }
101
+ /**
102
+ * Pending requests are stored in a map, where the key is the request ID.
103
+ * Each request has a set of handlers: resolve, reject, and onProgress.
104
+ * This allows having a single listener for the client, and having multiple in-flight calls to the same procedure.
105
+ */
67
106
  const pendingRequests = new Map();
107
+ // Have we started the client listener?
68
108
  let _clientListenerStarted = false;
69
- async function startClientListener(worker) {
109
+ /**
110
+ * Starts the client listener, which listens for messages from the sw&rpc server.
111
+ * @param worker if provided, the client will use this worker to listen for messages, instead of using the service worker
112
+ * @returns
113
+ */
114
+ async function startClientListener(worker, hooks = {}) {
70
115
  if (_clientListenerStarted)
71
116
  return;
117
+ // Get service worker registration if no worker is provided
72
118
  if (!worker) {
73
119
  const sw = await navigator.serviceWorker.ready;
74
120
  if (!sw?.active) {
@@ -79,40 +125,67 @@ async function startClientListener(worker) {
79
125
  }
80
126
  }
81
127
  const w = worker ?? navigator.serviceWorker;
128
+ // Start listening for messages
82
129
  l.client.debug("", "Starting client listener on", w);
83
130
  w.addEventListener("message", (event) => {
84
- const { functionName, requestId, ...data } = event.data || {};
131
+ // Get the data from the event
132
+ const eventData = event.data || {};
133
+ // Ignore other messages that aren't for us
134
+ if (eventData?.by !== "sw&rpc")
135
+ return;
136
+ // We don't use a arktype schema here, we trust the server to send valid data
137
+ const { functionName, requestId, ...data } = eventData;
138
+ // Sanity check in case we somehow receive a message without requestId
85
139
  if (!requestId) {
86
140
  throw new Error("[SWARPC Client] Message received without requestId");
87
141
  }
142
+ // Get the associated pending request handlers
88
143
  const handlers = pendingRequests.get(requestId);
89
144
  if (!handlers) {
90
145
  throw new Error(`[SWARPC Client] ${requestId} has no active request handlers`);
91
146
  }
147
+ // React to the data received: call hook, call handler,
148
+ // and remove the request from pendingRequests (unless it's a progress update)
92
149
  if ("error" in data) {
150
+ hooks.error?.(functionName, new Error(data.error.message));
93
151
  handlers.reject(new Error(data.error.message));
94
152
  pendingRequests.delete(requestId);
95
153
  }
96
154
  else if ("progress" in data) {
155
+ hooks.progress?.(functionName, data.progress);
97
156
  handlers.onProgress(data.progress);
98
157
  }
99
158
  else if ("result" in data) {
159
+ hooks.success?.(functionName, data.result);
100
160
  handlers.resolve(data.result);
101
161
  pendingRequests.delete(requestId);
102
162
  }
103
163
  });
104
164
  _clientListenerStarted = true;
105
165
  }
106
- export function Client(procedures, { worker } = {}) {
166
+ /**
167
+ *
168
+ * @param procedures procedures the client will be able to call
169
+ * @param param1 various options
170
+ * @param param1.worker if provided, the client will use this worker to post messages.
171
+ * @param param1.hooks hooks to run on messages received from the server
172
+ * @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.
173
+ */
174
+ export function Client(procedures, { worker, hooks = {} } = {}) {
175
+ // Store procedures on a symbol key, to avoid conflicts with procedure names
107
176
  const instance = { [zProcedures]: procedures };
108
177
  for (const functionName of Object.keys(procedures)) {
109
178
  if (typeof functionName !== "string") {
110
179
  throw new Error(`[SWARPC Client] Invalid function name, don't use symbols`);
111
180
  }
181
+ // Set the method on the instance
112
182
  // @ts-expect-error
113
183
  instance[functionName] = (async (input, onProgress = () => { }) => {
184
+ // Validate the input against the procedure's input schema
114
185
  procedures[functionName].input.assert(input);
115
- await startClientListener(worker);
186
+ // Ensure that we're listening for messages from the server
187
+ await startClientListener(worker, hooks);
188
+ // If no worker is provided, we use the service worker
116
189
  const w = worker ?? (await navigator.serviceWorker.ready.then((r) => r.active));
117
190
  if (!w) {
118
191
  throw new Error("[SWARPC Client] No active service worker found");
@@ -121,28 +194,56 @@ export function Client(procedures, { worker } = {}) {
121
194
  if (!worker && !navigator.serviceWorker.controller)
122
195
  l.client.warn("", "Service Worker is not controlling the page");
123
196
  const requestId = generateRequestId();
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
124
200
  pendingRequests.set(requestId, { resolve, onProgress, reject });
201
+ // Post the message to the server
125
202
  l.client.debug(requestId, `Requesting ${functionName} with`, input);
126
- w.postMessage({ functionName, input, requestId });
203
+ w.postMessage({ functionName, input, requestId }, {
204
+ transfer: procedures[functionName].autotransfer === "always"
205
+ ? findTransferables(input)
206
+ : [],
207
+ });
127
208
  });
128
209
  });
129
210
  }
130
211
  return instance;
131
212
  }
213
+ /**
214
+ * Convenience shortcuts for logging.
215
+ */
132
216
  const l = {
133
217
  server: {
134
- debug: (rqid, message, ...args) => log("debug", "server", rqid, message, ...args),
135
- info: (rqid, message, ...args) => log("info", "server", rqid, message, ...args),
136
- warn: (rqid, message, ...args) => log("warn", "server", rqid, message, ...args),
137
- error: (rqid, message, ...args) => log("error", "server", rqid, message, ...args),
218
+ debug: logger("debug", "server"),
219
+ info: logger("info", "server"),
220
+ warn: logger("warn", "server"),
221
+ error: logger("error", "server"),
138
222
  },
139
223
  client: {
140
- debug: (rqid, message, ...args) => log("debug", "client", rqid, message, ...args),
141
- info: (rqid, message, ...args) => log("info", "client", rqid, message, ...args),
142
- warn: (rqid, message, ...args) => log("warn", "client", rqid, message, ...args),
143
- error: (rqid, message, ...args) => log("error", "client", rqid, message, ...args),
224
+ debug: logger("debug", "client"),
225
+ info: logger("info", "client"),
226
+ warn: logger("warn", "client"),
227
+ error: logger("error", "client"),
144
228
  },
145
229
  };
230
+ /**
231
+ * Creates partially-applied logging functions given the first 2 args
232
+ * @param severity
233
+ * @param side
234
+ * @returns
235
+ */
236
+ function logger(severity, side) {
237
+ return (rqid, message, ...args) => log(severity, side, rqid, message, ...args);
238
+ }
239
+ /**
240
+ * Send log messages to the console, with a helpful prefix.
241
+ * @param severity
242
+ * @param side
243
+ * @param rqid request ID
244
+ * @param message
245
+ * @param args passed to console methods directly
246
+ */
146
247
  function log(severity, side, rqid, message, ...args) {
147
248
  const prefix = "[" +
148
249
  ["SWARPC", side, rqid ? `%c${rqid}%c` : ""].filter(Boolean).join(" ") +
package/dist/types.d.ts CHANGED
@@ -1,22 +1,109 @@
1
1
  import type { Type } from "arktype";
2
+ /**
3
+ * A procedure declaration
4
+ */
2
5
  export type Procedure<I extends Type, P extends Type, S extends Type> = {
6
+ /**
7
+ * ArkType type for the input (first argument) of the procedure, when calling it from the client.
8
+ */
3
9
  input: I;
10
+ /**
11
+ * ArkType type for the data as the first argument given to the `onProgress` callback
12
+ * when calling the procedure from the client.
13
+ */
4
14
  progress: P;
15
+ /**
16
+ * ArkType type for the output (return value) of the procedure, when calling it from the client.
17
+ */
5
18
  success: S;
19
+ /**
20
+ * When should the procedure automatically add ArrayBuffers and other transferable objects
21
+ * to the [transfer list](https://developer.mozilla.org/en-US/docs/Web/API/DedicatedWorkerGlobalScope/postMessage#transfer)
22
+ * when sending messages, both from the client to the server and vice versa.
23
+ *
24
+ * Transferring objects can improve performance by avoiding copies of large objects,
25
+ * but _moves_ them to the other context, meaning that they cannot be used in the original context after being sent.
26
+ *
27
+ * 'output-only' by default: only transferables sent from the server to the client will be transferred.
28
+ */
29
+ autotransfer?: "always" | "never" | "output-only";
6
30
  };
7
- export type ProcedureImplementation<I extends Type, P extends Type, S extends Type> = (input: I["inferOut"], onProgress: (progress: P["inferOut"]) => void) => Promise<S["inferOut"]>;
31
+ /**
32
+ * An implementation of a procedure
33
+ */
34
+ export type ProcedureImplementation<I extends Type, P extends Type, S extends Type> = (input: I["inferOut"], onProgress: (progress: P["inferIn"]) => void) => Promise<S["inferIn"]>;
35
+ /**
36
+ * Declarations of procedures by name.
37
+ */
8
38
  export type ProceduresMap = Record<string, Procedure<Type, Type, Type>>;
39
+ /**
40
+ * Implementations of procedures by name
41
+ */
9
42
  export type ImplementationsMap<Procedures extends ProceduresMap> = {
10
43
  [F in keyof Procedures]: ProcedureImplementation<Procedures[F]["input"], Procedures[F]["progress"], Procedures[F]["success"]>;
11
44
  };
45
+ /**
46
+ * Declaration of hooks to run on messages received from the server
47
+ */
48
+ export type Hooks<Procedures extends ProceduresMap> = {
49
+ /**
50
+ * Called when a procedure call has been successful.
51
+ */
52
+ success?: <Procedure extends keyof ProceduresMap>(procedure: Procedure, data: Procedures[Procedure]["success"]["inferOut"]) => void;
53
+ /**
54
+ * Called when a procedure call has failed.
55
+ */
56
+ error?: <Procedure extends keyof ProceduresMap>(procedure: Procedure, error: Error) => void;
57
+ /**
58
+ * Called when a procedure call sends progress updates.
59
+ */
60
+ progress?: <Procedure extends keyof ProceduresMap>(procedure: Procedure, data: Procedures[Procedure]["progress"]["inferOut"]) => void;
61
+ };
62
+ export type PayloadHeader<PM extends ProceduresMap, Name extends keyof PM = keyof PM> = {
63
+ by: "sw&rpc";
64
+ functionName: Name & string;
65
+ requestId: string;
66
+ autotransfer: PM[Name]["autotransfer"];
67
+ };
68
+ export type PayloadCore<PM extends ProceduresMap, Name extends keyof PM = keyof PM> = {
69
+ input: PM[Name]["input"]["inferOut"];
70
+ } | {
71
+ progress: PM[Name]["progress"]["inferOut"];
72
+ } | {
73
+ result: PM[Name]["success"]["inferOut"];
74
+ } | {
75
+ error: {
76
+ message: string;
77
+ };
78
+ };
79
+ /**
80
+ * The effective payload as sent by the server to the client
81
+ */
82
+ export type Payload<PM extends ProceduresMap, Name extends keyof PM = keyof PM> = PayloadHeader<PM, Name> & PayloadCore<PM, Name>;
83
+ /**
84
+ * A procedure's corresponding method on the client instance -- used to call the procedure
85
+ */
12
86
  export type ClientMethod<P extends Procedure<Type, Type, Type>> = (input: P["input"]["inferIn"], onProgress?: (progress: P["progress"]["inferOut"]) => void) => Promise<P["success"]["inferOut"]>;
87
+ /**
88
+ * Symbol used as the key for the procedures map on the server instance
89
+ */
13
90
  export declare const zImplementations: unique symbol;
91
+ /**
92
+ * Symbol used as the key for the procedures map on instances
93
+ */
14
94
  export declare const zProcedures: unique symbol;
95
+ /**
96
+ * The sw&rpc client instance, which provides methods to call procedures
97
+ */
15
98
  export type SwarpcClient<Procedures extends ProceduresMap> = {
16
99
  [zProcedures]: Procedures;
17
100
  } & {
18
101
  [F in keyof Procedures]: ClientMethod<Procedures[F]>;
19
102
  };
103
+ /**
104
+ * The sw&rpc server instance, which provides methods to register procedure implementations,
105
+ * and listens for incoming messages that call those procedures
106
+ */
20
107
  export type SwarpcServer<Procedures extends ProceduresMap> = {
21
108
  [zProcedures]: Procedures;
22
109
  [zImplementations]: ImplementationsMap<Procedures>;
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,SAAS,CAAA;AAEnC,MAAM,MAAM,SAAS,CAAC,CAAC,SAAS,IAAI,EAAE,CAAC,SAAS,IAAI,EAAE,CAAC,SAAS,IAAI,IAAI;IACtE,KAAK,EAAE,CAAC,CAAA;IACR,QAAQ,EAAE,CAAC,CAAA;IACX,OAAO,EAAE,CAAC,CAAA;CACX,CAAA;AAED,MAAM,MAAM,uBAAuB,CACjC,CAAC,SAAS,IAAI,EACd,CAAC,SAAS,IAAI,EACd,CAAC,SAAS,IAAI,IACZ,CACF,KAAK,EAAE,CAAC,CAAC,UAAU,CAAC,EACpB,UAAU,EAAE,CAAC,QAAQ,EAAE,CAAC,CAAC,UAAU,CAAC,KAAK,IAAI,KAC1C,OAAO,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAA;AAE3B,MAAM,MAAM,aAAa,GAAG,MAAM,CAAC,MAAM,EAAE,SAAS,CAAC,IAAI,EAAE,IAAI,EAAE,IAAI,CAAC,CAAC,CAAA;AAEvE,MAAM,MAAM,kBAAkB,CAAC,UAAU,SAAS,aAAa,IAAI;KAChE,CAAC,IAAI,MAAM,UAAU,GAAG,uBAAuB,CAC9C,UAAU,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,EACtB,UAAU,CAAC,CAAC,CAAC,CAAC,UAAU,CAAC,EACzB,UAAU,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,CACzB;CACF,CAAA;AAED,MAAM,MAAM,YAAY,CAAC,CAAC,SAAS,SAAS,CAAC,IAAI,EAAE,IAAI,EAAE,IAAI,CAAC,IAAI,CAChE,KAAK,EAAE,CAAC,CAAC,OAAO,CAAC,CAAC,SAAS,CAAC,EAC5B,UAAU,CAAC,EAAE,CAAC,QAAQ,EAAE,CAAC,CAAC,UAAU,CAAC,CAAC,UAAU,CAAC,KAAK,IAAI,KACvD,OAAO,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,UAAU,CAAC,CAAC,CAAA;AAEtC,eAAO,MAAM,gBAAgB,eAAmC,CAAA;AAChE,eAAO,MAAM,WAAW,eAA8B,CAAA;AAEtD,MAAM,MAAM,YAAY,CAAC,UAAU,SAAS,aAAa,IAAI;IAC3D,CAAC,WAAW,CAAC,EAAE,UAAU,CAAA;CAC1B,GAAG;KACD,CAAC,IAAI,MAAM,UAAU,GAAG,YAAY,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC;CACrD,CAAA;AAED,MAAM,MAAM,YAAY,CAAC,UAAU,SAAS,aAAa,IAAI;IAC3D,CAAC,WAAW,CAAC,EAAE,UAAU,CAAA;IACzB,CAAC,gBAAgB,CAAC,EAAE,kBAAkB,CAAC,UAAU,CAAC,CAAA;IAClD,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAA;CAC1B,GAAG;KACD,CAAC,IAAI,MAAM,UAAU,GAAG,CACvB,IAAI,EAAE,uBAAuB,CAC3B,UAAU,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,EACtB,UAAU,CAAC,CAAC,CAAC,CAAC,UAAU,CAAC,EACzB,UAAU,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,CACzB,KACE,IAAI;CACV,CAAA"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
package/dist/types.js CHANGED
@@ -1,2 +1,8 @@
1
+ /**
2
+ * Symbol used as the key for the procedures map on the server instance
3
+ */
1
4
  export const zImplementations = Symbol("SWARPC implementations");
5
+ /**
6
+ * Symbol used as the key for the procedures map on instances
7
+ */
2
8
  export const zProcedures = Symbol("SWARPC procedures");
@@ -0,0 +1,2 @@
1
+ export declare function findTransferables(value: any): Transferable[];
2
+ //# sourceMappingURL=utils.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../src/utils.ts"],"names":[],"mappings":"AAiBA,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,GAAG,GAAG,YAAY,EAAE,CAsB5D"}
package/dist/utils.js ADDED
@@ -0,0 +1,32 @@
1
+ // TODO: keep it in sync with web standards, how?
2
+ const transferableClasses = [
3
+ OffscreenCanvas,
4
+ ImageBitmap,
5
+ MessagePort,
6
+ MediaSourceHandle,
7
+ ReadableStream,
8
+ WritableStream,
9
+ TransformStream,
10
+ AudioData,
11
+ VideoFrame,
12
+ RTCDataChannel,
13
+ ArrayBuffer,
14
+ ];
15
+ export function findTransferables(value) {
16
+ if (value === null || value === undefined) {
17
+ return [];
18
+ }
19
+ if (typeof value === "object") {
20
+ if (ArrayBuffer.isView(value) || value instanceof ArrayBuffer) {
21
+ return [value];
22
+ }
23
+ if (transferableClasses.some((cls) => value instanceof cls)) {
24
+ return [value];
25
+ }
26
+ if (Array.isArray(value)) {
27
+ return value.flatMap(findTransferables);
28
+ }
29
+ return Object.values(value).flatMap(findTransferables);
30
+ }
31
+ return [];
32
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "swarpc",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Full type-safe RPC library for service worker -- move things off of the UI thread with ease!",
5
5
  "keywords": [
6
6
  "service-workers",
@@ -28,13 +28,17 @@
28
28
  "types": "dist/swarpc.d.ts",
29
29
  "scripts": {
30
30
  "build": "tsc",
31
+ "dev": "tsc --watch",
31
32
  "test": "echo \"Error: no test specified\" && exit 1",
32
- "typedoc": "typedoc src/swarpc.ts src/types.ts --readme README.md"
33
+ "typedoc": "typedoc src/swarpc.ts src/types.ts --readme README.md",
34
+ "version": "kacl release && prettier -w CHANGELOG.md && git add CHANGELOG.md"
33
35
  },
34
36
  "dependencies": {
35
37
  "arktype": "^2.1.20"
36
38
  },
37
39
  "devDependencies": {
40
+ "kacl": "^1.1.1",
41
+ "prettier": "^3.6.2",
38
42
  "typedoc": "^0.28.7",
39
43
  "typescript": "^5.8.3"
40
44
  }
package/src/swarpc.ts CHANGED
@@ -1,25 +1,40 @@
1
1
  import { type } from "arktype"
2
2
  import {
3
+ Hooks,
3
4
  ImplementationsMap,
5
+ Payload,
6
+ PayloadCore,
4
7
  zImplementations,
5
8
  zProcedures,
6
9
  type ProceduresMap,
7
10
  type SwarpcClient,
8
11
  type SwarpcServer,
9
12
  } from "./types.js"
13
+ import { findTransferables } from "./utils.js"
10
14
 
11
15
  export type { ProceduresMap, SwarpcClient, SwarpcServer } from "./types.js"
12
16
 
17
+ /**
18
+ * Creates a sw&rpc server instance.
19
+ * @param procedures procedures the server will implement
20
+ * @param param1 various options
21
+ * @param param1.worker if provided, the server will use this worker to post messages, instead of sending it to all clients
22
+ * @returns a SwarpcServer instance. Each property of the procedures map will be a method, that accepts a function implementing the procedure. There is also .start(), to be called after implementing all procedures.
23
+ */
13
24
  export function Server<Procedures extends ProceduresMap>(
14
25
  procedures: Procedures,
15
26
  { worker }: { worker?: Worker } = {}
16
27
  ): SwarpcServer<Procedures> {
28
+ // Initialize the instance.
29
+ // Procedures and implementations are stored on properties with symbol keys,
30
+ // to avoid any conflicts with procedure names, and also discourage direct access to them.
17
31
  const instance = {
18
32
  [zProcedures]: procedures,
19
33
  [zImplementations]: {} as ImplementationsMap<Procedures>,
20
34
  start: (self: Window) => {},
21
35
  } as SwarpcServer<Procedures>
22
36
 
37
+ // Set all implementation-setter methods
23
38
  for (const functionName in procedures) {
24
39
  instance[functionName] = ((implementation) => {
25
40
  if (!instance[zProcedures][functionName]) {
@@ -29,6 +44,7 @@ export function Server<Procedures extends ProceduresMap>(
29
44
  }) as SwarpcServer<Procedures>[typeof functionName]
30
45
  }
31
46
 
47
+ // Define payload schema for incoming messages
32
48
  const PayloadSchema = type.or(
33
49
  ...Object.entries(procedures).map(([functionName, { input }]) => ({
34
50
  functionName: type(`"${functionName}"`),
@@ -38,55 +54,74 @@ export function Server<Procedures extends ProceduresMap>(
38
54
  )
39
55
 
40
56
  instance.start = (self: Window) => {
41
- const postMessage = async (
42
- data: { functionName: string; requestId: string } & Partial<{
43
- result: any
44
- error: any
45
- progress: any
46
- }>
47
- ) => {
57
+ // Used to post messages back to the client
58
+ const postMessage = async (data: Payload<Procedures>) => {
59
+ const transfer =
60
+ data.autotransfer === "never" ? [] : findTransferables(data)
61
+
48
62
  if (worker) {
49
- self.postMessage(data)
63
+ self.postMessage(data, { transfer })
50
64
  } else {
51
65
  await (self as any).clients.matchAll().then((clients: any[]) => {
52
- clients.forEach((client) => client.postMessage(data))
66
+ clients.forEach((client) => client.postMessage(data, { transfer }))
53
67
  })
54
68
  }
55
69
  }
56
70
 
71
+ // Listen for messages from the client
57
72
  self.addEventListener("message", async (event: MessageEvent) => {
73
+ // Decode the payload
58
74
  const { functionName, requestId, input } = PayloadSchema.assert(
59
75
  event.data
60
76
  )
61
77
 
62
78
  l.server.debug(requestId, `Received request for ${functionName}`, input)
63
79
 
64
- const postError = async (error: any) =>
80
+ // Get autotransfer preference from the procedure definition
81
+ const { autotransfer = "output-only" } =
82
+ instance[zProcedures][functionName]
83
+
84
+ // Shorthand function with functionName, requestId, etc. set
85
+ const postMsg = async (
86
+ data: PayloadCore<Procedures, typeof functionName>
87
+ ) =>
65
88
  postMessage({
89
+ by: "sw&rpc",
66
90
  functionName,
67
91
  requestId,
92
+ autotransfer,
93
+ ...data,
94
+ })
95
+
96
+ // Prepare a function to post errors back to the client
97
+ const postError = async (error: any) =>
98
+ postMsg({
68
99
  error: {
69
100
  message: "message" in error ? error.message : String(error),
70
101
  },
71
102
  })
72
103
 
104
+ // Retrieve the implementation for the requested function
73
105
  const implementation = instance[zImplementations][functionName]
74
106
  if (!implementation) {
75
107
  await postError("No implementation found")
76
108
  return
77
109
  }
78
110
 
111
+ // Call the implementation with the input and a progress callback
79
112
  await implementation(input, async (progress: any) => {
80
113
  l.server.debug(requestId, `Progress for ${functionName}`, progress)
81
- await postMessage({ functionName, requestId, progress })
114
+ await postMsg({ progress })
82
115
  })
116
+ // Send errors
83
117
  .catch(async (error: any) => {
84
118
  l.server.error(requestId, `Error in ${functionName}`, error)
85
119
  await postError(error)
86
120
  })
121
+ // Send results
87
122
  .then(async (result: any) => {
88
123
  l.server.debug(requestId, `Result for ${functionName}`, result)
89
- await postMessage({ functionName, requestId, result })
124
+ await postMsg({ result })
90
125
  })
91
126
  })
92
127
  }
@@ -94,22 +129,41 @@ export function Server<Procedures extends ProceduresMap>(
94
129
  return instance
95
130
  }
96
131
 
132
+ /**
133
+ * Generate a random request ID, used to identify requests between client and server.
134
+ * @returns a 6-character hexadecimal string
135
+ */
97
136
  function generateRequestId(): string {
98
137
  return Math.random().toString(16).substring(2, 8).toUpperCase()
99
138
  }
100
139
 
140
+ /**
141
+ * Pending requests are stored in a map, where the key is the request ID.
142
+ * Each request has a set of handlers: resolve, reject, and onProgress.
143
+ * This allows having a single listener for the client, and having multiple in-flight calls to the same procedure.
144
+ */
145
+ const pendingRequests = new Map<string, PendingRequest>()
101
146
  type PendingRequest = {
102
147
  reject: (err: Error) => void
103
148
  onProgress: (progress: any) => void
104
149
  resolve: (result: any) => void
105
150
  }
106
151
 
107
- const pendingRequests = new Map<string, PendingRequest>()
108
-
152
+ // Have we started the client listener?
109
153
  let _clientListenerStarted = false
110
- async function startClientListener(worker?: Worker) {
154
+
155
+ /**
156
+ * Starts the client listener, which listens for messages from the sw&rpc server.
157
+ * @param worker if provided, the client will use this worker to listen for messages, instead of using the service worker
158
+ * @returns
159
+ */
160
+ async function startClientListener<Procedures extends ProceduresMap>(
161
+ worker?: Worker,
162
+ hooks: Hooks<Procedures> = {}
163
+ ) {
111
164
  if (_clientListenerStarted) return
112
165
 
166
+ // Get service worker registration if no worker is provided
113
167
  if (!worker) {
114
168
  const sw = await navigator.serviceWorker.ready
115
169
  if (!sw?.active) {
@@ -122,15 +176,26 @@ async function startClientListener(worker?: Worker) {
122
176
  }
123
177
 
124
178
  const w = worker ?? navigator.serviceWorker
179
+
180
+ // Start listening for messages
125
181
  l.client.debug("", "Starting client listener on", w)
126
182
  w.addEventListener("message", (event) => {
183
+ // Get the data from the event
184
+ const eventData = (event as MessageEvent).data || {}
185
+
186
+ // Ignore other messages that aren't for us
187
+ if (eventData?.by !== "sw&rpc") return
188
+
189
+ // We don't use a arktype schema here, we trust the server to send valid data
127
190
  const { functionName, requestId, ...data } =
128
- (event as MessageEvent).data || {}
191
+ eventData as Payload<Procedures>
129
192
 
193
+ // Sanity check in case we somehow receive a message without requestId
130
194
  if (!requestId) {
131
195
  throw new Error("[SWARPC Client] Message received without requestId")
132
196
  }
133
197
 
198
+ // Get the associated pending request handlers
134
199
  const handlers = pendingRequests.get(requestId)
135
200
  if (!handlers) {
136
201
  throw new Error(
@@ -138,12 +203,17 @@ async function startClientListener(worker?: Worker) {
138
203
  )
139
204
  }
140
205
 
206
+ // React to the data received: call hook, call handler,
207
+ // and remove the request from pendingRequests (unless it's a progress update)
141
208
  if ("error" in data) {
209
+ hooks.error?.(functionName, new Error(data.error.message))
142
210
  handlers.reject(new Error(data.error.message))
143
211
  pendingRequests.delete(requestId)
144
212
  } else if ("progress" in data) {
213
+ hooks.progress?.(functionName, data.progress)
145
214
  handlers.onProgress(data.progress)
146
215
  } else if ("result" in data) {
216
+ hooks.success?.(functionName, data.result)
147
217
  handlers.resolve(data.result)
148
218
  pendingRequests.delete(requestId)
149
219
  }
@@ -152,10 +222,19 @@ async function startClientListener(worker?: Worker) {
152
222
  _clientListenerStarted = true
153
223
  }
154
224
 
225
+ /**
226
+ *
227
+ * @param procedures procedures the client will be able to call
228
+ * @param param1 various options
229
+ * @param param1.worker if provided, the client will use this worker to post messages.
230
+ * @param param1.hooks hooks to run on messages received from the server
231
+ * @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.
232
+ */
155
233
  export function Client<Procedures extends ProceduresMap>(
156
234
  procedures: Procedures,
157
- { worker }: { worker?: Worker } = {}
235
+ { worker, hooks = {} }: { worker?: Worker; hooks?: Hooks<Procedures> } = {}
158
236
  ): SwarpcClient<Procedures> {
237
+ // Store procedures on a symbol key, to avoid conflicts with procedure names
159
238
  const instance = { [zProcedures]: procedures } as Partial<
160
239
  SwarpcClient<Procedures>
161
240
  >
@@ -169,11 +248,15 @@ export function Client<Procedures extends ProceduresMap>(
169
248
  )
170
249
  }
171
250
 
251
+ // Set the method on the instance
172
252
  // @ts-expect-error
173
253
  instance[functionName] = (async (input: unknown, onProgress = () => {}) => {
254
+ // Validate the input against the procedure's input schema
174
255
  procedures[functionName].input.assert(input)
175
- await startClientListener(worker)
256
+ // Ensure that we're listening for messages from the server
257
+ await startClientListener(worker, hooks)
176
258
 
259
+ // If no worker is provided, we use the service worker
177
260
  const w =
178
261
  worker ?? (await navigator.serviceWorker.ready.then((r) => r.active))
179
262
 
@@ -187,10 +270,22 @@ export function Client<Procedures extends ProceduresMap>(
187
270
 
188
271
  const requestId = generateRequestId()
189
272
 
273
+ // Store promise handlers (as well as progress updates handler)
274
+ // so the client listener can resolve/reject the promise (and react to progress updates)
275
+ // when the server sends messages back
190
276
  pendingRequests.set(requestId, { resolve, onProgress, reject })
191
277
 
278
+ // Post the message to the server
192
279
  l.client.debug(requestId, `Requesting ${functionName} with`, input)
193
- w.postMessage({ functionName, input, requestId })
280
+ w.postMessage(
281
+ { functionName, input, requestId },
282
+ {
283
+ transfer:
284
+ procedures[functionName].autotransfer === "always"
285
+ ? findTransferables(input)
286
+ : [],
287
+ }
288
+ )
194
289
  })
195
290
  }) as SwarpcClient<Procedures>[typeof functionName]
196
291
  }
@@ -198,29 +293,46 @@ export function Client<Procedures extends ProceduresMap>(
198
293
  return instance as SwarpcClient<Procedures>
199
294
  }
200
295
 
296
+ /**
297
+ * Convenience shortcuts for logging.
298
+ */
201
299
  const l = {
202
300
  server: {
203
- debug: (rqid: string | null, message: string, ...args: any[]) =>
204
- log("debug", "server", rqid, message, ...args),
205
- info: (rqid: string | null, message: string, ...args: any[]) =>
206
- log("info", "server", rqid, message, ...args),
207
- warn: (rqid: string | null, message: string, ...args: any[]) =>
208
- log("warn", "server", rqid, message, ...args),
209
- error: (rqid: string | null, message: string, ...args: any[]) =>
210
- log("error", "server", rqid, message, ...args),
301
+ debug: logger("debug", "server"),
302
+ info: logger("info", "server"),
303
+ warn: logger("warn", "server"),
304
+ error: logger("error", "server"),
211
305
  },
212
306
  client: {
213
- debug: (rqid: string | null, message: string, ...args: any[]) =>
214
- log("debug", "client", rqid, message, ...args),
215
- info: (rqid: string | null, message: string, ...args: any[]) =>
216
- log("info", "client", rqid, message, ...args),
217
- warn: (rqid: string | null, message: string, ...args: any[]) =>
218
- log("warn", "client", rqid, message, ...args),
219
- error: (rqid: string | null, message: string, ...args: any[]) =>
220
- log("error", "client", rqid, message, ...args),
307
+ debug: logger("debug", "client"),
308
+ info: logger("info", "client"),
309
+ warn: logger("warn", "client"),
310
+ error: logger("error", "client"),
221
311
  },
222
312
  }
223
313
 
314
+ /**
315
+ * Creates partially-applied logging functions given the first 2 args
316
+ * @param severity
317
+ * @param side
318
+ * @returns
319
+ */
320
+ function logger(
321
+ severity: "debug" | "info" | "warn" | "error",
322
+ side: "server" | "client"
323
+ ) {
324
+ return (rqid: string | null, message: string, ...args: any[]) =>
325
+ log(severity, side, rqid, message, ...args)
326
+ }
327
+
328
+ /**
329
+ * Send log messages to the console, with a helpful prefix.
330
+ * @param severity
331
+ * @param side
332
+ * @param rqid request ID
333
+ * @param message
334
+ * @param args passed to console methods directly
335
+ */
224
336
  function log(
225
337
  severity: "debug" | "info" | "warn" | "error",
226
338
  side: "server" | "client",
package/src/types.ts CHANGED
@@ -1,22 +1,55 @@
1
1
  import type { Type } from "arktype"
2
2
 
3
+ /**
4
+ * A procedure declaration
5
+ */
3
6
  export type Procedure<I extends Type, P extends Type, S extends Type> = {
7
+ /**
8
+ * ArkType type for the input (first argument) of the procedure, when calling it from the client.
9
+ */
4
10
  input: I
11
+ /**
12
+ * ArkType type for the data as the first argument given to the `onProgress` callback
13
+ * when calling the procedure from the client.
14
+ */
5
15
  progress: P
16
+ /**
17
+ * ArkType type for the output (return value) of the procedure, when calling it from the client.
18
+ */
6
19
  success: S
20
+ /**
21
+ * When should the procedure automatically add ArrayBuffers and other transferable objects
22
+ * to the [transfer list](https://developer.mozilla.org/en-US/docs/Web/API/DedicatedWorkerGlobalScope/postMessage#transfer)
23
+ * when sending messages, both from the client to the server and vice versa.
24
+ *
25
+ * Transferring objects can improve performance by avoiding copies of large objects,
26
+ * but _moves_ them to the other context, meaning that they cannot be used in the original context after being sent.
27
+ *
28
+ * 'output-only' by default: only transferables sent from the server to the client will be transferred.
29
+ */
30
+ autotransfer?: "always" | "never" | "output-only"
7
31
  }
8
32
 
33
+ /**
34
+ * An implementation of a procedure
35
+ */
9
36
  export type ProcedureImplementation<
10
37
  I extends Type,
11
38
  P extends Type,
12
39
  S extends Type
13
40
  > = (
14
41
  input: I["inferOut"],
15
- onProgress: (progress: P["inferOut"]) => void
16
- ) => Promise<S["inferOut"]>
42
+ onProgress: (progress: P["inferIn"]) => void
43
+ ) => Promise<S["inferIn"]>
17
44
 
45
+ /**
46
+ * Declarations of procedures by name.
47
+ */
18
48
  export type ProceduresMap = Record<string, Procedure<Type, Type, Type>>
19
49
 
50
+ /**
51
+ * Implementations of procedures by name
52
+ */
20
53
  export type ImplementationsMap<Procedures extends ProceduresMap> = {
21
54
  [F in keyof Procedures]: ProcedureImplementation<
22
55
  Procedures[F]["input"],
@@ -25,20 +58,98 @@ export type ImplementationsMap<Procedures extends ProceduresMap> = {
25
58
  >
26
59
  }
27
60
 
61
+ /**
62
+ * Declaration of hooks to run on messages received from the server
63
+ */
64
+ export type Hooks<Procedures extends ProceduresMap> = {
65
+ /**
66
+ * Called when a procedure call has been successful.
67
+ */
68
+ success?: <Procedure extends keyof ProceduresMap>(
69
+ procedure: Procedure,
70
+ data: Procedures[Procedure]["success"]["inferOut"]
71
+ ) => void
72
+ /**
73
+ * Called when a procedure call has failed.
74
+ */
75
+ error?: <Procedure extends keyof ProceduresMap>(
76
+ procedure: Procedure,
77
+ error: Error
78
+ ) => void
79
+ /**
80
+ * Called when a procedure call sends progress updates.
81
+ */
82
+ progress?: <Procedure extends keyof ProceduresMap>(
83
+ procedure: Procedure,
84
+ data: Procedures[Procedure]["progress"]["inferOut"]
85
+ ) => void
86
+ }
87
+
88
+ export type PayloadHeader<
89
+ PM extends ProceduresMap,
90
+ Name extends keyof PM = keyof PM
91
+ > = {
92
+ by: "sw&rpc"
93
+ functionName: Name & string
94
+ requestId: string
95
+ autotransfer: PM[Name]["autotransfer"]
96
+ }
97
+
98
+ export type PayloadCore<
99
+ PM extends ProceduresMap,
100
+ Name extends keyof PM = keyof PM
101
+ > =
102
+ | {
103
+ input: PM[Name]["input"]["inferOut"]
104
+ }
105
+ | {
106
+ progress: PM[Name]["progress"]["inferOut"]
107
+ }
108
+ | {
109
+ result: PM[Name]["success"]["inferOut"]
110
+ }
111
+ | {
112
+ error: { message: string }
113
+ }
114
+
115
+ /**
116
+ * The effective payload as sent by the server to the client
117
+ */
118
+ export type Payload<
119
+ PM extends ProceduresMap,
120
+ Name extends keyof PM = keyof PM
121
+ > = PayloadHeader<PM, Name> & PayloadCore<PM, Name>
122
+
123
+ /**
124
+ * A procedure's corresponding method on the client instance -- used to call the procedure
125
+ */
28
126
  export type ClientMethod<P extends Procedure<Type, Type, Type>> = (
29
127
  input: P["input"]["inferIn"],
30
128
  onProgress?: (progress: P["progress"]["inferOut"]) => void
31
129
  ) => Promise<P["success"]["inferOut"]>
32
130
 
131
+ /**
132
+ * Symbol used as the key for the procedures map on the server instance
133
+ */
33
134
  export const zImplementations = Symbol("SWARPC implementations")
135
+ /**
136
+ * Symbol used as the key for the procedures map on instances
137
+ */
34
138
  export const zProcedures = Symbol("SWARPC procedures")
35
139
 
140
+ /**
141
+ * The sw&rpc client instance, which provides methods to call procedures
142
+ */
36
143
  export type SwarpcClient<Procedures extends ProceduresMap> = {
37
144
  [zProcedures]: Procedures
38
145
  } & {
39
146
  [F in keyof Procedures]: ClientMethod<Procedures[F]>
40
147
  }
41
148
 
149
+ /**
150
+ * The sw&rpc server instance, which provides methods to register procedure implementations,
151
+ * and listens for incoming messages that call those procedures
152
+ */
42
153
  export type SwarpcServer<Procedures extends ProceduresMap> = {
43
154
  [zProcedures]: Procedures
44
155
  [zImplementations]: ImplementationsMap<Procedures>
package/src/utils.ts ADDED
@@ -0,0 +1,40 @@
1
+ type Constructor<T> = new (...args: any[]) => T
2
+
3
+ // TODO: keep it in sync with web standards, how?
4
+ const transferableClasses: Constructor<Transferable>[] = [
5
+ OffscreenCanvas,
6
+ ImageBitmap,
7
+ MessagePort,
8
+ MediaSourceHandle,
9
+ ReadableStream,
10
+ WritableStream,
11
+ TransformStream,
12
+ AudioData,
13
+ VideoFrame,
14
+ RTCDataChannel,
15
+ ArrayBuffer,
16
+ ]
17
+
18
+ export function findTransferables(value: any): Transferable[] {
19
+ if (value === null || value === undefined) {
20
+ return []
21
+ }
22
+
23
+ if (typeof value === "object") {
24
+ if (ArrayBuffer.isView(value) || value instanceof ArrayBuffer) {
25
+ return [value]
26
+ }
27
+
28
+ if (transferableClasses.some((cls) => value instanceof cls)) {
29
+ return [value as Transferable]
30
+ }
31
+
32
+ if (Array.isArray(value)) {
33
+ return value.flatMap(findTransferables)
34
+ }
35
+
36
+ return Object.values(value).flatMap(findTransferables)
37
+ }
38
+
39
+ return []
40
+ }