swarpc 0.6.1 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/swarpc.ts DELETED
@@ -1,359 +0,0 @@
1
- import { type } from "arktype"
2
- import {
3
- Hooks,
4
- ImplementationsMap,
5
- Payload,
6
- PayloadCore,
7
- zImplementations,
8
- zProcedures,
9
- type ProceduresMap,
10
- type SwarpcClient,
11
- type SwarpcServer,
12
- } from "./types.js"
13
- import { findTransferables } from "./utils.js"
14
-
15
- export type { ProceduresMap, SwarpcClient, SwarpcServer } from "./types.js"
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
- */
24
- export function Server<Procedures extends ProceduresMap>(
25
- procedures: Procedures,
26
- { worker }: { worker?: Worker } = {}
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.
31
- const instance = {
32
- [zProcedures]: procedures,
33
- [zImplementations]: {} as ImplementationsMap<Procedures>,
34
- start: (self: Window) => {},
35
- } as SwarpcServer<Procedures>
36
-
37
- // Set all implementation-setter methods
38
- for (const functionName in procedures) {
39
- instance[functionName] = ((implementation) => {
40
- if (!instance[zProcedures][functionName]) {
41
- throw new Error(`No procedure found for function name: ${functionName}`)
42
- }
43
- instance[zImplementations][functionName] = implementation as any
44
- }) as SwarpcServer<Procedures>[typeof functionName]
45
- }
46
-
47
- // Define payload schema for incoming messages
48
- const PayloadSchema = type.or(
49
- ...Object.entries(procedures).map(([functionName, { input }]) => ({
50
- functionName: type(`"${functionName}"`),
51
- requestId: type("string >= 1"),
52
- input,
53
- }))
54
- )
55
-
56
- instance.start = (self: Window) => {
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
-
62
- if (worker) {
63
- self.postMessage(data, { transfer })
64
- } else {
65
- await (self as any).clients.matchAll().then((clients: any[]) => {
66
- clients.forEach((client) => client.postMessage(data, { transfer }))
67
- })
68
- }
69
- }
70
-
71
- // Listen for messages from the client
72
- self.addEventListener("message", async (event: MessageEvent) => {
73
- // Decode the payload
74
- const { functionName, requestId, input } = PayloadSchema.assert(
75
- event.data
76
- )
77
-
78
- l.server.debug(requestId, `Received request for ${functionName}`, input)
79
-
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
- ) =>
88
- postMessage({
89
- by: "sw&rpc",
90
- functionName,
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({
99
- error: {
100
- message: "message" in error ? error.message : String(error),
101
- },
102
- })
103
-
104
- // Retrieve the implementation for the requested function
105
- const implementation = instance[zImplementations][functionName]
106
- if (!implementation) {
107
- await postError("No implementation found")
108
- return
109
- }
110
-
111
- // Call the implementation with the input and a progress callback
112
- await implementation(input, async (progress: any) => {
113
- l.server.debug(requestId, `Progress for ${functionName}`, progress)
114
- await postMsg({ progress })
115
- })
116
- // Send errors
117
- .catch(async (error: any) => {
118
- l.server.error(requestId, `Error in ${functionName}`, error)
119
- await postError(error)
120
- })
121
- // Send results
122
- .then(async (result: any) => {
123
- l.server.debug(requestId, `Result for ${functionName}`, result)
124
- await postMsg({ result })
125
- })
126
- })
127
- }
128
-
129
- return instance
130
- }
131
-
132
- /**
133
- * Generate a random request ID, used to identify requests between client and server.
134
- * @returns a 6-character hexadecimal string
135
- */
136
- function generateRequestId(): string {
137
- return Math.random().toString(16).substring(2, 8).toUpperCase()
138
- }
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>()
146
- type PendingRequest = {
147
- reject: (err: Error) => void
148
- onProgress: (progress: any) => void
149
- resolve: (result: any) => void
150
- }
151
-
152
- // Have we started the client listener?
153
- let _clientListenerStarted = false
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
- ) {
164
- if (_clientListenerStarted) return
165
-
166
- // Get service worker registration if no worker is provided
167
- if (!worker) {
168
- const sw = await navigator.serviceWorker.ready
169
- if (!sw?.active) {
170
- throw new Error("[SWARPC Client] Service Worker is not active")
171
- }
172
-
173
- if (!navigator.serviceWorker.controller) {
174
- l.client.warn("", "Service Worker is not controlling the page")
175
- }
176
- }
177
-
178
- const w = worker ?? navigator.serviceWorker
179
-
180
- // Start listening for messages
181
- l.client.debug("", "Starting client listener on", w)
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
190
- const { functionName, requestId, ...data } =
191
- eventData as Payload<Procedures>
192
-
193
- // Sanity check in case we somehow receive a message without requestId
194
- if (!requestId) {
195
- throw new Error("[SWARPC Client] Message received without requestId")
196
- }
197
-
198
- // Get the associated pending request handlers
199
- const handlers = pendingRequests.get(requestId)
200
- if (!handlers) {
201
- throw new Error(
202
- `[SWARPC Client] ${requestId} has no active request handlers`
203
- )
204
- }
205
-
206
- // React to the data received: call hook, call handler,
207
- // and remove the request from pendingRequests (unless it's a progress update)
208
- if ("error" in data) {
209
- hooks.error?.(functionName, new Error(data.error.message))
210
- handlers.reject(new Error(data.error.message))
211
- pendingRequests.delete(requestId)
212
- } else if ("progress" in data) {
213
- hooks.progress?.(functionName, data.progress)
214
- handlers.onProgress(data.progress)
215
- } else if ("result" in data) {
216
- hooks.success?.(functionName, data.result)
217
- handlers.resolve(data.result)
218
- pendingRequests.delete(requestId)
219
- }
220
- })
221
-
222
- _clientListenerStarted = true
223
- }
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
- */
233
- export function Client<Procedures extends ProceduresMap>(
234
- procedures: Procedures,
235
- { worker, hooks = {} }: { worker?: Worker; hooks?: Hooks<Procedures> } = {}
236
- ): SwarpcClient<Procedures> {
237
- // Store procedures on a symbol key, to avoid conflicts with procedure names
238
- const instance = { [zProcedures]: procedures } as Partial<
239
- SwarpcClient<Procedures>
240
- >
241
-
242
- for (const functionName of Object.keys(procedures) as Array<
243
- keyof Procedures
244
- >) {
245
- if (typeof functionName !== "string") {
246
- throw new Error(
247
- `[SWARPC Client] Invalid function name, don't use symbols`
248
- )
249
- }
250
-
251
- // Set the method on the instance
252
- // @ts-expect-error
253
- instance[functionName] = (async (input: unknown, onProgress = () => {}) => {
254
- // Validate the input against the procedure's input schema
255
- procedures[functionName].input.assert(input)
256
- // Ensure that we're listening for messages from the server
257
- await startClientListener(worker, hooks)
258
-
259
- // If no worker is provided, we use the service worker
260
- const w =
261
- worker ?? (await navigator.serviceWorker.ready.then((r) => r.active))
262
-
263
- if (!w) {
264
- throw new Error("[SWARPC Client] No active service worker found")
265
- }
266
-
267
- return new Promise((resolve, reject) => {
268
- if (!worker && !navigator.serviceWorker.controller)
269
- l.client.warn("", "Service Worker is not controlling the page")
270
-
271
- const requestId = generateRequestId()
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
276
- pendingRequests.set(requestId, { resolve, onProgress, reject })
277
-
278
- // Post the message to the server
279
- l.client.debug(requestId, `Requesting ${functionName} with`, input)
280
- w.postMessage(
281
- { functionName, input, requestId },
282
- {
283
- transfer:
284
- procedures[functionName].autotransfer === "always"
285
- ? findTransferables(input)
286
- : [],
287
- }
288
- )
289
- })
290
- }) as SwarpcClient<Procedures>[typeof functionName]
291
- }
292
-
293
- return instance as SwarpcClient<Procedures>
294
- }
295
-
296
- /**
297
- * Convenience shortcuts for logging.
298
- */
299
- const l = {
300
- server: {
301
- debug: logger("debug", "server"),
302
- info: logger("info", "server"),
303
- warn: logger("warn", "server"),
304
- error: logger("error", "server"),
305
- },
306
- client: {
307
- debug: logger("debug", "client"),
308
- info: logger("info", "client"),
309
- warn: logger("warn", "client"),
310
- error: logger("error", "client"),
311
- },
312
- }
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
- */
336
- function log(
337
- severity: "debug" | "info" | "warn" | "error",
338
- side: "server" | "client",
339
- rqid: string | null,
340
- message: string,
341
- ...args: any[]
342
- ) {
343
- const prefix =
344
- "[" +
345
- ["SWARPC", side, rqid ? `%c${rqid}%c` : ""].filter(Boolean).join(" ") +
346
- "]"
347
-
348
- const prefixStyles = rqid ? ["color: cyan;", "color: inherit;"] : []
349
-
350
- if (severity === "debug") {
351
- console.debug(prefix, ...prefixStyles, message, ...args)
352
- } else if (severity === "info") {
353
- console.info(prefix, ...prefixStyles, message, ...args)
354
- } else if (severity === "warn") {
355
- console.warn(prefix, ...prefixStyles, message, ...args)
356
- } else if (severity === "error") {
357
- console.error(prefix, ...prefixStyles, message, ...args)
358
- }
359
- }