@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.
- package/README.md +319 -65
- 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 +3907 -266
- package/dist/link/errors.d.ts +2 -0
- package/dist/link/errors.d.ts.map +1 -0
- package/dist/link/http-link.d.ts +58 -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 +1160 -55
- package/dist/relay-worker.js +1161 -55
- 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 +2 -1
- 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 +2075 -210
- package/dist/sw.d.ts +2 -0
- package/dist/sw.d.ts.map +1 -1
- package/dist/sw.js +3853 -480
- package/dist/worker.d.ts +10 -0
- package/dist/worker.d.ts.map +1 -0
- package/dist/worker.js +3507 -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/errors.ts +3 -0
- package/src/link/http-link.ts +222 -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 +205 -29
- 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 +12 -30
- 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
|
@@ -0,0 +1,613 @@
|
|
|
1
|
+
import type { HttpHandler } from "@statewalker/webrun-http-streams";
|
|
2
|
+
import { callChannel, callChannelWithin, handleChannelCalls } from "../core/data-calls.js";
|
|
3
|
+
import { withDeadline } from "../core/deadline.js";
|
|
4
|
+
import {
|
|
5
|
+
awaitServiceWorkerControl,
|
|
6
|
+
DEFAULT_SERVICE_WORKER_TIMEOUT,
|
|
7
|
+
ServiceWorkerControlError,
|
|
8
|
+
} from "../core/service-worker-control.js";
|
|
9
|
+
import { HTTP_LINK_PROTOCOL } from "../link/http-link.js";
|
|
10
|
+
import { heldLockNames, holdLock, locksAvailable, newLockName } from "../link/locks.js";
|
|
11
|
+
import {
|
|
12
|
+
createWebSocketClass,
|
|
13
|
+
type PortWebSocketClass,
|
|
14
|
+
type WebSocketClassOptions,
|
|
15
|
+
type WebSocketConnection,
|
|
16
|
+
} from "../ws/client.js";
|
|
17
|
+
import { serveWebSocketUpgrade } from "../ws/upgrade.js";
|
|
18
|
+
import { HandlerEndpoint } from "./handler-endpoint.js";
|
|
19
|
+
import {
|
|
20
|
+
ATTACH_CALL,
|
|
21
|
+
type AttachParams,
|
|
22
|
+
type AttachReply,
|
|
23
|
+
DETACH_CALL,
|
|
24
|
+
DRAIN_CALL,
|
|
25
|
+
KEEPALIVE_MESSAGE,
|
|
26
|
+
NEED_LINKS_CALL,
|
|
27
|
+
type NeedLinksParams,
|
|
28
|
+
WAKE_MESSAGE,
|
|
29
|
+
WORKER_HELLO,
|
|
30
|
+
type WorkerControlMessage,
|
|
31
|
+
WS_CONNECT_CALL,
|
|
32
|
+
} from "./protocol.js";
|
|
33
|
+
import { resolveWorkerUrl } from "./worker-url.js";
|
|
34
|
+
|
|
35
|
+
export interface SwHttpAdapterOptions {
|
|
36
|
+
/** The first path segment under the scope that this page's handlers serve. */
|
|
37
|
+
key: string;
|
|
38
|
+
scope?: string;
|
|
39
|
+
serviceWorkerUrl?: string;
|
|
40
|
+
/**
|
|
41
|
+
* Upper bound, in ms, for `start()`'s wait for the worker to activate, take
|
|
42
|
+
* control of the page and answer the handshake. `start()` rejects with a
|
|
43
|
+
* `ServiceWorkerControlError` past it instead of waiting forever.
|
|
44
|
+
* Default `DEFAULT_SERVICE_WORKER_TIMEOUT` (30 s).
|
|
45
|
+
*/
|
|
46
|
+
timeout?: number;
|
|
47
|
+
/**
|
|
48
|
+
* If the page is still uncontrolled once the worker is active — after a
|
|
49
|
+
* hard reload, say — and the worker does not take it over when asked,
|
|
50
|
+
* reload the page once instead of rejecting. Default `false`.
|
|
51
|
+
*/
|
|
52
|
+
reloadIfUncontrolled?: boolean;
|
|
53
|
+
/**
|
|
54
|
+
* Per-stream inactivity timeout of the links, in ms (webrun-rpc's): a
|
|
55
|
+
* request or response body with no chunk for that long fails. Unset — the
|
|
56
|
+
* default — means none: a slow reader is throttled, never failed.
|
|
57
|
+
*/
|
|
58
|
+
streamTimeout?: number;
|
|
59
|
+
/** The handler slot of this page's own handlers. Default: one per adapter. */
|
|
60
|
+
id?: string;
|
|
61
|
+
/** What a `route` function in the ServiceWorker sees of this page's handlers. */
|
|
62
|
+
meta?: unknown;
|
|
63
|
+
/** Options of the `WebSocket` class (`adapter.WebSocket`). */
|
|
64
|
+
webSocket?: WebSocketClassOptions;
|
|
65
|
+
/** @deprecated Version 1 only; ignored. */
|
|
66
|
+
bindPort?: (port: MessagePort) => void | Promise<void>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface SwHttpRegistration {
|
|
70
|
+
baseUrl: string;
|
|
71
|
+
prefix: string;
|
|
72
|
+
remove(): Promise<void>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface AttachWorkerOptions {
|
|
76
|
+
/** The key the worker serves. Default: the adapter's. */
|
|
77
|
+
key?: string;
|
|
78
|
+
/** The handler slot. Attaching an id the key has already replaces it. Default: a fresh one. */
|
|
79
|
+
id?: string;
|
|
80
|
+
/** What a `route` function sees of this handler. */
|
|
81
|
+
meta?: unknown;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** A Dedicated Worker attached as a handler. */
|
|
85
|
+
export interface WorkerLink {
|
|
86
|
+
readonly key: string;
|
|
87
|
+
readonly id: string;
|
|
88
|
+
/** Token of this attachment; `drain` and `detach` name it. */
|
|
89
|
+
readonly owner: string;
|
|
90
|
+
/**
|
|
91
|
+
* Hand-over: the worker gets no new requests (they wait for the next owner
|
|
92
|
+
* of the slot), and those in flight may finish for up to `ms`. Then the
|
|
93
|
+
* link is closed.
|
|
94
|
+
*/
|
|
95
|
+
drain(options?: { ms?: number }): Promise<void>;
|
|
96
|
+
/** Removes the link now. Requests in flight fail. */
|
|
97
|
+
detach(): Promise<void>;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The page side of the same-origin ServiceWorker: registers the worker, and
|
|
102
|
+
* attaches handler links to it.
|
|
103
|
+
*
|
|
104
|
+
* - `register(prefix, handler)` serves a handler in this page, under
|
|
105
|
+
* `<scope>/<key>/...`.
|
|
106
|
+
* - `attachWorker(worker)` links a Dedicated Worker that runs
|
|
107
|
+
* `serveHttpInWorker(handler)`: the ServiceWorker then talks to the worker
|
|
108
|
+
* directly, and this page carries nothing.
|
|
109
|
+
* - `connectWebSocket(url, protocols)` and `WebSocket` open sockets to
|
|
110
|
+
* handlers, through the ServiceWorker or straight to a handler of this page.
|
|
111
|
+
*
|
|
112
|
+
* Links are attached again whenever the ServiceWorker asks (after a restart),
|
|
113
|
+
* and requests in flight on a handler that dies fail at once.
|
|
114
|
+
*/
|
|
115
|
+
export class SwHttpAdapter {
|
|
116
|
+
readonly options: SwHttpAdapterOptions;
|
|
117
|
+
private readonly _handlers = new Map<string, HttpHandler>();
|
|
118
|
+
private readonly _endpoint: HandlerEndpoint;
|
|
119
|
+
private readonly _ownerLock = newLockName("webrun-http-owner");
|
|
120
|
+
private readonly _id: string;
|
|
121
|
+
private readonly _owner = crypto.randomUUID();
|
|
122
|
+
private readonly _workers = new Set<WorkerLinkImpl>();
|
|
123
|
+
private _releaseOwner?: () => void;
|
|
124
|
+
private _serviceWorkerUrl?: string;
|
|
125
|
+
private _serviceWorker?: ServiceWorker;
|
|
126
|
+
private _registration?: ServiceWorkerRegistration;
|
|
127
|
+
private _registrationPromise?: Promise<void>;
|
|
128
|
+
private _cleanup: Array<() => void> = [];
|
|
129
|
+
private _attaching?: Promise<void>;
|
|
130
|
+
private _routerLock?: string;
|
|
131
|
+
private _WebSocket?: PortWebSocketClass;
|
|
132
|
+
|
|
133
|
+
constructor(options: SwHttpAdapterOptions) {
|
|
134
|
+
this.options = options;
|
|
135
|
+
if (!this.key) throw new Error("Key is not defined.");
|
|
136
|
+
if (!this.scope) throw new Error("Scope is not defined");
|
|
137
|
+
this._id = options.id ?? `page:${crypto.randomUUID()}`;
|
|
138
|
+
this._endpoint = new HandlerEndpoint((request) => this._handleHttpRequest(request), {
|
|
139
|
+
streamTimeout: options.streamTimeout,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
get key(): string {
|
|
144
|
+
return this.options.key;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
get scope(): string {
|
|
148
|
+
return this.options.scope ?? new URL("./", this.serviceWorkerUrl).pathname;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The scope as a url on the worker's origin. Resolved against the worker's
|
|
153
|
+
* own url when one is given, not against this module's: a bundle that is
|
|
154
|
+
* not an ES module (an IIFE, Theia's frontend) has no `import.meta.url`, and
|
|
155
|
+
* `new URL(scope, undefined)` threw `Invalid URL` there. Only without a
|
|
156
|
+
* worker url does the module's location decide (its default `sw-worker.js`
|
|
157
|
+
* sits next to it).
|
|
158
|
+
*/
|
|
159
|
+
get rootUrl(): URL {
|
|
160
|
+
const base = this.options.serviceWorkerUrl ? this.serviceWorkerUrl : import.meta.url;
|
|
161
|
+
return new URL(this.scope, base);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
get serviceWorkerUrl(): string {
|
|
165
|
+
if (!this._serviceWorkerUrl) {
|
|
166
|
+
const url = this.options.serviceWorkerUrl
|
|
167
|
+
? resolveWorkerUrl(this.options.serviceWorkerUrl)
|
|
168
|
+
: new URL("./sw-worker.js", this.rootUrl);
|
|
169
|
+
this._serviceWorkerUrl = `${url}`;
|
|
170
|
+
}
|
|
171
|
+
return this._serviceWorkerUrl;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Registers the worker and waits until it is activated and controls this
|
|
176
|
+
* page, then attaches this page's handler link. The page must be controlled,
|
|
177
|
+
* because only a controlled page's `fetch()` reaches the worker. When the
|
|
178
|
+
* page loaded uncontrolled (a hard reload does that), the worker is asked to
|
|
179
|
+
* claim it. Rejects with a `ServiceWorkerControlError` when that fails or
|
|
180
|
+
* `timeout` passes (`reason` says which step); a later `start()` tries
|
|
181
|
+
* again.
|
|
182
|
+
*/
|
|
183
|
+
async start(): Promise<void> {
|
|
184
|
+
if (!this._registrationPromise) {
|
|
185
|
+
this._registrationPromise = (async () => {
|
|
186
|
+
const registration = await navigator.serviceWorker.register(this.serviceWorkerUrl, {
|
|
187
|
+
scope: this.scope,
|
|
188
|
+
});
|
|
189
|
+
this._registration = registration;
|
|
190
|
+
const stopListening = handleChannelCalls(
|
|
191
|
+
navigator.serviceWorker,
|
|
192
|
+
NEED_LINKS_CALL,
|
|
193
|
+
async (_event, params) => {
|
|
194
|
+
await this.relink((params as NeedLinksParams | undefined)?.routerLock);
|
|
195
|
+
return true;
|
|
196
|
+
},
|
|
197
|
+
);
|
|
198
|
+
const onControllerChange = () => {
|
|
199
|
+
const controller = navigator.serviceWorker.controller;
|
|
200
|
+
if (controller) this._serviceWorker = controller;
|
|
201
|
+
};
|
|
202
|
+
navigator.serviceWorker.addEventListener("controllerchange", onControllerChange);
|
|
203
|
+
this._cleanup.push(stopListening, () =>
|
|
204
|
+
navigator.serviceWorker.removeEventListener("controllerchange", onControllerChange),
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
const timeout = this.options.timeout ?? DEFAULT_SERVICE_WORKER_TIMEOUT;
|
|
208
|
+
const deadline = Date.now() + timeout;
|
|
209
|
+
try {
|
|
210
|
+
this._serviceWorker = await awaitServiceWorkerControl(registration, {
|
|
211
|
+
timeout,
|
|
212
|
+
reloadIfUncontrolled: this.options.reloadIfUncontrolled,
|
|
213
|
+
});
|
|
214
|
+
this._releaseOwner ??= await holdLock(this._ownerLock);
|
|
215
|
+
await withDeadline(
|
|
216
|
+
deadline,
|
|
217
|
+
this._attachPage(),
|
|
218
|
+
() =>
|
|
219
|
+
new ServiceWorkerControlError(
|
|
220
|
+
"unresponsive",
|
|
221
|
+
`ServiceWorker "${this._serviceWorker?.scriptURL}" controls this page but did ` +
|
|
222
|
+
`not answer the UPDATE_COMMUNICATION_PORT handshake within ${timeout} ms. ` +
|
|
223
|
+
"Check that the worker script runs this package's same-origin dispatcher " +
|
|
224
|
+
"(it importScripts `sw-worker.js`, or calls `startHttpDispatcher`).",
|
|
225
|
+
),
|
|
226
|
+
);
|
|
227
|
+
} catch (error) {
|
|
228
|
+
// Forget this attempt so a later start() retries. Keep the
|
|
229
|
+
// registration: the worker is fine — it is this page that is not
|
|
230
|
+
// controlled, and other pages may be using the worker.
|
|
231
|
+
for (const cleanup of this._cleanup.splice(0)) cleanup();
|
|
232
|
+
this._registration = undefined;
|
|
233
|
+
this._registrationPromise = undefined;
|
|
234
|
+
throw error;
|
|
235
|
+
}
|
|
236
|
+
})();
|
|
237
|
+
}
|
|
238
|
+
return this._registrationPromise;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Detaches every link, and unregisters the worker. */
|
|
242
|
+
async stop(): Promise<void> {
|
|
243
|
+
for (const cleanup of this._cleanup.splice(0)) cleanup();
|
|
244
|
+
for (const worker of [...this._workers]) await worker.detach().catch(() => {});
|
|
245
|
+
const sw = this._serviceWorker;
|
|
246
|
+
if (sw) {
|
|
247
|
+
await callChannel(sw, DETACH_CALL, { key: this.key, id: this._id, owner: this._owner }).catch(
|
|
248
|
+
() => {},
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
await this._endpoint.unlink();
|
|
252
|
+
this._releaseOwner?.();
|
|
253
|
+
this._releaseOwner = undefined;
|
|
254
|
+
const registration = this._registration;
|
|
255
|
+
this._registration = undefined;
|
|
256
|
+
this._registrationPromise = undefined;
|
|
257
|
+
try {
|
|
258
|
+
await registration?.unregister();
|
|
259
|
+
} catch (error) {
|
|
260
|
+
console.log("Service Worker unregistration failed: ", error);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Registers an HTTP handler on a prefix. Returns the resulting base URL and a
|
|
266
|
+
* disposer.
|
|
267
|
+
*/
|
|
268
|
+
async register(prefix: string, handler: HttpHandler): Promise<SwHttpRegistration> {
|
|
269
|
+
const cleanPrefix = `./${(prefix ?? "").replace(/^[./]+/, "")}`;
|
|
270
|
+
const baseUrl = `${new URL(cleanPrefix, this.rootUrl)}`;
|
|
271
|
+
this._handlers.set(baseUrl, handler);
|
|
272
|
+
const handlers = this._handlers;
|
|
273
|
+
return {
|
|
274
|
+
baseUrl,
|
|
275
|
+
prefix: cleanPrefix,
|
|
276
|
+
async remove() {
|
|
277
|
+
handlers.delete(baseUrl);
|
|
278
|
+
},
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Links a Dedicated Worker that runs `serveHttpInWorker(handler)` as a
|
|
284
|
+
* handler of `key`. The ServiceWorker sends it requests directly, without
|
|
285
|
+
* this page relaying anything, so a busy or throttled page does not slow
|
|
286
|
+
* them down.
|
|
287
|
+
*/
|
|
288
|
+
async attachWorker(worker: Worker, options: AttachWorkerOptions = {}): Promise<WorkerLink> {
|
|
289
|
+
await this.start();
|
|
290
|
+
// The hello is repeated until the worker answers: a worker that calls
|
|
291
|
+
// `serveHttpInWorker` only after an `await` has no listener yet, and a
|
|
292
|
+
// message to a worker with no listener is lost.
|
|
293
|
+
const controls: MessagePort[] = [];
|
|
294
|
+
let retry: ReturnType<typeof setInterval> | undefined;
|
|
295
|
+
const answered = new Promise<{ control: MessagePort; ownerLock?: string }>(
|
|
296
|
+
(resolve, reject) => {
|
|
297
|
+
const timer = setTimeout(() => {
|
|
298
|
+
clearInterval(retry);
|
|
299
|
+
for (const port of controls) port.close();
|
|
300
|
+
reject(
|
|
301
|
+
new Error(
|
|
302
|
+
"The worker did not answer within the timeout. Does it call serveHttpInWorker()?",
|
|
303
|
+
),
|
|
304
|
+
);
|
|
305
|
+
}, this.options.timeout ?? DEFAULT_SERVICE_WORKER_TIMEOUT);
|
|
306
|
+
const hello = () => {
|
|
307
|
+
const channel = new MessageChannel();
|
|
308
|
+
controls.push(channel.port1);
|
|
309
|
+
channel.port1.onmessage = (event) => {
|
|
310
|
+
const message = event.data as WorkerControlMessage;
|
|
311
|
+
if (message?.type !== "ready") return;
|
|
312
|
+
clearTimeout(timer);
|
|
313
|
+
clearInterval(retry);
|
|
314
|
+
for (const port of controls) if (port !== channel.port1) port.close();
|
|
315
|
+
resolve({ control: channel.port1, ownerLock: message.ownerLock });
|
|
316
|
+
};
|
|
317
|
+
worker.postMessage({ type: WORKER_HELLO }, [channel.port2]);
|
|
318
|
+
};
|
|
319
|
+
hello();
|
|
320
|
+
retry = setInterval(hello, 500);
|
|
321
|
+
},
|
|
322
|
+
);
|
|
323
|
+
const { control, ownerLock } = await answered;
|
|
324
|
+
const key = options.key ?? this.key;
|
|
325
|
+
const id = options.id ?? `worker:${crypto.randomUUID()}`;
|
|
326
|
+
// Attaching a slot replaces whoever held it: the ServiceWorker lets the old
|
|
327
|
+
// one finish its requests, and this page must not attach it again later.
|
|
328
|
+
for (const old of [...this._workers]) {
|
|
329
|
+
if (old.key === key && old.id === id) old.retire();
|
|
330
|
+
}
|
|
331
|
+
const link = new WorkerLinkImpl(this, control, {
|
|
332
|
+
key,
|
|
333
|
+
id,
|
|
334
|
+
owner: crypto.randomUUID(),
|
|
335
|
+
meta: options.meta,
|
|
336
|
+
ownerLock,
|
|
337
|
+
});
|
|
338
|
+
this._workers.add(link);
|
|
339
|
+
await link.attach();
|
|
340
|
+
return link;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Opens the transport of a socket to the handler `url` routes to: a handler
|
|
345
|
+
* of this page directly, any other through the ServiceWorker. Resolves once
|
|
346
|
+
* the handler accepted. An arrow function, so it can be handed around:
|
|
347
|
+
* `createWebSocketClass(adapter.connectWebSocket)`.
|
|
348
|
+
*/
|
|
349
|
+
connectWebSocket = async (
|
|
350
|
+
url: string | URL,
|
|
351
|
+
protocols: string[] = [],
|
|
352
|
+
): Promise<WebSocketConnection> => {
|
|
353
|
+
const httpUrl = new URL(`${url}`, globalThis.location?.href);
|
|
354
|
+
if (httpUrl.protocol === "ws:") httpUrl.protocol = "http:";
|
|
355
|
+
else if (httpUrl.protocol === "wss:") httpUrl.protocol = "https:";
|
|
356
|
+
const local = this._findHandler(`${httpUrl}`);
|
|
357
|
+
if (local) {
|
|
358
|
+
// A handler in this very page needs no ServiceWorker.
|
|
359
|
+
const result = await serveWebSocketUpgrade(local, { url: `${httpUrl}`, protocols });
|
|
360
|
+
if (!result.ok) throw new Error(result.message);
|
|
361
|
+
return { port: result.port, protocol: result.protocol };
|
|
362
|
+
}
|
|
363
|
+
await this.start();
|
|
364
|
+
return await callChannelWithin<WebSocketConnection>(
|
|
365
|
+
this._callTimeout(),
|
|
366
|
+
this._worker(),
|
|
367
|
+
WS_CONNECT_CALL,
|
|
368
|
+
{ url: `${httpUrl}`, protocols },
|
|
369
|
+
);
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* A WHATWG `WebSocket` class whose sockets reach this adapter's handlers:
|
|
374
|
+
* `new adapter.WebSocket(`${baseUrl}chat`, ["v1"])`. The global
|
|
375
|
+
* `WebSocket` is left alone.
|
|
376
|
+
*/
|
|
377
|
+
get WebSocket(): PortWebSocketClass {
|
|
378
|
+
this._WebSocket ??= createWebSocketClass(this.connectWebSocket, this.options.webSocket);
|
|
379
|
+
return this._WebSocket;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Attaches again every link not made for the router holding
|
|
384
|
+
* `currentRouterLock` (every link, without it). The ServiceWorker asks for
|
|
385
|
+
* this after a restart; a page may call it too.
|
|
386
|
+
*/
|
|
387
|
+
async relink(currentRouterLock?: string): Promise<void> {
|
|
388
|
+
if (!this._registrationPromise) return;
|
|
389
|
+
const stale = (routerLock?: string) =>
|
|
390
|
+
currentRouterLock === undefined || routerLock !== currentRouterLock;
|
|
391
|
+
const jobs: Promise<void>[] = [];
|
|
392
|
+
if (stale(this._routerLock)) jobs.push(this._attachPage());
|
|
393
|
+
for (const worker of this._workers) {
|
|
394
|
+
if (stale(worker.routerLock)) jobs.push(worker.attach());
|
|
395
|
+
}
|
|
396
|
+
await Promise.allSettled(jobs);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** @internal Posts a plain message to the ServiceWorker, which starts it if stopped. */
|
|
400
|
+
_poke(type: typeof WAKE_MESSAGE | typeof KEEPALIVE_MESSAGE): void {
|
|
401
|
+
try {
|
|
402
|
+
this._worker().postMessage({ type });
|
|
403
|
+
} catch {}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** @internal */
|
|
407
|
+
_worker(): ServiceWorker {
|
|
408
|
+
const worker =
|
|
409
|
+
navigator.serviceWorker.controller ?? this._serviceWorker ?? this._registration?.active;
|
|
410
|
+
if (!worker) throw new Error("The ServiceWorker is not running; call start() first");
|
|
411
|
+
return worker;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** @internal */
|
|
415
|
+
_forget(link: WorkerLinkImpl): void {
|
|
416
|
+
this._workers.delete(link);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** @internal How long a call to the ServiceWorker may take. */
|
|
420
|
+
_callTimeout(): number {
|
|
421
|
+
return this.options.timeout ?? DEFAULT_SERVICE_WORKER_TIMEOUT;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
private _attachPage(): Promise<void> {
|
|
425
|
+
this._attaching ??= (async () => {
|
|
426
|
+
try {
|
|
427
|
+
const down = new MessageChannel();
|
|
428
|
+
const ctl = new MessageChannel();
|
|
429
|
+
const params: AttachParams = {
|
|
430
|
+
wire: HTTP_LINK_PROTOCOL,
|
|
431
|
+
key: this.key,
|
|
432
|
+
id: this._id,
|
|
433
|
+
owner: this._owner,
|
|
434
|
+
// Sent only when held for real: without Web Locks the ServiceWorker
|
|
435
|
+
// falls back to `clients.get()`, which an owner lock would skip.
|
|
436
|
+
ownerLock: this._releaseOwner && locksAvailable() ? this._ownerLock : undefined,
|
|
437
|
+
meta: this.options.meta,
|
|
438
|
+
ctl: true,
|
|
439
|
+
};
|
|
440
|
+
const reply = await callChannelWithin<AttachReply>(
|
|
441
|
+
this._callTimeout(),
|
|
442
|
+
this._worker(),
|
|
443
|
+
ATTACH_CALL,
|
|
444
|
+
params,
|
|
445
|
+
down.port1,
|
|
446
|
+
ctl.port1,
|
|
447
|
+
);
|
|
448
|
+
checkReply(reply, this._worker());
|
|
449
|
+
// The previous link is not dropped here: the endpoint lets it finish
|
|
450
|
+
// what it serves (see `HandlerEndpoint.link`).
|
|
451
|
+
await this._endpoint.link(this.key, { down: down.port2, ctl: ctl.port2 }, reply.routerLock);
|
|
452
|
+
this._routerLock = reply.routerLock;
|
|
453
|
+
} finally {
|
|
454
|
+
this._attaching = undefined;
|
|
455
|
+
}
|
|
456
|
+
})();
|
|
457
|
+
return this._attaching;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
private _findHandler(url: string): HttpHandler | undefined {
|
|
461
|
+
for (const [urlPrefix, handler] of this._handlers) {
|
|
462
|
+
if (url.indexOf(urlPrefix) === 0) return handler;
|
|
463
|
+
}
|
|
464
|
+
return undefined;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
private async _handleHttpRequest(request: Request): Promise<Response> {
|
|
468
|
+
const handler = this._findHandler(request.url);
|
|
469
|
+
if (handler) return await handler(request);
|
|
470
|
+
return new Response(null, { status: 404, statusText: "Error 404: Not found" });
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
class WorkerLinkImpl implements WorkerLink {
|
|
475
|
+
readonly key: string;
|
|
476
|
+
readonly id: string;
|
|
477
|
+
readonly owner: string;
|
|
478
|
+
routerLock?: string;
|
|
479
|
+
private readonly _meta: unknown;
|
|
480
|
+
private readonly _ownerLock?: string;
|
|
481
|
+
private _attaching?: Promise<void>;
|
|
482
|
+
private _detached = false;
|
|
483
|
+
|
|
484
|
+
constructor(
|
|
485
|
+
private readonly _adapter: SwHttpAdapter,
|
|
486
|
+
private readonly _control: MessagePort,
|
|
487
|
+
init: { key: string; id: string; owner: string; meta?: unknown; ownerLock?: string },
|
|
488
|
+
) {
|
|
489
|
+
this.key = init.key;
|
|
490
|
+
this.id = init.id;
|
|
491
|
+
this.owner = init.owner;
|
|
492
|
+
this._meta = init.meta;
|
|
493
|
+
this._ownerLock = init.ownerLock;
|
|
494
|
+
_control.onmessage = (event) => {
|
|
495
|
+
const message = event.data as WorkerControlMessage;
|
|
496
|
+
if (message?.type === "relink") {
|
|
497
|
+
// Starts the ServiceWorker if it was stopped; the attach follows.
|
|
498
|
+
this._adapter._poke(WAKE_MESSAGE);
|
|
499
|
+
void this.attach().catch(() => {});
|
|
500
|
+
} else if (message?.type === "keepalive") this._adapter._poke(KEEPALIVE_MESSAGE);
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Forgets this link without telling the ServiceWorker: another attachment
|
|
506
|
+
* took its slot, and the ServiceWorker retires it by itself.
|
|
507
|
+
*/
|
|
508
|
+
retire(): void {
|
|
509
|
+
this._detached = true;
|
|
510
|
+
this._adapter._forget(this);
|
|
511
|
+
this._control.onmessage = null;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/** Makes fresh channels and attaches them; the worker gets its ends. */
|
|
515
|
+
attach(): Promise<void> {
|
|
516
|
+
if (this._detached) return Promise.resolve();
|
|
517
|
+
this._attaching ??= (async () => {
|
|
518
|
+
try {
|
|
519
|
+
// A worker that was terminated holds no owner lock any more: attaching
|
|
520
|
+
// it again would route requests to it until the ServiceWorker noticed.
|
|
521
|
+
if (this._ownerLock) {
|
|
522
|
+
const held = await heldLockNames();
|
|
523
|
+
if (held && !held.has(this._ownerLock)) {
|
|
524
|
+
this.retire();
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
const down = new MessageChannel();
|
|
529
|
+
const ctl = new MessageChannel();
|
|
530
|
+
const up = new MessageChannel();
|
|
531
|
+
const params: AttachParams = {
|
|
532
|
+
wire: HTTP_LINK_PROTOCOL,
|
|
533
|
+
key: this.key,
|
|
534
|
+
id: this.id,
|
|
535
|
+
owner: this.owner,
|
|
536
|
+
ownerLock: this._ownerLock,
|
|
537
|
+
meta: this._meta,
|
|
538
|
+
ctl: true,
|
|
539
|
+
up: true,
|
|
540
|
+
};
|
|
541
|
+
const sw = this._adapter._worker();
|
|
542
|
+
const reply = await callChannelWithin<AttachReply>(
|
|
543
|
+
this._adapter._callTimeout(),
|
|
544
|
+
sw,
|
|
545
|
+
ATTACH_CALL,
|
|
546
|
+
params,
|
|
547
|
+
down.port1,
|
|
548
|
+
ctl.port1,
|
|
549
|
+
up.port1,
|
|
550
|
+
);
|
|
551
|
+
checkReply(reply, sw);
|
|
552
|
+
this.routerLock = reply.routerLock;
|
|
553
|
+
this._control.postMessage(
|
|
554
|
+
{
|
|
555
|
+
type: "link",
|
|
556
|
+
key: this.key,
|
|
557
|
+
routerLock: reply.routerLock,
|
|
558
|
+
} satisfies WorkerControlMessage,
|
|
559
|
+
[down.port2, ctl.port2, up.port2],
|
|
560
|
+
);
|
|
561
|
+
} finally {
|
|
562
|
+
this._attaching = undefined;
|
|
563
|
+
}
|
|
564
|
+
})();
|
|
565
|
+
return this._attaching;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
async drain({ ms }: { ms?: number } = {}): Promise<void> {
|
|
569
|
+
if (this._detached) return;
|
|
570
|
+
this._detached = true;
|
|
571
|
+
this._adapter._forget(this);
|
|
572
|
+
try {
|
|
573
|
+
await callChannel(this._adapter._worker(), DRAIN_CALL, {
|
|
574
|
+
key: this.key,
|
|
575
|
+
id: this.id,
|
|
576
|
+
owner: this.owner,
|
|
577
|
+
ms,
|
|
578
|
+
});
|
|
579
|
+
} finally {
|
|
580
|
+
this._unlink();
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
async detach(): Promise<void> {
|
|
585
|
+
if (this._detached) return;
|
|
586
|
+
this._detached = true;
|
|
587
|
+
this._adapter._forget(this);
|
|
588
|
+
await callChannel(this._adapter._worker(), DETACH_CALL, {
|
|
589
|
+
key: this.key,
|
|
590
|
+
id: this.id,
|
|
591
|
+
owner: this.owner,
|
|
592
|
+
}).catch(() => {});
|
|
593
|
+
this._unlink();
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
private _unlink(): void {
|
|
597
|
+
this._control.postMessage({ type: "unlink", key: this.key } satisfies WorkerControlMessage);
|
|
598
|
+
this._control.onmessage = null;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function checkReply(
|
|
603
|
+
reply: AttachReply | undefined,
|
|
604
|
+
sw: ServiceWorker,
|
|
605
|
+
): asserts reply is AttachReply {
|
|
606
|
+
if (reply?.accepted === HTTP_LINK_PROTOCOL) return;
|
|
607
|
+
throw new ServiceWorkerControlError(
|
|
608
|
+
"incompatible",
|
|
609
|
+
`ServiceWorker "${sw.scriptURL}" answered the handshake but does not speak ` +
|
|
610
|
+
`${HTTP_LINK_PROTOCOL}: it runs an older version of @statewalker/webrun-http-browser. ` +
|
|
611
|
+
"Reload the page once the updated worker is active.",
|
|
612
|
+
);
|
|
613
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { callChannel } from "../core/data-calls.js";
|
|
2
|
+
import {
|
|
3
|
+
createWebSocketClass,
|
|
4
|
+
type PortWebSocketClass,
|
|
5
|
+
type WebSocketClassOptions,
|
|
6
|
+
type WebSocketConnection,
|
|
7
|
+
} from "../ws/client.js";
|
|
8
|
+
import { WS_CONNECT_CALL } from "./protocol.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Opens the transport of a socket through the ServiceWorker controlling this
|
|
12
|
+
* page, for a page that has no adapter of its own: content a handler serves
|
|
13
|
+
* under the same-origin dispatcher, or under a relay. The ServiceWorker
|
|
14
|
+
* routes `url` like a fetch, and the handler accepts or refuses the upgrade.
|
|
15
|
+
*/
|
|
16
|
+
export async function connectWebSocketThroughServiceWorker(
|
|
17
|
+
url: string | URL,
|
|
18
|
+
protocols: string[] = [],
|
|
19
|
+
container: ServiceWorkerContainer = navigator.serviceWorker,
|
|
20
|
+
): Promise<WebSocketConnection> {
|
|
21
|
+
const worker = container.controller;
|
|
22
|
+
if (!worker) throw new Error("This page is not controlled by a ServiceWorker");
|
|
23
|
+
const httpUrl = new URL(`${url}`, globalThis.location?.href);
|
|
24
|
+
if (httpUrl.protocol === "ws:") httpUrl.protocol = "http:";
|
|
25
|
+
else if (httpUrl.protocol === "wss:") httpUrl.protocol = "https:";
|
|
26
|
+
return await callChannel<WebSocketConnection>(worker, WS_CONNECT_CALL, {
|
|
27
|
+
url: `${httpUrl}`,
|
|
28
|
+
protocols,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A WHATWG `WebSocket` class over `connectWebSocketThroughServiceWorker`.
|
|
34
|
+
* Install it where the page's code expects `WebSocket`, e.g.
|
|
35
|
+
* `globalThis.WebSocket = serviceWorkerWebSocketClass()` in content that only
|
|
36
|
+
* talks to its own handlers.
|
|
37
|
+
*/
|
|
38
|
+
export function serviceWorkerWebSocketClass(options?: WebSocketClassOptions): PortWebSocketClass {
|
|
39
|
+
return createWebSocketClass(
|
|
40
|
+
(url, protocols) => connectWebSocketThroughServiceWorker(url, protocols),
|
|
41
|
+
options,
|
|
42
|
+
);
|
|
43
|
+
}
|