swarpc 0.3.0 → 0.5.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,8 +1,22 @@
1
1
  import { 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>;
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
+ * @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.
19
+ */
6
20
  export declare function Client<Procedures extends ProceduresMap>(procedures: Procedures, { worker }?: {
7
21
  worker?: Worker;
8
22
  }): SwarpcClient<Procedures>;
@@ -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,CAyE1B;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,CAmC1B"}
1
+ {"version":3,"file":"swarpc.d.ts","sourceRoot":"","sources":["../src/swarpc.ts"],"names":[],"mappings":"AACA,OAAO,EAKL,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,CA0G1B;AAoFD;;;;;;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,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,77 +26,116 @@ 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
- const postError = async (error) => postMessage({
36
- functionName,
37
- requestId,
52
+ l.server.debug(requestId, `Received request for ${functionName}`, input);
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({ functionName, requestId, autotransfer, ...data });
57
+ // Prepare a function to post errors back to the client
58
+ const postError = async (error) => postMsg({
38
59
  error: {
39
60
  message: "message" in error ? error.message : String(error),
40
61
  },
41
62
  });
63
+ // Retrieve the implementation for the requested function
42
64
  const implementation = instance[zImplementations][functionName];
43
65
  if (!implementation) {
44
66
  await postError("No implementation found");
45
67
  return;
46
68
  }
69
+ // Call the implementation with the input and a progress callback
47
70
  await implementation(input, async (progress) => {
48
- await postMessage({ functionName, requestId, progress });
71
+ l.server.debug(requestId, `Progress for ${functionName}`, progress);
72
+ await postMsg({ progress });
49
73
  })
74
+ // Send errors
50
75
  .catch(async (error) => {
76
+ l.server.error(requestId, `Error in ${functionName}`, error);
51
77
  await postError(error);
52
78
  })
79
+ // Send results
53
80
  .then(async (result) => {
54
- await postMessage({ functionName, requestId, result });
81
+ l.server.debug(requestId, `Result for ${functionName}`, result);
82
+ await postMsg({ result });
55
83
  });
56
84
  });
57
85
  };
58
86
  return instance;
59
87
  }
88
+ /**
89
+ * Generate a random request ID, used to identify requests between client and server.
90
+ * @returns a 6-character hexadecimal string
91
+ */
60
92
  function generateRequestId() {
61
- return Math.random().toString(36).substring(2, 15);
93
+ return Math.random().toString(16).substring(2, 8).toUpperCase();
62
94
  }
95
+ /**
96
+ * Pending requests are stored in a map, where the key is the request ID.
97
+ * Each request has a set of handlers: resolve, reject, and onProgress.
98
+ * This allows having a single listener for the client, and having multiple in-flight calls to the same procedure.
99
+ */
63
100
  const pendingRequests = new Map();
101
+ // Have we started the client listener?
64
102
  let _clientListenerStarted = false;
103
+ /**
104
+ * Starts the client listener, which listens for messages from the sw&rpc server.
105
+ * @param worker if provided, the client will use this worker to listen for messages, instead of using the service worker
106
+ * @returns
107
+ */
65
108
  async function startClientListener(worker) {
66
109
  if (_clientListenerStarted)
67
110
  return;
111
+ // Get service worker registration if no worker is provided
68
112
  if (!worker) {
69
113
  const sw = await navigator.serviceWorker.ready;
70
114
  if (!sw?.active) {
71
115
  throw new Error("[SWARPC Client] Service Worker is not active");
72
116
  }
73
117
  if (!navigator.serviceWorker.controller) {
74
- console.warn("[SWARPC Client] Service Worker is not controlling the page");
118
+ l.client.warn("", "Service Worker is not controlling the page");
75
119
  }
76
120
  }
77
121
  const w = worker ?? navigator.serviceWorker;
78
- console.log("[SWARPC Client] Starting client listener on", w);
122
+ // Start listening for messages
123
+ l.client.debug("", "Starting client listener on", w);
79
124
  w.addEventListener("message", (event) => {
125
+ // Get the data from the event
126
+ // We don't use a arktype schema here, we trust the server to send valid data
80
127
  const { functionName, requestId, ...data } = event.data || {};
128
+ // Sanity check in case we somehow receive a message without requestId
81
129
  if (!requestId) {
82
130
  throw new Error("[SWARPC Client] Message received without requestId");
83
131
  }
132
+ // Get the associated pending request handlers
84
133
  const handlers = pendingRequests.get(requestId);
85
134
  if (!handlers) {
86
135
  throw new Error(`[SWARPC Client] ${requestId} has no active request handlers`);
87
136
  }
137
+ // React to the data received
138
+ // Unless it's a progress update, the request is finished, thus we can remove it from the pending requests
88
139
  if ("error" in data) {
89
140
  handlers.reject(new Error(data.error.message));
90
141
  pendingRequests.delete(requestId);
@@ -99,24 +150,101 @@ async function startClientListener(worker) {
99
150
  });
100
151
  _clientListenerStarted = true;
101
152
  }
153
+ /**
154
+ *
155
+ * @param procedures procedures the client will be able to call
156
+ * @param param1 various options
157
+ * @param param1.worker if provided, the client will use this worker to post messages.
158
+ * @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.
159
+ */
102
160
  export function Client(procedures, { worker } = {}) {
161
+ // Store procedures on a symbol key, to avoid conflicts with procedure names
103
162
  const instance = { [zProcedures]: procedures };
104
163
  for (const functionName of Object.keys(procedures)) {
164
+ if (typeof functionName !== "string") {
165
+ throw new Error(`[SWARPC Client] Invalid function name, don't use symbols`);
166
+ }
167
+ // Set the method on the instance
168
+ // @ts-expect-error
105
169
  instance[functionName] = (async (input, onProgress = () => { }) => {
170
+ // Validate the input against the procedure's input schema
106
171
  procedures[functionName].input.assert(input);
172
+ // Ensure that we're listening for messages from the server
107
173
  await startClientListener(worker);
174
+ // If no worker is provided, we use the service worker
108
175
  const w = worker ?? (await navigator.serviceWorker.ready.then((r) => r.active));
109
176
  if (!w) {
110
177
  throw new Error("[SWARPC Client] No active service worker found");
111
178
  }
112
179
  return new Promise((resolve, reject) => {
113
180
  if (!worker && !navigator.serviceWorker.controller)
114
- console.warn("[SWARPC Client] Service Worker is not controlling the page");
181
+ l.client.warn("", "Service Worker is not controlling the page");
115
182
  const requestId = generateRequestId();
183
+ // Store promise handlers (as well as progress updates handler)
184
+ // so the client listener can resolve/reject the promise (and react to progress updates)
185
+ // when the server sends messages back
116
186
  pendingRequests.set(requestId, { resolve, onProgress, reject });
117
- w.postMessage({ functionName, input, requestId });
187
+ // Post the message to the server
188
+ l.client.debug(requestId, `Requesting ${functionName} with`, input);
189
+ w.postMessage({ functionName, input, requestId }, {
190
+ transfer: procedures[functionName].autotransfer === "always"
191
+ ? findTransferables(input)
192
+ : [],
193
+ });
118
194
  });
119
195
  });
120
196
  }
121
197
  return instance;
122
198
  }
199
+ /**
200
+ * Convenience shortcuts for logging.
201
+ */
202
+ const l = {
203
+ server: {
204
+ debug: logger("debug", "server"),
205
+ info: logger("info", "server"),
206
+ warn: logger("warn", "server"),
207
+ error: logger("error", "server"),
208
+ },
209
+ client: {
210
+ debug: logger("debug", "client"),
211
+ info: logger("info", "client"),
212
+ warn: logger("warn", "client"),
213
+ error: logger("error", "client"),
214
+ },
215
+ };
216
+ /**
217
+ * Creates partially-applied logging functions given the first 2 args
218
+ * @param severity
219
+ * @param side
220
+ * @returns
221
+ */
222
+ function logger(severity, side) {
223
+ return (rqid, message, ...args) => log(severity, side, rqid, message, ...args);
224
+ }
225
+ /**
226
+ * Send log messages to the console, with a helpful prefix.
227
+ * @param severity
228
+ * @param side
229
+ * @param rqid request ID
230
+ * @param message
231
+ * @param args passed to console methods directly
232
+ */
233
+ function log(severity, side, rqid, message, ...args) {
234
+ const prefix = "[" +
235
+ ["SWARPC", side, rqid ? `%c${rqid}%c` : ""].filter(Boolean).join(" ") +
236
+ "]";
237
+ const prefixStyles = rqid ? ["color: cyan;", "color: inherit;"] : [];
238
+ if (severity === "debug") {
239
+ console.debug(prefix, ...prefixStyles, message, ...args);
240
+ }
241
+ else if (severity === "info") {
242
+ console.info(prefix, ...prefixStyles, message, ...args);
243
+ }
244
+ else if (severity === "warn") {
245
+ console.warn(prefix, ...prefixStyles, message, ...args);
246
+ }
247
+ else if (severity === "error") {
248
+ console.error(prefix, ...prefixStyles, message, ...args);
249
+ }
250
+ }
package/dist/types.d.ts CHANGED
@@ -1,22 +1,71 @@
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
+ * A procedure's corresponding method on the client instance -- used to call the procedure
47
+ */
12
48
  export type ClientMethod<P extends Procedure<Type, Type, Type>> = (input: P["input"]["inferIn"], onProgress?: (progress: P["progress"]["inferOut"]) => void) => Promise<P["success"]["inferOut"]>;
49
+ /**
50
+ * Symbol used as the key for the procedures map on the server instance
51
+ */
13
52
  export declare const zImplementations: unique symbol;
53
+ /**
54
+ * Symbol used as the key for the procedures map on instances
55
+ */
14
56
  export declare const zProcedures: unique symbol;
57
+ /**
58
+ * The sw&rpc client instance, which provides methods to call procedures
59
+ */
15
60
  export type SwarpcClient<Procedures extends ProceduresMap> = {
16
61
  [zProcedures]: Procedures;
17
62
  } & {
18
63
  [F in keyof Procedures]: ClientMethod<Procedures[F]>;
19
64
  };
65
+ /**
66
+ * The sw&rpc server instance, which provides methods to register procedure implementations,
67
+ * and listens for incoming messages that call those procedures
68
+ */
20
69
  export type SwarpcServer<Procedures extends ProceduresMap> = {
21
70
  [zProcedures]: Procedures;
22
71
  [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":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,SAAS,CAAA;AAEnC;;GAEG;AACH,MAAM,MAAM,SAAS,CAAC,CAAC,SAAS,IAAI,EAAE,CAAC,SAAS,IAAI,EAAE,CAAC,SAAS,IAAI,IAAI;IACtE;;OAEG;IACH,KAAK,EAAE,CAAC,CAAA;IACR;;;OAGG;IACH,QAAQ,EAAE,CAAC,CAAA;IACX;;OAEG;IACH,OAAO,EAAE,CAAC,CAAA;IACV;;;;;;;;;OASG;IACH,YAAY,CAAC,EAAE,QAAQ,GAAG,OAAO,GAAG,aAAa,CAAA;CAClD,CAAA;AAED;;GAEG;AACH,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,SAAS,CAAC,KAAK,IAAI,KACzC,OAAO,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAA;AAE1B;;GAEG;AACH,MAAM,MAAM,aAAa,GAAG,MAAM,CAAC,MAAM,EAAE,SAAS,CAAC,IAAI,EAAE,IAAI,EAAE,IAAI,CAAC,CAAC,CAAA;AAEvE;;GAEG;AACH,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;;GAEG;AACH,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;;GAEG;AACH,eAAO,MAAM,gBAAgB,eAAmC,CAAA;AAChE;;GAEG;AACH,eAAO,MAAM,WAAW,eAA8B,CAAA;AAEtD;;GAEG;AACH,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;;;GAGG;AACH,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"}
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.3.0",
3
+ "version": "0.5.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,6 +28,7 @@
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
33
  "typedoc": "typedoc src/swarpc.ts src/types.ts --readme README.md"
33
34
  },
package/src/swarpc.ts CHANGED
@@ -1,25 +1,38 @@
1
- import { type } from "arktype"
1
+ import { type, type Type } from "arktype"
2
2
  import {
3
3
  ImplementationsMap,
4
+ Procedure,
4
5
  zImplementations,
5
6
  zProcedures,
6
7
  type ProceduresMap,
7
8
  type SwarpcClient,
8
9
  type SwarpcServer,
9
10
  } from "./types.js"
11
+ import { findTransferables } from "./utils.js"
10
12
 
11
13
  export type { ProceduresMap, SwarpcClient, SwarpcServer } from "./types.js"
12
14
 
15
+ /**
16
+ * Creates a sw&rpc server instance.
17
+ * @param procedures procedures the server will implement
18
+ * @param param1 various options
19
+ * @param param1.worker if provided, the server will use this worker to post messages, instead of sending it to all clients
20
+ * @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.
21
+ */
13
22
  export function Server<Procedures extends ProceduresMap>(
14
23
  procedures: Procedures,
15
24
  { worker }: { worker?: Worker } = {}
16
25
  ): SwarpcServer<Procedures> {
26
+ // Initialize the instance.
27
+ // Procedures and implementations are stored on properties with symbol keys,
28
+ // to avoid any conflicts with procedure names, and also discourage direct access to them.
17
29
  const instance = {
18
30
  [zProcedures]: procedures,
19
31
  [zImplementations]: {} as ImplementationsMap<Procedures>,
20
32
  start: (self: Window) => {},
21
33
  } as SwarpcServer<Procedures>
22
34
 
35
+ // Set all implementation-setter methods
23
36
  for (const functionName in procedures) {
24
37
  instance[functionName] = ((implementation) => {
25
38
  if (!instance[zProcedures][functionName]) {
@@ -29,6 +42,7 @@ export function Server<Procedures extends ProceduresMap>(
29
42
  }) as SwarpcServer<Procedures>[typeof functionName]
30
43
  }
31
44
 
45
+ // Define payload schema for incoming messages
32
46
  const PayloadSchema = type.or(
33
47
  ...Object.entries(procedures).map(([functionName, { input }]) => ({
34
48
  functionName: type(`"${functionName}"`),
@@ -38,49 +52,77 @@ export function Server<Procedures extends ProceduresMap>(
38
52
  )
39
53
 
40
54
  instance.start = (self: Window) => {
55
+ // Used to post messages back to the client
41
56
  const postMessage = async (
42
- data: { functionName: string; requestId: string } & Partial<{
57
+ data: {
58
+ functionName: string
59
+ requestId: string
60
+ autotransfer: Procedure<Type, Type, Type>["autotransfer"]
61
+ } & Partial<{
43
62
  result: any
44
63
  error: any
45
64
  progress: any
46
65
  }>
47
66
  ) => {
67
+ const transfer =
68
+ data.autotransfer === "never" ? [] : findTransferables(data)
69
+
48
70
  if (worker) {
49
- self.postMessage(data)
71
+ self.postMessage(data, { transfer })
50
72
  } else {
51
73
  await (self as any).clients.matchAll().then((clients: any[]) => {
52
- clients.forEach((client) => client.postMessage(data))
74
+ clients.forEach((client) => client.postMessage(data, { transfer }))
53
75
  })
54
76
  }
55
77
  }
56
78
 
79
+ // Listen for messages from the client
57
80
  self.addEventListener("message", async (event: MessageEvent) => {
81
+ // Decode the payload
58
82
  const { functionName, requestId, input } = PayloadSchema.assert(
59
83
  event.data
60
84
  )
85
+
86
+ l.server.debug(requestId, `Received request for ${functionName}`, input)
87
+
88
+ // Get autotransfer preference from the procedure definition
89
+ const { autotransfer = "output-only" } =
90
+ instance[zProcedures][functionName]
91
+
92
+ // Shorthand function with functionName, requestId, etc. set
93
+ const postMsg = async (
94
+ data: { result: any } | { error: any } | { progress: any }
95
+ ) => postMessage({ functionName, requestId, autotransfer, ...data })
96
+
97
+ // Prepare a function to post errors back to the client
61
98
  const postError = async (error: any) =>
62
- postMessage({
63
- functionName,
64
- requestId,
99
+ postMsg({
65
100
  error: {
66
101
  message: "message" in error ? error.message : String(error),
67
102
  },
68
103
  })
69
104
 
105
+ // Retrieve the implementation for the requested function
70
106
  const implementation = instance[zImplementations][functionName]
71
107
  if (!implementation) {
72
108
  await postError("No implementation found")
73
109
  return
74
110
  }
75
111
 
112
+ // Call the implementation with the input and a progress callback
76
113
  await implementation(input, async (progress: any) => {
77
- await postMessage({ functionName, requestId, progress })
114
+ l.server.debug(requestId, `Progress for ${functionName}`, progress)
115
+ await postMsg({ progress })
78
116
  })
117
+ // Send errors
79
118
  .catch(async (error: any) => {
119
+ l.server.error(requestId, `Error in ${functionName}`, error)
80
120
  await postError(error)
81
121
  })
122
+ // Send results
82
123
  .then(async (result: any) => {
83
- await postMessage({ functionName, requestId, result })
124
+ l.server.debug(requestId, `Result for ${functionName}`, result)
125
+ await postMsg({ result })
84
126
  })
85
127
  })
86
128
  }
@@ -88,22 +130,38 @@ export function Server<Procedures extends ProceduresMap>(
88
130
  return instance
89
131
  }
90
132
 
133
+ /**
134
+ * Generate a random request ID, used to identify requests between client and server.
135
+ * @returns a 6-character hexadecimal string
136
+ */
91
137
  function generateRequestId(): string {
92
- return Math.random().toString(36).substring(2, 15)
138
+ return Math.random().toString(16).substring(2, 8).toUpperCase()
93
139
  }
94
140
 
141
+ /**
142
+ * Pending requests are stored in a map, where the key is the request ID.
143
+ * Each request has a set of handlers: resolve, reject, and onProgress.
144
+ * This allows having a single listener for the client, and having multiple in-flight calls to the same procedure.
145
+ */
146
+ const pendingRequests = new Map<string, PendingRequest>()
95
147
  type PendingRequest = {
96
148
  reject: (err: Error) => void
97
149
  onProgress: (progress: any) => void
98
150
  resolve: (result: any) => void
99
151
  }
100
152
 
101
- const pendingRequests = new Map<string, PendingRequest>()
102
-
153
+ // Have we started the client listener?
103
154
  let _clientListenerStarted = false
155
+
156
+ /**
157
+ * Starts the client listener, which listens for messages from the sw&rpc server.
158
+ * @param worker if provided, the client will use this worker to listen for messages, instead of using the service worker
159
+ * @returns
160
+ */
104
161
  async function startClientListener(worker?: Worker) {
105
162
  if (_clientListenerStarted) return
106
163
 
164
+ // Get service worker registration if no worker is provided
107
165
  if (!worker) {
108
166
  const sw = await navigator.serviceWorker.ready
109
167
  if (!sw?.active) {
@@ -111,20 +169,26 @@ async function startClientListener(worker?: Worker) {
111
169
  }
112
170
 
113
171
  if (!navigator.serviceWorker.controller) {
114
- console.warn("[SWARPC Client] Service Worker is not controlling the page")
172
+ l.client.warn("", "Service Worker is not controlling the page")
115
173
  }
116
174
  }
117
175
 
118
176
  const w = worker ?? navigator.serviceWorker
119
- console.log("[SWARPC Client] Starting client listener on", w)
177
+
178
+ // Start listening for messages
179
+ l.client.debug("", "Starting client listener on", w)
120
180
  w.addEventListener("message", (event) => {
181
+ // Get the data from the event
182
+ // We don't use a arktype schema here, we trust the server to send valid data
121
183
  const { functionName, requestId, ...data } =
122
184
  (event as MessageEvent).data || {}
123
185
 
186
+ // Sanity check in case we somehow receive a message without requestId
124
187
  if (!requestId) {
125
188
  throw new Error("[SWARPC Client] Message received without requestId")
126
189
  }
127
190
 
191
+ // Get the associated pending request handlers
128
192
  const handlers = pendingRequests.get(requestId)
129
193
  if (!handlers) {
130
194
  throw new Error(
@@ -132,6 +196,8 @@ async function startClientListener(worker?: Worker) {
132
196
  )
133
197
  }
134
198
 
199
+ // React to the data received
200
+ // Unless it's a progress update, the request is finished, thus we can remove it from the pending requests
135
201
  if ("error" in data) {
136
202
  handlers.reject(new Error(data.error.message))
137
203
  pendingRequests.delete(requestId)
@@ -146,10 +212,18 @@ async function startClientListener(worker?: Worker) {
146
212
  _clientListenerStarted = true
147
213
  }
148
214
 
215
+ /**
216
+ *
217
+ * @param procedures procedures the client will be able to call
218
+ * @param param1 various options
219
+ * @param param1.worker if provided, the client will use this worker to post messages.
220
+ * @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.
221
+ */
149
222
  export function Client<Procedures extends ProceduresMap>(
150
223
  procedures: Procedures,
151
224
  { worker }: { worker?: Worker } = {}
152
225
  ): SwarpcClient<Procedures> {
226
+ // Store procedures on a symbol key, to avoid conflicts with procedure names
153
227
  const instance = { [zProcedures]: procedures } as Partial<
154
228
  SwarpcClient<Procedures>
155
229
  >
@@ -157,10 +231,21 @@ export function Client<Procedures extends ProceduresMap>(
157
231
  for (const functionName of Object.keys(procedures) as Array<
158
232
  keyof Procedures
159
233
  >) {
234
+ if (typeof functionName !== "string") {
235
+ throw new Error(
236
+ `[SWARPC Client] Invalid function name, don't use symbols`
237
+ )
238
+ }
239
+
240
+ // Set the method on the instance
241
+ // @ts-expect-error
160
242
  instance[functionName] = (async (input: unknown, onProgress = () => {}) => {
243
+ // Validate the input against the procedure's input schema
161
244
  procedures[functionName].input.assert(input)
245
+ // Ensure that we're listening for messages from the server
162
246
  await startClientListener(worker)
163
247
 
248
+ // If no worker is provided, we use the service worker
164
249
  const w =
165
250
  worker ?? (await navigator.serviceWorker.ready.then((r) => r.active))
166
251
 
@@ -170,18 +255,94 @@ export function Client<Procedures extends ProceduresMap>(
170
255
 
171
256
  return new Promise((resolve, reject) => {
172
257
  if (!worker && !navigator.serviceWorker.controller)
173
- console.warn(
174
- "[SWARPC Client] Service Worker is not controlling the page"
175
- )
258
+ l.client.warn("", "Service Worker is not controlling the page")
176
259
 
177
260
  const requestId = generateRequestId()
178
261
 
262
+ // Store promise handlers (as well as progress updates handler)
263
+ // so the client listener can resolve/reject the promise (and react to progress updates)
264
+ // when the server sends messages back
179
265
  pendingRequests.set(requestId, { resolve, onProgress, reject })
180
266
 
181
- w.postMessage({ functionName, input, requestId })
267
+ // Post the message to the server
268
+ l.client.debug(requestId, `Requesting ${functionName} with`, input)
269
+ w.postMessage(
270
+ { functionName, input, requestId },
271
+ {
272
+ transfer:
273
+ procedures[functionName].autotransfer === "always"
274
+ ? findTransferables(input)
275
+ : [],
276
+ }
277
+ )
182
278
  })
183
279
  }) as SwarpcClient<Procedures>[typeof functionName]
184
280
  }
185
281
 
186
282
  return instance as SwarpcClient<Procedures>
187
283
  }
284
+
285
+ /**
286
+ * Convenience shortcuts for logging.
287
+ */
288
+ const l = {
289
+ server: {
290
+ debug: logger("debug", "server"),
291
+ info: logger("info", "server"),
292
+ warn: logger("warn", "server"),
293
+ error: logger("error", "server"),
294
+ },
295
+ client: {
296
+ debug: logger("debug", "client"),
297
+ info: logger("info", "client"),
298
+ warn: logger("warn", "client"),
299
+ error: logger("error", "client"),
300
+ },
301
+ }
302
+
303
+ /**
304
+ * Creates partially-applied logging functions given the first 2 args
305
+ * @param severity
306
+ * @param side
307
+ * @returns
308
+ */
309
+ function logger(
310
+ severity: "debug" | "info" | "warn" | "error",
311
+ side: "server" | "client"
312
+ ) {
313
+ return (rqid: string | null, message: string, ...args: any[]) =>
314
+ log(severity, side, rqid, message, ...args)
315
+ }
316
+
317
+ /**
318
+ * Send log messages to the console, with a helpful prefix.
319
+ * @param severity
320
+ * @param side
321
+ * @param rqid request ID
322
+ * @param message
323
+ * @param args passed to console methods directly
324
+ */
325
+ function log(
326
+ severity: "debug" | "info" | "warn" | "error",
327
+ side: "server" | "client",
328
+ rqid: string | null,
329
+ message: string,
330
+ ...args: any[]
331
+ ) {
332
+ const prefix =
333
+ "[" +
334
+ ["SWARPC", side, rqid ? `%c${rqid}%c` : ""].filter(Boolean).join(" ") +
335
+ "]"
336
+
337
+ const prefixStyles = rqid ? ["color: cyan;", "color: inherit;"] : []
338
+
339
+ if (severity === "debug") {
340
+ console.debug(prefix, ...prefixStyles, message, ...args)
341
+ } else if (severity === "info") {
342
+ console.info(prefix, ...prefixStyles, message, ...args)
343
+ } else if (severity === "warn") {
344
+ console.warn(prefix, ...prefixStyles, message, ...args)
345
+ } else if (severity === "error") {
346
+ console.error(prefix, ...prefixStyles, message, ...args)
347
+ }
348
+ }
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,36 @@ export type ImplementationsMap<Procedures extends ProceduresMap> = {
25
58
  >
26
59
  }
27
60
 
61
+ /**
62
+ * A procedure's corresponding method on the client instance -- used to call the procedure
63
+ */
28
64
  export type ClientMethod<P extends Procedure<Type, Type, Type>> = (
29
65
  input: P["input"]["inferIn"],
30
66
  onProgress?: (progress: P["progress"]["inferOut"]) => void
31
67
  ) => Promise<P["success"]["inferOut"]>
32
68
 
69
+ /**
70
+ * Symbol used as the key for the procedures map on the server instance
71
+ */
33
72
  export const zImplementations = Symbol("SWARPC implementations")
73
+ /**
74
+ * Symbol used as the key for the procedures map on instances
75
+ */
34
76
  export const zProcedures = Symbol("SWARPC procedures")
35
77
 
78
+ /**
79
+ * The sw&rpc client instance, which provides methods to call procedures
80
+ */
36
81
  export type SwarpcClient<Procedures extends ProceduresMap> = {
37
82
  [zProcedures]: Procedures
38
83
  } & {
39
84
  [F in keyof Procedures]: ClientMethod<Procedures[F]>
40
85
  }
41
86
 
87
+ /**
88
+ * The sw&rpc server instance, which provides methods to register procedure implementations,
89
+ * and listens for incoming messages that call those procedures
90
+ */
42
91
  export type SwarpcServer<Procedures extends ProceduresMap> = {
43
92
  [zProcedures]: Procedures
44
93
  [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
+ }