@statewalker/webrun-http-browser 0.4.2 → 0.6.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/LICENSE +21 -0
- package/README.md +207 -12
- package/dist/core/deadline.d.ts +7 -0
- package/dist/core/deadline.d.ts.map +1 -0
- package/dist/core/index.d.ts +1 -0
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/service-worker-control.d.ts +72 -0
- package/dist/core/service-worker-control.d.ts.map +1 -0
- package/dist/index.js +328 -67
- package/dist/relay/index-sw.d.ts +86 -1
- package/dist/relay/index-sw.d.ts.map +1 -1
- package/dist/relay/index.d.ts +32 -8
- package/dist/relay/index.d.ts.map +1 -1
- package/dist/relay/mount-table.d.ts +48 -0
- package/dist/relay/mount-table.d.ts.map +1 -0
- package/dist/relay/split-service-url.d.ts.map +1 -1
- package/dist/relay-sw.js +293 -50
- package/dist/relay-worker.d.ts +20 -0
- package/dist/relay-worker.d.ts.map +1 -0
- package/dist/relay-worker.js +1899 -0
- package/dist/sw/sw-dispatcher.d.ts +22 -0
- package/dist/sw/sw-dispatcher.d.ts.map +1 -1
- package/dist/sw-worker.js +23 -2
- package/dist/sw.js +219 -36
- package/package.json +9 -1
- package/src/core/deadline.ts +31 -0
- package/src/core/index.ts +1 -0
- package/src/core/service-worker-control.ts +243 -0
- package/src/relay/index-sw.ts +293 -37
- package/src/relay/index.ts +125 -51
- package/src/relay/mount-table.ts +115 -0
- package/src/relay/split-service-url.ts +73 -13
- package/src/relay-sw.ts +11 -3
- package/src/relay-worker.ts +20 -0
- package/src/sw/sw-dispatcher.ts +72 -45
- package/public-relay/heartbeat.js +0 -31
package/src/relay/index.ts
CHANGED
|
@@ -1,19 +1,38 @@
|
|
|
1
1
|
import type { HttpHandler } from "@statewalker/webrun-http-streams";
|
|
2
|
-
import {
|
|
2
|
+
import { serializeError } from "@statewalker/webrun-streams";
|
|
3
|
+
import { type ChannelCallHandler, callChannel } from "../core/data-calls.js";
|
|
3
4
|
import type { MessageTarget } from "../core/message-target.js";
|
|
4
5
|
import { newRegistry } from "../core/registry.js";
|
|
6
|
+
import {
|
|
7
|
+
awaitActiveServiceWorker,
|
|
8
|
+
DEFAULT_SERVICE_WORKER_TIMEOUT,
|
|
9
|
+
} from "../core/service-worker-control.js";
|
|
5
10
|
import { handleHttpRequests, sendHttpRequest } from "../http/http-send-recieve.js";
|
|
6
11
|
|
|
7
12
|
export * from "./split-service-url.js";
|
|
8
13
|
|
|
14
|
+
/**
|
|
15
|
+
* The URL of this module, kept in a variable on purpose. Bundlers (Vite
|
|
16
|
+
* among them) rewrite every literal `new URL("<path>", import.meta.url)` into
|
|
17
|
+
* an emitted asset at build time, before tree-shaking — so the defaults below
|
|
18
|
+
* made every Vite consumer of this entry emit a dead copy of the package's
|
|
19
|
+
* own `dist/index.js` (`"../"` resolves to the package, hence to its `main`).
|
|
20
|
+
* Resolving against a variable is the same URL at run time and invisible to
|
|
21
|
+
* that transform.
|
|
22
|
+
*/
|
|
23
|
+
const moduleUrl: string = import.meta.url;
|
|
24
|
+
|
|
9
25
|
/**
|
|
10
26
|
* Returns a MessagePort that transparently bridges messages to/from the
|
|
11
|
-
* ServiceWorker controlling
|
|
27
|
+
* page's ServiceWorker: the one controlling the page, or — when the page is
|
|
28
|
+
* not controlled (a hard reload, or a page Firefox left uncontrolled) — the
|
|
29
|
+
* active worker of `registration`, which answers messages all the same.
|
|
12
30
|
*/
|
|
13
|
-
export function newServiceWorkerPort(): MessagePort {
|
|
31
|
+
export function newServiceWorkerPort(registration?: ServiceWorkerRegistration): MessagePort {
|
|
14
32
|
const channel = new MessageChannel();
|
|
15
33
|
channel.port1.onmessage = (event) => {
|
|
16
|
-
navigator.serviceWorker.controller?.
|
|
34
|
+
const worker = navigator.serviceWorker.controller ?? registration?.active;
|
|
35
|
+
worker?.postMessage(event.data, [...event.ports]);
|
|
17
36
|
};
|
|
18
37
|
navigator.serviceWorker.addEventListener("message", (event) => {
|
|
19
38
|
channel.port1.postMessage(event.data, [...event.ports]);
|
|
@@ -25,55 +44,47 @@ export interface InitServiceWorkerOptions {
|
|
|
25
44
|
swUrl: string;
|
|
26
45
|
scopeUrl?: string;
|
|
27
46
|
type?: WorkerType;
|
|
47
|
+
/**
|
|
48
|
+
* Upper bound, in ms, for the wait for the worker to activate; past it the
|
|
49
|
+
* promise rejects with a `ServiceWorkerControlError`. Default
|
|
50
|
+
* `DEFAULT_SERVICE_WORKER_TIMEOUT` (30 s).
|
|
51
|
+
*/
|
|
52
|
+
timeout?: number;
|
|
28
53
|
}
|
|
29
54
|
|
|
30
55
|
/**
|
|
31
|
-
* Registers a ServiceWorker and resolves once it
|
|
56
|
+
* Registers a ServiceWorker and resolves with it once it is activated: the
|
|
57
|
+
* worker controlling the page, or the registration's active worker when the
|
|
58
|
+
* page is not controlled. Messaging works either way, which is all the relay
|
|
59
|
+
* needs; nothing here waits for control, because an uncontrolled page (hard
|
|
60
|
+
* reload; Firefox) may never get it. Rejects with a
|
|
61
|
+
* `ServiceWorkerControlError` if activation takes longer than `timeout`.
|
|
32
62
|
*/
|
|
33
|
-
export async function initServiceWorker({
|
|
63
|
+
export async function initServiceWorker(options: InitServiceWorkerOptions): Promise<ServiceWorker> {
|
|
64
|
+
return (await registerServiceWorker(options)).worker;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function registerServiceWorker({
|
|
34
68
|
swUrl,
|
|
35
69
|
scopeUrl,
|
|
36
70
|
type,
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
return new Promise((resolve) => {
|
|
46
|
-
const container = navigator.serviceWorker;
|
|
47
|
-
if (container.controller) {
|
|
48
|
-
resolve(container.controller);
|
|
49
|
-
return;
|
|
50
|
-
}
|
|
51
|
-
const onChange = () => {
|
|
52
|
-
if (!container.controller) return;
|
|
53
|
-
resolve(container.controller);
|
|
54
|
-
container.removeEventListener("controllerchange", onChange);
|
|
55
|
-
};
|
|
56
|
-
container.addEventListener("controllerchange", onChange);
|
|
57
|
-
});
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function awaitServiceWorkerActivation(worker: ServiceWorker): Promise<void> {
|
|
61
|
-
return new Promise((resolve) => {
|
|
62
|
-
if (worker.state === "activated") {
|
|
63
|
-
resolve();
|
|
64
|
-
return;
|
|
65
|
-
}
|
|
66
|
-
const onStateChange = () => {
|
|
67
|
-
if (worker.state !== "activated") return;
|
|
68
|
-
worker.removeEventListener("statechange", onStateChange);
|
|
69
|
-
resolve();
|
|
70
|
-
};
|
|
71
|
-
worker.addEventListener("statechange", onStateChange);
|
|
72
|
-
});
|
|
71
|
+
timeout = DEFAULT_SERVICE_WORKER_TIMEOUT,
|
|
72
|
+
}: InitServiceWorkerOptions): Promise<{
|
|
73
|
+
registration: ServiceWorkerRegistration;
|
|
74
|
+
worker: ServiceWorker;
|
|
75
|
+
}> {
|
|
76
|
+
const registration = await navigator.serviceWorker.register(swUrl, { type, scope: scopeUrl });
|
|
77
|
+
const active = await awaitActiveServiceWorker(registration, { timeout });
|
|
78
|
+
return { registration, worker: navigator.serviceWorker.controller ?? active };
|
|
73
79
|
}
|
|
74
80
|
|
|
75
81
|
export interface ServiceOptions {
|
|
76
82
|
key: string;
|
|
83
|
+
/**
|
|
84
|
+
* Where this service is mounted on the relay origin, e.g. `/` or `/peers/`.
|
|
85
|
+
* Omitted, the service stays reachable at `/~<key>/`, as before mounts.
|
|
86
|
+
*/
|
|
87
|
+
path?: string;
|
|
77
88
|
port: MessageTarget;
|
|
78
89
|
}
|
|
79
90
|
|
|
@@ -83,10 +94,11 @@ export interface ServiceOptions {
|
|
|
83
94
|
*/
|
|
84
95
|
export async function initHttpService(
|
|
85
96
|
handler: HttpHandler,
|
|
86
|
-
{ key, port }: ServiceOptions,
|
|
97
|
+
{ key, path, port }: ServiceOptions,
|
|
87
98
|
): Promise<() => void> {
|
|
88
99
|
return await registerConnectionsHandler({
|
|
89
100
|
key,
|
|
101
|
+
path,
|
|
90
102
|
communicationPort: port,
|
|
91
103
|
handler: async (_event, _data, callPort) => {
|
|
92
104
|
handleHttpRequests(callPort, handler);
|
|
@@ -111,16 +123,21 @@ export async function callHttpService(
|
|
|
111
123
|
export interface RelayWindowHandlerOptions {
|
|
112
124
|
swUrl?: string;
|
|
113
125
|
scopeUrl?: string;
|
|
126
|
+
/** Passed to `initServiceWorker`: how long to wait for the relay worker to activate. */
|
|
127
|
+
timeout?: number;
|
|
114
128
|
}
|
|
115
129
|
|
|
116
130
|
/**
|
|
117
131
|
* Returns a `window.onmessage` handler for use inside the relay iframe:
|
|
118
132
|
* it accepts a CONNECT message, starts the relay ServiceWorker, and bridges
|
|
119
|
-
* the parent's MessagePort with the SW.
|
|
133
|
+
* the parent's MessagePort with the SW. If the worker cannot be started, every
|
|
134
|
+
* call the parent makes on that port is answered with the error, so the
|
|
135
|
+
* parent's `initHttpService` / `callHttpService` reject instead of waiting.
|
|
120
136
|
*/
|
|
121
137
|
export function getRelayWindowMessageHandler({
|
|
122
|
-
swUrl = `${new URL("./index-sw.js",
|
|
123
|
-
scopeUrl = `${new URL("../",
|
|
138
|
+
swUrl = `${new URL("./index-sw.js", moduleUrl)}`,
|
|
139
|
+
scopeUrl = `${new URL("../", moduleUrl)}`,
|
|
140
|
+
timeout,
|
|
124
141
|
}: RelayWindowHandlerOptions = {}): (ev: MessageEvent) => Promise<void> {
|
|
125
142
|
let externalPort: MessagePort | undefined;
|
|
126
143
|
return async (ev) => {
|
|
@@ -132,8 +149,15 @@ export function getRelayWindowMessageHandler({
|
|
|
132
149
|
return;
|
|
133
150
|
}
|
|
134
151
|
externalPort = newExternalPort;
|
|
135
|
-
|
|
136
|
-
|
|
152
|
+
let registration: ServiceWorkerRegistration;
|
|
153
|
+
try {
|
|
154
|
+
({ registration } = await registerServiceWorker({ swUrl, scopeUrl, timeout }));
|
|
155
|
+
} catch (error) {
|
|
156
|
+
const serialized = serializeError(error);
|
|
157
|
+
externalPort.onmessage = (event) => event.ports[0]?.postMessage({ error: serialized });
|
|
158
|
+
throw error;
|
|
159
|
+
}
|
|
160
|
+
const serviceWorkerPort = newServiceWorkerPort(registration);
|
|
137
161
|
serviceWorkerPort.onmessage = (event) => {
|
|
138
162
|
externalPort?.postMessage(event.data, [...event.ports]);
|
|
139
163
|
};
|
|
@@ -160,7 +184,7 @@ export interface RemoteRelayChannel {
|
|
|
160
184
|
* returns the port to be used with `initHttpService` / `callHttpService`.
|
|
161
185
|
*/
|
|
162
186
|
export async function newRemoteRelayChannel({
|
|
163
|
-
baseUrl = new URL("../public-relay/",
|
|
187
|
+
baseUrl = new URL("../public-relay/", moduleUrl),
|
|
164
188
|
url = new URL("relay.html", baseUrl),
|
|
165
189
|
container = document.body,
|
|
166
190
|
}: RemoteRelayChannelOptions = {}): Promise<RemoteRelayChannel> {
|
|
@@ -240,22 +264,72 @@ export async function initializeConnection({
|
|
|
240
264
|
|
|
241
265
|
export interface RegisterConnectionsHandlerOptions {
|
|
242
266
|
key: string;
|
|
267
|
+
/** Where this service is mounted; see `ServiceOptions.path`. */
|
|
268
|
+
path?: string;
|
|
243
269
|
handler: (event: MessageEvent, data: unknown, port: MessagePort) => boolean | Promise<boolean>;
|
|
244
270
|
communicationPort: MessageTarget;
|
|
245
271
|
}
|
|
246
272
|
|
|
247
273
|
export async function registerConnectionsHandler({
|
|
248
274
|
key,
|
|
275
|
+
path,
|
|
249
276
|
handler,
|
|
250
277
|
communicationPort,
|
|
251
278
|
}: RegisterConnectionsHandlerOptions): Promise<() => void> {
|
|
252
279
|
const [register, cleanup] = newRegistry();
|
|
253
|
-
|
|
280
|
+
// `path` is omitted rather than sent as undefined: the worker distinguishes
|
|
281
|
+
// "mounted at /" from "not mounted", and a key with no path keeps /~<key>/.
|
|
282
|
+
await callChannel(communicationPort, "REGISTER", path == null ? { key } : { key, path });
|
|
254
283
|
register(() => callChannel(communicationPort, "UNREGISTER", { key }));
|
|
255
284
|
register(
|
|
256
|
-
|
|
285
|
+
handleKeyedChannelCalls(communicationPort, "CONNECT", key, async (event, data, port) => {
|
|
257
286
|
return await handler(event, data, port);
|
|
258
287
|
}),
|
|
259
288
|
);
|
|
260
289
|
return cleanup;
|
|
261
290
|
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* `handleChannelCalls`, but only for calls whose `params.key` is `key`.
|
|
294
|
+
*
|
|
295
|
+
* ONE CONNECTION CARRIES SEVERAL SERVICES — an app at `/` and a mesh gateway
|
|
296
|
+
* at `/peers/` over one relay iframe is the shape mounts exist for. Plain
|
|
297
|
+
* `handleChannelCalls` cannot do that: every listener it has for a call type
|
|
298
|
+
* runs on every message, and each is handed the SAME reply port and the SAME
|
|
299
|
+
* transferred stream port. Two services then both serve the one channel the
|
|
300
|
+
* worker is reading, and its response comes back with both bodies in it.
|
|
301
|
+
*
|
|
302
|
+
* WHY NOT A FILTER INSIDE THE HANDLER. Returning `false` for a foreign key
|
|
303
|
+
* does not help: `handleChannelCalls` still replies, `callChannel` resolves on
|
|
304
|
+
* the FIRST reply it receives, and the loser's `false` reaches the worker as
|
|
305
|
+
* "the client refused" — a 403, non-deterministically. A service that is not
|
|
306
|
+
* the addressee must stay SILENT and leave the transferred port untouched.
|
|
307
|
+
*
|
|
308
|
+
* Local to this module on purpose. `handleChannelCalls` is also used where a
|
|
309
|
+
* call carries no key (REGISTER/UNREGISTER, and the worker's own CONNECT in
|
|
310
|
+
* `index-sw.ts`, which is the other direction), so its semantics must not
|
|
311
|
+
* change.
|
|
312
|
+
*/
|
|
313
|
+
function handleKeyedChannelCalls(
|
|
314
|
+
target: MessageTarget,
|
|
315
|
+
callType: string,
|
|
316
|
+
key: string,
|
|
317
|
+
handler: ChannelCallHandler,
|
|
318
|
+
): () => void {
|
|
319
|
+
const listener = async (event: MessageEvent) => {
|
|
320
|
+
const data = event.data as { type?: string; params?: { key?: unknown } } | null | undefined;
|
|
321
|
+
if (!data || data.type !== callType) return;
|
|
322
|
+
if (data.params?.key !== key) return;
|
|
323
|
+
const [port, ...transfers] = (event.ports ?? []) as MessagePort[];
|
|
324
|
+
const response: { result?: unknown; error?: unknown } = {};
|
|
325
|
+
try {
|
|
326
|
+
response.result = await handler(event, data.params, ...transfers);
|
|
327
|
+
} catch (error) {
|
|
328
|
+
response.error = serializeError(error);
|
|
329
|
+
}
|
|
330
|
+
port?.postMessage(response);
|
|
331
|
+
};
|
|
332
|
+
target.addEventListener("message", listener);
|
|
333
|
+
target.start?.();
|
|
334
|
+
return () => target.removeEventListener("message", listener);
|
|
335
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which registered service owns a URL.
|
|
3
|
+
*
|
|
4
|
+
* A pure lookup: no ServiceWorker, no storage, no I/O, so the routing rules
|
|
5
|
+
* can be tested as arithmetic rather than through a browser.
|
|
6
|
+
*
|
|
7
|
+
* TWO KINDS OF MOUNT, AND WHY BOTH. A `path` is a prefix, which is all most
|
|
8
|
+
* hosts need and costs nothing to match. A `match` predicate is the escape
|
|
9
|
+
* hatch for anything richer -- a host that wants URLPattern brings it and pays
|
|
10
|
+
* for it; this file must stay dependency-free, because it runs in a
|
|
11
|
+
* ServiceWorker that has to start fast.
|
|
12
|
+
*
|
|
13
|
+
* SPECIFICITY, NOT REGISTRATION ORDER. Prefixes are tried longest-first, so a
|
|
14
|
+
* catch-all at "/" cannot swallow "/peers/" and a host need not register in a
|
|
15
|
+
* careful order. Predicates are opaque -- nothing can be said about how
|
|
16
|
+
* specific they are -- so they are tried after every prefix, in the order they
|
|
17
|
+
* were registered.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export interface MountSpec {
|
|
21
|
+
/** A path prefix, e.g. `/peers/`. `/` is the whole origin. */
|
|
22
|
+
path?: string;
|
|
23
|
+
/** Anything richer. Consulted only when no prefix matches. */
|
|
24
|
+
match?: (url: URL) => boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface MountTable {
|
|
28
|
+
/** Add or replace the mount for `key`. */
|
|
29
|
+
set(key: string, spec: MountSpec): void;
|
|
30
|
+
remove(key: string): void;
|
|
31
|
+
/** The key that owns `url`, or `undefined` — meaning "not the relay's". */
|
|
32
|
+
find(url: URL): string | undefined;
|
|
33
|
+
/**
|
|
34
|
+
* Is `url` reserved by `exclude`? `find` already applies it, but the relay
|
|
35
|
+
* has a SECOND route — the `/~<key>/` spelling, which does not go through
|
|
36
|
+
* the table at all — and "an excluded path is never claimed" has to hold
|
|
37
|
+
* for both. The predicate lives here so there is one copy of it.
|
|
38
|
+
*/
|
|
39
|
+
excludes(url: URL): boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface MountTableOptions {
|
|
43
|
+
/**
|
|
44
|
+
* Paths the relay never claims, checked BEFORE the table. A root mount
|
|
45
|
+
* matches every path, so a host with files of its own (a relay page, a
|
|
46
|
+
* worker, hashed assets) is unusable without this.
|
|
47
|
+
*/
|
|
48
|
+
exclude?: (url: URL) => boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface Entry {
|
|
52
|
+
key: string;
|
|
53
|
+
/** `""` for a predicate-only mount; otherwise `/` or `/a/b/`. */
|
|
54
|
+
prefix: string;
|
|
55
|
+
match?: (url: URL) => boolean;
|
|
56
|
+
/** Registration order, to break ties between predicates. */
|
|
57
|
+
seq: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** `/` stays `/`; `/peers` and `/peers/` both become `/peers/`. */
|
|
61
|
+
function normalise(path: string): string {
|
|
62
|
+
if (path === "" || path === "/") return "/";
|
|
63
|
+
const withSlash = path.startsWith("/") ? path : `/${path}`;
|
|
64
|
+
return withSlash.endsWith("/") ? withSlash : `${withSlash}/`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Does `prefix` own `pathname`? `/peers/` owns `/peers/`, `/peers` and `/peers/x`. */
|
|
68
|
+
function owns(prefix: string, pathname: string): boolean {
|
|
69
|
+
if (prefix === "/") return true;
|
|
70
|
+
if (pathname.startsWith(prefix)) return true;
|
|
71
|
+
return `${pathname}/` === prefix;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function newMountTable(options: MountTableOptions = {}): MountTable {
|
|
75
|
+
const entries = new Map<string, Entry>();
|
|
76
|
+
let seq = 0;
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
set(key, spec) {
|
|
80
|
+
entries.set(key, {
|
|
81
|
+
key,
|
|
82
|
+
prefix: spec.path == null ? "" : normalise(spec.path),
|
|
83
|
+
match: spec.match,
|
|
84
|
+
seq: seq++,
|
|
85
|
+
});
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
remove(key) {
|
|
89
|
+
entries.delete(key);
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
excludes(url) {
|
|
93
|
+
return options.exclude?.(url) === true;
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
find(url) {
|
|
97
|
+
if (options.exclude?.(url) === true) return undefined;
|
|
98
|
+
|
|
99
|
+
let best: Entry | undefined;
|
|
100
|
+
for (const entry of entries.values()) {
|
|
101
|
+
if (entry.prefix === "" || !owns(entry.prefix, url.pathname)) continue;
|
|
102
|
+
if (best == null || entry.prefix.length > best.prefix.length) best = entry;
|
|
103
|
+
}
|
|
104
|
+
if (best != null) return best.key;
|
|
105
|
+
|
|
106
|
+
const predicates = [...entries.values()]
|
|
107
|
+
.filter((entry) => entry.match != null)
|
|
108
|
+
.sort((a, b) => a.seq - b.seq);
|
|
109
|
+
for (const entry of predicates) {
|
|
110
|
+
if (entry.match?.(url) === true) return entry.key;
|
|
111
|
+
}
|
|
112
|
+
return undefined;
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
@@ -12,19 +12,79 @@ export interface SplitServiceUrl {
|
|
|
12
12
|
*/
|
|
13
13
|
export function splitServiceUrl(url: URL | string, separator = "~"): SplitServiceUrl {
|
|
14
14
|
const str = `${url}`;
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
15
|
+
const empty = { url: str, key: "", baseUrl: "", path: "" };
|
|
16
|
+
|
|
17
|
+
// Strip query and fragment to prevent false positives on `?q=~foo` or `#~FS`.
|
|
18
|
+
// Extract the prefix (origin) and path from the input verbatim, preserving case
|
|
19
|
+
// and form (e.g., `//host` vs. `http://host`, `HTTPS://` vs. `https://`).
|
|
20
|
+
const hashIdx = str.indexOf("#");
|
|
21
|
+
const queryIdx = str.indexOf("?");
|
|
22
|
+
let strippedEnd = str.length;
|
|
23
|
+
if (hashIdx >= 0) strippedEnd = Math.min(strippedEnd, hashIdx);
|
|
24
|
+
if (queryIdx >= 0) strippedEnd = Math.min(strippedEnd, queryIdx);
|
|
25
|
+
const stripped = str.slice(0, strippedEnd);
|
|
26
|
+
|
|
27
|
+
// Identify where the path begins in the input. Three cases:
|
|
28
|
+
// 1. scheme://authority/path — prefix is scheme://authority
|
|
29
|
+
// 2. //authority/path — prefix is //authority
|
|
30
|
+
// 3. relative path — prefix is empty
|
|
31
|
+
let prefixEnd = 0;
|
|
32
|
+
const schemeMatch = stripped.match(/^[a-zA-Z][a-zA-Z0-9+\-.]*:\/\//);
|
|
33
|
+
if (schemeMatch) {
|
|
34
|
+
// scheme://authority/path — find the next / after the scheme
|
|
35
|
+
prefixEnd = schemeMatch[0].length;
|
|
36
|
+
const slashIdx = stripped.indexOf("/", prefixEnd);
|
|
37
|
+
if (slashIdx >= 0) {
|
|
38
|
+
prefixEnd = slashIdx;
|
|
39
|
+
} else {
|
|
40
|
+
return empty; // No path
|
|
41
|
+
}
|
|
42
|
+
} else if (stripped.startsWith("//")) {
|
|
43
|
+
// //authority/path — find the next / after the //
|
|
44
|
+
prefixEnd = 2;
|
|
45
|
+
const slashIdx = stripped.indexOf("/", prefixEnd);
|
|
46
|
+
if (slashIdx >= 0) {
|
|
47
|
+
prefixEnd = slashIdx;
|
|
48
|
+
} else {
|
|
49
|
+
return empty; // No path
|
|
50
|
+
}
|
|
28
51
|
}
|
|
52
|
+
// else prefixEnd = 0: relative URL
|
|
53
|
+
|
|
54
|
+
const prefix = stripped.slice(0, prefixEnd);
|
|
55
|
+
const pathPart = stripped.slice(prefixEnd);
|
|
56
|
+
|
|
57
|
+
// Check if pathPart starts with the separator, anchored at the segment boundary.
|
|
58
|
+
// With an authority the path always begins with `/`, so the separator must be
|
|
59
|
+
// at `/<separator>`. Without one the input is either ROOT-RELATIVE
|
|
60
|
+
// (`/~FS/a/b` -- `location.pathname`, or any root-absolute href) or plain
|
|
61
|
+
// relative (`~FS/a/b`), and both are accepted: treating "no authority" as
|
|
62
|
+
// "relative" made every root-relative caller get nothing back.
|
|
63
|
+
let keyStart: number;
|
|
64
|
+
let rooted = false;
|
|
65
|
+
if (prefix === "") {
|
|
66
|
+
if (pathPart.startsWith(`/${separator}`)) {
|
|
67
|
+
rooted = true;
|
|
68
|
+
keyStart = separator.length + 1;
|
|
69
|
+
} else if (pathPart.startsWith(separator)) {
|
|
70
|
+
keyStart = separator.length;
|
|
71
|
+
} else {
|
|
72
|
+
return empty;
|
|
73
|
+
}
|
|
74
|
+
} else {
|
|
75
|
+
if (!pathPart.startsWith(`/${separator}`)) return empty;
|
|
76
|
+
keyStart = separator.length + 1;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const rest = pathPart.slice(keyStart);
|
|
80
|
+
const slash = rest.indexOf("/");
|
|
81
|
+
const key = slash < 0 ? rest : rest.slice(0, slash);
|
|
82
|
+
if (key === "") return empty;
|
|
83
|
+
|
|
84
|
+
// The base keeps the input's own form: `https://host/~FS/`, `//host/~FS/`,
|
|
85
|
+
// `/~FS/` and `~FS/` each round-trip as written.
|
|
86
|
+
const base = prefix === "" ? (rooted ? "/" : "") : `${prefix}/`;
|
|
87
|
+
const baseUrl = `${base}${separator}${key}${slash < 0 ? "" : "/"}`;
|
|
88
|
+
const path = slash < 0 ? "" : rest.slice(slash + 1);
|
|
29
89
|
return { url: str, key, baseUrl, path };
|
|
30
90
|
}
|
package/src/relay-sw.ts
CHANGED
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
/// <reference lib="webworker" />
|
|
2
2
|
|
|
3
|
-
import { startRelayServiceWorker } from "./relay/index-sw.js";
|
|
3
|
+
import { type RelayServiceWorkerOptions, startRelayServiceWorker } from "./relay/index-sw.js";
|
|
4
4
|
|
|
5
|
-
declare const self: ServiceWorkerGlobalScope;
|
|
5
|
+
declare const self: ServiceWorkerGlobalScope & { RELAY_OPTIONS?: RelayServiceWorkerOptions };
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
// OPTIONS FROM A GLOBAL, because this bundle is loaded by `importScripts` from
|
|
8
|
+
// a host's own worker script, which runs first and can set them. A classic
|
|
9
|
+
// worker cannot pass arguments any other way, and module service workers are
|
|
10
|
+
// not reachable through the relay page's registration (the bundle is IIFE, and
|
|
11
|
+
// `getRelayWindowMessageHandler` registers with no `type`).
|
|
12
|
+
//
|
|
13
|
+
// Absent, the options are `{}` -- exactly what this file passed before, so a
|
|
14
|
+
// host that only `importScripts`es the bundle sees no change.
|
|
15
|
+
startRelayServiceWorker(self, self.RELAY_OPTIONS ?? {});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The relay ServiceWorker runtime, for a host that builds its own worker.
|
|
3
|
+
*
|
|
4
|
+
* TWO WAYS TO SHIP THE RELAY WORKER, and this is the typed one. A host that
|
|
5
|
+
* takes the prebuilt bundle loads `@statewalker/webrun-http-browser/relay-sw`
|
|
6
|
+
* through classic `importScripts` and passes its options in
|
|
7
|
+
* `self.RELAY_OPTIONS`, which cannot be typed from the outside because that
|
|
8
|
+
* bundle ships no declarations. A host that bundles its own worker imports
|
|
9
|
+
* this module instead and calls `startRelayServiceWorker(self, { … })`
|
|
10
|
+
* directly, with `RelayServiceWorkerOptions` to type the options — including
|
|
11
|
+
* `self.RELAY_OPTIONS`, for a host writing the tiny loader script by hand.
|
|
12
|
+
*
|
|
13
|
+
* Kept as its own entry rather than re-exported from the package root: this
|
|
14
|
+
* code only runs inside a ServiceWorker, and the root entry is what pages
|
|
15
|
+
* import.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export type { RelayServiceWorkerOptions } from "./relay/index-sw.js";
|
|
19
|
+
export { startRelayServiceWorker } from "./relay/index-sw.js";
|
|
20
|
+
export type { MountSpec } from "./relay/mount-table.js";
|
package/src/sw/sw-dispatcher.ts
CHANGED
|
@@ -1,12 +1,32 @@
|
|
|
1
1
|
import { get, set } from "idb-keyval";
|
|
2
2
|
import { callChannel, handleChannelCalls } from "../core/data-calls.js";
|
|
3
|
+
import { withDeadline } from "../core/deadline.js";
|
|
3
4
|
import { newRegistry } from "../core/registry.js";
|
|
5
|
+
import {
|
|
6
|
+
awaitServiceWorkerControl,
|
|
7
|
+
DEFAULT_SERVICE_WORKER_TIMEOUT,
|
|
8
|
+
handleClaimRequests,
|
|
9
|
+
ServiceWorkerControlError,
|
|
10
|
+
} from "../core/service-worker-control.js";
|
|
4
11
|
|
|
5
12
|
export interface SwPortHandlerOptions {
|
|
6
13
|
key: string;
|
|
7
14
|
scope?: string;
|
|
8
15
|
serviceWorkerUrl?: string;
|
|
9
16
|
bindPort: (port: MessagePort) => void | Promise<void>;
|
|
17
|
+
/**
|
|
18
|
+
* Upper bound, in ms, for `start()`'s wait for the worker to activate, take
|
|
19
|
+
* control of the page and answer the handshake. `start()` rejects with a
|
|
20
|
+
* `ServiceWorkerControlError` past it instead of waiting forever.
|
|
21
|
+
* Default `DEFAULT_SERVICE_WORKER_TIMEOUT` (30 s).
|
|
22
|
+
*/
|
|
23
|
+
timeout?: number;
|
|
24
|
+
/**
|
|
25
|
+
* If the page is still uncontrolled once the worker is active — after a
|
|
26
|
+
* hard reload, say — and the worker does not take it over when asked,
|
|
27
|
+
* reload the page once instead of rejecting. Default `false`.
|
|
28
|
+
*/
|
|
29
|
+
reloadIfUncontrolled?: boolean;
|
|
10
30
|
}
|
|
11
31
|
|
|
12
32
|
interface ChannelInfo {
|
|
@@ -84,6 +104,15 @@ export class SwPortHandler {
|
|
|
84
104
|
return { key: this.key };
|
|
85
105
|
}
|
|
86
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Registers the worker and waits until it is activated and controls this
|
|
109
|
+
* page, then opens the port to it. The page must be controlled, because
|
|
110
|
+
* only a controlled page's `fetch()` reaches the worker. When the page
|
|
111
|
+
* loaded uncontrolled (a hard reload does that), the worker is asked to
|
|
112
|
+
* claim it. Rejects with a `ServiceWorkerControlError` when that fails or
|
|
113
|
+
* `timeout` passes (`reason` says which step); a later `start()` tries
|
|
114
|
+
* again.
|
|
115
|
+
*/
|
|
87
116
|
async start(): Promise<void> {
|
|
88
117
|
if (!this._registrationPromise) {
|
|
89
118
|
this._registrationPromise = (async () => {
|
|
@@ -97,20 +126,44 @@ export class SwPortHandler {
|
|
|
97
126
|
});
|
|
98
127
|
register(() => registration.unregister());
|
|
99
128
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
},
|
|
108
|
-
),
|
|
129
|
+
const stopListening = handleChannelCalls(
|
|
130
|
+
navigator.serviceWorker,
|
|
131
|
+
"UPDATE_COMMUNICATION_PORT",
|
|
132
|
+
async (_event, _params, port: MessagePort) => {
|
|
133
|
+
await this._setCommunicationPort(port);
|
|
134
|
+
return this._getRegistrationInfo();
|
|
135
|
+
},
|
|
109
136
|
);
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
137
|
+
register(stopListening);
|
|
138
|
+
|
|
139
|
+
const timeout = this.options.timeout ?? DEFAULT_SERVICE_WORKER_TIMEOUT;
|
|
140
|
+
const deadline = Date.now() + timeout;
|
|
141
|
+
try {
|
|
142
|
+
this._serviceWorker = await awaitServiceWorkerControl(registration, {
|
|
143
|
+
timeout,
|
|
144
|
+
reloadIfUncontrolled: this.options.reloadIfUncontrolled,
|
|
145
|
+
});
|
|
146
|
+
await withDeadline(
|
|
147
|
+
deadline,
|
|
148
|
+
this._updateCommunicationChannel(),
|
|
149
|
+
() =>
|
|
150
|
+
new ServiceWorkerControlError(
|
|
151
|
+
"unresponsive",
|
|
152
|
+
`ServiceWorker "${this._serviceWorker?.scriptURL}" controls this page but did ` +
|
|
153
|
+
`not answer the UPDATE_COMMUNICATION_PORT handshake within ${timeout} ms. ` +
|
|
154
|
+
"Check that the worker script runs this package's same-origin dispatcher " +
|
|
155
|
+
"(it importScripts `sw-worker.js`, or calls `startHttpDispatcher`).",
|
|
156
|
+
),
|
|
157
|
+
);
|
|
158
|
+
} catch (error) {
|
|
159
|
+
// Forget this attempt so a later start() retries. Stop listening,
|
|
160
|
+
// but keep the registration: the worker is fine — it is this page
|
|
161
|
+
// that is not controlled, and other pages may be using the worker.
|
|
162
|
+
stopListening();
|
|
163
|
+
this._cleanupRegistrations = undefined;
|
|
164
|
+
this._registrationPromise = undefined;
|
|
165
|
+
throw error;
|
|
166
|
+
}
|
|
114
167
|
})();
|
|
115
168
|
}
|
|
116
169
|
return this._registrationPromise;
|
|
@@ -129,37 +182,6 @@ export class SwPortHandler {
|
|
|
129
182
|
}
|
|
130
183
|
}
|
|
131
184
|
|
|
132
|
-
function getServiceWorkerController(): Promise<ServiceWorker> {
|
|
133
|
-
return new Promise((resolve) => {
|
|
134
|
-
const container = navigator.serviceWorker;
|
|
135
|
-
if (container.controller) {
|
|
136
|
-
resolve(container.controller);
|
|
137
|
-
return;
|
|
138
|
-
}
|
|
139
|
-
const onChange = () => {
|
|
140
|
-
if (!container.controller) return;
|
|
141
|
-
resolve(container.controller);
|
|
142
|
-
container.removeEventListener("controllerchange", onChange);
|
|
143
|
-
};
|
|
144
|
-
container.addEventListener("controllerchange", onChange);
|
|
145
|
-
});
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
function awaitServiceWorkerActivation(worker: ServiceWorker): Promise<void> {
|
|
149
|
-
return new Promise((resolve) => {
|
|
150
|
-
if (worker.state === "activated") {
|
|
151
|
-
resolve();
|
|
152
|
-
return;
|
|
153
|
-
}
|
|
154
|
-
const onStateChange = () => {
|
|
155
|
-
if (worker.state !== "activated") return;
|
|
156
|
-
worker.removeEventListener("statechange", onStateChange);
|
|
157
|
-
resolve();
|
|
158
|
-
};
|
|
159
|
-
worker.addEventListener("statechange", onStateChange);
|
|
160
|
-
});
|
|
161
|
-
}
|
|
162
|
-
|
|
163
185
|
export interface SwPortDispatcherOptions {
|
|
164
186
|
self: ServiceWorkerGlobalScope;
|
|
165
187
|
log?: (...args: unknown[]) => void;
|
|
@@ -221,7 +243,8 @@ export class SwPortDispatcher {
|
|
|
221
243
|
}
|
|
222
244
|
|
|
223
245
|
start(): void {
|
|
224
|
-
|
|
246
|
+
const stopClaims = handleClaimRequests(this.self);
|
|
247
|
+
const stopPortUpdates = handleChannelCalls(
|
|
225
248
|
this.self,
|
|
226
249
|
"UPDATE_COMMUNICATION_PORT",
|
|
227
250
|
async (event, channelInfo, port: MessagePort) => {
|
|
@@ -232,6 +255,10 @@ export class SwPortDispatcher {
|
|
|
232
255
|
return { ...(channelInfo as ChannelInfo) };
|
|
233
256
|
},
|
|
234
257
|
);
|
|
258
|
+
this._cleanup = () => {
|
|
259
|
+
stopClaims();
|
|
260
|
+
stopPortUpdates();
|
|
261
|
+
};
|
|
235
262
|
|
|
236
263
|
this.self.addEventListener("install", (event) => {
|
|
237
264
|
this.log("Skip waiting on install.", event);
|