@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,62 @@
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 function assertKeyMatchesPrefix(key, prefix) {
45
+ const firstSegment = prefix.split("/")[0];
46
+ if (firstSegment !== key) {
47
+ throw new Error(`mountEdge: prefix "${prefix}" does not start with adapter key "${key}" -- ` +
48
+ "the ServiceWorker dispatcher keys a registration by the URL's first path segment " +
49
+ "while the adapter matches the full base URL; a mismatch here means every mounting " +
50
+ "step reports success while the handler is never called, and the request silently " +
51
+ "falls through to the origin server (design note 39 §3). Refusing to start rather " +
52
+ "than mount a registration nothing can ever reach.");
53
+ }
54
+ if (!prefix.endsWith("/")) {
55
+ throw new Error(`mountEdge: prefix "${prefix}" does not end with "/" -- the resulting baseUrl would ` +
56
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: quoting what a page writes, not interpolating.
57
+ "not either, and every mesh call a page composes as `${baseUrl}${peerId}/...` would " +
58
+ "run the two together into one segment the ServiceWorker cannot route, falling " +
59
+ "through to the origin server. `BrowserPeerHandle.baseUrl` promises a trailing " +
60
+ "slash (Ruling 58); this is what makes that promise keepable.");
61
+ }
62
+ }
package/dist/edge.d.ts ADDED
@@ -0,0 +1,97 @@
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 { assertKeyMatchesPrefix } from "./edge-guard.js";
27
+ export { UncontrolledPageError } from "./edge-control.js";
28
+ export { assertKeyMatchesPrefix };
29
+ export interface MountEdgeInit {
30
+ /** The ServiceWorker adapter's channel key -- see `assertKeyMatchesPrefix`'s doc comment for why this must equal `prefix`'s first path segment. */
31
+ key: string;
32
+ /** Defaults to `${key}/` -- the one prefix shape `assertKeyMatchesPrefix` is guaranteed to accept. Overridable only for a caller with a real reason to diverge. */
33
+ prefix?: string;
34
+ /** Defaults to `DEFAULT_SERVICE_WORKER_URL` -- see its own comment; omitting it entirely is NOT an option this adapter survives. */
35
+ serviceWorkerUrl?: string;
36
+ /** The peer's own router. Mounted unchanged -- see this module's doc comment. */
37
+ dispatch: FetchHandler;
38
+ /**
39
+ * The bound on getting a controlling ServiceWorker, in ms -- passed to
40
+ * `SwHttpAdapter` as its `timeout`. Defaults to `DEFAULT_CONTROL_TIMEOUT_MS`.
41
+ */
42
+ controlTimeoutMs?: number;
43
+ }
44
+ /**
45
+ * How long `mountEdge` waits for its ServiceWorker to activate, take control
46
+ * of the page and answer, before giving up with an error.
47
+ *
48
+ * Before webrun-http-browser 0.5 `SwHttpAdapter.start()` had no bound, and a
49
+ * page that never got a controller -- a hard reload was the case that
50
+ * shipped; see `./edge-control.ts` -- hung at "mounting-edge" for good. The
51
+ * library now bounds every wait by its `timeout`; this is the value passed.
52
+ * A first visit installs a ~60 KB script and activates it, which takes well
53
+ * under a second on a slow link; thirty seconds is generous. It is also how
54
+ * long a worker that does NOT answer the `CLAIM` request (one older than 0.5,
55
+ * say) keeps a hard-reloaded page waiting before the one automatic reload.
56
+ */
57
+ export declare const DEFAULT_CONTROL_TIMEOUT_MS = 30000;
58
+ /**
59
+ * Where each origin's ServiceWorker script lives: `/sw.js`, the dist root.
60
+ * Fixed by `../static-server/main.ts`'s own `swFile` default (it serves
61
+ * exactly that filename with `Service-Worker-Allowed: /`) and by both vite
62
+ * configs, which name the `sw` entry `sw.js` with no content hash for this
63
+ * exact reason.
64
+ *
65
+ * SUPPLYING THIS IS NOT OPTIONAL, AND OMITTING IT IS NOT A GRACEFUL
66
+ * DEGRADATION. `SwHttpAdapter`'s own defaults are mutually recursive:
67
+ * `scope` reads `serviceWorkerUrl`, which (when unset) computes
68
+ * `new URL("./index-sw.js", rootUrl)`, and `rootUrl` reads `scope` again
69
+ * (`sw-dispatcher.ts`'s three getters). With neither `scope` nor
70
+ * `serviceWorkerUrl` given, the CONSTRUCTOR -- which validates `this.scope`
71
+ * -- recurses until the stack is exhausted. Verified directly by
72
+ * constructing one under Node: `RangeError: Maximum call stack size
73
+ * exceeded`, before `start()` is ever reached. `mountEdge` previously
74
+ * forwarded `init.serviceWorkerUrl` straight through, so every caller that
75
+ * left it unset (the image peer page, Task 12) would have hit that
76
+ * RangeError the first time it ran in a real browser. Defaulting here fixes
77
+ * every such caller at once rather than asking each page to remember.
78
+ */
79
+ export declare const DEFAULT_SERVICE_WORKER_URL = "/sw.js";
80
+ export interface EdgeHandle {
81
+ /** The URL a same-origin `fetch()` reaches this peer's `dispatch` through. */
82
+ baseUrl: string;
83
+ stop(): Promise<void>;
84
+ }
85
+ /**
86
+ * Register a peer's `dispatch` on the ServiceWorker edge. Throws (via
87
+ * `assertKeyMatchesPrefix`) before `SwHttpAdapter` is even constructed if
88
+ * `key`/`prefix` disagree -- the failure this function exists to make
89
+ * loud instead of silent.
90
+ *
91
+ * On a page its ServiceWorker does not control (a hard reload) the adapter asks
92
+ * the worker to claim it, in place; if that does not take, it reloads the page
93
+ * once, guarded, and otherwise this throws `UncontrolledPageError`. It gives up
94
+ * after `controlTimeoutMs` rather than wait forever. See `./edge-control.ts`.
95
+ */
96
+ export declare function mountEdge(init: MountEdgeInit): Promise<EdgeHandle>;
97
+ //# sourceMappingURL=edge.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"edge.d.ts","sourceRoot":"","sources":["../src/edge.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,4BAA4B,CAAC;AAG/D,OAAO,EAAE,sBAAsB,EAAE,MAAM,iBAAiB,CAAC;AAEzD,OAAO,EAAE,qBAAqB,EAAE,MAAM,mBAAmB,CAAC;AAC1D,OAAO,EAAE,sBAAsB,EAAE,CAAC;AAclC,MAAM,WAAW,aAAa;IAC5B,mJAAmJ;IACnJ,GAAG,EAAE,MAAM,CAAC;IACZ,mKAAmK;IACnK,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,oIAAoI;IACpI,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,iFAAiF;IACjF,QAAQ,EAAE,YAAY,CAAC;IACvB;;;OAGG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,0BAA0B,QAAS,CAAC;AAEjD;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,eAAO,MAAM,0BAA0B,WAAW,CAAC;AAEnD,MAAM,WAAW,UAAU;IACzB,8EAA8E;IAC9E,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACvB;AAED;;;;;;;;;;GAUG;AACH,wBAAsB,SAAS,CAAC,IAAI,EAAE,aAAa,GAAG,OAAO,CAAC,UAAU,CAAC,CA2DxE"}
package/dist/edge.js ADDED
@@ -0,0 +1,104 @@
1
+ import { SwHttpAdapter } from "@statewalker/webrun-http-browser/sw";
2
+ import { edgeStartError } from "./edge-control.js";
3
+ import { assertKeyMatchesPrefix } from "./edge-guard.js";
4
+ export { UncontrolledPageError } from "./edge-control.js";
5
+ export { assertKeyMatchesPrefix };
6
+ /**
7
+ * How long `mountEdge` waits for its ServiceWorker to activate, take control
8
+ * of the page and answer, before giving up with an error.
9
+ *
10
+ * Before webrun-http-browser 0.5 `SwHttpAdapter.start()` had no bound, and a
11
+ * page that never got a controller -- a hard reload was the case that
12
+ * shipped; see `./edge-control.ts` -- hung at "mounting-edge" for good. The
13
+ * library now bounds every wait by its `timeout`; this is the value passed.
14
+ * A first visit installs a ~60 KB script and activates it, which takes well
15
+ * under a second on a slow link; thirty seconds is generous. It is also how
16
+ * long a worker that does NOT answer the `CLAIM` request (one older than 0.5,
17
+ * say) keeps a hard-reloaded page waiting before the one automatic reload.
18
+ */
19
+ export const DEFAULT_CONTROL_TIMEOUT_MS = 30_000;
20
+ /**
21
+ * Where each origin's ServiceWorker script lives: `/sw.js`, the dist root.
22
+ * Fixed by `../static-server/main.ts`'s own `swFile` default (it serves
23
+ * exactly that filename with `Service-Worker-Allowed: /`) and by both vite
24
+ * configs, which name the `sw` entry `sw.js` with no content hash for this
25
+ * exact reason.
26
+ *
27
+ * SUPPLYING THIS IS NOT OPTIONAL, AND OMITTING IT IS NOT A GRACEFUL
28
+ * DEGRADATION. `SwHttpAdapter`'s own defaults are mutually recursive:
29
+ * `scope` reads `serviceWorkerUrl`, which (when unset) computes
30
+ * `new URL("./index-sw.js", rootUrl)`, and `rootUrl` reads `scope` again
31
+ * (`sw-dispatcher.ts`'s three getters). With neither `scope` nor
32
+ * `serviceWorkerUrl` given, the CONSTRUCTOR -- which validates `this.scope`
33
+ * -- recurses until the stack is exhausted. Verified directly by
34
+ * constructing one under Node: `RangeError: Maximum call stack size
35
+ * exceeded`, before `start()` is ever reached. `mountEdge` previously
36
+ * forwarded `init.serviceWorkerUrl` straight through, so every caller that
37
+ * left it unset (the image peer page, Task 12) would have hit that
38
+ * RangeError the first time it ran in a real browser. Defaulting here fixes
39
+ * every such caller at once rather than asking each page to remember.
40
+ */
41
+ export const DEFAULT_SERVICE_WORKER_URL = "/sw.js";
42
+ /**
43
+ * Register a peer's `dispatch` on the ServiceWorker edge. Throws (via
44
+ * `assertKeyMatchesPrefix`) before `SwHttpAdapter` is even constructed if
45
+ * `key`/`prefix` disagree -- the failure this function exists to make
46
+ * loud instead of silent.
47
+ *
48
+ * On a page its ServiceWorker does not control (a hard reload) the adapter asks
49
+ * the worker to claim it, in place; if that does not take, it reloads the page
50
+ * once, guarded, and otherwise this throws `UncontrolledPageError`. It gives up
51
+ * after `controlTimeoutMs` rather than wait forever. See `./edge-control.ts`.
52
+ */
53
+ export async function mountEdge(init) {
54
+ const prefix = init.prefix ?? `${init.key}/`;
55
+ assertKeyMatchesPrefix(init.key, prefix);
56
+ // SAY WHAT IS ACTUALLY WRONG. ServiceWorkers exist only in a secure context:
57
+ // `https://`, or `http://` on `localhost`/`127.0.0.1`. Served from a LAN
58
+ // address over plain HTTP -- exactly what someone does when opening these
59
+ // pages from a phone -- `navigator.serviceWorker` is `undefined`, and the
60
+ // first symptom is `TypeError: Cannot read properties of undefined (reading
61
+ // 'register')` thrown from inside the adapter, which names neither the cause
62
+ // nor the fix. This is a browser rule, not something this stack can work
63
+ // around; the deployment answer is the TLS the design already provides for
64
+ // (`TLS_CERT`/`TLS_KEY`).
65
+ if (typeof navigator === "undefined" || navigator.serviceWorker == null) {
66
+ const origin = typeof location !== "undefined" ? location.origin : "this origin";
67
+ throw new Error(`mountEdge: this browser exposes no ServiceWorker at ${origin}, so a peer cannot be ` +
68
+ "reached through the page's own fetch(). ServiceWorkers require a secure context: " +
69
+ "serve the page over https, or open it on localhost / 127.0.0.1. " +
70
+ "(A LAN address over plain http is not a secure context, which is why this fails " +
71
+ "there but works on localhost.)");
72
+ }
73
+ // Resolved against the page's own origin: `SwHttpAdapter` passes this to
74
+ // `new URL(...)` with no base, so a bare "/sw.js" would throw `Invalid
75
+ // URL` rather than being treated as origin-relative.
76
+ const serviceWorkerUrl = new URL(init.serviceWorkerUrl ?? DEFAULT_SERVICE_WORKER_URL, location.href).href;
77
+ // AN UNCONTROLLED PAGE UNDER AN ACTIVE WORKER (a hard reload): `start()` asks
78
+ // the worker to claim it (`CLAIM`), and failing that reloads once --
79
+ // `reloadIfUncontrolled`, kept as the last resort for a worker that does not
80
+ // answer `CLAIM` (an older `/sw.js` still installed in a returning visitor's
81
+ // browser, or a custom `serviceWorkerUrl`). It costs nothing when the claim
82
+ // works, and the library guards it against looping. See `./edge-control.ts`.
83
+ const timeoutMs = init.controlTimeoutMs ?? DEFAULT_CONTROL_TIMEOUT_MS;
84
+ const adapter = new SwHttpAdapter({
85
+ key: init.key,
86
+ serviceWorkerUrl,
87
+ timeout: timeoutMs,
88
+ reloadIfUncontrolled: true,
89
+ });
90
+ try {
91
+ await adapter.start();
92
+ }
93
+ catch (error) {
94
+ throw edgeStartError(error, serviceWorkerUrl, timeoutMs);
95
+ }
96
+ const registration = await adapter.register(prefix, init.dispatch);
97
+ return {
98
+ baseUrl: registration.baseUrl,
99
+ async stop() {
100
+ await registration.remove();
101
+ await adapter.stop();
102
+ },
103
+ };
104
+ }
@@ -0,0 +1,49 @@
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
+ import type { FetchHandler, MeshView } from "@statewalker/httpeers-core";
28
+ /** What the gateway needs of a member. `MemberHandle` satisfies it; so does a test double. */
29
+ export interface GatewaySource {
30
+ peerId: string;
31
+ /** The proven edge dispatch: `/{key}/{peerId}/{path}` in, mesh call out. */
32
+ fetch: FetchHandler;
33
+ /** Read per request — a peer that joined a second ago must be dispatchable. */
34
+ meshView(): MeshView | null;
35
+ }
36
+ export interface GatewayInit {
37
+ source: GatewaySource;
38
+ /**
39
+ * The prefix this gateway is mounted at, stripped before dispatch.
40
+ * `""` on a Node server; `"/peers"` behind a ServiceWorker keyed `peers`.
41
+ */
42
+ basePath?: string;
43
+ /** The key the underlying edge dispatch expects as the first segment. */
44
+ edgeKey: string;
45
+ }
46
+ /** Marks the gateway's own responses so they are never mistaken for a peer's. */
47
+ export declare const GATEWAY_MARKER = "x-httpeers-gateway";
48
+ export declare function createGateway(init: GatewayInit): FetchHandler;
49
+ //# sourceMappingURL=gateway.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"gateway.d.ts","sourceRoot":"","sources":["../src/gateway.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,QAAQ,EAAE,MAAM,4BAA4B,CAAC;AAGzE,8FAA8F;AAC9F,MAAM,WAAW,aAAa;IAC5B,MAAM,EAAE,MAAM,CAAC;IACf,4EAA4E;IAC5E,KAAK,EAAE,YAAY,CAAC;IACpB,+EAA+E;IAC/E,QAAQ,IAAI,QAAQ,GAAG,IAAI,CAAC;CAC7B;AAED,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,aAAa,CAAC;IACtB;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,yEAAyE;IACzE,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,iFAAiF;AACjF,eAAO,MAAM,cAAc,uBAAuB,CAAC;AAEnD,wBAAgB,aAAa,CAAC,IAAI,EAAE,WAAW,GAAG,YAAY,CAiD7D"}
@@ -0,0 +1,102 @@
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
+ import { bodyOf, stripPeerBinding } from "@statewalker/httpeers-core";
28
+ /** Marks the gateway's own responses so they are never mistaken for a peer's. */
29
+ export const GATEWAY_MARKER = "x-httpeers-gateway";
30
+ export function createGateway(init) {
31
+ const base = normaliseBase(init.basePath ?? "");
32
+ return async (request) => {
33
+ const url = new URL(request.url);
34
+ if (base !== "" && !url.pathname.startsWith(base)) {
35
+ return marked(`not under ${base}`, 404, "outside-mount");
36
+ }
37
+ const rest = url.pathname.slice(base.length) || "/";
38
+ // The listing. Honest about what it knows: peers and advertised kinds,
39
+ // with the view's own version and an `asOf` — never a URL, because the
40
+ // mesh view carries no service paths.
41
+ if ((rest === "/" || rest === "") && request.method === "GET") {
42
+ return listing(init.source);
43
+ }
44
+ // SEGMENTS, NEVER A URL HOST. A peer id is base58 and case-sensitive; a
45
+ // URL host is lower-cased by normalisation and userinfo (`a@b`) silently
46
+ // reassigns it. The path is the only safe place to carry one.
47
+ const [, first = "", ...tail] = rest.split("/");
48
+ if (first === "")
49
+ return marked("no peer in the path", 404, "no-peer");
50
+ // Hand it to the proven dispatch, spelled the way that handler expects.
51
+ const target = new URL(`/${init.edgeKey}/${first}${tail.length > 0 ? `/${tail.join("/")}` : ""}${url.search}`, "http://gateway.local");
52
+ // ORDINARY HTTP IN FRONT OF A MEMBER, so the caller is a browser or a
53
+ // curl and any proven-peer header it sent is a claim about somebody else.
54
+ // Strip before the request enters the mesh.
55
+ stripPeerBinding(request);
56
+ const body = await bodyOf(request);
57
+ const forwarded = new Request(target, {
58
+ method: request.method,
59
+ headers: request.headers,
60
+ body,
61
+ ...(body instanceof ReadableStream ? { duplex: "half" } : {}),
62
+ // FORWARDED DELIBERATELY. `@hono/node-server` aborts this signal when the
63
+ // client hangs up, and nothing else would stop the libp2p call — the
64
+ // stream would run to completion with nobody to receive it.
65
+ signal: request.signal,
66
+ });
67
+ return await init.source.fetch(forwarded);
68
+ };
69
+ }
70
+ /** `""` or `/a/b` — never a trailing slash, so `base.length` is a clean cut. */
71
+ function normaliseBase(value) {
72
+ if (value === "" || value === "/")
73
+ return "";
74
+ const withSlash = value.startsWith("/") ? value : `/${value}`;
75
+ return withSlash.endsWith("/") ? withSlash.slice(0, -1) : withSlash;
76
+ }
77
+ function listing(source) {
78
+ const view = source.meshView();
79
+ // `null` is a real state — "no view yet" is not "nobody is here" — and a
80
+ // listing that flattened the two would be a lie on every first load.
81
+ if (view == null) {
82
+ return Response.json({ ready: false, self: source.peerId, peers: [], offers: [] }, { headers: { [GATEWAY_MARKER]: "no-view" } });
83
+ }
84
+ return Response.json({
85
+ ready: true,
86
+ self: source.peerId,
87
+ version: view.version,
88
+ asOf: Date.now(),
89
+ peers: view.members.map((m) => ({ peerId: m.peerId, roles: m.roles })),
90
+ // Flat, and independent of `peers`: the hub advertises under its own id
91
+ // and is in no member list.
92
+ offers: view.advertisements.map((a) => ({
93
+ peerId: a.peerId,
94
+ id: a.id,
95
+ kind: a.kind,
96
+ title: a.title,
97
+ })),
98
+ });
99
+ }
100
+ function marked(message, status, kind) {
101
+ return new Response(message, { status, headers: { [GATEWAY_MARKER]: kind } });
102
+ }
@@ -0,0 +1,78 @@
1
+ import type { Ed25519PrivateKey } from "@libp2p/interface";
2
+ import type { AsyncBytesBackend } from "./kv.js";
3
+ /** Where this origin's identity key lives in IndexedDB (via `idb-keyval`, the same store `@statewalker/webrun-http-browser` already uses). */
4
+ export declare const IDENTITY_STORAGE_KEY = "httpeers:identity-key";
5
+ /**
6
+ * Where the key is kept, injectable -- `./kv.ts`'s bytes seam, defaulting
7
+ * to the real IndexedDB.
8
+ *
9
+ * ADDED IN TASK 28 SO THE STORE'S OWN BEHAVIOUR IS NODE-TESTABLE, not just
10
+ * its codec. "A first run has no key", "a second run gets the same peerId
11
+ * back", and "after a reset the next run is a different peerId" are the
12
+ * three facts the two consumer pages' whole resume story rests on, and all
13
+ * three used to be unreachable from Node because `idb-keyval` needs a real
14
+ * IndexedDB. The stored VALUE is unchanged -- raw protobuf bytes, exactly
15
+ * as Task 24 wrote them -- so a page that already holds a key reads it back
16
+ * identically; see `./kv.ts` on why the bytes seam is separate from the
17
+ * string one.
18
+ */
19
+ export interface IdentityStoreInit {
20
+ backend: AsyncBytesBackend;
21
+ storageKey?: string;
22
+ }
23
+ /**
24
+ * Decode the protobuf bytes this store holds, refusing anything that is not
25
+ * Ed25519. Exported so a Node test can exercise the decode half of the
26
+ * round trip without an IndexedDB (see this module's comment).
27
+ */
28
+ export declare function decodeIdentity(bytes: Uint8Array): Ed25519PrivateKey;
29
+ /** The bytes this store holds for `key`. The inverse of `decodeIdentity`. */
30
+ export declare function encodeIdentity(key: Ed25519PrivateKey): Uint8Array;
31
+ /**
32
+ * The peerId a key derives to -- the same computation `httpeers.core`'s
33
+ * `mintToken` uses, and the same one `../setup/keys.ts`'s `peerIdOf` does
34
+ * on the Node side. Not imported from there: that module reads
35
+ * `node:fs`/`node:crypto` at module scope and would poison a browser
36
+ * bundle. `tests/browser-identity.test.ts` pins that the two agree.
37
+ */
38
+ export declare function peerIdOf(key: Ed25519PrivateKey): string;
39
+ /**
40
+ * Load this origin's persisted identity, or generate and persist one on
41
+ * first run. Reuses `httpeers.core`'s own `generateKey` rather than
42
+ * calling `@libp2p/crypto`'s `generateKeyPair` directly a second time --
43
+ * one call site for "how this mesh mints a fresh identity," matching the
44
+ * lesson note 22 §5/§6 draws about reading this workspace's own source
45
+ * before re-deriving something already sitting in it.
46
+ */
47
+ export declare function loadOrCreateIdentity(init: IdentityStoreInit): Promise<Ed25519PrivateKey>;
48
+ /**
49
+ * This origin's persisted identity, or `null` when it has never had one.
50
+ *
51
+ * THE DIFFERENCE BETWEEN THIS AND `loadOrCreateIdentity` IS A SENTENCE ON
52
+ * SCREEN. A page that starts by trying to RESUME a membership has to tell
53
+ * an operator which of two situations it is in, and they are not alike:
54
+ * "this browser has never joined anything" is a first run, and "this
55
+ * browser holds an identity the hub does not recognise" is a hub reset or a
56
+ * revoked membership -- the second being the one that is otherwise baffling
57
+ * (Task 28, requirement 3). `loadOrCreateIdentity` cannot tell them apart
58
+ * by construction, because it has already created the key by the time it
59
+ * returns. So the read is its own step, and creation happens only when the
60
+ * page actually goes on to join.
61
+ */
62
+ export declare function readIdentity(init: IdentityStoreInit): Promise<Ed25519PrivateKey | null>;
63
+ /**
64
+ * Forget this origin's identity, so the next `loadOrCreateIdentity` mints a
65
+ * fresh one.
66
+ *
67
+ * DESTRUCTIVE, AND FOR THE HUB PAGE IT IS THE MOST DESTRUCTIVE THING THERE
68
+ * IS: the hub's peerId IS the mesh (`claims.mesh === claims.iss`), so
69
+ * dropping this key does not rotate a credential, it founds a DIFFERENT
70
+ * mesh -- every token ever issued stops verifying and every join URL handed
71
+ * out stops working. Whoever calls this owes the operator a confirmation
72
+ * that says so in those terms; `../pages/hub/main.ts` is where that lives.
73
+ * A caller that also holds hub state must clear it in the same breath (see
74
+ * that page): members carried into a new mesh would be peers that never
75
+ * joined it.
76
+ */
77
+ export declare function clearIdentity(init: IdentityStoreInit): Promise<void>;
78
+ //# sourceMappingURL=identity.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"identity.d.ts","sourceRoot":"","sources":["../src/identity.ts"],"names":[],"mappings":"AA+BA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AAG3D,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,SAAS,CAAC;AAEjD,8IAA8I;AAC9I,eAAO,MAAM,oBAAoB,0BAA0B,CAAC;AAE5D;;;;;;;;;;;;;GAaG;AACH,MAAM,WAAW,iBAAiB;IAChC,OAAO,EAAE,iBAAiB,CAAC;IAC3B,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAKD;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,UAAU,GAAG,iBAAiB,CASnE;AAED,6EAA6E;AAC7E,wBAAgB,cAAc,CAAC,GAAG,EAAE,iBAAiB,GAAG,UAAU,CAEjE;AAED;;;;;;GAMG;AACH,wBAAgB,QAAQ,CAAC,GAAG,EAAE,iBAAiB,GAAG,MAAM,CAEvD;AAED;;;;;;;GAOG;AACH,wBAAsB,oBAAoB,CAAC,IAAI,EAAE,iBAAiB,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAM9F;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAsB,YAAY,CAAC,IAAI,EAAE,iBAAiB,GAAG,OAAO,CAAC,iBAAiB,GAAG,IAAI,CAAC,CAI7F;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAsB,aAAa,CAAC,IAAI,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC,CAE1E"}
@@ -0,0 +1,117 @@
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 { peerIdFromPrivateKey } from "@libp2p/peer-id";
33
+ import { generateKey } from "@statewalker/httpeers-libp2p";
34
+ /** Where this origin's identity key lives in IndexedDB (via `idb-keyval`, the same store `@statewalker/webrun-http-browser` already uses). */
35
+ export const IDENTITY_STORAGE_KEY = "httpeers:identity-key";
36
+ const backendOf = (init) => init.backend;
37
+ const keyOf = (init) => init.storageKey ?? IDENTITY_STORAGE_KEY;
38
+ /**
39
+ * Decode the protobuf bytes this store holds, refusing anything that is not
40
+ * Ed25519. Exported so a Node test can exercise the decode half of the
41
+ * round trip without an IndexedDB (see this module's comment).
42
+ */
43
+ export function decodeIdentity(bytes) {
44
+ const key = privateKeyFromProtobuf(bytes);
45
+ if (key.type !== "Ed25519") {
46
+ throw new Error(`identity: the key stored at "${IDENTITY_STORAGE_KEY}" is a ${key.type} key -- ` +
47
+ "only Ed25519 is supported (design note 05 §2).");
48
+ }
49
+ return key;
50
+ }
51
+ /** The bytes this store holds for `key`. The inverse of `decodeIdentity`. */
52
+ export function encodeIdentity(key) {
53
+ return privateKeyToProtobuf(key);
54
+ }
55
+ /**
56
+ * The peerId a key derives to -- the same computation `httpeers.core`'s
57
+ * `mintToken` uses, and the same one `../setup/keys.ts`'s `peerIdOf` does
58
+ * on the Node side. Not imported from there: that module reads
59
+ * `node:fs`/`node:crypto` at module scope and would poison a browser
60
+ * bundle. `tests/browser-identity.test.ts` pins that the two agree.
61
+ */
62
+ export function peerIdOf(key) {
63
+ return peerIdFromPrivateKey(key).toString();
64
+ }
65
+ /**
66
+ * Load this origin's persisted identity, or generate and persist one on
67
+ * first run. Reuses `httpeers.core`'s own `generateKey` rather than
68
+ * calling `@libp2p/crypto`'s `generateKeyPair` directly a second time --
69
+ * one call site for "how this mesh mints a fresh identity," matching the
70
+ * lesson note 22 §5/§6 draws about reading this workspace's own source
71
+ * before re-deriving something already sitting in it.
72
+ */
73
+ export async function loadOrCreateIdentity(init) {
74
+ const stored = await readIdentity(init);
75
+ if (stored != null)
76
+ return stored;
77
+ const key = await generateKey();
78
+ await backendOf(init).set(keyOf(init), encodeIdentity(key));
79
+ return key;
80
+ }
81
+ /**
82
+ * This origin's persisted identity, or `null` when it has never had one.
83
+ *
84
+ * THE DIFFERENCE BETWEEN THIS AND `loadOrCreateIdentity` IS A SENTENCE ON
85
+ * SCREEN. A page that starts by trying to RESUME a membership has to tell
86
+ * an operator which of two situations it is in, and they are not alike:
87
+ * "this browser has never joined anything" is a first run, and "this
88
+ * browser holds an identity the hub does not recognise" is a hub reset or a
89
+ * revoked membership -- the second being the one that is otherwise baffling
90
+ * (Task 28, requirement 3). `loadOrCreateIdentity` cannot tell them apart
91
+ * by construction, because it has already created the key by the time it
92
+ * returns. So the read is its own step, and creation happens only when the
93
+ * page actually goes on to join.
94
+ */
95
+ export async function readIdentity(init) {
96
+ const stored = await backendOf(init).get(keyOf(init));
97
+ if (stored == null)
98
+ return null;
99
+ return decodeIdentity(stored);
100
+ }
101
+ /**
102
+ * Forget this origin's identity, so the next `loadOrCreateIdentity` mints a
103
+ * fresh one.
104
+ *
105
+ * DESTRUCTIVE, AND FOR THE HUB PAGE IT IS THE MOST DESTRUCTIVE THING THERE
106
+ * IS: the hub's peerId IS the mesh (`claims.mesh === claims.iss`), so
107
+ * dropping this key does not rotate a credential, it founds a DIFFERENT
108
+ * mesh -- every token ever issued stops verifying and every join URL handed
109
+ * out stops working. Whoever calls this owes the operator a confirmation
110
+ * that says so in those terms; `../pages/hub/main.ts` is where that lives.
111
+ * A caller that also holds hub state must clear it in the same breath (see
112
+ * that page): members carried into a new mesh would be peers that never
113
+ * joined it.
114
+ */
115
+ export async function clearIdentity(init) {
116
+ await backendOf(init).delete(keyOf(init));
117
+ }