swarpc 0.4.0 → 0.6.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 +18 -2
- package/dist/swarpc.d.ts.map +1 -1
- package/dist/swarpc.js +119 -18
- package/dist/types.d.ts +88 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +6 -0
- package/dist/utils.d.ts +2 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +32 -0
- package/package.json +6 -2
- package/src/swarpc.ts +147 -35
- package/src/types.ts +113 -2
- package/src/utils.ts +40 -0
package/dist/swarpc.d.ts
CHANGED
|
@@ -1,9 +1,25 @@
|
|
|
1
|
-
import { type ProceduresMap, type SwarpcClient, type SwarpcServer } from "./types.js";
|
|
1
|
+
import { Hooks, 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>;
|
|
6
|
-
|
|
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 }?: {
|
|
7
22
|
worker?: Worker;
|
|
23
|
+
hooks?: Hooks<Procedures>;
|
|
8
24
|
}): SwarpcClient<Procedures>;
|
|
9
25
|
//# sourceMappingURL=swarpc.d.ts.map
|
package/dist/swarpc.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"swarpc.d.ts","sourceRoot":"","sources":["../src/swarpc.ts"],"names":[],"mappings":"AACA,OAAO,
|
|
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
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,95 @@ 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
|
-
|
|
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",
|
|
37
58
|
functionName,
|
|
38
59
|
requestId,
|
|
60
|
+
autotransfer,
|
|
61
|
+
...data,
|
|
62
|
+
});
|
|
63
|
+
// Prepare a function to post errors back to the client
|
|
64
|
+
const postError = async (error) => postMsg({
|
|
39
65
|
error: {
|
|
40
66
|
message: "message" in error ? error.message : String(error),
|
|
41
67
|
},
|
|
42
68
|
});
|
|
69
|
+
// Retrieve the implementation for the requested function
|
|
43
70
|
const implementation = instance[zImplementations][functionName];
|
|
44
71
|
if (!implementation) {
|
|
45
72
|
await postError("No implementation found");
|
|
46
73
|
return;
|
|
47
74
|
}
|
|
75
|
+
// Call the implementation with the input and a progress callback
|
|
48
76
|
await implementation(input, async (progress) => {
|
|
49
77
|
l.server.debug(requestId, `Progress for ${functionName}`, progress);
|
|
50
|
-
await
|
|
78
|
+
await postMsg({ progress });
|
|
51
79
|
})
|
|
80
|
+
// Send errors
|
|
52
81
|
.catch(async (error) => {
|
|
53
82
|
l.server.error(requestId, `Error in ${functionName}`, error);
|
|
54
83
|
await postError(error);
|
|
55
84
|
})
|
|
85
|
+
// Send results
|
|
56
86
|
.then(async (result) => {
|
|
57
87
|
l.server.debug(requestId, `Result for ${functionName}`, result);
|
|
58
|
-
await
|
|
88
|
+
await postMsg({ result });
|
|
59
89
|
});
|
|
60
90
|
});
|
|
61
91
|
};
|
|
62
92
|
return instance;
|
|
63
93
|
}
|
|
94
|
+
/**
|
|
95
|
+
* Generate a random request ID, used to identify requests between client and server.
|
|
96
|
+
* @returns a 6-character hexadecimal string
|
|
97
|
+
*/
|
|
64
98
|
function generateRequestId() {
|
|
65
99
|
return Math.random().toString(16).substring(2, 8).toUpperCase();
|
|
66
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
|
+
*/
|
|
67
106
|
const pendingRequests = new Map();
|
|
107
|
+
// Have we started the client listener?
|
|
68
108
|
let _clientListenerStarted = false;
|
|
69
|
-
|
|
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 = {}) {
|
|
70
115
|
if (_clientListenerStarted)
|
|
71
116
|
return;
|
|
117
|
+
// Get service worker registration if no worker is provided
|
|
72
118
|
if (!worker) {
|
|
73
119
|
const sw = await navigator.serviceWorker.ready;
|
|
74
120
|
if (!sw?.active) {
|
|
@@ -79,40 +125,67 @@ async function startClientListener(worker) {
|
|
|
79
125
|
}
|
|
80
126
|
}
|
|
81
127
|
const w = worker ?? navigator.serviceWorker;
|
|
128
|
+
// Start listening for messages
|
|
82
129
|
l.client.debug("", "Starting client listener on", w);
|
|
83
130
|
w.addEventListener("message", (event) => {
|
|
84
|
-
|
|
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
|
|
85
139
|
if (!requestId) {
|
|
86
140
|
throw new Error("[SWARPC Client] Message received without requestId");
|
|
87
141
|
}
|
|
142
|
+
// Get the associated pending request handlers
|
|
88
143
|
const handlers = pendingRequests.get(requestId);
|
|
89
144
|
if (!handlers) {
|
|
90
145
|
throw new Error(`[SWARPC Client] ${requestId} has no active request handlers`);
|
|
91
146
|
}
|
|
147
|
+
// React to the data received: call hook, call handler,
|
|
148
|
+
// and remove the request from pendingRequests (unless it's a progress update)
|
|
92
149
|
if ("error" in data) {
|
|
150
|
+
hooks.error?.(functionName, new Error(data.error.message));
|
|
93
151
|
handlers.reject(new Error(data.error.message));
|
|
94
152
|
pendingRequests.delete(requestId);
|
|
95
153
|
}
|
|
96
154
|
else if ("progress" in data) {
|
|
155
|
+
hooks.progress?.(functionName, data.progress);
|
|
97
156
|
handlers.onProgress(data.progress);
|
|
98
157
|
}
|
|
99
158
|
else if ("result" in data) {
|
|
159
|
+
hooks.success?.(functionName, data.result);
|
|
100
160
|
handlers.resolve(data.result);
|
|
101
161
|
pendingRequests.delete(requestId);
|
|
102
162
|
}
|
|
103
163
|
});
|
|
104
164
|
_clientListenerStarted = true;
|
|
105
165
|
}
|
|
106
|
-
|
|
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
|
|
107
176
|
const instance = { [zProcedures]: procedures };
|
|
108
177
|
for (const functionName of Object.keys(procedures)) {
|
|
109
178
|
if (typeof functionName !== "string") {
|
|
110
179
|
throw new Error(`[SWARPC Client] Invalid function name, don't use symbols`);
|
|
111
180
|
}
|
|
181
|
+
// Set the method on the instance
|
|
112
182
|
// @ts-expect-error
|
|
113
183
|
instance[functionName] = (async (input, onProgress = () => { }) => {
|
|
184
|
+
// Validate the input against the procedure's input schema
|
|
114
185
|
procedures[functionName].input.assert(input);
|
|
115
|
-
|
|
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
|
|
116
189
|
const w = worker ?? (await navigator.serviceWorker.ready.then((r) => r.active));
|
|
117
190
|
if (!w) {
|
|
118
191
|
throw new Error("[SWARPC Client] No active service worker found");
|
|
@@ -121,28 +194,56 @@ export function Client(procedures, { worker } = {}) {
|
|
|
121
194
|
if (!worker && !navigator.serviceWorker.controller)
|
|
122
195
|
l.client.warn("", "Service Worker is not controlling the page");
|
|
123
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
|
|
124
200
|
pendingRequests.set(requestId, { resolve, onProgress, reject });
|
|
201
|
+
// Post the message to the server
|
|
125
202
|
l.client.debug(requestId, `Requesting ${functionName} with`, input);
|
|
126
|
-
w.postMessage({ functionName, input, requestId }
|
|
203
|
+
w.postMessage({ functionName, input, requestId }, {
|
|
204
|
+
transfer: procedures[functionName].autotransfer === "always"
|
|
205
|
+
? findTransferables(input)
|
|
206
|
+
: [],
|
|
207
|
+
});
|
|
127
208
|
});
|
|
128
209
|
});
|
|
129
210
|
}
|
|
130
211
|
return instance;
|
|
131
212
|
}
|
|
213
|
+
/**
|
|
214
|
+
* Convenience shortcuts for logging.
|
|
215
|
+
*/
|
|
132
216
|
const l = {
|
|
133
217
|
server: {
|
|
134
|
-
debug: (
|
|
135
|
-
info: (
|
|
136
|
-
warn: (
|
|
137
|
-
error: (
|
|
218
|
+
debug: logger("debug", "server"),
|
|
219
|
+
info: logger("info", "server"),
|
|
220
|
+
warn: logger("warn", "server"),
|
|
221
|
+
error: logger("error", "server"),
|
|
138
222
|
},
|
|
139
223
|
client: {
|
|
140
|
-
debug: (
|
|
141
|
-
info: (
|
|
142
|
-
warn: (
|
|
143
|
-
error: (
|
|
224
|
+
debug: logger("debug", "client"),
|
|
225
|
+
info: logger("info", "client"),
|
|
226
|
+
warn: logger("warn", "client"),
|
|
227
|
+
error: logger("error", "client"),
|
|
144
228
|
},
|
|
145
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
|
+
*/
|
|
146
247
|
function log(severity, side, rqid, message, ...args) {
|
|
147
248
|
const prefix = "[" +
|
|
148
249
|
["SWARPC", side, rqid ? `%c${rqid}%c` : ""].filter(Boolean).join(" ") +
|
package/dist/types.d.ts
CHANGED
|
@@ -1,22 +1,109 @@
|
|
|
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
|
-
|
|
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
|
+
* Declaration of hooks to run on messages received from the server
|
|
47
|
+
*/
|
|
48
|
+
export type Hooks<Procedures extends ProceduresMap> = {
|
|
49
|
+
/**
|
|
50
|
+
* Called when a procedure call has been successful.
|
|
51
|
+
*/
|
|
52
|
+
success?: <Procedure extends keyof ProceduresMap>(procedure: Procedure, data: Procedures[Procedure]["success"]["inferOut"]) => void;
|
|
53
|
+
/**
|
|
54
|
+
* Called when a procedure call has failed.
|
|
55
|
+
*/
|
|
56
|
+
error?: <Procedure extends keyof ProceduresMap>(procedure: Procedure, error: Error) => void;
|
|
57
|
+
/**
|
|
58
|
+
* Called when a procedure call sends progress updates.
|
|
59
|
+
*/
|
|
60
|
+
progress?: <Procedure extends keyof ProceduresMap>(procedure: Procedure, data: Procedures[Procedure]["progress"]["inferOut"]) => void;
|
|
61
|
+
};
|
|
62
|
+
export type PayloadHeader<PM extends ProceduresMap, Name extends keyof PM = keyof PM> = {
|
|
63
|
+
by: "sw&rpc";
|
|
64
|
+
functionName: Name & string;
|
|
65
|
+
requestId: string;
|
|
66
|
+
autotransfer: PM[Name]["autotransfer"];
|
|
67
|
+
};
|
|
68
|
+
export type PayloadCore<PM extends ProceduresMap, Name extends keyof PM = keyof PM> = {
|
|
69
|
+
input: PM[Name]["input"]["inferOut"];
|
|
70
|
+
} | {
|
|
71
|
+
progress: PM[Name]["progress"]["inferOut"];
|
|
72
|
+
} | {
|
|
73
|
+
result: PM[Name]["success"]["inferOut"];
|
|
74
|
+
} | {
|
|
75
|
+
error: {
|
|
76
|
+
message: string;
|
|
77
|
+
};
|
|
78
|
+
};
|
|
79
|
+
/**
|
|
80
|
+
* The effective payload as sent by the server to the client
|
|
81
|
+
*/
|
|
82
|
+
export type Payload<PM extends ProceduresMap, Name extends keyof PM = keyof PM> = PayloadHeader<PM, Name> & PayloadCore<PM, Name>;
|
|
83
|
+
/**
|
|
84
|
+
* A procedure's corresponding method on the client instance -- used to call the procedure
|
|
85
|
+
*/
|
|
12
86
|
export type ClientMethod<P extends Procedure<Type, Type, Type>> = (input: P["input"]["inferIn"], onProgress?: (progress: P["progress"]["inferOut"]) => void) => Promise<P["success"]["inferOut"]>;
|
|
87
|
+
/**
|
|
88
|
+
* Symbol used as the key for the procedures map on the server instance
|
|
89
|
+
*/
|
|
13
90
|
export declare const zImplementations: unique symbol;
|
|
91
|
+
/**
|
|
92
|
+
* Symbol used as the key for the procedures map on instances
|
|
93
|
+
*/
|
|
14
94
|
export declare const zProcedures: unique symbol;
|
|
95
|
+
/**
|
|
96
|
+
* The sw&rpc client instance, which provides methods to call procedures
|
|
97
|
+
*/
|
|
15
98
|
export type SwarpcClient<Procedures extends ProceduresMap> = {
|
|
16
99
|
[zProcedures]: Procedures;
|
|
17
100
|
} & {
|
|
18
101
|
[F in keyof Procedures]: ClientMethod<Procedures[F]>;
|
|
19
102
|
};
|
|
103
|
+
/**
|
|
104
|
+
* The sw&rpc server instance, which provides methods to register procedure implementations,
|
|
105
|
+
* and listens for incoming messages that call those procedures
|
|
106
|
+
*/
|
|
20
107
|
export type SwarpcServer<Procedures extends ProceduresMap> = {
|
|
21
108
|
[zProcedures]: Procedures;
|
|
22
109
|
[zImplementations]: ImplementationsMap<Procedures>;
|
package/dist/types.d.ts.map
CHANGED
|
@@ -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;
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
|
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");
|
package/dist/utils.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../src/utils.ts"],"names":[],"mappings":"AAiBA,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,GAAG,GAAG,YAAY,EAAE,CAsB5D"}
|
package/dist/utils.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// TODO: keep it in sync with web standards, how?
|
|
2
|
+
const transferableClasses = [
|
|
3
|
+
OffscreenCanvas,
|
|
4
|
+
ImageBitmap,
|
|
5
|
+
MessagePort,
|
|
6
|
+
MediaSourceHandle,
|
|
7
|
+
ReadableStream,
|
|
8
|
+
WritableStream,
|
|
9
|
+
TransformStream,
|
|
10
|
+
AudioData,
|
|
11
|
+
VideoFrame,
|
|
12
|
+
RTCDataChannel,
|
|
13
|
+
ArrayBuffer,
|
|
14
|
+
];
|
|
15
|
+
export function findTransferables(value) {
|
|
16
|
+
if (value === null || value === undefined) {
|
|
17
|
+
return [];
|
|
18
|
+
}
|
|
19
|
+
if (typeof value === "object") {
|
|
20
|
+
if (ArrayBuffer.isView(value) || value instanceof ArrayBuffer) {
|
|
21
|
+
return [value];
|
|
22
|
+
}
|
|
23
|
+
if (transferableClasses.some((cls) => value instanceof cls)) {
|
|
24
|
+
return [value];
|
|
25
|
+
}
|
|
26
|
+
if (Array.isArray(value)) {
|
|
27
|
+
return value.flatMap(findTransferables);
|
|
28
|
+
}
|
|
29
|
+
return Object.values(value).flatMap(findTransferables);
|
|
30
|
+
}
|
|
31
|
+
return [];
|
|
32
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "swarpc",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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,13 +28,17 @@
|
|
|
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
|
-
"typedoc": "typedoc src/swarpc.ts src/types.ts --readme README.md"
|
|
33
|
+
"typedoc": "typedoc src/swarpc.ts src/types.ts --readme README.md",
|
|
34
|
+
"version": "kacl release && prettier -w CHANGELOG.md && git add CHANGELOG.md"
|
|
33
35
|
},
|
|
34
36
|
"dependencies": {
|
|
35
37
|
"arktype": "^2.1.20"
|
|
36
38
|
},
|
|
37
39
|
"devDependencies": {
|
|
40
|
+
"kacl": "^1.1.1",
|
|
41
|
+
"prettier": "^3.6.2",
|
|
38
42
|
"typedoc": "^0.28.7",
|
|
39
43
|
"typescript": "^5.8.3"
|
|
40
44
|
}
|
package/src/swarpc.ts
CHANGED
|
@@ -1,25 +1,40 @@
|
|
|
1
1
|
import { type } from "arktype"
|
|
2
2
|
import {
|
|
3
|
+
Hooks,
|
|
3
4
|
ImplementationsMap,
|
|
5
|
+
Payload,
|
|
6
|
+
PayloadCore,
|
|
4
7
|
zImplementations,
|
|
5
8
|
zProcedures,
|
|
6
9
|
type ProceduresMap,
|
|
7
10
|
type SwarpcClient,
|
|
8
11
|
type SwarpcServer,
|
|
9
12
|
} from "./types.js"
|
|
13
|
+
import { findTransferables } from "./utils.js"
|
|
10
14
|
|
|
11
15
|
export type { ProceduresMap, SwarpcClient, SwarpcServer } from "./types.js"
|
|
12
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
|
+
*/
|
|
13
24
|
export function Server<Procedures extends ProceduresMap>(
|
|
14
25
|
procedures: Procedures,
|
|
15
26
|
{ worker }: { worker?: Worker } = {}
|
|
16
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.
|
|
17
31
|
const instance = {
|
|
18
32
|
[zProcedures]: procedures,
|
|
19
33
|
[zImplementations]: {} as ImplementationsMap<Procedures>,
|
|
20
34
|
start: (self: Window) => {},
|
|
21
35
|
} as SwarpcServer<Procedures>
|
|
22
36
|
|
|
37
|
+
// Set all implementation-setter methods
|
|
23
38
|
for (const functionName in procedures) {
|
|
24
39
|
instance[functionName] = ((implementation) => {
|
|
25
40
|
if (!instance[zProcedures][functionName]) {
|
|
@@ -29,6 +44,7 @@ export function Server<Procedures extends ProceduresMap>(
|
|
|
29
44
|
}) as SwarpcServer<Procedures>[typeof functionName]
|
|
30
45
|
}
|
|
31
46
|
|
|
47
|
+
// Define payload schema for incoming messages
|
|
32
48
|
const PayloadSchema = type.or(
|
|
33
49
|
...Object.entries(procedures).map(([functionName, { input }]) => ({
|
|
34
50
|
functionName: type(`"${functionName}"`),
|
|
@@ -38,55 +54,74 @@ export function Server<Procedures extends ProceduresMap>(
|
|
|
38
54
|
)
|
|
39
55
|
|
|
40
56
|
instance.start = (self: Window) => {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
}>
|
|
47
|
-
) => {
|
|
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
|
+
|
|
48
62
|
if (worker) {
|
|
49
|
-
self.postMessage(data)
|
|
63
|
+
self.postMessage(data, { transfer })
|
|
50
64
|
} else {
|
|
51
65
|
await (self as any).clients.matchAll().then((clients: any[]) => {
|
|
52
|
-
clients.forEach((client) => client.postMessage(data))
|
|
66
|
+
clients.forEach((client) => client.postMessage(data, { transfer }))
|
|
53
67
|
})
|
|
54
68
|
}
|
|
55
69
|
}
|
|
56
70
|
|
|
71
|
+
// Listen for messages from the client
|
|
57
72
|
self.addEventListener("message", async (event: MessageEvent) => {
|
|
73
|
+
// Decode the payload
|
|
58
74
|
const { functionName, requestId, input } = PayloadSchema.assert(
|
|
59
75
|
event.data
|
|
60
76
|
)
|
|
61
77
|
|
|
62
78
|
l.server.debug(requestId, `Received request for ${functionName}`, input)
|
|
63
79
|
|
|
64
|
-
|
|
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
|
+
) =>
|
|
65
88
|
postMessage({
|
|
89
|
+
by: "sw&rpc",
|
|
66
90
|
functionName,
|
|
67
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({
|
|
68
99
|
error: {
|
|
69
100
|
message: "message" in error ? error.message : String(error),
|
|
70
101
|
},
|
|
71
102
|
})
|
|
72
103
|
|
|
104
|
+
// Retrieve the implementation for the requested function
|
|
73
105
|
const implementation = instance[zImplementations][functionName]
|
|
74
106
|
if (!implementation) {
|
|
75
107
|
await postError("No implementation found")
|
|
76
108
|
return
|
|
77
109
|
}
|
|
78
110
|
|
|
111
|
+
// Call the implementation with the input and a progress callback
|
|
79
112
|
await implementation(input, async (progress: any) => {
|
|
80
113
|
l.server.debug(requestId, `Progress for ${functionName}`, progress)
|
|
81
|
-
await
|
|
114
|
+
await postMsg({ progress })
|
|
82
115
|
})
|
|
116
|
+
// Send errors
|
|
83
117
|
.catch(async (error: any) => {
|
|
84
118
|
l.server.error(requestId, `Error in ${functionName}`, error)
|
|
85
119
|
await postError(error)
|
|
86
120
|
})
|
|
121
|
+
// Send results
|
|
87
122
|
.then(async (result: any) => {
|
|
88
123
|
l.server.debug(requestId, `Result for ${functionName}`, result)
|
|
89
|
-
await
|
|
124
|
+
await postMsg({ result })
|
|
90
125
|
})
|
|
91
126
|
})
|
|
92
127
|
}
|
|
@@ -94,22 +129,41 @@ export function Server<Procedures extends ProceduresMap>(
|
|
|
94
129
|
return instance
|
|
95
130
|
}
|
|
96
131
|
|
|
132
|
+
/**
|
|
133
|
+
* Generate a random request ID, used to identify requests between client and server.
|
|
134
|
+
* @returns a 6-character hexadecimal string
|
|
135
|
+
*/
|
|
97
136
|
function generateRequestId(): string {
|
|
98
137
|
return Math.random().toString(16).substring(2, 8).toUpperCase()
|
|
99
138
|
}
|
|
100
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>()
|
|
101
146
|
type PendingRequest = {
|
|
102
147
|
reject: (err: Error) => void
|
|
103
148
|
onProgress: (progress: any) => void
|
|
104
149
|
resolve: (result: any) => void
|
|
105
150
|
}
|
|
106
151
|
|
|
107
|
-
|
|
108
|
-
|
|
152
|
+
// Have we started the client listener?
|
|
109
153
|
let _clientListenerStarted = false
|
|
110
|
-
|
|
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
|
+
) {
|
|
111
164
|
if (_clientListenerStarted) return
|
|
112
165
|
|
|
166
|
+
// Get service worker registration if no worker is provided
|
|
113
167
|
if (!worker) {
|
|
114
168
|
const sw = await navigator.serviceWorker.ready
|
|
115
169
|
if (!sw?.active) {
|
|
@@ -122,15 +176,26 @@ async function startClientListener(worker?: Worker) {
|
|
|
122
176
|
}
|
|
123
177
|
|
|
124
178
|
const w = worker ?? navigator.serviceWorker
|
|
179
|
+
|
|
180
|
+
// Start listening for messages
|
|
125
181
|
l.client.debug("", "Starting client listener on", w)
|
|
126
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
|
|
127
190
|
const { functionName, requestId, ...data } =
|
|
128
|
-
|
|
191
|
+
eventData as Payload<Procedures>
|
|
129
192
|
|
|
193
|
+
// Sanity check in case we somehow receive a message without requestId
|
|
130
194
|
if (!requestId) {
|
|
131
195
|
throw new Error("[SWARPC Client] Message received without requestId")
|
|
132
196
|
}
|
|
133
197
|
|
|
198
|
+
// Get the associated pending request handlers
|
|
134
199
|
const handlers = pendingRequests.get(requestId)
|
|
135
200
|
if (!handlers) {
|
|
136
201
|
throw new Error(
|
|
@@ -138,12 +203,17 @@ async function startClientListener(worker?: Worker) {
|
|
|
138
203
|
)
|
|
139
204
|
}
|
|
140
205
|
|
|
206
|
+
// React to the data received: call hook, call handler,
|
|
207
|
+
// and remove the request from pendingRequests (unless it's a progress update)
|
|
141
208
|
if ("error" in data) {
|
|
209
|
+
hooks.error?.(functionName, new Error(data.error.message))
|
|
142
210
|
handlers.reject(new Error(data.error.message))
|
|
143
211
|
pendingRequests.delete(requestId)
|
|
144
212
|
} else if ("progress" in data) {
|
|
213
|
+
hooks.progress?.(functionName, data.progress)
|
|
145
214
|
handlers.onProgress(data.progress)
|
|
146
215
|
} else if ("result" in data) {
|
|
216
|
+
hooks.success?.(functionName, data.result)
|
|
147
217
|
handlers.resolve(data.result)
|
|
148
218
|
pendingRequests.delete(requestId)
|
|
149
219
|
}
|
|
@@ -152,10 +222,19 @@ async function startClientListener(worker?: Worker) {
|
|
|
152
222
|
_clientListenerStarted = true
|
|
153
223
|
}
|
|
154
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
|
+
*/
|
|
155
233
|
export function Client<Procedures extends ProceduresMap>(
|
|
156
234
|
procedures: Procedures,
|
|
157
|
-
{ worker }: { worker?: Worker } = {}
|
|
235
|
+
{ worker, hooks = {} }: { worker?: Worker; hooks?: Hooks<Procedures> } = {}
|
|
158
236
|
): SwarpcClient<Procedures> {
|
|
237
|
+
// Store procedures on a symbol key, to avoid conflicts with procedure names
|
|
159
238
|
const instance = { [zProcedures]: procedures } as Partial<
|
|
160
239
|
SwarpcClient<Procedures>
|
|
161
240
|
>
|
|
@@ -169,11 +248,15 @@ export function Client<Procedures extends ProceduresMap>(
|
|
|
169
248
|
)
|
|
170
249
|
}
|
|
171
250
|
|
|
251
|
+
// Set the method on the instance
|
|
172
252
|
// @ts-expect-error
|
|
173
253
|
instance[functionName] = (async (input: unknown, onProgress = () => {}) => {
|
|
254
|
+
// Validate the input against the procedure's input schema
|
|
174
255
|
procedures[functionName].input.assert(input)
|
|
175
|
-
|
|
256
|
+
// Ensure that we're listening for messages from the server
|
|
257
|
+
await startClientListener(worker, hooks)
|
|
176
258
|
|
|
259
|
+
// If no worker is provided, we use the service worker
|
|
177
260
|
const w =
|
|
178
261
|
worker ?? (await navigator.serviceWorker.ready.then((r) => r.active))
|
|
179
262
|
|
|
@@ -187,10 +270,22 @@ export function Client<Procedures extends ProceduresMap>(
|
|
|
187
270
|
|
|
188
271
|
const requestId = generateRequestId()
|
|
189
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
|
|
190
276
|
pendingRequests.set(requestId, { resolve, onProgress, reject })
|
|
191
277
|
|
|
278
|
+
// Post the message to the server
|
|
192
279
|
l.client.debug(requestId, `Requesting ${functionName} with`, input)
|
|
193
|
-
w.postMessage(
|
|
280
|
+
w.postMessage(
|
|
281
|
+
{ functionName, input, requestId },
|
|
282
|
+
{
|
|
283
|
+
transfer:
|
|
284
|
+
procedures[functionName].autotransfer === "always"
|
|
285
|
+
? findTransferables(input)
|
|
286
|
+
: [],
|
|
287
|
+
}
|
|
288
|
+
)
|
|
194
289
|
})
|
|
195
290
|
}) as SwarpcClient<Procedures>[typeof functionName]
|
|
196
291
|
}
|
|
@@ -198,29 +293,46 @@ export function Client<Procedures extends ProceduresMap>(
|
|
|
198
293
|
return instance as SwarpcClient<Procedures>
|
|
199
294
|
}
|
|
200
295
|
|
|
296
|
+
/**
|
|
297
|
+
* Convenience shortcuts for logging.
|
|
298
|
+
*/
|
|
201
299
|
const l = {
|
|
202
300
|
server: {
|
|
203
|
-
debug: (
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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),
|
|
301
|
+
debug: logger("debug", "server"),
|
|
302
|
+
info: logger("info", "server"),
|
|
303
|
+
warn: logger("warn", "server"),
|
|
304
|
+
error: logger("error", "server"),
|
|
211
305
|
},
|
|
212
306
|
client: {
|
|
213
|
-
debug: (
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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),
|
|
307
|
+
debug: logger("debug", "client"),
|
|
308
|
+
info: logger("info", "client"),
|
|
309
|
+
warn: logger("warn", "client"),
|
|
310
|
+
error: logger("error", "client"),
|
|
221
311
|
},
|
|
222
312
|
}
|
|
223
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
|
+
*/
|
|
224
336
|
function log(
|
|
225
337
|
severity: "debug" | "info" | "warn" | "error",
|
|
226
338
|
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["
|
|
16
|
-
) => Promise<S["
|
|
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,98 @@ export type ImplementationsMap<Procedures extends ProceduresMap> = {
|
|
|
25
58
|
>
|
|
26
59
|
}
|
|
27
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Declaration of hooks to run on messages received from the server
|
|
63
|
+
*/
|
|
64
|
+
export type Hooks<Procedures extends ProceduresMap> = {
|
|
65
|
+
/**
|
|
66
|
+
* Called when a procedure call has been successful.
|
|
67
|
+
*/
|
|
68
|
+
success?: <Procedure extends keyof ProceduresMap>(
|
|
69
|
+
procedure: Procedure,
|
|
70
|
+
data: Procedures[Procedure]["success"]["inferOut"]
|
|
71
|
+
) => void
|
|
72
|
+
/**
|
|
73
|
+
* Called when a procedure call has failed.
|
|
74
|
+
*/
|
|
75
|
+
error?: <Procedure extends keyof ProceduresMap>(
|
|
76
|
+
procedure: Procedure,
|
|
77
|
+
error: Error
|
|
78
|
+
) => void
|
|
79
|
+
/**
|
|
80
|
+
* Called when a procedure call sends progress updates.
|
|
81
|
+
*/
|
|
82
|
+
progress?: <Procedure extends keyof ProceduresMap>(
|
|
83
|
+
procedure: Procedure,
|
|
84
|
+
data: Procedures[Procedure]["progress"]["inferOut"]
|
|
85
|
+
) => void
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export type PayloadHeader<
|
|
89
|
+
PM extends ProceduresMap,
|
|
90
|
+
Name extends keyof PM = keyof PM
|
|
91
|
+
> = {
|
|
92
|
+
by: "sw&rpc"
|
|
93
|
+
functionName: Name & string
|
|
94
|
+
requestId: string
|
|
95
|
+
autotransfer: PM[Name]["autotransfer"]
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export type PayloadCore<
|
|
99
|
+
PM extends ProceduresMap,
|
|
100
|
+
Name extends keyof PM = keyof PM
|
|
101
|
+
> =
|
|
102
|
+
| {
|
|
103
|
+
input: PM[Name]["input"]["inferOut"]
|
|
104
|
+
}
|
|
105
|
+
| {
|
|
106
|
+
progress: PM[Name]["progress"]["inferOut"]
|
|
107
|
+
}
|
|
108
|
+
| {
|
|
109
|
+
result: PM[Name]["success"]["inferOut"]
|
|
110
|
+
}
|
|
111
|
+
| {
|
|
112
|
+
error: { message: string }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The effective payload as sent by the server to the client
|
|
117
|
+
*/
|
|
118
|
+
export type Payload<
|
|
119
|
+
PM extends ProceduresMap,
|
|
120
|
+
Name extends keyof PM = keyof PM
|
|
121
|
+
> = PayloadHeader<PM, Name> & PayloadCore<PM, Name>
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* A procedure's corresponding method on the client instance -- used to call the procedure
|
|
125
|
+
*/
|
|
28
126
|
export type ClientMethod<P extends Procedure<Type, Type, Type>> = (
|
|
29
127
|
input: P["input"]["inferIn"],
|
|
30
128
|
onProgress?: (progress: P["progress"]["inferOut"]) => void
|
|
31
129
|
) => Promise<P["success"]["inferOut"]>
|
|
32
130
|
|
|
131
|
+
/**
|
|
132
|
+
* Symbol used as the key for the procedures map on the server instance
|
|
133
|
+
*/
|
|
33
134
|
export const zImplementations = Symbol("SWARPC implementations")
|
|
135
|
+
/**
|
|
136
|
+
* Symbol used as the key for the procedures map on instances
|
|
137
|
+
*/
|
|
34
138
|
export const zProcedures = Symbol("SWARPC procedures")
|
|
35
139
|
|
|
140
|
+
/**
|
|
141
|
+
* The sw&rpc client instance, which provides methods to call procedures
|
|
142
|
+
*/
|
|
36
143
|
export type SwarpcClient<Procedures extends ProceduresMap> = {
|
|
37
144
|
[zProcedures]: Procedures
|
|
38
145
|
} & {
|
|
39
146
|
[F in keyof Procedures]: ClientMethod<Procedures[F]>
|
|
40
147
|
}
|
|
41
148
|
|
|
149
|
+
/**
|
|
150
|
+
* The sw&rpc server instance, which provides methods to register procedure implementations,
|
|
151
|
+
* and listens for incoming messages that call those procedures
|
|
152
|
+
*/
|
|
42
153
|
export type SwarpcServer<Procedures extends ProceduresMap> = {
|
|
43
154
|
[zProcedures]: Procedures
|
|
44
155
|
[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
|
+
}
|