@statewalker/webrun-http-browser 0.3.4 → 0.5.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.3.4",
3
+ "version": "0.5.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "ServiceWorker-based HTTP server for browsers, with relay and same-origin dispatch modes",
@@ -42,15 +42,19 @@
42
42
  ],
43
43
  "dependencies": {
44
44
  "idb-keyval": "^6.3.0",
45
- "@statewalker/webrun-streams": "0.1.1",
46
- "@statewalker/webrun-http-streams": "0.2.1"
45
+ "@statewalker/webrun-http-streams": "0.2.2",
46
+ "@statewalker/webrun-streams": "0.2.0",
47
+ "@statewalker/webrun-rpc": "0.4.0"
47
48
  },
48
49
  "devDependencies": {
50
+ "@biomejs/biome": "^2.5.8",
49
51
  "@types/node": "^26.2.0",
50
52
  "http-server": "^14.1.1",
53
+ "playwright": "^1.62.1",
51
54
  "rimraf": "^6.1.3",
52
55
  "rolldown": "^1.2.4",
53
56
  "typescript": "^7.0.2",
57
+ "vite": "^8.2.1",
54
58
  "vitest": "^4.1.10"
55
59
  },
56
60
  "sideEffects": false,
@@ -60,6 +64,7 @@
60
64
  "scripts": {
61
65
  "build": "rimraf dist && rolldown -c && tsc --emitDeclarationOnly --declaration",
62
66
  "test": "vitest run",
67
+ "test:browser": "pnpm run build && vitest run --config vitest.browser.config.ts",
63
68
  "lint": "biome check src tests",
64
69
  "serve": "http-server -p 5173 -c-1 -s .",
65
70
  "example:same-origin": "pnpm run build && http-server -p 5173 -c-1 -o /public/index.html .",
@@ -125,11 +125,18 @@ export async function* sendStream<T>(
125
125
  communicationPort.postMessage({ type: "START_CALL", params }, [messageChannel.port2]);
126
126
 
127
127
  const channel = newStreamChannel<T>(messageChannel.port1);
128
+ let drained = false;
128
129
  try {
129
130
  await channel.start();
130
131
  void channel.sendAll(input);
131
132
  yield* channel.recieveAll();
133
+ drained = true;
132
134
  } finally {
135
+ // A caller that stops early — an aborted `fetch`, a cancelled response
136
+ // body, a `break` — has to TELL the peer. Closing a `MessagePort` does not
137
+ // notify the other end, so without this the handler on the far side keeps
138
+ // producing into a port nobody reads, for the life of the page.
139
+ if (!drained) channel.cancel();
133
140
  await channel.close();
134
141
  }
135
142
  }
@@ -168,26 +175,57 @@ export function handleStreams<T>(
168
175
  interface StreamChannel<T> {
169
176
  start(): Promise<void>;
170
177
  close(): Promise<void>;
178
+ /** Tell the peer we have stopped reading, so it can release its producer. */
179
+ cancel(): void;
171
180
  recieveAll(): AsyncGenerator<T, void, unknown>;
172
181
  sendAll(it: AsyncIterable<T>): Promise<void>;
173
182
  }
174
183
 
184
+ /** A data chunk, or — with `cancel` — the peer saying it has stopped reading. */
185
+ type StreamMessage<T> = { done?: boolean; value?: T; error?: unknown; cancel?: boolean };
186
+
175
187
  function newStreamChannel<T>(port: MessageTarget): StreamChannel<T> {
176
- type DataListener = (msg: { done?: boolean; value?: T; error?: unknown }) => Promise<boolean>;
188
+ type DataListener = (msg: StreamMessage<T>) => Promise<boolean>;
177
189
  let listeners: DataListener[] = [];
178
190
  let iterators: AsyncIterable<T>[] = [];
179
191
 
180
- const notifyAll = async (data: { done?: boolean; value?: T; error?: unknown }) => {
192
+ const notifyAll = async (data: StreamMessage<T>) => {
181
193
  for (const listener of listeners) await listener(data);
182
194
  };
183
195
 
196
+ /**
197
+ * Release whatever we are sending. NOT awaited: `.return()` on an async
198
+ * generator parked awaiting its own source is queued behind that pending
199
+ * `next()`, so awaiting it here would block the message handler — and the
200
+ * chunk that would unblock it can only arrive through that same handler.
201
+ */
202
+ const cancelOutgoing = (): void => {
203
+ for (const it of [...iterators]) {
204
+ const iterable = it as AsyncIterable<T> & { return?: () => unknown };
205
+ void Promise.resolve(iterable.return?.()).catch(() => {});
206
+ }
207
+ };
208
+
184
209
  const channel = newInvokationChannel({
185
210
  port,
186
- handler: (data: unknown) => notifyAll(data as { done?: boolean; value?: T; error?: unknown }),
211
+ handler: (data: unknown) => {
212
+ const message = data as StreamMessage<T>;
213
+ if (message?.cancel) {
214
+ cancelOutgoing();
215
+ return;
216
+ }
217
+ return notifyAll(message);
218
+ },
187
219
  });
188
220
 
189
221
  const start = () => channel.start();
190
222
 
223
+ const cancel = (): void => {
224
+ // Best effort by design: the port may already be gone, and a peer that
225
+ // never hears this is no worse off than before the message existed.
226
+ void channel.invoke({ cancel: true }).catch(() => {});
227
+ };
228
+
191
229
  const close = async () => {
192
230
  await notifyAll({ done: true });
193
231
  for (const it of [...iterators]) {
@@ -220,5 +258,5 @@ function newStreamChannel<T>(port: MessageTarget): StreamChannel<T> {
220
258
  );
221
259
  }
222
260
 
223
- return { start, close, recieveAll, sendAll };
261
+ return { start, close, cancel, recieveAll, sendAll };
224
262
  }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Settles like `promise` if it settles before `deadline` (a `Date.now()`
3
+ * value). Otherwise calls `onTimeout`: an `Error` it returns rejects, any
4
+ * other value resolves. Internal; not re-exported.
5
+ */
6
+ export function withDeadline<T, F>(
7
+ deadline: number,
8
+ promise: Promise<T>,
9
+ onTimeout: () => F,
10
+ ): Promise<T | (F extends Error ? never : F)> {
11
+ return new Promise((resolve, reject) => {
12
+ const timer = setTimeout(
13
+ () => {
14
+ const outcome = onTimeout();
15
+ if (outcome instanceof Error) reject(outcome);
16
+ else resolve(outcome as F extends Error ? never : F);
17
+ },
18
+ Math.max(0, deadline - Date.now()),
19
+ );
20
+ promise.then(
21
+ (value) => {
22
+ clearTimeout(timer);
23
+ resolve(value);
24
+ },
25
+ (error) => {
26
+ clearTimeout(timer);
27
+ reject(error);
28
+ },
29
+ );
30
+ });
31
+ }
package/src/core/index.ts CHANGED
@@ -7,3 +7,4 @@ export * from "./data-calls.js";
7
7
  export * from "./data-channels.js";
8
8
  export * from "./message-target.js";
9
9
  export * from "./registry.js";
10
+ export * from "./service-worker-control.js";
@@ -1,18 +1,8 @@
1
- export type MessageListener = (event: MessageEvent) => void | Promise<void>;
2
-
3
- /** An object we can listen for `"message"` events on. */
4
- export interface MessageSource {
5
- addEventListener(type: "message", listener: MessageListener): void;
6
- removeEventListener(type: "message", listener: MessageListener): void;
7
- start?(): void | Promise<void>;
8
- }
9
-
10
- /** An object we can post messages to (with optional transferable list). */
11
- export interface MessageSink {
12
- postMessage(message: unknown, transfer?: Transferable[]): void;
13
- }
14
-
15
- /** Full-duplex message target: both sends and receives. */
16
- export interface MessageTarget extends MessageSource, MessageSink {
17
- close?(): void | Promise<void>;
18
- }
1
+ // Re-exported from @statewalker/webrun-rpc, which owns the port layer, so this
2
+ // package's five internal importers and its public API are unchanged.
3
+ export type {
4
+ MessageListener,
5
+ MessageSink,
6
+ MessageSource,
7
+ MessageTarget,
8
+ } from "@statewalker/webrun-rpc";
@@ -0,0 +1,243 @@
1
+ /// <reference lib="webworker" />
2
+
3
+ import { callChannel, handleChannelCalls } from "./data-calls.js";
4
+ import { withDeadline } from "./deadline.js";
5
+
6
+ /**
7
+ * How long, by default, the page waits for its ServiceWorker to activate and
8
+ * to take control before giving up. Generous: a first install downloads and
9
+ * evaluates the worker script, which on a slow link takes seconds.
10
+ */
11
+ export const DEFAULT_SERVICE_WORKER_TIMEOUT = 30_000;
12
+
13
+ /**
14
+ * How long the page waits for `controllerchange` once the worker has
15
+ * answered the `CLAIM` request. `clients.claim()` resolves only after the
16
+ * browser has queued that event, so this is a grace period for delivery, not
17
+ * a second budget.
18
+ */
19
+ const CLAIM_GRACE_MS = 1_000;
20
+
21
+ /** Channel call a page sends to ask its ServiceWorker to `clients.claim()` it. */
22
+ export const CLAIM_CALL = "CLAIM";
23
+
24
+ /**
25
+ * - `activation-timeout`: the worker did not activate in time.
26
+ * - `uncontrolled`: the worker is active but the page is not controlled by it.
27
+ * - `unresponsive`: the page is controlled, but the worker did not answer the
28
+ * adapter's handshake in time.
29
+ */
30
+ export type ServiceWorkerControlFailure = "activation-timeout" | "uncontrolled" | "unresponsive";
31
+
32
+ /**
33
+ * Why a page could not get a working ServiceWorker. `reason` says which wait
34
+ * failed; check it (or `name`) rather than `instanceof`, because each of this
35
+ * package's bundles carries its own copy of this class.
36
+ */
37
+ export class ServiceWorkerControlError extends Error {
38
+ readonly reason: ServiceWorkerControlFailure;
39
+ constructor(reason: ServiceWorkerControlFailure, message: string) {
40
+ super(message);
41
+ this.name = "ServiceWorkerControlError";
42
+ this.reason = reason;
43
+ }
44
+ }
45
+
46
+ export interface AwaitServiceWorkerOptions {
47
+ /** Upper bound for the whole wait, in ms. Default `DEFAULT_SERVICE_WORKER_TIMEOUT`. */
48
+ timeout?: number;
49
+ }
50
+
51
+ export interface AwaitServiceWorkerControlOptions extends AwaitServiceWorkerOptions {
52
+ /**
53
+ * When the page is still uncontrolled after asking the worker to claim it,
54
+ * reload the page once instead of rejecting. A normal reload is a
55
+ * navigation, and navigations are controlled. Guarded by `sessionStorage`
56
+ * so it never loops: if the reloaded page is uncontrolled too, it rejects.
57
+ * Default `false`.
58
+ */
59
+ reloadIfUncontrolled?: boolean;
60
+ /** For tests. Default `navigator.serviceWorker`. */
61
+ container?: ServiceWorkerContainer;
62
+ }
63
+
64
+ /**
65
+ * Resolves with the registration's worker once it is `activated`. Rejects
66
+ * with a `ServiceWorkerControlError` (`reason: "activation-timeout"`) if that
67
+ * takes longer than `timeout` — an install that throws or never finishes
68
+ * would otherwise leave the caller waiting forever.
69
+ */
70
+ export async function awaitActiveServiceWorker(
71
+ registration: ServiceWorkerRegistration,
72
+ { timeout = DEFAULT_SERVICE_WORKER_TIMEOUT }: AwaitServiceWorkerOptions = {},
73
+ ): Promise<ServiceWorker> {
74
+ const deadline = Date.now() + timeout;
75
+ return await withDeadline(deadline, waitForActivated(registration), () => {
76
+ const worker = registration.installing ?? registration.waiting ?? registration.active;
77
+ return new ServiceWorkerControlError(
78
+ "activation-timeout",
79
+ `ServiceWorker ${scriptUrl(worker)} (scope ${registration.scope}) did not activate within ` +
80
+ `${timeout} ms` +
81
+ (worker ? `; it is "${worker.state}"` : "") +
82
+ ". Check the worker script for errors during install (DevTools → Application → " +
83
+ "Service Workers), or raise the `timeout` option.",
84
+ );
85
+ });
86
+ }
87
+
88
+ /**
89
+ * Resolves with the ServiceWorker that controls this page, once `registration`
90
+ * has an activated worker and the page is controlled by it.
91
+ *
92
+ * A page can stay uncontrolled while its worker is active, and then no
93
+ * `controllerchange` ever fires on its own:
94
+ * - a hard reload (Ctrl+Shift+R) bypasses the worker for that load, and the
95
+ * worker's `clients.claim()` already ran when it activated;
96
+ * - Firefox can leave a page loaded while the worker is running uncontrolled.
97
+ *
98
+ * So when the page is uncontrolled, this asks the active worker to claim it
99
+ * again (a `CLAIM` channel call — this package's workers answer it) and waits
100
+ * for `controllerchange`. Every wait is bounded by `timeout`. If control never
101
+ * comes it reloads once (`reloadIfUncontrolled`) or rejects with a
102
+ * `ServiceWorkerControlError` (`reason: "uncontrolled"`) that says what
103
+ * happened and what to do.
104
+ */
105
+ export async function awaitServiceWorkerControl(
106
+ registration: ServiceWorkerRegistration,
107
+ {
108
+ timeout = DEFAULT_SERVICE_WORKER_TIMEOUT,
109
+ reloadIfUncontrolled = false,
110
+ container = navigator.serviceWorker,
111
+ }: AwaitServiceWorkerControlOptions = {},
112
+ ): Promise<ServiceWorker> {
113
+ const deadline = Date.now() + timeout;
114
+ // Listen before anything else, so a `controllerchange` that lands while we
115
+ // wait for activation is not missed.
116
+ const controlled = waitForController(container);
117
+ try {
118
+ const active = await awaitActiveServiceWorker(registration, { timeout });
119
+ let controller = container.controller;
120
+ if (!controller) {
121
+ controller = await withDeadline(
122
+ deadline,
123
+ (async () => {
124
+ // Answered only once `clients.claim()` has resolved; by then the
125
+ // browser has queued `controllerchange` — or declined to.
126
+ await Promise.race([callChannel(active, CLAIM_CALL, {}), controlled.promise]);
127
+ return await Promise.race([controlled.promise, delay(CLAIM_GRACE_MS, null)]);
128
+ })(),
129
+ () => null,
130
+ );
131
+ }
132
+ if (controller) {
133
+ forgetReload(registration);
134
+ return controller;
135
+ }
136
+ if (reloadIfUncontrolled && markReload(registration)) {
137
+ location.reload();
138
+ // The page is going away; settling now would only race the unload.
139
+ return await new Promise<never>(() => {});
140
+ }
141
+ forgetReload(registration);
142
+ throw new ServiceWorkerControlError(
143
+ "uncontrolled",
144
+ `This page is not controlled by its ServiceWorker ${scriptUrl(active)} ` +
145
+ `(scope ${registration.scope}), although the worker is active, and the worker did not ` +
146
+ `take control when asked (clients.claim()) within ${timeout} ms. ` +
147
+ "This happens after a hard reload (Ctrl+Shift+R / Cmd+Shift+R), which bypasses " +
148
+ "ServiceWorkers for that load, and in Firefox for some pages opened while the worker " +
149
+ "was already running. Requests from this page would not reach the worker. " +
150
+ "Reload the page normally, pass `reloadIfUncontrolled: true` to do that automatically, " +
151
+ "check that the page is inside the worker's scope, and that the worker answers the " +
152
+ `"${CLAIM_CALL}" request (this package's workers do).`,
153
+ );
154
+ } finally {
155
+ controlled.cancel();
156
+ }
157
+ }
158
+
159
+ /**
160
+ * ServiceWorker side: answers the page's `CLAIM` request with
161
+ * `clients.claim()`, which takes over every uncontrolled client in scope.
162
+ * Returns a function that stops answering.
163
+ */
164
+ export function handleClaimRequests(self: ServiceWorkerGlobalScope): () => void {
165
+ return handleChannelCalls(self, CLAIM_CALL, (event) => {
166
+ const claimed = self.clients.claim().then(() => true);
167
+ (event as unknown as Partial<ExtendableMessageEvent>).waitUntil?.(claimed);
168
+ return claimed;
169
+ });
170
+ }
171
+
172
+ function waitForActivated(registration: ServiceWorkerRegistration): Promise<ServiceWorker> {
173
+ return new Promise((resolve) => {
174
+ const watched = new Set<ServiceWorker>();
175
+ const check = () => {
176
+ const active = registration.active;
177
+ if (active?.state === "activated") {
178
+ registration.removeEventListener("updatefound", watch);
179
+ for (const worker of watched) worker.removeEventListener("statechange", check);
180
+ resolve(active);
181
+ return;
182
+ }
183
+ watch();
184
+ };
185
+ function watch() {
186
+ for (const worker of [registration.installing, registration.waiting, registration.active]) {
187
+ if (!worker || watched.has(worker)) continue;
188
+ watched.add(worker);
189
+ worker.addEventListener("statechange", check);
190
+ }
191
+ }
192
+ registration.addEventListener("updatefound", watch);
193
+ check();
194
+ });
195
+ }
196
+
197
+ function waitForController(container: ServiceWorkerContainer): {
198
+ promise: Promise<ServiceWorker>;
199
+ cancel: () => void;
200
+ } {
201
+ let cancel = () => {};
202
+ const promise = new Promise<ServiceWorker>((resolve) => {
203
+ const onChange = () => {
204
+ if (!container.controller) return;
205
+ cancel();
206
+ resolve(container.controller);
207
+ };
208
+ cancel = () => container.removeEventListener("controllerchange", onChange);
209
+ container.addEventListener("controllerchange", onChange);
210
+ });
211
+ return { promise, cancel };
212
+ }
213
+
214
+ function delay<T>(ms: number, value: T): Promise<T> {
215
+ return new Promise((resolve) => setTimeout(() => resolve(value), ms));
216
+ }
217
+
218
+ function scriptUrl(worker: ServiceWorker | null | undefined): string {
219
+ return worker?.scriptURL ? `"${worker.scriptURL}"` : "(no worker)";
220
+ }
221
+
222
+ function reloadKey(registration: ServiceWorkerRegistration): string {
223
+ return `webrun-http-browser:reloaded-uncontrolled:${registration.scope}`;
224
+ }
225
+
226
+ /** Records the reload about to happen. `false` if one already happened, or if it cannot be recorded. */
227
+ function markReload(registration: ServiceWorkerRegistration): boolean {
228
+ try {
229
+ const key = reloadKey(registration);
230
+ if (sessionStorage.getItem(key)) return false;
231
+ sessionStorage.setItem(key, "1");
232
+ return true;
233
+ } catch {
234
+ // No storage, no loop guard: reloading could then repeat forever.
235
+ return false;
236
+ }
237
+ }
238
+
239
+ function forgetReload(registration: ServiceWorkerRegistration): void {
240
+ try {
241
+ sessionStorage.removeItem(reloadKey(registration));
242
+ } catch {}
243
+ }
@@ -39,13 +39,21 @@ async function httpFromIterator<Options>(
39
39
  }
40
40
 
41
41
  /**
42
- * @deprecated For new code, prefer the `MessagePort`-based stack from
43
- * `@statewalker/webrun-http-port`. Once a `MessagePort` is established between
44
- * page and worker, `httpServe(port, handler)` provides equivalent semantics
45
- * with `callBidi` multiplexing, full-duplex streaming, and `AbortSignal`.
46
- * This helper remains for existing ServiceWorker setups that still consume the
47
- * `MessageTarget` surface; it will be reimplemented on top of
48
- * `webrun-http-port` in a follow-up release.
42
+ * Serve an `HttpHandler` over a `MessageTarget`, using this package's own
43
+ * `handleStreams` transport.
44
+ *
45
+ * @deprecated Prefer the port stack in `@statewalker/webrun-rpc`: open a port
46
+ * (`multiplexPort` over one pipe, or `transferPortMux` where the platform can
47
+ * transfer a real `MessagePort`), turn it into a `Duplex` with
48
+ * `serveDuplexOverPort`, and serve HTTP on that with
49
+ * `httpServe(handler, options)` from `@statewalker/webrun-http-streams`.
50
+ *
51
+ * That path has backpressure; **this one does not.** `sendStream`'s chunk
52
+ * sender discards the promise it is given, so a fast producer over a slow
53
+ * consumer accumulates without bound. It also has no per-stream timeout and no
54
+ * chunking to a transport's message ceiling.
55
+ *
56
+ * Kept for existing ServiceWorker setups built on the `MessageTarget` surface.
49
57
  */
50
58
  export function handleHttpRequests(
51
59
  communicationPort: MessageTarget,
@@ -60,13 +68,18 @@ export function handleHttpRequests(
60
68
  }
61
69
 
62
70
  /**
63
- * @deprecated For new code, prefer the `MessagePort`-based stack from
64
- * `@statewalker/webrun-http-port/fetch`. Once the page and SW share a
65
- * `MessagePort`, `fetchOverPort(port, request)` provides the same
66
- * `Request → Response` semantics with multiplexing via `callBidi`, JSONL
67
- * envelope framing, and native `AbortSignal` support. This helper remains for
68
- * existing ServiceWorker setups; it will be reimplemented on top of
69
- * `webrun-http-port` in a follow-up release.
71
+ * Ship a `Request` over a `MessageTarget` and await the `Response`, using this
72
+ * package's own `sendStream` transport.
73
+ *
74
+ * @deprecated Prefer the port stack in `@statewalker/webrun-rpc`: open a port
75
+ * (`multiplexPort` over one pipe, or `transferPortMux` where the platform can
76
+ * transfer a real `MessagePort`), turn it into a `Duplex` with
77
+ * `duplexOverPort`, and drive HTTP over it with `httpFetch` from
78
+ * `@statewalker/webrun-http-streams`.
79
+ *
80
+ * Same caveat as {@link handleHttpRequests}: the transport underneath this
81
+ * helper has no backpressure, no per-stream timeout, and no chunking to a
82
+ * transport's message ceiling. Kept for existing ServiceWorker setups.
70
83
  */
71
84
  export async function sendHttpRequest(
72
85
  communicationPort: MessageTarget,
@@ -2,6 +2,7 @@ import { HttpError } from "@statewalker/webrun-http-streams";
2
2
  import { get, set } from "idb-keyval";
3
3
  import { callChannel, handleChannelCalls } from "../core/data-calls.js";
4
4
  import { newRegistry } from "../core/registry.js";
5
+ import { handleClaimRequests } from "../core/service-worker-control.js";
5
6
  import { sendHttpRequest } from "../http/http-send-recieve.js";
6
7
  import { splitServiceUrl } from "./split-service-url.js";
7
8
 
@@ -27,6 +28,10 @@ export function startRelayServiceWorker(self: ServiceWorkerGlobalScope): () => v
27
28
 
28
29
  const clientsRegistry = newClientsRegistry({ self });
29
30
 
31
+ // Pages bridge to `registration.active` when uncontrolled, so they do not
32
+ // need this; it is here so any page of this origin can ask for control.
33
+ register(handleClaimRequests(self));
34
+
30
35
  register(
31
36
  handleChannelCalls(self, "REGISTER", async (event, data) => {
32
37
  const source = event.source as Client | null;