@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,403 @@
1
+ /**
2
+ * The page session: one persistent identity, resume-or-redeem on start, and
3
+ * the three operator controls -- join, disconnect, reset identity -- with
4
+ * no DOM anywhere in it.
5
+ *
6
+ * WHY EVERY CONSUMER PAGE SHARES THIS. Two pages differ in exactly one
7
+ * thing: what they mount and advertise. Everything else -- read the saved
8
+ * identity, try to resume, say which of the two "cannot resume" situations
9
+ * this is, offer an invitation field, disconnect without surrendering
10
+ * membership, reset the key -- is identical between them, down to the
11
+ * wording, and duplicating it would have been duplicating the wording too.
12
+ *
13
+ * NO DOM, AND NOTHING BROWSER-ONLY IMPORTED. This module decides things --
14
+ * which mesh to try, resume or redeem, what an operator is told -- and
15
+ * deciding is exactly what is worth testing, so `tests/session.test.ts`
16
+ * exercises every branch under Node with no browser, no relay and no
17
+ * ServiceWorker. That is only possible because the platform, the two stores
18
+ * and the reload are INJECTED rather than defaulted: `browserPlatform`
19
+ * reaches `@libp2p/webrtc`, whose Node build loads a native module, so a
20
+ * default here would have made importing this module a browser-only act.
21
+ * `./browser.ts`'s `createSession` is where those defaults live instead.
22
+ *
23
+ * Pages render `SessionState` and call the four methods; they hold no state
24
+ * of their own.
25
+ *
26
+ * THE CONFIG IS RESOLVED HERE, because `startMember` requires one and will
27
+ * not fetch anything itself (see `StartMemberInit.config`). That moved the
28
+ * `httpeers.json` read up into this module, which is the only place that
29
+ * knows which of the three candidates -- a blob's mesh, the remembered one,
30
+ * the deployment's -- applies to the attempt at hand.
31
+ *
32
+ * WHICH MESH IS TRIED, AND THE ONE RULE THAT MUST NOT BEND. When someone
33
+ * supplies an invitation, the invitation decides: a join blob names its own
34
+ * mesh, and a BARE invitation id still means "the mesh `httpeers.json`
35
+ * names", i.e. the Node hub's -- the contract `./join-blob.ts` states and
36
+ * the reason both deployments keep working. Only a RESUME (nobody supplied
37
+ * anything) consults `./mesh-memory.ts`, and only because a mesh whose hub
38
+ * is a browser page cannot be named any other way after the `?join=` query
39
+ * has gone. See `./mesh-memory.ts`.
40
+ *
41
+ * DISCONNECT IS NOT LEAVING. `MemberHandle.stop()` drops presence and
42
+ * keeps membership, which is what makes the page resumable afterwards with
43
+ * no new invitation; see that method's own doc comment. This module never
44
+ * calls anything that surrenders membership, and the one control that
45
+ * destroys something -- `resetIdentity` -- says what it costs and is named
46
+ * separately.
47
+ */
48
+ import { clearIdentity, loadOrCreateIdentity, peerIdOf, readIdentity } from "./identity.js";
49
+ import { readJoinInputFromSearch, readJoinInputFromText } from "./join-blob.js";
50
+ import { parseMeshConfig } from "./mesh-config.js";
51
+ import { MemberJoinError, startMember } from "./start-member.js";
52
+ export function createIdentityStore(init) {
53
+ return {
54
+ read: () => readIdentity(init),
55
+ loadOrCreate: () => loadOrCreateIdentity(init),
56
+ clear: () => clearIdentity(init),
57
+ };
58
+ }
59
+ function controlsFor(phase) {
60
+ switch (phase.kind) {
61
+ case "live":
62
+ return { join: false, disconnect: true, reconnect: false, reset: true };
63
+ case "disconnected":
64
+ return { join: false, disconnect: false, reconnect: true, reset: true };
65
+ // Every remaining phase leaves the invitation field open -- including
66
+ // `failed`. A page that could not reach the relay will not be fixed by
67
+ // an invitation, but an operator who has just been handed a link to a
68
+ // DIFFERENT mesh has every reason to try it, and a form that vanished on
69
+ // the wrong kind of failure would be the page deciding that for them.
70
+ case "blocked":
71
+ return { join: false, disconnect: false, reconnect: false, reset: true };
72
+ case "checking":
73
+ case "starting":
74
+ return { join: false, disconnect: false, reconnect: false, reset: false };
75
+ default:
76
+ return { join: true, disconnect: false, reconnect: false, reset: true };
77
+ }
78
+ }
79
+ /** A blob names its own mesh; a bare invitation id means "whichever mesh `httpeers.json` names" -- `./join-blob.ts`. */
80
+ function configOf(input) {
81
+ return input.kind === "blob"
82
+ ? { relayAddrs: input.blob.relayAddrs, hubPeerId: input.blob.hubPeerId }
83
+ : undefined;
84
+ }
85
+ function invitationIdOf(input) {
86
+ return input.kind === "blob" ? input.blob.invitationId : input.invitationId;
87
+ }
88
+ /**
89
+ * Where a deployment publishes the mesh it names.
90
+ *
91
+ * A SITE-ABSOLUTE PATH, so every page of an origin reads the same file
92
+ * whatever directory it was served from. It left with `peer-runtime.ts` and
93
+ * is declared here because this module is now the only thing that reads it.
94
+ */
95
+ export const DEFAULT_HTTPEERS_CONFIG_URL = "/httpeers.json";
96
+ async function readDeploymentConfigOverHttp() {
97
+ try {
98
+ const res = await fetch(DEFAULT_HTTPEERS_CONFIG_URL);
99
+ if (!res.ok)
100
+ return null;
101
+ // PARSED, NOT CAST. A hub-page deployment publishes `relayAddrs` with no
102
+ // `hubPeerId` -- there is no stable one to publish -- and casting that let
103
+ // it reach `startMember` as `hubPeerId: undefined`. `null` is the honest
104
+ // answer and the session already renders it as "paste an invitation".
105
+ return parseMeshConfig(await res.json());
106
+ }
107
+ catch {
108
+ // A diagnostic that cannot be read is simply not added to the message.
109
+ return null;
110
+ }
111
+ }
112
+ /**
113
+ * The sentence added to a "this hub does not know you" message when the
114
+ * deployment has visibly moved underneath the page.
115
+ *
116
+ * THE CASE THIS EXISTS FOR: `pnpm bootstrap` was re-run, so `httpeers.json`
117
+ * now names a hub with a different peerId than the one this page last
118
+ * joined. Everything about the page is fine, the hub it is talking to is
119
+ * fine, and the membership is simply in a mesh that no longer exists. With
120
+ * nothing said, that presents as "please paste an invitation" for reasons
121
+ * an operator cannot see. Exported for its own test.
122
+ */
123
+ export function describeMeshDrift(tried, deployment) {
124
+ if (deployment == null || deployment.hubPeerId === tried)
125
+ return null;
126
+ return (` This deployment's httpeers.json now names a different hub (${deployment.hubPeerId}), ` +
127
+ "which is a different mesh -- so a membership in the old one cannot carry over. Re-running " +
128
+ '"pnpm bootstrap" does that, and so does moving between the Node hub and a hub page.');
129
+ }
130
+ export function createPeerSession(init) {
131
+ const startPeer = init.startPeer ?? startMember;
132
+ const identityStore = init.identity;
133
+ const meshMemory = init.meshMemory;
134
+ const readDeployment = init.readDeploymentConfig ?? readDeploymentConfigOverHttp;
135
+ const reload = init.reload;
136
+ let phase = { kind: "checking" };
137
+ let identity = null;
138
+ let handle = null;
139
+ /** One attempt at a time. Two concurrent `startBrowserPeer` calls would build two libp2p nodes on ONE identity -- the very duplicate this session refuses when someone else does it. */
140
+ let busy = false;
141
+ const state = () => ({
142
+ phase,
143
+ identity,
144
+ handle,
145
+ hubLink: phase.kind === "live" ? (handle?.hubLink() ?? null) : null,
146
+ controls: controlsFor(phase),
147
+ });
148
+ const set = (next) => {
149
+ phase = next;
150
+ init.onChange(state());
151
+ };
152
+ /**
153
+ * One join attempt, whichever way it was provoked (page load, the form,
154
+ * reconnect). `input` is what someone supplied, or `null` for a pure
155
+ * resume.
156
+ */
157
+ const attempt = async (input) => {
158
+ if (busy)
159
+ return;
160
+ busy = true;
161
+ /** The mesh this attempt actually tried, for the drift diagnostic below. */
162
+ let tried = null;
163
+ try {
164
+ set({ kind: "starting", peerState: "loading-config" });
165
+ // WHICH MESH, AND IN WHICH ORDER. A blob names its own and that is the
166
+ // end of it. A BARE invitation id skips the memory entirely and means
167
+ // the deployment's -- the contract `./join-blob.ts` states. Only a pure
168
+ // resume consults the memory, falling back to the deployment for a page
169
+ // that has only ever joined the Node hub.
170
+ const preferred = input != null ? configOf(input) : ((await meshMemory.read()) ?? undefined);
171
+ const config = preferred ?? (await readDeployment()) ?? undefined;
172
+ if (config == null) {
173
+ // `startMember` requires a config and there is none to give it, so
174
+ // nothing is started and the key is left untouched.
175
+ set({
176
+ kind: "needs-invitation",
177
+ reason: "no-mesh",
178
+ message: "There is no mesh to join: this page remembers none, and this deployment's " +
179
+ `${DEFAULT_HTTPEERS_CONFIG_URL} does not name one either. Paste a join link or a ` +
180
+ "join blob -- either names its own mesh.",
181
+ });
182
+ return;
183
+ }
184
+ tried = config;
185
+ const key = await identityStore.loadOrCreate();
186
+ identity = peerIdOf(key);
187
+ const joined = await startPeer({
188
+ key: init.key,
189
+ mounts: init.mounts,
190
+ rules: init.rules,
191
+ advertisements: init.advertisements,
192
+ // The three things a page does differently, as one object — see
193
+ // `browser-platform.ts` for why this replaced a second lifecycle.
194
+ platform: init.platform,
195
+ privateKey: key,
196
+ invitationId: input == null ? undefined : invitationIdOf(input),
197
+ config,
198
+ onState: (peerState) => {
199
+ // Late lifecycle callbacks from a peer that has since been stopped
200
+ // must not repaint the page: `stop()` fires `"stopped"`, and a
201
+ // disconnect that then redrew itself as "starting" would be a lie.
202
+ if (phase.kind === "starting")
203
+ set({ kind: "starting", peerState });
204
+ },
205
+ onPresenceRefused: (refusal) => {
206
+ void onLostPresence(refusal);
207
+ },
208
+ });
209
+ handle = joined;
210
+ identity = joined.peerId;
211
+ // Remembered only now, and only from the handle -- see
212
+ // `./mesh-memory.ts`'s "WRITTEN ONLY AFTER A JOIN ACTUALLY SUCCEEDS".
213
+ await meshMemory.write({
214
+ relayAddrs: [joined.relayAddr],
215
+ hubPeerId: joined.hubPeerId,
216
+ });
217
+ set({
218
+ kind: "live",
219
+ joinedBy: joined.joinedBy,
220
+ note: [
221
+ joined.joinedBy === "resumed" && input != null
222
+ ? "This page was already a member of this mesh, so it resumed instead of " +
223
+ "redeeming -- the invitation you supplied was not used and is still unspent. " +
224
+ "Reset this page's identity if you meant to join as a new peer."
225
+ : null,
226
+ // Why the link is `relay` when it could have been direct -- see `MemberHandle.hubLinkNote`.
227
+ joined.hubLinkNote ?? null,
228
+ ]
229
+ .filter((line) => line != null)
230
+ .join(" ") || null,
231
+ });
232
+ }
233
+ catch (err) {
234
+ handle = null;
235
+ await onAttemptFailed(err, input, tried);
236
+ }
237
+ finally {
238
+ busy = false;
239
+ }
240
+ };
241
+ const onAttemptFailed = async (err, input, tried) => {
242
+ if (err instanceof MemberJoinError) {
243
+ if (err.reason === "duplicate-identity") {
244
+ set({ kind: "blocked", message: err.message });
245
+ return;
246
+ }
247
+ if (err.reason === "invitation-refused") {
248
+ set({ kind: "needs-invitation", reason: "invitation-refused", message: err.message });
249
+ return;
250
+ }
251
+ if (err.reason === "not-a-member") {
252
+ // Only a RESUME gets the drift diagnostic: if an invitation was
253
+ // supplied, the mesh came from that invitation and comparing it
254
+ // against `httpeers.json` would be comparing two things nobody
255
+ // claimed were the same.
256
+ const drift = input == null && tried != null
257
+ ? describeMeshDrift(tried.hubPeerId, await readDeployment())
258
+ : null;
259
+ set({
260
+ kind: "needs-invitation",
261
+ reason: "unknown-identity",
262
+ message: err.message + (drift ?? ""),
263
+ });
264
+ return;
265
+ }
266
+ }
267
+ set({ kind: "failed", message: String(err) });
268
+ console.error("session: could not join the mesh:", err);
269
+ };
270
+ /**
271
+ * The hub refused a heartbeat of a page that WAS live. Nothing is torn
272
+ * down here except this peer's own node: a membership that has gone away,
273
+ * or an identity two nodes are fighting over, will not repair itself on
274
+ * the next tick, and a page left heartbeating into a refusal would look
275
+ * joined while being invisible to everyone else.
276
+ */
277
+ const onLostPresence = async (refusal) => {
278
+ if (phase.kind !== "live")
279
+ return;
280
+ const stopping = handle;
281
+ handle = null;
282
+ if (refusal.kind === "duplicate-identity") {
283
+ set({
284
+ kind: "blocked",
285
+ message: `Another live peer is now using this identity (${identity ?? "unknown"}). This page ` +
286
+ "has stopped rather than take turns overwriting its presence -- close the other " +
287
+ "tab, or reset this page's identity.",
288
+ });
289
+ }
290
+ else if (refusal.kind === "not-a-member") {
291
+ set({
292
+ kind: "needs-invitation",
293
+ reason: "unknown-identity",
294
+ message: "This hub no longer lists this peer as a member -- the membership was revoked, or " +
295
+ "the hub's state was reset. This page has stopped; an invitation is the way back in.",
296
+ });
297
+ }
298
+ else {
299
+ set({
300
+ kind: "failed",
301
+ message: `The hub refused this page's heartbeat: ${refusal.message}`,
302
+ });
303
+ }
304
+ await stopping?.stop().catch(() => { });
305
+ };
306
+ return {
307
+ async start() {
308
+ let input;
309
+ try {
310
+ input = readJoinInputFromSearch(init.search);
311
+ }
312
+ catch (err) {
313
+ // A half-pasted link is a legible complaint, not a blank page -- and
314
+ // the form stays open so the operator can paste the whole one.
315
+ identity = await identityStore
316
+ .read()
317
+ .then((k) => (k == null ? null : peerIdOf(k)))
318
+ .catch(() => null);
319
+ set({ kind: "needs-invitation", reason: "no-mesh", message: String(err) });
320
+ return;
321
+ }
322
+ const key = await identityStore.read();
323
+ identity = key == null ? null : peerIdOf(key);
324
+ if (key == null && input == null) {
325
+ // A FIRST RUN, SAID AS SUCH. Nothing is generated yet: creating a
326
+ // key here would make every later load report an identity this
327
+ // browser has never used for anything, and the difference between
328
+ // that and "the hub does not know my identity" is exactly what
329
+ // requirement 3 is about.
330
+ set({
331
+ kind: "needs-invitation",
332
+ reason: "no-identity",
333
+ message: "This page has no saved identity yet -- it has never joined a mesh from this " +
334
+ "browser. Paste an invitation id, a join blob, or a whole join link to join one; " +
335
+ "an identity is created and saved at that point, and every later visit resumes " +
336
+ "with it.",
337
+ });
338
+ return;
339
+ }
340
+ // An identity with no remembered mesh is NOT a refusal: `attempt`
341
+ // leaves the config undefined, `startBrowserPeer` fetches
342
+ // `httpeers.json`, and the deployment's own mesh is tried -- which is
343
+ // the only candidate there is, and the right one for every page that
344
+ // has only ever joined the Node hub.
345
+ await attempt(input);
346
+ },
347
+ async join(text) {
348
+ let input;
349
+ try {
350
+ input = readJoinInputFromText(text);
351
+ }
352
+ catch (err) {
353
+ set({ kind: "needs-invitation", reason: "invitation-refused", message: String(err) });
354
+ return;
355
+ }
356
+ if (input == null)
357
+ return; // an empty field is not an error, it is nothing happening.
358
+ await attempt(input);
359
+ },
360
+ async disconnect() {
361
+ const stopping = handle;
362
+ if (stopping == null)
363
+ return;
364
+ handle = null;
365
+ await stopping.stop();
366
+ set({
367
+ kind: "disconnected",
368
+ message: "Disconnected. This page still IS a member of the mesh -- membership is held by the " +
369
+ "hub and nothing here surrendered it -- so reconnecting needs no new invitation. " +
370
+ "Presence is a heartbeat, so this peer leaves everyone else's mesh view (and its " +
371
+ "advertisements with it) within one presence interval.",
372
+ });
373
+ },
374
+ async reconnect() {
375
+ await attempt(null);
376
+ },
377
+ /**
378
+ * Forget this origin's key. DESTRUCTIVE, and the cost is worth stating
379
+ * where the caller can read it: the next run is a DIFFERENT peer, so
380
+ * this page's current membership becomes an orphaned record on the hub
381
+ * (visible to an admin, removable by one) and rejoining needs a fresh
382
+ * invitation. It exists because a stuck key with no way out is worse
383
+ * than no persistence at all -- the same reason the hub page has one
384
+ * (`../pages/hub/main.ts`), for a much smaller blast radius.
385
+ *
386
+ * The remembered mesh is deliberately KEPT: the new identity will still
387
+ * be joining the mesh this page was pointed at, and forgetting it would
388
+ * only mean a bare invitation id could no longer be used against a hub
389
+ * page.
390
+ */
391
+ async resetIdentity() {
392
+ const stopping = handle;
393
+ handle = null;
394
+ // Stopped first, so nothing is still heartbeating as a peer whose key
395
+ // is about to disappear.
396
+ await stopping?.stop().catch(() => { });
397
+ await identityStore.clear();
398
+ identity = null;
399
+ reload();
400
+ },
401
+ state,
402
+ };
403
+ }
@@ -0,0 +1,176 @@
1
+ /**
2
+ * `startMember` — the candidate `member` package's entry point.
3
+ *
4
+ * THIS IS `src/browser/peer-runtime.ts`'s `startBrowserPeer` WITH THREE
5
+ * COUPLINGS LIFTED OUT, and nothing else changed. Every step of the join is
6
+ * the proven one, imported from the stack rather than reimplemented:
7
+ * `dialRelay`, `reachHub`, `reachHubRelayed`, `createPeer`, `resumeMembership`,
8
+ * `redeemInvitation`, `reserveOnHub`, `superviseHubReservation`,
9
+ * `leaveRelay`, `startJoin`, `createRouteEnsurer`, `createEdgeDispatch`.
10
+ * If this file had to re-implement any of them, the seam would be in the
11
+ * wrong place and the rung would have failed.
12
+ *
13
+ * The three couplings, and why each is a platform boundary rather than a
14
+ * preference:
15
+ *
16
+ * 1. `createNode` — `createBrowserNode` hard-codes the browser transport
17
+ * set (webSockets + webRTC + circuitRelay) and reads the identity key
18
+ * out of IndexedDB. Node needs `tcp` as well, and its key comes from a
19
+ * file. The LISTEN ADDRESSES ARE NOT A PLATFORM CHOICE and are kept
20
+ * identical: a member listens on `/p2p-circuit` + `/webrtc` because it
21
+ * reserves on its hub, which is what `feat/hub-relay` established.
22
+ * 2. `mountEdge` — the ServiceWorker is how a *page* turns `fetch()` into
23
+ * a mesh call. Node has no ServiceWorker and needs none: the dispatch
24
+ * handler IS the edge, callable in process. So the edge is optional,
25
+ * and `MemberHandle.fetch` is always present — on both platforms it is
26
+ * the same `createEdgeDispatch` handler, which is the whole isomorphism
27
+ * claim in one field.
28
+ * 3. `watchWake` — `page-wake.ts` listens on `window`/`document`. A Node
29
+ * process has no such events; the supervisor's own backstop timer is
30
+ * what covers it, so the default is a no-op rather than a stub.
31
+ *
32
+ * `rules` replaces `policies`: `peer-runtime.ts` calls the app's `appRules`,
33
+ * which bakes `APP_RULES` — an application's vocabulary — into what ought to
34
+ * be library code. The caller passes a built `RuleSet` instead.
35
+ */
36
+ import type { Ed25519PrivateKey, Libp2p } from "@libp2p/interface";
37
+ import type { RuleSet } from "@statewalker/httpeers-access";
38
+ import type { FetchHandler, MeshView, Mounts, PeerIdStr } from "@statewalker/httpeers-core";
39
+ import { type ConnectionKind } from "./connection-kind.js";
40
+ import type { AdvertisementInput, PresenceRefusal } from "./join.js";
41
+ /** The mesh a member joins: exactly `httpeers.json`'s two fields. */
42
+ export interface MeshConfig {
43
+ relayAddrs: string[];
44
+ hubPeerId: string;
45
+ }
46
+ /** What a platform's edge gives back. The browser's `mountEdge` already returns this shape. */
47
+ export interface MemberEdge {
48
+ /** Where this peer's own `fetch()` reaches the mesh, e.g. `https://origin/peers/`. */
49
+ baseUrl: string;
50
+ stop(): Promise<void>;
51
+ }
52
+ /** The platform adapters — one per thing that genuinely differs between Node and a page. */
53
+ export interface MemberPlatform {
54
+ /** Build the libp2p node. Browser: `createBrowserNode`. Node: `createNodeMemberNode` (`./node.ts`). */
55
+ createNode(init: {
56
+ privateKey?: Ed25519PrivateKey;
57
+ }): Promise<Libp2p>;
58
+ /**
59
+ * Publish `dispatch` at a local URL. Browser: the ServiceWorker edge, via
60
+ * `@statewalker/webrun-http-browser`'s `SwHttpAdapter`. Omitted under Node,
61
+ * where `MemberHandle.fetch` is the edge.
62
+ */
63
+ mountEdge?(init: {
64
+ key: string;
65
+ dispatch: FetchHandler;
66
+ }): Promise<MemberEdge>;
67
+ /** Fires when the host wakes (online / visible / resumed) so the supervisor retries at once. Browser: `watchPageWake`. */
68
+ watchWake?(onWake: () => void): () => void;
69
+ }
70
+ export interface StartMemberInit {
71
+ /** Mount-prefix first segment, and the ServiceWorker adapter key where there is one. */
72
+ key: string;
73
+ /** This peer's own mount table, served to other members and through its own edge. */
74
+ mounts: Mounts;
75
+ /** This peer's rule set, already built. Replaces `peer-runtime.ts`'s `policies` + `appRules`. */
76
+ rules: RuleSet;
77
+ /** The mesh to join. A page may read it from `httpeers.json`; that fetch is the caller's, not this function's. */
78
+ config: MeshConfig;
79
+ platform: MemberPlatform;
80
+ invitationId?: string;
81
+ privateKey?: Ed25519PrivateKey;
82
+ advertisements?: () => AdvertisementInput[];
83
+ onPresenceRefused?: (refusal: PresenceRefusal) => void;
84
+ onState?: (state: MemberState) => void;
85
+ heartbeatIntervalMs?: number;
86
+ keepaliveIntervalMs?: number;
87
+ }
88
+ export type MemberState = "connecting-relay" | "starting-peer" | "dialing-hub" | "resuming" | "joining" | "awaiting-reservation" | "mounting-edge" | "ready" | "stopped";
89
+ export type JoinMethod = "resumed" | "redeemed";
90
+ /**
91
+ * How a member reaches its hub. `"direct"` is the WebRTC upgrade, with a
92
+ * reservation on the hub so other members can reach this one through it.
93
+ * `"relay"` is the fallback when that upgrade fails (a hub in Docker on a
94
+ * bridge network), or when it succeeds but the hub then refuses the
95
+ * reservation for want of capacity (see `fallsBackToRelay`): a KEPT limited
96
+ * circuit through the public relay, over which this member calls the hub and
97
+ * nothing else -- no reservation, so no other member can reach it.
98
+ *
99
+ * FIXED FOR A RUN. It is decided once, at join; a relay-mode member re-links
100
+ * over a new circuit and never retries the upgrade (no automatic re-upgrade in
101
+ * v1), so it never becomes `"direct"` without direct mode's reservation. A new
102
+ * join (a reconnect) decides again.
103
+ */
104
+ export type HubLink = "direct" | "relay";
105
+ export interface MemberHandle {
106
+ peerId: PeerIdStr;
107
+ hubPeerId: string;
108
+ relayAddr: string;
109
+ joinedBy: JoinMethod;
110
+ /**
111
+ * This member's edge, in process. The SAME handler the ServiceWorker
112
+ * serves in a page — `createEdgeDispatch`'s — so a Node caller writes
113
+ * `member.fetch(new Request(\`http://local/${key}/${peer}/x\`))` where a
114
+ * page writes `fetch(\`${baseUrl}${peer}/x\`)`, and both take the identical
115
+ * path from there on.
116
+ */
117
+ fetch: FetchHandler;
118
+ /** Present only where the platform mounted an edge (a page). */
119
+ baseUrl?: string;
120
+ meshView(): MeshView | null;
121
+ token(): string;
122
+ connectionKind(peerId: string): ConnectionKind;
123
+ /** How this member reaches its hub -- see `HubLink`. Constant for the life of this handle. */
124
+ hubLink(): HubLink;
125
+ /**
126
+ * Why this member is in relay mode although its WebRTC upgrade worked -- the
127
+ * hub refused it a reservation -- in words a page can show. Absent when
128
+ * there is nothing unexpected to say: a direct link, or a relay link because
129
+ * the upgrade itself failed (a known deployment shape, see `HubLink`).
130
+ */
131
+ hubLinkNote?: string;
132
+ /**
133
+ * The libp2p node, as an escape hatch.
134
+ *
135
+ * `startBrowserPeer` does not expose it and a page never needs it. It is
136
+ * here because everything that OBSERVES OR PERTURBS THE TRANSPORT has to
137
+ * reach it: `connectionKind` above is already such a thing, and claim 7
138
+ * of this rung — drop the hub link and watch the supervisor restore it —
139
+ * cannot be written without it. A library that hides the node entirely
140
+ * makes its own recovery behaviour untestable from outside.
141
+ */
142
+ node: Libp2p;
143
+ stop(): Promise<void>;
144
+ }
145
+ export declare class MemberJoinError extends Error {
146
+ readonly reason: "not-a-member" | "duplicate-identity" | "presence-refused" | "invitation-refused";
147
+ constructor(reason: "not-a-member" | "duplicate-identity" | "presence-refused" | "invitation-refused", message: string, options?: {
148
+ cause?: unknown;
149
+ });
150
+ }
151
+ /**
152
+ * Does a refused reservation on the hub leave the join standing, in relay
153
+ * mode? Only when the refusal says nothing about THIS member:
154
+ *
155
+ * - `store-full` (`RESERVATION_REFUSED`) and `resource-limit`
156
+ * (`RESOURCE_LIMIT_EXCEEDED`) -- the hub is out of capacity. This is the
157
+ * production failure: a hub's relay grants a fixed number of
158
+ * reservations, and once they were spent every direct-linked page failed
159
+ * to join while relay-mode pages worked.
160
+ * - `no-answer` -- a timeout or a dropped link; nothing was refused.
161
+ *
162
+ * The reservation only lets OTHER members reach this one; everything the
163
+ * member does itself works in relay mode, so failing the join over it is
164
+ * strictly worse than degrading.
165
+ *
166
+ * NOT for `not-a-member` (`PERMISSION_DENIED`): the hub has just accepted
167
+ * this peer, so a hub that now denies it membership contradicts itself (a
168
+ * revocation in between, a gater reading a different store) -- a real fault
169
+ * to report, not a condition to route around. NOT for `no-relay`: a hub
170
+ * deployed without its relay service is a configuration error that would put
171
+ * every member in relay mode silently. NOT for any other status, which is a
172
+ * protocol fault. And not for errors that are not refusals at all.
173
+ */
174
+ export declare function fallsBackToRelay(err: unknown): boolean;
175
+ export declare function startMember(init: StartMemberInit): Promise<MemberHandle>;
176
+ //# sourceMappingURL=start-member.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"start-member.d.ts","sourceRoot":"","sources":["../src/start-member.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AAEH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,EAAE,MAAM,mBAAmB,CAAC;AAEnE,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,8BAA8B,CAAC;AAE5D,OAAO,KAAK,EAAE,YAAY,EAAE,QAAQ,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,4BAA4B,CAAC;AAY5F,OAAO,EAAE,KAAK,cAAc,EAAsB,MAAM,sBAAsB,CAAC;AAE/E,OAAO,KAAK,EAAE,kBAAkB,EAAE,eAAe,EAAE,MAAM,WAAW,CAAC;AAUrE,qEAAqE;AACrE,MAAM,WAAW,UAAU;IACzB,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,+FAA+F;AAC/F,MAAM,WAAW,UAAU;IACzB,sFAAsF;IACtF,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACvB;AAED,4FAA4F;AAC5F,MAAM,WAAW,cAAc;IAC7B,uGAAuG;IACvG,UAAU,CAAC,IAAI,EAAE;QAAE,UAAU,CAAC,EAAE,iBAAiB,CAAA;KAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IACtE;;;;OAIG;IACH,SAAS,CAAC,CAAC,IAAI,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,YAAY,CAAA;KAAE,GAAG,OAAO,CAAC,UAAU,CAAC,CAAC;IAC/E,0HAA0H;IAC1H,SAAS,CAAC,CAAC,MAAM,EAAE,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC;CAC5C;AAED,MAAM,WAAW,eAAe;IAC9B,wFAAwF;IACxF,GAAG,EAAE,MAAM,CAAC;IACZ,qFAAqF;IACrF,MAAM,EAAE,MAAM,CAAC;IACf,iGAAiG;IACjG,KAAK,EAAE,OAAO,CAAC;IACf,kHAAkH;IAClH,MAAM,EAAE,UAAU,CAAC;IACnB,QAAQ,EAAE,cAAc,CAAC;IACzB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,UAAU,CAAC,EAAE,iBAAiB,CAAC;IAC/B,cAAc,CAAC,EAAE,MAAM,kBAAkB,EAAE,CAAC;IAC5C,iBAAiB,CAAC,EAAE,CAAC,OAAO,EAAE,eAAe,KAAK,IAAI,CAAC;IACvD,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,WAAW,KAAK,IAAI,CAAC;IACvC,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,mBAAmB,CAAC,EAAE,MAAM,CAAC;CAC9B;AAED,MAAM,MAAM,WAAW,GACnB,kBAAkB,GAClB,eAAe,GACf,aAAa,GACb,UAAU,GACV,SAAS,GACT,sBAAsB,GACtB,eAAe,GACf,OAAO,GACP,SAAS,CAAC;AAEd,MAAM,MAAM,UAAU,GAAG,SAAS,GAAG,UAAU,CAAC;AAEhD;;;;;;;;;;;;;GAaG;AACH,MAAM,MAAM,OAAO,GAAG,QAAQ,GAAG,OAAO,CAAC;AAEzC,MAAM,WAAW,YAAY;IAC3B,MAAM,EAAE,SAAS,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,UAAU,CAAC;IACrB;;;;;;OAMG;IACH,KAAK,EAAE,YAAY,CAAC;IACpB,gEAAgE;IAChE,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,QAAQ,IAAI,QAAQ,GAAG,IAAI,CAAC;IAC5B,KAAK,IAAI,MAAM,CAAC;IAChB,cAAc,CAAC,MAAM,EAAE,MAAM,GAAG,cAAc,CAAC;IAC/C,8FAA8F;IAC9F,OAAO,IAAI,OAAO,CAAC;IACnB;;;;;OAKG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;;;;;;OASG;IACH,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACvB;AAED,qBAAa,eAAgB,SAAQ,KAAK;IAEtC,QAAQ,CAAC,MAAM,EACX,cAAc,GACd,oBAAoB,GACpB,kBAAkB,GAClB,oBAAoB;IAL1B,YACW,MAAM,EACX,cAAc,GACd,oBAAoB,GACpB,kBAAkB,GAClB,oBAAoB,EACxB,OAAO,EAAE,MAAM,EACf,OAAO,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,OAAO,CAAA;KAAE,EAI9B;CACF;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAKtD;AAED,wBAAsB,WAAW,CAAC,IAAI,EAAE,eAAe,GAAG,OAAO,CAAC,YAAY,CAAC,CA0P9E"}