@statewalker/webrun-http-browser 0.6.5 → 0.7.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 +302 -52
- package/dist/core/data-calls.d.ts +17 -0
- package/dist/core/data-calls.d.ts.map +1 -1
- package/dist/core/service-worker-control.d.ts +3 -1
- package/dist/core/service-worker-control.d.ts.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4218 -301
- package/dist/link/async-dispose.d.ts +10 -0
- package/dist/link/async-dispose.d.ts.map +1 -0
- package/dist/link/errors.d.ts +2 -0
- package/dist/link/errors.d.ts.map +1 -0
- package/dist/link/http-link.d.ts +72 -0
- package/dist/link/http-link.d.ts.map +1 -0
- package/dist/link/index.d.ts +4 -0
- package/dist/link/index.d.ts.map +1 -0
- package/dist/link/locks.d.ts +39 -0
- package/dist/link/locks.d.ts.map +1 -0
- package/dist/link/rendezvous.d.ts +21 -0
- package/dist/link/rendezvous.d.ts.map +1 -0
- package/dist/relay/index-sw.d.ts +12 -2
- package/dist/relay/index-sw.d.ts.map +1 -1
- package/dist/relay/index.d.ts +66 -8
- package/dist/relay/index.d.ts.map +1 -1
- package/dist/relay-sw.js +1328 -64
- package/dist/relay-worker.js +1329 -64
- package/dist/sw/handler-endpoint.d.ts +81 -0
- package/dist/sw/handler-endpoint.d.ts.map +1 -0
- package/dist/sw/handler-table.d.ts +53 -0
- package/dist/sw/handler-table.d.ts.map +1 -0
- package/dist/sw/http-sw-dispatcher.d.ts +162 -21
- package/dist/sw/http-sw-dispatcher.d.ts.map +1 -1
- package/dist/sw/index.d.ts +5 -0
- package/dist/sw/index.d.ts.map +1 -1
- package/dist/sw/protocol.d.ts +115 -0
- package/dist/sw/protocol.d.ts.map +1 -0
- package/dist/sw/sw-dispatcher.d.ts.map +1 -1
- package/dist/sw/sw-http-adapter.d.ts +199 -0
- package/dist/sw/sw-http-adapter.d.ts.map +1 -0
- package/dist/sw/sw-websocket.d.ts +16 -0
- package/dist/sw/sw-websocket.d.ts.map +1 -0
- package/dist/sw/worker-service.d.ts +45 -0
- package/dist/sw/worker-service.d.ts.map +1 -0
- package/dist/sw/worker-url.d.ts +9 -0
- package/dist/sw/worker-url.d.ts.map +1 -0
- package/dist/sw-worker.js +2403 -265
- package/dist/sw.d.ts +2 -0
- package/dist/sw.d.ts.map +1 -1
- package/dist/sw.js +4189 -546
- package/dist/worker.d.ts +10 -0
- package/dist/worker.d.ts.map +1 -0
- package/dist/worker.js +3780 -0
- package/dist/ws/client.d.ts +73 -0
- package/dist/ws/client.d.ts.map +1 -0
- package/dist/ws/frames.d.ts +68 -0
- package/dist/ws/frames.d.ts.map +1 -0
- package/dist/ws/index.d.ts +6 -0
- package/dist/ws/index.d.ts.map +1 -0
- package/dist/ws/server.d.ts +53 -0
- package/dist/ws/server.d.ts.map +1 -0
- package/dist/ws/socket-core.d.ts +91 -0
- package/dist/ws/socket-core.d.ts.map +1 -0
- package/dist/ws/upgrade.d.ts +93 -0
- package/dist/ws/upgrade.d.ts.map +1 -0
- package/package.json +10 -5
- package/src/core/data-calls.ts +52 -2
- package/src/core/service-worker-control.ts +7 -1
- package/src/index.ts +3 -0
- package/src/link/async-dispose.ts +22 -0
- package/src/link/errors.ts +3 -0
- package/src/link/http-link.ts +238 -0
- package/src/link/index.ts +3 -0
- package/src/link/locks.ts +97 -0
- package/src/link/rendezvous.ts +60 -0
- package/src/relay/index-sw.ts +84 -12
- package/src/relay/index.ts +203 -27
- package/src/sw/handler-endpoint.ts +278 -0
- package/src/sw/handler-table.ts +130 -0
- package/src/sw/http-sw-dispatcher.ts +649 -90
- package/src/sw/index.ts +5 -0
- package/src/sw/protocol.ts +103 -0
- package/src/sw/sw-dispatcher.ts +1 -20
- package/src/sw/sw-http-adapter.ts +613 -0
- package/src/sw/sw-websocket.ts +43 -0
- package/src/sw/worker-service.ts +133 -0
- package/src/sw/worker-url.ts +19 -0
- package/src/sw-worker.ts +8 -3
- package/src/sw.ts +2 -0
- package/src/worker.ts +9 -0
- package/src/ws/client.ts +346 -0
- package/src/ws/frames.ts +119 -0
- package/src/ws/index.ts +11 -0
- package/src/ws/server.ts +132 -0
- package/src/ws/socket-core.ts +251 -0
- package/src/ws/upgrade.ts +200 -0
|
@@ -1,119 +1,678 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
/// <reference lib="webworker" />
|
|
2
|
+
|
|
3
|
+
import { get, set } from "idb-keyval";
|
|
4
|
+
import { callChannel, handleChannelCalls, withTransfer } from "../core/data-calls.js";
|
|
5
|
+
import { handleClaimRequests } from "../core/service-worker-control.js";
|
|
6
|
+
import { sendHttpRequest } from "../http/http-send-recieve.js";
|
|
7
|
+
import { errorMessage } from "../link/errors.js";
|
|
8
|
+
import {
|
|
9
|
+
connectHttpLink,
|
|
10
|
+
HTTP_LINK_PROTOCOL,
|
|
11
|
+
type HttpLink,
|
|
12
|
+
LinkLostError,
|
|
13
|
+
serveHttpLink,
|
|
14
|
+
trackedLink,
|
|
15
|
+
} from "../link/http-link.js";
|
|
16
|
+
import { heldLockNames, holdLock, locksAvailable, newLockName, watchLock } from "../link/locks.js";
|
|
17
|
+
import { PortRendezvous } from "../link/rendezvous.js";
|
|
18
|
+
import type { WebSocketConnection } from "../ws/client.js";
|
|
19
|
+
import { newWebSocketUpgradeRequest } from "../ws/upgrade.js";
|
|
3
20
|
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
type
|
|
8
|
-
} from "./
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
21
|
+
type HandlerInfo,
|
|
22
|
+
HandlerTable,
|
|
23
|
+
leastInflightRoute,
|
|
24
|
+
type RouteFunction,
|
|
25
|
+
} from "./handler-table.js";
|
|
26
|
+
import {
|
|
27
|
+
ATTACH_CALL,
|
|
28
|
+
type AttachParams,
|
|
29
|
+
type AttachReply,
|
|
30
|
+
type ControlMessage,
|
|
31
|
+
DETACH_CALL,
|
|
32
|
+
DRAIN_CALL,
|
|
33
|
+
type DrainParams,
|
|
34
|
+
KEEPALIVE_MESSAGE,
|
|
35
|
+
NEED_LINKS_CALL,
|
|
36
|
+
type NeedLinksParams,
|
|
37
|
+
WAKE_MESSAGE,
|
|
38
|
+
WS_CONNECT_CALL,
|
|
39
|
+
} from "./protocol.js";
|
|
40
|
+
|
|
41
|
+
export interface SwHttpDispatcherOptions {
|
|
42
|
+
self: ServiceWorkerGlobalScope;
|
|
43
|
+
log?: (...args: unknown[]) => void;
|
|
44
|
+
/**
|
|
45
|
+
* Picks the handler of a request among the live handlers of its key.
|
|
46
|
+
* Default `leastInflightRoute`: the fewest requests in flight, the oldest on
|
|
47
|
+
* a tie — with one tab per key, the first live handler, as before.
|
|
48
|
+
*/
|
|
49
|
+
route?: RouteFunction;
|
|
50
|
+
/**
|
|
51
|
+
* How long a request waits for a handler while its key is handed over (the
|
|
52
|
+
* handlers it has are all draining, or `route` picked none), in ms. Past
|
|
53
|
+
* it, 503. Default 15 s.
|
|
54
|
+
*/
|
|
55
|
+
attachTimeoutMs?: number;
|
|
56
|
+
/**
|
|
57
|
+
* How long a request for a key with no handler waits for the pages to
|
|
58
|
+
* attach their links again (after a ServiceWorker restart), in ms. Past it,
|
|
59
|
+
* 404 for a key this worker has served before. Default 3 s.
|
|
60
|
+
*/
|
|
61
|
+
relinkTimeoutMs?: number;
|
|
62
|
+
/** How long a replaced link may finish its requests, in ms. Default 5 s. */
|
|
63
|
+
handoverMs?: number;
|
|
64
|
+
/** How long a socket waits for its handler's answer, in ms. Default 15 s. */
|
|
65
|
+
wsConnectTimeoutMs?: number;
|
|
66
|
+
/** Per-stream inactivity timeout of the links (webrun-rpc's). Default none. */
|
|
67
|
+
streamTimeout?: number;
|
|
68
|
+
/**
|
|
69
|
+
* Period of the backup liveness check, in ms: handlers whose owner lock is
|
|
70
|
+
* no longer held are dropped. Default 5 s; 0 turns it off.
|
|
71
|
+
*/
|
|
72
|
+
livenessCheckMs?: number;
|
|
12
73
|
}
|
|
13
74
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
75
|
+
/** One attached handler link, as the ServiceWorker holds it. */
|
|
76
|
+
class HandlerEntry implements HandlerInfo {
|
|
77
|
+
draining = false;
|
|
78
|
+
readonly attachedAt = Date.now();
|
|
79
|
+
readonly sockets: PortRendezvous<WebSocketConnection>;
|
|
80
|
+
stopWatch: () => void = () => {};
|
|
81
|
+
closeUp?: () => Promise<void>;
|
|
82
|
+
|
|
83
|
+
constructor(
|
|
84
|
+
readonly key: string,
|
|
85
|
+
readonly id: string,
|
|
86
|
+
readonly owner: string,
|
|
87
|
+
readonly meta: unknown,
|
|
88
|
+
readonly link: HttpLink,
|
|
89
|
+
readonly ctl: MessagePort | undefined,
|
|
90
|
+
readonly clientId: string | undefined,
|
|
91
|
+
readonly ownerLock: string | undefined,
|
|
92
|
+
) {
|
|
93
|
+
this.sockets = new PortRendezvous(`s:${id}`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
get inflight(): number {
|
|
97
|
+
return this.link.inflight;
|
|
98
|
+
}
|
|
18
99
|
}
|
|
19
100
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
private _cleanupRequestChannel?: () => void;
|
|
101
|
+
/** The reason requests fail with when their handler dies under them. */
|
|
102
|
+
export const HANDLER_LOST_MESSAGE = "The handler was lost while this request was in flight";
|
|
23
103
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
104
|
+
const TIMED_OUT = Symbol("timed-out");
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The same-origin ServiceWorker: routes `fetch` events under its scope to
|
|
108
|
+
* handler links that pages attach — handlers in the pages themselves
|
|
109
|
+
* (`SwHttpAdapter.register`) or in Dedicated Workers
|
|
110
|
+
* (`SwHttpAdapter.attachWorker` + `serveHttpInWorker`).
|
|
111
|
+
*
|
|
112
|
+
* A url `<scope>/<key>/...` belongs to the handlers of `key`. Of those, the
|
|
113
|
+
* `route` option picks one per request. Requests and responses stream with
|
|
114
|
+
* backpressure in both directions, and an aborted fetch reaches the handler
|
|
115
|
+
* as a cancellation (webrun-rpc + webrun-http-streams).
|
|
116
|
+
*
|
|
117
|
+
* Urls of keys this worker never served go to the network untouched: the
|
|
118
|
+
* worker does not call `respondWith` for them.
|
|
119
|
+
*
|
|
120
|
+
* RESTARTS. The browser stops an idle ServiceWorker, and every port it held
|
|
121
|
+
* dies with it. Nothing is persisted but the set of keys ever served: the
|
|
122
|
+
* first request after a restart asks every window to attach its links again
|
|
123
|
+
* (`NEED_LINKS`) and waits for them.
|
|
124
|
+
*
|
|
125
|
+
* LIVENESS. Each handler holds an owner lock; when it is granted to this
|
|
126
|
+
* worker, the handler is gone and its requests in flight fail at once (502).
|
|
127
|
+
* This worker holds a router lock that handlers watch the same way.
|
|
128
|
+
*/
|
|
129
|
+
export class SwHttpDispatcher {
|
|
130
|
+
readonly self: ServiceWorkerGlobalScope;
|
|
131
|
+
readonly log: (...args: unknown[]) => void;
|
|
132
|
+
readonly options: SwHttpDispatcherOptions;
|
|
133
|
+
readonly table = new HandlerTable<HandlerEntry>();
|
|
134
|
+
/**
|
|
135
|
+
* Keys that were served at some point on this worker (sticky, kept in
|
|
136
|
+
* IndexedDB). Lets a fetch tell "a site url whose handler is gone → 404"
|
|
137
|
+
* from "an unknown url under the scope → the network".
|
|
138
|
+
*/
|
|
139
|
+
readonly claimedKeys = new Set<string>();
|
|
140
|
+
/** The Web Lock this worker holds while it lives; handlers watch it. */
|
|
141
|
+
readonly routerLock?: string;
|
|
142
|
+
private _routerHeld: Promise<void> = Promise.resolve();
|
|
143
|
+
private _restored: Promise<void> = Promise.resolve();
|
|
144
|
+
private _restoreSettled = false;
|
|
145
|
+
private _cleanup: Array<() => void> = [];
|
|
146
|
+
private _livenessTimer?: ReturnType<typeof setInterval>;
|
|
147
|
+
private readonly _relinkRounds = new Map<string, { at: number; done: Promise<void> }>();
|
|
148
|
+
/** Keys whose last handler was drained: until the time stored, requests wait for the next. */
|
|
149
|
+
private readonly _vacant = new Map<string, number>();
|
|
150
|
+
|
|
151
|
+
constructor(options: SwHttpDispatcherOptions) {
|
|
152
|
+
this.options = options;
|
|
153
|
+
this.self = options.self;
|
|
154
|
+
this.log = options.log ?? (() => {});
|
|
155
|
+
if (locksAvailable()) this.routerLock = newLockName("webrun-http-router");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
get scope(): string {
|
|
159
|
+
return `${new URL("./", this.self.location.href)}`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** The key `url` belongs to: its first path segment under the scope. */
|
|
163
|
+
keyOf(url: string | URL): string | undefined {
|
|
164
|
+
const href = `${url}`;
|
|
165
|
+
const scope = this.scope;
|
|
166
|
+
if (!href.startsWith(scope)) return undefined;
|
|
167
|
+
const key = href
|
|
168
|
+
.substring(scope.length)
|
|
169
|
+
.replace(/[?#].*$/, "")
|
|
170
|
+
.split("/")[0];
|
|
171
|
+
return key ? decodeURIComponent(key) : undefined;
|
|
29
172
|
}
|
|
30
173
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
174
|
+
/** Whether `key` has ever been served on this worker. */
|
|
175
|
+
isClaimedKey(key: string): boolean {
|
|
176
|
+
return this.claimedKeys.has(key);
|
|
34
177
|
}
|
|
35
178
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
179
|
+
/** Whether requests for `key` are this worker's to answer. */
|
|
180
|
+
serves(key: string): boolean {
|
|
181
|
+
return this.claimedKeys.has(key) || this.table.all(key).length > 0;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
start(): void {
|
|
185
|
+
const self = this.self;
|
|
186
|
+
if (this.routerLock) this._routerHeld = holdLock(this.routerLock).then(() => {});
|
|
187
|
+
this._restored = this._restoreClaimedKeys();
|
|
188
|
+
const latch = () => {
|
|
189
|
+
this._restoreSettled = true;
|
|
190
|
+
};
|
|
191
|
+
this._restored.then(latch, latch);
|
|
192
|
+
|
|
193
|
+
this._cleanup.push(
|
|
194
|
+
handleClaimRequests(self),
|
|
195
|
+
handleChannelCalls(self, ATTACH_CALL, (event, params, ...ports) =>
|
|
196
|
+
this._attach(event, params as AttachParams, ports),
|
|
197
|
+
),
|
|
198
|
+
handleChannelCalls(self, DETACH_CALL, (_event, params) => {
|
|
199
|
+
const entry = this._find(params as DrainParams);
|
|
200
|
+
if (!entry) return false;
|
|
201
|
+
this._close(entry, new LinkLostError("The handler was detached"));
|
|
202
|
+
return true;
|
|
203
|
+
}),
|
|
204
|
+
handleChannelCalls(self, DRAIN_CALL, async (_event, params) => {
|
|
205
|
+
const entry = this._find(params as DrainParams);
|
|
206
|
+
if (!entry) return false;
|
|
207
|
+
// Requests for the key wait for its next owner, even once this one
|
|
208
|
+
// is gone, for up to `attachTimeoutMs`.
|
|
209
|
+
this._vacant.set(entry.key, Date.now() + (this.options.attachTimeoutMs ?? 15_000));
|
|
210
|
+
await this._retire(entry, (params as DrainParams).ms ?? this.options.handoverMs ?? 5_000);
|
|
211
|
+
return true;
|
|
212
|
+
}),
|
|
213
|
+
handleChannelCalls(self, WS_CONNECT_CALL, async (event, params) => {
|
|
214
|
+
if (!this._isOwnClient(event)) throw new Error("Only this origin's pages may open sockets");
|
|
215
|
+
const { url, protocols } = params as { url: string; protocols?: string[] };
|
|
216
|
+
const connection = await this.connectWebSocket(url, protocols ?? []);
|
|
217
|
+
return withTransfer(connection, [connection.port]);
|
|
218
|
+
}),
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
const onMessage = (event: ExtendableMessageEvent) => {
|
|
222
|
+
const type = (event.data as { type?: unknown } | null)?.type;
|
|
223
|
+
if (type === WAKE_MESSAGE) {
|
|
224
|
+
// Started by this very message if it was stopped: hold it up until the
|
|
225
|
+
// pages had a chance to link again.
|
|
226
|
+
event.waitUntil(this._askForLinks());
|
|
227
|
+
} else if (type === KEEPALIVE_MESSAGE) {
|
|
228
|
+
// Receiving the message is what resets the idle timer.
|
|
229
|
+
event.waitUntil(Promise.resolve());
|
|
41
230
|
}
|
|
231
|
+
};
|
|
232
|
+
self.addEventListener("message", onMessage);
|
|
233
|
+
this._cleanup.push(() => self.removeEventListener("message", onMessage));
|
|
234
|
+
|
|
235
|
+
const onInstall = (event: ExtendableEvent) => {
|
|
236
|
+
this.log("Skip waiting on install.");
|
|
237
|
+
event.waitUntil(self.skipWaiting());
|
|
238
|
+
};
|
|
239
|
+
const onActivate = (event: ExtendableEvent) => {
|
|
240
|
+
this.log("Claim control over all clients.");
|
|
241
|
+
event.waitUntil(self.clients.claim());
|
|
242
|
+
};
|
|
243
|
+
const onFetch = (event: FetchEvent) => this._onFetch(event);
|
|
244
|
+
self.addEventListener("install", onInstall);
|
|
245
|
+
self.addEventListener("activate", onActivate);
|
|
246
|
+
self.addEventListener("fetch", onFetch);
|
|
247
|
+
this._cleanup.push(() => {
|
|
248
|
+
self.removeEventListener("install", onInstall);
|
|
249
|
+
self.removeEventListener("activate", onActivate);
|
|
250
|
+
self.removeEventListener("fetch", onFetch);
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async stop(): Promise<void> {
|
|
255
|
+
for (const cleanup of this._cleanup.splice(0)) cleanup();
|
|
256
|
+
if (this._livenessTimer) clearInterval(this._livenessTimer);
|
|
257
|
+
this._livenessTimer = undefined;
|
|
258
|
+
for (const entry of this.table.entries()) {
|
|
259
|
+
this._close(entry, new LinkLostError("The ServiceWorker dispatcher stopped"));
|
|
42
260
|
}
|
|
43
|
-
return new Response(null, { status: 404, statusText: "Error 404: Not found" });
|
|
44
261
|
}
|
|
45
262
|
|
|
46
263
|
/**
|
|
47
|
-
*
|
|
48
|
-
*
|
|
264
|
+
* Answers `request` as a fetch under the scope would be: through the
|
|
265
|
+
* handler its key routes to. A url outside the scope, or of a key this
|
|
266
|
+
* worker never served, goes to the network.
|
|
49
267
|
*/
|
|
50
|
-
async
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
this.
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
268
|
+
async dispatch(request: Request): Promise<Response> {
|
|
269
|
+
const key = this.keyOf(request.url);
|
|
270
|
+
if (key === undefined) return await fetch(request);
|
|
271
|
+
await this._restored.catch(() => {});
|
|
272
|
+
const entry = await this._acquire(request, key);
|
|
273
|
+
if (entry === TIMED_OUT) {
|
|
274
|
+
return new Response(null, {
|
|
275
|
+
status: 503,
|
|
276
|
+
statusText: "Service Unavailable (no handler attached in time)",
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
if (!entry) {
|
|
280
|
+
if (!this.serves(key)) return await fetch(request);
|
|
281
|
+
// Key was served at some point but has no live handler now (its tab
|
|
282
|
+
// closed). 404 rather than the network: a dev server's SPA fallback
|
|
283
|
+
// would serve the app shell for every site url and stale tabs would
|
|
284
|
+
// keep resurrecting registrations.
|
|
285
|
+
return new Response(null, { status: 404, statusText: "Not Found (no active handler)" });
|
|
286
|
+
}
|
|
287
|
+
try {
|
|
288
|
+
return await entry.link.send(request);
|
|
289
|
+
} catch (error) {
|
|
290
|
+
this.log("[dispatch] failed", key, error);
|
|
291
|
+
return new Response(errorMessage(error), {
|
|
292
|
+
status: 502,
|
|
293
|
+
statusText: "Bad Gateway",
|
|
294
|
+
headers: { "content-type": "text/plain; charset=utf-8" },
|
|
295
|
+
});
|
|
296
|
+
}
|
|
62
297
|
}
|
|
63
|
-
}
|
|
64
298
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
299
|
+
/**
|
|
300
|
+
* Opens a socket to the handler `url` routes to: the handler accepts or
|
|
301
|
+
* refuses it like an HTTP upgrade, and the socket's port comes back. After
|
|
302
|
+
* that the socket runs between the caller and the handler directly; this
|
|
303
|
+
* worker is not on its data path.
|
|
304
|
+
*/
|
|
305
|
+
async connectWebSocket(
|
|
306
|
+
url: string,
|
|
307
|
+
protocols: string[] = [],
|
|
308
|
+
headers?: [string, string][],
|
|
309
|
+
): Promise<WebSocketConnection> {
|
|
310
|
+
const request = newWebSocketUpgradeRequest(url, { protocols, headers });
|
|
311
|
+
const key = this.keyOf(request.url);
|
|
312
|
+
if (key === undefined) throw new Error(`${url} is outside this ServiceWorker's scope`);
|
|
313
|
+
await this._restored.catch(() => {});
|
|
314
|
+
const entry = await this._acquire(request, key);
|
|
315
|
+
if (!entry || entry === TIMED_OUT) throw new Error(`No handler serves ${url}`);
|
|
316
|
+
if (!entry.ctl) throw new Error(`The handler of ${url} does not accept sockets`);
|
|
317
|
+
const id = entry.sockets.newId();
|
|
318
|
+
const answer = entry.sockets.wait(
|
|
319
|
+
id,
|
|
320
|
+
this.options.wsConnectTimeoutMs ?? 15_000,
|
|
321
|
+
`the socket request for ${url}`,
|
|
322
|
+
);
|
|
323
|
+
entry.ctl.postMessage({
|
|
324
|
+
t: "ws-connect",
|
|
325
|
+
id,
|
|
326
|
+
url: request.url,
|
|
327
|
+
protocols,
|
|
328
|
+
headers,
|
|
329
|
+
} satisfies ControlMessage);
|
|
330
|
+
return await answer;
|
|
69
331
|
}
|
|
70
332
|
|
|
71
|
-
private
|
|
333
|
+
private _onFetch(event: FetchEvent): void {
|
|
334
|
+
const request = event.request;
|
|
335
|
+
const key = this.keyOf(request.url);
|
|
336
|
+
// Outside the scope: not ours. No `respondWith`, so the browser does the
|
|
337
|
+
// request exactly as it would without a worker.
|
|
338
|
+
if (key === undefined) return;
|
|
339
|
+
if (this._restoreSettled) {
|
|
340
|
+
if (this.serves(key)) {
|
|
341
|
+
event.respondWith(this.dispatch(request));
|
|
342
|
+
} else if (request.mode === "navigate") {
|
|
343
|
+
// A navigation under the scope is answered even when it is nobody's,
|
|
344
|
+
// by passing it through: Firefox can leave a page uncontrolled when
|
|
345
|
+
// its worker did not answer the navigation, and an uncontrolled page's
|
|
346
|
+
// `fetch()` never reaches this worker. Everything else is left alone.
|
|
347
|
+
event.respondWith(fetch(request));
|
|
348
|
+
}
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
// Before the stored keys are read there is nothing to decide on, and
|
|
352
|
+
// `respondWith` cannot be called after an await: claim the request, and
|
|
353
|
+
// re-issue it if it turns out to be nobody's.
|
|
72
354
|
event.respondWith(
|
|
73
|
-
(async ()
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
const rootUrl = this.scope;
|
|
78
|
-
if (requestUrl.indexOf(rootUrl) === 0) {
|
|
79
|
-
const key = requestUrl.substring(rootUrl.length).replace(/^\/?([^/]+).*$/, "$1");
|
|
80
|
-
const channelInfo = await this.loadChannelInfo(key);
|
|
81
|
-
if (channelInfo?.port) {
|
|
82
|
-
return await sendHttpRequest(channelInfo.port, request);
|
|
83
|
-
}
|
|
84
|
-
// Key was registered at some point on this SW but has no live
|
|
85
|
-
// handler right now (owner tab closed). Return 404 instead of
|
|
86
|
-
// falling through to the network — the alternative (a dev
|
|
87
|
-
// server's SPA fallback) serves the app shell for every site
|
|
88
|
-
// URL and creates a confusing refresh-loop where stale tabs
|
|
89
|
-
// keep resurrecting registrations.
|
|
90
|
-
if (this.isClaimedKey(key)) {
|
|
91
|
-
return new Response(null, {
|
|
92
|
-
status: 404,
|
|
93
|
-
statusText: "Not Found (no active handler)",
|
|
94
|
-
});
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
// URL is outside scope, or the first segment is unrelated to any
|
|
98
|
-
// registered site (Vite's /assets/*, /favicon.ico, etc.) — pass
|
|
99
|
-
// the event's Request straight through. Reconstructing it drops
|
|
100
|
-
// `mode`, which makes the constructor throw
|
|
101
|
-
// `'only-if-cached' can be set only with 'same-origin' mode` for
|
|
102
|
-
// navigation-style requests Chrome issues with that cache mode.
|
|
103
|
-
return await fetch(request);
|
|
104
|
-
} catch (error) {
|
|
105
|
-
console.error(error);
|
|
106
|
-
return new Response(null, {
|
|
107
|
-
status: 500,
|
|
108
|
-
statusText: "Error 500: Internal error",
|
|
109
|
-
});
|
|
110
|
-
}
|
|
355
|
+
(async () => {
|
|
356
|
+
await this._restored.catch(() => {});
|
|
357
|
+
if (!this.serves(key)) return await fetch(request);
|
|
358
|
+
return await this.dispatch(request);
|
|
111
359
|
})(),
|
|
112
360
|
);
|
|
113
361
|
}
|
|
362
|
+
|
|
363
|
+
private async _attach(
|
|
364
|
+
event: MessageEvent,
|
|
365
|
+
params: AttachParams,
|
|
366
|
+
ports: MessagePort[],
|
|
367
|
+
): Promise<AttachReply | AttachParams> {
|
|
368
|
+
const clientId = (event.source as Client | null)?.id;
|
|
369
|
+
const [down, ...rest] = ports;
|
|
370
|
+
if (!down) throw new Error("An attach carries at least one port");
|
|
371
|
+
this.log("[attach]", clientId, params);
|
|
372
|
+
|
|
373
|
+
if (params.wire === undefined) {
|
|
374
|
+
// A page of version 1 (before #32): its own stream transport, no
|
|
375
|
+
// backpressure. Served as it was, until the page reloads; after a
|
|
376
|
+
// restart it is asked for a port the way version 1 asked
|
|
377
|
+
// (`_askLegacyPage`).
|
|
378
|
+
const link = trackedLink(
|
|
379
|
+
(request) => sendHttpRequest(down, request),
|
|
380
|
+
() => down.close(),
|
|
381
|
+
);
|
|
382
|
+
const id = clientId ?? crypto.randomUUID();
|
|
383
|
+
this._add(
|
|
384
|
+
new HandlerEntry(params.key, id, id, undefined, link, undefined, clientId, undefined),
|
|
385
|
+
);
|
|
386
|
+
return { ...params };
|
|
387
|
+
}
|
|
388
|
+
if (params.wire !== HTTP_LINK_PROTOCOL) {
|
|
389
|
+
for (const port of ports) port.close();
|
|
390
|
+
throw new Error(
|
|
391
|
+
`This ServiceWorker speaks ${HTTP_LINK_PROTOCOL}, the page ${params.wire}. ` +
|
|
392
|
+
"Reload the page so that both run the same version of @statewalker/webrun-http-browser.",
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const ctl = params.ctl ? rest.shift() : undefined;
|
|
397
|
+
const up = params.up ? rest.shift() : undefined;
|
|
398
|
+
const streamTimeout = this.options.streamTimeout;
|
|
399
|
+
const link = await connectHttpLink(down, { streamTimeout });
|
|
400
|
+
const entry = new HandlerEntry(
|
|
401
|
+
params.key,
|
|
402
|
+
params.id ?? clientId ?? crypto.randomUUID(),
|
|
403
|
+
params.owner ?? crypto.randomUUID(),
|
|
404
|
+
params.meta,
|
|
405
|
+
link,
|
|
406
|
+
ctl,
|
|
407
|
+
clientId,
|
|
408
|
+
params.ownerLock,
|
|
409
|
+
);
|
|
410
|
+
if (up) {
|
|
411
|
+
entry.closeUp = await serveHttpLink(up, (request) => this.dispatch(request), {
|
|
412
|
+
streamTimeout,
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
if (ctl) ctl.onmessage = (message) => this._onControl(entry, message.data as ControlMessage);
|
|
416
|
+
if (params.ownerLock) {
|
|
417
|
+
entry.stopWatch = watchLock(params.ownerLock, () => {
|
|
418
|
+
this.log("[owner lost]", entry.key, entry.id);
|
|
419
|
+
this._close(entry, new LinkLostError(HANDLER_LOST_MESSAGE));
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
this._add(entry);
|
|
423
|
+
await this._routerHeld;
|
|
424
|
+
return { accepted: HTTP_LINK_PROTOCOL, routerLock: this.routerLock };
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
private _add(entry: HandlerEntry): void {
|
|
428
|
+
this._vacant.delete(entry.key);
|
|
429
|
+
const replaced = this.table.add(entry);
|
|
430
|
+
if (replaced && replaced !== entry) {
|
|
431
|
+
void this._retire(replaced, this.options.handoverMs ?? 5_000, false);
|
|
432
|
+
}
|
|
433
|
+
void this._claim(entry.key);
|
|
434
|
+
if (entry.ownerLock) this._watchLiveness();
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* The backup of the owner-lock watches: every `livenessCheckMs`, drops the
|
|
439
|
+
* handlers whose owner lock is no longer held. Runs only while some handler
|
|
440
|
+
* has an owner lock, and keeps nothing awake: a timer is not an event.
|
|
441
|
+
*/
|
|
442
|
+
private _watchLiveness(): void {
|
|
443
|
+
const period = this.options.livenessCheckMs ?? 5_000;
|
|
444
|
+
if (this._livenessTimer || period <= 0) return;
|
|
445
|
+
this._livenessTimer = setInterval(async () => {
|
|
446
|
+
const watched = this.table.entries().filter((entry) => entry.ownerLock);
|
|
447
|
+
if (watched.length === 0) {
|
|
448
|
+
clearInterval(this._livenessTimer);
|
|
449
|
+
this._livenessTimer = undefined;
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
const held = await heldLockNames();
|
|
453
|
+
if (!held) return;
|
|
454
|
+
for (const entry of watched) {
|
|
455
|
+
if (entry.ownerLock && !held.has(entry.ownerLock) && this.table.remove(entry)) {
|
|
456
|
+
this.log("[owner gone]", entry.key, entry.id);
|
|
457
|
+
this._close(entry, new LinkLostError(HANDLER_LOST_MESSAGE), false);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}, period);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** The entry a drain or detach names, if its owner token still matches. */
|
|
464
|
+
private _find({ key, id, owner }: DrainParams): HandlerEntry | undefined {
|
|
465
|
+
const entry = this.table.get(key, id);
|
|
466
|
+
return entry && entry.owner === owner ? entry : undefined;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Stops routing to `entry`, lets its requests in flight run for up to `ms`,
|
|
471
|
+
* then closes it. Requests that arrive meanwhile wait for the next owner.
|
|
472
|
+
*/
|
|
473
|
+
private async _retire(entry: HandlerEntry, ms: number, inTable = true): Promise<void> {
|
|
474
|
+
entry.draining = true;
|
|
475
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
476
|
+
await Promise.race([
|
|
477
|
+
entry.link.whenIdle(),
|
|
478
|
+
new Promise<void>((resolve) => {
|
|
479
|
+
timer = setTimeout(resolve, ms);
|
|
480
|
+
}),
|
|
481
|
+
]);
|
|
482
|
+
clearTimeout(timer);
|
|
483
|
+
this._close(entry, new LinkLostError("The handler was drained"), inTable);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
private _close(entry: HandlerEntry, reason: Error, inTable = true): void {
|
|
487
|
+
if (inTable) this.table.remove(entry);
|
|
488
|
+
entry.stopWatch();
|
|
489
|
+
entry.sockets.failAll(reason);
|
|
490
|
+
void entry.link.close(reason);
|
|
491
|
+
void entry.closeUp?.().catch(() => {});
|
|
492
|
+
if (entry.ctl) {
|
|
493
|
+
entry.ctl.onmessage = null;
|
|
494
|
+
entry.ctl.close();
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* The handler for `request`: picked by `route` among the live handlers of
|
|
500
|
+
* its key, waiting for one when there is none.
|
|
501
|
+
*/
|
|
502
|
+
private async _acquire(
|
|
503
|
+
request: Request,
|
|
504
|
+
key: string,
|
|
505
|
+
): Promise<HandlerEntry | typeof TIMED_OUT | undefined> {
|
|
506
|
+
const picked = await this._pick(request, key);
|
|
507
|
+
if (picked) return picked;
|
|
508
|
+
if (this.table.all(key).length > 0) {
|
|
509
|
+
// A hand-over: every handler of the key drains, or `route` declined
|
|
510
|
+
// them all. Wait for the next owner.
|
|
511
|
+
const attached = await this.table.waitFor(
|
|
512
|
+
key,
|
|
513
|
+
this.options.attachTimeoutMs ?? 15_000,
|
|
514
|
+
this.table.live(key).length > 0,
|
|
515
|
+
);
|
|
516
|
+
return attached ? ((await this._pick(request, key)) ?? TIMED_OUT) : TIMED_OUT;
|
|
517
|
+
}
|
|
518
|
+
const vacantUntil = this._vacant.get(key) ?? 0;
|
|
519
|
+
if (vacantUntil > Date.now()) {
|
|
520
|
+
// The last handler was drained: wait for the next owner.
|
|
521
|
+
const attached = await this.table.waitFor(key, vacantUntil - Date.now());
|
|
522
|
+
return attached ? ((await this._pick(request, key)) ?? TIMED_OUT) : TIMED_OUT;
|
|
523
|
+
}
|
|
524
|
+
if (!this.serves(key)) return undefined;
|
|
525
|
+
// Nothing attached for a key this worker served: it may have restarted
|
|
526
|
+
// and lost its links. Ask the pages for them.
|
|
527
|
+
await this._askForLinks(key);
|
|
528
|
+
return await this._pick(request, key);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
private async _pick(request: Request, key: string): Promise<HandlerEntry | undefined> {
|
|
532
|
+
let live = this.table.live(key);
|
|
533
|
+
// Without Web Locks there is no owner lock: check the page is still there.
|
|
534
|
+
for (const entry of live) {
|
|
535
|
+
if (entry.ownerLock || !entry.clientId) continue;
|
|
536
|
+
if (!(await this.self.clients.get(entry.clientId))) {
|
|
537
|
+
this._close(entry, new LinkLostError(HANDLER_LOST_MESSAGE));
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
live = this.table.live(key);
|
|
541
|
+
if (live.length === 0) return undefined;
|
|
542
|
+
const route = this.options.route ?? leastInflightRoute;
|
|
543
|
+
return route(request, key, live);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Asks every window to attach its links again, and waits until a handler
|
|
548
|
+
* for `key` is back, every window answered, or `relinkTimeoutMs` passed.
|
|
549
|
+
* At most one round per key every 10 s: a key whose tab is simply gone
|
|
550
|
+
* should not cost every request the wait.
|
|
551
|
+
*/
|
|
552
|
+
private _askForLinks(key = ""): Promise<void> {
|
|
553
|
+
const round = this._relinkRounds.get(key);
|
|
554
|
+
// Requests arriving during a round wait for the same round; after it, a
|
|
555
|
+
// key whose tab is simply gone does not cost every request the wait.
|
|
556
|
+
if (round && Date.now() - round.at < 10_000) return round.done;
|
|
557
|
+
const done = this._linkRound(key);
|
|
558
|
+
this._relinkRounds.set(key, { at: Date.now(), done });
|
|
559
|
+
return done;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
private async _linkRound(key: string): Promise<void> {
|
|
563
|
+
const timeout = this.options.relinkTimeoutMs ?? 3_000;
|
|
564
|
+
const windows = await this.self.clients.matchAll({ type: "window", includeUncontrolled: true });
|
|
565
|
+
this.log("[need-links]", key, windows.length);
|
|
566
|
+
const params: NeedLinksParams = { routerLock: this.routerLock };
|
|
567
|
+
const pending = new Set(windows);
|
|
568
|
+
const answered = Promise.allSettled(
|
|
569
|
+
windows.map((client) =>
|
|
570
|
+
callChannel(client, NEED_LINKS_CALL, params).then(() => pending.delete(client)),
|
|
571
|
+
),
|
|
572
|
+
);
|
|
573
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
574
|
+
const timedOut = new Promise<void>((resolve) => {
|
|
575
|
+
timer = setTimeout(resolve, timeout);
|
|
576
|
+
});
|
|
577
|
+
// Every window answering is the end of the round; a window without this
|
|
578
|
+
// package never answers, which is what the timeout is for.
|
|
579
|
+
await Promise.race([answered, timedOut]);
|
|
580
|
+
clearTimeout(timer);
|
|
581
|
+
// The windows that did not answer may run version 1 of this package,
|
|
582
|
+
// which re-links when asked the way version 1's worker asked.
|
|
583
|
+
for (const client of pending) this._askLegacyPage(client);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/** Asks a version-1 page for a fresh port, as version 1's worker did. */
|
|
587
|
+
private _askLegacyPage(client: Client): void {
|
|
588
|
+
const channel = new MessageChannel();
|
|
589
|
+
callChannel<AttachParams>(client, ATTACH_CALL, {}, channel.port2).then(
|
|
590
|
+
(info) => {
|
|
591
|
+
if (!info?.key || info.wire !== undefined) return;
|
|
592
|
+
const link = trackedLink(
|
|
593
|
+
(request) => sendHttpRequest(channel.port1, request),
|
|
594
|
+
() => channel.port1.close(),
|
|
595
|
+
);
|
|
596
|
+
this._add(
|
|
597
|
+
new HandlerEntry(
|
|
598
|
+
info.key,
|
|
599
|
+
client.id,
|
|
600
|
+
client.id,
|
|
601
|
+
undefined,
|
|
602
|
+
link,
|
|
603
|
+
undefined,
|
|
604
|
+
client.id,
|
|
605
|
+
undefined,
|
|
606
|
+
),
|
|
607
|
+
);
|
|
608
|
+
},
|
|
609
|
+
() => channel.port1.close(),
|
|
610
|
+
);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
private _onControl(entry: HandlerEntry, message: ControlMessage): void {
|
|
614
|
+
if (!message || typeof message !== "object") return;
|
|
615
|
+
switch (message.t) {
|
|
616
|
+
case "ws-accept":
|
|
617
|
+
// Too late (the caller gave up): close the port, or the handler's
|
|
618
|
+
// socket lingers until its heartbeat notices.
|
|
619
|
+
if (!entry.sockets.settle(message.id, { port: message.port, protocol: message.protocol })) {
|
|
620
|
+
message.port.close();
|
|
621
|
+
}
|
|
622
|
+
return;
|
|
623
|
+
case "ws-refuse":
|
|
624
|
+
entry.sockets.fail(message.id, new Error(message.message));
|
|
625
|
+
return;
|
|
626
|
+
case "ws-open":
|
|
627
|
+
// A handler opens a socket to another handler, through this router.
|
|
628
|
+
this.connectWebSocket(message.url, message.protocols).then(
|
|
629
|
+
({ port, protocol }) =>
|
|
630
|
+
entry.ctl?.postMessage(
|
|
631
|
+
{ t: "ws-opened", id: message.id, protocol, port } satisfies ControlMessage,
|
|
632
|
+
[port],
|
|
633
|
+
),
|
|
634
|
+
(error) =>
|
|
635
|
+
entry.ctl?.postMessage({
|
|
636
|
+
t: "ws-failed",
|
|
637
|
+
id: message.id,
|
|
638
|
+
message: errorMessage(error),
|
|
639
|
+
} satisfies ControlMessage),
|
|
640
|
+
);
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
private _isOwnClient(event: MessageEvent): boolean {
|
|
646
|
+
const source = event.source as Client | null;
|
|
647
|
+
if (!source?.url) return false;
|
|
648
|
+
try {
|
|
649
|
+
return new URL(source.url).origin === this.self.location.origin;
|
|
650
|
+
} catch {
|
|
651
|
+
return false;
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
private async _claim(key: string): Promise<void> {
|
|
656
|
+
if (!key || this.claimedKeys.has(key)) return;
|
|
657
|
+
this.claimedKeys.add(key);
|
|
658
|
+
try {
|
|
659
|
+
await set("claimedKeys", [...this.claimedKeys].sort());
|
|
660
|
+
} catch (error) {
|
|
661
|
+
this.log("[claimedKeys] not stored", error);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
private async _restoreClaimedKeys(): Promise<void> {
|
|
666
|
+
const stored = ((await get<string[]>("claimedKeys")) ?? []) as string[];
|
|
667
|
+
for (const key of stored) this.claimedKeys.add(key);
|
|
668
|
+
}
|
|
114
669
|
}
|
|
115
670
|
|
|
116
|
-
|
|
671
|
+
/**
|
|
672
|
+
* Starts the same-origin dispatcher in a ServiceWorker. Returns a function
|
|
673
|
+
* that stops it.
|
|
674
|
+
*/
|
|
675
|
+
export function startHttpDispatcher(options: SwHttpDispatcherOptions): () => void {
|
|
117
676
|
const dispatcher = new SwHttpDispatcher(options);
|
|
118
677
|
dispatcher.start();
|
|
119
678
|
return () => {
|