@statewalker/webrun-http-browser 0.7.1 → 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.1",
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.2",
61
62
  "@statewalker/webrun-streams": "^0.2.3",
62
- "@statewalker/webrun-rpc": "^0.5.1",
63
- "@statewalker/webrun-http-streams": "^0.3.1"
63
+ "@statewalker/webrun-rpc": "^0.7.0"
64
64
  },
65
65
  "devDependencies": {
66
66
  "@biomejs/biome": "^2.5.15",
@@ -181,7 +181,7 @@ export function trackedLink(
181
181
  return response;
182
182
  }
183
183
  return new Response(
184
- guardBody(response.body, lost, () => lostReason, finish),
184
+ guardBody(response.body, lost, () => lostReason, finish, request.signal),
185
185
  {
186
186
  status: response.status,
187
187
  statusText: response.statusText,
@@ -204,35 +204,71 @@ export function trackedLink(
204
204
  }
205
205
 
206
206
  /**
207
- * A response body that reports its end (done, cancelled or failed) and fails
208
- * 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.
209
211
  */
210
212
  function guardBody(
211
213
  body: ReadableStream<Uint8Array>,
212
214
  lost: Promise<never>,
213
215
  reason: () => Error | undefined,
214
216
  finish: () => void,
217
+ signal: AbortSignal | undefined,
215
218
  ): ReadableStream<Uint8Array> {
216
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
+ };
217
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
+ },
218
243
  async pull(controller) {
219
244
  try {
220
245
  const { done, value } = await Promise.race([reader.read(), lost]);
246
+ if (ended) return;
221
247
  if (done) {
222
- finish();
248
+ end();
223
249
  controller.close();
224
250
  return;
225
251
  }
226
252
  controller.enqueue(value);
227
253
  } catch (error) {
228
- finish();
254
+ if (ended) return;
255
+ end();
229
256
  void reader.cancel().catch(() => {});
230
257
  controller.error(reason() ?? error);
231
258
  }
232
259
  },
233
260
  async cancel(why) {
234
- finish();
261
+ end();
235
262
  await reader.cancel(why).catch(() => {});
236
263
  },
237
264
  });
238
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
  };