@panyam/tsappkit 0.6.3 → 0.6.9
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/LICENSE +202 -0
- package/dist/wasmhost/index.d.mts +76 -2
- package/dist/wasmhost/index.d.ts +76 -2
- package/dist/wasmhost/index.js +237 -18
- package/dist/wasmhost/index.js.map +1 -1
- package/dist/wasmhost/index.mjs +236 -19
- package/dist/wasmhost/index.mjs.map +1 -1
- package/dist/wasmhost/worker.js +82 -1
- package/dist/wasmhost/worker.js.map +1 -1
- package/package.json +2 -2
- package/src/wasmhost/cache.ts +111 -0
- package/src/wasmhost/client.ts +171 -23
- package/src/wasmhost/index.ts +3 -1
- package/src/wasmhost/lane.ts +171 -0
- package/src/wasmhost/protocol.ts +15 -1
- package/src/wasmhost/worker.ts +22 -3
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The worker side of wasmhost's Cache (BrowserCache in Go): blobs by key in a directory of the
|
|
3
|
+
* Origin Private File System, which outlives a reload. worker.ts installs it as
|
|
4
|
+
* globalThis.wasmhostCache before the Go program starts.
|
|
5
|
+
*
|
|
6
|
+
* Only the parts of the File System Access types it uses are named here, so tests can hand it a
|
|
7
|
+
* fake directory and the package doesn't depend on the DOM lib having them.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** What Go's BrowserCache calls. get resolves to null for a key with nothing under it. */
|
|
11
|
+
export interface BlobCache {
|
|
12
|
+
get(key: string): Promise<Uint8Array | null>;
|
|
13
|
+
put(key: string, bytes: Uint8Array): Promise<void>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface CacheFile {
|
|
17
|
+
getFile(): Promise<{ arrayBuffer(): Promise<ArrayBuffer> }>;
|
|
18
|
+
createSyncAccessHandle?(): Promise<{
|
|
19
|
+
truncate(size: number): void;
|
|
20
|
+
write(buf: Uint8Array, opts?: { at: number }): number;
|
|
21
|
+
flush(): void;
|
|
22
|
+
close(): void;
|
|
23
|
+
}>;
|
|
24
|
+
createWritable?(): Promise<{ write(data: Uint8Array): Promise<void>; close(): Promise<void> }>;
|
|
25
|
+
move?(dir: CacheDirectory, name: string): Promise<void>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface CacheDirectory {
|
|
29
|
+
getFileHandle(name: string, opts?: { create?: boolean }): Promise<CacheFile>;
|
|
30
|
+
removeEntry(name: string): Promise<void>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A BlobCache over dir, one file per key. put writes a temporary file and moves it over the key
|
|
35
|
+
* where the browser can move files, so get never sees half a blob; elsewhere it writes in place.
|
|
36
|
+
* In a dedicated worker the write goes through a SyncAccessHandle, which every browser with OPFS
|
|
37
|
+
* offers there.
|
|
38
|
+
*/
|
|
39
|
+
export function opfsCache(dir: CacheDirectory): BlobCache {
|
|
40
|
+
return {
|
|
41
|
+
async get(key) {
|
|
42
|
+
let file: CacheFile;
|
|
43
|
+
try {
|
|
44
|
+
file = await dir.getFileHandle(key);
|
|
45
|
+
} catch (err) {
|
|
46
|
+
if (isNotFound(err)) return null;
|
|
47
|
+
throw err;
|
|
48
|
+
}
|
|
49
|
+
return new Uint8Array(await (await file.getFile()).arrayBuffer());
|
|
50
|
+
},
|
|
51
|
+
async put(key, bytes) {
|
|
52
|
+
const tmp = `.put-${key}-${Math.random().toString(36).slice(2)}`;
|
|
53
|
+
const file = await dir.getFileHandle(tmp, { create: true });
|
|
54
|
+
if (!file.move) {
|
|
55
|
+
await dir.removeEntry(tmp).catch(() => {});
|
|
56
|
+
await write(await dir.getFileHandle(key, { create: true }), bytes);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
await write(file, bytes);
|
|
61
|
+
await file.move(dir, key);
|
|
62
|
+
} catch (err) {
|
|
63
|
+
await dir.removeEntry(tmp).catch(() => {});
|
|
64
|
+
throw err;
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function write(file: CacheFile, bytes: Uint8Array) {
|
|
71
|
+
if (file.createSyncAccessHandle) {
|
|
72
|
+
const h = await file.createSyncAccessHandle();
|
|
73
|
+
try {
|
|
74
|
+
h.truncate(0);
|
|
75
|
+
h.write(bytes, { at: 0 });
|
|
76
|
+
h.flush();
|
|
77
|
+
} finally {
|
|
78
|
+
h.close();
|
|
79
|
+
}
|
|
80
|
+
} else if (file.createWritable) {
|
|
81
|
+
const w = await file.createWritable();
|
|
82
|
+
await w.write(bytes);
|
|
83
|
+
await w.close();
|
|
84
|
+
} else {
|
|
85
|
+
throw new Error("this browser can't write to the origin private file system");
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function isNotFound(err: unknown): boolean {
|
|
90
|
+
return typeof err === "object" && err !== null && (err as { name?: unknown }).name === "NotFoundError";
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The cache for namespace ns under wasmhost/<ns>/ in the origin's private file system, or undefined
|
|
95
|
+
* where there's none (navigator.storage.getDirectory missing). The directory is opened on first use.
|
|
96
|
+
*/
|
|
97
|
+
export function workerCache(ns: string, storage: { getDirectory?: () => Promise<unknown> } | undefined): BlobCache | undefined {
|
|
98
|
+
if (!storage?.getDirectory) return undefined;
|
|
99
|
+
const getDirectory = storage.getDirectory.bind(storage);
|
|
100
|
+
let dir: Promise<BlobCache> | undefined;
|
|
101
|
+
const open = () =>
|
|
102
|
+
(dir ??= (async () => {
|
|
103
|
+
const root = (await getDirectory()) as { getDirectoryHandle(n: string, o: { create: boolean }): Promise<unknown> };
|
|
104
|
+
const base = (await root.getDirectoryHandle("wasmhost", { create: true })) as typeof root;
|
|
105
|
+
return opfsCache((await base.getDirectoryHandle(ns, { create: true })) as CacheDirectory);
|
|
106
|
+
})());
|
|
107
|
+
return {
|
|
108
|
+
get: async (key) => (await open()).get(key),
|
|
109
|
+
put: async (key, bytes) => (await open()).put(key, bytes),
|
|
110
|
+
};
|
|
111
|
+
}
|
package/src/wasmhost/client.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// a Web Worker; workerFetch is a fetch to hand a Connect transport, so the app's generated clients
|
|
3
3
|
// talk to the worker exactly as they would to a server; mountFiles pushes files in before the
|
|
4
4
|
// requests that read them.
|
|
5
|
-
import type { Files, HostReply, HostRequest, HostStatus } from "./protocol";
|
|
5
|
+
import type { Files, HostChunk, HostReply, HostRequest, HostStatus } from "./protocol";
|
|
6
6
|
|
|
7
7
|
export type { Files } from "./protocol";
|
|
8
8
|
|
|
@@ -22,14 +22,19 @@ type Unsent = HostRequest extends infer R ? (R extends HostRequest ? Omit<R, "id
|
|
|
22
22
|
|
|
23
23
|
class Channel {
|
|
24
24
|
private next = 1;
|
|
25
|
-
private pending = new Map<
|
|
25
|
+
private pending = new Map<
|
|
26
|
+
number,
|
|
27
|
+
{ resolve: (r: HostReply) => void; reject: (e: Error) => void; onChunk?: (c: HostChunk) => void }
|
|
28
|
+
>();
|
|
26
29
|
private dead: Error | null = null;
|
|
27
30
|
|
|
28
31
|
constructor(private worker: Worker) {
|
|
29
|
-
worker.addEventListener("message", (ev: MessageEvent<HostReply | HostStatus>) => {
|
|
32
|
+
worker.addEventListener("message", (ev: MessageEvent<HostReply | HostStatus | HostChunk>) => {
|
|
30
33
|
const m = ev.data;
|
|
31
34
|
if ("exited" in m) {
|
|
32
35
|
this.fail(new Error(`wasm host: ${m.exited}`));
|
|
36
|
+
} else if ("chunk" in m) {
|
|
37
|
+
this.pending.get(m.id)?.onChunk?.(m);
|
|
33
38
|
} else if ("id" in m) {
|
|
34
39
|
this.pending.get(m.id)?.resolve(m);
|
|
35
40
|
this.pending.delete(m.id);
|
|
@@ -38,16 +43,22 @@ class Channel {
|
|
|
38
43
|
worker.addEventListener("error", (ev: ErrorEvent) => this.fail(new Error(`wasm host worker: ${ev.message}`)));
|
|
39
44
|
}
|
|
40
45
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
+
/** Sends req and returns its id with the final reply; chunks before it go to onChunk. */
|
|
47
|
+
start(req: Unsent, transfer: Transferable[], onChunk?: (c: HostChunk) => void): { id: number; done: Promise<HostReply> } {
|
|
48
|
+
const id = this.next++;
|
|
49
|
+
if (this.dead) return { id, done: Promise.reject(this.dead) };
|
|
50
|
+
const done = new Promise<HostReply>((resolve, reject) => {
|
|
51
|
+
this.pending.set(id, { resolve, reject, onChunk });
|
|
46
52
|
this.worker.postMessage({ ...req, id }, transfer);
|
|
47
53
|
});
|
|
54
|
+
return { id, done };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
call(req: Unsent, transfer: Transferable[]): Promise<HostReply> {
|
|
58
|
+
return this.start(req, transfer).done;
|
|
48
59
|
}
|
|
49
60
|
|
|
50
|
-
|
|
61
|
+
fail(err: Error) {
|
|
51
62
|
this.dead = err;
|
|
52
63
|
for (const p of this.pending.values()) p.reject(err);
|
|
53
64
|
this.pending.clear();
|
|
@@ -56,6 +67,16 @@ class Channel {
|
|
|
56
67
|
|
|
57
68
|
const channels = new WeakMap<Worker, Channel>();
|
|
58
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Terminates worker and rejects every request still waiting on it with `err`, which terminate
|
|
72
|
+
* alone wouldn't do: a terminated worker never replies, so its pending requests would wait forever.
|
|
73
|
+
* For lane.ts; not exported from the package.
|
|
74
|
+
*/
|
|
75
|
+
export function endWorker(worker: Worker, err: Error): void {
|
|
76
|
+
channels.get(worker)?.fail(err);
|
|
77
|
+
worker.terminate();
|
|
78
|
+
}
|
|
79
|
+
|
|
59
80
|
function channel(worker: Worker): Channel {
|
|
60
81
|
let c = channels.get(worker);
|
|
61
82
|
if (!c) {
|
|
@@ -67,7 +88,8 @@ function channel(worker: Worker): Channel {
|
|
|
67
88
|
|
|
68
89
|
/**
|
|
69
90
|
* Starts the worker and resolves with it once the Go program has installed its exports, or rejects
|
|
70
|
-
* with why it couldn't load
|
|
91
|
+
* with why it couldn't load (and terminates it, since it can't answer anything). Relative URLs
|
|
92
|
+
* resolve against the page, not the worker script.
|
|
71
93
|
*/
|
|
72
94
|
export function startWorker(opts: StartWorkerOptions): Promise<Worker> {
|
|
73
95
|
const abs = (u: string | URL) => new URL(u, location.href).href;
|
|
@@ -82,13 +104,22 @@ export function startWorker(opts: StartWorkerOptions): Promise<Worker> {
|
|
|
82
104
|
const m = ev.data;
|
|
83
105
|
if (!("ready" in m)) return;
|
|
84
106
|
worker.removeEventListener("message", onMessage);
|
|
85
|
-
if (m.ready)
|
|
86
|
-
|
|
107
|
+
if (m.ready) {
|
|
108
|
+
resolve(worker);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
worker.terminate();
|
|
112
|
+
reject(new Error(`wasm host failed to load: ${m.error}`));
|
|
87
113
|
};
|
|
88
114
|
worker.addEventListener("message", onMessage);
|
|
89
|
-
worker.addEventListener(
|
|
90
|
-
|
|
91
|
-
|
|
115
|
+
worker.addEventListener(
|
|
116
|
+
"error",
|
|
117
|
+
(ev: ErrorEvent) => {
|
|
118
|
+
worker.terminate();
|
|
119
|
+
reject(new Error(`wasm host worker: ${ev.message}`));
|
|
120
|
+
},
|
|
121
|
+
{ once: true },
|
|
122
|
+
);
|
|
92
123
|
});
|
|
93
124
|
}
|
|
94
125
|
|
|
@@ -100,27 +131,144 @@ const NULL_BODY = new Set([101, 103, 204, 205, 304]);
|
|
|
100
131
|
* Only the path and query reach the worker, so baseUrl can be anything absolute, such as
|
|
101
132
|
* location.origin. It rejects if the worker reports an error (no handler, a failed build, a handler
|
|
102
133
|
* panic) and resolves with any HTTP status the handler chose.
|
|
134
|
+
*
|
|
135
|
+
* A handler that flushes (http.Flusher) streams: the Response resolves at its first flush, and its
|
|
136
|
+
* body delivers each flush as it happens, even from a Go loop that never yields, then the rest
|
|
137
|
+
* when the handler returns. Aborting the request (init.signal) rejects it, or errors a body that's
|
|
138
|
+
* already streaming, and cancels the handler's context; a handler sees that the next time it checks
|
|
139
|
+
* ctx, which a loop that never yields to JS doesn't get to do (a lane ends such a worker instead).
|
|
140
|
+
* Once the body is streaming, Chrome's text() and json() report the abort as "TypeError: Failed to
|
|
141
|
+
* fetch" rather than an AbortError, so tell an abort from a failure by signal.aborted.
|
|
103
142
|
*/
|
|
104
143
|
export function workerFetch(worker: Worker): (input: RequestInfo | URL, init?: RequestInit) => Promise<Response> {
|
|
105
144
|
const c = channel(worker);
|
|
106
145
|
return async (input, init) => {
|
|
107
146
|
const req = new Request(input, init);
|
|
147
|
+
const signal = req.signal;
|
|
148
|
+
if (signal.aborted) throw abortReason(signal);
|
|
108
149
|
const buf = new Uint8Array(await req.arrayBuffer());
|
|
109
150
|
const body = buf.byteLength ? buf : null;
|
|
110
151
|
const headers: Record<string, string> = {};
|
|
111
152
|
req.headers.forEach((v, k) => (headers[k] = v));
|
|
112
153
|
const u = new URL(req.url);
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
154
|
+
|
|
155
|
+
return new Promise<Response>((resolve, reject) => {
|
|
156
|
+
let stream: ReadableStreamDefaultController<Uint8Array> | undefined;
|
|
157
|
+
let settled = false;
|
|
158
|
+
const fail = (err: unknown) => {
|
|
159
|
+
if (settled) return;
|
|
160
|
+
settled = true;
|
|
161
|
+
if (stream) stream.error(err);
|
|
162
|
+
else reject(err);
|
|
163
|
+
};
|
|
164
|
+
const { id, done } = c.start(
|
|
165
|
+
{ kind: "http", method: req.method, url: u.pathname + u.search, headers, body },
|
|
166
|
+
body ? [body.buffer] : [],
|
|
167
|
+
(chunk) => {
|
|
168
|
+
if (settled) return;
|
|
169
|
+
if (!stream) {
|
|
170
|
+
const status = chunk.status ?? 200;
|
|
171
|
+
const rs = new ReadableStream<Uint8Array>({ start: (ctl) => void (stream = ctl) });
|
|
172
|
+
resolve(new Response(NULL_BODY.has(status) ? null : rs, { status, headers: chunk.headers }));
|
|
173
|
+
}
|
|
174
|
+
if (chunk.body.byteLength) stream!.enqueue(chunk.body);
|
|
175
|
+
},
|
|
176
|
+
);
|
|
177
|
+
const onAbort = () => {
|
|
178
|
+
void c.call({ kind: "cancel", target: id }, []).catch(() => {});
|
|
179
|
+
fail(abortReason(signal));
|
|
180
|
+
};
|
|
181
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
182
|
+
done.then(
|
|
183
|
+
(reply) => {
|
|
184
|
+
signal.removeEventListener("abort", onAbort);
|
|
185
|
+
if (!reply.ok) return fail(new Error(reply.error));
|
|
186
|
+
if (settled) return;
|
|
187
|
+
settled = true;
|
|
188
|
+
if (stream) {
|
|
189
|
+
if (reply.body?.byteLength) stream.enqueue(reply.body);
|
|
190
|
+
stream.close();
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
const status = reply.status ?? 200;
|
|
194
|
+
const resBody = NULL_BODY.has(status) ? null : ((reply.body ?? null) as BodyInit | null);
|
|
195
|
+
resolve(new Response(resBody, { status, headers: reply.headers }));
|
|
196
|
+
},
|
|
197
|
+
(err) => {
|
|
198
|
+
signal.removeEventListener("abort", onAbort);
|
|
199
|
+
fail(err);
|
|
200
|
+
},
|
|
201
|
+
);
|
|
202
|
+
});
|
|
121
203
|
};
|
|
122
204
|
}
|
|
123
205
|
|
|
206
|
+
function abortReason(signal: AbortSignal): unknown {
|
|
207
|
+
return signal.reason ?? new DOMException("This request was aborted.", "AbortError");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* A fetch like workerFetch's, except that each call starts a fresh worker from `opts`, sends it that
|
|
212
|
+
* one request, and terminates it once the response is in, whether the request succeeded or not.
|
|
213
|
+
* Ending the worker is the only way to give its wasm memory back to the browser, since wasm memory
|
|
214
|
+
* never shrinks, so route a request with a big transient peak (an ingest that parses a large input
|
|
215
|
+
* on the way to a small result) through this, and have it leave its result in the shared Cache
|
|
216
|
+
* (Go's wasmhost.BrowserCache) for the long-lived worker to load. Every worker for the same `ns`
|
|
217
|
+
* sees the same cache.
|
|
218
|
+
*
|
|
219
|
+
* Each call pays for a worker start and a wasm instantiate, a few hundred ms, so it's for jobs
|
|
220
|
+
* that take seconds, not for ordinary requests. The worker ends when the response's body has been
|
|
221
|
+
* read to the end, fails, or is cancelled, so a streamed body isn't cut short; read or cancel the
|
|
222
|
+
* body, or the worker stays up.
|
|
223
|
+
*/
|
|
224
|
+
export function oneShotFetch(opts: StartWorkerOptions): (input: RequestInfo | URL, init?: RequestInit) => Promise<Response> {
|
|
225
|
+
return async (input, init) => {
|
|
226
|
+
const worker = await startWorker(opts);
|
|
227
|
+
let res: Response;
|
|
228
|
+
try {
|
|
229
|
+
res = await workerFetch(worker)(input, init);
|
|
230
|
+
} catch (err) {
|
|
231
|
+
worker.terminate();
|
|
232
|
+
throw err;
|
|
233
|
+
}
|
|
234
|
+
return untilBodyEnds(res, () => worker.terminate());
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* res with `end` called once its body has been read to the end, has failed, or has been
|
|
240
|
+
* cancelled (or at once, for a response without a body). For oneShotFetch and lanes, which must
|
|
241
|
+
* not let go of a worker while its streamed body is still arriving. Not exported from the package.
|
|
242
|
+
*/
|
|
243
|
+
export function untilBodyEnds(res: Response, end: () => void): Response {
|
|
244
|
+
if (!res.body) {
|
|
245
|
+
end();
|
|
246
|
+
return res;
|
|
247
|
+
}
|
|
248
|
+
const reader = res.body.getReader();
|
|
249
|
+
const body = new ReadableStream<Uint8Array>({
|
|
250
|
+
async pull(ctl) {
|
|
251
|
+
try {
|
|
252
|
+
const { done, value } = await reader.read();
|
|
253
|
+
if (done) {
|
|
254
|
+
ctl.close();
|
|
255
|
+
end();
|
|
256
|
+
} else {
|
|
257
|
+
ctl.enqueue(value);
|
|
258
|
+
}
|
|
259
|
+
} catch (err) {
|
|
260
|
+
end();
|
|
261
|
+
ctl.error(err);
|
|
262
|
+
}
|
|
263
|
+
},
|
|
264
|
+
cancel(reason) {
|
|
265
|
+
end();
|
|
266
|
+
return reader.cancel(reason);
|
|
267
|
+
},
|
|
268
|
+
});
|
|
269
|
+
return new Response(body, { status: res.status, statusText: res.statusText, headers: res.headers });
|
|
270
|
+
}
|
|
271
|
+
|
|
124
272
|
/**
|
|
125
273
|
* Replaces the mount `name` with `files`. The buffers are transferred to the worker, not copied, so
|
|
126
274
|
* each Uint8Array is empty here afterwards; copy first anything the page still needs. Resolves once
|
package/src/wasmhost/index.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
-
export { startWorker, workerFetch, mountFiles, addFiles, unmountFiles, workerMemory } from "./client";
|
|
1
|
+
export { startWorker, workerFetch, oneShotFetch, mountFiles, addFiles, unmountFiles, workerMemory } from "./client";
|
|
2
2
|
export type { StartWorkerOptions, Files } from "./client";
|
|
3
3
|
export { filesFromDrop, filesFromFileList } from "./drop";
|
|
4
|
+
export { startLane } from "./lane";
|
|
5
|
+
export type { Lane, LaneOptions } from "./lane";
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// Lanes (goapplib issue 80): more than one worker for one wasm service, with the app routing
|
|
2
|
+
// requests by kind, so a quick request never waits behind a long one. Go's wasm target runs one
|
|
3
|
+
// thread per worker, and a job that never yields keeps that thread busy, so neither a second
|
|
4
|
+
// request nor an abort can reach it. A lane holds one worker it can replace: aborting a request
|
|
5
|
+
// on it terminates the worker, which is the only way to stop a job that never yields, and starts
|
|
6
|
+
// a fresh one, warmed from the shared Cache by the app's warm function.
|
|
7
|
+
import { endWorker, startWorker, untilBodyEnds, workerFetch, workerMemory, type StartWorkerOptions } from "./client";
|
|
8
|
+
|
|
9
|
+
type Fetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
|
10
|
+
|
|
11
|
+
export interface LaneOptions {
|
|
12
|
+
/**
|
|
13
|
+
* Runs on each new worker before the lane hands it a request: typically a request that loads
|
|
14
|
+
* the service's state from the cache (wasmhost.BrowserCache), so a replacement worker answers
|
|
15
|
+
* like the one it replaced. A warm that throws ends that worker, and the error is what the next
|
|
16
|
+
* request gets; the request after it starts another worker.
|
|
17
|
+
*/
|
|
18
|
+
warm?: (fetch: Fetch) => Promise<unknown>;
|
|
19
|
+
/**
|
|
20
|
+
* A memory limit for the lane's worker, in bytes of wasm memory (workerMemory). Wasm memory never
|
|
21
|
+
* shrinks, so a long-lived worker creeps up through requests that each allocate and drop some.
|
|
22
|
+
* When a request finishes (its body read to the end, or failed) and the lane has nothing else
|
|
23
|
+
* in flight, the lane asks the worker how much it holds, and past this limit replaces it with a
|
|
24
|
+
* fresh one, warmed as on an abort. It never interrupts a request, so a lane that's never idle
|
|
25
|
+
* never restarts, and a response whose body nobody reads keeps the lane busy. Size it above what
|
|
26
|
+
* the warmed state needs, or the lane restarts after every request.
|
|
27
|
+
*/
|
|
28
|
+
maxMemoryBytes?: number;
|
|
29
|
+
/** Called when the lane replaces its worker for passing maxMemoryBytes, with what it held. */
|
|
30
|
+
onRestart?: (why: { memoryBytes: number }) => void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** One replaceable worker. See startLane. */
|
|
34
|
+
export interface Lane {
|
|
35
|
+
/**
|
|
36
|
+
* A fetch for a Connect transport, like workerFetch, answered by the lane's current worker.
|
|
37
|
+
* Aborting a request (init.signal) rejects it with the signal's reason at once (or errors its
|
|
38
|
+
* body, if it's already streaming), terminates the worker, and starts and warms a new one; other
|
|
39
|
+
* requests still waiting on the old worker are rejected too. A request made meanwhile waits for
|
|
40
|
+
* the new worker.
|
|
41
|
+
*/
|
|
42
|
+
fetch: Fetch;
|
|
43
|
+
/** The lane's current worker, once it's started and warmed. */
|
|
44
|
+
worker(): Promise<Worker>;
|
|
45
|
+
/** Replaces the worker, as an abort does, and resolves once the new one is warm. */
|
|
46
|
+
restart(): Promise<void>;
|
|
47
|
+
/** Terminates the worker. Requests after this reject. */
|
|
48
|
+
close(): void;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Starts a lane: a worker from `opts`, warmed with `lane.warm`, that the lane replaces whenever a
|
|
53
|
+
* request on it is aborted. Give each kind of request its own lane (a "serve" lane for quick
|
|
54
|
+
* queries, a "jobs" lane for long jobs) by building each client's transport on that lane's fetch.
|
|
55
|
+
* Every lane of one ns shares the same Cache, which is how a replacement or a second lane gets the
|
|
56
|
+
* state without rebuilding it.
|
|
57
|
+
*/
|
|
58
|
+
export function startLane(opts: StartWorkerOptions, lane: LaneOptions = {}): Lane {
|
|
59
|
+
let closed = false;
|
|
60
|
+
let current!: Promise<Worker>;
|
|
61
|
+
let failed = false;
|
|
62
|
+
|
|
63
|
+
const boot = () => {
|
|
64
|
+
failed = false;
|
|
65
|
+
const p: Promise<Worker> = startWorker(opts).then(async (w) => {
|
|
66
|
+
if (lane.warm) {
|
|
67
|
+
try {
|
|
68
|
+
await lane.warm(workerFetch(w));
|
|
69
|
+
} catch (err) {
|
|
70
|
+
endWorker(w, err instanceof Error ? err : new Error(String(err)));
|
|
71
|
+
throw err;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return w;
|
|
75
|
+
});
|
|
76
|
+
p.catch(() => {
|
|
77
|
+
if (current === p) failed = true;
|
|
78
|
+
});
|
|
79
|
+
current = p;
|
|
80
|
+
return p;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const replace = (old: Worker, why: Error) => {
|
|
84
|
+
endWorker(old, why);
|
|
85
|
+
return boot();
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
let inFlight = 0;
|
|
89
|
+
// Runs when the lane goes idle. A request that starts while workerMemory is out means the lane
|
|
90
|
+
// isn't idle any more, so the restart waits for the next idle moment.
|
|
91
|
+
const checkMemory = async (w: Worker, of: Promise<Worker>) => {
|
|
92
|
+
let bytes: number;
|
|
93
|
+
try {
|
|
94
|
+
bytes = await workerMemory(w);
|
|
95
|
+
} catch {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (bytes <= lane.maxMemoryBytes! || inFlight !== 0 || current !== of || closed) return;
|
|
99
|
+
lane.onRestart?.({ memoryBytes: bytes });
|
|
100
|
+
void replace(w, new Error("wasm host lane: restarted past its memory limit")).catch(() => {});
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
boot();
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
async fetch(input, init) {
|
|
107
|
+
if (closed) throw new Error("wasm host lane: closed");
|
|
108
|
+
const signal = init?.signal ?? (input instanceof Request ? input.signal : undefined);
|
|
109
|
+
if (signal?.aborted) throw abortReason(signal);
|
|
110
|
+
if (failed) boot();
|
|
111
|
+
const booting = current;
|
|
112
|
+
const w = await booting;
|
|
113
|
+
if (signal?.aborted) throw abortReason(signal);
|
|
114
|
+
inFlight++;
|
|
115
|
+
let ended = false;
|
|
116
|
+
const end = () => {
|
|
117
|
+
if (ended) return;
|
|
118
|
+
ended = true;
|
|
119
|
+
inFlight--;
|
|
120
|
+
if (inFlight === 0 && lane.maxMemoryBytes !== undefined && current === booting) void checkMemory(w, booting);
|
|
121
|
+
};
|
|
122
|
+
const run = workerFetch(w)(input, init);
|
|
123
|
+
if (!signal) {
|
|
124
|
+
return run.then(
|
|
125
|
+
(res) => untilBodyEnds(res, end),
|
|
126
|
+
(err) => {
|
|
127
|
+
end();
|
|
128
|
+
throw err;
|
|
129
|
+
},
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
return new Promise<Response>((resolve, reject) => {
|
|
133
|
+
const onAbort = () => {
|
|
134
|
+
end();
|
|
135
|
+
reject(abortReason(signal));
|
|
136
|
+
if (current === booting) void replace(w, new Error("wasm host lane: restarted after an abort")).catch(() => {});
|
|
137
|
+
};
|
|
138
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
139
|
+
run.then(
|
|
140
|
+
// A streamed body is still coming from the busy worker, so an abort until it ends must
|
|
141
|
+
// still end the worker.
|
|
142
|
+
(res) =>
|
|
143
|
+
resolve(
|
|
144
|
+
untilBodyEnds(res, () => {
|
|
145
|
+
signal.removeEventListener("abort", onAbort);
|
|
146
|
+
end();
|
|
147
|
+
}),
|
|
148
|
+
),
|
|
149
|
+
(err) => {
|
|
150
|
+
signal.removeEventListener("abort", onAbort);
|
|
151
|
+
end();
|
|
152
|
+
reject(err);
|
|
153
|
+
},
|
|
154
|
+
);
|
|
155
|
+
});
|
|
156
|
+
},
|
|
157
|
+
worker: () => current,
|
|
158
|
+
async restart() {
|
|
159
|
+
const old = await current.catch(() => undefined);
|
|
160
|
+
await (old ? replace(old, new Error("wasm host lane: restarted")) : boot());
|
|
161
|
+
},
|
|
162
|
+
close() {
|
|
163
|
+
closed = true;
|
|
164
|
+
void current.then((w) => endWorker(w, new Error("wasm host lane: closed")), () => {});
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function abortReason(signal: AbortSignal): unknown {
|
|
170
|
+
return signal.reason ?? new DOMException("This request was aborted.", "AbortError");
|
|
171
|
+
}
|
package/src/wasmhost/protocol.ts
CHANGED
|
@@ -16,7 +16,21 @@ export type HostRequest =
|
|
|
16
16
|
| { id: number; kind: "mount"; name: string; files: Files }
|
|
17
17
|
| { id: number; kind: "add"; name: string; files: Files }
|
|
18
18
|
| { id: number; kind: "stats" }
|
|
19
|
-
| { id: number; kind: "unmount"; name: string }
|
|
19
|
+
| { id: number; kind: "unmount"; name: string }
|
|
20
|
+
/** Cancels the Go context of http request `target`, if it's still running. */
|
|
21
|
+
| { id: number; kind: "cancel"; target: number };
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Part of an http response, sent when the Go handler flushes (wasmhost's Host.DoStream), before the
|
|
25
|
+
* request's final HostReply. The first chunk of a response carries its status and headers.
|
|
26
|
+
*/
|
|
27
|
+
export type HostChunk = {
|
|
28
|
+
id: number;
|
|
29
|
+
chunk: true;
|
|
30
|
+
status?: number;
|
|
31
|
+
headers?: Record<string, string>;
|
|
32
|
+
body: Uint8Array;
|
|
33
|
+
};
|
|
20
34
|
|
|
21
35
|
export type HostReply =
|
|
22
36
|
| {
|
package/src/wasmhost/worker.ts
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
// It is a classic worker, built as an IIFE, because Go's wasm_exec.js is a plain script defining
|
|
7
7
|
// globalThis.Go, which importScripts can load and a module worker cannot. The asset URLs and the
|
|
8
8
|
// namespace come in the worker's query string (?wasm=&exec=&ns=), which startWorker writes.
|
|
9
|
-
import type { Files, HostReply, HostRequest, HostStatus } from "./protocol";
|
|
9
|
+
import type { Files, HostChunk, HostReply, HostRequest, HostStatus } from "./protocol";
|
|
10
|
+
import { workerCache } from "./cache";
|
|
10
11
|
|
|
11
12
|
interface Exports {
|
|
12
13
|
ready?: () => void;
|
|
@@ -15,7 +16,11 @@ interface Exports {
|
|
|
15
16
|
url: string,
|
|
16
17
|
headers: Record<string, string>,
|
|
17
18
|
body: Uint8Array | null,
|
|
19
|
+
onChunk?: (c: { status?: number; headers?: Record<string, string>; body: Uint8Array }) => void,
|
|
20
|
+
id?: number,
|
|
18
21
|
): Promise<{ status: number; headers: Record<string, string>; body: Uint8Array }>;
|
|
22
|
+
/** Missing in a wasm built against goapplib before 0.6.7; cancelling is then a no-op. */
|
|
23
|
+
cancel?(id: number): Promise<void>;
|
|
19
24
|
mount(name: string, files: Files): Promise<void>;
|
|
20
25
|
add(name: string, files: Files): Promise<void>;
|
|
21
26
|
unmount(name: string): Promise<void>;
|
|
@@ -39,7 +44,11 @@ const ns = params.get("ns") ?? "wasmhost";
|
|
|
39
44
|
// The wasm's linear memory, exported by Go as `mem`. It only grows, so its size is the peak so far.
|
|
40
45
|
let memory: WebAssembly.Memory | undefined;
|
|
41
46
|
|
|
42
|
-
const post = (m: HostStatus | HostReply, transfer: Transferable[] = []) => self.postMessage(m, transfer);
|
|
47
|
+
const post = (m: HostStatus | HostReply | HostChunk, transfer: Transferable[] = []) => self.postMessage(m, transfer);
|
|
48
|
+
|
|
49
|
+
// Go's wasmhost.BrowserCache finds the cache here (cache.ts).
|
|
50
|
+
const cache = workerCache(ns, (self as unknown as { navigator?: { storage?: { getDirectory?: () => Promise<unknown> } } }).navigator?.storage);
|
|
51
|
+
if (cache) self.wasmhostCache = cache;
|
|
43
52
|
|
|
44
53
|
async function boot(): Promise<Exports> {
|
|
45
54
|
importScripts(execUrl);
|
|
@@ -71,7 +80,14 @@ self.onmessage = async (ev) => {
|
|
|
71
80
|
try {
|
|
72
81
|
const host = await booted;
|
|
73
82
|
if (req.kind === "http") {
|
|
74
|
-
const res = await host.http(
|
|
83
|
+
const res = await host.http(
|
|
84
|
+
req.method,
|
|
85
|
+
req.url,
|
|
86
|
+
req.headers,
|
|
87
|
+
req.body,
|
|
88
|
+
(c) => post({ id: req.id, chunk: true, ...c }, [c.body.buffer]),
|
|
89
|
+
req.id,
|
|
90
|
+
);
|
|
75
91
|
post({ id: req.id, ok: true, status: res.status, headers: res.headers, body: res.body }, [res.body.buffer]);
|
|
76
92
|
} else if (req.kind === "mount") {
|
|
77
93
|
await host.mount(req.name, req.files);
|
|
@@ -81,6 +97,9 @@ self.onmessage = async (ev) => {
|
|
|
81
97
|
post({ id: req.id, ok: true });
|
|
82
98
|
} else if (req.kind === "stats") {
|
|
83
99
|
post({ id: req.id, ok: true, memoryBytes: memory?.buffer.byteLength ?? 0 });
|
|
100
|
+
} else if (req.kind === "cancel") {
|
|
101
|
+
await host.cancel?.(req.target);
|
|
102
|
+
post({ id: req.id, ok: true });
|
|
84
103
|
} else if (req.kind === "unmount") {
|
|
85
104
|
await host.unmount(req.name);
|
|
86
105
|
post({ id: req.id, ok: true });
|