@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
|
@@ -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
|
@@ -60,8 +60,17 @@ export class SwPortHandler {
|
|
|
60
60
|
return this.options.scope ?? new URL("./", this.serviceWorkerUrl).pathname;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
/**
|
|
64
|
+
* The scope as a url on the worker's origin. Resolved against the worker's
|
|
65
|
+
* own url when one is given, not against this module's: a bundle that is
|
|
66
|
+
* not an ES module (an IIFE, Theia's frontend) has no `import.meta.url`, and
|
|
67
|
+
* `new URL(scope, undefined)` threw `Invalid URL` there. Only without a
|
|
68
|
+
* worker url does the module's location decide (its default `index-sw.js`
|
|
69
|
+
* sits next to it).
|
|
70
|
+
*/
|
|
63
71
|
get rootUrl(): URL {
|
|
64
|
-
|
|
72
|
+
const base = this.options.serviceWorkerUrl ? this.serviceWorkerUrl : import.meta.url;
|
|
73
|
+
return new URL(this.scope, base);
|
|
65
74
|
}
|
|
66
75
|
|
|
67
76
|
get serviceWorkerUrl(): string {
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
import * as workerTimer from "https://unpkg.com/worker-timers@7.0.74/build/es2019/module.js?module";
|
|
2
|
-
|
|
3
|
-
export function startHeartbit({
|
|
4
|
-
timeout = 10 * 1000,
|
|
5
|
-
url = "./ping", // 10 seconds
|
|
6
|
-
}) {
|
|
7
|
-
return callPeriodically(async () => {
|
|
8
|
-
const _res = await fetch(url);
|
|
9
|
-
}, timeout);
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export async function callPeriodically(action, timeout) {
|
|
13
|
-
let timerId,
|
|
14
|
-
stopped = false;
|
|
15
|
-
async function run() {
|
|
16
|
-
try {
|
|
17
|
-
await action();
|
|
18
|
-
} catch (err) {
|
|
19
|
-
console.error(err);
|
|
20
|
-
}
|
|
21
|
-
timerId = !stopped ? workerTimer.setTimeout(run, timeout) : 0;
|
|
22
|
-
}
|
|
23
|
-
run();
|
|
24
|
-
return () => {
|
|
25
|
-
stopped = true;
|
|
26
|
-
if (timerId) {
|
|
27
|
-
workerTimer.clearTimeout(timerId);
|
|
28
|
-
timerId = 0;
|
|
29
|
-
}
|
|
30
|
-
};
|
|
31
|
-
}
|