@statewalker/webrun-http-browser 0.3.4 → 0.4.2

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.4.2",
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,8 +42,9 @@
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-rpc": "0.4.0",
47
+ "@statewalker/webrun-streams": "0.2.0"
47
48
  },
48
49
  "devDependencies": {
49
50
  "@types/node": "^26.2.0",
@@ -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
  }
@@ -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";
@@ -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,
@@ -47,7 +47,12 @@ export class SwPortHandler {
47
47
  get serviceWorkerUrl(): string {
48
48
  if (!this._serviceWorkerUrl) {
49
49
  const url = this.options.serviceWorkerUrl
50
- ? new URL(this.options.serviceWorkerUrl)
50
+ ? // A worker url is relative to the document that registers it, so it
51
+ // is resolved against `location.href` like any other url a page
52
+ // writes. Without the base, the root-relative form callers actually
53
+ // use — `"/sw-worker.js"` — threw a bare `Invalid URL` naming
54
+ // neither the option nor the value.
55
+ resolveWorkerUrl(this.options.serviceWorkerUrl)
51
56
  : new URL("./index-sw.js", this.rootUrl);
52
57
  this._serviceWorkerUrl = `${url}`;
53
58
  }
@@ -305,3 +310,23 @@ export class SwPortDispatcher {
305
310
  return index;
306
311
  }
307
312
  }
313
+
314
+ /**
315
+ * Resolve a `serviceWorkerUrl` option the way a page would: relative to the
316
+ * current document. Absolute urls pass through untouched. A url that cannot
317
+ * be resolved is reported with the option name and the offending value,
318
+ * because the raw `TypeError: Failed to construct 'URL': Invalid URL` says
319
+ * neither.
320
+ */
321
+ function resolveWorkerUrl(serviceWorkerUrl: string): URL {
322
+ const base = globalThis.location?.href;
323
+ try {
324
+ return new URL(serviceWorkerUrl, base);
325
+ } catch (error) {
326
+ throw new Error(
327
+ `Invalid serviceWorkerUrl: ${JSON.stringify(serviceWorkerUrl)}` +
328
+ (base ? ` (relative to ${base})` : " (no document to resolve it against)"),
329
+ { cause: error },
330
+ );
331
+ }
332
+ }
package/LICENSE DELETED
@@ -1,21 +0,0 @@
1
- MIT License
2
-
3
- Copyright (c) 2022-2026 statewalker
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.