@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
@@ -0,0 +1,52 @@
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
+ * Classify the connections open to one peer, from their multiaddrs.
26
+ *
27
+ * `/webrtc` present means the WebRTC upgrade completed and the relay is no
28
+ * longer in the data path. A `/p2p-circuit` WITHOUT it means it did not, and
29
+ * traffic is being relayed. Direct wins when both are open, because that is the
30
+ * one libp2p will actually use.
31
+ */
32
+ export function classifyConnection(remoteAddrs) {
33
+ if (remoteAddrs.length === 0)
34
+ return "none";
35
+ if (remoteAddrs.some((addr) => addr.includes("/webrtc")))
36
+ return "direct";
37
+ if (remoteAddrs.some((addr) => addr.includes("/p2p-circuit")))
38
+ return "relayed";
39
+ // Anything else -- a plain TCP or WebSocket address -- is a direct dial.
40
+ return "direct";
41
+ }
42
+ /** One line a page can render unconditionally, saying what the kind means rather than naming it. */
43
+ export function describeConnection(kind) {
44
+ switch (kind) {
45
+ case "direct":
46
+ return "direct (WebRTC) — data goes peer to peer, the relay is not involved";
47
+ case "relayed":
48
+ return "relayed — the WebRTC upgrade did not succeed, so every byte crosses the relay: expect it to be slow";
49
+ case "none":
50
+ return "not connected yet";
51
+ }
52
+ }
@@ -0,0 +1,18 @@
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
+ export declare function describeError(err: unknown): string;
18
+ //# sourceMappingURL=describe-error.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"describe-error.d.ts","sourceRoot":"","sources":["../src/describe-error.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAgBH,wBAAgB,aAAa,CAAC,GAAG,EAAE,OAAO,GAAG,MAAM,CA4ClD"}
@@ -0,0 +1,72 @@
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
+ /** WebSocket.readyState, in words. The bare number tells a reader nothing. */
18
+ const READY_STATE = ["CONNECTING", "OPEN", "CLOSING", "CLOSED"];
19
+ function pick(obj, key) {
20
+ try {
21
+ return obj != null && typeof obj === "object"
22
+ ? obj[key]
23
+ : undefined;
24
+ }
25
+ catch {
26
+ // A getter that throws must not take the error report down with it.
27
+ return undefined;
28
+ }
29
+ }
30
+ export function describeError(err) {
31
+ if (err instanceof Error) {
32
+ const cause = err.cause;
33
+ if (cause == null || cause === err)
34
+ return err.message;
35
+ // Compare the DESCRIPTIONS, not the objects. libp2p wraps a WebSocket
36
+ // error event in an Error whose `cause` is a different object describing
37
+ // the same failure, so identity comparison lets it print twice --
38
+ // "socket CLOSED (cause: ... socket CLOSED)" -- which reads like two
39
+ // problems.
40
+ const described = describeError(cause);
41
+ return err.message.includes(described) ? err.message : `${err.message} (cause: ${described})`;
42
+ }
43
+ const type = pick(err, "type");
44
+ if (typeof type === "string") {
45
+ const target = pick(err, "target");
46
+ const parts = [`${type} event`];
47
+ const url = pick(target, "url");
48
+ if (typeof url === "string" && url !== "")
49
+ parts.push(url);
50
+ const state = pick(target, "readyState");
51
+ if (typeof state === "number" && READY_STATE[state] != null) {
52
+ parts.push(`socket ${READY_STATE[state]}`);
53
+ }
54
+ const code = pick(err, "code");
55
+ if (typeof code === "number")
56
+ parts.push(`code ${code}`);
57
+ const reason = pick(err, "reason");
58
+ if (typeof reason === "string" && reason !== "")
59
+ parts.push(`reason "${reason}"`);
60
+ // A `error` event on a socket still CONNECTING means the connection was
61
+ // never established -- DNS, TLS, a proxy or a blocked port -- rather than
62
+ // anything the peers said to each other. That distinction is the whole
63
+ // value of printing the state.
64
+ return parts.join(", ");
65
+ }
66
+ try {
67
+ return String(err);
68
+ }
69
+ catch {
70
+ return "an error that could not be converted to a string";
71
+ }
72
+ }
@@ -0,0 +1,50 @@
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
+ /** The error a page shows when it cannot get its ServiceWorker's control. */
31
+ export declare class UncontrolledPageError extends Error {
32
+ constructor(detail: string, options?: {
33
+ cause?: unknown;
34
+ });
35
+ }
36
+ /**
37
+ * The error `mountEdge` throws when `SwHttpAdapter.start()` rejects:
38
+ *
39
+ * - `reason: "uncontrolled"` -- the worker is active but did not take the
40
+ * page when asked, and the one automatic reload was already spent (or
41
+ * could not be guarded) -> `UncontrolledPageError`;
42
+ * - `"activation-timeout"` / `"unresponsive"` -- the worker never activated,
43
+ * or controls the page but did not answer -> an `Error` naming the worker
44
+ * and the bound, and saying what to do;
45
+ * - anything else is not a control failure and is returned unchanged.
46
+ *
47
+ * The library's own error is kept as `cause`, for DevTools.
48
+ */
49
+ export declare function edgeStartError(error: unknown, serviceWorkerUrl: string, timeoutMs: number): unknown;
50
+ //# sourceMappingURL=edge-control.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"edge-control.d.ts","sourceRoot":"","sources":["../src/edge-control.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAEH,6EAA6E;AAC7E,qBAAa,qBAAsB,SAAQ,KAAK;IAC9C,YAAY,MAAM,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,OAAO,CAAA;KAAE,EAOxD;CACF;AAiBD;;;;;;;;;;;;GAYG;AACH,wBAAgB,cAAc,CAC5B,KAAK,EAAE,OAAO,EACd,gBAAgB,EAAE,MAAM,EACxB,SAAS,EAAE,MAAM,GAChB,OAAO,CAmBT"}
@@ -0,0 +1,69 @@
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
+ /** The error a page shows when it cannot get its ServiceWorker's control. */
31
+ export class UncontrolledPageError extends Error {
32
+ constructor(detail, options) {
33
+ super("This page isn't controlled by its ServiceWorker, so the mesh cannot be reached through " +
34
+ `it — close the tab and reopen it. (${detail})`, options);
35
+ this.name = "UncontrolledPageError";
36
+ }
37
+ }
38
+ function isControlError(error) {
39
+ return (error instanceof Error &&
40
+ error.name === "ServiceWorkerControlError" &&
41
+ typeof error.reason === "string");
42
+ }
43
+ /**
44
+ * The error `mountEdge` throws when `SwHttpAdapter.start()` rejects:
45
+ *
46
+ * - `reason: "uncontrolled"` -- the worker is active but did not take the
47
+ * page when asked, and the one automatic reload was already spent (or
48
+ * could not be guarded) -> `UncontrolledPageError`;
49
+ * - `"activation-timeout"` / `"unresponsive"` -- the worker never activated,
50
+ * or controls the page but did not answer -> an `Error` naming the worker
51
+ * and the bound, and saying what to do;
52
+ * - anything else is not a control failure and is returned unchanged.
53
+ *
54
+ * The library's own error is kept as `cause`, for DevTools.
55
+ */
56
+ export function edgeStartError(error, serviceWorkerUrl, timeoutMs) {
57
+ if (!isControlError(error))
58
+ return error;
59
+ if (error.reason === "uncontrolled") {
60
+ return new UncontrolledPageError("its ServiceWorker is active but did not take control when asked, and the page " +
61
+ "has already been reloaded once to recover, or could not be", { cause: error });
62
+ }
63
+ const what = error.reason === "activation-timeout"
64
+ ? "did not activate"
65
+ : "did not answer the page, although it controls it,";
66
+ return new Error(`mountEdge: the ServiceWorker ${serviceWorkerUrl} ${what} within ` +
67
+ `${Math.round(timeoutMs / 1000)} s. Close the tab and reopen it; if that does not help, ` +
68
+ "clear this site's data in the browser to remove the ServiceWorker and start clean.", { cause: error });
69
+ }
@@ -0,0 +1,188 @@
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
+ /**
107
+ * `PeerErrorKind` -> HTTP status, the whole table.
108
+ *
109
+ * A timeout is a 504 (`Gateway Timeout`) because that is literally what
110
+ * happened: this peer, acting as the page's gateway onto the mesh, gave up
111
+ * waiting for the peer behind it. Everything else is a 502 (`Bad Gateway`):
112
+ * the upstream peer could not be reached, does not speak the protocol, reset
113
+ * the stream, blew a relay limit, or offered only a limited connection the
114
+ * call did not opt in to -- all of them "the gateway could not
115
+ * get an answer from upstream", none of them a fault in the request the page
116
+ * made. Deliberately never a 5xx that would blame the page's own request
117
+ * (a 400-family status), and never a 500: a 500 here is reserved for a
118
+ * genuine bug (see `createEdgeDispatch`'s rethrow).
119
+ */
120
+ export declare const PEER_ERROR_STATUS: Readonly<Record<PeerErrorKind, number>>;
121
+ /** 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`. */
122
+ export interface PeerErrorBody {
123
+ error: string;
124
+ kind: PeerErrorKind;
125
+ peerId: string;
126
+ }
127
+ export interface EdgeDispatchInit {
128
+ /** This peer's own router -- `peer.dispatch`. Never called with a modified request unless the request originated locally; see the module comment. */
129
+ dispatch: FetchHandler;
130
+ /** The ServiceWorker adapter key. Requests arriving from the page still carry `/${key}` on their path -- job 1 above. */
131
+ key: string;
132
+ /** This peer's current membership token, READ AT CALL TIME -- `JoinHandle.token`. */
133
+ token: () => string;
134
+ /**
135
+ * Establish a usable route to `peerId` before the call rides it -- job 4
136
+ * above. Optional: omitting it restores the pre-Task-15 behaviour, which
137
+ * is correct for a caller whose peers are already connected (every Node
138
+ * suite in this app) and broken for a browser page (see job 4).
139
+ *
140
+ * CALLED FOR EVERY PEER-ID-SHAPED FIRST SEGMENT, INCLUDING THIS PEER'S
141
+ * OWN. This module knows the shape of a peer id (`targetPeerId`) and not
142
+ * which one it is: `createEdgeDispatch` is never told its own peer id, and
143
+ * giving it one only to skip a call would duplicate a check the
144
+ * implementation has to make anyway. `join.ts`'s `createRouteEnsurer` is
145
+ * where "that is us" is recognised and returns immediately. An
146
+ * implementation that does not know its own id must therefore tolerate
147
+ * being asked to route to itself.
148
+ *
149
+ * Called once per outbound request that names a peer; an implementation is
150
+ * expected to be cheap when a route already exists, because it is on the
151
+ * path of every single mesh call the page makes.
152
+ */
153
+ ensureRoute?: (peerId: PeerIdStr) => Promise<void>;
154
+ }
155
+ /**
156
+ * The peer id an outbound request is addressed to -- the first path segment,
157
+ * when it looks like one -- or `null` for a request this peer serves itself.
158
+ *
159
+ * THE SHAPE TEST IS A COPY, AND IT SHOULD NOT BE. `httpeers.core`'s
160
+ * `router.ts` has exactly this function (`looksLikePeerId`) and it is the
161
+ * one that decides, three layers down, whether the first segment is treated
162
+ * as a peer id at all. It is not exported, so this module cannot share it,
163
+ * and a copy that drifts from it would make `ensureRoute` dial for requests
164
+ * the router serves locally (harmless -- the dial is skipped for a
165
+ * non-member) or, worse, skip the dial for requests the router forwards
166
+ * (the Task 15 bug, back again). Reported to the core package rather than
167
+ * worked around further; see this task's report.
168
+ */
169
+ export declare function targetPeerId(pathname: string): PeerIdStr | null;
170
+ /**
171
+ * Removes a leading `/${key}` segment from `pathname`, respecting segment
172
+ * boundaries: with `key` `"app"`, `/app/x` -> `/x` and `/app` -> `/`, but
173
+ * `/application/x` is left alone (it is not this prefix, it merely starts
174
+ * with the same characters -- the same trap `httpeers.core`'s own mount
175
+ * matching documents in `router.ts`, "`/test` does NOT match `/testing`").
176
+ *
177
+ * Exported for its own test: this is pure string logic and there is no
178
+ * reason to reach it only through a Request.
179
+ */
180
+ export declare function stripEdgePrefix(pathname: string, key: string): string;
181
+ /**
182
+ * Wraps `dispatch` with the three jobs above, for mounting on the
183
+ * ServiceWorker edge (`edge.ts`'s `mountEdge`). The result is still a plain
184
+ * `FetchHandler`, so `mountEdge` keeps passing a handler through unchanged
185
+ * and nothing about that contract changes.
186
+ */
187
+ export declare function createEdgeDispatch(init: EdgeDispatchInit): FetchHandler;
188
+ //# sourceMappingURL=edge-dispatch.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"edge-dispatch.d.ts","sourceRoot":"","sources":["../src/edge-dispatch.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuGG;AACH,OAAO,KAAK,EAAE,YAAY,EAAE,aAAa,EAAE,SAAS,EAAE,MAAM,4BAA4B,CAAC;AAUzF;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,iBAAiB,EAAE,QAAQ,CAAC,MAAM,CAAC,aAAa,EAAE,MAAM,CAAC,CAQrE,CAAC;AAEF,yJAAyJ;AACzJ,MAAM,WAAW,aAAa;IAC5B,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,aAAa,CAAC;IACpB,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,gBAAgB;IAC/B,qJAAqJ;IACrJ,QAAQ,EAAE,YAAY,CAAC;IACvB,yHAAyH;IACzH,GAAG,EAAE,MAAM,CAAC;IACZ,qFAAqF;IACrF,KAAK,EAAE,MAAM,MAAM,CAAC;IACpB;;;;;;;;;;;;;;;;;;OAkBG;IACH,WAAW,CAAC,EAAE,CAAC,MAAM,EAAE,SAAS,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACpD;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,YAAY,CAAC,QAAQ,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,CAO/D;AAED;;;;;;;;;GASG;AACH,wBAAgB,eAAe,CAAC,QAAQ,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,CAKrE;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,gBAAgB,GAAG,YAAY,CA0EvE"}
@@ -0,0 +1,142 @@
1
+ import { bodyOf, json, MESH_TOKEN_HEADER, PeerCallError, setMeshToken, stripPeerBinding, } from "@statewalker/httpeers-core";
2
+ /**
3
+ * `PeerErrorKind` -> HTTP status, the whole table.
4
+ *
5
+ * A timeout is a 504 (`Gateway Timeout`) because that is literally what
6
+ * happened: this peer, acting as the page's gateway onto the mesh, gave up
7
+ * waiting for the peer behind it. Everything else is a 502 (`Bad Gateway`):
8
+ * the upstream peer could not be reached, does not speak the protocol, reset
9
+ * the stream, blew a relay limit, or offered only a limited connection the
10
+ * call did not opt in to -- all of them "the gateway could not
11
+ * get an answer from upstream", none of them a fault in the request the page
12
+ * made. Deliberately never a 5xx that would blame the page's own request
13
+ * (a 400-family status), and never a 500: a 500 here is reserved for a
14
+ * genuine bug (see `createEdgeDispatch`'s rethrow).
15
+ */
16
+ export const PEER_ERROR_STATUS = {
17
+ "request-timeout": 504,
18
+ "peer-unreachable": 502,
19
+ "protocol-unsupported": 502,
20
+ "stream-reset": 502,
21
+ "relay-limit-exceeded": 502,
22
+ "limited-connection": 502,
23
+ unknown: 502,
24
+ };
25
+ /**
26
+ * The peer id an outbound request is addressed to -- the first path segment,
27
+ * when it looks like one -- or `null` for a request this peer serves itself.
28
+ *
29
+ * THE SHAPE TEST IS A COPY, AND IT SHOULD NOT BE. `httpeers.core`'s
30
+ * `router.ts` has exactly this function (`looksLikePeerId`) and it is the
31
+ * one that decides, three layers down, whether the first segment is treated
32
+ * as a peer id at all. It is not exported, so this module cannot share it,
33
+ * and a copy that drifts from it would make `ensureRoute` dial for requests
34
+ * the router serves locally (harmless -- the dial is skipped for a
35
+ * non-member) or, worse, skip the dial for requests the router forwards
36
+ * (the Task 15 bug, back again). Reported to the core package rather than
37
+ * worked around further; see this task's report.
38
+ */
39
+ export function targetPeerId(pathname) {
40
+ const [, first = ""] = pathname.split("/");
41
+ const looksLikePeerId = /^12D3Koo[A-Za-z0-9]{40,}$/.test(first) ||
42
+ /^Qm[1-9A-HJ-NP-Za-km-z]{44}$/.test(first) ||
43
+ /^k51[a-z0-9]{55,}$/.test(first);
44
+ return looksLikePeerId ? first : null;
45
+ }
46
+ /**
47
+ * Removes a leading `/${key}` segment from `pathname`, respecting segment
48
+ * boundaries: with `key` `"app"`, `/app/x` -> `/x` and `/app` -> `/`, but
49
+ * `/application/x` is left alone (it is not this prefix, it merely starts
50
+ * with the same characters -- the same trap `httpeers.core`'s own mount
51
+ * matching documents in `router.ts`, "`/test` does NOT match `/testing`").
52
+ *
53
+ * Exported for its own test: this is pure string logic and there is no
54
+ * reason to reach it only through a Request.
55
+ */
56
+ export function stripEdgePrefix(pathname, key) {
57
+ const prefix = `/${key}`;
58
+ if (pathname === prefix)
59
+ return "/";
60
+ if (pathname.startsWith(`${prefix}/`))
61
+ return pathname.slice(prefix.length);
62
+ return pathname;
63
+ }
64
+ /**
65
+ * Wraps `dispatch` with the three jobs above, for mounting on the
66
+ * ServiceWorker edge (`edge.ts`'s `mountEdge`). The result is still a plain
67
+ * `FetchHandler`, so `mountEdge` keeps passing a handler through unchanged
68
+ * and nothing about that contract changes.
69
+ */
70
+ export function createEdgeDispatch(init) {
71
+ const { dispatch, key } = init;
72
+ return async function edgeDispatch(req) {
73
+ // THE EDGE IS A LOCAL INGRESS, ALWAYS. Strip any proven-peer header the
74
+ // caller supplied: a page builds the requests that enter its own edge, so
75
+ // a header here is a CLAIM, never a fact, and `withAccess` downstream
76
+ // cannot tell the two apart.
77
+ //
78
+ // This used to return early when a binding was present, on the reading
79
+ // "it arrived from the network, not ours to touch". That was safe while
80
+ // the binding lived in a WeakMap no page could write; with a header it
81
+ // let a page name itself any peer AND skip the token attachment. The
82
+ // branch is removed rather than repaired, because a network request never
83
+ // reaches here anyway -- `serveTransport` dispatches to `peer.dispatch`
84
+ // directly. See `tests/ingress-forgery.test.ts`.
85
+ stripPeerBinding(req);
86
+ const url = new URL(req.url);
87
+ url.pathname = stripEdgePrefix(url.pathname, key);
88
+ // `new Request(url, req)` reads `req.body` off the init object -- and
89
+ // FIREFOX HAS NO `Request.prototype.body` (checked against 155), so that
90
+ // read is `undefined` there and the outbound request loses its body
91
+ // entirely, silently. Rebuilt explicitly through `bodyOf`, the one copy
92
+ // of this logic, exactly as `gateway.ts` and the ghost's `pin.ts` already
93
+ // do for the same reason.
94
+ const body = await bodyOf(req);
95
+ const outbound = new Request(url, {
96
+ method: req.method,
97
+ headers: req.headers,
98
+ body,
99
+ ...(body instanceof ReadableStream ? { duplex: "half" } : {}),
100
+ signal: req.signal,
101
+ });
102
+ // NEVER OVERWRITE A CALLER'S OWN MEMBERSHIP TOKEN. A page that set one
103
+ // deliberately -- calling a peer with a token it was handed out of band,
104
+ // or testing what a provider does with a bad one -- meant it, and
105
+ // silently replacing it would make that call untestable and its failure
106
+ // inexplicable.
107
+ //
108
+ // `Authorization` is not looked at at all. It is the page's header for
109
+ // the application it is calling (LiteLLM's dashboard puts its own key
110
+ // there); when the token lived in it too, a page that set it got no mesh
111
+ // token and the provider refused the application key `malformed-token`.
112
+ if (!outbound.headers.has(MESH_TOKEN_HEADER)) {
113
+ setMeshToken(outbound.headers, init.token());
114
+ }
115
+ // Job 4. Before the call, not after a failure: a retry-on-failure shape
116
+ // would turn every genuinely-unreachable peer into two attempts and a
117
+ // doubled wait, and it would still be wrong for the case that matters
118
+ // (the first call to a peer this page has never dialed, which is most
119
+ // of them).
120
+ const target = targetPeerId(url.pathname);
121
+ if (init.ensureRoute != null && target != null) {
122
+ // Swallowed deliberately -- see job 4's second paragraph.
123
+ await init.ensureRoute(target).catch((err) => {
124
+ console.warn(`edge: could not establish a route to ${target}:`, err);
125
+ });
126
+ }
127
+ try {
128
+ return await dispatch(outbound);
129
+ }
130
+ catch (err) {
131
+ // `instanceof` ONLY, and rethrow everything else. A genuine bug in a
132
+ // handler must still surface as `SwHttpDispatcher`'s 500 rather than
133
+ // be dressed up as a network condition the page will politely offer
134
+ // to retry -- the failure mode this mapping exists to prevent, turned
135
+ // inside out.
136
+ if (!(err instanceof PeerCallError))
137
+ throw err;
138
+ const body = { error: err.message, kind: err.kind, peerId: err.peerId };
139
+ return json(body, PEER_ERROR_STATUS[err.kind]);
140
+ }
141
+ };
142
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * The key/prefix guard, factored out of `edge.ts` on its own -- see that
3
+ * module's doc comment for the full story of the trap this closes (design
4
+ * note 39 §3, work item 1). Kept dependency-free and in its own file
5
+ * DELIBERATELY: it is pure logic that needs no browser, but `edge.ts`
6
+ * itself imports `@statewalker/webrun-http-browser/sw`, whose workspace
7
+ * build has no committed `dist/` (note 39's own "published 0.3.3 and
8
+ * workspace 0.3.3 are different artefacts" finding -- see this task's
9
+ * report). Importing THIS file instead of `edge.ts` lets the guard's own
10
+ * unit test run without depending on that package's build state at all.
11
+ */
12
+ /**
13
+ * `@statewalker/webrun-http-browser`'s `SwHttpDispatcher` (the
14
+ * ServiceWorker side) keys a registration by the incoming URL's FIRST
15
+ * PATH SEGMENT (`sw-dispatcher.ts`'s `_handleFetchEvent`); `SwHttpAdapter`
16
+ * (the page side) registers a handler under a prefix matched against the
17
+ * FULL base URL (`http-sw-dispatcher.ts`'s `_handleHttpRequest`). Nothing
18
+ * connects the two: an adapter constructed with `key: 'peer-under-test'`
19
+ * that then calls `register('mesh/', dispatch)` reports success at every
20
+ * step -- constructed, started, registered, correct `baseUrl` -- while the
21
+ * ServiceWorker looks up channel `'mesh'`, finds nothing registered under
22
+ * that key, and the request falls through to the origin server. The
23
+ * symptom looks exactly like a routing bug in httpeers; it is not one.
24
+ *
25
+ * Refuse to start instead, the same remedy `ruleSet()` applies to a policy
26
+ * typo (`httpeers.core`'s `rules.ts`, `assertValid`) and note 39 §5
27
+ * (work item 1) prescribes here: throw before `SwHttpAdapter.register` is
28
+ * ever called, so a mismatch is a loud construction-time error rather than
29
+ * a silent 404 three layers away.
30
+ *
31
+ * THE SECOND RULE -- THE TRAILING SLASH -- IS NEWER AND HAS ITS OWN TRAP.
32
+ * `SwHttpRegistration.baseUrl` is `new URL("./" + prefix, rootUrl)`, so a
33
+ * prefix with no trailing slash (`"app"`, or `"app/sub"`) produces a
34
+ * baseUrl with none either. Since Ruling 58 that baseUrl is a published
35
+ * contract -- `BrowserPeerHandle.baseUrl`, documented as always ending in
36
+ * a slash, because every page-originated call composes
37
+ * `${baseUrl}${peerId}/...`. A slash-less baseUrl silently yields
38
+ * `/appPEERID/search`: a URL the ServiceWorker still routes (its key is
39
+ * the first segment, `appPEERID`... which matches nothing), so the request
40
+ * falls through to the origin server exactly like the first trap, and for
41
+ * a reason just as invisible. No caller does this today; the point of a
42
+ * guard is that none ever can.
43
+ */
44
+ export declare function assertKeyMatchesPrefix(key: string, prefix: string): void;
45
+ //# sourceMappingURL=edge-guard.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"edge-guard.d.ts","sourceRoot":"","sources":["../src/edge-guard.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,IAAI,CAsBxE"}