@statewalker/webrun-http-browser 0.5.0 → 0.6.2
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 +127 -5
- package/dist/index.js +92 -20
- 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 +9 -2
- 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 +275 -49
- 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 +8 -0
- package/dist/sw/sw-dispatcher.d.ts.map +1 -1
- package/dist/sw.js +10 -1
- package/package.json +19 -10
- package/src/relay/index-sw.ts +288 -37
- package/src/relay/index.ts +60 -4
- 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 +10 -1
- package/public-relay/heartbeat.js +0 -31
|
@@ -35,6 +35,14 @@ export declare class SwPortHandler {
|
|
|
35
35
|
constructor(options: SwPortHandlerOptions);
|
|
36
36
|
get key(): string;
|
|
37
37
|
get scope(): string;
|
|
38
|
+
/**
|
|
39
|
+
* The scope as a url on the worker's origin. Resolved against the worker's
|
|
40
|
+
* own url when one is given, not against this module's: a bundle that is
|
|
41
|
+
* not an ES module (an IIFE, Theia's frontend) has no `import.meta.url`, and
|
|
42
|
+
* `new URL(scope, undefined)` threw `Invalid URL` there. Only without a
|
|
43
|
+
* worker url does the module's location decide (its default `index-sw.js`
|
|
44
|
+
* sits next to it).
|
|
45
|
+
*/
|
|
38
46
|
get rootUrl(): URL;
|
|
39
47
|
get serviceWorkerUrl(): string;
|
|
40
48
|
protected _newCommunicationChannel(): Promise<[MessagePort, MessagePort]>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sw-dispatcher.d.ts","sourceRoot":"","sources":["../../src/sw/sw-dispatcher.ts"],"names":[],"mappings":"AAWA,MAAM,WAAW,oBAAoB;IACnC,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,QAAQ,EAAE,CAAC,IAAI,EAAE,WAAW,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACtD;;;;;OAKG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB;;;;OAIG;IACH,oBAAoB,CAAC,EAAE,OAAO,CAAC;CAChC;AAED,UAAU,WAAW;IACnB,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,IAAI,CAAC,EAAE,WAAW,CAAC;CACpB;AAED;;;GAGG;AACH,qBAAa,aAAa;IACxB,QAAQ,CAAC,OAAO,EAAE,oBAAoB,CAAC;IACvC,OAAO,CAAC,iBAAiB,CAAC,CAAS;IACnC,OAAO,CAAC,cAAc,CAAC,CAAgB;IACvC,OAAO,CAAC,oBAAoB,CAAC,CAAgB;IAC7C,OAAO,CAAC,qBAAqB,CAAC,CAAa;IAE3C,YAAY,OAAO,EAAE,oBAAoB,EAIxC;IAED,IAAI,GAAG,IAAI,MAAM,CAEhB;IAED,IAAI,KAAK,IAAI,MAAM,CAElB;IAED,IAAI,OAAO,IAAI,GAAG,
|
|
1
|
+
{"version":3,"file":"sw-dispatcher.d.ts","sourceRoot":"","sources":["../../src/sw/sw-dispatcher.ts"],"names":[],"mappings":"AAWA,MAAM,WAAW,oBAAoB;IACnC,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,QAAQ,EAAE,CAAC,IAAI,EAAE,WAAW,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACtD;;;;;OAKG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB;;;;OAIG;IACH,oBAAoB,CAAC,EAAE,OAAO,CAAC;CAChC;AAED,UAAU,WAAW;IACnB,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,IAAI,CAAC,EAAE,WAAW,CAAC;CACpB;AAED;;;GAGG;AACH,qBAAa,aAAa;IACxB,QAAQ,CAAC,OAAO,EAAE,oBAAoB,CAAC;IACvC,OAAO,CAAC,iBAAiB,CAAC,CAAS;IACnC,OAAO,CAAC,cAAc,CAAC,CAAgB;IACvC,OAAO,CAAC,oBAAoB,CAAC,CAAgB;IAC7C,OAAO,CAAC,qBAAqB,CAAC,CAAa;IAE3C,YAAY,OAAO,EAAE,oBAAoB,EAIxC;IAED,IAAI,GAAG,IAAI,MAAM,CAEhB;IAED,IAAI,KAAK,IAAI,MAAM,CAElB;IAED;;;;;;;OAOG;IACH,IAAI,OAAO,IAAI,GAAG,CAGjB;IAED,IAAI,gBAAgB,IAAI,MAAM,CAa7B;IAED,UAAgB,wBAAwB,IAAI,OAAO,CAAC,CAAC,WAAW,EAAE,WAAW,CAAC,CAAC,CAG9E;IAED,UAAgB,qBAAqB,CAAC,IAAI,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC,CAEtE;IAED,UAAgB,2BAA2B,IAAI,OAAO,CAAC,IAAI,CAAC,CAU3D;IAED,SAAS,CAAC,oBAAoB,IAAI,WAAW,CAE5C;IAED;;;;;;;;OAQG;IACG,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAsD3B;IAEK,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAU1B;CACF;AAED,MAAM,WAAW,uBAAuB;IACtC,IAAI,EAAE,wBAAwB,CAAC;IAC/B,GAAG,CAAC,EAAE,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,IAAI,CAAC;CACpC;AAED;;;GAGG;AACH,qBAAa,gBAAgB;IAC3B,QAAQ,CAAC,IAAI,EAAE,wBAAwB,CAAC;IACxC,QAAQ,CAAC,GAAG,EAAE,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,IAAI,CAAC;IAC3C,QAAQ,CAAC,aAAa,2BAAkC;IACxD;;;;;OAKG;IACH,QAAQ,CAAC,WAAW,cAAqB;IACzC,OAAO,CAAC,QAAQ,CAAC,CAAa;IAC9B,OAAO,CAAC,kBAAkB,CAAC,CAAgB;IAE3C,YAAY,EAAE,IAAI,EAAE,GAAc,EAAE,EAAE,uBAAuB,EAG5D;IAED,IAAI,KAAK,IAAI,MAAM,CAElB;IAED;;;;OAIG;IACG,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,GAAG,SAAS,CAAC,CAgBnE;IAED,yDAAyD;IACzD,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAEjC;IAED,KAAK,IAAI,IAAI,CAgCZ;IAEK,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAK1B;IAED,OAAO,CAAC,gBAAgB;YAOV,SAAS;YAgBT,kBAAkB;YAWlB,cAAc;YAOd,gBAAgB;CAY/B"}
|
package/dist/sw.js
CHANGED
|
@@ -1765,8 +1765,17 @@ var SwPortHandler = class {
|
|
|
1765
1765
|
get scope() {
|
|
1766
1766
|
return this.options.scope ?? new URL("./", this.serviceWorkerUrl).pathname;
|
|
1767
1767
|
}
|
|
1768
|
+
/**
|
|
1769
|
+
* The scope as a url on the worker's origin. Resolved against the worker's
|
|
1770
|
+
* own url when one is given, not against this module's: a bundle that is
|
|
1771
|
+
* not an ES module (an IIFE, Theia's frontend) has no `import.meta.url`, and
|
|
1772
|
+
* `new URL(scope, undefined)` threw `Invalid URL` there. Only without a
|
|
1773
|
+
* worker url does the module's location decide (its default `index-sw.js`
|
|
1774
|
+
* sits next to it).
|
|
1775
|
+
*/
|
|
1768
1776
|
get rootUrl() {
|
|
1769
|
-
|
|
1777
|
+
const base = this.options.serviceWorkerUrl ? this.serviceWorkerUrl : import.meta.url;
|
|
1778
|
+
return new URL(this.scope, base);
|
|
1770
1779
|
}
|
|
1771
1780
|
get serviceWorkerUrl() {
|
|
1772
1781
|
if (!this._serviceWorkerUrl) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@statewalker/webrun-http-browser",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.2",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "ServiceWorker-based HTTP server for browsers, with relay and same-origin dispatch modes",
|
|
@@ -19,17 +19,26 @@
|
|
|
19
19
|
"types": "./dist/index.d.ts",
|
|
20
20
|
"exports": {
|
|
21
21
|
".": {
|
|
22
|
+
"source": "./src/index.ts",
|
|
22
23
|
"types": "./dist/index.d.ts",
|
|
23
24
|
"import": "./dist/index.js"
|
|
24
25
|
},
|
|
25
26
|
"./sw": {
|
|
27
|
+
"source": "./src/sw.ts",
|
|
26
28
|
"types": "./dist/sw.d.ts",
|
|
27
29
|
"import": "./dist/sw.js"
|
|
28
30
|
},
|
|
29
31
|
"./relay-sw": {
|
|
32
|
+
"source": "./src/relay-sw.ts",
|
|
30
33
|
"default": "./dist/relay-sw.js"
|
|
31
34
|
},
|
|
35
|
+
"./relay-worker": {
|
|
36
|
+
"source": "./src/relay-worker.ts",
|
|
37
|
+
"types": "./dist/relay-worker.d.ts",
|
|
38
|
+
"import": "./dist/relay-worker.js"
|
|
39
|
+
},
|
|
32
40
|
"./sw-worker": {
|
|
41
|
+
"source": "./src/sw-worker.ts",
|
|
33
42
|
"default": "./dist/sw-worker.js"
|
|
34
43
|
}
|
|
35
44
|
},
|
|
@@ -42,20 +51,20 @@
|
|
|
42
51
|
],
|
|
43
52
|
"dependencies": {
|
|
44
53
|
"idb-keyval": "^6.3.0",
|
|
45
|
-
"@statewalker/webrun-http-streams": "0.2.
|
|
46
|
-
"@statewalker/webrun-
|
|
47
|
-
"@statewalker/webrun-
|
|
54
|
+
"@statewalker/webrun-http-streams": "^0.2.4",
|
|
55
|
+
"@statewalker/webrun-rpc": "^0.4.2",
|
|
56
|
+
"@statewalker/webrun-streams": "^0.2.1"
|
|
48
57
|
},
|
|
49
58
|
"devDependencies": {
|
|
50
|
-
"@biomejs/biome": "^2.5.
|
|
51
|
-
"@types/node": "^26.
|
|
59
|
+
"@biomejs/biome": "^2.5.15",
|
|
60
|
+
"@types/node": "^26.6.4",
|
|
52
61
|
"http-server": "^14.1.1",
|
|
53
|
-
"playwright": "^1.
|
|
62
|
+
"playwright": "^1.63.0",
|
|
54
63
|
"rimraf": "^6.1.3",
|
|
55
|
-
"rolldown": "^1.2.
|
|
64
|
+
"rolldown": "^1.2.12",
|
|
56
65
|
"typescript": "^7.0.2",
|
|
57
|
-
"vite": "^8.2
|
|
58
|
-
"vitest": "^
|
|
66
|
+
"vite": "^8.3.2",
|
|
67
|
+
"vitest": "^5.0.3"
|
|
59
68
|
},
|
|
60
69
|
"sideEffects": false,
|
|
61
70
|
"publishConfig": {
|
package/src/relay/index-sw.ts
CHANGED
|
@@ -4,15 +4,173 @@ import { callChannel, handleChannelCalls } from "../core/data-calls.js";
|
|
|
4
4
|
import { newRegistry } from "../core/registry.js";
|
|
5
5
|
import { handleClaimRequests } from "../core/service-worker-control.js";
|
|
6
6
|
import { sendHttpRequest } from "../http/http-send-recieve.js";
|
|
7
|
+
import { type MountSpec, type MountTable, newMountTable } from "./mount-table.js";
|
|
7
8
|
import { splitServiceUrl } from "./split-service-url.js";
|
|
8
9
|
|
|
10
|
+
/** What the registry keeps per service key. */
|
|
11
|
+
export interface RegisteredClient {
|
|
12
|
+
clientId: string;
|
|
13
|
+
/** Where the service is mounted. Absent means `/~<key>/`, as before mounts. */
|
|
14
|
+
path?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* One stored registry entry, whatever shape it is on disk.
|
|
19
|
+
*
|
|
20
|
+
* BEFORE MOUNTS THE VALUE WAS A BARE CLIENT ID. A browser that ran the earlier
|
|
21
|
+
* worker still holds that shape, and reading it as an object would drop the id
|
|
22
|
+
* and quietly unregister every service the visitor had.
|
|
23
|
+
*/
|
|
24
|
+
export function readStoredEntry(value: unknown): RegisteredClient | undefined {
|
|
25
|
+
if (typeof value === "string") return { clientId: value };
|
|
26
|
+
if (typeof value !== "object" || value === null) return undefined;
|
|
27
|
+
const { clientId, path } = value as { clientId?: unknown; path?: unknown };
|
|
28
|
+
if (typeof clientId !== "string" || clientId === "") return undefined;
|
|
29
|
+
return typeof path === "string" ? { clientId, path } : { clientId };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Which service, if any, should answer `url`.
|
|
34
|
+
*
|
|
35
|
+
* `undefined` means NOT THE RELAY'S, and the caller must not call
|
|
36
|
+
* `respondWith`: the request then goes to the network, which is how a host
|
|
37
|
+
* keeps serving its own files from its own origin. Answering 404 here instead
|
|
38
|
+
* would make a root mount fatal.
|
|
39
|
+
*/
|
|
40
|
+
export function resolveServiceKey(
|
|
41
|
+
url: URL,
|
|
42
|
+
table: MountTable,
|
|
43
|
+
selfOrigin: string,
|
|
44
|
+
): string | undefined {
|
|
45
|
+
// Another origin's resource is the network's business, as in any page.
|
|
46
|
+
if (url.origin !== selfOrigin) return undefined;
|
|
47
|
+
// Checked before EITHER route: an excluded path is never claimed, and the
|
|
48
|
+
// `/~<key>/` fallback below does not consult the table.
|
|
49
|
+
if (table.excludes(url)) return undefined;
|
|
50
|
+
const mounted = table.find(url);
|
|
51
|
+
if (mounted != null) return mounted;
|
|
52
|
+
const { key } = splitServiceUrl(url);
|
|
53
|
+
return key === "" ? undefined : key;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Waits for the mount table to be restored from the registry before routing
|
|
58
|
+
* `url` — but a restore failure must never wedge every fetch. `restored`
|
|
59
|
+
* rejecting (blocked storage, quota, private-mode edge cases) would otherwise
|
|
60
|
+
* propagate straight to `respondWith` on every request, including ones that
|
|
61
|
+
* should reach the network, which breaks the one rule this file exists to
|
|
62
|
+
* uphold. So: log the failure and route with whatever the in-memory table
|
|
63
|
+
* already holds — possibly empty, never fatal.
|
|
64
|
+
*/
|
|
65
|
+
export async function resolveAfterRestore(
|
|
66
|
+
restored: Promise<void>,
|
|
67
|
+
url: URL,
|
|
68
|
+
table: MountTable,
|
|
69
|
+
selfOrigin: string,
|
|
70
|
+
): Promise<string | undefined> {
|
|
71
|
+
try {
|
|
72
|
+
await restored;
|
|
73
|
+
} catch (error) {
|
|
74
|
+
console.error("[relay] failed to restore mounts from the registry", error);
|
|
75
|
+
}
|
|
76
|
+
return resolveServiceKey(url, table, selfOrigin);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* What REGISTER does to the mount table: set it when `path` is given, or
|
|
81
|
+
* remove any earlier mount when it is not. A path-less re-registration
|
|
82
|
+
* reverts a service to `/~<key>/` addressing, and a stale prefix left behind
|
|
83
|
+
* would keep routing requests to a mount that no longer exists.
|
|
84
|
+
*
|
|
85
|
+
* A REGISTRATION ONLY TOUCHES WHAT A REGISTRATION MADE. `hostKeys` names the
|
|
86
|
+
* mounts the host declared itself, in `options.mounts`. Those are the host's
|
|
87
|
+
* build-time decision and a page may not undo it: the documented static flow
|
|
88
|
+
* -- host declares `{ key: "app", path: "/" }`, page calls `initHttpService(h,
|
|
89
|
+
* { key: "app", port })` with no path -- would otherwise have its very first
|
|
90
|
+
* registration delete the host's own mount and leave the origin unmounted. A
|
|
91
|
+
* path-ful REGISTER naming a host key is ignored for the same reason (it would
|
|
92
|
+
* replace a `match` predicate with a prefix of the page's choosing).
|
|
93
|
+
*/
|
|
94
|
+
export function applyRegisteredMount(
|
|
95
|
+
table: MountTable,
|
|
96
|
+
key: string,
|
|
97
|
+
path: string | undefined,
|
|
98
|
+
hostKeys: ReadonlySet<string> = new Set(),
|
|
99
|
+
): void {
|
|
100
|
+
if (hostKeys.has(key)) return;
|
|
101
|
+
if (path != null) {
|
|
102
|
+
table.set(key, { path });
|
|
103
|
+
} else {
|
|
104
|
+
table.remove(key);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* What UNREGISTER does to the mount table: drop the mount a registration
|
|
110
|
+
* made. A host-declared mount stays, for the reason `applyRegisteredMount`
|
|
111
|
+
* gives -- a page tearing down its service must not take the host's table
|
|
112
|
+
* with it.
|
|
113
|
+
*/
|
|
114
|
+
export function removeRegisteredMount(
|
|
115
|
+
table: MountTable,
|
|
116
|
+
key: string,
|
|
117
|
+
hostKeys: ReadonlySet<string> = new Set(),
|
|
118
|
+
): void {
|
|
119
|
+
if (hostKeys.has(key)) return;
|
|
120
|
+
table.remove(key);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface RelayServiceWorkerOptions {
|
|
124
|
+
/** A fixed table, for a host that knows its services at build time. */
|
|
125
|
+
mounts?: Array<{ key: string } & MountSpec>;
|
|
126
|
+
/** Paths the relay never claims. Checked before the table. */
|
|
127
|
+
exclude?: (url: URL) => boolean;
|
|
128
|
+
/** Refuse a registration from the wrong client. Default: everyone may. */
|
|
129
|
+
canRegister?: (client: Client, key: string) => boolean | Promise<boolean>;
|
|
130
|
+
/** Default `"last-wins"`, the behaviour before this option existed. */
|
|
131
|
+
takeover?: "first-wins" | "last-wins";
|
|
132
|
+
/** Stamp headers on responses the relay makes. Not applied to network fetches. */
|
|
133
|
+
decorateResponse?: (response: Response, request: Request) => Response;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* May `candidateId` take the key?
|
|
138
|
+
*
|
|
139
|
+
* `last-wins` is what the relay has always done and stays the default. With
|
|
140
|
+
* `first-wins`, a LIVE holder keeps its key: on an origin whose name is
|
|
141
|
+
* guessable, a second page proves nothing by existing. A holder that reloaded
|
|
142
|
+
* is no longer live, so a host's own re-registration is never blocked.
|
|
143
|
+
*/
|
|
144
|
+
export function mayRegister(args: {
|
|
145
|
+
current?: RegisteredClient;
|
|
146
|
+
candidateId: string;
|
|
147
|
+
isCurrentLive: boolean;
|
|
148
|
+
takeover: "first-wins" | "last-wins";
|
|
149
|
+
}): boolean {
|
|
150
|
+
if (args.takeover === "last-wins") return true;
|
|
151
|
+
if (args.current == null || !args.isCurrentLive) return true;
|
|
152
|
+
return args.current.clientId === args.candidateId;
|
|
153
|
+
}
|
|
154
|
+
|
|
9
155
|
/**
|
|
10
156
|
* Boots the relay ServiceWorker: routes fetches shaped `<origin>/~<key>/…` to
|
|
11
157
|
* the client that registered `key`, and exposes REGISTER/UNREGISTER/CONNECT
|
|
12
158
|
* channel calls used by the page-side relay client.
|
|
13
159
|
*/
|
|
14
|
-
export function startRelayServiceWorker(
|
|
160
|
+
export function startRelayServiceWorker(
|
|
161
|
+
self: ServiceWorkerGlobalScope,
|
|
162
|
+
options: RelayServiceWorkerOptions = {},
|
|
163
|
+
): () => void {
|
|
15
164
|
const [register, clear] = newRegistry();
|
|
165
|
+
const mounts = newMountTable({ exclude: options.exclude });
|
|
166
|
+
// The keys the HOST declared. Kept so a page's REGISTER/UNREGISTER cannot
|
|
167
|
+
// replace or delete a mount it never created -- see `applyRegisteredMount`.
|
|
168
|
+
const hostKeys = new Set<string>();
|
|
169
|
+
for (const { key, ...spec } of options.mounts ?? []) {
|
|
170
|
+
mounts.set(key, spec);
|
|
171
|
+
hostKeys.add(key);
|
|
172
|
+
}
|
|
173
|
+
const takeover = options.takeover ?? "last-wins";
|
|
16
174
|
|
|
17
175
|
if (typeof self.skipWaiting === "function") {
|
|
18
176
|
self.addEventListener("install", (e: ExtendableEvent) => {
|
|
@@ -28,6 +186,30 @@ export function startRelayServiceWorker(self: ServiceWorkerGlobalScope): () => v
|
|
|
28
186
|
|
|
29
187
|
const clientsRegistry = newClientsRegistry({ self });
|
|
30
188
|
|
|
189
|
+
// A RESTARTED WORKER HAS AN EMPTY TABLE AND A FULL DATABASE. The registry
|
|
190
|
+
// survives in IndexedDB; the mounts are in memory, so they must be read back
|
|
191
|
+
// or the first fetch after a restart finds nothing mounted.
|
|
192
|
+
const restored = clientsRegistry.restoreMounts(mounts, hostKeys);
|
|
193
|
+
|
|
194
|
+
// ONCE THE RESTORE HAS SETTLED THE TABLE CAN BE READ SYNCHRONOUSLY, and the
|
|
195
|
+
// fetch listener needs that: `respondWith` must be called synchronously, so
|
|
196
|
+
// a listener that can only decide after an `await` has to call it for EVERY
|
|
197
|
+
// request and re-issue the ones that are nobody's. That is not the same as
|
|
198
|
+
// leaving them alone -- it defeats navigation preload and makes a request
|
|
199
|
+
// with `cache: "only-if-cached"` throw. This latch keeps the async path for
|
|
200
|
+
// the handful of requests that arrive before the restore settles, and only
|
|
201
|
+
// those.
|
|
202
|
+
//
|
|
203
|
+
// The handler is attached here, at creation, rather than at the first fetch:
|
|
204
|
+
// a rejection with nothing listening surfaces as an `unhandledrejection` in
|
|
205
|
+
// the worker. `resolveAfterRestore` still does its own logging for the
|
|
206
|
+
// requests that await `restored` directly.
|
|
207
|
+
let restoreSettled = false;
|
|
208
|
+
const latch = () => {
|
|
209
|
+
restoreSettled = true;
|
|
210
|
+
};
|
|
211
|
+
restored.then(latch, latch);
|
|
212
|
+
|
|
31
213
|
// Pages bridge to `registration.active` when uncontrolled, so they do not
|
|
32
214
|
// need this; it is here so any page of this origin can ask for control.
|
|
33
215
|
register(handleClaimRequests(self));
|
|
@@ -36,13 +218,33 @@ export function startRelayServiceWorker(self: ServiceWorkerGlobalScope): () => v
|
|
|
36
218
|
handleChannelCalls(self, "REGISTER", async (event, data) => {
|
|
37
219
|
const source = event.source as Client | null;
|
|
38
220
|
if (!source) return false;
|
|
39
|
-
const { key } = data as { key: string };
|
|
40
|
-
|
|
221
|
+
const { key, path } = data as { key: string; path?: string };
|
|
222
|
+
|
|
223
|
+
if (options.canRegister != null && !(await options.canRegister(source, key))) {
|
|
224
|
+
throw new Error(`this client may not register "${key}"`);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// `last-wins` -- the untouched default -- must cost exactly what it did
|
|
228
|
+
// before this option existed: no registry lookup beyond `addClient`'s
|
|
229
|
+
// own. Only `first-wins` needs to know who currently holds the key and
|
|
230
|
+
// whether they are still live, so only it pays for finding out.
|
|
231
|
+
if (takeover === "first-wins") {
|
|
232
|
+
const current = await clientsRegistry.getMount(key);
|
|
233
|
+
const isCurrentLive = current != null && (await clientsRegistry.getClient(key)) != null;
|
|
234
|
+
if (!mayRegister({ current, candidateId: source.id, isCurrentLive, takeover })) {
|
|
235
|
+
throw new Error(`"${key}" is already served by another client`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const added = await clientsRegistry.addClient(key, source, path);
|
|
240
|
+
applyRegisteredMount(mounts, key, path, hostKeys);
|
|
241
|
+
return added;
|
|
41
242
|
}),
|
|
42
243
|
);
|
|
43
244
|
register(
|
|
44
245
|
handleChannelCalls(self, "UNREGISTER", async (_event, data) => {
|
|
45
246
|
const { key } = data as { key: string };
|
|
247
|
+
removeRegisteredMount(mounts, key, hostKeys);
|
|
46
248
|
return await clientsRegistry.removeClient(key);
|
|
47
249
|
}),
|
|
48
250
|
);
|
|
@@ -55,31 +257,54 @@ export function startRelayServiceWorker(self: ServiceWorkerGlobalScope): () => v
|
|
|
55
257
|
}),
|
|
56
258
|
);
|
|
57
259
|
|
|
260
|
+
/** Answers `request` as the service registered under `key`. */
|
|
261
|
+
async function serve(key: string, request: Request, url: URL): Promise<Response> {
|
|
262
|
+
const params = splitServiceUrl(url);
|
|
263
|
+
try {
|
|
264
|
+
const channel = new MessageChannel();
|
|
265
|
+
const client = await clientsRegistry.getClient(key);
|
|
266
|
+
if (!client) throw HttpError.errorResourceGone(params);
|
|
267
|
+
const data = { type: "http", key };
|
|
268
|
+
const accepted = await callChannel<boolean>(client, "CONNECT", data, channel.port2);
|
|
269
|
+
if (!accepted) throw HttpError.errorForbidden(params);
|
|
270
|
+
const response = await sendHttpRequest(channel.port1, request);
|
|
271
|
+
return options.decorateResponse?.(response, request) ?? response;
|
|
272
|
+
} catch (error) {
|
|
273
|
+
const httpError = HttpError.fromError(error);
|
|
274
|
+
const errorOptions = httpError.getResponseOptions(params);
|
|
275
|
+
const errorResponse = new Response(JSON.stringify(errorOptions), {
|
|
276
|
+
status: httpError.status ?? 500,
|
|
277
|
+
statusText: httpError.statusText ?? "Internal Error",
|
|
278
|
+
headers: { "Content-Type": "application/json" },
|
|
279
|
+
});
|
|
280
|
+
return options.decorateResponse?.(errorResponse, request) ?? errorResponse;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
58
284
|
const fetchListener = (event: FetchEvent) => {
|
|
59
285
|
const request = event.request;
|
|
60
|
-
const
|
|
61
|
-
const { key } = params;
|
|
62
|
-
if (!key) return;
|
|
286
|
+
const url = new URL(request.url);
|
|
63
287
|
|
|
288
|
+
// THE ORDINARY CASE: decide synchronously and, when the request is
|
|
289
|
+
// nobody's, RETURN WITHOUT `respondWith` -- the browser then performs it
|
|
290
|
+
// exactly as it would with no worker installed.
|
|
291
|
+
if (restoreSettled) {
|
|
292
|
+
const key = resolveServiceKey(url, mounts, self.location.origin);
|
|
293
|
+
if (key == null) return;
|
|
294
|
+
event.respondWith(serve(key, request, url));
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Before the restore settles there is nothing to decide on synchronously,
|
|
299
|
+
// and `respondWith` cannot be called after an await. Such a request is
|
|
300
|
+
// claimed and, if it turns out to be nobody's, re-issued -- the one
|
|
301
|
+
// window in which the worker still stands in for the network, and it
|
|
302
|
+
// lasts only until the registry read completes.
|
|
64
303
|
event.respondWith(
|
|
65
304
|
(async (): Promise<Response> => {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
if (!client) throw HttpError.errorResourceGone(params);
|
|
70
|
-
const data = { type: "http", key };
|
|
71
|
-
const accepted = await callChannel<boolean>(client, "CONNECT", data, channel.port2);
|
|
72
|
-
if (!accepted) throw HttpError.errorForbidden(params);
|
|
73
|
-
return await sendHttpRequest(channel.port1, request);
|
|
74
|
-
} catch (error) {
|
|
75
|
-
const httpError = HttpError.fromError(error);
|
|
76
|
-
const options = httpError.getResponseOptions(params);
|
|
77
|
-
return new Response(JSON.stringify(options), {
|
|
78
|
-
status: httpError.status ?? 500,
|
|
79
|
-
statusText: httpError.statusText ?? "Internal Error",
|
|
80
|
-
headers: { "Content-Type": "application/json" },
|
|
81
|
-
});
|
|
82
|
-
}
|
|
305
|
+
const key = await resolveAfterRestore(restored, url, mounts, self.location.origin);
|
|
306
|
+
if (key == null) return await fetch(request);
|
|
307
|
+
return await serve(key, request, url);
|
|
83
308
|
})(),
|
|
84
309
|
);
|
|
85
310
|
};
|
|
@@ -95,33 +320,41 @@ interface ClientsRegistryOptions {
|
|
|
95
320
|
}
|
|
96
321
|
|
|
97
322
|
interface ClientsRegistry {
|
|
98
|
-
addClient(clientKey: string, client: Client): Promise<boolean>;
|
|
323
|
+
addClient(clientKey: string, client: Client, path?: string): Promise<boolean>;
|
|
99
324
|
removeClient(clientKey: string): Promise<boolean>;
|
|
100
325
|
getClient(clientKey: string): Promise<Client | undefined>;
|
|
326
|
+
getMount(clientKey: string): Promise<RegisteredClient | undefined>;
|
|
327
|
+
restoreMounts(table: MountTable, hostKeys?: ReadonlySet<string>): Promise<void>;
|
|
101
328
|
}
|
|
102
329
|
|
|
103
330
|
function newClientsRegistry({ self, key = "clientsIds" }: ClientsRegistryOptions): ClientsRegistry {
|
|
104
|
-
let _index: Record<string,
|
|
331
|
+
let _index: Record<string, RegisteredClient> | undefined;
|
|
105
332
|
|
|
106
|
-
async function loadClientsIndex(): Promise<Record<string,
|
|
333
|
+
async function loadClientsIndex(): Promise<Record<string, RegisteredClient>> {
|
|
107
334
|
if (!_index) {
|
|
108
|
-
const entries = ((await get<Array<[string,
|
|
109
|
-
|
|
335
|
+
const entries = ((await get<Array<[string, unknown]>>(key)) ?? []) as Array<
|
|
336
|
+
[string, unknown]
|
|
337
|
+
>;
|
|
338
|
+
_index = {};
|
|
339
|
+
for (const [clientKey, value] of entries) {
|
|
340
|
+
const entry = readStoredEntry(value);
|
|
341
|
+
if (entry) _index[clientKey] = entry;
|
|
342
|
+
}
|
|
110
343
|
}
|
|
111
344
|
return _index;
|
|
112
345
|
}
|
|
113
346
|
|
|
114
|
-
async function storeClientsIndex(): Promise<Record<string,
|
|
347
|
+
async function storeClientsIndex(): Promise<Record<string, RegisteredClient>> {
|
|
115
348
|
const index = await loadClientsIndex();
|
|
116
349
|
await set(key, Object.entries(index));
|
|
117
350
|
return index;
|
|
118
351
|
}
|
|
119
352
|
|
|
120
|
-
async function addClient(clientKey: string, client: Client): Promise<boolean> {
|
|
353
|
+
async function addClient(clientKey: string, client: Client, path?: string): Promise<boolean> {
|
|
121
354
|
const index = await loadClientsIndex();
|
|
122
|
-
const
|
|
123
|
-
if (
|
|
124
|
-
index[clientKey] = clientId;
|
|
355
|
+
const current = index[clientKey];
|
|
356
|
+
if (current?.clientId === client.id && current.path === path) return false;
|
|
357
|
+
index[clientKey] = path == null ? { clientId: client.id } : { clientId: client.id, path };
|
|
125
358
|
await storeClientsIndex();
|
|
126
359
|
return true;
|
|
127
360
|
}
|
|
@@ -134,11 +367,29 @@ function newClientsRegistry({ self, key = "clientsIds" }: ClientsRegistryOptions
|
|
|
134
367
|
return true;
|
|
135
368
|
}
|
|
136
369
|
|
|
370
|
+
async function getMount(clientKey: string): Promise<RegisteredClient | undefined> {
|
|
371
|
+
const index = await loadClientsIndex();
|
|
372
|
+
return index[clientKey];
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async function restoreMounts(
|
|
376
|
+
table: MountTable,
|
|
377
|
+
hostKeys: ReadonlySet<string> = new Set(),
|
|
378
|
+
): Promise<void> {
|
|
379
|
+
const index = await loadClientsIndex();
|
|
380
|
+
for (const [clientKey, entry] of Object.entries(index)) {
|
|
381
|
+
// A host-declared mount outranks a persisted registration of the same
|
|
382
|
+
// key, exactly as it does on a live REGISTER.
|
|
383
|
+
if (hostKeys.has(clientKey)) continue;
|
|
384
|
+
if (entry.path != null) table.set(clientKey, { path: entry.path });
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
137
388
|
async function getClient(clientKey: string): Promise<Client | undefined> {
|
|
138
389
|
const index = await loadClientsIndex();
|
|
139
|
-
const
|
|
140
|
-
if (!
|
|
141
|
-
const client = await self.clients.get(clientId);
|
|
390
|
+
const entry = index[clientKey];
|
|
391
|
+
if (!entry) return undefined;
|
|
392
|
+
const client = await self.clients.get(entry.clientId);
|
|
142
393
|
if (!client) {
|
|
143
394
|
delete index[clientKey];
|
|
144
395
|
await storeClientsIndex();
|
|
@@ -146,5 +397,5 @@ function newClientsRegistry({ self, key = "clientsIds" }: ClientsRegistryOptions
|
|
|
146
397
|
return client ?? undefined;
|
|
147
398
|
}
|
|
148
399
|
|
|
149
|
-
return { getClient, addClient, removeClient };
|
|
400
|
+
return { getClient, getMount, addClient, removeClient, restoreMounts };
|
|
150
401
|
}
|
package/src/relay/index.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { HttpHandler } from "@statewalker/webrun-http-streams";
|
|
2
2
|
import { serializeError } from "@statewalker/webrun-streams";
|
|
3
|
-
import {
|
|
3
|
+
import { type ChannelCallHandler, callChannel } from "../core/data-calls.js";
|
|
4
4
|
import type { MessageTarget } from "../core/message-target.js";
|
|
5
5
|
import { newRegistry } from "../core/registry.js";
|
|
6
6
|
import {
|
|
@@ -80,6 +80,11 @@ async function registerServiceWorker({
|
|
|
80
80
|
|
|
81
81
|
export interface ServiceOptions {
|
|
82
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;
|
|
83
88
|
port: MessageTarget;
|
|
84
89
|
}
|
|
85
90
|
|
|
@@ -89,10 +94,11 @@ export interface ServiceOptions {
|
|
|
89
94
|
*/
|
|
90
95
|
export async function initHttpService(
|
|
91
96
|
handler: HttpHandler,
|
|
92
|
-
{ key, port }: ServiceOptions,
|
|
97
|
+
{ key, path, port }: ServiceOptions,
|
|
93
98
|
): Promise<() => void> {
|
|
94
99
|
return await registerConnectionsHandler({
|
|
95
100
|
key,
|
|
101
|
+
path,
|
|
96
102
|
communicationPort: port,
|
|
97
103
|
handler: async (_event, _data, callPort) => {
|
|
98
104
|
handleHttpRequests(callPort, handler);
|
|
@@ -258,22 +264,72 @@ export async function initializeConnection({
|
|
|
258
264
|
|
|
259
265
|
export interface RegisterConnectionsHandlerOptions {
|
|
260
266
|
key: string;
|
|
267
|
+
/** Where this service is mounted; see `ServiceOptions.path`. */
|
|
268
|
+
path?: string;
|
|
261
269
|
handler: (event: MessageEvent, data: unknown, port: MessagePort) => boolean | Promise<boolean>;
|
|
262
270
|
communicationPort: MessageTarget;
|
|
263
271
|
}
|
|
264
272
|
|
|
265
273
|
export async function registerConnectionsHandler({
|
|
266
274
|
key,
|
|
275
|
+
path,
|
|
267
276
|
handler,
|
|
268
277
|
communicationPort,
|
|
269
278
|
}: RegisterConnectionsHandlerOptions): Promise<() => void> {
|
|
270
279
|
const [register, cleanup] = newRegistry();
|
|
271
|
-
|
|
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 });
|
|
272
283
|
register(() => callChannel(communicationPort, "UNREGISTER", { key }));
|
|
273
284
|
register(
|
|
274
|
-
|
|
285
|
+
handleKeyedChannelCalls(communicationPort, "CONNECT", key, async (event, data, port) => {
|
|
275
286
|
return await handler(event, data, port);
|
|
276
287
|
}),
|
|
277
288
|
);
|
|
278
289
|
return cleanup;
|
|
279
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
|
+
}
|