@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@statewalker/webrun-http-browser",
3
- "version": "0.7.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.0",
62
- "@statewalker/webrun-rpc": "^0.5.0",
63
- "@statewalker/webrun-streams": "^0.2.2"
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
+ }
@@ -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
- return link;
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 failed) and fails
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
- finish();
248
+ end();
207
249
  controller.close();
208
250
  return;
209
251
  }
210
252
  controller.enqueue(value);
211
253
  } catch (error) {
212
- finish();
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
- finish();
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
+ }
@@ -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
- | { type: "relink" }
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
  );
@@ -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: () => tellPages({ type: "relink" }),
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
  };