@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
package/src/session.ts ADDED
@@ -0,0 +1,579 @@
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
+
49
+ import type { Ed25519PrivateKey } from "@libp2p/interface";
50
+ import type { RuleSet } from "@statewalker/httpeers-access";
51
+ import type { MeshConfig as HttpeersConfig, Mounts } from "@statewalker/httpeers-core";
52
+ import type { IdentityStoreInit } from "./identity.js";
53
+ import { clearIdentity, loadOrCreateIdentity, peerIdOf, readIdentity } from "./identity.js";
54
+ import type { AdvertisementInput, PresenceRefusal } from "./join.js";
55
+ import type { JoinInput } from "./join-blob.js";
56
+ import { readJoinInputFromSearch, readJoinInputFromText } from "./join-blob.js";
57
+ import { parseMeshConfig } from "./mesh-config.js";
58
+ import type { MeshMemory } from "./mesh-memory.js";
59
+ import type {
60
+ HubLink,
61
+ JoinMethod,
62
+ MemberHandle,
63
+ MemberPlatform,
64
+ MemberState,
65
+ StartMemberInit,
66
+ } from "./start-member.js";
67
+ import { MemberJoinError, startMember } from "./start-member.js";
68
+
69
+ /** Why this page is asking for an invitation. The two that matter are the first two -- see `SessionPhase`. */
70
+ export type NeedsInvitationReason =
71
+ /** This browser has never held an identity for this origin: a genuine first run. */
72
+ | "no-identity"
73
+ /** This browser holds an identity, the hub was reachable, and it does not know it. */
74
+ | "unknown-identity"
75
+ /** An invitation was supplied and the hub refused it (spent, expired, unknown). */
76
+ | "invitation-refused"
77
+ /** Nothing was supplied and nothing is remembered, so there is no mesh to even try. */
78
+ | "no-mesh";
79
+
80
+ /**
81
+ * Where the session is. Every one of these is a distinct thing to say on
82
+ * screen, which is the only reason there are six.
83
+ *
84
+ * `needs-invitation` CARRIES ITS REASON because "no saved identity yet" and
85
+ * "saved identity, but this hub does not know it" are not the same
86
+ * situation and must not read the same (Task 28, requirement 3). The first
87
+ * is a page that has never run; the second is a hub reset or a revoked
88
+ * membership, and an operator told only "please paste an invitation" would
89
+ * have no way to tell which had happened -- or that their saved identity is
90
+ * intact and simply unrecognised.
91
+ *
92
+ * `blocked` is the duplicate-identity refusal, kept apart from `failed`
93
+ * because it is not a fault to retry: two tabs are running one libp2p
94
+ * identity, and something has to give before this page can proceed.
95
+ */
96
+ /**
97
+ * What `starting` reports.
98
+ *
99
+ * `"loading-config"` is the SESSION'S OWN step, not the member's: resolving
100
+ * which mesh to try can mean an `httpeers.json` fetch, which is the one part
101
+ * of a start that happens before `startMember` exists to have a state. Every
102
+ * other value is `MemberState` verbatim, forwarded.
103
+ */
104
+ export type SessionStartState = "loading-config" | MemberState;
105
+
106
+ export type SessionPhase =
107
+ | { kind: "checking" }
108
+ | { kind: "starting"; peerState: SessionStartState }
109
+ | { kind: "live"; joinedBy: JoinMethod; note: string | null }
110
+ | { kind: "needs-invitation"; reason: NeedsInvitationReason; message: string }
111
+ | { kind: "disconnected"; message: string }
112
+ | { kind: "blocked"; message: string }
113
+ | { kind: "failed"; message: string };
114
+
115
+ /** Which controls make sense in the current phase. Derived here, once, rather than by each page re-deriving it from `phase.kind`. */
116
+ export interface SessionControls {
117
+ join: boolean;
118
+ disconnect: boolean;
119
+ reconnect: boolean;
120
+ reset: boolean;
121
+ }
122
+
123
+ export interface SessionState {
124
+ phase: SessionPhase;
125
+ /** The peerId this origin holds, or `null` when it has never had one. Known before anything is dialled. */
126
+ identity: string | null;
127
+ /** Live only while `phase.kind === "live"`. */
128
+ handle: MemberHandle | null;
129
+ /** How the live member reaches its hub (`MemberHandle.hubLink`, fixed for the member's run); `null` unless live. */
130
+ hubLink: HubLink | null;
131
+ controls: SessionControls;
132
+ }
133
+
134
+ /** `startMember`'s shape, as a seam -- see the module comment. */
135
+ export type StartPeer = (init: StartMemberInit) => Promise<MemberHandle>;
136
+
137
+ /** The identity store's three operations, as a seam. Defaults to `./identity.ts` over the real IndexedDB. */
138
+ export interface IdentityStore {
139
+ read(): Promise<Ed25519PrivateKey | null>;
140
+ loadOrCreate(): Promise<Ed25519PrivateKey>;
141
+ clear(): Promise<void>;
142
+ }
143
+
144
+ export function createIdentityStore(init: IdentityStoreInit): IdentityStore {
145
+ return {
146
+ read: () => readIdentity(init),
147
+ loadOrCreate: () => loadOrCreateIdentity(init),
148
+ clear: () => clearIdentity(init),
149
+ };
150
+ }
151
+
152
+ export interface PeerSessionInit {
153
+ /** Everything `startMember` needs that is this PAGE's own, and nothing that is the session's. */
154
+ key: string;
155
+ mounts: Mounts;
156
+ /** This page's own rule set, already built -- see `StartMemberInit.rules`. */
157
+ rules: RuleSet;
158
+ advertisements?: () => AdvertisementInput[];
159
+ /**
160
+ * How this host builds a node, mounts an edge and hears about wakes.
161
+ * REQUIRED, and deliberately: see the module comment on why a default
162
+ * would make importing this module browser-only.
163
+ */
164
+ platform: MemberPlatform;
165
+ /** This page's `location.search`, read once at `start()`. */
166
+ search: string;
167
+ /** Fired on every state change, including the first. */
168
+ onChange: (state: SessionState) => void;
169
+ /** Where this origin's key lives. Required for the same reason `./mesh-memory.ts`'s backend is. */
170
+ identity: IdentityStore;
171
+ /** The mesh this origin last joined. Required for the same reason. */
172
+ meshMemory: MeshMemory;
173
+ /**
174
+ * Reloading the host after a reset. REQUIRED because `location` is the one
175
+ * DOM global this module would otherwise have to name; `./browser.ts`
176
+ * supplies it.
177
+ */
178
+ reload: () => void;
179
+ // --- seams, defaulted ----------------------------------------------------
180
+ startPeer?: StartPeer;
181
+ /**
182
+ * The mesh this DEPLOYMENT names (`httpeers.json`).
183
+ *
184
+ * READ FOR TWO REASONS. It is the fallback mesh -- what a bare invitation
185
+ * id means, and what a page with an identity but no remembered mesh tries.
186
+ * And it explains a failure: when a page cannot resume against the mesh it
187
+ * REMEMBERS and the deployment now names a different hub, that is almost
188
+ * always a re-run of `pnpm bootstrap` (or a switch between the Node hub and
189
+ * a hub page), and saying so is the difference between a two-minute fix and
190
+ * an hour.
191
+ */
192
+ readDeploymentConfig?: () => Promise<HttpeersConfig | null>;
193
+ }
194
+
195
+ export interface PeerSession {
196
+ /** Read the saved identity and try to resume. Call once, on page load. */
197
+ start(): Promise<void>;
198
+ /** Join with what someone typed or pasted: a blob, a bare invitation id, or a whole join link. */
199
+ join(text: string): Promise<void>;
200
+ /** Stop this peer: drop presence, KEEP membership -- see `MemberHandle.stop`. */
201
+ disconnect(): Promise<void>;
202
+ /** Come back after a `disconnect`, by resuming. No invitation involved. */
203
+ reconnect(): Promise<void>;
204
+ /** Forget this origin's key, so the next run is a different peer. Destructive -- see below. */
205
+ resetIdentity(): Promise<void>;
206
+ state(): SessionState;
207
+ }
208
+
209
+ function controlsFor(phase: SessionPhase): SessionControls {
210
+ switch (phase.kind) {
211
+ case "live":
212
+ return { join: false, disconnect: true, reconnect: false, reset: true };
213
+ case "disconnected":
214
+ return { join: false, disconnect: false, reconnect: true, reset: true };
215
+ // Every remaining phase leaves the invitation field open -- including
216
+ // `failed`. A page that could not reach the relay will not be fixed by
217
+ // an invitation, but an operator who has just been handed a link to a
218
+ // DIFFERENT mesh has every reason to try it, and a form that vanished on
219
+ // the wrong kind of failure would be the page deciding that for them.
220
+ case "blocked":
221
+ return { join: false, disconnect: false, reconnect: false, reset: true };
222
+ case "checking":
223
+ case "starting":
224
+ return { join: false, disconnect: false, reconnect: false, reset: false };
225
+ default:
226
+ return { join: true, disconnect: false, reconnect: false, reset: true };
227
+ }
228
+ }
229
+
230
+ /** A blob names its own mesh; a bare invitation id means "whichever mesh `httpeers.json` names" -- `./join-blob.ts`. */
231
+ function configOf(input: JoinInput): HttpeersConfig | undefined {
232
+ return input.kind === "blob"
233
+ ? { relayAddrs: input.blob.relayAddrs, hubPeerId: input.blob.hubPeerId }
234
+ : undefined;
235
+ }
236
+
237
+ function invitationIdOf(input: JoinInput): string {
238
+ return input.kind === "blob" ? input.blob.invitationId : input.invitationId;
239
+ }
240
+
241
+ /**
242
+ * Where a deployment publishes the mesh it names.
243
+ *
244
+ * A SITE-ABSOLUTE PATH, so every page of an origin reads the same file
245
+ * whatever directory it was served from. It left with `peer-runtime.ts` and
246
+ * is declared here because this module is now the only thing that reads it.
247
+ */
248
+ export const DEFAULT_HTTPEERS_CONFIG_URL = "/httpeers.json";
249
+
250
+ async function readDeploymentConfigOverHttp(): Promise<HttpeersConfig | null> {
251
+ try {
252
+ const res = await fetch(DEFAULT_HTTPEERS_CONFIG_URL);
253
+ if (!res.ok) return null;
254
+ // PARSED, NOT CAST. A hub-page deployment publishes `relayAddrs` with no
255
+ // `hubPeerId` -- there is no stable one to publish -- and casting that let
256
+ // it reach `startMember` as `hubPeerId: undefined`. `null` is the honest
257
+ // answer and the session already renders it as "paste an invitation".
258
+ return parseMeshConfig(await res.json());
259
+ } catch {
260
+ // A diagnostic that cannot be read is simply not added to the message.
261
+ return null;
262
+ }
263
+ }
264
+
265
+ /**
266
+ * The sentence added to a "this hub does not know you" message when the
267
+ * deployment has visibly moved underneath the page.
268
+ *
269
+ * THE CASE THIS EXISTS FOR: `pnpm bootstrap` was re-run, so `httpeers.json`
270
+ * now names a hub with a different peerId than the one this page last
271
+ * joined. Everything about the page is fine, the hub it is talking to is
272
+ * fine, and the membership is simply in a mesh that no longer exists. With
273
+ * nothing said, that presents as "please paste an invitation" for reasons
274
+ * an operator cannot see. Exported for its own test.
275
+ */
276
+ export function describeMeshDrift(tried: string, deployment: HttpeersConfig | null): string | null {
277
+ if (deployment == null || deployment.hubPeerId === tried) return null;
278
+ return (
279
+ ` This deployment's httpeers.json now names a different hub (${deployment.hubPeerId}), ` +
280
+ "which is a different mesh -- so a membership in the old one cannot carry over. Re-running " +
281
+ '"pnpm bootstrap" does that, and so does moving between the Node hub and a hub page.'
282
+ );
283
+ }
284
+
285
+ export function createPeerSession(init: PeerSessionInit): PeerSession {
286
+ const startPeer = init.startPeer ?? startMember;
287
+ const identityStore = init.identity;
288
+ const meshMemory = init.meshMemory;
289
+ const readDeployment = init.readDeploymentConfig ?? readDeploymentConfigOverHttp;
290
+ const reload = init.reload;
291
+
292
+ let phase: SessionPhase = { kind: "checking" };
293
+ let identity: string | null = null;
294
+ let handle: MemberHandle | null = null;
295
+ /** 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. */
296
+ let busy = false;
297
+
298
+ const state = (): SessionState => ({
299
+ phase,
300
+ identity,
301
+ handle,
302
+ hubLink: phase.kind === "live" ? (handle?.hubLink() ?? null) : null,
303
+ controls: controlsFor(phase),
304
+ });
305
+
306
+ const set = (next: SessionPhase): void => {
307
+ phase = next;
308
+ init.onChange(state());
309
+ };
310
+
311
+ /**
312
+ * One join attempt, whichever way it was provoked (page load, the form,
313
+ * reconnect). `input` is what someone supplied, or `null` for a pure
314
+ * resume.
315
+ */
316
+ const attempt = async (input: JoinInput | null): Promise<void> => {
317
+ if (busy) return;
318
+ busy = true;
319
+ /** The mesh this attempt actually tried, for the drift diagnostic below. */
320
+ let tried: HttpeersConfig | null = null;
321
+ try {
322
+ set({ kind: "starting", peerState: "loading-config" });
323
+
324
+ // WHICH MESH, AND IN WHICH ORDER. A blob names its own and that is the
325
+ // end of it. A BARE invitation id skips the memory entirely and means
326
+ // the deployment's -- the contract `./join-blob.ts` states. Only a pure
327
+ // resume consults the memory, falling back to the deployment for a page
328
+ // that has only ever joined the Node hub.
329
+ const preferred = input != null ? configOf(input) : ((await meshMemory.read()) ?? undefined);
330
+ const config = preferred ?? (await readDeployment()) ?? undefined;
331
+
332
+ if (config == null) {
333
+ // `startMember` requires a config and there is none to give it, so
334
+ // nothing is started and the key is left untouched.
335
+ set({
336
+ kind: "needs-invitation",
337
+ reason: "no-mesh",
338
+ message:
339
+ "There is no mesh to join: this page remembers none, and this deployment's " +
340
+ `${DEFAULT_HTTPEERS_CONFIG_URL} does not name one either. Paste a join link or a ` +
341
+ "join blob -- either names its own mesh.",
342
+ });
343
+ return;
344
+ }
345
+
346
+ tried = config;
347
+ const key = await identityStore.loadOrCreate();
348
+ identity = peerIdOf(key);
349
+
350
+ const joined = await startPeer({
351
+ key: init.key,
352
+ mounts: init.mounts,
353
+ rules: init.rules,
354
+ advertisements: init.advertisements,
355
+ // The three things a page does differently, as one object — see
356
+ // `browser-platform.ts` for why this replaced a second lifecycle.
357
+ platform: init.platform,
358
+ privateKey: key,
359
+ invitationId: input == null ? undefined : invitationIdOf(input),
360
+ config,
361
+ onState: (peerState: MemberState) => {
362
+ // Late lifecycle callbacks from a peer that has since been stopped
363
+ // must not repaint the page: `stop()` fires `"stopped"`, and a
364
+ // disconnect that then redrew itself as "starting" would be a lie.
365
+ if (phase.kind === "starting") set({ kind: "starting", peerState });
366
+ },
367
+ onPresenceRefused: (refusal: PresenceRefusal) => {
368
+ void onLostPresence(refusal);
369
+ },
370
+ });
371
+
372
+ handle = joined;
373
+ identity = joined.peerId;
374
+ // Remembered only now, and only from the handle -- see
375
+ // `./mesh-memory.ts`'s "WRITTEN ONLY AFTER A JOIN ACTUALLY SUCCEEDS".
376
+ await meshMemory.write({
377
+ relayAddrs: [joined.relayAddr],
378
+ hubPeerId: joined.hubPeerId,
379
+ });
380
+
381
+ set({
382
+ kind: "live",
383
+ joinedBy: joined.joinedBy,
384
+ note:
385
+ [
386
+ joined.joinedBy === "resumed" && input != null
387
+ ? "This page was already a member of this mesh, so it resumed instead of " +
388
+ "redeeming -- the invitation you supplied was not used and is still unspent. " +
389
+ "Reset this page's identity if you meant to join as a new peer."
390
+ : null,
391
+ // Why the link is `relay` when it could have been direct -- see `MemberHandle.hubLinkNote`.
392
+ joined.hubLinkNote ?? null,
393
+ ]
394
+ .filter((line) => line != null)
395
+ .join(" ") || null,
396
+ });
397
+ } catch (err) {
398
+ handle = null;
399
+ await onAttemptFailed(err, input, tried);
400
+ } finally {
401
+ busy = false;
402
+ }
403
+ };
404
+
405
+ const onAttemptFailed = async (
406
+ err: unknown,
407
+ input: JoinInput | null,
408
+ tried: HttpeersConfig | null,
409
+ ): Promise<void> => {
410
+ if (err instanceof MemberJoinError) {
411
+ if (err.reason === "duplicate-identity") {
412
+ set({ kind: "blocked", message: err.message });
413
+ return;
414
+ }
415
+ if (err.reason === "invitation-refused") {
416
+ set({ kind: "needs-invitation", reason: "invitation-refused", message: err.message });
417
+ return;
418
+ }
419
+ if (err.reason === "not-a-member") {
420
+ // Only a RESUME gets the drift diagnostic: if an invitation was
421
+ // supplied, the mesh came from that invitation and comparing it
422
+ // against `httpeers.json` would be comparing two things nobody
423
+ // claimed were the same.
424
+ const drift =
425
+ input == null && tried != null
426
+ ? describeMeshDrift(tried.hubPeerId, await readDeployment())
427
+ : null;
428
+ set({
429
+ kind: "needs-invitation",
430
+ reason: "unknown-identity",
431
+ message: err.message + (drift ?? ""),
432
+ });
433
+ return;
434
+ }
435
+ }
436
+ set({ kind: "failed", message: String(err) });
437
+ console.error("session: could not join the mesh:", err);
438
+ };
439
+
440
+ /**
441
+ * The hub refused a heartbeat of a page that WAS live. Nothing is torn
442
+ * down here except this peer's own node: a membership that has gone away,
443
+ * or an identity two nodes are fighting over, will not repair itself on
444
+ * the next tick, and a page left heartbeating into a refusal would look
445
+ * joined while being invisible to everyone else.
446
+ */
447
+ const onLostPresence = async (refusal: PresenceRefusal): Promise<void> => {
448
+ if (phase.kind !== "live") return;
449
+ const stopping = handle;
450
+ handle = null;
451
+ if (refusal.kind === "duplicate-identity") {
452
+ set({
453
+ kind: "blocked",
454
+ message:
455
+ `Another live peer is now using this identity (${identity ?? "unknown"}). This page ` +
456
+ "has stopped rather than take turns overwriting its presence -- close the other " +
457
+ "tab, or reset this page's identity.",
458
+ });
459
+ } else if (refusal.kind === "not-a-member") {
460
+ set({
461
+ kind: "needs-invitation",
462
+ reason: "unknown-identity",
463
+ message:
464
+ "This hub no longer lists this peer as a member -- the membership was revoked, or " +
465
+ "the hub's state was reset. This page has stopped; an invitation is the way back in.",
466
+ });
467
+ } else {
468
+ set({
469
+ kind: "failed",
470
+ message: `The hub refused this page's heartbeat: ${refusal.message}`,
471
+ });
472
+ }
473
+ await stopping?.stop().catch(() => {});
474
+ };
475
+
476
+ return {
477
+ async start() {
478
+ let input: JoinInput | null;
479
+ try {
480
+ input = readJoinInputFromSearch(init.search);
481
+ } catch (err) {
482
+ // A half-pasted link is a legible complaint, not a blank page -- and
483
+ // the form stays open so the operator can paste the whole one.
484
+ identity = await identityStore
485
+ .read()
486
+ .then((k) => (k == null ? null : peerIdOf(k)))
487
+ .catch(() => null);
488
+ set({ kind: "needs-invitation", reason: "no-mesh", message: String(err) });
489
+ return;
490
+ }
491
+
492
+ const key = await identityStore.read();
493
+ identity = key == null ? null : peerIdOf(key);
494
+
495
+ if (key == null && input == null) {
496
+ // A FIRST RUN, SAID AS SUCH. Nothing is generated yet: creating a
497
+ // key here would make every later load report an identity this
498
+ // browser has never used for anything, and the difference between
499
+ // that and "the hub does not know my identity" is exactly what
500
+ // requirement 3 is about.
501
+ set({
502
+ kind: "needs-invitation",
503
+ reason: "no-identity",
504
+ message:
505
+ "This page has no saved identity yet -- it has never joined a mesh from this " +
506
+ "browser. Paste an invitation id, a join blob, or a whole join link to join one; " +
507
+ "an identity is created and saved at that point, and every later visit resumes " +
508
+ "with it.",
509
+ });
510
+ return;
511
+ }
512
+
513
+ // An identity with no remembered mesh is NOT a refusal: `attempt`
514
+ // leaves the config undefined, `startBrowserPeer` fetches
515
+ // `httpeers.json`, and the deployment's own mesh is tried -- which is
516
+ // the only candidate there is, and the right one for every page that
517
+ // has only ever joined the Node hub.
518
+ await attempt(input);
519
+ },
520
+
521
+ async join(text: string) {
522
+ let input: JoinInput | null;
523
+ try {
524
+ input = readJoinInputFromText(text);
525
+ } catch (err) {
526
+ set({ kind: "needs-invitation", reason: "invitation-refused", message: String(err) });
527
+ return;
528
+ }
529
+ if (input == null) return; // an empty field is not an error, it is nothing happening.
530
+ await attempt(input);
531
+ },
532
+
533
+ async disconnect() {
534
+ const stopping = handle;
535
+ if (stopping == null) return;
536
+ handle = null;
537
+ await stopping.stop();
538
+ set({
539
+ kind: "disconnected",
540
+ message:
541
+ "Disconnected. This page still IS a member of the mesh -- membership is held by the " +
542
+ "hub and nothing here surrendered it -- so reconnecting needs no new invitation. " +
543
+ "Presence is a heartbeat, so this peer leaves everyone else's mesh view (and its " +
544
+ "advertisements with it) within one presence interval.",
545
+ });
546
+ },
547
+
548
+ async reconnect() {
549
+ await attempt(null);
550
+ },
551
+
552
+ /**
553
+ * Forget this origin's key. DESTRUCTIVE, and the cost is worth stating
554
+ * where the caller can read it: the next run is a DIFFERENT peer, so
555
+ * this page's current membership becomes an orphaned record on the hub
556
+ * (visible to an admin, removable by one) and rejoining needs a fresh
557
+ * invitation. It exists because a stuck key with no way out is worse
558
+ * than no persistence at all -- the same reason the hub page has one
559
+ * (`../pages/hub/main.ts`), for a much smaller blast radius.
560
+ *
561
+ * The remembered mesh is deliberately KEPT: the new identity will still
562
+ * be joining the mesh this page was pointed at, and forgetting it would
563
+ * only mean a bare invitation id could no longer be used against a hub
564
+ * page.
565
+ */
566
+ async resetIdentity() {
567
+ const stopping = handle;
568
+ handle = null;
569
+ // Stopped first, so nothing is still heartbeating as a peer whose key
570
+ // is about to disappear.
571
+ await stopping?.stop().catch(() => {});
572
+ await identityStore.clear();
573
+ identity = null;
574
+ reload();
575
+ },
576
+
577
+ state,
578
+ };
579
+ }