@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.
- package/README.md +155 -10
- package/dist/core/index.d.ts +1 -0
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/service-worker-control.d.ts +2 -1
- package/dist/core/service-worker-control.d.ts.map +1 -1
- package/dist/core/sw-scope.d.ts +31 -0
- package/dist/core/sw-scope.d.ts.map +1 -0
- package/dist/index.js +908 -221
- package/dist/link/http-link.d.ts +16 -3
- package/dist/link/http-link.d.ts.map +1 -1
- package/dist/relay/index-sw.d.ts +8 -2
- package/dist/relay/index-sw.d.ts.map +1 -1
- package/dist/relay/index.d.ts +1 -1
- package/dist/relay/index.d.ts.map +1 -1
- package/dist/relay-sw.js +470 -81
- package/dist/relay-worker.d.ts +1 -0
- package/dist/relay-worker.d.ts.map +1 -1
- package/dist/relay-worker.js +470 -81
- package/dist/sw/handler-endpoint.d.ts +5 -2
- package/dist/sw/handler-endpoint.d.ts.map +1 -1
- package/dist/sw/http-sw-dispatcher.d.ts +114 -7
- package/dist/sw/http-sw-dispatcher.d.ts.map +1 -1
- package/dist/sw/index.d.ts +1 -0
- package/dist/sw/index.d.ts.map +1 -1
- package/dist/sw/protocol.d.ts +3 -0
- package/dist/sw/protocol.d.ts.map +1 -1
- package/dist/sw/sw-dispatcher.d.ts +5 -2
- package/dist/sw/sw-dispatcher.d.ts.map +1 -1
- package/dist/sw/sw-http-adapter.d.ts +19 -4
- package/dist/sw/sw-http-adapter.d.ts.map +1 -1
- package/dist/sw/worker-service.d.ts +5 -2
- package/dist/sw/worker-service.d.ts.map +1 -1
- package/dist/sw-worker.js +681 -141
- package/dist/sw.js +896 -218
- package/dist/worker.js +689 -115
- package/dist/ws/server.d.ts +50 -2
- package/dist/ws/server.d.ts.map +1 -1
- package/dist/ws/socket-core.d.ts +5 -2
- package/dist/ws/socket-core.d.ts.map +1 -1
- package/dist/ws/upgrade.d.ts +12 -1
- package/dist/ws/upgrade.d.ts.map +1 -1
- package/package.json +4 -3
- package/src/core/index.ts +1 -0
- package/src/core/service-worker-control.ts +5 -3
- package/src/core/sw-scope.ts +27 -0
- package/src/link/http-link.ts +20 -4
- package/src/relay/index-sw.ts +15 -2
- package/src/relay/index.ts +2 -2
- package/src/relay-worker.ts +1 -0
- package/src/sw/handler-endpoint.ts +17 -4
- package/src/sw/http-sw-dispatcher.ts +267 -67
- package/src/sw/index.ts +5 -0
- package/src/sw/protocol.ts +11 -1
- package/src/sw/sw-dispatcher.ts +17 -11
- package/src/sw/sw-http-adapter.ts +36 -10
- package/src/sw/worker-service.ts +11 -4
- package/src/ws/server.ts +166 -11
- package/src/ws/socket-core.ts +7 -2
- 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
|
-
|
|
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 =
|
|
187
|
-
|
|
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.
|
|
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
|
|
package/src/sw/worker-service.ts
CHANGED
|
@@ -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
|
-
/**
|
|
34
|
-
|
|
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 {
|
|
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))
|
|
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 (
|
|
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
|
|
79
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
+
}
|
package/src/ws/socket-core.ts
CHANGED
|
@@ -43,7 +43,10 @@ export function newCloseEvent(init: WsCloseEventInit): Event {
|
|
|
43
43
|
|
|
44
44
|
export interface PortSocketOptions {
|
|
45
45
|
timers?: WsTimers;
|
|
46
|
-
/**
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
128
|
-
[UPGRADE]: { value: { port: channel.port2, protocol }, configurable: true, writable: true },
|
|
146
|
+
[UPGRADE]: { value: { port, protocol }, configurable: true, writable: true },
|
|
129
147
|
});
|
|
130
|
-
return
|
|
148
|
+
return response;
|
|
131
149
|
}
|
|
132
150
|
|
|
133
151
|
/**
|