@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.
Files changed (95) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +203 -0
  3. package/dist/browser-platform.d.ts +30 -0
  4. package/dist/browser-platform.d.ts.map +1 -0
  5. package/dist/browser-platform.js +30 -0
  6. package/dist/browser-profile.d.ts +85 -0
  7. package/dist/browser-profile.d.ts.map +1 -0
  8. package/dist/browser-profile.js +120 -0
  9. package/dist/browser.d.ts +48 -0
  10. package/dist/browser.d.ts.map +1 -0
  11. package/dist/browser.js +51 -0
  12. package/dist/connection-kind.d.ts +36 -0
  13. package/dist/connection-kind.d.ts.map +1 -0
  14. package/dist/connection-kind.js +52 -0
  15. package/dist/describe-error.d.ts +18 -0
  16. package/dist/describe-error.d.ts.map +1 -0
  17. package/dist/describe-error.js +72 -0
  18. package/dist/edge-control.d.ts +50 -0
  19. package/dist/edge-control.d.ts.map +1 -0
  20. package/dist/edge-control.js +69 -0
  21. package/dist/edge-dispatch.d.ts +188 -0
  22. package/dist/edge-dispatch.d.ts.map +1 -0
  23. package/dist/edge-dispatch.js +142 -0
  24. package/dist/edge-guard.d.ts +45 -0
  25. package/dist/edge-guard.d.ts.map +1 -0
  26. package/dist/edge-guard.js +62 -0
  27. package/dist/edge.d.ts +97 -0
  28. package/dist/edge.d.ts.map +1 -0
  29. package/dist/edge.js +104 -0
  30. package/dist/gateway.d.ts +49 -0
  31. package/dist/gateway.d.ts.map +1 -0
  32. package/dist/gateway.js +102 -0
  33. package/dist/identity.d.ts +78 -0
  34. package/dist/identity.d.ts.map +1 -0
  35. package/dist/identity.js +117 -0
  36. package/dist/index.d.ts +39 -0
  37. package/dist/index.d.ts.map +1 -0
  38. package/dist/index.js +37 -0
  39. package/dist/join-blob.d.ts +125 -0
  40. package/dist/join-blob.d.ts.map +1 -0
  41. package/dist/join-blob.js +195 -0
  42. package/dist/join.d.ts +343 -0
  43. package/dist/join.d.ts.map +1 -0
  44. package/dist/join.js +409 -0
  45. package/dist/kv.d.ts +20 -0
  46. package/dist/kv.d.ts.map +1 -0
  47. package/dist/kv.js +10 -0
  48. package/dist/mesh-config.d.ts +26 -0
  49. package/dist/mesh-config.d.ts.map +1 -0
  50. package/dist/mesh-config.js +39 -0
  51. package/dist/mesh-memory.d.ts +55 -0
  52. package/dist/mesh-memory.d.ts.map +1 -0
  53. package/dist/mesh-memory.js +74 -0
  54. package/dist/node.d.ts +29 -0
  55. package/dist/node.d.ts.map +1 -0
  56. package/dist/node.js +42 -0
  57. package/dist/page-wake.d.ts +24 -0
  58. package/dist/page-wake.d.ts.map +1 -0
  59. package/dist/page-wake.js +31 -0
  60. package/dist/peer-request.d.ts +29 -0
  61. package/dist/peer-request.d.ts.map +1 -0
  62. package/dist/peer-request.js +11 -0
  63. package/dist/reset.d.ts +56 -0
  64. package/dist/reset.d.ts.map +1 -0
  65. package/dist/reset.js +103 -0
  66. package/dist/session.d.ts +214 -0
  67. package/dist/session.d.ts.map +1 -0
  68. package/dist/session.js +403 -0
  69. package/dist/start-member.d.ts +176 -0
  70. package/dist/start-member.d.ts.map +1 -0
  71. package/dist/start-member.js +307 -0
  72. package/package.json +120 -0
  73. package/src/browser-platform.ts +53 -0
  74. package/src/browser-profile.ts +179 -0
  75. package/src/browser.ts +103 -0
  76. package/src/connection-kind.ts +53 -0
  77. package/src/describe-error.ts +76 -0
  78. package/src/edge-control.ts +94 -0
  79. package/src/edge-dispatch.ts +295 -0
  80. package/src/edge-guard.ts +67 -0
  81. package/src/edge.ts +175 -0
  82. package/src/gateway.ts +141 -0
  83. package/src/identity.ts +146 -0
  84. package/src/index.ts +95 -0
  85. package/src/join-blob.ts +229 -0
  86. package/src/join.ts +632 -0
  87. package/src/kv.ts +21 -0
  88. package/src/mesh-config.ts +41 -0
  89. package/src/mesh-memory.ts +104 -0
  90. package/src/node.ts +49 -0
  91. package/src/page-wake.ts +59 -0
  92. package/src/peer-request.ts +39 -0
  93. package/src/reset.ts +127 -0
  94. package/src/session.ts +579 -0
  95. 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
+ }