@statewalker/webrun-http-browser 0.9.0 → 0.12.1

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.
Files changed (59) hide show
  1. package/README.md +155 -10
  2. package/dist/core/index.d.ts +1 -0
  3. package/dist/core/index.d.ts.map +1 -1
  4. package/dist/core/service-worker-control.d.ts +2 -1
  5. package/dist/core/service-worker-control.d.ts.map +1 -1
  6. package/dist/core/sw-scope.d.ts +31 -0
  7. package/dist/core/sw-scope.d.ts.map +1 -0
  8. package/dist/index.js +908 -221
  9. package/dist/link/http-link.d.ts +16 -3
  10. package/dist/link/http-link.d.ts.map +1 -1
  11. package/dist/relay/index-sw.d.ts +8 -2
  12. package/dist/relay/index-sw.d.ts.map +1 -1
  13. package/dist/relay/index.d.ts +1 -1
  14. package/dist/relay/index.d.ts.map +1 -1
  15. package/dist/relay-sw.js +470 -81
  16. package/dist/relay-worker.d.ts +1 -0
  17. package/dist/relay-worker.d.ts.map +1 -1
  18. package/dist/relay-worker.js +470 -81
  19. package/dist/sw/handler-endpoint.d.ts +5 -2
  20. package/dist/sw/handler-endpoint.d.ts.map +1 -1
  21. package/dist/sw/http-sw-dispatcher.d.ts +114 -7
  22. package/dist/sw/http-sw-dispatcher.d.ts.map +1 -1
  23. package/dist/sw/index.d.ts +1 -0
  24. package/dist/sw/index.d.ts.map +1 -1
  25. package/dist/sw/protocol.d.ts +3 -0
  26. package/dist/sw/protocol.d.ts.map +1 -1
  27. package/dist/sw/sw-dispatcher.d.ts +5 -2
  28. package/dist/sw/sw-dispatcher.d.ts.map +1 -1
  29. package/dist/sw/sw-http-adapter.d.ts +19 -4
  30. package/dist/sw/sw-http-adapter.d.ts.map +1 -1
  31. package/dist/sw/worker-service.d.ts +5 -2
  32. package/dist/sw/worker-service.d.ts.map +1 -1
  33. package/dist/sw-worker.js +681 -141
  34. package/dist/sw.js +896 -218
  35. package/dist/worker.js +689 -115
  36. package/dist/ws/server.d.ts +50 -2
  37. package/dist/ws/server.d.ts.map +1 -1
  38. package/dist/ws/socket-core.d.ts +5 -2
  39. package/dist/ws/socket-core.d.ts.map +1 -1
  40. package/dist/ws/upgrade.d.ts +12 -1
  41. package/dist/ws/upgrade.d.ts.map +1 -1
  42. package/package.json +4 -3
  43. package/src/core/index.ts +1 -0
  44. package/src/core/service-worker-control.ts +5 -3
  45. package/src/core/sw-scope.ts +27 -0
  46. package/src/link/http-link.ts +20 -4
  47. package/src/relay/index-sw.ts +15 -2
  48. package/src/relay/index.ts +2 -2
  49. package/src/relay-worker.ts +1 -0
  50. package/src/sw/handler-endpoint.ts +17 -4
  51. package/src/sw/http-sw-dispatcher.ts +267 -67
  52. package/src/sw/index.ts +5 -0
  53. package/src/sw/protocol.ts +11 -1
  54. package/src/sw/sw-dispatcher.ts +17 -11
  55. package/src/sw/sw-http-adapter.ts +36 -10
  56. package/src/sw/worker-service.ts +11 -4
  57. package/src/ws/server.ts +166 -11
  58. package/src/ws/socket-core.ts +7 -2
  59. package/src/ws/upgrade.ts +22 -4
@@ -22,6 +22,7 @@ import {
22
22
  type AttachReply,
23
23
  DETACH_CALL,
24
24
  DRAIN_CALL,
25
+ headerPairs,
25
26
  KEEPALIVE_MESSAGE,
26
27
  NEED_LINKS_CALL,
27
28
  type NeedLinksParams,
@@ -35,8 +36,23 @@ import { resolveWorkerUrl } from "./worker-url.js";
35
36
  export interface SwHttpAdapterOptions {
36
37
  /** The first path segment under the scope that this page's handlers serve. */
37
38
  key: string;
39
+ /** The worker's scope. Default: the folder of `serviceWorkerUrl`, or `registration`'s scope. */
38
40
  scope?: string;
39
41
  serviceWorkerUrl?: string;
42
+ /**
43
+ * How the browser loads the worker script: `"module"` for an ES-module worker (one that
44
+ * `import`s `startHttpDispatcher` from this package's `sw.js`), `"classic"` — the default — for
45
+ * one that `importScripts` `sw-worker.js`. A script registered again with another type than
46
+ * its registration's fails to update, so this must match how the page registers it.
47
+ */
48
+ type?: WorkerType;
49
+ /**
50
+ * A registration the page made itself, for a worker it registers its own way (its own script,
51
+ * scope or type). The adapter then registers nothing, and `stop()` leaves the registration in
52
+ * place: it is not the adapter's to remove. `scope` defaults to the registration's, and
53
+ * `type` is not used.
54
+ */
55
+ registration?: ServiceWorkerRegistration;
40
56
  /**
41
57
  * Upper bound, in ms, for `start()`'s wait for the worker to activate, take
42
58
  * control of the page and answer the handshake. `start()` rejects with a
@@ -145,7 +161,10 @@ export class SwHttpAdapter {
145
161
  }
146
162
 
147
163
  get scope(): string {
148
- return this.options.scope ?? new URL("./", this.serviceWorkerUrl).pathname;
164
+ if (this.options.scope) return this.options.scope;
165
+ const registration = this.options.registration;
166
+ if (registration) return new URL(registration.scope).pathname;
167
+ return new URL("./", this.serviceWorkerUrl).pathname;
149
168
  }
150
169
 
151
170
  /**
@@ -157,6 +176,9 @@ export class SwHttpAdapter {
157
176
  * sits next to it).
158
177
  */
159
178
  get rootUrl(): URL {
179
+ const registration = this.options.registration;
180
+ if (registration && !this.options.serviceWorkerUrl)
181
+ return new URL(this.scope, registration.scope);
160
182
  const base = this.options.serviceWorkerUrl ? this.serviceWorkerUrl : import.meta.url;
161
183
  return new URL(this.scope, base);
162
184
  }
@@ -183,9 +205,12 @@ export class SwHttpAdapter {
183
205
  async start(): Promise<void> {
184
206
  if (!this._registrationPromise) {
185
207
  this._registrationPromise = (async () => {
186
- const registration = await navigator.serviceWorker.register(this.serviceWorkerUrl, {
187
- scope: this.scope,
188
- });
208
+ const registration =
209
+ this.options.registration ??
210
+ (await navigator.serviceWorker.register(this.serviceWorkerUrl, {
211
+ scope: this.scope,
212
+ ...(this.options.type ? { type: this.options.type } : {}),
213
+ }));
189
214
  this._registration = registration;
190
215
  const stopListening = handleChannelCalls(
191
216
  navigator.serviceWorker,
@@ -238,7 +263,7 @@ export class SwHttpAdapter {
238
263
  return this._registrationPromise;
239
264
  }
240
265
 
241
- /** Detaches every link, and unregisters the worker. */
266
+ /** Detaches every link, and unregisters the worker (unless the page passed its `registration`). */
242
267
  async stop(): Promise<void> {
243
268
  for (const cleanup of this._cleanup.splice(0)) cleanup();
244
269
  for (const worker of [...this._workers]) await worker.detach().catch(() => {});
@@ -255,7 +280,7 @@ export class SwHttpAdapter {
255
280
  this._registration = undefined;
256
281
  this._registrationPromise = undefined;
257
282
  try {
258
- await registration?.unregister();
283
+ if (!this.options.registration) await registration?.unregister();
259
284
  } catch (error) {
260
285
  console.log("Service Worker unregistration failed: ", error);
261
286
  }
@@ -343,12 +368,13 @@ export class SwHttpAdapter {
343
368
  /**
344
369
  * Opens the transport of a socket to the handler `url` routes to: a handler
345
370
  * of this page directly, any other through the ServiceWorker. Resolves once
346
- * the handler accepted. An arrow function, so it can be handed around:
347
- * `createWebSocketClass(adapter.connectWebSocket)`.
371
+ * the handler accepted. The handler sees `headers` on the upgrade request. An arrow function,
372
+ * so it can be handed around: `createWebSocketClass(adapter.connectWebSocket)`.
348
373
  */
349
374
  connectWebSocket = async (
350
375
  url: string | URL,
351
376
  protocols: string[] = [],
377
+ headers?: HeadersInit,
352
378
  ): Promise<WebSocketConnection> => {
353
379
  const httpUrl = new URL(`${url}`, globalThis.location?.href);
354
380
  if (httpUrl.protocol === "ws:") httpUrl.protocol = "http:";
@@ -356,7 +382,7 @@ export class SwHttpAdapter {
356
382
  const local = this._findHandler(`${httpUrl}`);
357
383
  if (local) {
358
384
  // A handler in this very page needs no ServiceWorker.
359
- const result = await serveWebSocketUpgrade(local, { url: `${httpUrl}`, protocols });
385
+ const result = await serveWebSocketUpgrade(local, { url: `${httpUrl}`, protocols, headers });
360
386
  if (!result.ok) throw new Error(result.message);
361
387
  return { port: result.port, protocol: result.protocol };
362
388
  }
@@ -365,7 +391,7 @@ export class SwHttpAdapter {
365
391
  this._callTimeout(),
366
392
  this._worker(),
367
393
  WS_CONNECT_CALL,
368
- { url: `${httpUrl}`, protocols },
394
+ { url: `${httpUrl}`, protocols, headers: headerPairs(headers) },
369
395
  );
370
396
  };
371
397
 
@@ -30,8 +30,15 @@ export interface WorkerHttpService {
30
30
  * with `LinkLostError` rather than hanging, and the next waits for a link.
31
31
  */
32
32
  fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
33
- /** Opens the transport of a socket to the handler `url` routes to. */
34
- connectWebSocket(url: string | URL, protocols?: string[]): Promise<WebSocketConnection>;
33
+ /**
34
+ * Opens the transport of a socket to the handler `url` routes to. The handler sees `headers`
35
+ * on the upgrade request (cookies, origin, the routing a host needs), as with `ws-connect`.
36
+ */
37
+ connectWebSocket(
38
+ url: string | URL,
39
+ protocols?: string[],
40
+ headers?: HeadersInit,
41
+ ): Promise<WebSocketConnection>;
35
42
  /** A WHATWG `WebSocket` class over `connectWebSocket`. */
36
43
  readonly WebSocket: PortWebSocketClass;
37
44
  /** Whether the worker has a live link. */
@@ -110,11 +117,11 @@ export function serveHttpInWorker(
110
117
  scope.addEventListener("message", onMessage);
111
118
 
112
119
  const baseUrl = () => (globalThis as { location?: { href: string } }).location?.href;
113
- const connectWebSocket = (url: string | URL, protocols: string[] = []) => {
120
+ const connectWebSocket = (url: string | URL, protocols: string[] = [], headers?: HeadersInit) => {
114
121
  const httpUrl = new URL(`${url}`, baseUrl());
115
122
  if (httpUrl.protocol === "ws:") httpUrl.protocol = "http:";
116
123
  else if (httpUrl.protocol === "wss:") httpUrl.protocol = "https:";
117
- return endpoint.connectWebSocket(`${httpUrl}`, protocols);
124
+ return endpoint.connectWebSocket(`${httpUrl}`, protocols, headers);
118
125
  };
119
126
  let WebSocketClass: PortWebSocketClass | undefined;
120
127
 
package/src/ws/server.ts CHANGED
@@ -1,4 +1,11 @@
1
- import { DEFAULT_HEARTBEAT_MS, utf8Length, type WsFrame, type WsTimers } from "./frames.js";
1
+ import {
2
+ DEFAULT_HEARTBEAT_MS,
3
+ messageSize,
4
+ postFrame,
5
+ utf8Length,
6
+ type WsFrame,
7
+ type WsTimers,
8
+ } from "./frames.js";
2
9
  import {
3
10
  CLOSED,
4
11
  CLOSING,
@@ -15,8 +22,35 @@ export interface ServerWebSocketOptions {
15
22
  /** The subprotocol the server picked, `""` for none. */
16
23
  protocol?: string;
17
24
  timers?: WsTimers;
18
- /** How long `close()` waits for the client's echo, in ms. Default 5 s. */
25
+ /** How long `close()` waits for the client's echo, in ms. Default 5 s; `Infinity` for ever. */
19
26
  closeTimeoutMs?: number;
27
+ /**
28
+ * When the heartbeat watch starts: `"open"` (the default) at once, so a client that never
29
+ * pings is closed with 1006 after three default periods; `"first-ping"` with the client's
30
+ * first ping, so a peer that does not ping at all (the other end of a pair kept in the same
31
+ * realm, say) is never closed for it. Once pings have started, both close a socket whose
32
+ * pings stop. Under `"first-ping"` a peer that never pings is never found dead.
33
+ */
34
+ watch?: "open" | "first-ping";
35
+ /**
36
+ * Which heartbeat period the watch keeps: `"follow"` (the default) the one each ping
37
+ * announces, `"first"` the first ping's. A client announces a longer period when its page
38
+ * turns hidden (browsers throttle hidden pages' timers), and a closing tab turns hidden on its
39
+ * way out: following it notices a dead tab only after three of the longer periods.
40
+ */
41
+ heartbeatPeriod?: "follow" | "first";
42
+ /** Lets `send()` before `accept()`; the frames wait in the port until the peer reads. */
43
+ sendBeforeAccept?: boolean;
44
+ /**
45
+ * Delivers the messages that arrive after `close()` until the peer's close frame does (they
46
+ * were in flight), as workerd does. By default they are dropped, as browsers do.
47
+ */
48
+ deliverWhileClosing?: boolean;
49
+ /**
50
+ * The code `close()` sends when given none. Default 1000. 1005 ("no status code") is what a
51
+ * close frame without a code reads as on the other end, as `close()` does on workerd.
52
+ */
53
+ defaultCloseCode?: number;
20
54
  }
21
55
 
22
56
  /**
@@ -31,7 +65,8 @@ export interface ServerWebSocketOptions {
31
65
  * `whenDrained(limit)` waits for it to fall: the backpressure a producer
32
66
  * applies.
33
67
  * - When the client's pings stop for three heartbeat periods (a closed tab, a
34
- * terminated worker), the socket closes with 1006.
68
+ * terminated worker), the socket closes with 1006. With `watch: "first-ping"`
69
+ * the watch starts only once the client pings.
35
70
  */
36
71
  export class ServerWebSocket extends PortSocketCore {
37
72
  static readonly CONNECTING = CONNECTING;
@@ -41,19 +76,25 @@ export class ServerWebSocket extends PortSocketCore {
41
76
 
42
77
  readonly url: string;
43
78
  readonly protocol: string;
79
+ private _binaryType: "blob" | "arraybuffer" = "arraybuffer";
80
+ private readonly _options: ServerWebSocketOptions;
44
81
  private _accepted = false;
82
+ private _detached = false;
45
83
  private _queue: Array<Extract<WsFrame, { t: "m" | "c" }>> = [];
46
84
  private _heartbeatMs = DEFAULT_HEARTBEAT_MS;
47
85
  private _lastPing = Date.now();
86
+ /** Whether a ping (with a period a timer takes) arrived already. */
87
+ private _pinged = false;
48
88
  private _watch: unknown;
49
89
 
50
90
  constructor(port: MessagePort, options: ServerWebSocketOptions = {}) {
51
91
  super({ timers: options.timers, closeTimeoutMs: options.closeTimeoutMs });
92
+ this._options = options;
52
93
  this.url = options.url ?? "";
53
94
  this.protocol = options.protocol ?? "";
54
95
  this._attach(port);
55
96
  this._state = OPEN;
56
- this._watchHeartbeat();
97
+ if (options.watch !== "first-ping") this._watchHeartbeat();
57
98
  }
58
99
 
59
100
  /** Whether `accept()` was called. */
@@ -61,22 +102,111 @@ export class ServerWebSocket extends PortSocketCore {
61
102
  return this._accepted;
62
103
  }
63
104
 
105
+ /** How binary messages arrive: `ArrayBuffer`s (the default) or `Blob`s. Other values are ignored. */
106
+ get binaryType(): "blob" | "arraybuffer" {
107
+ return this._binaryType;
108
+ }
109
+ set binaryType(value: "blob" | "arraybuffer") {
110
+ if (value === "blob" || value === "arraybuffer") this._binaryType = value;
111
+ }
112
+
113
+ /**
114
+ * Hands this end's transport to another holder before it was accepted: the port to give the
115
+ * client (a pair's end returned in a 101, say), with the frames that reached this end first
116
+ * delivered ahead of the rest. This end is closed then, with no event, and answers nothing.
117
+ */
118
+ detach(): MessagePort {
119
+ const port = this._port;
120
+ if (!port || this._accepted || this._state !== OPEN) {
121
+ throw new DOMException(
122
+ "Only an open end that was not accepted can be detached.",
123
+ "InvalidStateError",
124
+ );
125
+ }
126
+ this._detached = true;
127
+ this._port = undefined;
128
+ port.onmessage = null;
129
+ // Acks the peer still owes this end (it sent before being detached) are not the new
130
+ // holder's to count: they are absorbed on the way.
131
+ let owed = this.bufferedAmount;
132
+ this._state = CLOSED;
133
+ this._onClosed();
134
+ this._wakeDrainWaiters();
135
+ const queued = this._queue.splice(0);
136
+ if (queued.length === 0 && owed === 0) return port;
137
+ // The queued frames first, then the rest, both ways, through a channel of its own: frames
138
+ // pass as they are (kinds this version does not know included), and the relay ends once
139
+ // each way carried a close frame, or either port closes.
140
+ const relay = new MessageChannel();
141
+ const holder = relay.port2;
142
+ const closing = new Set<"in" | "out">();
143
+ const end = () => {
144
+ port.onmessage = null;
145
+ holder.onmessage = null;
146
+ port.close();
147
+ holder.close();
148
+ };
149
+ const sawClose = (way: "in" | "out") => {
150
+ closing.add(way);
151
+ if (closing.size === 2) end();
152
+ };
153
+ for (const frame of queued) {
154
+ postFrame(holder, frame);
155
+ if (frame.t === "c") closing.add("in");
156
+ }
157
+ holder.onmessage = (event) => {
158
+ port.postMessage(event.data, transferables(event.data));
159
+ if ((event.data as { t?: unknown } | null)?.t === "c") sawClose("out");
160
+ };
161
+ port.onmessage = (event) => {
162
+ const data = event.data as { t?: unknown; n?: unknown } | null;
163
+ if (data?.t === "a" && typeof data.n === "number" && owed > 0) {
164
+ const absorbed = Math.min(owed, data.n);
165
+ owed -= absorbed;
166
+ if (data.n > absorbed) holder.postMessage({ t: "a", n: data.n - absorbed });
167
+ return;
168
+ }
169
+ holder.postMessage(event.data, transferables(event.data));
170
+ if (data?.t === "c") sawClose("in");
171
+ };
172
+ // A side that dies without a close frame: where the platform tells (MessagePort's "close").
173
+ port.addEventListener?.("close", end);
174
+ holder.addEventListener?.("close", end);
175
+ return relay.port1;
176
+ }
177
+
64
178
  /** Starts delivering events, the queued ones first. */
65
179
  accept(): void {
180
+ if (this._detached) {
181
+ throw new DOMException("This end was detached; it cannot be accepted.", "InvalidStateError");
182
+ }
66
183
  if (this._accepted) return;
67
184
  this._accepted = true;
68
- for (const frame of this._queue.splice(0)) super._deliver(frame);
185
+ for (const frame of this._queue.splice(0)) this._deliver(frame);
69
186
  }
70
187
 
71
188
  send(data: string | ArrayBuffer | ArrayBufferView): void {
72
- if (!this._accepted) {
189
+ if (this._detached) {
190
+ throw new DOMException("This end was detached; it cannot send.", "InvalidStateError");
191
+ }
192
+ if (!this._accepted && !this._options.sendBeforeAccept) {
73
193
  throw new DOMException("accept() the socket before sending on it.", "InvalidStateError");
74
194
  }
75
195
  this._sendData(isBinary(data) ? toArrayBuffer(data) : String(data));
76
196
  }
77
197
 
78
- close(code = 1000, reason = ""): void {
79
- if (!isSendableCloseCode(code)) {
198
+ close(code?: number, reason = ""): void {
199
+ this.checkClose(code, reason);
200
+ this._startClose(code ?? this._options.defaultCloseCode ?? 1000, reason);
201
+ }
202
+
203
+ /**
204
+ * Refuses a `close()` the endpoint may not make: by default a code outside RFC 6455 §7.4's
205
+ * sendable ones, or a reason over 123 bytes. A subclass with another platform's rules
206
+ * overrides it.
207
+ */
208
+ protected checkClose(code: number | undefined, reason: string): void {
209
+ if (code !== undefined && !isSendableCloseCode(code)) {
80
210
  throw new DOMException(
81
211
  `${code} is not a close code an endpoint may send.`,
82
212
  "InvalidAccessError",
@@ -85,7 +215,6 @@ export class ServerWebSocket extends PortSocketCore {
85
215
  if (utf8Length(reason) > 123) {
86
216
  throw new DOMException("The close reason is longer than 123 bytes.", "SyntaxError");
87
217
  }
88
- this._startClose(code, reason);
89
218
  }
90
219
 
91
220
  protected override _deliver(frame: Extract<WsFrame, { t: "m" | "c" }>): void {
@@ -93,17 +222,37 @@ export class ServerWebSocket extends PortSocketCore {
93
222
  this._queue.push(frame);
94
223
  return;
95
224
  }
225
+ if (frame.t === "m" && this._state === CLOSING && this._options.deliverWhileClosing) {
226
+ this._dispatchMessage(frame.d);
227
+ this._consumed(messageSize(frame.d));
228
+ return;
229
+ }
96
230
  super._deliver(frame);
97
231
  }
98
232
 
99
233
  protected _dispatchMessage(data: string | ArrayBuffer): void {
100
- this.dispatchEvent(new MessageEvent("message", { data }));
234
+ const delivered =
235
+ typeof data !== "string" && this._binaryType === "blob" ? new Blob([data]) : data;
236
+ this.dispatchEvent(new MessageEvent("message", { data: delivered }));
101
237
  }
102
238
 
103
239
  protected override _onPing(hb: number): void {
104
240
  this._post({ t: "q" });
105
241
  this._lastPing = Date.now();
106
- if (hb !== this._heartbeatMs) {
242
+ if (this._state === CLOSED) return; // defensive: a closed socket's port receives nothing
243
+ const keep = this._options.heartbeatPeriod === "first" && this._pinged;
244
+ // A period no timer takes (2^31 - 1 ms and over: a client that turned its heartbeat off,
245
+ // as cellb's does with `heartbeatMs: 0`) is no heartbeat at all: the watch stops, unless the
246
+ // first period is the one kept.
247
+ if (!(hb > 0 && hb < 2 ** 31 - 1)) {
248
+ if (!keep && this._watch !== undefined) {
249
+ this._timers.clearInterval(this._watch);
250
+ this._watch = undefined;
251
+ }
252
+ return;
253
+ }
254
+ this._pinged = true;
255
+ if (!keep && (this._watch === undefined || hb !== this._heartbeatMs)) {
107
256
  this._heartbeatMs = hb;
108
257
  this._watchHeartbeat();
109
258
  }
@@ -130,3 +279,9 @@ function isSendableCloseCode(code: number): boolean {
130
279
  if (code >= 3000 && code <= 4999) return true;
131
280
  return code >= 1000 && code <= 1014 && code !== 1004 && code !== 1005 && code !== 1006;
132
281
  }
282
+
283
+ /** The buffers a relayed frame carries, to transfer rather than copy. */
284
+ function transferables(data: unknown): Transferable[] {
285
+ const d = (data as { d?: unknown } | null)?.d;
286
+ return d instanceof ArrayBuffer ? [d] : [];
287
+ }
@@ -43,7 +43,10 @@ export function newCloseEvent(init: WsCloseEventInit): Event {
43
43
 
44
44
  export interface PortSocketOptions {
45
45
  timers?: WsTimers;
46
- /** How long to wait for the echo of a close frame before giving up with 1006. */
46
+ /**
47
+ * How long to wait for the echo of a close frame before giving up with 1006. `Infinity` waits
48
+ * for as long as it takes.
49
+ */
47
50
  closeTimeoutMs?: number;
48
51
  }
49
52
 
@@ -182,6 +185,8 @@ export abstract class PortSocketCore extends EventTarget {
182
185
  if (this._state === CLOSING || this._state === CLOSED) return;
183
186
  this._state = CLOSING;
184
187
  this._post({ t: "c", code, reason });
188
+ // A timer cannot take Infinity (it would fire at once): no timer is waiting forever.
189
+ if (!Number.isFinite(this._closeTimeoutMs)) return;
185
190
  this._closeTimer = this._timers.setTimeout(
186
191
  () => this._finish(1006, "", false),
187
192
  this._closeTimeoutMs,
@@ -218,7 +223,7 @@ export abstract class PortSocketCore extends EventTarget {
218
223
  this.dispatchEvent(newCloseEvent({ code, reason, wasClean }));
219
224
  }
220
225
 
221
- private _wakeDrainWaiters(): void {
226
+ protected _wakeDrainWaiters(): void {
222
227
  if (this._drainWaiters.length === 0) return;
223
228
  const closed = this._state === CLOSED;
224
229
  this._drainWaiters = this._drainWaiters.filter(({ limit, resolve }) => {
package/src/ws/upgrade.ts CHANGED
@@ -23,7 +23,11 @@ export const WS_VERSION_HEADER = "x-webrun-ws-version";
23
23
  export interface WebSocketUpgradeInit {
24
24
  /** Requested subprotocols, in order of preference. */
25
25
  protocols?: string[];
26
- /** More headers for the handler to see (cookies, origin, authorization...). */
26
+ /**
27
+ * More headers for the handler to see (what a `route` function picks by, origin...). They are
28
+ * the caller's word: nothing checks them, so a handler trusts a `cookie` or an `authorization`
29
+ * here no more than its caller. `sec-websocket-protocol` comes from `protocols` only.
30
+ */
27
31
  headers?: HeadersInit;
28
32
  }
29
33
 
@@ -43,6 +47,9 @@ export function newWebSocketUpgradeRequest(
43
47
  all.set("connection", "Upgrade");
44
48
  all.set("sec-websocket-version", "13");
45
49
  if (!all.has("sec-websocket-key")) all.set("sec-websocket-key", randomKey());
50
+ // `protocols` alone says which subprotocols are asked for: a header of the caller's could
51
+ // name one the handler then picks, and the upgrade refuses as "not requested".
52
+ all.delete("sec-websocket-protocol");
46
53
  if (protocols.length > 0) all.set("sec-websocket-protocol", protocols.join(", "));
47
54
  if (!all.has(WS_VERSION_HEADER)) all.set(WS_VERSION_HEADER, `${WS_PORT_PROTOCOL_VERSION}`);
48
55
  return withHeaders(new Request(httpUrl, { method: "GET", headers: all }), all);
@@ -117,6 +124,18 @@ export function upgradeWebSocket(
117
124
  const protocol = options.protocol ?? "";
118
125
  const channel = new MessageChannel();
119
126
  const socket = new ServerWebSocket(channel.port1, { ...options, url: request.url, protocol });
127
+ const response = webSocketUpgradeResponse(channel.port2, protocol);
128
+ Object.defineProperty(response, "webSocket", { value: socket, configurable: true });
129
+ return { socket, response };
130
+ }
131
+
132
+ /**
133
+ * A 101 for a socket whose server end the handler runs itself, on the other end of `port`
134
+ * (a server of its own over this package's frames, say): what `upgradeWebSocket` returns, minus
135
+ * the `ServerWebSocket`. The host hands `port` to the caller as it would for `upgradeWebSocket`
136
+ * (`takePort`), and `protocol` (`""` for none) is the subprotocol picked.
137
+ */
138
+ export function webSocketUpgradeResponse(port: MessagePort, protocol = ""): Response {
120
139
  const response = new Response(null, {
121
140
  status: 200,
122
141
  headers: protocol ? { "sec-websocket-protocol": protocol } : {},
@@ -124,10 +143,9 @@ export function upgradeWebSocket(
124
143
  Object.defineProperties(response, {
125
144
  status: { value: 101, configurable: true },
126
145
  statusText: { value: "Switching Protocols", configurable: true },
127
- webSocket: { value: socket, configurable: true },
128
- [UPGRADE]: { value: { port: channel.port2, protocol }, configurable: true, writable: true },
146
+ [UPGRADE]: { value: { port, protocol }, configurable: true, writable: true },
129
147
  });
130
- return { socket, response };
148
+ return response;
131
149
  }
132
150
 
133
151
  /**