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/README.md +44 -0
- package/dist/client.d.ts +22 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +159 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/log.d.ts +20 -0
- package/dist/log.d.ts.map +1 -0
- package/dist/log.js +45 -0
- package/dist/server.d.ts +15 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +132 -0
- package/dist/types.d.ts +153 -7
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +20 -0
- package/package.json +11 -7
- package/src/client.ts +245 -0
- package/src/index.ts +3 -0
- package/src/log.ts +62 -0
- package/src/server.ts +193 -0
- package/src/types.ts +66 -12
- package/dist/swarpc.d.ts +0 -25
- package/dist/swarpc.d.ts.map +0 -1
- package/dist/swarpc.js +0 -264
- package/src/swarpc.ts +0 -359
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
|
-
}
|