@statewalker/webrun-http-browser 0.7.0 → 0.9.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/index.js +946 -120
- package/dist/link/async-dispose.d.ts +10 -0
- package/dist/link/async-dispose.d.ts.map +1 -0
- package/dist/link/http-link.d.ts +16 -2
- package/dist/link/http-link.d.ts.map +1 -1
- package/dist/relay-sw.js +809 -132
- package/dist/relay-worker.js +809 -132
- package/dist/sw/protocol.d.ts +2 -0
- package/dist/sw/protocol.d.ts.map +1 -1
- package/dist/sw/sw-http-adapter.d.ts +2 -0
- package/dist/sw/sw-http-adapter.d.ts.map +1 -1
- package/dist/sw/worker-service.d.ts.map +1 -1
- package/dist/sw-worker.js +929 -118
- package/dist/sw.js +942 -119
- package/dist/worker.js +938 -119
- package/package.json +4 -4
- package/src/link/async-dispose.ts +22 -0
- package/src/link/http-link.ts +64 -12
- package/src/sw/protocol.ts +4 -3
- package/src/sw/sw-http-adapter.ts +7 -0
- package/src/sw/worker-service.ts +14 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@statewalker/webrun-http-browser",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "ServiceWorker-based HTTP and WebSocket server for browsers: handlers in pages and Dedicated Workers, same-origin and relay modes, streams with backpressure",
|
|
@@ -58,9 +58,9 @@
|
|
|
58
58
|
],
|
|
59
59
|
"dependencies": {
|
|
60
60
|
"idb-keyval": "^6.3.0",
|
|
61
|
-
"@statewalker/webrun-http-streams": "^0.3.
|
|
62
|
-
"@statewalker/webrun-
|
|
63
|
-
"@statewalker/webrun-
|
|
61
|
+
"@statewalker/webrun-http-streams": "^0.3.2",
|
|
62
|
+
"@statewalker/webrun-streams": "^0.2.3",
|
|
63
|
+
"@statewalker/webrun-rpc": "^0.7.0"
|
|
64
64
|
},
|
|
65
65
|
"devDependencies": {
|
|
66
66
|
"@biomejs/biome": "^2.5.15",
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Makes `target` disposable with `await using`: installs `dispose` as its
|
|
3
|
+
* `[Symbol.asyncDispose]`, where the runtime defines the symbol. Hand-written
|
|
4
|
+
* iterators lose what a native generator gets from `AsyncIteratorPrototype`;
|
|
5
|
+
* this gives it back. A no-op where the symbol is missing. Kept off the
|
|
6
|
+
* declared types, which would require consumers to compile with the
|
|
7
|
+
* `esnext.disposable` lib.
|
|
8
|
+
*/
|
|
9
|
+
export function asyncDisposable<T extends object>(target: T, dispose: () => Promise<unknown>): T {
|
|
10
|
+
// Read here, not at module level: a top-level read is code a bundler keeps.
|
|
11
|
+
const asyncDispose = (Symbol as { asyncDispose?: symbol }).asyncDispose;
|
|
12
|
+
if (asyncDispose && !(asyncDispose in target)) {
|
|
13
|
+
Object.defineProperty(target, asyncDispose, {
|
|
14
|
+
value: async () => {
|
|
15
|
+
await dispose();
|
|
16
|
+
},
|
|
17
|
+
configurable: true,
|
|
18
|
+
writable: true,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
return target;
|
|
22
|
+
}
|
package/src/link/http-link.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { fetchOverDuplex, serveFetchOverDuplex } from "@statewalker/webrun-http-streams";
|
|
2
2
|
import { connect, overPipe, serve, structuredCodec } from "@statewalker/webrun-rpc";
|
|
3
|
+
import { asyncDisposable } from "./async-dispose.js";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* The version of the HTTP-over-`MessagePort` protocol this package speaks,
|
|
@@ -19,6 +20,20 @@ export interface HttpLinkOptions {
|
|
|
19
20
|
* none: a slow consumer is throttled, never failed.
|
|
20
21
|
*/
|
|
21
22
|
streamTimeout?: number;
|
|
23
|
+
/**
|
|
24
|
+
* Ceiling on the requests in flight on the link at once: webrun-rpc's
|
|
25
|
+
* `maxPorts`, one multiplexed port per request (default 1024). A request
|
|
26
|
+
* past it fails on the calling end; one the answering end has no room for is
|
|
27
|
+
* refused. Every exchange gives its port back when it ends, however it ends.
|
|
28
|
+
*/
|
|
29
|
+
maxPorts?: number;
|
|
30
|
+
/**
|
|
31
|
+
* The answering end only: once a response is out, how long the request body
|
|
32
|
+
* may go unread before it is abandoned and the exchange ends (default 30 s;
|
|
33
|
+
* webrun-http-streams' `requestIdleMs`). A handler that answers first and
|
|
34
|
+
* reads the upload afterwards keeps it as long as it keeps reading.
|
|
35
|
+
*/
|
|
36
|
+
requestIdleMs?: number;
|
|
22
37
|
}
|
|
23
38
|
|
|
24
39
|
/** The calling end of a link: sends requests, gets responses. */
|
|
@@ -62,10 +77,10 @@ const LINK_CLOSE = "webrun-http:link-close";
|
|
|
62
77
|
/** Opens the calling end of a link on `port` (one port per link). */
|
|
63
78
|
export async function connectHttpLink(
|
|
64
79
|
port: MessagePort,
|
|
65
|
-
{ streamTimeout }: HttpLinkOptions = {},
|
|
80
|
+
{ streamTimeout, maxPorts }: HttpLinkOptions = {},
|
|
66
81
|
): Promise<HttpLink> {
|
|
67
82
|
const { call, close } = await connect({
|
|
68
|
-
mux: overPipe(port, { codec: structuredCodec, side: "initiator" }),
|
|
83
|
+
mux: overPipe(port, { codec: structuredCodec, side: "initiator", maxPorts }),
|
|
69
84
|
timeout: streamTimeout,
|
|
70
85
|
});
|
|
71
86
|
return trackedLink(
|
|
@@ -87,14 +102,14 @@ export async function connectHttpLink(
|
|
|
87
102
|
export async function serveHttpLink(
|
|
88
103
|
port: MessagePort,
|
|
89
104
|
handler: (request: Request) => Response | Promise<Response>,
|
|
90
|
-
{ streamTimeout }: HttpLinkOptions = {},
|
|
105
|
+
{ streamTimeout, maxPorts, requestIdleMs }: HttpLinkOptions = {},
|
|
91
106
|
): Promise<() => Promise<void>> {
|
|
92
107
|
const teardown = await serve(
|
|
93
108
|
{
|
|
94
|
-
mux: overPipe(port, { codec: structuredCodec, side: "responder" }),
|
|
109
|
+
mux: overPipe(port, { codec: structuredCodec, side: "responder", maxPorts }),
|
|
95
110
|
timeout: streamTimeout,
|
|
96
111
|
},
|
|
97
|
-
serveFetchOverDuplex(async (request) => await handler(request)),
|
|
112
|
+
serveFetchOverDuplex(async (request) => await handler(request), { requestIdleMs }),
|
|
98
113
|
);
|
|
99
114
|
const onClose = (event: MessageEvent) => {
|
|
100
115
|
if ((event.data as { type?: unknown } | null)?.type === LINK_CLOSE) void close();
|
|
@@ -166,7 +181,7 @@ export function trackedLink(
|
|
|
166
181
|
return response;
|
|
167
182
|
}
|
|
168
183
|
return new Response(
|
|
169
|
-
guardBody(response.body, lost, () => lostReason, finish),
|
|
184
|
+
guardBody(response.body, lost, () => lostReason, finish, request.signal),
|
|
170
185
|
{
|
|
171
186
|
status: response.status,
|
|
172
187
|
statusText: response.statusText,
|
|
@@ -184,39 +199,76 @@ export function trackedLink(
|
|
|
184
199
|
} catch {}
|
|
185
200
|
},
|
|
186
201
|
};
|
|
187
|
-
|
|
202
|
+
// `await using link = await connectHttpLink(port)` closes it at the end of the block.
|
|
203
|
+
return asyncDisposable(link, () => link.close());
|
|
188
204
|
}
|
|
189
205
|
|
|
190
206
|
/**
|
|
191
|
-
* A response body that reports its end (done, cancelled or
|
|
192
|
-
* with the link's reason when the link is lost mid-stream.
|
|
207
|
+
* A response body that reports its end (done, cancelled, failed or aborted) and
|
|
208
|
+
* fails with the link's reason when the link is lost mid-stream. An abort of the
|
|
209
|
+
* request's signal ends it at once, read or not: the exchange is over (the layer
|
|
210
|
+
* below has given its port back), so the request leaves `inflight` then.
|
|
193
211
|
*/
|
|
194
212
|
function guardBody(
|
|
195
213
|
body: ReadableStream<Uint8Array>,
|
|
196
214
|
lost: Promise<never>,
|
|
197
215
|
reason: () => Error | undefined,
|
|
198
216
|
finish: () => void,
|
|
217
|
+
signal: AbortSignal | undefined,
|
|
199
218
|
): ReadableStream<Uint8Array> {
|
|
200
219
|
const reader = body.getReader();
|
|
220
|
+
let controller!: ReadableStreamDefaultController<Uint8Array>;
|
|
221
|
+
let ended = false;
|
|
222
|
+
const end = () => {
|
|
223
|
+
ended = true;
|
|
224
|
+
signal?.removeEventListener("abort", onAbort);
|
|
225
|
+
finish();
|
|
226
|
+
};
|
|
227
|
+
const onAbort = () => {
|
|
228
|
+
if (ended) return;
|
|
229
|
+
end();
|
|
230
|
+
void reader.cancel().catch(() => {});
|
|
231
|
+
try {
|
|
232
|
+
controller.error(abortError(signal as AbortSignal));
|
|
233
|
+
} catch {
|
|
234
|
+
/* already closed or errored */
|
|
235
|
+
}
|
|
236
|
+
};
|
|
201
237
|
return new ReadableStream<Uint8Array>({
|
|
238
|
+
start(c) {
|
|
239
|
+
controller = c;
|
|
240
|
+
if (signal?.aborted) onAbort();
|
|
241
|
+
else signal?.addEventListener("abort", onAbort, { once: true });
|
|
242
|
+
},
|
|
202
243
|
async pull(controller) {
|
|
203
244
|
try {
|
|
204
245
|
const { done, value } = await Promise.race([reader.read(), lost]);
|
|
246
|
+
if (ended) return;
|
|
205
247
|
if (done) {
|
|
206
|
-
|
|
248
|
+
end();
|
|
207
249
|
controller.close();
|
|
208
250
|
return;
|
|
209
251
|
}
|
|
210
252
|
controller.enqueue(value);
|
|
211
253
|
} catch (error) {
|
|
212
|
-
|
|
254
|
+
if (ended) return;
|
|
255
|
+
end();
|
|
213
256
|
void reader.cancel().catch(() => {});
|
|
214
257
|
controller.error(reason() ?? error);
|
|
215
258
|
}
|
|
216
259
|
},
|
|
217
260
|
async cancel(why) {
|
|
218
|
-
|
|
261
|
+
end();
|
|
219
262
|
await reader.cancel(why).catch(() => {});
|
|
220
263
|
},
|
|
221
264
|
});
|
|
222
265
|
}
|
|
266
|
+
|
|
267
|
+
/** The signal's reason as an `Error` (`AbortError` when it gave none). */
|
|
268
|
+
function abortError(signal: AbortSignal): Error {
|
|
269
|
+
const why = (signal as unknown as { reason?: unknown }).reason;
|
|
270
|
+
if (why instanceof Error) return why;
|
|
271
|
+
const err = new Error(why === undefined ? "Aborted" : String(why));
|
|
272
|
+
err.name = "AbortError";
|
|
273
|
+
return err;
|
|
274
|
+
}
|
package/src/sw/protocol.ts
CHANGED
|
@@ -93,11 +93,12 @@ export const WORKER_HELLO = "webrun-http:worker-hello";
|
|
|
93
93
|
export type WorkerControlMessage =
|
|
94
94
|
// worker → page, once: the worker serves, and holds this owner lock.
|
|
95
95
|
| { type: "ready"; ownerLock?: string }
|
|
96
|
-
// page → worker: a fresh link (ports: down, ctl, up).
|
|
97
|
-
| { type: "link"; key: string; routerLock?: string }
|
|
96
|
+
// page → worker: a fresh link (ports: down, ctl, up), numbered per control port.
|
|
97
|
+
| { type: "link"; key: string; routerLock?: string; seq?: number }
|
|
98
98
|
// page → worker: drop the links of this key.
|
|
99
99
|
| { type: "unlink"; key: string }
|
|
100
100
|
// worker → page: my links are dead and a call waits; attach me again.
|
|
101
|
-
|
|
101
|
+
// `seen`: the `seq` of the last link received on this port (0: none yet).
|
|
102
|
+
| { type: "relink"; seen?: number }
|
|
102
103
|
// worker → page: a call is in flight; keep the ServiceWorker awake.
|
|
103
104
|
| { type: "keepalive" };
|
|
@@ -480,6 +480,8 @@ class WorkerLinkImpl implements WorkerLink {
|
|
|
480
480
|
private readonly _ownerLock?: string;
|
|
481
481
|
private _attaching?: Promise<void>;
|
|
482
482
|
private _detached = false;
|
|
483
|
+
/** The `seq` of the last link sent to the worker. */
|
|
484
|
+
private _sent = 0;
|
|
483
485
|
|
|
484
486
|
constructor(
|
|
485
487
|
private readonly _adapter: SwHttpAdapter,
|
|
@@ -494,6 +496,10 @@ class WorkerLinkImpl implements WorkerLink {
|
|
|
494
496
|
_control.onmessage = (event) => {
|
|
495
497
|
const message = event.data as WorkerControlMessage;
|
|
496
498
|
if (message?.type === "relink") {
|
|
499
|
+
// The worker has not received the latest link yet: it is on its way
|
|
500
|
+
// (a call made before it arrived asks too). Attaching again would
|
|
501
|
+
// replace the link a call is about to run on.
|
|
502
|
+
if (message.seen !== undefined && message.seen < this._sent) return;
|
|
497
503
|
// Starts the ServiceWorker if it was stopped; the attach follows.
|
|
498
504
|
this._adapter._poke(WAKE_MESSAGE);
|
|
499
505
|
void this.attach().catch(() => {});
|
|
@@ -555,6 +561,7 @@ class WorkerLinkImpl implements WorkerLink {
|
|
|
555
561
|
type: "link",
|
|
556
562
|
key: this.key,
|
|
557
563
|
routerLock: reply.routerLock,
|
|
564
|
+
seq: ++this._sent,
|
|
558
565
|
} satisfies WorkerControlMessage,
|
|
559
566
|
[down.port2, ctl.port2, up.port2],
|
|
560
567
|
);
|
package/src/sw/worker-service.ts
CHANGED
|
@@ -64,12 +64,23 @@ export function serveHttpInWorker(
|
|
|
64
64
|
const ownerLock = newLockName("webrun-http-owner");
|
|
65
65
|
const held = holdLock(ownerLock);
|
|
66
66
|
const controls = new Set<MessagePort>();
|
|
67
|
+
// Per control port, the `seq` of the last link received on it. A relink
|
|
68
|
+
// request carries it: the page attaches again only if this worker has its
|
|
69
|
+
// latest link, not while that link is still on its way.
|
|
70
|
+
const seen = new Map<MessagePort, number>();
|
|
67
71
|
const tellPages = (message: WorkerControlMessage) => {
|
|
68
72
|
for (const control of controls) control.postMessage(message);
|
|
69
73
|
};
|
|
70
74
|
const endpoint = new HandlerEndpoint(handler, {
|
|
71
75
|
...options,
|
|
72
|
-
requestRelink: () =>
|
|
76
|
+
requestRelink: () => {
|
|
77
|
+
for (const control of controls) {
|
|
78
|
+
control.postMessage({
|
|
79
|
+
type: "relink",
|
|
80
|
+
seen: seen.get(control) ?? 0,
|
|
81
|
+
} satisfies WorkerControlMessage);
|
|
82
|
+
}
|
|
83
|
+
},
|
|
73
84
|
keepAlive: () => tellPages({ type: "keepalive" }),
|
|
74
85
|
});
|
|
75
86
|
|
|
@@ -82,6 +93,7 @@ export function serveHttpInWorker(
|
|
|
82
93
|
control.onmessage = (message) => {
|
|
83
94
|
const msg = message.data as WorkerControlMessage;
|
|
84
95
|
if (msg?.type === "link") {
|
|
96
|
+
if (msg.seq !== undefined) seen.set(control, msg.seq);
|
|
85
97
|
const [down, ctl, up] = message.ports;
|
|
86
98
|
if (down) void endpoint.link(msg.key, { down, ctl, up }, msg.routerLock);
|
|
87
99
|
} else if (msg?.type === "unlink") {
|
|
@@ -127,6 +139,7 @@ export function serveHttpInWorker(
|
|
|
127
139
|
await endpoint.close();
|
|
128
140
|
for (const control of controls) control.close();
|
|
129
141
|
controls.clear();
|
|
142
|
+
seen.clear();
|
|
130
143
|
(await held)();
|
|
131
144
|
},
|
|
132
145
|
};
|