@statewalker/httpeers-member 0.1.4
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 +203 -0
- package/dist/browser-platform.d.ts +30 -0
- package/dist/browser-platform.d.ts.map +1 -0
- package/dist/browser-platform.js +30 -0
- package/dist/browser-profile.d.ts +85 -0
- package/dist/browser-profile.d.ts.map +1 -0
- package/dist/browser-profile.js +120 -0
- package/dist/browser.d.ts +48 -0
- package/dist/browser.d.ts.map +1 -0
- package/dist/browser.js +51 -0
- package/dist/connection-kind.d.ts +36 -0
- package/dist/connection-kind.d.ts.map +1 -0
- package/dist/connection-kind.js +52 -0
- package/dist/describe-error.d.ts +18 -0
- package/dist/describe-error.d.ts.map +1 -0
- package/dist/describe-error.js +72 -0
- package/dist/edge-control.d.ts +50 -0
- package/dist/edge-control.d.ts.map +1 -0
- package/dist/edge-control.js +69 -0
- package/dist/edge-dispatch.d.ts +188 -0
- package/dist/edge-dispatch.d.ts.map +1 -0
- package/dist/edge-dispatch.js +142 -0
- package/dist/edge-guard.d.ts +45 -0
- package/dist/edge-guard.d.ts.map +1 -0
- package/dist/edge-guard.js +62 -0
- package/dist/edge.d.ts +97 -0
- package/dist/edge.d.ts.map +1 -0
- package/dist/edge.js +104 -0
- package/dist/gateway.d.ts +49 -0
- package/dist/gateway.d.ts.map +1 -0
- package/dist/gateway.js +102 -0
- package/dist/identity.d.ts +78 -0
- package/dist/identity.d.ts.map +1 -0
- package/dist/identity.js +117 -0
- package/dist/index.d.ts +39 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +37 -0
- package/dist/join-blob.d.ts +125 -0
- package/dist/join-blob.d.ts.map +1 -0
- package/dist/join-blob.js +195 -0
- package/dist/join.d.ts +343 -0
- package/dist/join.d.ts.map +1 -0
- package/dist/join.js +409 -0
- package/dist/kv.d.ts +20 -0
- package/dist/kv.d.ts.map +1 -0
- package/dist/kv.js +10 -0
- package/dist/mesh-config.d.ts +26 -0
- package/dist/mesh-config.d.ts.map +1 -0
- package/dist/mesh-config.js +39 -0
- package/dist/mesh-memory.d.ts +55 -0
- package/dist/mesh-memory.d.ts.map +1 -0
- package/dist/mesh-memory.js +74 -0
- package/dist/node.d.ts +29 -0
- package/dist/node.d.ts.map +1 -0
- package/dist/node.js +42 -0
- package/dist/page-wake.d.ts +24 -0
- package/dist/page-wake.d.ts.map +1 -0
- package/dist/page-wake.js +31 -0
- package/dist/peer-request.d.ts +29 -0
- package/dist/peer-request.d.ts.map +1 -0
- package/dist/peer-request.js +11 -0
- package/dist/reset.d.ts +56 -0
- package/dist/reset.d.ts.map +1 -0
- package/dist/reset.js +103 -0
- package/dist/session.d.ts +214 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +403 -0
- package/dist/start-member.d.ts +176 -0
- package/dist/start-member.d.ts.map +1 -0
- package/dist/start-member.js +307 -0
- package/package.json +120 -0
- package/src/browser-platform.ts +53 -0
- package/src/browser-profile.ts +179 -0
- package/src/browser.ts +103 -0
- package/src/connection-kind.ts +53 -0
- package/src/describe-error.ts +76 -0
- package/src/edge-control.ts +94 -0
- package/src/edge-dispatch.ts +295 -0
- package/src/edge-guard.ts +67 -0
- package/src/edge.ts +175 -0
- package/src/gateway.ts +141 -0
- package/src/identity.ts +146 -0
- package/src/index.ts +95 -0
- package/src/join-blob.ts +229 -0
- package/src/join.ts +632 -0
- package/src/kv.ts +21 -0
- package/src/mesh-config.ts +41 -0
- package/src/mesh-memory.ts +104 -0
- package/src/node.ts +49 -0
- package/src/page-wake.ts +59 -0
- package/src/peer-request.ts +39 -0
- package/src/reset.ts +127 -0
- package/src/session.ts +579 -0
- package/src/start-member.ts +476 -0
package/src/browser.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The BROWSER half of a member: the platform object, the two IndexedDB
|
|
3
|
+
* stores, and one function that assembles a page session out of them.
|
|
4
|
+
*
|
|
5
|
+
* EVERYTHING BROWSER-BOUND IS BEHIND THIS ENTRY, and that is a load-bearing
|
|
6
|
+
* arrangement rather than tidiness. `idb-keyval` needs a real IndexedDB;
|
|
7
|
+
* `@libp2p/webrtc`'s Node build loads a native module and throws on import;
|
|
8
|
+
* `@statewalker/webrun-http-browser/sw` is a ServiceWorker adapter. Any of
|
|
9
|
+
* them reachable from `./index.ts` would make a Node member fail at import
|
|
10
|
+
* time, which `tests/boundary.test.ts` now measures by reachability.
|
|
11
|
+
*
|
|
12
|
+
* `createSession` IS THE ONLY THING HERE THAT IS NOT A RE-EXPORT. It is the
|
|
13
|
+
* defaults `session.ts` deliberately does not carry: the platform, the two
|
|
14
|
+
* stores, `location.search` and the reload. A page that wants to substitute
|
|
15
|
+
* any of them passes it and this steps aside.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { del, get, set } from "idb-keyval";
|
|
19
|
+
import { browserPlatform } from "./browser-platform.js";
|
|
20
|
+
import type { AsyncBytesBackend, AsyncKeyValueBackend } from "./kv.js";
|
|
21
|
+
import { createMeshMemory } from "./mesh-memory.js";
|
|
22
|
+
import type { PeerSession, PeerSessionInit } from "./session.js";
|
|
23
|
+
import { createIdentityStore, createPeerSession } from "./session.js";
|
|
24
|
+
|
|
25
|
+
export { type BrowserPlatformInit, browserPlatform } from "./browser-platform.js";
|
|
26
|
+
export {
|
|
27
|
+
type CreateBrowserNodeInit,
|
|
28
|
+
createBrowserNode,
|
|
29
|
+
dialNeedsPermissiveGater,
|
|
30
|
+
} from "./browser-profile.js";
|
|
31
|
+
export {
|
|
32
|
+
DEFAULT_CONTROL_TIMEOUT_MS,
|
|
33
|
+
DEFAULT_SERVICE_WORKER_URL,
|
|
34
|
+
type MountEdgeInit,
|
|
35
|
+
mountEdge,
|
|
36
|
+
UncontrolledPageError,
|
|
37
|
+
} from "./edge.js";
|
|
38
|
+
export {
|
|
39
|
+
type WakeDocument,
|
|
40
|
+
type WakeWindow,
|
|
41
|
+
type WatchPageWakeInit,
|
|
42
|
+
watchPageWake,
|
|
43
|
+
} from "./page-wake.js";
|
|
44
|
+
|
|
45
|
+
// THE SESSION ITSELF IS NOT RE-EXPORTED HERE. It is isomorphic and lives at
|
|
46
|
+
// the root, and two import paths to one symbol is how a package ends up with
|
|
47
|
+
// two versions of a type that look identical and are not.
|
|
48
|
+
|
|
49
|
+
/** The page's own globals, declared locally — see `./page-wake.ts` for why not `lib: dom`. */
|
|
50
|
+
declare const location: { search: string; reload(): void };
|
|
51
|
+
|
|
52
|
+
/** The real thing: the same IndexedDB store `@statewalker/webrun-http-browser` already uses. */
|
|
53
|
+
export function idbBackend(): AsyncKeyValueBackend {
|
|
54
|
+
return {
|
|
55
|
+
get: async (key) => (await get<string>(key)) ?? undefined,
|
|
56
|
+
set: async (key, value) => await set(key, value),
|
|
57
|
+
delete: async (key) => await del(key),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function idbBytesBackend(): AsyncBytesBackend {
|
|
62
|
+
return {
|
|
63
|
+
get: async (key) => (await get<Uint8Array>(key)) ?? undefined,
|
|
64
|
+
set: async (key, value) => await set(key, value),
|
|
65
|
+
delete: async (key) => await del(key),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* `PeerSessionInit` with every browser-supplied part made optional.
|
|
71
|
+
*
|
|
72
|
+
* The four that become optional are exactly the four `session.ts` refuses to
|
|
73
|
+
* default, and they are listed there with the reason. `serviceWorkerUrl` and
|
|
74
|
+
* `dev` are here rather than there because they are `browserPlatform`'s
|
|
75
|
+
* arguments, and a page that passes its own `platform` has no use for them.
|
|
76
|
+
*/
|
|
77
|
+
export interface CreateSessionInit
|
|
78
|
+
extends Omit<PeerSessionInit, "platform" | "identity" | "meshMemory" | "reload" | "search"> {
|
|
79
|
+
platform?: PeerSessionInit["platform"];
|
|
80
|
+
identity?: PeerSessionInit["identity"];
|
|
81
|
+
meshMemory?: PeerSessionInit["meshMemory"];
|
|
82
|
+
reload?: () => void;
|
|
83
|
+
/** Defaults to `location.search`, read once at `start()`. */
|
|
84
|
+
search?: string;
|
|
85
|
+
/** Where the ServiceWorker script lives — see `mountEdge`. */
|
|
86
|
+
serviceWorkerUrl?: string;
|
|
87
|
+
/** Relax libp2p's dial gater for loopback and private addresses. Off in production. */
|
|
88
|
+
dev?: boolean;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function createSession(init: CreateSessionInit): PeerSession {
|
|
92
|
+
return createPeerSession({
|
|
93
|
+
...init,
|
|
94
|
+
search: init.search ?? location.search,
|
|
95
|
+
platform:
|
|
96
|
+
init.platform ??
|
|
97
|
+
browserPlatform({ serviceWorkerUrl: init.serviceWorkerUrl, dev: init.dev ?? false }),
|
|
98
|
+
identity: init.identity ?? createIdentityStore({ backend: idbBytesBackend() }),
|
|
99
|
+
meshMemory: init.meshMemory ?? createMeshMemory({ backend: idbBackend() }),
|
|
100
|
+
reload: init.reload ?? ((): void => location.reload()),
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
export { type ResetResult, resetBrowserState } from "./reset.js";
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Is this peer reached directly, or through the relay?
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS. libp2p does not upgrade a circuit to WebRTC on its own:
|
|
5
|
+
* `join.ts`'s `preDialPeer` dials `<relay>/p2p-circuit/webrtc/p2p/<peer>`
|
|
6
|
+
* explicitly, and the circuit's only job there is to carry the SDP exchange.
|
|
7
|
+
* When that dial fails -- symmetric NAT on a mobile carrier, a VPN, no reachable
|
|
8
|
+
* STUN -- `edge-dispatch.ts` SWALLOWS the failure by design and the call is
|
|
9
|
+
* attempted anyway. `remote()` then dials by peer id, which resolves through the
|
|
10
|
+
* peerStore to a bare `/p2p-circuit` address, and every byte crosses the relay.
|
|
11
|
+
*
|
|
12
|
+
* That fallback works. What it does not do is announce itself, and the silence
|
|
13
|
+
* is the problem: it is 10-100x slower, it spends the relay operator's
|
|
14
|
+
* bandwidth instead of going peer-to-peer, and while the relay applied a
|
|
15
|
+
* per-connection data cap it truncated transfers mid-stream. The visible
|
|
16
|
+
* symptom was broken images in a gallery, with one of them loading fine in a
|
|
17
|
+
* fresh tab -- which reads as random corruption rather than as a transport
|
|
18
|
+
* that quietly changed underneath.
|
|
19
|
+
*
|
|
20
|
+
* Reading the open connection's own address is the whole diagnosis, and it is
|
|
21
|
+
* one call. Strings in, so this module needs no libp2p import and is testable
|
|
22
|
+
* under plain Node.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export type ConnectionKind = "direct" | "relayed" | "none";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Classify the connections open to one peer, from their multiaddrs.
|
|
29
|
+
*
|
|
30
|
+
* `/webrtc` present means the WebRTC upgrade completed and the relay is no
|
|
31
|
+
* longer in the data path. A `/p2p-circuit` WITHOUT it means it did not, and
|
|
32
|
+
* traffic is being relayed. Direct wins when both are open, because that is the
|
|
33
|
+
* one libp2p will actually use.
|
|
34
|
+
*/
|
|
35
|
+
export function classifyConnection(remoteAddrs: readonly string[]): ConnectionKind {
|
|
36
|
+
if (remoteAddrs.length === 0) return "none";
|
|
37
|
+
if (remoteAddrs.some((addr) => addr.includes("/webrtc"))) return "direct";
|
|
38
|
+
if (remoteAddrs.some((addr) => addr.includes("/p2p-circuit"))) return "relayed";
|
|
39
|
+
// Anything else -- a plain TCP or WebSocket address -- is a direct dial.
|
|
40
|
+
return "direct";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** One line a page can render unconditionally, saying what the kind means rather than naming it. */
|
|
44
|
+
export function describeConnection(kind: ConnectionKind): string {
|
|
45
|
+
switch (kind) {
|
|
46
|
+
case "direct":
|
|
47
|
+
return "direct (WebRTC) — data goes peer to peer, the relay is not involved";
|
|
48
|
+
case "relayed":
|
|
49
|
+
return "relayed — the WebRTC upgrade did not succeed, so every byte crosses the relay: expect it to be slow";
|
|
50
|
+
case "none":
|
|
51
|
+
return "not connected yet";
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turn anything thrown into something a person can act on.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS. Every "could not reach the relay" message in this app ended
|
|
5
|
+
* with `Cause: ${String(err)}`, and a failed WebSocket does not reject with an
|
|
6
|
+
* `Error` -- it rejects with a DOM `Event`. `String(event)` is
|
|
7
|
+
* **"[object Event]"**. A phone that could not reach the relay reported exactly
|
|
8
|
+
* that, and it named nothing: not the URL, not whether the socket ever opened,
|
|
9
|
+
* not whether it was closed or refused. The one message whose entire job was to
|
|
10
|
+
* explain a connection failure discarded every fact about it.
|
|
11
|
+
*
|
|
12
|
+
* The event carries what is needed -- `type`, `target.url`, `target.readyState`
|
|
13
|
+
* and, for a close event, `code` and `reason`. This reads them defensively,
|
|
14
|
+
* because an object that is not what we assume must not turn a bad error message
|
|
15
|
+
* into a second exception thrown while reporting the first.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** WebSocket.readyState, in words. The bare number tells a reader nothing. */
|
|
19
|
+
const READY_STATE = ["CONNECTING", "OPEN", "CLOSING", "CLOSED"] as const;
|
|
20
|
+
|
|
21
|
+
function pick(obj: unknown, key: string): unknown {
|
|
22
|
+
try {
|
|
23
|
+
return obj != null && typeof obj === "object"
|
|
24
|
+
? (obj as Record<string, unknown>)[key]
|
|
25
|
+
: undefined;
|
|
26
|
+
} catch {
|
|
27
|
+
// A getter that throws must not take the error report down with it.
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function describeError(err: unknown): string {
|
|
33
|
+
if (err instanceof Error) {
|
|
34
|
+
const cause = (err as { cause?: unknown }).cause;
|
|
35
|
+
if (cause == null || cause === err) return err.message;
|
|
36
|
+
// Compare the DESCRIPTIONS, not the objects. libp2p wraps a WebSocket
|
|
37
|
+
// error event in an Error whose `cause` is a different object describing
|
|
38
|
+
// the same failure, so identity comparison lets it print twice --
|
|
39
|
+
// "socket CLOSED (cause: ... socket CLOSED)" -- which reads like two
|
|
40
|
+
// problems.
|
|
41
|
+
const described = describeError(cause);
|
|
42
|
+
return err.message.includes(described) ? err.message : `${err.message} (cause: ${described})`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const type = pick(err, "type");
|
|
46
|
+
if (typeof type === "string") {
|
|
47
|
+
const target = pick(err, "target");
|
|
48
|
+
const parts: string[] = [`${type} event`];
|
|
49
|
+
|
|
50
|
+
const url = pick(target, "url");
|
|
51
|
+
if (typeof url === "string" && url !== "") parts.push(url);
|
|
52
|
+
|
|
53
|
+
const state = pick(target, "readyState");
|
|
54
|
+
if (typeof state === "number" && READY_STATE[state] != null) {
|
|
55
|
+
parts.push(`socket ${READY_STATE[state]}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const code = pick(err, "code");
|
|
59
|
+
if (typeof code === "number") parts.push(`code ${code}`);
|
|
60
|
+
|
|
61
|
+
const reason = pick(err, "reason");
|
|
62
|
+
if (typeof reason === "string" && reason !== "") parts.push(`reason "${reason}"`);
|
|
63
|
+
|
|
64
|
+
// A `error` event on a socket still CONNECTING means the connection was
|
|
65
|
+
// never established -- DNS, TLS, a proxy or a blocked port -- rather than
|
|
66
|
+
// anything the peers said to each other. That distinction is the whole
|
|
67
|
+
// value of printing the state.
|
|
68
|
+
return parts.join(", ");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
return String(err);
|
|
73
|
+
} catch {
|
|
74
|
+
return "an error that could not be converted to a string";
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turning a failure to get the page's ServiceWorker into an error a user can
|
|
3
|
+
* act on.
|
|
4
|
+
*
|
|
5
|
+
* THE HANG THIS USED TO GUARD. A page can load UNCONTROLLED while its worker
|
|
6
|
+
* is already active -- a HARD RELOAD (Ctrl+Shift+R; Firefox's
|
|
7
|
+
* `location.reload(true)`) bypasses the worker by spec, and Firefox has left a
|
|
8
|
+
* second tab uncontrolled too. The worker claimed its clients when it
|
|
9
|
+
* activated, long ago, so no `controllerchange` ever comes. Up to
|
|
10
|
+
* `@statewalker/webrun-http-browser` 0.4, `SwHttpAdapter.start()` then waited
|
|
11
|
+
* forever: llm-chat's mesh.html sat at "Joining… (mounting-edge)" after every
|
|
12
|
+
* hard reload, and this file carried a reload guard of its own.
|
|
13
|
+
*
|
|
14
|
+
* SINCE 0.5 THE LIBRARY RECOVERS IT ITSELF. `start()` asks the active worker to
|
|
15
|
+
* claim the page again (a `CLAIM` call, answered with `clients.claim()` by the
|
|
16
|
+
* package's own `sw-worker` -- which is what this stack's `/sw.js` is), so a
|
|
17
|
+
* hard-reloaded page is taken over in place, with no navigation. Every wait is
|
|
18
|
+
* bounded by `timeout`; past it `start()` rejects with a
|
|
19
|
+
* `ServiceWorkerControlError` whose `reason` says which wait failed. And with
|
|
20
|
+
* `reloadIfUncontrolled` it reloads once, guarded by `sessionStorage`, when the
|
|
21
|
+
* claim did not take. Measured in Chromium and Firefox by
|
|
22
|
+
* `packages/httpeers-browser-conformance/scripts/edge-reload.mjs`.
|
|
23
|
+
*
|
|
24
|
+
* What is left here is the message: `edgeStartError` maps the library's error
|
|
25
|
+
* onto the ones `mountEdge` has always thrown, so a page shows "close the tab
|
|
26
|
+
* and reopen it" rather than a library's diagnostic. It matches on `name` and
|
|
27
|
+
* `reason`, not `instanceof`: every bundle of webrun-http-browser carries its
|
|
28
|
+
* own copy of the class.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/** The error a page shows when it cannot get its ServiceWorker's control. */
|
|
32
|
+
export class UncontrolledPageError extends Error {
|
|
33
|
+
constructor(detail: string, options?: { cause?: unknown }) {
|
|
34
|
+
super(
|
|
35
|
+
"This page isn't controlled by its ServiceWorker, so the mesh cannot be reached through " +
|
|
36
|
+
`it — close the tab and reopen it. (${detail})`,
|
|
37
|
+
options,
|
|
38
|
+
);
|
|
39
|
+
this.name = "UncontrolledPageError";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The shape of webrun-http-browser's `ServiceWorkerControlError`, matched structurally. */
|
|
44
|
+
interface ControlErrorLike {
|
|
45
|
+
name: "ServiceWorkerControlError";
|
|
46
|
+
reason: string;
|
|
47
|
+
message: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function isControlError(error: unknown): error is ControlErrorLike {
|
|
51
|
+
return (
|
|
52
|
+
error instanceof Error &&
|
|
53
|
+
error.name === "ServiceWorkerControlError" &&
|
|
54
|
+
typeof (error as Partial<ControlErrorLike>).reason === "string"
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The error `mountEdge` throws when `SwHttpAdapter.start()` rejects:
|
|
60
|
+
*
|
|
61
|
+
* - `reason: "uncontrolled"` -- the worker is active but did not take the
|
|
62
|
+
* page when asked, and the one automatic reload was already spent (or
|
|
63
|
+
* could not be guarded) -> `UncontrolledPageError`;
|
|
64
|
+
* - `"activation-timeout"` / `"unresponsive"` -- the worker never activated,
|
|
65
|
+
* or controls the page but did not answer -> an `Error` naming the worker
|
|
66
|
+
* and the bound, and saying what to do;
|
|
67
|
+
* - anything else is not a control failure and is returned unchanged.
|
|
68
|
+
*
|
|
69
|
+
* The library's own error is kept as `cause`, for DevTools.
|
|
70
|
+
*/
|
|
71
|
+
export function edgeStartError(
|
|
72
|
+
error: unknown,
|
|
73
|
+
serviceWorkerUrl: string,
|
|
74
|
+
timeoutMs: number,
|
|
75
|
+
): unknown {
|
|
76
|
+
if (!isControlError(error)) return error;
|
|
77
|
+
if (error.reason === "uncontrolled") {
|
|
78
|
+
return new UncontrolledPageError(
|
|
79
|
+
"its ServiceWorker is active but did not take control when asked, and the page " +
|
|
80
|
+
"has already been reloaded once to recover, or could not be",
|
|
81
|
+
{ cause: error },
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
const what =
|
|
85
|
+
error.reason === "activation-timeout"
|
|
86
|
+
? "did not activate"
|
|
87
|
+
: "did not answer the page, although it controls it,";
|
|
88
|
+
return new Error(
|
|
89
|
+
`mountEdge: the ServiceWorker ${serviceWorkerUrl} ${what} within ` +
|
|
90
|
+
`${Math.round(timeoutMs / 1000)} s. Close the tab and reopen it; if that does not help, ` +
|
|
91
|
+
"clear this site's data in the browser to remove the ServiceWorker and start clean.",
|
|
92
|
+
{ cause: error },
|
|
93
|
+
);
|
|
94
|
+
}
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one module that sits between a page's own `fetch()` and this peer's
|
|
3
|
+
* `dispatch`. Everything a request needs in order to survive the crossing
|
|
4
|
+
* page -> ServiceWorker -> `SwHttpAdapter` -> `peer.dispatch` lives here,
|
|
5
|
+
* and nowhere else -- above all, NOT in the page.
|
|
6
|
+
*
|
|
7
|
+
* IT EXISTS SO THAT A PAGE IS AN ORDINARY HTTP CLIENT. The project's
|
|
8
|
+
* central bet is that application code calls a mesh peer with a plain
|
|
9
|
+
* `fetch()` -- no SDK, no client library. A page that had to hand-assemble
|
|
10
|
+
* a bearer header on every call, strip a mount prefix, or parse an error
|
|
11
|
+
* message string to tell "gone" from "refused" would have an SDK; it would
|
|
12
|
+
* just be an undocumented one, reimplemented per page. This module is the
|
|
13
|
+
* runtime's side of that bet.
|
|
14
|
+
*
|
|
15
|
+
* Kept dependency-free of libp2p AND of `@statewalker/webrun-http-browser`
|
|
16
|
+
* on purpose (only `httpeers.core`, all of it pure): that is what lets
|
|
17
|
+
* every rule below be proven under plain Node in `tests/browser-edge-
|
|
18
|
+
* dispatch.test.ts` rather than deferred to Task 15's Playwright run.
|
|
19
|
+
*
|
|
20
|
+
* THREE JOBS, IN ORDER.
|
|
21
|
+
*
|
|
22
|
+
* 1. STRIP THE EDGE'S OWN MOUNT PREFIX. Verified directly against
|
|
23
|
+
* `@statewalker/webrun-http-browser@workspace:0.3.3`, not assumed:
|
|
24
|
+
* `SwHttpAdapter._handleHttpRequest` (`src/sw/http-sw-dispatcher.ts`)
|
|
25
|
+
* selects a handler by `requestUrl.indexOf(urlPrefix) === 0` and then
|
|
26
|
+
* calls `handler(request)` with the ORIGINAL, UNMODIFIED request. So a
|
|
27
|
+
* page fetching `${baseUrl}${peerId}/search` -- where `baseUrl` ends in
|
|
28
|
+
* `/${key}/`, and `key` must be the first path segment
|
|
29
|
+
* (`edge-guard.ts`'s `assertKeyMatchesPrefix`; `SwHttpDispatcher` keys
|
|
30
|
+
* its channel lookup on that segment, so mounting at `/` is not
|
|
31
|
+
* available) -- reaches `dispatch` with pathname
|
|
32
|
+
* `/${key}/${peerId}/search`. `httpeers.core`'s router
|
|
33
|
+
* (`router.ts`'s `route`) reads the FIRST segment and asks
|
|
34
|
+
* `looksLikePeerId`; `key` is not a peer id, so the request is served
|
|
35
|
+
* LOCALLY against a path no mount claims -- a 404 whose body says "not
|
|
36
|
+
* found", three layers away from the mistake. Stripping the prefix here
|
|
37
|
+
* is what makes the router see `/${peerId}/search`, which is the shape
|
|
38
|
+
* it documents.
|
|
39
|
+
*
|
|
40
|
+
* 2. ATTACH THE MEMBERSHIP TOKEN (Ruling 59). `peer.call()` turns a
|
|
41
|
+
* `{ token }` option into a `MESH_TOKEN_HEADER` header (`peer-request.ts`);
|
|
42
|
+
* `fetch()` has no such option, so without this every mesh call from a
|
|
43
|
+
* page would arrive at the provider tokenless and be refused 401. The
|
|
44
|
+
* token is NOT the page's to hold: it rotates on each 5 s heartbeat
|
|
45
|
+
* inside `startJoin`'s closure (`join.ts`), so a page that read one and
|
|
46
|
+
* kept it would be sending a stale token within seconds. `token()` is
|
|
47
|
+
* therefore called per request, never snapshotted.
|
|
48
|
+
*
|
|
49
|
+
* 3. TURN A THROWN `PeerCallError` INTO A RESPONSE (Ruling 60). `remote()`
|
|
50
|
+
* THROWS T-2's typed error; nothing between it and the page converts it
|
|
51
|
+
* to a Response, and `SwHttpDispatcher`'s own catch
|
|
52
|
+
* (`http-sw-dispatcher.ts`) collapses any thrown handler error into a
|
|
53
|
+
* bare 500 with no body. A page would then see one indistinguishable
|
|
54
|
+
* 500 for a departed peer, a reset stream and a timeout -- precisely the
|
|
55
|
+
* "no way to tell 'this peer is gone' from 'this peer refused you'
|
|
56
|
+
* without parsing a message string" that `httpeers.core`'s `errors.ts`
|
|
57
|
+
* exists to end. The mapping below forwards `kind` in the body so the
|
|
58
|
+
* page can `switch` on it.
|
|
59
|
+
*
|
|
60
|
+
* WHY THE STATUS TABLE LIVES IN THE APP AND NOT IN `httpeers.core`. Same
|
|
61
|
+
* split the core already uses for `allowForward` (supplied by the caller,
|
|
62
|
+
* never decided in the router): a status code is policy. `errors.ts`
|
|
63
|
+
* anticipated this crossing -- `kind` exists, in its own words, "so a
|
|
64
|
+
* caller can `switch` on it, or forward it across a boundary that cannot
|
|
65
|
+
* carry a class reference" -- but anticipating the crossing is not owning
|
|
66
|
+
* the status choice.
|
|
67
|
+
*
|
|
68
|
+
* 4. MAKE SURE A ROUTE TO THE TARGET PEER EXISTS FIRST (Task 15). Added
|
|
69
|
+
* after the Playwright suite found that this had NEVER worked: a page
|
|
70
|
+
* calling ANOTHER PAGE got `peer-unreachable`, every time, while a Node
|
|
71
|
+
* peer calling that same browser provider over the same relay got a 200.
|
|
72
|
+
* The cause is not in the provider and not in the transport --
|
|
73
|
+
* `httpeers.core`'s `remote()` dials by PEER ID, which resolves through
|
|
74
|
+
* libp2p's peerStore, and a browser peer's peerStore entry for another
|
|
75
|
+
* browser peer is either absent or a bare `/p2p-circuit` address that
|
|
76
|
+
* libp2p refuses to open `/httpeers/1.0.0` over. Something has to dial
|
|
77
|
+
* `<relay>/p2p-circuit/webrtc/p2p/<peer>` explicitly, exactly as
|
|
78
|
+
* `peer-runtime.ts` already does for the HUB at startup, and the page
|
|
79
|
+
* cannot be the thing that does it without becoming the SDK this module
|
|
80
|
+
* exists to prevent. So the edge does it, through the `ensureRoute` hook
|
|
81
|
+
* below -- a callback, so this module stays libp2p-free and testable
|
|
82
|
+
* under plain Node (see the note above).
|
|
83
|
+
*
|
|
84
|
+
* IT NEVER FAILS THE CALL ITSELF. A dial that throws is swallowed and
|
|
85
|
+
* `dispatch` is attempted anyway: the call that follows produces T-2's
|
|
86
|
+
* own typed `PeerCallError` -- with the right `kind` for the page to
|
|
87
|
+
* render -- whereas a rethrow here would surface as an untyped 500 from
|
|
88
|
+
* `SwHttpDispatcher`'s catch and lose exactly the distinction job 3
|
|
89
|
+
* exists to preserve.
|
|
90
|
+
*
|
|
91
|
+
* THIS IS A LOCAL INGRESS, AND ONLY THAT. Traffic arriving from other peers
|
|
92
|
+
* over libp2p does NOT come through here -- `serveTransport` dispatches it to
|
|
93
|
+
* `peer.dispatch` directly, which is what keeps our token off it (handing our
|
|
94
|
+
* token to inbound traffic would let any peer that can reach us borrow our
|
|
95
|
+
* membership for a call to a third party).
|
|
96
|
+
*
|
|
97
|
+
* So the first thing this does is STRIP the proven-peer header. A page builds
|
|
98
|
+
* the requests that enter its own edge; a header here is a claim, not a fact,
|
|
99
|
+
* and nothing downstream can tell the difference. An earlier version branched
|
|
100
|
+
* on `lookupPeer(req) !== undefined` to mean "arrived from the network" --
|
|
101
|
+
* correct while that binding was a WeakMap a page could not write, and a
|
|
102
|
+
* forgery hole the moment it became a header. `tests/ingress-forgery.test.ts`
|
|
103
|
+
* holds that shut.
|
|
104
|
+
*/
|
|
105
|
+
import type { FetchHandler, PeerErrorKind, PeerIdStr } from "@statewalker/httpeers-core";
|
|
106
|
+
import {
|
|
107
|
+
bodyOf,
|
|
108
|
+
json,
|
|
109
|
+
MESH_TOKEN_HEADER,
|
|
110
|
+
PeerCallError,
|
|
111
|
+
setMeshToken,
|
|
112
|
+
stripPeerBinding,
|
|
113
|
+
} from "@statewalker/httpeers-core";
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* `PeerErrorKind` -> HTTP status, the whole table.
|
|
117
|
+
*
|
|
118
|
+
* A timeout is a 504 (`Gateway Timeout`) because that is literally what
|
|
119
|
+
* happened: this peer, acting as the page's gateway onto the mesh, gave up
|
|
120
|
+
* waiting for the peer behind it. Everything else is a 502 (`Bad Gateway`):
|
|
121
|
+
* the upstream peer could not be reached, does not speak the protocol, reset
|
|
122
|
+
* the stream, blew a relay limit, or offered only a limited connection the
|
|
123
|
+
* call did not opt in to -- all of them "the gateway could not
|
|
124
|
+
* get an answer from upstream", none of them a fault in the request the page
|
|
125
|
+
* made. Deliberately never a 5xx that would blame the page's own request
|
|
126
|
+
* (a 400-family status), and never a 500: a 500 here is reserved for a
|
|
127
|
+
* genuine bug (see `createEdgeDispatch`'s rethrow).
|
|
128
|
+
*/
|
|
129
|
+
export const PEER_ERROR_STATUS: Readonly<Record<PeerErrorKind, number>> = {
|
|
130
|
+
"request-timeout": 504,
|
|
131
|
+
"peer-unreachable": 502,
|
|
132
|
+
"protocol-unsupported": 502,
|
|
133
|
+
"stream-reset": 502,
|
|
134
|
+
"relay-limit-exceeded": 502,
|
|
135
|
+
"limited-connection": 502,
|
|
136
|
+
unknown: 502,
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/** The body shape a page reads back from a failed mesh call. `kind` is the discriminant to `switch` on; `error` is for a human, never for a `switch`. */
|
|
140
|
+
export interface PeerErrorBody {
|
|
141
|
+
error: string;
|
|
142
|
+
kind: PeerErrorKind;
|
|
143
|
+
peerId: string;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export interface EdgeDispatchInit {
|
|
147
|
+
/** This peer's own router -- `peer.dispatch`. Never called with a modified request unless the request originated locally; see the module comment. */
|
|
148
|
+
dispatch: FetchHandler;
|
|
149
|
+
/** The ServiceWorker adapter key. Requests arriving from the page still carry `/${key}` on their path -- job 1 above. */
|
|
150
|
+
key: string;
|
|
151
|
+
/** This peer's current membership token, READ AT CALL TIME -- `JoinHandle.token`. */
|
|
152
|
+
token: () => string;
|
|
153
|
+
/**
|
|
154
|
+
* Establish a usable route to `peerId` before the call rides it -- job 4
|
|
155
|
+
* above. Optional: omitting it restores the pre-Task-15 behaviour, which
|
|
156
|
+
* is correct for a caller whose peers are already connected (every Node
|
|
157
|
+
* suite in this app) and broken for a browser page (see job 4).
|
|
158
|
+
*
|
|
159
|
+
* CALLED FOR EVERY PEER-ID-SHAPED FIRST SEGMENT, INCLUDING THIS PEER'S
|
|
160
|
+
* OWN. This module knows the shape of a peer id (`targetPeerId`) and not
|
|
161
|
+
* which one it is: `createEdgeDispatch` is never told its own peer id, and
|
|
162
|
+
* giving it one only to skip a call would duplicate a check the
|
|
163
|
+
* implementation has to make anyway. `join.ts`'s `createRouteEnsurer` is
|
|
164
|
+
* where "that is us" is recognised and returns immediately. An
|
|
165
|
+
* implementation that does not know its own id must therefore tolerate
|
|
166
|
+
* being asked to route to itself.
|
|
167
|
+
*
|
|
168
|
+
* Called once per outbound request that names a peer; an implementation is
|
|
169
|
+
* expected to be cheap when a route already exists, because it is on the
|
|
170
|
+
* path of every single mesh call the page makes.
|
|
171
|
+
*/
|
|
172
|
+
ensureRoute?: (peerId: PeerIdStr) => Promise<void>;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The peer id an outbound request is addressed to -- the first path segment,
|
|
177
|
+
* when it looks like one -- or `null` for a request this peer serves itself.
|
|
178
|
+
*
|
|
179
|
+
* THE SHAPE TEST IS A COPY, AND IT SHOULD NOT BE. `httpeers.core`'s
|
|
180
|
+
* `router.ts` has exactly this function (`looksLikePeerId`) and it is the
|
|
181
|
+
* one that decides, three layers down, whether the first segment is treated
|
|
182
|
+
* as a peer id at all. It is not exported, so this module cannot share it,
|
|
183
|
+
* and a copy that drifts from it would make `ensureRoute` dial for requests
|
|
184
|
+
* the router serves locally (harmless -- the dial is skipped for a
|
|
185
|
+
* non-member) or, worse, skip the dial for requests the router forwards
|
|
186
|
+
* (the Task 15 bug, back again). Reported to the core package rather than
|
|
187
|
+
* worked around further; see this task's report.
|
|
188
|
+
*/
|
|
189
|
+
export function targetPeerId(pathname: string): PeerIdStr | null {
|
|
190
|
+
const [, first = ""] = pathname.split("/");
|
|
191
|
+
const looksLikePeerId =
|
|
192
|
+
/^12D3Koo[A-Za-z0-9]{40,}$/.test(first) ||
|
|
193
|
+
/^Qm[1-9A-HJ-NP-Za-km-z]{44}$/.test(first) ||
|
|
194
|
+
/^k51[a-z0-9]{55,}$/.test(first);
|
|
195
|
+
return looksLikePeerId ? first : null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Removes a leading `/${key}` segment from `pathname`, respecting segment
|
|
200
|
+
* boundaries: with `key` `"app"`, `/app/x` -> `/x` and `/app` -> `/`, but
|
|
201
|
+
* `/application/x` is left alone (it is not this prefix, it merely starts
|
|
202
|
+
* with the same characters -- the same trap `httpeers.core`'s own mount
|
|
203
|
+
* matching documents in `router.ts`, "`/test` does NOT match `/testing`").
|
|
204
|
+
*
|
|
205
|
+
* Exported for its own test: this is pure string logic and there is no
|
|
206
|
+
* reason to reach it only through a Request.
|
|
207
|
+
*/
|
|
208
|
+
export function stripEdgePrefix(pathname: string, key: string): string {
|
|
209
|
+
const prefix = `/${key}`;
|
|
210
|
+
if (pathname === prefix) return "/";
|
|
211
|
+
if (pathname.startsWith(`${prefix}/`)) return pathname.slice(prefix.length);
|
|
212
|
+
return pathname;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Wraps `dispatch` with the three jobs above, for mounting on the
|
|
217
|
+
* ServiceWorker edge (`edge.ts`'s `mountEdge`). The result is still a plain
|
|
218
|
+
* `FetchHandler`, so `mountEdge` keeps passing a handler through unchanged
|
|
219
|
+
* and nothing about that contract changes.
|
|
220
|
+
*/
|
|
221
|
+
export function createEdgeDispatch(init: EdgeDispatchInit): FetchHandler {
|
|
222
|
+
const { dispatch, key } = init;
|
|
223
|
+
|
|
224
|
+
return async function edgeDispatch(req: Request): Promise<Response> {
|
|
225
|
+
// THE EDGE IS A LOCAL INGRESS, ALWAYS. Strip any proven-peer header the
|
|
226
|
+
// caller supplied: a page builds the requests that enter its own edge, so
|
|
227
|
+
// a header here is a CLAIM, never a fact, and `withAccess` downstream
|
|
228
|
+
// cannot tell the two apart.
|
|
229
|
+
//
|
|
230
|
+
// This used to return early when a binding was present, on the reading
|
|
231
|
+
// "it arrived from the network, not ours to touch". That was safe while
|
|
232
|
+
// the binding lived in a WeakMap no page could write; with a header it
|
|
233
|
+
// let a page name itself any peer AND skip the token attachment. The
|
|
234
|
+
// branch is removed rather than repaired, because a network request never
|
|
235
|
+
// reaches here anyway -- `serveTransport` dispatches to `peer.dispatch`
|
|
236
|
+
// directly. See `tests/ingress-forgery.test.ts`.
|
|
237
|
+
stripPeerBinding(req);
|
|
238
|
+
|
|
239
|
+
const url = new URL(req.url);
|
|
240
|
+
url.pathname = stripEdgePrefix(url.pathname, key);
|
|
241
|
+
// `new Request(url, req)` reads `req.body` off the init object -- and
|
|
242
|
+
// FIREFOX HAS NO `Request.prototype.body` (checked against 155), so that
|
|
243
|
+
// read is `undefined` there and the outbound request loses its body
|
|
244
|
+
// entirely, silently. Rebuilt explicitly through `bodyOf`, the one copy
|
|
245
|
+
// of this logic, exactly as `gateway.ts` and the ghost's `pin.ts` already
|
|
246
|
+
// do for the same reason.
|
|
247
|
+
const body = await bodyOf(req);
|
|
248
|
+
const outbound = new Request(url, {
|
|
249
|
+
method: req.method,
|
|
250
|
+
headers: req.headers,
|
|
251
|
+
body,
|
|
252
|
+
...(body instanceof ReadableStream ? { duplex: "half" as const } : {}),
|
|
253
|
+
signal: req.signal,
|
|
254
|
+
});
|
|
255
|
+
// NEVER OVERWRITE A CALLER'S OWN MEMBERSHIP TOKEN. A page that set one
|
|
256
|
+
// deliberately -- calling a peer with a token it was handed out of band,
|
|
257
|
+
// or testing what a provider does with a bad one -- meant it, and
|
|
258
|
+
// silently replacing it would make that call untestable and its failure
|
|
259
|
+
// inexplicable.
|
|
260
|
+
//
|
|
261
|
+
// `Authorization` is not looked at at all. It is the page's header for
|
|
262
|
+
// the application it is calling (LiteLLM's dashboard puts its own key
|
|
263
|
+
// there); when the token lived in it too, a page that set it got no mesh
|
|
264
|
+
// token and the provider refused the application key `malformed-token`.
|
|
265
|
+
if (!outbound.headers.has(MESH_TOKEN_HEADER)) {
|
|
266
|
+
setMeshToken(outbound.headers, init.token());
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Job 4. Before the call, not after a failure: a retry-on-failure shape
|
|
270
|
+
// would turn every genuinely-unreachable peer into two attempts and a
|
|
271
|
+
// doubled wait, and it would still be wrong for the case that matters
|
|
272
|
+
// (the first call to a peer this page has never dialed, which is most
|
|
273
|
+
// of them).
|
|
274
|
+
const target = targetPeerId(url.pathname);
|
|
275
|
+
if (init.ensureRoute != null && target != null) {
|
|
276
|
+
// Swallowed deliberately -- see job 4's second paragraph.
|
|
277
|
+
await init.ensureRoute(target).catch((err: unknown) => {
|
|
278
|
+
console.warn(`edge: could not establish a route to ${target}:`, err);
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
try {
|
|
283
|
+
return await dispatch(outbound);
|
|
284
|
+
} catch (err) {
|
|
285
|
+
// `instanceof` ONLY, and rethrow everything else. A genuine bug in a
|
|
286
|
+
// handler must still surface as `SwHttpDispatcher`'s 500 rather than
|
|
287
|
+
// be dressed up as a network condition the page will politely offer
|
|
288
|
+
// to retry -- the failure mode this mapping exists to prevent, turned
|
|
289
|
+
// inside out.
|
|
290
|
+
if (!(err instanceof PeerCallError)) throw err;
|
|
291
|
+
const body: PeerErrorBody = { error: err.message, kind: err.kind, peerId: err.peerId };
|
|
292
|
+
return json(body, PEER_ERROR_STATUS[err.kind]);
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
}
|