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/server.ts
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { type } from "arktype"
|
|
2
|
+
import { createLogger, type LogLevel } from "./log.js"
|
|
3
|
+
import {
|
|
4
|
+
ImplementationsMap,
|
|
5
|
+
Payload,
|
|
6
|
+
PayloadCore,
|
|
7
|
+
PayloadHeaderSchema,
|
|
8
|
+
PayloadSchema,
|
|
9
|
+
zImplementations,
|
|
10
|
+
zProcedures,
|
|
11
|
+
type ProceduresMap,
|
|
12
|
+
type SwarpcServer,
|
|
13
|
+
} from "./types.js"
|
|
14
|
+
import { findTransferables } from "./utils.js"
|
|
15
|
+
|
|
16
|
+
export type { SwarpcServer } from "./types.js"
|
|
17
|
+
|
|
18
|
+
const abortControllers = new Map<string, AbortController>()
|
|
19
|
+
const abortedRequests = new Set<string>()
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Creates a sw&rpc server instance.
|
|
23
|
+
* @param procedures procedures the server will implement
|
|
24
|
+
* @param options various options
|
|
25
|
+
* @param options.worker if provided, the server will use this worker to post messages, instead of sending it to all clients
|
|
26
|
+
* @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.
|
|
27
|
+
*/
|
|
28
|
+
export function Server<Procedures extends ProceduresMap>(
|
|
29
|
+
procedures: Procedures,
|
|
30
|
+
{ worker, loglevel = "debug" }: { worker?: Worker; loglevel?: LogLevel } = {}
|
|
31
|
+
): SwarpcServer<Procedures> {
|
|
32
|
+
const l = createLogger("server", loglevel)
|
|
33
|
+
|
|
34
|
+
// Initialize the instance.
|
|
35
|
+
// Procedures and implementations are stored on properties with symbol keys,
|
|
36
|
+
// to avoid any conflicts with procedure names, and also discourage direct access to them.
|
|
37
|
+
const instance = {
|
|
38
|
+
[zProcedures]: procedures,
|
|
39
|
+
[zImplementations]: {} as ImplementationsMap<Procedures>,
|
|
40
|
+
start: (self: Window) => {},
|
|
41
|
+
} as SwarpcServer<Procedures>
|
|
42
|
+
|
|
43
|
+
// Set all implementation-setter methods
|
|
44
|
+
for (const functionName in procedures) {
|
|
45
|
+
instance[functionName] = ((implementation) => {
|
|
46
|
+
if (!instance[zProcedures][functionName]) {
|
|
47
|
+
throw new Error(`No procedure found for function name: ${functionName}`)
|
|
48
|
+
}
|
|
49
|
+
instance[zImplementations][functionName] = (
|
|
50
|
+
input,
|
|
51
|
+
onProgress,
|
|
52
|
+
abortSignal
|
|
53
|
+
) => {
|
|
54
|
+
abortSignal?.throwIfAborted()
|
|
55
|
+
return new Promise((resolve, reject) => {
|
|
56
|
+
abortSignal?.addEventListener("abort", () => {
|
|
57
|
+
let { requestId, reason } = abortSignal?.reason
|
|
58
|
+
l.debug(requestId, `Aborted ${functionName} request: ${reason}`)
|
|
59
|
+
reject({ aborted: reason })
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
implementation(input, onProgress, abortSignal)
|
|
63
|
+
.then(resolve)
|
|
64
|
+
.catch(reject)
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
}) as SwarpcServer<Procedures>[typeof functionName]
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
instance.start = (self: Window) => {
|
|
71
|
+
// Used to post messages back to the client
|
|
72
|
+
const postMessage = async (
|
|
73
|
+
autotransfer: boolean,
|
|
74
|
+
data: Payload<Procedures>
|
|
75
|
+
) => {
|
|
76
|
+
const transfer = autotransfer ? [] : findTransferables(data)
|
|
77
|
+
|
|
78
|
+
if (worker) {
|
|
79
|
+
self.postMessage(data, { transfer })
|
|
80
|
+
} else {
|
|
81
|
+
await (self as any).clients.matchAll().then((clients: any[]) => {
|
|
82
|
+
clients.forEach((client) => client.postMessage(data, { transfer }))
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Listen for messages from the client
|
|
88
|
+
self.addEventListener("message", async (event: MessageEvent) => {
|
|
89
|
+
// Decode the payload
|
|
90
|
+
const { requestId, functionName } = PayloadHeaderSchema(
|
|
91
|
+
type.enumerated(...Object.keys(procedures))
|
|
92
|
+
).assert(event.data)
|
|
93
|
+
|
|
94
|
+
l.debug(requestId, `Received request for ${functionName}`, event.data)
|
|
95
|
+
|
|
96
|
+
// Get autotransfer preference from the procedure definition
|
|
97
|
+
const { autotransfer = "output-only", ...schemas } =
|
|
98
|
+
instance[zProcedures][functionName]
|
|
99
|
+
|
|
100
|
+
// Shorthand function with functionName, requestId, etc. set
|
|
101
|
+
const postMsg = async (
|
|
102
|
+
data: PayloadCore<Procedures, typeof functionName>
|
|
103
|
+
) => {
|
|
104
|
+
if (abortedRequests.has(requestId)) return
|
|
105
|
+
await postMessage(autotransfer !== "never", {
|
|
106
|
+
by: "sw&rpc",
|
|
107
|
+
functionName,
|
|
108
|
+
requestId,
|
|
109
|
+
...data,
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Prepare a function to post errors back to the client
|
|
114
|
+
const postError = async (error: any) =>
|
|
115
|
+
postMsg({
|
|
116
|
+
error: {
|
|
117
|
+
message: "message" in error ? error.message : String(error),
|
|
118
|
+
},
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
// Retrieve the implementation for the requested function
|
|
122
|
+
const implementation = instance[zImplementations][functionName]
|
|
123
|
+
if (!implementation) {
|
|
124
|
+
await postError("No implementation found")
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Define payload schema for incoming messages
|
|
129
|
+
const payload = PayloadSchema(
|
|
130
|
+
type(`"${functionName}"`),
|
|
131
|
+
schemas.input,
|
|
132
|
+
schemas.progress,
|
|
133
|
+
schemas.success
|
|
134
|
+
).assert(event.data)
|
|
135
|
+
|
|
136
|
+
// Handle abortion requests (pro-choice ftw!!)
|
|
137
|
+
if (payload.abort) {
|
|
138
|
+
const controller = abortControllers.get(requestId)
|
|
139
|
+
|
|
140
|
+
if (!controller)
|
|
141
|
+
await postError("No abort controller found for request")
|
|
142
|
+
|
|
143
|
+
controller?.abort(payload.abort.reason)
|
|
144
|
+
return
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Set up the abort controller for this request
|
|
148
|
+
abortControllers.set(requestId, new AbortController())
|
|
149
|
+
|
|
150
|
+
if (!payload.input) {
|
|
151
|
+
await postError("No input provided")
|
|
152
|
+
return
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Call the implementation with the input and a progress callback
|
|
156
|
+
await implementation(
|
|
157
|
+
payload.input,
|
|
158
|
+
async (progress: any) => {
|
|
159
|
+
l.debug(requestId, `Progress for ${functionName}`, progress)
|
|
160
|
+
await postMsg({ progress })
|
|
161
|
+
},
|
|
162
|
+
abortControllers.get(requestId)?.signal
|
|
163
|
+
)
|
|
164
|
+
// Send errors
|
|
165
|
+
.catch(async (error: any) => {
|
|
166
|
+
// Handle errors caused by abortions
|
|
167
|
+
if ("aborted" in error) {
|
|
168
|
+
l.debug(
|
|
169
|
+
requestId,
|
|
170
|
+
`Received abort error for ${functionName}`,
|
|
171
|
+
error.aborted
|
|
172
|
+
)
|
|
173
|
+
abortedRequests.add(requestId)
|
|
174
|
+
abortControllers.delete(requestId)
|
|
175
|
+
return
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
l.error(requestId, `Error in ${functionName}`, error)
|
|
179
|
+
await postError(error)
|
|
180
|
+
})
|
|
181
|
+
// Send results
|
|
182
|
+
.then(async (result: any) => {
|
|
183
|
+
l.debug(requestId, `Result for ${functionName}`, result)
|
|
184
|
+
await postMsg({ result })
|
|
185
|
+
})
|
|
186
|
+
.finally(() => {
|
|
187
|
+
abortedRequests.delete(requestId)
|
|
188
|
+
})
|
|
189
|
+
})
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return instance
|
|
193
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type, type Type } from "arktype"
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* A procedure declaration
|
|
@@ -30,16 +30,35 @@ export type Procedure<I extends Type, P extends Type, S extends Type> = {
|
|
|
30
30
|
autotransfer?: "always" | "never" | "output-only"
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* A promise that you can cancel by calling `.cancel(reason)` on it:
|
|
35
|
+
*
|
|
36
|
+
* ```js
|
|
37
|
+
* const { request, cancel } = client.runProcedure.cancelable(input, onProgress)
|
|
38
|
+
* setTimeout(() => cancel("Cancelled by user"), 1000)
|
|
39
|
+
* const result = await request
|
|
40
|
+
* ```
|
|
41
|
+
*/
|
|
42
|
+
export type CancelablePromise<T> = {
|
|
43
|
+
request: Promise<T>
|
|
44
|
+
/**
|
|
45
|
+
* Abort the request.
|
|
46
|
+
* @param reason The reason for cancelling the request.
|
|
47
|
+
*/
|
|
48
|
+
cancel: (reason: string) => Promise<void>
|
|
49
|
+
}
|
|
50
|
+
|
|
33
51
|
/**
|
|
34
52
|
* An implementation of a procedure
|
|
35
53
|
*/
|
|
36
54
|
export type ProcedureImplementation<
|
|
37
55
|
I extends Type,
|
|
38
56
|
P extends Type,
|
|
39
|
-
S extends Type
|
|
57
|
+
S extends Type,
|
|
40
58
|
> = (
|
|
41
59
|
input: I["inferOut"],
|
|
42
|
-
onProgress: (progress: P["inferIn"]) => void
|
|
60
|
+
onProgress: (progress: P["inferIn"]) => void,
|
|
61
|
+
abortSignal?: AbortSignal
|
|
43
62
|
) => Promise<S["inferIn"]>
|
|
44
63
|
|
|
45
64
|
/**
|
|
@@ -85,19 +104,32 @@ export type Hooks<Procedures extends ProceduresMap> = {
|
|
|
85
104
|
) => void
|
|
86
105
|
}
|
|
87
106
|
|
|
107
|
+
export const PayloadHeaderSchema = type("<Name extends string>", {
|
|
108
|
+
by: '"sw&rpc"',
|
|
109
|
+
functionName: "Name",
|
|
110
|
+
requestId: "string >= 1",
|
|
111
|
+
})
|
|
112
|
+
|
|
88
113
|
export type PayloadHeader<
|
|
89
114
|
PM extends ProceduresMap,
|
|
90
|
-
Name extends keyof PM = keyof PM
|
|
115
|
+
Name extends keyof PM = keyof PM,
|
|
91
116
|
> = {
|
|
92
117
|
by: "sw&rpc"
|
|
93
118
|
functionName: Name & string
|
|
94
119
|
requestId: string
|
|
95
|
-
autotransfer: PM[Name]["autotransfer"]
|
|
96
120
|
}
|
|
97
121
|
|
|
122
|
+
export const PayloadCoreSchema = type("<I, P, S>", {
|
|
123
|
+
"input?": "I",
|
|
124
|
+
"progress?": "P",
|
|
125
|
+
"result?": "S",
|
|
126
|
+
"abort?": { reason: "string" },
|
|
127
|
+
"error?": { message: "string" },
|
|
128
|
+
})
|
|
129
|
+
|
|
98
130
|
export type PayloadCore<
|
|
99
131
|
PM extends ProceduresMap,
|
|
100
|
-
Name extends keyof PM = keyof PM
|
|
132
|
+
Name extends keyof PM = keyof PM,
|
|
101
133
|
> =
|
|
102
134
|
| {
|
|
103
135
|
input: PM[Name]["input"]["inferOut"]
|
|
@@ -108,25 +140,45 @@ export type PayloadCore<
|
|
|
108
140
|
| {
|
|
109
141
|
result: PM[Name]["success"]["inferOut"]
|
|
110
142
|
}
|
|
143
|
+
| {
|
|
144
|
+
abort: { reason: string }
|
|
145
|
+
}
|
|
111
146
|
| {
|
|
112
147
|
error: { message: string }
|
|
113
148
|
}
|
|
114
149
|
|
|
150
|
+
export const PayloadSchema = type
|
|
151
|
+
.scope({ PayloadCoreSchema, PayloadHeaderSchema })
|
|
152
|
+
.type("<Name extends string, I, P, S>", [
|
|
153
|
+
"PayloadHeaderSchema<Name>",
|
|
154
|
+
"&",
|
|
155
|
+
"PayloadCoreSchema<I, P, S>",
|
|
156
|
+
])
|
|
157
|
+
|
|
115
158
|
/**
|
|
116
159
|
* The effective payload as sent by the server to the client
|
|
117
160
|
*/
|
|
118
161
|
export type Payload<
|
|
119
162
|
PM extends ProceduresMap,
|
|
120
|
-
Name extends keyof PM = keyof PM
|
|
163
|
+
Name extends keyof PM = keyof PM,
|
|
121
164
|
> = PayloadHeader<PM, Name> & PayloadCore<PM, Name>
|
|
122
165
|
|
|
123
166
|
/**
|
|
124
|
-
* A procedure's corresponding method on the client instance -- used to call the procedure
|
|
167
|
+
* A procedure's corresponding method on the client instance -- used to call the procedure. If you want to be able to cancel the request, you can use the `cancelable` method instead of running the procedure directly.
|
|
125
168
|
*/
|
|
126
|
-
export type ClientMethod<P extends Procedure<Type, Type, Type>> = (
|
|
169
|
+
export type ClientMethod<P extends Procedure<Type, Type, Type>> = ((
|
|
127
170
|
input: P["input"]["inferIn"],
|
|
128
171
|
onProgress?: (progress: P["progress"]["inferOut"]) => void
|
|
129
|
-
) => Promise<P["success"]["inferOut"]>
|
|
172
|
+
) => Promise<P["success"]["inferOut"]>) & {
|
|
173
|
+
/**
|
|
174
|
+
* A method that returns a `CancelablePromise`. Cancel it by calling `.cancel(reason)` on it, and wait for the request to resolve by awaiting the `request` property on the returned object.
|
|
175
|
+
*/
|
|
176
|
+
cancelable: (
|
|
177
|
+
input: P["input"]["inferIn"],
|
|
178
|
+
onProgress?: (progress: P["progress"]["inferOut"]) => void,
|
|
179
|
+
requestId?: string
|
|
180
|
+
) => CancelablePromise<P["success"]["inferOut"]>
|
|
181
|
+
}
|
|
130
182
|
|
|
131
183
|
/**
|
|
132
184
|
* Symbol used as the key for the procedures map on the server instance
|
|
@@ -138,7 +190,9 @@ export const zImplementations = Symbol("SWARPC implementations")
|
|
|
138
190
|
export const zProcedures = Symbol("SWARPC procedures")
|
|
139
191
|
|
|
140
192
|
/**
|
|
141
|
-
* The sw&rpc client instance, which provides methods to call procedures
|
|
193
|
+
* The sw&rpc client instance, which provides methods to call procedures.
|
|
194
|
+
* Each property of the procedures map will be a method, that accepts an input, an optional onProgress callback and an optional request ID.
|
|
195
|
+
* If you want to be able to cancel the request, you can set the request's ID yourself, and call `.abort(requestId, reason)` on the client instance to cancel it.
|
|
142
196
|
*/
|
|
143
197
|
export type SwarpcClient<Procedures extends ProceduresMap> = {
|
|
144
198
|
[zProcedures]: Procedures
|
|
@@ -153,7 +207,7 @@ export type SwarpcClient<Procedures extends ProceduresMap> = {
|
|
|
153
207
|
export type SwarpcServer<Procedures extends ProceduresMap> = {
|
|
154
208
|
[zProcedures]: Procedures
|
|
155
209
|
[zImplementations]: ImplementationsMap<Procedures>
|
|
156
|
-
start(self: Window): void
|
|
210
|
+
start(self: Window | Worker): void
|
|
157
211
|
} & {
|
|
158
212
|
[F in keyof Procedures]: (
|
|
159
213
|
impl: ProcedureImplementation<
|
package/dist/swarpc.d.ts
DELETED
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
import { Hooks, type ProceduresMap, type SwarpcClient, type SwarpcServer } from "./types.js";
|
|
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
|
-
*/
|
|
10
|
-
export declare function Server<Procedures extends ProceduresMap>(procedures: Procedures, { worker }?: {
|
|
11
|
-
worker?: Worker;
|
|
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
|
-
* @param param1.hooks hooks to run on messages received from the server
|
|
19
|
-
* @returns a sw&rpc client instance. Each property of the procedures map will be a method, that accepts an input and an optional onProgress callback.
|
|
20
|
-
*/
|
|
21
|
-
export declare function Client<Procedures extends ProceduresMap>(procedures: Procedures, { worker, hooks }?: {
|
|
22
|
-
worker?: Worker;
|
|
23
|
-
hooks?: Hooks<Procedures>;
|
|
24
|
-
}): SwarpcClient<Procedures>;
|
|
25
|
-
//# sourceMappingURL=swarpc.d.ts.map
|
package/dist/swarpc.d.ts.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"swarpc.d.ts","sourceRoot":"","sources":["../src/swarpc.ts"],"names":[],"mappings":"AACA,OAAO,EACL,KAAK,EAML,KAAK,aAAa,EAClB,KAAK,YAAY,EACjB,KAAK,YAAY,EAClB,MAAM,YAAY,CAAA;AAGnB,YAAY,EAAE,aAAa,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,YAAY,CAAA;AAE3E;;;;;;GAMG;AACH,wBAAgB,MAAM,CAAC,UAAU,SAAS,aAAa,EACrD,UAAU,EAAE,UAAU,EACtB,EAAE,MAAM,EAAE,GAAE;IAAE,MAAM,CAAC,EAAE,MAAM,CAAA;CAAO,GACnC,YAAY,CAAC,UAAU,CAAC,CAuG1B;AA+FD;;;;;;;GAOG;AACH,wBAAgB,MAAM,CAAC,UAAU,SAAS,aAAa,EACrD,UAAU,EAAE,UAAU,EACtB,EAAE,MAAM,EAAE,KAAU,EAAE,GAAE;IAAE,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,KAAK,CAAC,UAAU,CAAC,CAAA;CAAO,GAC1E,YAAY,CAAC,UAAU,CAAC,CA0D1B"}
|
package/dist/swarpc.js
DELETED
|
@@ -1,264 +0,0 @@
|
|
|
1
|
-
import { type } from "arktype";
|
|
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
|
-
*/
|
|
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.
|
|
15
|
-
const instance = {
|
|
16
|
-
[zProcedures]: procedures,
|
|
17
|
-
[zImplementations]: {},
|
|
18
|
-
start: (self) => { },
|
|
19
|
-
};
|
|
20
|
-
// Set all implementation-setter methods
|
|
21
|
-
for (const functionName in procedures) {
|
|
22
|
-
instance[functionName] = ((implementation) => {
|
|
23
|
-
if (!instance[zProcedures][functionName]) {
|
|
24
|
-
throw new Error(`No procedure found for function name: ${functionName}`);
|
|
25
|
-
}
|
|
26
|
-
instance[zImplementations][functionName] = implementation;
|
|
27
|
-
});
|
|
28
|
-
}
|
|
29
|
-
// Define payload schema for incoming messages
|
|
30
|
-
const PayloadSchema = type.or(...Object.entries(procedures).map(([functionName, { input }]) => ({
|
|
31
|
-
functionName: type(`"${functionName}"`),
|
|
32
|
-
requestId: type("string >= 1"),
|
|
33
|
-
input,
|
|
34
|
-
})));
|
|
35
|
-
instance.start = (self) => {
|
|
36
|
-
// Used to post messages back to the client
|
|
37
|
-
const postMessage = async (data) => {
|
|
38
|
-
const transfer = data.autotransfer === "never" ? [] : findTransferables(data);
|
|
39
|
-
if (worker) {
|
|
40
|
-
self.postMessage(data, { transfer });
|
|
41
|
-
}
|
|
42
|
-
else {
|
|
43
|
-
await self.clients.matchAll().then((clients) => {
|
|
44
|
-
clients.forEach((client) => client.postMessage(data, { transfer }));
|
|
45
|
-
});
|
|
46
|
-
}
|
|
47
|
-
};
|
|
48
|
-
// Listen for messages from the client
|
|
49
|
-
self.addEventListener("message", async (event) => {
|
|
50
|
-
// Decode the payload
|
|
51
|
-
const { functionName, requestId, input } = PayloadSchema.assert(event.data);
|
|
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({
|
|
57
|
-
by: "sw&rpc",
|
|
58
|
-
functionName,
|
|
59
|
-
requestId,
|
|
60
|
-
autotransfer,
|
|
61
|
-
...data,
|
|
62
|
-
});
|
|
63
|
-
// Prepare a function to post errors back to the client
|
|
64
|
-
const postError = async (error) => postMsg({
|
|
65
|
-
error: {
|
|
66
|
-
message: "message" in error ? error.message : String(error),
|
|
67
|
-
},
|
|
68
|
-
});
|
|
69
|
-
// Retrieve the implementation for the requested function
|
|
70
|
-
const implementation = instance[zImplementations][functionName];
|
|
71
|
-
if (!implementation) {
|
|
72
|
-
await postError("No implementation found");
|
|
73
|
-
return;
|
|
74
|
-
}
|
|
75
|
-
// Call the implementation with the input and a progress callback
|
|
76
|
-
await implementation(input, async (progress) => {
|
|
77
|
-
l.server.debug(requestId, `Progress for ${functionName}`, progress);
|
|
78
|
-
await postMsg({ progress });
|
|
79
|
-
})
|
|
80
|
-
// Send errors
|
|
81
|
-
.catch(async (error) => {
|
|
82
|
-
l.server.error(requestId, `Error in ${functionName}`, error);
|
|
83
|
-
await postError(error);
|
|
84
|
-
})
|
|
85
|
-
// Send results
|
|
86
|
-
.then(async (result) => {
|
|
87
|
-
l.server.debug(requestId, `Result for ${functionName}`, result);
|
|
88
|
-
await postMsg({ result });
|
|
89
|
-
});
|
|
90
|
-
});
|
|
91
|
-
};
|
|
92
|
-
return instance;
|
|
93
|
-
}
|
|
94
|
-
/**
|
|
95
|
-
* Generate a random request ID, used to identify requests between client and server.
|
|
96
|
-
* @returns a 6-character hexadecimal string
|
|
97
|
-
*/
|
|
98
|
-
function generateRequestId() {
|
|
99
|
-
return Math.random().toString(16).substring(2, 8).toUpperCase();
|
|
100
|
-
}
|
|
101
|
-
/**
|
|
102
|
-
* Pending requests are stored in a map, where the key is the request ID.
|
|
103
|
-
* Each request has a set of handlers: resolve, reject, and onProgress.
|
|
104
|
-
* This allows having a single listener for the client, and having multiple in-flight calls to the same procedure.
|
|
105
|
-
*/
|
|
106
|
-
const pendingRequests = new Map();
|
|
107
|
-
// Have we started the client listener?
|
|
108
|
-
let _clientListenerStarted = false;
|
|
109
|
-
/**
|
|
110
|
-
* Starts the client listener, which listens for messages from the sw&rpc server.
|
|
111
|
-
* @param worker if provided, the client will use this worker to listen for messages, instead of using the service worker
|
|
112
|
-
* @returns
|
|
113
|
-
*/
|
|
114
|
-
async function startClientListener(worker, hooks = {}) {
|
|
115
|
-
if (_clientListenerStarted)
|
|
116
|
-
return;
|
|
117
|
-
// Get service worker registration if no worker is provided
|
|
118
|
-
if (!worker) {
|
|
119
|
-
const sw = await navigator.serviceWorker.ready;
|
|
120
|
-
if (!sw?.active) {
|
|
121
|
-
throw new Error("[SWARPC Client] Service Worker is not active");
|
|
122
|
-
}
|
|
123
|
-
if (!navigator.serviceWorker.controller) {
|
|
124
|
-
l.client.warn("", "Service Worker is not controlling the page");
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
const w = worker ?? navigator.serviceWorker;
|
|
128
|
-
// Start listening for messages
|
|
129
|
-
l.client.debug("", "Starting client listener on", w);
|
|
130
|
-
w.addEventListener("message", (event) => {
|
|
131
|
-
// Get the data from the event
|
|
132
|
-
const eventData = event.data || {};
|
|
133
|
-
// Ignore other messages that aren't for us
|
|
134
|
-
if (eventData?.by !== "sw&rpc")
|
|
135
|
-
return;
|
|
136
|
-
// We don't use a arktype schema here, we trust the server to send valid data
|
|
137
|
-
const { functionName, requestId, ...data } = eventData;
|
|
138
|
-
// Sanity check in case we somehow receive a message without requestId
|
|
139
|
-
if (!requestId) {
|
|
140
|
-
throw new Error("[SWARPC Client] Message received without requestId");
|
|
141
|
-
}
|
|
142
|
-
// Get the associated pending request handlers
|
|
143
|
-
const handlers = pendingRequests.get(requestId);
|
|
144
|
-
if (!handlers) {
|
|
145
|
-
throw new Error(`[SWARPC Client] ${requestId} has no active request handlers`);
|
|
146
|
-
}
|
|
147
|
-
// React to the data received: call hook, call handler,
|
|
148
|
-
// and remove the request from pendingRequests (unless it's a progress update)
|
|
149
|
-
if ("error" in data) {
|
|
150
|
-
hooks.error?.(functionName, new Error(data.error.message));
|
|
151
|
-
handlers.reject(new Error(data.error.message));
|
|
152
|
-
pendingRequests.delete(requestId);
|
|
153
|
-
}
|
|
154
|
-
else if ("progress" in data) {
|
|
155
|
-
hooks.progress?.(functionName, data.progress);
|
|
156
|
-
handlers.onProgress(data.progress);
|
|
157
|
-
}
|
|
158
|
-
else if ("result" in data) {
|
|
159
|
-
hooks.success?.(functionName, data.result);
|
|
160
|
-
handlers.resolve(data.result);
|
|
161
|
-
pendingRequests.delete(requestId);
|
|
162
|
-
}
|
|
163
|
-
});
|
|
164
|
-
_clientListenerStarted = true;
|
|
165
|
-
}
|
|
166
|
-
/**
|
|
167
|
-
*
|
|
168
|
-
* @param procedures procedures the client will be able to call
|
|
169
|
-
* @param param1 various options
|
|
170
|
-
* @param param1.worker if provided, the client will use this worker to post messages.
|
|
171
|
-
* @param param1.hooks hooks to run on messages received from the server
|
|
172
|
-
* @returns a sw&rpc client instance. Each property of the procedures map will be a method, that accepts an input and an optional onProgress callback.
|
|
173
|
-
*/
|
|
174
|
-
export function Client(procedures, { worker, hooks = {} } = {}) {
|
|
175
|
-
// Store procedures on a symbol key, to avoid conflicts with procedure names
|
|
176
|
-
const instance = { [zProcedures]: procedures };
|
|
177
|
-
for (const functionName of Object.keys(procedures)) {
|
|
178
|
-
if (typeof functionName !== "string") {
|
|
179
|
-
throw new Error(`[SWARPC Client] Invalid function name, don't use symbols`);
|
|
180
|
-
}
|
|
181
|
-
// Set the method on the instance
|
|
182
|
-
// @ts-expect-error
|
|
183
|
-
instance[functionName] = (async (input, onProgress = () => { }) => {
|
|
184
|
-
// Validate the input against the procedure's input schema
|
|
185
|
-
procedures[functionName].input.assert(input);
|
|
186
|
-
// Ensure that we're listening for messages from the server
|
|
187
|
-
await startClientListener(worker, hooks);
|
|
188
|
-
// If no worker is provided, we use the service worker
|
|
189
|
-
const w = worker ?? (await navigator.serviceWorker.ready.then((r) => r.active));
|
|
190
|
-
if (!w) {
|
|
191
|
-
throw new Error("[SWARPC Client] No active service worker found");
|
|
192
|
-
}
|
|
193
|
-
return new Promise((resolve, reject) => {
|
|
194
|
-
if (!worker && !navigator.serviceWorker.controller)
|
|
195
|
-
l.client.warn("", "Service Worker is not controlling the page");
|
|
196
|
-
const requestId = generateRequestId();
|
|
197
|
-
// Store promise handlers (as well as progress updates handler)
|
|
198
|
-
// so the client listener can resolve/reject the promise (and react to progress updates)
|
|
199
|
-
// when the server sends messages back
|
|
200
|
-
pendingRequests.set(requestId, { resolve, onProgress, reject });
|
|
201
|
-
// Post the message to the server
|
|
202
|
-
l.client.debug(requestId, `Requesting ${functionName} with`, input);
|
|
203
|
-
w.postMessage({ functionName, input, requestId }, {
|
|
204
|
-
transfer: procedures[functionName].autotransfer === "always"
|
|
205
|
-
? findTransferables(input)
|
|
206
|
-
: [],
|
|
207
|
-
});
|
|
208
|
-
});
|
|
209
|
-
});
|
|
210
|
-
}
|
|
211
|
-
return instance;
|
|
212
|
-
}
|
|
213
|
-
/**
|
|
214
|
-
* Convenience shortcuts for logging.
|
|
215
|
-
*/
|
|
216
|
-
const l = {
|
|
217
|
-
server: {
|
|
218
|
-
debug: logger("debug", "server"),
|
|
219
|
-
info: logger("info", "server"),
|
|
220
|
-
warn: logger("warn", "server"),
|
|
221
|
-
error: logger("error", "server"),
|
|
222
|
-
},
|
|
223
|
-
client: {
|
|
224
|
-
debug: logger("debug", "client"),
|
|
225
|
-
info: logger("info", "client"),
|
|
226
|
-
warn: logger("warn", "client"),
|
|
227
|
-
error: logger("error", "client"),
|
|
228
|
-
},
|
|
229
|
-
};
|
|
230
|
-
/**
|
|
231
|
-
* Creates partially-applied logging functions given the first 2 args
|
|
232
|
-
* @param severity
|
|
233
|
-
* @param side
|
|
234
|
-
* @returns
|
|
235
|
-
*/
|
|
236
|
-
function logger(severity, side) {
|
|
237
|
-
return (rqid, message, ...args) => log(severity, side, rqid, message, ...args);
|
|
238
|
-
}
|
|
239
|
-
/**
|
|
240
|
-
* Send log messages to the console, with a helpful prefix.
|
|
241
|
-
* @param severity
|
|
242
|
-
* @param side
|
|
243
|
-
* @param rqid request ID
|
|
244
|
-
* @param message
|
|
245
|
-
* @param args passed to console methods directly
|
|
246
|
-
*/
|
|
247
|
-
function log(severity, side, rqid, message, ...args) {
|
|
248
|
-
const prefix = "[" +
|
|
249
|
-
["SWARPC", side, rqid ? `%c${rqid}%c` : ""].filter(Boolean).join(" ") +
|
|
250
|
-
"]";
|
|
251
|
-
const prefixStyles = rqid ? ["color: cyan;", "color: inherit;"] : [];
|
|
252
|
-
if (severity === "debug") {
|
|
253
|
-
console.debug(prefix, ...prefixStyles, message, ...args);
|
|
254
|
-
}
|
|
255
|
-
else if (severity === "info") {
|
|
256
|
-
console.info(prefix, ...prefixStyles, message, ...args);
|
|
257
|
-
}
|
|
258
|
-
else if (severity === "warn") {
|
|
259
|
-
console.warn(prefix, ...prefixStyles, message, ...args);
|
|
260
|
-
}
|
|
261
|
-
else if (severity === "error") {
|
|
262
|
-
console.error(prefix, ...prefixStyles, message, ...args);
|
|
263
|
-
}
|
|
264
|
-
}
|