swarpc 0.4.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,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,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,61 +26,89 @@ 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({
37
- functionName,
38
- requestId,
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({
39
59
  error: {
40
60
  message: "message" in error ? error.message : String(error),
41
61
  },
42
62
  });
63
+ // Retrieve the implementation for the requested function
43
64
  const implementation = instance[zImplementations][functionName];
44
65
  if (!implementation) {
45
66
  await postError("No implementation found");
46
67
  return;
47
68
  }
69
+ // Call the implementation with the input and a progress callback
48
70
  await implementation(input, async (progress) => {
49
71
  l.server.debug(requestId, `Progress for ${functionName}`, progress);
50
- await postMessage({ functionName, requestId, progress });
72
+ await postMsg({ progress });
51
73
  })
74
+ // Send errors
52
75
  .catch(async (error) => {
53
76
  l.server.error(requestId, `Error in ${functionName}`, error);
54
77
  await postError(error);
55
78
  })
79
+ // Send results
56
80
  .then(async (result) => {
57
81
  l.server.debug(requestId, `Result for ${functionName}`, result);
58
- await postMessage({ functionName, requestId, result });
82
+ await postMsg({ result });
59
83
  });
60
84
  });
61
85
  };
62
86
  return instance;
63
87
  }
88
+ /**
89
+ * Generate a random request ID, used to identify requests between client and server.
90
+ * @returns a 6-character hexadecimal string
91
+ */
64
92
  function generateRequestId() {
65
93
  return Math.random().toString(16).substring(2, 8).toUpperCase();
66
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
+ */
67
100
  const pendingRequests = new Map();
101
+ // Have we started the client listener?
68
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
+ */
69
108
  async function startClientListener(worker) {
70
109
  if (_clientListenerStarted)
71
110
  return;
111
+ // Get service worker registration if no worker is provided
72
112
  if (!worker) {
73
113
  const sw = await navigator.serviceWorker.ready;
74
114
  if (!sw?.active) {
@@ -79,16 +119,23 @@ async function startClientListener(worker) {
79
119
  }
80
120
  }
81
121
  const w = worker ?? navigator.serviceWorker;
122
+ // Start listening for messages
82
123
  l.client.debug("", "Starting client listener on", w);
83
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
84
127
  const { functionName, requestId, ...data } = event.data || {};
128
+ // Sanity check in case we somehow receive a message without requestId
85
129
  if (!requestId) {
86
130
  throw new Error("[SWARPC Client] Message received without requestId");
87
131
  }
132
+ // Get the associated pending request handlers
88
133
  const handlers = pendingRequests.get(requestId);
89
134
  if (!handlers) {
90
135
  throw new Error(`[SWARPC Client] ${requestId} has no active request handlers`);
91
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
92
139
  if ("error" in data) {
93
140
  handlers.reject(new Error(data.error.message));
94
141
  pendingRequests.delete(requestId);
@@ -103,16 +150,28 @@ async function startClientListener(worker) {
103
150
  });
104
151
  _clientListenerStarted = true;
105
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
+ */
106
160
  export function Client(procedures, { worker } = {}) {
161
+ // Store procedures on a symbol key, to avoid conflicts with procedure names
107
162
  const instance = { [zProcedures]: procedures };
108
163
  for (const functionName of Object.keys(procedures)) {
109
164
  if (typeof functionName !== "string") {
110
165
  throw new Error(`[SWARPC Client] Invalid function name, don't use symbols`);
111
166
  }
167
+ // Set the method on the instance
112
168
  // @ts-expect-error
113
169
  instance[functionName] = (async (input, onProgress = () => { }) => {
170
+ // Validate the input against the procedure's input schema
114
171
  procedures[functionName].input.assert(input);
172
+ // Ensure that we're listening for messages from the server
115
173
  await startClientListener(worker);
174
+ // If no worker is provided, we use the service worker
116
175
  const w = worker ?? (await navigator.serviceWorker.ready.then((r) => r.active));
117
176
  if (!w) {
118
177
  throw new Error("[SWARPC Client] No active service worker found");
@@ -121,28 +180,56 @@ export function Client(procedures, { worker } = {}) {
121
180
  if (!worker && !navigator.serviceWorker.controller)
122
181
  l.client.warn("", "Service Worker is not controlling the page");
123
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
124
186
  pendingRequests.set(requestId, { resolve, onProgress, reject });
187
+ // Post the message to the server
125
188
  l.client.debug(requestId, `Requesting ${functionName} with`, input);
126
- w.postMessage({ functionName, input, requestId });
189
+ w.postMessage({ functionName, input, requestId }, {
190
+ transfer: procedures[functionName].autotransfer === "always"
191
+ ? findTransferables(input)
192
+ : [],
193
+ });
127
194
  });
128
195
  });
129
196
  }
130
197
  return instance;
131
198
  }
199
+ /**
200
+ * Convenience shortcuts for logging.
201
+ */
132
202
  const l = {
133
203
  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),
204
+ debug: logger("debug", "server"),
205
+ info: logger("info", "server"),
206
+ warn: logger("warn", "server"),
207
+ error: logger("error", "server"),
138
208
  },
139
209
  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),
210
+ debug: logger("debug", "client"),
211
+ info: logger("info", "client"),
212
+ warn: logger("warn", "client"),
213
+ error: logger("error", "client"),
144
214
  },
145
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
+ */
146
233
  function log(severity, side, rqid, message, ...args) {
147
234
  const prefix = "[" +
148
235
  ["SWARPC", side, rqid ? `%c${rqid}%c` : ""].filter(Boolean).join(" ") +
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.4.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,55 +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
  )
61
85
 
62
86
  l.server.debug(requestId, `Received request for ${functionName}`, input)
63
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
64
98
  const postError = async (error: any) =>
65
- postMessage({
66
- functionName,
67
- requestId,
99
+ postMsg({
68
100
  error: {
69
101
  message: "message" in error ? error.message : String(error),
70
102
  },
71
103
  })
72
104
 
105
+ // Retrieve the implementation for the requested function
73
106
  const implementation = instance[zImplementations][functionName]
74
107
  if (!implementation) {
75
108
  await postError("No implementation found")
76
109
  return
77
110
  }
78
111
 
112
+ // Call the implementation with the input and a progress callback
79
113
  await implementation(input, async (progress: any) => {
80
114
  l.server.debug(requestId, `Progress for ${functionName}`, progress)
81
- await postMessage({ functionName, requestId, progress })
115
+ await postMsg({ progress })
82
116
  })
117
+ // Send errors
83
118
  .catch(async (error: any) => {
84
119
  l.server.error(requestId, `Error in ${functionName}`, error)
85
120
  await postError(error)
86
121
  })
122
+ // Send results
87
123
  .then(async (result: any) => {
88
124
  l.server.debug(requestId, `Result for ${functionName}`, result)
89
- await postMessage({ functionName, requestId, result })
125
+ await postMsg({ result })
90
126
  })
91
127
  })
92
128
  }
@@ -94,22 +130,38 @@ export function Server<Procedures extends ProceduresMap>(
94
130
  return instance
95
131
  }
96
132
 
133
+ /**
134
+ * Generate a random request ID, used to identify requests between client and server.
135
+ * @returns a 6-character hexadecimal string
136
+ */
97
137
  function generateRequestId(): string {
98
138
  return Math.random().toString(16).substring(2, 8).toUpperCase()
99
139
  }
100
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>()
101
147
  type PendingRequest = {
102
148
  reject: (err: Error) => void
103
149
  onProgress: (progress: any) => void
104
150
  resolve: (result: any) => void
105
151
  }
106
152
 
107
- const pendingRequests = new Map<string, PendingRequest>()
108
-
153
+ // Have we started the client listener?
109
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
+ */
110
161
  async function startClientListener(worker?: Worker) {
111
162
  if (_clientListenerStarted) return
112
163
 
164
+ // Get service worker registration if no worker is provided
113
165
  if (!worker) {
114
166
  const sw = await navigator.serviceWorker.ready
115
167
  if (!sw?.active) {
@@ -122,15 +174,21 @@ async function startClientListener(worker?: Worker) {
122
174
  }
123
175
 
124
176
  const w = worker ?? navigator.serviceWorker
177
+
178
+ // Start listening for messages
125
179
  l.client.debug("", "Starting client listener on", w)
126
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
127
183
  const { functionName, requestId, ...data } =
128
184
  (event as MessageEvent).data || {}
129
185
 
186
+ // Sanity check in case we somehow receive a message without requestId
130
187
  if (!requestId) {
131
188
  throw new Error("[SWARPC Client] Message received without requestId")
132
189
  }
133
190
 
191
+ // Get the associated pending request handlers
134
192
  const handlers = pendingRequests.get(requestId)
135
193
  if (!handlers) {
136
194
  throw new Error(
@@ -138,6 +196,8 @@ async function startClientListener(worker?: Worker) {
138
196
  )
139
197
  }
140
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
141
201
  if ("error" in data) {
142
202
  handlers.reject(new Error(data.error.message))
143
203
  pendingRequests.delete(requestId)
@@ -152,10 +212,18 @@ async function startClientListener(worker?: Worker) {
152
212
  _clientListenerStarted = true
153
213
  }
154
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
+ */
155
222
  export function Client<Procedures extends ProceduresMap>(
156
223
  procedures: Procedures,
157
224
  { worker }: { worker?: Worker } = {}
158
225
  ): SwarpcClient<Procedures> {
226
+ // Store procedures on a symbol key, to avoid conflicts with procedure names
159
227
  const instance = { [zProcedures]: procedures } as Partial<
160
228
  SwarpcClient<Procedures>
161
229
  >
@@ -169,11 +237,15 @@ export function Client<Procedures extends ProceduresMap>(
169
237
  )
170
238
  }
171
239
 
240
+ // Set the method on the instance
172
241
  // @ts-expect-error
173
242
  instance[functionName] = (async (input: unknown, onProgress = () => {}) => {
243
+ // Validate the input against the procedure's input schema
174
244
  procedures[functionName].input.assert(input)
245
+ // Ensure that we're listening for messages from the server
175
246
  await startClientListener(worker)
176
247
 
248
+ // If no worker is provided, we use the service worker
177
249
  const w =
178
250
  worker ?? (await navigator.serviceWorker.ready.then((r) => r.active))
179
251
 
@@ -187,10 +259,22 @@ export function Client<Procedures extends ProceduresMap>(
187
259
 
188
260
  const requestId = generateRequestId()
189
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
190
265
  pendingRequests.set(requestId, { resolve, onProgress, reject })
191
266
 
267
+ // Post the message to the server
192
268
  l.client.debug(requestId, `Requesting ${functionName} with`, input)
193
- w.postMessage({ functionName, input, requestId })
269
+ w.postMessage(
270
+ { functionName, input, requestId },
271
+ {
272
+ transfer:
273
+ procedures[functionName].autotransfer === "always"
274
+ ? findTransferables(input)
275
+ : [],
276
+ }
277
+ )
194
278
  })
195
279
  }) as SwarpcClient<Procedures>[typeof functionName]
196
280
  }
@@ -198,29 +282,46 @@ export function Client<Procedures extends ProceduresMap>(
198
282
  return instance as SwarpcClient<Procedures>
199
283
  }
200
284
 
285
+ /**
286
+ * Convenience shortcuts for logging.
287
+ */
201
288
  const l = {
202
289
  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),
290
+ debug: logger("debug", "server"),
291
+ info: logger("info", "server"),
292
+ warn: logger("warn", "server"),
293
+ error: logger("error", "server"),
211
294
  },
212
295
  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),
296
+ debug: logger("debug", "client"),
297
+ info: logger("info", "client"),
298
+ warn: logger("warn", "client"),
299
+ error: logger("error", "client"),
221
300
  },
222
301
  }
223
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
+ */
224
325
  function log(
225
326
  severity: "debug" | "info" | "warn" | "error",
226
327
  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,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
+ }