@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,67 @@
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
+ /**
14
+ * `@statewalker/webrun-http-browser`'s `SwHttpDispatcher` (the
15
+ * ServiceWorker side) keys a registration by the incoming URL's FIRST
16
+ * PATH SEGMENT (`sw-dispatcher.ts`'s `_handleFetchEvent`); `SwHttpAdapter`
17
+ * (the page side) registers a handler under a prefix matched against the
18
+ * FULL base URL (`http-sw-dispatcher.ts`'s `_handleHttpRequest`). Nothing
19
+ * connects the two: an adapter constructed with `key: 'peer-under-test'`
20
+ * that then calls `register('mesh/', dispatch)` reports success at every
21
+ * step -- constructed, started, registered, correct `baseUrl` -- while the
22
+ * ServiceWorker looks up channel `'mesh'`, finds nothing registered under
23
+ * that key, and the request falls through to the origin server. The
24
+ * symptom looks exactly like a routing bug in httpeers; it is not one.
25
+ *
26
+ * Refuse to start instead, the same remedy `ruleSet()` applies to a policy
27
+ * typo (`httpeers.core`'s `rules.ts`, `assertValid`) and note 39 §5
28
+ * (work item 1) prescribes here: throw before `SwHttpAdapter.register` is
29
+ * ever called, so a mismatch is a loud construction-time error rather than
30
+ * a silent 404 three layers away.
31
+ *
32
+ * THE SECOND RULE -- THE TRAILING SLASH -- IS NEWER AND HAS ITS OWN TRAP.
33
+ * `SwHttpRegistration.baseUrl` is `new URL("./" + prefix, rootUrl)`, so a
34
+ * prefix with no trailing slash (`"app"`, or `"app/sub"`) produces a
35
+ * baseUrl with none either. Since Ruling 58 that baseUrl is a published
36
+ * contract -- `BrowserPeerHandle.baseUrl`, documented as always ending in
37
+ * a slash, because every page-originated call composes
38
+ * `${baseUrl}${peerId}/...`. A slash-less baseUrl silently yields
39
+ * `/appPEERID/search`: a URL the ServiceWorker still routes (its key is
40
+ * the first segment, `appPEERID`... which matches nothing), so the request
41
+ * falls through to the origin server exactly like the first trap, and for
42
+ * a reason just as invisible. No caller does this today; the point of a
43
+ * guard is that none ever can.
44
+ */
45
+ export function assertKeyMatchesPrefix(key: string, prefix: string): void {
46
+ const firstSegment = prefix.split("/")[0];
47
+ if (firstSegment !== key) {
48
+ throw new Error(
49
+ `mountEdge: prefix "${prefix}" does not start with adapter key "${key}" -- ` +
50
+ "the ServiceWorker dispatcher keys a registration by the URL's first path segment " +
51
+ "while the adapter matches the full base URL; a mismatch here means every mounting " +
52
+ "step reports success while the handler is never called, and the request silently " +
53
+ "falls through to the origin server (design note 39 §3). Refusing to start rather " +
54
+ "than mount a registration nothing can ever reach.",
55
+ );
56
+ }
57
+ if (!prefix.endsWith("/")) {
58
+ throw new Error(
59
+ `mountEdge: prefix "${prefix}" does not end with "/" -- the resulting baseUrl would ` +
60
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: quoting what a page writes, not interpolating.
61
+ "not either, and every mesh call a page composes as `${baseUrl}${peerId}/...` would " +
62
+ "run the two together into one segment the ServiceWorker cannot route, falling " +
63
+ "through to the origin server. `BrowserPeerHandle.baseUrl` promises a trailing " +
64
+ "slash (Ruling 58); this is what makes that promise keepable.",
65
+ );
66
+ }
67
+ }
package/src/edge.ts ADDED
@@ -0,0 +1,175 @@
1
+ /**
2
+ * The ServiceWorker edge: mounts a peer's `dispatch` into
3
+ * `@statewalker/webrun-http-browser`'s `SwHttpAdapter` so a page's own
4
+ * `fetch()` (same-origin) can reach it, no different in shape from any
5
+ * other HTTP request on the page.
6
+ *
7
+ * `dispatch` MOUNTS UNCHANGED -- NO WRAPPER, NO CAST (design note 22 §1,
8
+ * criterion 1 of prototype E-1). `httpeers.core`'s `FetchHandler` is
9
+ * `(Request) => Promise<Response>`; `SwHttpAdapter.register`'s `HttpHandler`
10
+ * (`@statewalker/webrun-http-streams`) is `(Request) => Response |
11
+ * Promise<Response>` -- the same contract, arrived at independently, which
12
+ * is exactly why `mountEdge` below passes `dispatch` straight through with
13
+ * no adapter of its own.
14
+ *
15
+ * THE KEY/PREFIX TRAP -- see `edge-guard.ts`'s `assertKeyMatchesPrefix` for
16
+ * the mechanism and why it is factored into its own file.
17
+ *
18
+ * THE `/sw` SUBPATH IS A BUILT FILE. `@statewalker/webrun-http-browser`
19
+ * (an optional peer, `^0.5.0`) points its `./sw` export at `./dist/sw.js`,
20
+ * which the published tarball ships. Inside an assembly, where the package is
21
+ * a linked workspace checkout, `dist/` is gitignored and only exists once
22
+ * `pnpm --filter @statewalker/webrun-http-browser build` has run -- and a
23
+ * stale one is bundled silently.
24
+ */
25
+ import type { FetchHandler } from "@statewalker/httpeers-core";
26
+ import { SwHttpAdapter } from "@statewalker/webrun-http-browser/sw";
27
+ import { edgeStartError } from "./edge-control.js";
28
+ import { assertKeyMatchesPrefix } from "./edge-guard.js";
29
+
30
+ export { UncontrolledPageError } from "./edge-control.js";
31
+ export { assertKeyMatchesPrefix };
32
+
33
+ /**
34
+ * The two browser globals this file names, declared LOCALLY rather than by
35
+ * adding "dom" to the package's `lib` -- see `./page-wake.ts` for the reason.
36
+ *
37
+ * `navigator` is the interesting one: @types/node declares it too, and ITS
38
+ * `Navigator` has no `serviceWorker`, so this is not merely filling a gap but
39
+ * shadowing a same-named global that means something else in the other
40
+ * runtime. Narrow on purpose -- only the members `mountEdge` reads.
41
+ */
42
+ declare const navigator: { serviceWorker?: unknown };
43
+ declare const location: { origin: string; href: string };
44
+
45
+ export interface MountEdgeInit {
46
+ /** The ServiceWorker adapter's channel key -- see `assertKeyMatchesPrefix`'s doc comment for why this must equal `prefix`'s first path segment. */
47
+ key: string;
48
+ /** Defaults to `${key}/` -- the one prefix shape `assertKeyMatchesPrefix` is guaranteed to accept. Overridable only for a caller with a real reason to diverge. */
49
+ prefix?: string;
50
+ /** Defaults to `DEFAULT_SERVICE_WORKER_URL` -- see its own comment; omitting it entirely is NOT an option this adapter survives. */
51
+ serviceWorkerUrl?: string;
52
+ /** The peer's own router. Mounted unchanged -- see this module's doc comment. */
53
+ dispatch: FetchHandler;
54
+ /**
55
+ * The bound on getting a controlling ServiceWorker, in ms -- passed to
56
+ * `SwHttpAdapter` as its `timeout`. Defaults to `DEFAULT_CONTROL_TIMEOUT_MS`.
57
+ */
58
+ controlTimeoutMs?: number;
59
+ }
60
+
61
+ /**
62
+ * How long `mountEdge` waits for its ServiceWorker to activate, take control
63
+ * of the page and answer, before giving up with an error.
64
+ *
65
+ * Before webrun-http-browser 0.5 `SwHttpAdapter.start()` had no bound, and a
66
+ * page that never got a controller -- a hard reload was the case that
67
+ * shipped; see `./edge-control.ts` -- hung at "mounting-edge" for good. The
68
+ * library now bounds every wait by its `timeout`; this is the value passed.
69
+ * A first visit installs a ~60 KB script and activates it, which takes well
70
+ * under a second on a slow link; thirty seconds is generous. It is also how
71
+ * long a worker that does NOT answer the `CLAIM` request (one older than 0.5,
72
+ * say) keeps a hard-reloaded page waiting before the one automatic reload.
73
+ */
74
+ export const DEFAULT_CONTROL_TIMEOUT_MS = 30_000;
75
+
76
+ /**
77
+ * Where each origin's ServiceWorker script lives: `/sw.js`, the dist root.
78
+ * Fixed by `../static-server/main.ts`'s own `swFile` default (it serves
79
+ * exactly that filename with `Service-Worker-Allowed: /`) and by both vite
80
+ * configs, which name the `sw` entry `sw.js` with no content hash for this
81
+ * exact reason.
82
+ *
83
+ * SUPPLYING THIS IS NOT OPTIONAL, AND OMITTING IT IS NOT A GRACEFUL
84
+ * DEGRADATION. `SwHttpAdapter`'s own defaults are mutually recursive:
85
+ * `scope` reads `serviceWorkerUrl`, which (when unset) computes
86
+ * `new URL("./index-sw.js", rootUrl)`, and `rootUrl` reads `scope` again
87
+ * (`sw-dispatcher.ts`'s three getters). With neither `scope` nor
88
+ * `serviceWorkerUrl` given, the CONSTRUCTOR -- which validates `this.scope`
89
+ * -- recurses until the stack is exhausted. Verified directly by
90
+ * constructing one under Node: `RangeError: Maximum call stack size
91
+ * exceeded`, before `start()` is ever reached. `mountEdge` previously
92
+ * forwarded `init.serviceWorkerUrl` straight through, so every caller that
93
+ * left it unset (the image peer page, Task 12) would have hit that
94
+ * RangeError the first time it ran in a real browser. Defaulting here fixes
95
+ * every such caller at once rather than asking each page to remember.
96
+ */
97
+ export const DEFAULT_SERVICE_WORKER_URL = "/sw.js";
98
+
99
+ export interface EdgeHandle {
100
+ /** The URL a same-origin `fetch()` reaches this peer's `dispatch` through. */
101
+ baseUrl: string;
102
+ stop(): Promise<void>;
103
+ }
104
+
105
+ /**
106
+ * Register a peer's `dispatch` on the ServiceWorker edge. Throws (via
107
+ * `assertKeyMatchesPrefix`) before `SwHttpAdapter` is even constructed if
108
+ * `key`/`prefix` disagree -- the failure this function exists to make
109
+ * loud instead of silent.
110
+ *
111
+ * On a page its ServiceWorker does not control (a hard reload) the adapter asks
112
+ * the worker to claim it, in place; if that does not take, it reloads the page
113
+ * once, guarded, and otherwise this throws `UncontrolledPageError`. It gives up
114
+ * after `controlTimeoutMs` rather than wait forever. See `./edge-control.ts`.
115
+ */
116
+ export async function mountEdge(init: MountEdgeInit): Promise<EdgeHandle> {
117
+ const prefix = init.prefix ?? `${init.key}/`;
118
+ assertKeyMatchesPrefix(init.key, prefix);
119
+
120
+ // SAY WHAT IS ACTUALLY WRONG. ServiceWorkers exist only in a secure context:
121
+ // `https://`, or `http://` on `localhost`/`127.0.0.1`. Served from a LAN
122
+ // address over plain HTTP -- exactly what someone does when opening these
123
+ // pages from a phone -- `navigator.serviceWorker` is `undefined`, and the
124
+ // first symptom is `TypeError: Cannot read properties of undefined (reading
125
+ // 'register')` thrown from inside the adapter, which names neither the cause
126
+ // nor the fix. This is a browser rule, not something this stack can work
127
+ // around; the deployment answer is the TLS the design already provides for
128
+ // (`TLS_CERT`/`TLS_KEY`).
129
+ if (typeof navigator === "undefined" || navigator.serviceWorker == null) {
130
+ const origin = typeof location !== "undefined" ? location.origin : "this origin";
131
+ throw new Error(
132
+ `mountEdge: this browser exposes no ServiceWorker at ${origin}, so a peer cannot be ` +
133
+ "reached through the page's own fetch(). ServiceWorkers require a secure context: " +
134
+ "serve the page over https, or open it on localhost / 127.0.0.1. " +
135
+ "(A LAN address over plain http is not a secure context, which is why this fails " +
136
+ "there but works on localhost.)",
137
+ );
138
+ }
139
+
140
+ // Resolved against the page's own origin: `SwHttpAdapter` passes this to
141
+ // `new URL(...)` with no base, so a bare "/sw.js" would throw `Invalid
142
+ // URL` rather than being treated as origin-relative.
143
+ const serviceWorkerUrl = new URL(
144
+ init.serviceWorkerUrl ?? DEFAULT_SERVICE_WORKER_URL,
145
+ location.href,
146
+ ).href;
147
+
148
+ // AN UNCONTROLLED PAGE UNDER AN ACTIVE WORKER (a hard reload): `start()` asks
149
+ // the worker to claim it (`CLAIM`), and failing that reloads once --
150
+ // `reloadIfUncontrolled`, kept as the last resort for a worker that does not
151
+ // answer `CLAIM` (an older `/sw.js` still installed in a returning visitor's
152
+ // browser, or a custom `serviceWorkerUrl`). It costs nothing when the claim
153
+ // works, and the library guards it against looping. See `./edge-control.ts`.
154
+ const timeoutMs = init.controlTimeoutMs ?? DEFAULT_CONTROL_TIMEOUT_MS;
155
+ const adapter = new SwHttpAdapter({
156
+ key: init.key,
157
+ serviceWorkerUrl,
158
+ timeout: timeoutMs,
159
+ reloadIfUncontrolled: true,
160
+ });
161
+ try {
162
+ await adapter.start();
163
+ } catch (error) {
164
+ throw edgeStartError(error, serviceWorkerUrl, timeoutMs);
165
+ }
166
+ const registration = await adapter.register(prefix, init.dispatch);
167
+
168
+ return {
169
+ baseUrl: registration.baseUrl,
170
+ async stop() {
171
+ await registration.remove();
172
+ await adapter.stop();
173
+ },
174
+ };
175
+ }
package/src/gateway.ts ADDED
@@ -0,0 +1,141 @@
1
+ /**
2
+ * 08 — the mesh as ordinary HTTP.
3
+ *
4
+ * ONE `FetchHandler` that serves every resource in the mesh at
5
+ * `/{peerId}/{servicePath}`, so whatever mounts it — `@hono/node-server`, a
6
+ * ServiceWorker, a relay — never learns that libp2p exists.
7
+ *
8
+ * BUILT ON THE PROVEN EDGE, NOT BESIDE IT. The dispatch a page already uses
9
+ * (`src/browser/edge-dispatch.ts`) does four things that took four separate
10
+ * bugs to discover: strip the mount prefix, attach the membership token at
11
+ * call time, ensure a route before the call rides it, and map a thrown
12
+ * `PeerCallError` onto a status the caller can read. This file does not
13
+ * reimplement any of them — it rewrites the request and hands it to that
14
+ * handler, which is what `MemberHandle.fetch` exposes.
15
+ *
16
+ * WHAT IS NEW HERE, and the only thing that is:
17
+ *
18
+ * 1. `basePath` is a parameter. The ServiceWorker edge is keyed to
19
+ * `/{key}/` and cannot mount at `/`; a Node server mounts wherever it
20
+ * likes, including `/`. One handler, three hosts.
21
+ * 2. A listing at `GET {basePath}/`, which enumerates peers and the KINDS
22
+ * they advertise — never URLs. The mesh view carries no service paths,
23
+ * so a listing that promised URLs would be inventing them.
24
+ * 3. Dispatch is read per request from the live mesh view, so a peer that
25
+ * joins after the server started is reachable with no rebuild.
26
+ */
27
+
28
+ import type { FetchHandler, MeshView } from "@statewalker/httpeers-core";
29
+ import { bodyOf, stripPeerBinding } from "@statewalker/httpeers-core";
30
+
31
+ /** What the gateway needs of a member. `MemberHandle` satisfies it; so does a test double. */
32
+ export interface GatewaySource {
33
+ peerId: string;
34
+ /** The proven edge dispatch: `/{key}/{peerId}/{path}` in, mesh call out. */
35
+ fetch: FetchHandler;
36
+ /** Read per request — a peer that joined a second ago must be dispatchable. */
37
+ meshView(): MeshView | null;
38
+ }
39
+
40
+ export interface GatewayInit {
41
+ source: GatewaySource;
42
+ /**
43
+ * The prefix this gateway is mounted at, stripped before dispatch.
44
+ * `""` on a Node server; `"/peers"` behind a ServiceWorker keyed `peers`.
45
+ */
46
+ basePath?: string;
47
+ /** The key the underlying edge dispatch expects as the first segment. */
48
+ edgeKey: string;
49
+ }
50
+
51
+ /** Marks the gateway's own responses so they are never mistaken for a peer's. */
52
+ export const GATEWAY_MARKER = "x-httpeers-gateway";
53
+
54
+ export function createGateway(init: GatewayInit): FetchHandler {
55
+ const base = normaliseBase(init.basePath ?? "");
56
+
57
+ return async (request: Request): Promise<Response> => {
58
+ const url = new URL(request.url);
59
+
60
+ if (base !== "" && !url.pathname.startsWith(base)) {
61
+ return marked(`not under ${base}`, 404, "outside-mount");
62
+ }
63
+ const rest = url.pathname.slice(base.length) || "/";
64
+
65
+ // The listing. Honest about what it knows: peers and advertised kinds,
66
+ // with the view's own version and an `asOf` — never a URL, because the
67
+ // mesh view carries no service paths.
68
+ if ((rest === "/" || rest === "") && request.method === "GET") {
69
+ return listing(init.source);
70
+ }
71
+
72
+ // SEGMENTS, NEVER A URL HOST. A peer id is base58 and case-sensitive; a
73
+ // URL host is lower-cased by normalisation and userinfo (`a@b`) silently
74
+ // reassigns it. The path is the only safe place to carry one.
75
+ const [, first = "", ...tail] = rest.split("/");
76
+ if (first === "") return marked("no peer in the path", 404, "no-peer");
77
+
78
+ // Hand it to the proven dispatch, spelled the way that handler expects.
79
+ const target = new URL(
80
+ `/${init.edgeKey}/${first}${tail.length > 0 ? `/${tail.join("/")}` : ""}${url.search}`,
81
+ "http://gateway.local",
82
+ );
83
+
84
+ // ORDINARY HTTP IN FRONT OF A MEMBER, so the caller is a browser or a
85
+ // curl and any proven-peer header it sent is a claim about somebody else.
86
+ // Strip before the request enters the mesh.
87
+ stripPeerBinding(request);
88
+
89
+ const body = await bodyOf(request);
90
+ const forwarded = new Request(target, {
91
+ method: request.method,
92
+ headers: request.headers,
93
+ body,
94
+ ...(body instanceof ReadableStream ? { duplex: "half" as const } : {}),
95
+ // FORWARDED DELIBERATELY. `@hono/node-server` aborts this signal when the
96
+ // client hangs up, and nothing else would stop the libp2p call — the
97
+ // stream would run to completion with nobody to receive it.
98
+ signal: request.signal,
99
+ });
100
+
101
+ return await init.source.fetch(forwarded);
102
+ };
103
+ }
104
+
105
+ /** `""` or `/a/b` — never a trailing slash, so `base.length` is a clean cut. */
106
+ function normaliseBase(value: string): string {
107
+ if (value === "" || value === "/") return "";
108
+ const withSlash = value.startsWith("/") ? value : `/${value}`;
109
+ return withSlash.endsWith("/") ? withSlash.slice(0, -1) : withSlash;
110
+ }
111
+
112
+ function listing(source: GatewaySource): Response {
113
+ const view = source.meshView();
114
+ // `null` is a real state — "no view yet" is not "nobody is here" — and a
115
+ // listing that flattened the two would be a lie on every first load.
116
+ if (view == null) {
117
+ return Response.json(
118
+ { ready: false, self: source.peerId, peers: [], offers: [] },
119
+ { headers: { [GATEWAY_MARKER]: "no-view" } },
120
+ );
121
+ }
122
+ return Response.json({
123
+ ready: true,
124
+ self: source.peerId,
125
+ version: view.version,
126
+ asOf: Date.now(),
127
+ peers: view.members.map((m) => ({ peerId: m.peerId, roles: m.roles })),
128
+ // Flat, and independent of `peers`: the hub advertises under its own id
129
+ // and is in no member list.
130
+ offers: view.advertisements.map((a) => ({
131
+ peerId: a.peerId,
132
+ id: a.id,
133
+ kind: a.kind,
134
+ title: a.title,
135
+ })),
136
+ });
137
+ }
138
+
139
+ function marked(message: string, status: number, kind: string): Response {
140
+ return new Response(message, { status, headers: { [GATEWAY_MARKER]: kind } });
141
+ }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * This origin's persistent libp2p identity, in IndexedDB.
3
+ *
4
+ * IDENTITY PERSISTS PER ORIGIN. Each page (the main app, the image peer,
5
+ * the hub page) is its own origin (`../static-server/main.ts`'s "ORIGINS
6
+ * ARE A CORRECTNESS REQUIREMENT" note) and therefore its own IndexedDB, so
7
+ * each gets its OWN signing key and its own peerId. That is deliberate: two
8
+ * pages sharing one identity would be two independent libp2p nodes racing
9
+ * to be "the" peer for that identity, which is a different and worse
10
+ * problem than two distinct mesh members.
11
+ *
12
+ * THE KEY FORMAT IS THE NODE SIDE'S, EXACTLY. `@libp2p/crypto/keys`'s
13
+ * protobuf encoding -- the same bytes `../setup/keys.ts` writes to
14
+ * `.httpeers/hub.key` and `../hub/main.ts` reads back -- just stored in
15
+ * IndexedDB instead of a file, because a browser has no filesystem to write
16
+ * one to. That is not a cosmetic alignment: `peerIdFromPrivateKey` on a key
17
+ * that round-tripped through this store must produce the SAME peerId string
18
+ * as the Node path produces for the same key, or the hub page's mesh
19
+ * identity would not be the thing every token's `mesh` claim restates.
20
+ * `tests/browser-identity.test.ts` pins that round trip.
21
+ *
22
+ * WHY THIS IS SPLIT OUT OF `./browser-profile.ts` (Task 24). That module
23
+ * loaded the key and handed it straight to `createLibp2p`, which never
24
+ * gives it back -- fine for a page that only needs a node, and impossible
25
+ * for the hub page, which must ALSO pass the very same key to `createPeer`
26
+ * so its `mintToken` closure signs as the mesh (`httpeers.core`'s
27
+ * `CreatePeerInit.privateKey`). Loading the identity is now its own step
28
+ * that a caller can take first and use twice. `browser-profile.ts` re-exports
29
+ * everything here, so nothing that imported it from there had to change.
30
+ */
31
+ import { privateKeyFromProtobuf, privateKeyToProtobuf } from "@libp2p/crypto/keys";
32
+ import type { Ed25519PrivateKey } from "@libp2p/interface";
33
+ import { peerIdFromPrivateKey } from "@libp2p/peer-id";
34
+ import { generateKey } from "@statewalker/httpeers-libp2p";
35
+ import type { AsyncBytesBackend } from "./kv.js";
36
+
37
+ /** Where this origin's identity key lives in IndexedDB (via `idb-keyval`, the same store `@statewalker/webrun-http-browser` already uses). */
38
+ export const IDENTITY_STORAGE_KEY = "httpeers:identity-key";
39
+
40
+ /**
41
+ * Where the key is kept, injectable -- `./kv.ts`'s bytes seam, defaulting
42
+ * to the real IndexedDB.
43
+ *
44
+ * ADDED IN TASK 28 SO THE STORE'S OWN BEHAVIOUR IS NODE-TESTABLE, not just
45
+ * its codec. "A first run has no key", "a second run gets the same peerId
46
+ * back", and "after a reset the next run is a different peerId" are the
47
+ * three facts the two consumer pages' whole resume story rests on, and all
48
+ * three used to be unreachable from Node because `idb-keyval` needs a real
49
+ * IndexedDB. The stored VALUE is unchanged -- raw protobuf bytes, exactly
50
+ * as Task 24 wrote them -- so a page that already holds a key reads it back
51
+ * identically; see `./kv.ts` on why the bytes seam is separate from the
52
+ * string one.
53
+ */
54
+ export interface IdentityStoreInit {
55
+ backend: AsyncBytesBackend;
56
+ storageKey?: string;
57
+ }
58
+
59
+ const backendOf = (init: IdentityStoreInit): AsyncBytesBackend => init.backend;
60
+ const keyOf = (init: IdentityStoreInit): string => init.storageKey ?? IDENTITY_STORAGE_KEY;
61
+
62
+ /**
63
+ * Decode the protobuf bytes this store holds, refusing anything that is not
64
+ * Ed25519. Exported so a Node test can exercise the decode half of the
65
+ * round trip without an IndexedDB (see this module's comment).
66
+ */
67
+ export function decodeIdentity(bytes: Uint8Array): Ed25519PrivateKey {
68
+ const key = privateKeyFromProtobuf(bytes);
69
+ if (key.type !== "Ed25519") {
70
+ throw new Error(
71
+ `identity: the key stored at "${IDENTITY_STORAGE_KEY}" is a ${key.type} key -- ` +
72
+ "only Ed25519 is supported (design note 05 §2).",
73
+ );
74
+ }
75
+ return key;
76
+ }
77
+
78
+ /** The bytes this store holds for `key`. The inverse of `decodeIdentity`. */
79
+ export function encodeIdentity(key: Ed25519PrivateKey): Uint8Array {
80
+ return privateKeyToProtobuf(key);
81
+ }
82
+
83
+ /**
84
+ * The peerId a key derives to -- the same computation `httpeers.core`'s
85
+ * `mintToken` uses, and the same one `../setup/keys.ts`'s `peerIdOf` does
86
+ * on the Node side. Not imported from there: that module reads
87
+ * `node:fs`/`node:crypto` at module scope and would poison a browser
88
+ * bundle. `tests/browser-identity.test.ts` pins that the two agree.
89
+ */
90
+ export function peerIdOf(key: Ed25519PrivateKey): string {
91
+ return peerIdFromPrivateKey(key).toString();
92
+ }
93
+
94
+ /**
95
+ * Load this origin's persisted identity, or generate and persist one on
96
+ * first run. Reuses `httpeers.core`'s own `generateKey` rather than
97
+ * calling `@libp2p/crypto`'s `generateKeyPair` directly a second time --
98
+ * one call site for "how this mesh mints a fresh identity," matching the
99
+ * lesson note 22 §5/§6 draws about reading this workspace's own source
100
+ * before re-deriving something already sitting in it.
101
+ */
102
+ export async function loadOrCreateIdentity(init: IdentityStoreInit): Promise<Ed25519PrivateKey> {
103
+ const stored = await readIdentity(init);
104
+ if (stored != null) return stored;
105
+ const key = await generateKey();
106
+ await backendOf(init).set(keyOf(init), encodeIdentity(key));
107
+ return key;
108
+ }
109
+
110
+ /**
111
+ * This origin's persisted identity, or `null` when it has never had one.
112
+ *
113
+ * THE DIFFERENCE BETWEEN THIS AND `loadOrCreateIdentity` IS A SENTENCE ON
114
+ * SCREEN. A page that starts by trying to RESUME a membership has to tell
115
+ * an operator which of two situations it is in, and they are not alike:
116
+ * "this browser has never joined anything" is a first run, and "this
117
+ * browser holds an identity the hub does not recognise" is a hub reset or a
118
+ * revoked membership -- the second being the one that is otherwise baffling
119
+ * (Task 28, requirement 3). `loadOrCreateIdentity` cannot tell them apart
120
+ * by construction, because it has already created the key by the time it
121
+ * returns. So the read is its own step, and creation happens only when the
122
+ * page actually goes on to join.
123
+ */
124
+ export async function readIdentity(init: IdentityStoreInit): Promise<Ed25519PrivateKey | null> {
125
+ const stored = await backendOf(init).get(keyOf(init));
126
+ if (stored == null) return null;
127
+ return decodeIdentity(stored);
128
+ }
129
+
130
+ /**
131
+ * Forget this origin's identity, so the next `loadOrCreateIdentity` mints a
132
+ * fresh one.
133
+ *
134
+ * DESTRUCTIVE, AND FOR THE HUB PAGE IT IS THE MOST DESTRUCTIVE THING THERE
135
+ * IS: the hub's peerId IS the mesh (`claims.mesh === claims.iss`), so
136
+ * dropping this key does not rotate a credential, it founds a DIFFERENT
137
+ * mesh -- every token ever issued stops verifying and every join URL handed
138
+ * out stops working. Whoever calls this owes the operator a confirmation
139
+ * that says so in those terms; `../pages/hub/main.ts` is where that lives.
140
+ * A caller that also holds hub state must clear it in the same breath (see
141
+ * that page): members carried into a new mesh would be peers that never
142
+ * joined it.
143
+ */
144
+ export async function clearIdentity(init: IdentityStoreInit): Promise<void> {
145
+ await backendOf(init).delete(keyOf(init));
146
+ }
package/src/index.ts ADDED
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Everything a participant does: hold an identity, redeem or resume, connect,
3
+ * keep the link, advertise, discover, call, and serve.
4
+ *
5
+ * `startMember` is the seam rung 01 proved: one lifecycle, with the three
6
+ * things that genuinely differ between a Node process and a page injected as a
7
+ * `MemberPlatform`. `MemberHandle.fetch` IS the edge on both platforms;
8
+ * `baseUrl` is the browser-only part.
9
+ */
10
+
11
+ export { type ConnectionKind, classifyConnection, describeConnection } from "./connection-kind.js";
12
+ export { describeError } from "./describe-error.js";
13
+
14
+ export {
15
+ createEdgeDispatch,
16
+ type EdgeDispatchInit,
17
+ PEER_ERROR_STATUS,
18
+ type PeerErrorBody,
19
+ stripEdgePrefix,
20
+ targetPeerId,
21
+ } from "./edge-dispatch.js";
22
+ /**
23
+ * The mesh as ordinary HTTP.
24
+ *
25
+ * `createGateway` turns a member into a `FetchHandler` dispatching
26
+ * `/{peerId}/{path}` — so a client with no peer object, no token and no
27
+ * knowledge of libp2p can `fetch()` a mesh resource. `GatewaySource` is
28
+ * structurally a subset of `MemberHandle`, so a member IS one.
29
+ */
30
+ export { createGateway, GATEWAY_MARKER, type GatewayInit, type GatewaySource } from "./gateway.js";
31
+ export {
32
+ clearIdentity,
33
+ decodeIdentity,
34
+ encodeIdentity,
35
+ IDENTITY_STORAGE_KEY,
36
+ type IdentityStoreInit,
37
+ loadOrCreateIdentity,
38
+ peerIdOf,
39
+ readIdentity,
40
+ } from "./identity.js";
41
+ export {
42
+ decodeJoinBlob,
43
+ encodeJoinBlob,
44
+ invitationFromQrText,
45
+ JOIN_BLOB_PARAM,
46
+ type JoinBlob,
47
+ type JoinInput,
48
+ joinUrl,
49
+ readJoinInputFromSearch,
50
+ readJoinInputFromText,
51
+ } from "./join-blob.js";
52
+ export type { AsyncBytesBackend, AsyncKeyValueBackend } from "./kv.js";
53
+ export { parseMeshConfig } from "./mesh-config.js";
54
+ export {
55
+ type CreateMeshMemoryInit,
56
+ createMeshMemory,
57
+ MESH_STORAGE_KEY,
58
+ type MeshMemory,
59
+ } from "./mesh-memory.js";
60
+ export { type PeerRequestInit, peerRequest } from "./peer-request.js";
61
+ /**
62
+ * The session machine — ISOMORPHIC, and exported from the root on purpose.
63
+ *
64
+ * It used to be the browser peer's private business. Everything platform-bound
65
+ * in it is now injected (the platform, the two stores, the reload), so a Node
66
+ * member that wants resume-or-redeem and the same four operator controls gets
67
+ * them here rather than reimplementing them. `./browser`'s `createSession` is
68
+ * the same thing with a page's defaults filled in.
69
+ */
70
+ export {
71
+ createIdentityStore,
72
+ createPeerSession,
73
+ DEFAULT_HTTPEERS_CONFIG_URL,
74
+ describeMeshDrift,
75
+ type IdentityStore,
76
+ type NeedsInvitationReason,
77
+ type PeerSession,
78
+ type PeerSessionInit,
79
+ type SessionControls,
80
+ type SessionPhase,
81
+ type SessionStartState,
82
+ type SessionState,
83
+ type StartPeer,
84
+ } from "./session.js";
85
+ export {
86
+ type HubLink,
87
+ type JoinMethod,
88
+ type MemberEdge,
89
+ type MemberHandle,
90
+ MemberJoinError,
91
+ type MemberPlatform,
92
+ type MemberState,
93
+ type StartMemberInit,
94
+ startMember,
95
+ } from "./start-member.js";