@statewalker/httpeers-member 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +203 -0
- package/dist/browser-platform.d.ts +30 -0
- package/dist/browser-platform.d.ts.map +1 -0
- package/dist/browser-platform.js +30 -0
- package/dist/browser-profile.d.ts +85 -0
- package/dist/browser-profile.d.ts.map +1 -0
- package/dist/browser-profile.js +120 -0
- package/dist/browser.d.ts +48 -0
- package/dist/browser.d.ts.map +1 -0
- package/dist/browser.js +51 -0
- package/dist/connection-kind.d.ts +36 -0
- package/dist/connection-kind.d.ts.map +1 -0
- package/dist/connection-kind.js +52 -0
- package/dist/describe-error.d.ts +18 -0
- package/dist/describe-error.d.ts.map +1 -0
- package/dist/describe-error.js +72 -0
- package/dist/edge-control.d.ts +50 -0
- package/dist/edge-control.d.ts.map +1 -0
- package/dist/edge-control.js +69 -0
- package/dist/edge-dispatch.d.ts +188 -0
- package/dist/edge-dispatch.d.ts.map +1 -0
- package/dist/edge-dispatch.js +142 -0
- package/dist/edge-guard.d.ts +45 -0
- package/dist/edge-guard.d.ts.map +1 -0
- package/dist/edge-guard.js +62 -0
- package/dist/edge.d.ts +97 -0
- package/dist/edge.d.ts.map +1 -0
- package/dist/edge.js +104 -0
- package/dist/gateway.d.ts +49 -0
- package/dist/gateway.d.ts.map +1 -0
- package/dist/gateway.js +102 -0
- package/dist/identity.d.ts +78 -0
- package/dist/identity.d.ts.map +1 -0
- package/dist/identity.js +117 -0
- package/dist/index.d.ts +39 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +37 -0
- package/dist/join-blob.d.ts +125 -0
- package/dist/join-blob.d.ts.map +1 -0
- package/dist/join-blob.js +195 -0
- package/dist/join.d.ts +343 -0
- package/dist/join.d.ts.map +1 -0
- package/dist/join.js +409 -0
- package/dist/kv.d.ts +20 -0
- package/dist/kv.d.ts.map +1 -0
- package/dist/kv.js +10 -0
- package/dist/mesh-config.d.ts +26 -0
- package/dist/mesh-config.d.ts.map +1 -0
- package/dist/mesh-config.js +39 -0
- package/dist/mesh-memory.d.ts +55 -0
- package/dist/mesh-memory.d.ts.map +1 -0
- package/dist/mesh-memory.js +74 -0
- package/dist/node.d.ts +29 -0
- package/dist/node.d.ts.map +1 -0
- package/dist/node.js +42 -0
- package/dist/page-wake.d.ts +24 -0
- package/dist/page-wake.d.ts.map +1 -0
- package/dist/page-wake.js +31 -0
- package/dist/peer-request.d.ts +29 -0
- package/dist/peer-request.d.ts.map +1 -0
- package/dist/peer-request.js +11 -0
- package/dist/reset.d.ts +56 -0
- package/dist/reset.d.ts.map +1 -0
- package/dist/reset.js +103 -0
- package/dist/session.d.ts +214 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +403 -0
- package/dist/start-member.d.ts +176 -0
- package/dist/start-member.d.ts.map +1 -0
- package/dist/start-member.js +307 -0
- package/package.json +120 -0
- package/src/browser-platform.ts +53 -0
- package/src/browser-profile.ts +179 -0
- package/src/browser.ts +103 -0
- package/src/connection-kind.ts +53 -0
- package/src/describe-error.ts +76 -0
- package/src/edge-control.ts +94 -0
- package/src/edge-dispatch.ts +295 -0
- package/src/edge-guard.ts +67 -0
- package/src/edge.ts +175 -0
- package/src/gateway.ts +141 -0
- package/src/identity.ts +146 -0
- package/src/index.ts +95 -0
- package/src/join-blob.ts +229 -0
- package/src/join.ts +632 -0
- package/src/kv.ts +21 -0
- package/src/mesh-config.ts +41 -0
- package/src/mesh-memory.ts +104 -0
- package/src/node.ts +49 -0
- package/src/page-wake.ts +59 -0
- package/src/peer-request.ts +39 -0
- package/src/reset.ts +127 -0
- package/src/session.ts +579 -0
- package/src/start-member.ts +476 -0
|
@@ -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
|
+
}
|
package/src/identity.ts
ADDED
|
@@ -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";
|