@statewalker/webrun-http-browser 0.6.4 → 0.7.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.
Files changed (93) hide show
  1. package/README.md +319 -65
  2. package/dist/core/data-calls.d.ts +17 -0
  3. package/dist/core/data-calls.d.ts.map +1 -1
  4. package/dist/core/service-worker-control.d.ts +3 -1
  5. package/dist/core/service-worker-control.d.ts.map +1 -1
  6. package/dist/index.d.ts +3 -0
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +3907 -266
  9. package/dist/link/errors.d.ts +2 -0
  10. package/dist/link/errors.d.ts.map +1 -0
  11. package/dist/link/http-link.d.ts +58 -0
  12. package/dist/link/http-link.d.ts.map +1 -0
  13. package/dist/link/index.d.ts +4 -0
  14. package/dist/link/index.d.ts.map +1 -0
  15. package/dist/link/locks.d.ts +39 -0
  16. package/dist/link/locks.d.ts.map +1 -0
  17. package/dist/link/rendezvous.d.ts +21 -0
  18. package/dist/link/rendezvous.d.ts.map +1 -0
  19. package/dist/relay/index-sw.d.ts +12 -2
  20. package/dist/relay/index-sw.d.ts.map +1 -1
  21. package/dist/relay/index.d.ts +66 -8
  22. package/dist/relay/index.d.ts.map +1 -1
  23. package/dist/relay-sw.js +1160 -55
  24. package/dist/relay-worker.js +1161 -55
  25. package/dist/sw/handler-endpoint.d.ts +81 -0
  26. package/dist/sw/handler-endpoint.d.ts.map +1 -0
  27. package/dist/sw/handler-table.d.ts +53 -0
  28. package/dist/sw/handler-table.d.ts.map +1 -0
  29. package/dist/sw/http-sw-dispatcher.d.ts +162 -21
  30. package/dist/sw/http-sw-dispatcher.d.ts.map +1 -1
  31. package/dist/sw/index.d.ts +5 -0
  32. package/dist/sw/index.d.ts.map +1 -1
  33. package/dist/sw/protocol.d.ts +115 -0
  34. package/dist/sw/protocol.d.ts.map +1 -0
  35. package/dist/sw/sw-dispatcher.d.ts +2 -1
  36. package/dist/sw/sw-dispatcher.d.ts.map +1 -1
  37. package/dist/sw/sw-http-adapter.d.ts +199 -0
  38. package/dist/sw/sw-http-adapter.d.ts.map +1 -0
  39. package/dist/sw/sw-websocket.d.ts +16 -0
  40. package/dist/sw/sw-websocket.d.ts.map +1 -0
  41. package/dist/sw/worker-service.d.ts +45 -0
  42. package/dist/sw/worker-service.d.ts.map +1 -0
  43. package/dist/sw/worker-url.d.ts +9 -0
  44. package/dist/sw/worker-url.d.ts.map +1 -0
  45. package/dist/sw-worker.js +2075 -210
  46. package/dist/sw.d.ts +2 -0
  47. package/dist/sw.d.ts.map +1 -1
  48. package/dist/sw.js +3853 -480
  49. package/dist/worker.d.ts +10 -0
  50. package/dist/worker.d.ts.map +1 -0
  51. package/dist/worker.js +3507 -0
  52. package/dist/ws/client.d.ts +73 -0
  53. package/dist/ws/client.d.ts.map +1 -0
  54. package/dist/ws/frames.d.ts +68 -0
  55. package/dist/ws/frames.d.ts.map +1 -0
  56. package/dist/ws/index.d.ts +6 -0
  57. package/dist/ws/index.d.ts.map +1 -0
  58. package/dist/ws/server.d.ts +53 -0
  59. package/dist/ws/server.d.ts.map +1 -0
  60. package/dist/ws/socket-core.d.ts +91 -0
  61. package/dist/ws/socket-core.d.ts.map +1 -0
  62. package/dist/ws/upgrade.d.ts +93 -0
  63. package/dist/ws/upgrade.d.ts.map +1 -0
  64. package/package.json +10 -5
  65. package/src/core/data-calls.ts +52 -2
  66. package/src/core/service-worker-control.ts +7 -1
  67. package/src/index.ts +3 -0
  68. package/src/link/errors.ts +3 -0
  69. package/src/link/http-link.ts +222 -0
  70. package/src/link/index.ts +3 -0
  71. package/src/link/locks.ts +97 -0
  72. package/src/link/rendezvous.ts +60 -0
  73. package/src/relay/index-sw.ts +84 -12
  74. package/src/relay/index.ts +205 -29
  75. package/src/sw/handler-endpoint.ts +278 -0
  76. package/src/sw/handler-table.ts +130 -0
  77. package/src/sw/http-sw-dispatcher.ts +649 -90
  78. package/src/sw/index.ts +5 -0
  79. package/src/sw/protocol.ts +103 -0
  80. package/src/sw/sw-dispatcher.ts +12 -30
  81. package/src/sw/sw-http-adapter.ts +613 -0
  82. package/src/sw/sw-websocket.ts +43 -0
  83. package/src/sw/worker-service.ts +133 -0
  84. package/src/sw/worker-url.ts +19 -0
  85. package/src/sw-worker.ts +8 -3
  86. package/src/sw.ts +2 -0
  87. package/src/worker.ts +9 -0
  88. package/src/ws/client.ts +346 -0
  89. package/src/ws/frames.ts +119 -0
  90. package/src/ws/index.ts +11 -0
  91. package/src/ws/server.ts +132 -0
  92. package/src/ws/socket-core.ts +251 -0
  93. package/src/ws/upgrade.ts +200 -0
@@ -0,0 +1,133 @@
1
+ import { holdLock, locksAvailable, newLockName } from "../link/locks.js";
2
+ import {
3
+ createWebSocketClass,
4
+ type PortWebSocketClass,
5
+ type WebSocketClassOptions,
6
+ type WebSocketConnection,
7
+ } from "../ws/client.js";
8
+ import {
9
+ type FetchHandler,
10
+ HandlerEndpoint,
11
+ type HandlerEndpointOptions,
12
+ } from "./handler-endpoint.js";
13
+ import { WORKER_HELLO, type WorkerControlMessage } from "./protocol.js";
14
+
15
+ export interface ServeHttpInWorkerOptions
16
+ extends Omit<HandlerEndpointOptions, "requestRelink" | "keepAlive"> {
17
+ /** Where the page's messages arrive. Default `globalThis` (the worker's scope). */
18
+ scope?: Pick<EventTarget, "addEventListener" | "removeEventListener">;
19
+ /** Options of the `WebSocket` class (`service.WebSocket`). */
20
+ webSocket?: WebSocketClassOptions;
21
+ }
22
+
23
+ /** What a worker serving HTTP can do besides serving. */
24
+ export interface WorkerHttpService {
25
+ /**
26
+ * Fetches through the ServiceWorker's router: a url under its scope reaches
27
+ * the handler it routes to (another worker, a page), any other the network.
28
+ * Needs no `fetch` event, so it works whether or not this worker is
29
+ * controlled; a call that is in flight when the ServiceWorker dies fails
30
+ * with `LinkLostError` rather than hanging, and the next waits for a link.
31
+ */
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>;
35
+ /** A WHATWG `WebSocket` class over `connectWebSocket`. */
36
+ readonly WebSocket: PortWebSocketClass;
37
+ /** Whether the worker has a live link. */
38
+ readonly linked: boolean;
39
+ /** Stops serving; requests in flight fail. */
40
+ close(): Promise<void>;
41
+ }
42
+
43
+ /**
44
+ * Serves `handler` from a Dedicated Worker, on the links the page attaches
45
+ * with `adapter.attachWorker(worker)`:
46
+ *
47
+ * ```ts
48
+ * // worker.ts
49
+ * const service = serveHttpInWorker(async (request) => new Response("hi"));
50
+ * ```
51
+ *
52
+ * The worker holds an owner lock while it lives, so the ServiceWorker notices
53
+ * at once when it is terminated. It watches the ServiceWorker's router lock,
54
+ * and asks its page to attach it again when its links died and a call waits.
55
+ * While calls through the router are in flight it asks its page every 20 s to
56
+ * keep the ServiceWorker awake — only a message to the ServiceWorker object
57
+ * counts as an event, and a worker cannot post one.
58
+ */
59
+ export function serveHttpInWorker(
60
+ handler: FetchHandler,
61
+ options: ServeHttpInWorkerOptions = {},
62
+ ): WorkerHttpService {
63
+ const scope = options.scope ?? (globalThis as unknown as EventTarget);
64
+ const ownerLock = newLockName("webrun-http-owner");
65
+ const held = holdLock(ownerLock);
66
+ const controls = new Set<MessagePort>();
67
+ const tellPages = (message: WorkerControlMessage) => {
68
+ for (const control of controls) control.postMessage(message);
69
+ };
70
+ const endpoint = new HandlerEndpoint(handler, {
71
+ ...options,
72
+ requestRelink: () => tellPages({ type: "relink" }),
73
+ keepAlive: () => tellPages({ type: "keepalive" }),
74
+ });
75
+
76
+ const onMessage = (event: Event) => {
77
+ const { data, ports } = event as MessageEvent;
78
+ if ((data as { type?: unknown } | null)?.type !== WORKER_HELLO) return;
79
+ const control = ports?.[0];
80
+ if (!control) return;
81
+ controls.add(control);
82
+ control.onmessage = (message) => {
83
+ const msg = message.data as WorkerControlMessage;
84
+ if (msg?.type === "link") {
85
+ const [down, ctl, up] = message.ports;
86
+ if (down) void endpoint.link(msg.key, { down, ctl, up }, msg.routerLock);
87
+ } else if (msg?.type === "unlink") {
88
+ void endpoint.unlink(msg.key);
89
+ }
90
+ };
91
+ void held.then(() =>
92
+ control.postMessage({
93
+ type: "ready",
94
+ ownerLock: locksAvailable() ? ownerLock : undefined,
95
+ } satisfies WorkerControlMessage),
96
+ );
97
+ };
98
+ scope.addEventListener("message", onMessage);
99
+
100
+ const baseUrl = () => (globalThis as { location?: { href: string } }).location?.href;
101
+ const connectWebSocket = (url: string | URL, protocols: string[] = []) => {
102
+ const httpUrl = new URL(`${url}`, baseUrl());
103
+ if (httpUrl.protocol === "ws:") httpUrl.protocol = "http:";
104
+ else if (httpUrl.protocol === "wss:") httpUrl.protocol = "https:";
105
+ return endpoint.connectWebSocket(`${httpUrl}`, protocols);
106
+ };
107
+ let WebSocketClass: PortWebSocketClass | undefined;
108
+
109
+ return {
110
+ fetch(input, init) {
111
+ const request =
112
+ input instanceof Request && init === undefined
113
+ ? input
114
+ : new Request(input instanceof Request ? input : new URL(`${input}`, baseUrl()), init);
115
+ return endpoint.fetch(request);
116
+ },
117
+ connectWebSocket,
118
+ get WebSocket() {
119
+ WebSocketClass ??= createWebSocketClass(connectWebSocket, options.webSocket);
120
+ return WebSocketClass;
121
+ },
122
+ get linked() {
123
+ return endpoint.linked;
124
+ },
125
+ async close() {
126
+ scope.removeEventListener("message", onMessage);
127
+ await endpoint.close();
128
+ for (const control of controls) control.close();
129
+ controls.clear();
130
+ (await held)();
131
+ },
132
+ };
133
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Resolve a `serviceWorkerUrl` option the way a page would: relative to the
3
+ * current document. Absolute urls pass through untouched. A url that cannot
4
+ * be resolved is reported with the option name and the offending value,
5
+ * because the raw `TypeError: Failed to construct 'URL': Invalid URL` says
6
+ * neither.
7
+ */
8
+ export function resolveWorkerUrl(serviceWorkerUrl: string): URL {
9
+ const base = globalThis.location?.href;
10
+ try {
11
+ return new URL(serviceWorkerUrl, base);
12
+ } catch (error) {
13
+ throw new Error(
14
+ `Invalid serviceWorkerUrl: ${JSON.stringify(serviceWorkerUrl)}` +
15
+ (base ? ` (relative to ${base})` : " (no document to resolve it against)"),
16
+ { cause: error },
17
+ );
18
+ }
19
+ }
package/src/sw-worker.ts CHANGED
@@ -1,7 +1,12 @@
1
1
  /// <reference lib="webworker" />
2
2
 
3
- import { startHttpDispatcher } from "./sw/http-sw-dispatcher.js";
3
+ import { type SwHttpDispatcherOptions, startHttpDispatcher } from "./sw/http-sw-dispatcher.js";
4
4
 
5
- declare const self: ServiceWorkerGlobalScope;
5
+ declare const self: ServiceWorkerGlobalScope & {
6
+ HTTP_DISPATCHER_OPTIONS?: Omit<SwHttpDispatcherOptions, "self">;
7
+ };
6
8
 
7
- startHttpDispatcher({ self, log: console.log });
9
+ // OPTIONS FROM A GLOBAL, as `relay-sw.js` takes `RELAY_OPTIONS`: this bundle is
10
+ // loaded by `importScripts` from a host's own worker script, which runs first
11
+ // and can set them (a `route` function, timeouts). Absent, the defaults.
12
+ startHttpDispatcher({ log: console.log, ...self.HTTP_DISPATCHER_OPTIONS, self });
package/src/sw.ts CHANGED
@@ -1 +1,3 @@
1
+ export * from "./link/index.js";
1
2
  export * from "./sw/index.js";
3
+ export * from "./ws/index.js";
package/src/worker.ts ADDED
@@ -0,0 +1,9 @@
1
+ /**
2
+ * For Dedicated Workers that serve HTTP and WebSockets through the
3
+ * same-origin ServiceWorker: `serveHttpInWorker`, and the WebSocket helpers a
4
+ * handler needs (`upgradeWebSocket`, `isWebSocketUpgrade`...).
5
+ */
6
+ export * from "./link/http-link.js";
7
+ export type { FetchHandler, HandlerEndpointOptions } from "./sw/handler-endpoint.js";
8
+ export * from "./sw/worker-service.js";
9
+ export * from "./ws/index.js";
@@ -0,0 +1,346 @@
1
+ import { DEFAULT_HEARTBEAT_MS, utf8Length, type WsTimers } from "./frames.js";
2
+ import {
3
+ CLOSED,
4
+ CLOSING,
5
+ CONNECTING,
6
+ isApplicationCloseCode,
7
+ isBinary,
8
+ isBlob,
9
+ OPEN,
10
+ PortSocketCore,
11
+ toArrayBuffer,
12
+ } from "./socket-core.js";
13
+
14
+ /** What `connect` gives a socket once the server accepted it (the 101). */
15
+ export interface WebSocketConnection {
16
+ port: MessagePort;
17
+ /** The subprotocol the server picked, `""` for none. */
18
+ protocol: string;
19
+ }
20
+
21
+ /**
22
+ * Opens the transport of one socket: resolves once the server accepted the
23
+ * upgrade, rejects when it refused or could not be reached. Where the port
24
+ * comes from — a ServiceWorker, a relay, a handler in the same page — is the
25
+ * caller's business.
26
+ */
27
+ export type WebSocketConnector = (url: string, protocols: string[]) => Promise<WebSocketConnection>;
28
+
29
+ export interface WebSocketClassOptions {
30
+ /** Ping period, in ms. Pongs missing for twice that close the socket with 1006. Default 10 s. */
31
+ heartbeatMs?: number;
32
+ /** Timer functions; see `WsTimers`. */
33
+ timers?: WsTimers;
34
+ /** How long `close()` waits for the server's echo, in ms. Default 5 s. */
35
+ closeTimeoutMs?: number;
36
+ }
37
+
38
+ type Listener = ((event: Event) => void) | null;
39
+
40
+ /** The instance side of the class `createWebSocketClass` returns: the WHATWG `WebSocket` surface. */
41
+ export interface PortWebSocket extends EventTarget {
42
+ readonly url: string;
43
+ readonly protocol: string;
44
+ readonly extensions: string;
45
+ readonly readyState: number;
46
+ readonly bufferedAmount: number;
47
+ binaryType: BinaryType;
48
+ onopen: Listener;
49
+ onmessage: ((event: MessageEvent) => void) | null;
50
+ onerror: Listener;
51
+ onclose: ((event: CloseEvent) => void) | null;
52
+ send(data: string | ArrayBufferLike | ArrayBufferView | Blob): void;
53
+ close(code?: number, reason?: string): void;
54
+ /** Not in the standard: see `PortSocketCore.whenDrained`. */
55
+ whenDrained(limit?: number): Promise<void>;
56
+ readonly CONNECTING: 0;
57
+ readonly OPEN: 1;
58
+ readonly CLOSING: 2;
59
+ readonly CLOSED: 3;
60
+ }
61
+
62
+ export interface PortWebSocketClass {
63
+ new (url: string | URL, protocols?: string | string[]): PortWebSocket;
64
+ readonly CONNECTING: 0;
65
+ readonly OPEN: 1;
66
+ readonly CLOSING: 2;
67
+ readonly CLOSED: 3;
68
+ }
69
+
70
+ /** RFC 7230 `token`: what a subprotocol name may be. */
71
+ const TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
72
+
73
+ /**
74
+ * A WHATWG-compatible `WebSocket` class whose transport is a `MessagePort`
75
+ * from `connect`. Code written against the browser's `WebSocket` runs on it
76
+ * unchanged:
77
+ *
78
+ * ```ts
79
+ * const WS = createWebSocketClass(adapter.connectWebSocket);
80
+ * const ws = new WS(`${baseUrl}chat`, ["v1"]);
81
+ * ws.onmessage = (event) => console.log(event.data);
82
+ * ```
83
+ *
84
+ * It validates as the standard does (url, scheme, fragment, subprotocol
85
+ * tokens and duplicates, close codes and reasons), has `binaryType`, the `on*`
86
+ * handlers next to `addEventListener`, and the close handshake.
87
+ * `bufferedAmount` is real: the server acknowledges what it consumed.
88
+ *
89
+ * The real `globalThis.WebSocket` is left alone; install the class where you
90
+ * want it.
91
+ */
92
+ export function createWebSocketClass(
93
+ connect: WebSocketConnector,
94
+ options: WebSocketClassOptions = {},
95
+ ): PortWebSocketClass {
96
+ const heartbeatMs = options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS;
97
+
98
+ class WebSocketOverPort extends PortSocketCore implements PortWebSocket {
99
+ static readonly CONNECTING = CONNECTING as 0;
100
+ static readonly OPEN = OPEN as 1;
101
+ static readonly CLOSING = CLOSING as 2;
102
+ static readonly CLOSED = CLOSED as 3;
103
+ readonly CONNECTING = CONNECTING as 0;
104
+ readonly OPEN = OPEN as 1;
105
+ readonly CLOSING = CLOSING as 2;
106
+ readonly CLOSED = CLOSED as 3;
107
+
108
+ readonly url: string;
109
+ readonly extensions = "";
110
+ private _protocol = "";
111
+ private _binaryType: BinaryType = "blob";
112
+ private _handlers: Record<string, Listener> = {};
113
+ private _heartbeat: unknown;
114
+ /** When the oldest ping still waiting for its pong was sent. */
115
+ private _unansweredSince?: number;
116
+ private _onVisibility?: () => void;
117
+ /** Sends waiting for a Blob ahead of them to be read, so order is kept. */
118
+ private _chain?: Promise<void>;
119
+
120
+ constructor(url: string | URL, protocols: string | string[] = []) {
121
+ super({ timers: options.timers, closeTimeoutMs: options.closeTimeoutMs });
122
+ this.url = parseUrl(url);
123
+ const list = typeof protocols === "string" ? [protocols] : [...protocols];
124
+ validateProtocols(list);
125
+ for (const type of ["open", "message", "error", "close"]) {
126
+ this.addEventListener(type, (event) => this._handlers[type]?.call(this, event));
127
+ }
128
+ // Asynchronously, as the platform does: listeners added right after the
129
+ // constructor must still see `open` or `error`.
130
+ Promise.resolve()
131
+ .then(() => connect(this.url, list))
132
+ .then(
133
+ (connection) => {
134
+ try {
135
+ this._opened(connection, list);
136
+ } catch {
137
+ this._failed();
138
+ }
139
+ },
140
+ () => this._failed(),
141
+ );
142
+ }
143
+
144
+ get protocol(): string {
145
+ return this._protocol;
146
+ }
147
+
148
+ get binaryType(): BinaryType {
149
+ return this._binaryType;
150
+ }
151
+ set binaryType(value: BinaryType) {
152
+ // The standard ignores values it does not know.
153
+ if (value === "blob" || value === "arraybuffer") this._binaryType = value;
154
+ }
155
+
156
+ get onopen(): Listener {
157
+ return this._handlers.open ?? null;
158
+ }
159
+ set onopen(fn: Listener) {
160
+ this._handlers.open = typeof fn === "function" ? fn : null;
161
+ }
162
+ get onmessage(): ((event: MessageEvent) => void) | null {
163
+ return (this._handlers.message as (event: MessageEvent) => void) ?? null;
164
+ }
165
+ set onmessage(fn: ((event: MessageEvent) => void) | null) {
166
+ this._handlers.message = typeof fn === "function" ? (fn as Listener) : null;
167
+ }
168
+ get onerror(): Listener {
169
+ return this._handlers.error ?? null;
170
+ }
171
+ set onerror(fn: Listener) {
172
+ this._handlers.error = typeof fn === "function" ? fn : null;
173
+ }
174
+ get onclose(): ((event: CloseEvent) => void) | null {
175
+ return (this._handlers.close as (event: CloseEvent) => void) ?? null;
176
+ }
177
+ set onclose(fn: ((event: CloseEvent) => void) | null) {
178
+ this._handlers.close = typeof fn === "function" ? (fn as Listener) : null;
179
+ }
180
+
181
+ send(data: string | ArrayBufferLike | ArrayBufferView | Blob): void {
182
+ if (this._state === CONNECTING) {
183
+ throw new DOMException("Still in CONNECTING state.", "InvalidStateError");
184
+ }
185
+ if (isBlob(data)) {
186
+ const size = data.size;
187
+ if (this._state !== OPEN) {
188
+ this._sendData(new ArrayBuffer(size));
189
+ return;
190
+ }
191
+ this._reserve(size);
192
+ this._enqueue(async () => this._sendData(await data.arrayBuffer(), true));
193
+ return;
194
+ }
195
+ const payload = isBinary(data) ? toArrayBuffer(data) : String(data);
196
+ if (this._chain) this._enqueue(async () => this._sendData(payload));
197
+ else this._sendData(payload);
198
+ }
199
+
200
+ close(code?: number, reason?: string): void {
201
+ if (code !== undefined && !isApplicationCloseCode(code)) {
202
+ throw new DOMException(
203
+ `The close code must be 1000 or in 3000-4999; ${code} is neither.`,
204
+ "InvalidAccessError",
205
+ );
206
+ }
207
+ const text = reason ?? "";
208
+ if (utf8Length(text) > 123) {
209
+ throw new DOMException("The close reason is longer than 123 bytes.", "SyntaxError");
210
+ }
211
+ if (this._state === CLOSING || this._state === CLOSED) return;
212
+ if (this._state === CONNECTING) {
213
+ // Fail the connection: `_opened` sees CLOSING and lets the port go.
214
+ this._state = CLOSING;
215
+ return;
216
+ }
217
+ this._startClose(code ?? 1005, text);
218
+ }
219
+
220
+ protected _dispatchMessage(data: string | ArrayBuffer): void {
221
+ const value =
222
+ typeof data === "string" || this._binaryType === "arraybuffer" ? data : new Blob([data]);
223
+ this.dispatchEvent(
224
+ new MessageEvent("message", { data: value, origin: new URL(this.url).origin }),
225
+ );
226
+ }
227
+
228
+ protected _onPong(): void {
229
+ this._unansweredSince = undefined;
230
+ }
231
+
232
+ protected _onClosed(): void {
233
+ if (this._heartbeat !== undefined) this._timers.clearInterval(this._heartbeat);
234
+ this._heartbeat = undefined;
235
+ if (this._onVisibility) {
236
+ globalThis.document?.removeEventListener("visibilitychange", this._onVisibility);
237
+ }
238
+ }
239
+
240
+ private _ping(hb = heartbeatMs): void {
241
+ this._unansweredSince ??= Date.now();
242
+ this._post({ t: "p", hb });
243
+ }
244
+
245
+ private _opened(connection: WebSocketConnection, requested: string[]): void {
246
+ const { port, protocol = "" } = (connection ?? {}) as Partial<WebSocketConnection>;
247
+ if (!port || typeof port.postMessage !== "function") {
248
+ this._failed();
249
+ return;
250
+ }
251
+ if (this._state !== CONNECTING) {
252
+ // Closed while connecting.
253
+ try {
254
+ port.postMessage({ t: "c", code: 1006, reason: "" });
255
+ port.close();
256
+ } catch {}
257
+ this._finish(1006, "", false, true);
258
+ return;
259
+ }
260
+ if (protocol !== "" && !requested.includes(protocol)) {
261
+ port.close();
262
+ this._failed();
263
+ return;
264
+ }
265
+ this._protocol = protocol;
266
+ this._attach(port);
267
+ this._state = OPEN;
268
+ this._ping();
269
+ this._heartbeat = this._timers.setInterval(() => {
270
+ // Only a ping that went unanswered counts. In a background tab this
271
+ // timer may run once a minute; time alone is no sign of a dead peer.
272
+ const since = this._unansweredSince;
273
+ if (since !== undefined && Date.now() - since > 2 * heartbeatMs) {
274
+ this._finish(1006, "", false, true);
275
+ return;
276
+ }
277
+ this._ping();
278
+ }, heartbeatMs);
279
+ // A hidden page's timers are throttled to once a minute (Chromium's
280
+ // intensive throttling): tell the server to expect pings that rarely,
281
+ // so it does not take a backgrounded tab for a dead one.
282
+ const doc = globalThis.document;
283
+ if (doc) {
284
+ this._onVisibility = () =>
285
+ this._ping(
286
+ doc.visibilityState === "hidden" ? Math.max(heartbeatMs, 70_000) : heartbeatMs,
287
+ );
288
+ doc.addEventListener("visibilitychange", this._onVisibility);
289
+ if (doc.visibilityState === "hidden") this._onVisibility();
290
+ }
291
+ this.dispatchEvent(new Event("open"));
292
+ }
293
+
294
+ private _failed(): void {
295
+ this._state = CLOSING;
296
+ this._finish(1006, "", false, true);
297
+ }
298
+
299
+ private _enqueue(task: () => Promise<void>): void {
300
+ const run = (this._chain ?? Promise.resolve()).then(task).catch(() => {});
301
+ this._chain = run;
302
+ void run.then(() => {
303
+ if (this._chain === run) this._chain = undefined;
304
+ });
305
+ }
306
+ }
307
+
308
+ return WebSocketOverPort;
309
+ }
310
+
311
+ function parseUrl(input: string | URL): string {
312
+ let url: URL;
313
+ try {
314
+ url = new URL(`${input}`, globalThis.location?.href);
315
+ } catch {
316
+ throw new DOMException(`The URL '${input}' is invalid.`, "SyntaxError");
317
+ }
318
+ if (url.protocol === "http:") url.protocol = "ws:";
319
+ else if (url.protocol === "https:") url.protocol = "wss:";
320
+ if (url.protocol !== "ws:" && url.protocol !== "wss:") {
321
+ throw new DOMException(
322
+ `The URL's scheme must be 'ws', 'wss', 'http' or 'https'; '${url.protocol.slice(0, -1)}' is not allowed.`,
323
+ "SyntaxError",
324
+ );
325
+ }
326
+ if (url.hash !== "" || `${input}`.includes("#")) {
327
+ throw new DOMException(
328
+ `The URL contains a fragment identifier ('${url.hash}'). Fragment identifiers are not allowed in WebSocket URLs.`,
329
+ "SyntaxError",
330
+ );
331
+ }
332
+ return url.href;
333
+ }
334
+
335
+ function validateProtocols(protocols: string[]): void {
336
+ const seen = new Set<string>();
337
+ for (const protocol of protocols) {
338
+ if (!TOKEN.test(protocol)) {
339
+ throw new DOMException(`The subprotocol '${protocol}' is invalid.`, "SyntaxError");
340
+ }
341
+ if (seen.has(protocol)) {
342
+ throw new DOMException(`The subprotocol '${protocol}' is duplicated.`, "SyntaxError");
343
+ }
344
+ seen.add(protocol);
345
+ }
346
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * The wire protocol of a WebSocket carried over a `MessagePort`.
3
+ *
4
+ * A `MessagePort` is a natural carrier for a WebSocket: messages arrive in
5
+ * order, and strings and transferable `ArrayBuffer`s cross it as they are. The
6
+ * protocol on top is these frames, the same in both directions:
7
+ *
8
+ * - `m` — one message. An `ArrayBuffer` is transferred, never copied.
9
+ * - `c` — close. The side that closes first sends it; the other answers with
10
+ * its own `c` (the echo), and both are then closed cleanly.
11
+ * - `p` — ping, client to server, carrying the client's heartbeat period.
12
+ * - `q` — pong, server to client.
13
+ * - `a` — ack: the receiver consumed `n` more bytes of messages. This is the
14
+ * flow control: what a side has sent and not yet seen acknowledged is its
15
+ * `bufferedAmount`, and the server end can wait for it to fall
16
+ * (`whenDrained`). Added in version 2; version 1 had no backpressure.
17
+ *
18
+ * HEARTBEAT. A `MessagePort` never reports that the other side died, so the
19
+ * client pings every `hb` ms and closes with 1006 when pongs stop for `2·hb`;
20
+ * the server closes with 1006 when pings stop for `3·hb`. That is how a socket
21
+ * notices a closed tab or a terminated worker.
22
+ *
23
+ * Unknown frames are ignored, so a later version can add frames a peer of
24
+ * this version skips.
25
+ */
26
+
27
+ /** The version of this frame protocol. Sent in the upgrade hand-shake. */
28
+ export const WS_PORT_PROTOCOL_VERSION = 2;
29
+
30
+ /** Default heartbeat period of the client, in ms. */
31
+ export const DEFAULT_HEARTBEAT_MS = 10_000;
32
+
33
+ /** How long a side waits for the echo of its close frame, in ms. */
34
+ export const DEFAULT_CLOSE_TIMEOUT_MS = 5_000;
35
+
36
+ export type WsFrame =
37
+ | { t: "m"; d: string | ArrayBuffer }
38
+ | { t: "c"; code: number; reason: string }
39
+ | { t: "p"; hb: number }
40
+ | { t: "q" }
41
+ | { t: "a"; n: number };
42
+
43
+ /**
44
+ * The timer functions a socket uses. On a page's main thread, background
45
+ * throttling delays timers by up to a minute, which would close healthy
46
+ * sockets; pass unthrottled ones (worker-timers, say) there.
47
+ */
48
+ export interface WsTimers {
49
+ setTimeout(fn: () => void, ms: number): unknown;
50
+ clearTimeout(id: unknown): void;
51
+ setInterval(fn: () => void, ms: number): unknown;
52
+ clearInterval(id: unknown): void;
53
+ }
54
+
55
+ export const defaultTimers: WsTimers = {
56
+ setTimeout: (fn, ms) => setTimeout(fn, ms),
57
+ clearTimeout: (id) => clearTimeout(id as ReturnType<typeof setTimeout>),
58
+ setInterval: (fn, ms) => setInterval(fn, ms),
59
+ clearInterval: (id) => clearInterval(id as ReturnType<typeof setInterval>),
60
+ };
61
+
62
+ /** Recovers a frame from a message's data, or `undefined` for anything else. */
63
+ export function readFrame(data: unknown): WsFrame | undefined {
64
+ if (typeof data !== "object" || data === null) return undefined;
65
+ const frame = data as { t?: unknown; d?: unknown; code?: unknown; reason?: unknown };
66
+ switch (frame.t) {
67
+ case "m":
68
+ return typeof frame.d === "string" || frame.d instanceof ArrayBuffer
69
+ ? (frame as WsFrame)
70
+ : undefined;
71
+ case "c":
72
+ return {
73
+ t: "c",
74
+ code: typeof frame.code === "number" ? frame.code : 1005,
75
+ reason: typeof frame.reason === "string" ? frame.reason : "",
76
+ };
77
+ case "p": {
78
+ const hb = (frame as { hb?: unknown }).hb;
79
+ return { t: "p", hb: typeof hb === "number" && hb > 0 ? hb : DEFAULT_HEARTBEAT_MS };
80
+ }
81
+ case "q":
82
+ return { t: "q" };
83
+ case "a": {
84
+ const n = (frame as { n?: unknown }).n;
85
+ return typeof n === "number" && n >= 0 ? { t: "a", n } : undefined;
86
+ }
87
+ default:
88
+ return undefined;
89
+ }
90
+ }
91
+
92
+ /** Posts `frame`, transferring a message's `ArrayBuffer`. */
93
+ export function postFrame(port: MessagePort, frame: WsFrame): void {
94
+ if (frame.t === "m" && frame.d instanceof ArrayBuffer) port.postMessage(frame, [frame.d]);
95
+ else port.postMessage(frame);
96
+ }
97
+
98
+ /** The size a message counts for in `bufferedAmount`: UTF-8 bytes, or the buffer's length. */
99
+ export function messageSize(data: string | ArrayBuffer): number {
100
+ return typeof data === "string" ? utf8Length(data) : data.byteLength;
101
+ }
102
+
103
+ /** The UTF-8 length of `text`, without encoding it. */
104
+ export function utf8Length(text: string): number {
105
+ let length = 0;
106
+ for (let i = 0; i < text.length; i++) {
107
+ const code = text.charCodeAt(i);
108
+ if (code < 0x80) length += 1;
109
+ else if (code < 0x800) length += 2;
110
+ else if (code >= 0xd800 && code <= 0xdbff && i + 1 < text.length) {
111
+ const next = text.charCodeAt(i + 1);
112
+ if (next >= 0xdc00 && next <= 0xdfff) {
113
+ length += 4;
114
+ i++;
115
+ } else length += 3;
116
+ } else length += 3;
117
+ }
118
+ return length;
119
+ }
@@ -0,0 +1,11 @@
1
+ export * from "./client.js";
2
+ export {
3
+ DEFAULT_CLOSE_TIMEOUT_MS,
4
+ DEFAULT_HEARTBEAT_MS,
5
+ WS_PORT_PROTOCOL_VERSION,
6
+ type WsFrame,
7
+ type WsTimers,
8
+ } from "./frames.js";
9
+ export * from "./server.js";
10
+ export { WsCloseEvent, type WsCloseEventInit } from "./socket-core.js";
11
+ export * from "./upgrade.js";