@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/dist/join.js ADDED
@@ -0,0 +1,409 @@
1
+ /**
2
+ * Joining the mesh: the `/webrtc` pre-dial, invitation redemption, the
3
+ * membership RESUME probe, and the two timers this runtime owns itself (the
4
+ * third -- circuit-relay's own reservation refresh -- is libp2p-managed;
5
+ * there is no code for it here, on purpose, see `startJoin`'s doc comment).
6
+ *
7
+ * THERE ARE TWO WAYS INTO A MESH, NOT ONE (Task 28). `redeemInvitation` is
8
+ * the first join and can only ever happen once per invitation -- the id is
9
+ * moved to `spentInvitationIds` on redemption and the spent check runs
10
+ * first, unconditionally (`../hub/hub-state.ts`). A page that persists its
11
+ * identity and reloads is therefore NOT making a first join: it is already
12
+ * a member, and redeeming again would fail `already-redeemed` forever.
13
+ * `resumeMembership` below is the other way in, and it needs no new hub
14
+ * route because the hub already has one that answers exactly this question
15
+ * -- see that function.
16
+ */
17
+ import { peerIdFromString } from "@libp2p/peer-id";
18
+ import { multiaddr } from "@multiformats/multiaddr";
19
+ import { hubRoute, reachHub } from "@statewalker/httpeers-libp2p";
20
+ import { peerRequest } from "./peer-request.js";
21
+ /**
22
+ * Dial `${relayAddr}/p2p-circuit/webrtc/p2p/${peerId}` explicitly, BEFORE
23
+ * opening any protocol against `peerId` (design note's Step 2; applies to
24
+ * the hub exactly as it does to any other peer -- spec §7, the hub
25
+ * declares no listen address of its own).
26
+ *
27
+ * WHY THIS IS NOT OPTIONAL. libp2p's auto-dial may already hold a
28
+ * relay-only LIMITED connection to `peerId` -- reachable, but not upgraded
29
+ * to WebRTC -- from nothing more than address exchange via `identify`.
30
+ * libp2p SILENTLY refuses to open a custom protocol (`/httpeers/1.0.0`)
31
+ * over a limited connection: the dial simply never completes, with no
32
+ * error naming a limited connection as the cause. Explicitly dialling the
33
+ * full `/webrtc/p2p/...` address forces the WebRTC upgrade before any
34
+ * `peer.call` is attempted, so that failure mode never has a chance to
35
+ * occur.
36
+ */
37
+ export async function preDialPeer(node, relayAddr, peerId) {
38
+ const target = multiaddr(`${relayAddr}/p2p-circuit/webrtc/p2p/${peerId}`);
39
+ await node.dial(target);
40
+ }
41
+ /**
42
+ * "Make sure this peer can be called" -- the function
43
+ * `../browser/edge-dispatch.ts`'s `ensureRoute` hook wants, and the piece
44
+ * that was missing for browser-to-browser calls entirely (Task 15; see that
45
+ * module's job 4 for the symptom and the diagnosis).
46
+ *
47
+ * ADDRESSES COME FROM THE MESH VIEW, WHICH IS THE CONTRACT
48
+ * `../browser/peer-runtime.ts`'s module comment already stated and nothing
49
+ * had yet had to honour: the hub builds each member's `addrs` from that
50
+ * member's own most recent heartbeat, so it is the freshest source there
51
+ * is, and libp2p's peerStore -- what `remote()` falls back on when it dials
52
+ * by bare peer id -- is exactly the stale/absent one that comment warns
53
+ * about.
54
+ *
55
+ * ONLY THE `/webrtc` ENTRIES ARE DIALED. The same view also carries bare
56
+ * `/p2p-circuit` addresses for every member; connecting over one of those
57
+ * produces a LIMITED connection, on which libp2p silently refuses to open
58
+ * `/httpeers/1.0.0` (`preDialPeer`'s own comment, and Task 14's test that
59
+ * pins the refusal). Dialing them would therefore "succeed" and leave the
60
+ * call to fail anyway.
61
+ *
62
+ * THE COMPOSED ADDRESS IS THE FALLBACK, NOT THE PRIMARY. `preDialPeer`'s
63
+ * `<relay>/p2p-circuit/webrtc/p2p/<peer>` is right whenever every peer
64
+ * reserves on the one relay `httpeers.json` names -- true of this
65
+ * deployment, and how the hub itself is reached at startup, since the hub
66
+ * is not a mesh MEMBER and so never appears in the view at all. It is the
67
+ * fallback rather than the rule because a peer that reserved on a different
68
+ * relay is reachable only at the address it reported.
69
+ *
70
+ * THE HUB ITSELF IS NEVER DIALED HERE. `hubRoute(hub, hub)` routes the hub
71
+ * through itself, which cannot work; and a member on the relay fallback holds
72
+ * only a limited circuit to the hub, so without this every edge call to the
73
+ * hub would pay a doomed dial -- up to libp2p's dial timeout -- first. The
74
+ * hub link is `startMember`'s and the keepalive's to keep.
75
+ *
76
+ * AN EXISTING UNLIMITED CONNECTION SHORT-CIRCUITS. This runs on every
77
+ * outbound mesh call, including every `<img src>` in a gallery; re-dialing
78
+ * a peer that is already connected would add a round trip per image.
79
+ */
80
+ export function createRouteEnsurer(init) {
81
+ const { node, relayAddr, selfPeerId } = init;
82
+ return async function ensureRoute(peerId) {
83
+ if (peerId === selfPeerId || peerId === init.hubPeerId)
84
+ return;
85
+ let target;
86
+ try {
87
+ target = peerIdFromString(peerId);
88
+ }
89
+ catch {
90
+ // Not a peer id after all (the shape test in `edge-dispatch.ts` is a
91
+ // regex, not a decoder). Nothing to dial; let the router decide what
92
+ // this path means.
93
+ return;
94
+ }
95
+ if (node.getConnections(target).some((conn) => conn.limits == null))
96
+ return;
97
+ if (init.hubPeerId != null && init.hubPeerId !== selfPeerId) {
98
+ await node.dial(multiaddr(hubRoute(init.hubPeerId, peerId)));
99
+ return;
100
+ }
101
+ const advertised = (init.meshView()?.members.find((m) => m.peerId === peerId)?.addrs ?? [])
102
+ .filter((addr) => addr.includes("/p2p-circuit") && addr.includes("/webrtc"))
103
+ .map((addr) => multiaddr(addr));
104
+ // Every candidate in ONE dial, not a loop: libp2p ranks and races the
105
+ // addresses of a single peer itself, and a sequential loop would pay the
106
+ // full dial timeout for each unreachable interface address the provider
107
+ // reported (a browser peer behind a relay routinely reports several).
108
+ if (advertised.length > 0)
109
+ await node.dial(advertised);
110
+ else
111
+ await preDialPeer(node, relayAddr, peerId);
112
+ };
113
+ }
114
+ /** Redeem `invitationId` against the hub -- `POST /.well-known/invite`, `../hub/endpoints.ts`'s bootstrap handler. No token exists yet: this call is proven by the transport handshake alone. */
115
+ export async function redeemInvitation(peer, hubPeerId, invitationId) {
116
+ const res = await peer.call(hubPeerId, peerRequest("/.well-known/invite", {
117
+ method: "POST",
118
+ headers: { "content-type": "application/json" },
119
+ body: JSON.stringify({ id: invitationId }),
120
+ }));
121
+ if (!res.ok) {
122
+ const body = await res.text().catch(() => "");
123
+ throw new Error(`join: invitation redemption failed (${res.status} ${res.statusText}): ${body}`);
124
+ }
125
+ return (await res.json());
126
+ }
127
+ /** Turn a refused presence response into one of the three cases above. Pure, so a test can pin the mapping without a hub. */
128
+ export function classifyPresenceRefusal(status, body) {
129
+ if (status === 403) {
130
+ return {
131
+ kind: "not-a-member",
132
+ status,
133
+ message: "this hub does not list this peer as a member",
134
+ };
135
+ }
136
+ if (status === 409) {
137
+ return {
138
+ kind: "duplicate-identity",
139
+ status,
140
+ message: "the hub has already accepted a newer heartbeat for this peer id",
141
+ };
142
+ }
143
+ return { kind: "refused", status, message: body === "" ? `HTTP ${status}` : body };
144
+ }
145
+ /**
146
+ * The sequence number a fresh run starts from: THE WALL CLOCK, not zero.
147
+ *
148
+ * THIS IS NOT A STYLE CHOICE, IT IS WHAT MAKES RESUMING POSSIBLE AT ALL.
149
+ * The hub keeps `lastSeqByPeer` for the life of the process and refuses any
150
+ * presence write at or below it (`../hub/endpoints.ts`). A page that
151
+ * persists its identity and starts counting from 1 again after a reload
152
+ * would be refused `stale-sequence` on every beat until it had climbed back
153
+ * past wherever its previous run left off -- minutes of a live, joined page
154
+ * silently failing to report presence, and a mesh view in which it never
155
+ * appears. Seeding from `Date.now()` makes every run start above every
156
+ * previous run's numbers by construction (a run advances its own counter by
157
+ * 1 per beat while the clock advances by thousands), which is also exactly
158
+ * the property `PresenceRefusal`'s duplicate-identity diagnosis rests on.
159
+ *
160
+ * The hub compares numbers and nothing else -- it never reads a seq as a
161
+ * time -- so this borrows the clock's monotonicity without giving the value
162
+ * any meaning it has to keep.
163
+ */
164
+ export function nextInitialSeq() {
165
+ return Date.now();
166
+ }
167
+ /**
168
+ * "Does this hub still consider me a member?" -- asked, and answered,
169
+ * WITHOUT A TOKEN, and answered by an endpoint that already exists.
170
+ *
171
+ * THE ORDERING TRAP THIS WALKS AROUND. Proving membership normally needs a
172
+ * token, and a token is what joining produces; a page resuming a membership
173
+ * has neither, and every ordinary hub read (`/.well-known/mesh` included)
174
+ * is gated on claims it cannot yet present. But `POST
175
+ * /.well-known/presence` is one of exactly two BOOTSTRAP routes
176
+ * (`usesTransportIdentity()` in `../hub/endpoints.ts`): `httpeers.core`'s
177
+ * binding middleware sees it, skips the token check and the access tree
178
+ * entirely, and hands the handler the peer id the libp2p handshake itself
179
+ * proved. That handler then looks the peer up in the member store and
180
+ * either mints a fresh token or answers 403. Which is precisely the
181
+ * question, precisely the proof, and precisely the credential -- so this
182
+ * needs no new route, and adding one would have been adding a second,
183
+ * weaker answer to a question the hub already answers.
184
+ *
185
+ * IT IS ALSO THE FIRST HEARTBEAT, NOT A PROBE BESIDE ONE. The body is an
186
+ * ordinary presence write, so a successful resume has already reported this
187
+ * peer's addresses and advertisements and is already visible in the mesh
188
+ * view -- there is no window where the page is "resumed" but absent. Its
189
+ * `seq` is the one `startJoin` continues from (`JoinInit.initialSeq`).
190
+ *
191
+ * Transport failures are NOT caught here: a call that never reached the hub
192
+ * is a different fact from a hub that answered, and only the caller knows
193
+ * what to say about it (`./peer-runtime.ts` does).
194
+ */
195
+ export async function resumeMembership(init) {
196
+ const res = await init.peer.call(init.hubPeerId, peerRequest("/.well-known/presence", {
197
+ method: "POST",
198
+ headers: { "content-type": "application/json" },
199
+ body: JSON.stringify({
200
+ seq: init.seq,
201
+ addrs: init.addrs,
202
+ ...(init.advertisements !== undefined ? { advertisements: init.advertisements } : {}),
203
+ }),
204
+ }));
205
+ if (!res.ok) {
206
+ const body = await res.text().catch(() => "");
207
+ return { status: "refused", refusal: classifyPresenceRefusal(res.status, body) };
208
+ }
209
+ const parsed = (await res.json());
210
+ return { status: "resumed", token: parsed.token, versions: parsed.versions, ttl: parsed.ttl };
211
+ }
212
+ /** How often the presence heartbeat fires -- design note's Step 3, 5 s. */
213
+ export const HEARTBEAT_INTERVAL_MS = 5_000;
214
+ /**
215
+ * How often the connection-keepalive timer checks the link to the hub.
216
+ * DELIBERATELY SEPARATE from `HEARTBEAT_INTERVAL_MS`: a heartbeat that
217
+ * fails to send says "the request failed," which could be the connection,
218
+ * the hub, or a revoked membership -- it does not by itself diagnose
219
+ * "the underlying libp2p connection to the hub is gone." This timer
220
+ * checks that fact directly (`node.getConnections(hubPeerId)`) and
221
+ * re-runs the `/webrtc` pre-dial when it finds nothing open, independent
222
+ * of whether a heartbeat happens to be in flight at the same moment.
223
+ * Matches the interval the validated p2p-demo prototype uses for the same
224
+ * purpose (`workspaces/webrun-wire/apps/p2p-demo/server-page/main.ts`,
225
+ * "relay link lost; re-dialing").
226
+ */
227
+ export const KEEPALIVE_INTERVAL_MS = 10_000;
228
+ /**
229
+ * How long a pulled revocation snapshot may go unconfirmed before this
230
+ * peer refuses every token rather than trust one it can no longer vouch
231
+ * for (`RevocationCache`'s own `maxStalenessMs` -- "there is a case that
232
+ * no default is correct and this should be required," `revocation.ts`).
233
+ * Generous relative to `HEARTBEAT_INTERVAL_MS` deliberately: the cache's
234
+ * freshness clock is reset on EVERY successful heartbeat (see
235
+ * `startJoin` below), not only when `versions.policy` actually moves, so
236
+ * this bound is really "how many consecutive heartbeats may fail before
237
+ * this peer stops trusting its own revocation snapshot," not "how often
238
+ * the policy must change." Twelve missed 5 s heartbeats' worth of
239
+ * tolerance before a live peer starts refusing tokens on staleness alone.
240
+ */
241
+ export const REVOCATION_MAX_STALENESS_MS = 60_000;
242
+ /**
243
+ * Start the presence heartbeat and the connection-keepalive timer over an
244
+ * already-joined peer (post `redeemInvitation`). Fires an immediate
245
+ * heartbeat rather than waiting for the first interval tick, so a caller
246
+ * observing `meshView()` sees a populated view as soon as the network
247
+ * allows rather than up to `heartbeatIntervalMs` later.
248
+ *
249
+ * THREE TIMERS, KEPT APART (design note's Step 3 / note 07 §6). This
250
+ * function owns two of them -- the heartbeat and the keepalive, below.
251
+ * THE THIRD, the reservation on the relay, is NOT started here either:
252
+ * libp2p renews a reservation it holds on its own schedule, and restoring
253
+ * one lost with the relay link -- which libp2p attempts once and then never
254
+ * again -- is `../reservation.ts`'s `superviseRelay`, started by the
255
+ * runtime. The keepalive below is NOT that: it watches the HUB connection,
256
+ * which can stay up over WebRTC after the relay link is gone. Folding
257
+ * either into these two would conflate "is my reservation still valid"
258
+ * with "does the hub still consider me a member" or "is my connection to
259
+ * the hub still open" -- three questions this design keeps separate
260
+ * because they fail independently and mean different things.
261
+ */
262
+ export function startJoin(init) {
263
+ const { peer, node, hubPeerId, relayAddr, revocationCache } = init;
264
+ const heartbeatIntervalMs = init.heartbeatIntervalMs ?? HEARTBEAT_INTERVAL_MS;
265
+ const keepaliveIntervalMs = init.keepaliveIntervalMs ?? KEEPALIVE_INTERVAL_MS;
266
+ let token = init.initialToken;
267
+ let seq = init.initialSeq ?? nextInitialSeq();
268
+ let heartbeatInFlight = false;
269
+ let meshViewCache = null;
270
+ let meshVersion = 0;
271
+ let rulesCache = null;
272
+ let rulesVersion = 0;
273
+ let revocationEntriesCache = [];
274
+ async function heartbeatOnce() {
275
+ if (heartbeatInFlight)
276
+ return; // never overlap two in-flight heartbeats -- see the module comment on why this is safe to just skip a tick rather than queue.
277
+ heartbeatInFlight = true;
278
+ // EVERYTHING BELOW IS INSIDE ONE try/catch, DELIBERATELY -- not only
279
+ // the presence call. `heartbeatOnce` is invoked as `void
280
+ // heartbeatOnce()` (fire-and-forget, on a `setInterval`), so ANY
281
+ // rejection that escapes it -- a dropped connection mid-call, a
282
+ // malformed JSON body from the hub, a follow-up GET failing after the
283
+ // presence POST already succeeded -- becomes an unhandled promise
284
+ // rejection, not the "next tick retries" behaviour the rest of this
285
+ // function's comments describe. A single catch-all below is what
286
+ // actually delivers that contract; catching only the first call and
287
+ // leaving the rest unprotected (an earlier version of this function)
288
+ // does not (found on review; reproduced by forcing an unconditional
289
+ // refetch and watching a follow-up call's rejection escape uncaught).
290
+ try {
291
+ seq += 1;
292
+ // THE PEER'S OWN CURRENT MULTIADDRS -- `node.getMultiaddrs()`, read
293
+ // fresh on every heartbeat. This is what makes THIS peer's own
294
+ // addresses the freshest possible source for OTHER peers reading
295
+ // them back out of the mesh view -- see `JoinHandle.meshView`'s doc
296
+ // comment and `peer-runtime.ts`'s module comment for the other half
297
+ // of this contract (never read a PEER's addrs from libp2p's own
298
+ // `peerStore`).
299
+ const addrs = node.getMultiaddrs().map((addr) => addr.toString());
300
+ const advertisements = init.advertisements?.() ?? [];
301
+ const res = await peer.call(hubPeerId, peerRequest("/.well-known/presence", {
302
+ method: "POST",
303
+ token,
304
+ headers: { "content-type": "application/json" },
305
+ body: JSON.stringify({ seq, addrs, advertisements }),
306
+ }));
307
+ if (!res.ok) {
308
+ // A refusal is not a transport fault and must not be swallowed with
309
+ // one: 403 means this peer is no longer a member (revoked, or the
310
+ // hub's state was reset) and 409 means a SECOND node is beating
311
+ // under this identity. Both are permanent until someone acts, so a
312
+ // page that only stopped advancing its caches would sit there
313
+ // looking joined. Every cache is still left exactly as it was --
314
+ // that part was always right.
315
+ init.onPresenceRefused?.(classifyPresenceRefusal(res.status, await res.text().catch(() => "")));
316
+ return;
317
+ }
318
+ const body = (await res.json());
319
+ token = body.token; // rotates every heartbeat -- the hub mints a fresh token on each call.
320
+ if (body.versions.mesh !== meshVersion) {
321
+ const meshRes = await peer.call(hubPeerId, peerRequest("/.well-known/mesh", { method: "GET", token }));
322
+ if (meshRes.ok) {
323
+ meshViewCache = (await meshRes.json());
324
+ meshVersion = body.versions.mesh;
325
+ }
326
+ }
327
+ if (body.versions.rules !== rulesVersion) {
328
+ const rulesRes = await peer.call(hubPeerId, peerRequest("/.well-known/rules", {
329
+ method: "GET",
330
+ token,
331
+ }));
332
+ if (rulesRes.ok) {
333
+ rulesCache = (await rulesRes.json());
334
+ rulesVersion = body.versions.rules;
335
+ }
336
+ }
337
+ if (body.versions.policy !== revocationCache.knownVersion()) {
338
+ const revRes = await peer.call(hubPeerId, peerRequest("/.well-known/revocations", {
339
+ method: "GET",
340
+ token,
341
+ }));
342
+ if (revRes.ok) {
343
+ const revBody = (await revRes.json());
344
+ revocationEntriesCache = revBody.entries;
345
+ }
346
+ }
347
+ // Refresh the cache's freshness stamp on EVERY successful heartbeat
348
+ // -- see `REVOCATION_MAX_STALENESS_MS`'s doc comment for why this is
349
+ // NOT gated on the policy version having actually moved.
350
+ revocationCache.update(body.versions.policy, revocationEntriesCache);
351
+ init.onHeartbeat?.(body.versions);
352
+ }
353
+ catch {
354
+ // Dial/transport failure, a malformed response body, or any other
355
+ // fault anywhere in the sequence above -- leave every cached value
356
+ // exactly as it was; the next tick retries from scratch.
357
+ // `KEEPALIVE_INTERVAL_MS`'s timer is what actually diagnoses and
358
+ // repairs a dropped connection to the hub; this heartbeat has no
359
+ // business doing that job too, only refusing to crash the page over
360
+ // it.
361
+ }
362
+ finally {
363
+ heartbeatInFlight = false;
364
+ }
365
+ }
366
+ const heartbeatTimer = setInterval(() => void heartbeatOnce(), heartbeatIntervalMs);
367
+ void heartbeatOnce();
368
+ let hubPeerIdObj;
369
+ try {
370
+ hubPeerIdObj = peerIdFromString(hubPeerId);
371
+ }
372
+ catch {
373
+ hubPeerIdObj = undefined; // malformed hubPeerId -- the keepalive timer below simply never finds a match and keeps re-predialling, which is the correct degraded behaviour.
374
+ }
375
+ // `reachHub`, not a bare pre-dial: it also closes the limited signalling
376
+ // circuit, which would otherwise break relaying THROUGH the hub (and so
377
+ // restoring this page's reservation on it) -- see `../hub-link.ts`.
378
+ const relinkHub = init.relinkHub ?? (() => reachHub(node, relayAddr, hubPeerId));
379
+ // One re-link at a time: a dial can outlast a tick, and two overlapping
380
+ // attempts would open two connections.
381
+ let relinking = false;
382
+ // A KEPT LIMITED CIRCUIT COUNTS AS OPEN, so a member on the relay fallback is
383
+ // left alone for as long as its circuit lives -- nothing here closes or
384
+ // replaces it -- and re-links only once it has gone.
385
+ const keepaliveTimer = setInterval(() => {
386
+ const stillOpen = hubPeerIdObj != null && node.getConnections(hubPeerIdObj).some((c) => c.status === "open");
387
+ if (stillOpen || relinking)
388
+ return;
389
+ relinking = true;
390
+ void relinkHub()
391
+ .catch(() => {
392
+ // Best-effort -- the next tick retries, and a heartbeat that keeps
393
+ // failing on its own schedule surfaces the same underlying
394
+ // unreachability independently.
395
+ })
396
+ .finally(() => {
397
+ relinking = false;
398
+ });
399
+ }, keepaliveIntervalMs);
400
+ return {
401
+ meshView: () => meshViewCache,
402
+ rules: () => rulesCache,
403
+ token: () => token, // read at call time, never snapshotted -- see `JoinHandle.token`.
404
+ stop() {
405
+ clearInterval(heartbeatTimer);
406
+ clearInterval(keepaliveTimer);
407
+ },
408
+ };
409
+ }
package/dist/kv.d.ts ADDED
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The key-value seams a member stores things behind — the SHAPES only.
3
+ *
4
+ * The IndexedDB implementations moved to `./browser.js`. They import
5
+ * `idb-keyval`, which needs a real IndexedDB, and a Node member reading its
6
+ * own identity has no business pulling a browser database into its bundle to
7
+ * do it. What is left here is what both platforms genuinely share: two
8
+ * interfaces, and nothing that can run.
9
+ */
10
+ export interface AsyncKeyValueBackend {
11
+ get(key: string): Promise<string | undefined>;
12
+ set(key: string, value: string): Promise<void>;
13
+ delete(key: string): Promise<void>;
14
+ }
15
+ export interface AsyncBytesBackend {
16
+ get(key: string): Promise<Uint8Array | undefined>;
17
+ set(key: string, value: Uint8Array): Promise<void>;
18
+ delete(key: string): Promise<void>;
19
+ }
20
+ //# sourceMappingURL=kv.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"kv.d.ts","sourceRoot":"","sources":["../src/kv.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,MAAM,WAAW,oBAAoB;IACnC,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAAC;IAC9C,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC/C,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACpC;AAED,MAAM,WAAW,iBAAiB;IAChC,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,GAAG,SAAS,CAAC,CAAC;IAClD,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACnD,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACpC"}
package/dist/kv.js ADDED
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The key-value seams a member stores things behind — the SHAPES only.
3
+ *
4
+ * The IndexedDB implementations moved to `./browser.js`. They import
5
+ * `idb-keyval`, which needs a real IndexedDB, and a Node member reading its
6
+ * own identity has no business pulling a browser database into its bundle to
7
+ * do it. What is left here is what both platforms genuinely share: two
8
+ * interfaces, and nothing that can run.
9
+ */
10
+ export {};
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The one place a `MeshConfig` is validated, whatever channel it arrived on.
3
+ *
4
+ * Two channels exist and both used to check differently: `mesh-memory.ts`
5
+ * validated what it read from storage, and `session.ts` CAST what it fetched
6
+ * over HTTP (`return (await res.json()) as HttpeersConfig`). A cast checks
7
+ * nothing, and the gap showed up as soon as the hub moved into a tab: a
8
+ * hub-page deployment has no stable `hubPeerId` to publish, so its
9
+ * `httpeers.json` carries `relayAddrs` alone, and that config reached
10
+ * `startMember` with `hubPeerId: undefined` — failing far from the cause.
11
+ *
12
+ * `null` FOR ANYTHING UNUSABLE, never a throw. The session already renders
13
+ * `null` correctly ("no mesh to join — paste an invitation"), which is exactly
14
+ * the state a hub-page deployment starts every other page in. Throwing would
15
+ * take out the page, including the invitation field that fixes it.
16
+ */
17
+ import type { MeshConfig } from "@statewalker/httpeers-core";
18
+ /**
19
+ * A usable `MeshConfig`, or `null`.
20
+ *
21
+ * Returns a FRESH object carrying only the two known fields: a deployment may
22
+ * publish more, and passing unknown keys through would let an unrelated one
23
+ * look like configuration this library honours.
24
+ */
25
+ export declare function parseMeshConfig(value: unknown): MeshConfig | null;
26
+ //# sourceMappingURL=mesh-config.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mesh-config.d.ts","sourceRoot":"","sources":["../src/mesh-config.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,4BAA4B,CAAC;AAE7D;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,OAAO,GAAG,UAAU,GAAG,IAAI,CAcjE"}
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The one place a `MeshConfig` is validated, whatever channel it arrived on.
3
+ *
4
+ * Two channels exist and both used to check differently: `mesh-memory.ts`
5
+ * validated what it read from storage, and `session.ts` CAST what it fetched
6
+ * over HTTP (`return (await res.json()) as HttpeersConfig`). A cast checks
7
+ * nothing, and the gap showed up as soon as the hub moved into a tab: a
8
+ * hub-page deployment has no stable `hubPeerId` to publish, so its
9
+ * `httpeers.json` carries `relayAddrs` alone, and that config reached
10
+ * `startMember` with `hubPeerId: undefined` — failing far from the cause.
11
+ *
12
+ * `null` FOR ANYTHING UNUSABLE, never a throw. The session already renders
13
+ * `null` correctly ("no mesh to join — paste an invitation"), which is exactly
14
+ * the state a hub-page deployment starts every other page in. Throwing would
15
+ * take out the page, including the invitation field that fixes it.
16
+ */
17
+ /**
18
+ * A usable `MeshConfig`, or `null`.
19
+ *
20
+ * Returns a FRESH object carrying only the two known fields: a deployment may
21
+ * publish more, and passing unknown keys through would let an unrelated one
22
+ * look like configuration this library honours.
23
+ */
24
+ export function parseMeshConfig(value) {
25
+ if (typeof value !== "object" || value === null || Array.isArray(value))
26
+ return null;
27
+ const { relayAddrs, hubPeerId } = value;
28
+ // A relay list that is empty is not a mesh: `startMember` reads
29
+ // `relayAddrs[0]` and has nothing to dial.
30
+ if (!Array.isArray(relayAddrs) || relayAddrs.length === 0)
31
+ return null;
32
+ if (!relayAddrs.every((addr) => typeof addr === "string" && addr !== ""))
33
+ return null;
34
+ // The hub's peerId IS the mesh's name — every token's `mesh` claim restates
35
+ // it — so a config without one names no mesh at all.
36
+ if (typeof hubPeerId !== "string" || hubPeerId === "")
37
+ return null;
38
+ return { relayAddrs, hubPeerId };
39
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * The mesh this origin last joined -- `{ relayAddrs, hubPeerId }`, kept
3
+ * beside the identity key it belongs to.
4
+ *
5
+ * WHY A PAGE HAS TO REMEMBER THIS AT ALL (Task 28). A page that resumes a
6
+ * membership needs two things: the identity that holds it
7
+ * (`./identity.ts`) and the mesh that granted it. The first has persisted
8
+ * since Task 24. The second has not, and for the Node hub it did not need
9
+ * to -- `httpeers.json` names that mesh on every origin, forever. For a
10
+ * mesh whose hub is a BROWSER PAGE it does need to: that hub's peerId
11
+ * exists only in a tab's IndexedDB and reaches other pages exactly once, in
12
+ * the join blob that admitted them (`./join-blob.ts`). Reload such a page
13
+ * without its `?join=` query and it holds a membership in a mesh it can no
14
+ * longer name. This module is that missing half -- and it is genuinely only
15
+ * a memory: nothing here is a credential, and every field in it was
16
+ * published in the blob anyway.
17
+ *
18
+ * WHAT IT DOES NOT CHANGE: WHAT A BARE INVITATION ID MEANS. `./join-blob.ts`
19
+ * is explicit that a `?invite=` id with no blob means "the mesh
20
+ * `httpeers.json` names", i.e. the Node hub's -- both deployments are
21
+ * supported deliberately. So this memory is consulted ONLY when resuming
22
+ * (no invitation in hand). The moment someone supplies an invitation, the
23
+ * invitation decides: a blob names its own mesh, and a bare id still means
24
+ * the deployment's. Reversing that would have silently pointed the Node-hub
25
+ * path at whatever mesh the page had last seen. See `./session.ts`, which
26
+ * is where the two rules meet.
27
+ *
28
+ * WRITTEN ONLY AFTER A JOIN ACTUALLY SUCCEEDS. A remembered mesh that was
29
+ * never reachable would turn one failed paste into a page that keeps
30
+ * failing the same way on every later load, with the operator's original
31
+ * mistake no longer visible anywhere.
32
+ */
33
+ import type { MeshConfig as HttpeersConfig } from "@statewalker/httpeers-core";
34
+ import type { AsyncKeyValueBackend } from "./kv.js";
35
+ /** Where the remembered mesh lives, namespaced like `./identity.ts`'s key and `./snapshot-store.ts`'s snapshot. */
36
+ export declare const MESH_STORAGE_KEY = "httpeers:mesh";
37
+ export interface MeshMemory {
38
+ /** The mesh this origin last joined, or `null` -- also `null` when the stored value is unreadable; see `parseMesh`. */
39
+ read(): Promise<HttpeersConfig | null>;
40
+ write(config: HttpeersConfig): Promise<void>;
41
+ clear(): Promise<void>;
42
+ }
43
+ export interface CreateMeshMemoryInit {
44
+ /**
45
+ * Where this lives. REQUIRED, and deliberately so: it used to default to
46
+ * IndexedDB, which put a browser database in the default path of a module
47
+ * that a Node member also imports. The platform supplies its own store —
48
+ * `./browser`'s for a page, a file-backed one for a process.
49
+ */
50
+ backend: AsyncKeyValueBackend;
51
+ /** Defaults to `MESH_STORAGE_KEY`. */
52
+ storageKey?: string;
53
+ }
54
+ export declare function createMeshMemory(init: CreateMeshMemoryInit): MeshMemory;
55
+ //# sourceMappingURL=mesh-memory.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mesh-memory.d.ts","sourceRoot":"","sources":["../src/mesh-memory.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AAKH,OAAO,KAAK,EAAE,UAAU,IAAI,cAAc,EAAE,MAAM,4BAA4B,CAAC;AAC/E,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,SAAS,CAAC;AAGpD,mHAAmH;AACnH,eAAO,MAAM,gBAAgB,kBAAkB,CAAC;AAEhD,MAAM,WAAW,UAAU;IACzB,uHAAuH;IACvH,IAAI,IAAI,OAAO,CAAC,cAAc,GAAG,IAAI,CAAC,CAAC;IACvC,KAAK,CAAC,MAAM,EAAE,cAAc,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC7C,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB;AAED,MAAM,WAAW,oBAAoB;IACnC;;;;;OAKG;IACH,OAAO,EAAE,oBAAoB,CAAC;IAC9B,sCAAsC;IACtC,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AA4BD,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,oBAAoB,GAAG,UAAU,CAevE"}
@@ -0,0 +1,74 @@
1
+ /**
2
+ * The mesh this origin last joined -- `{ relayAddrs, hubPeerId }`, kept
3
+ * beside the identity key it belongs to.
4
+ *
5
+ * WHY A PAGE HAS TO REMEMBER THIS AT ALL (Task 28). A page that resumes a
6
+ * membership needs two things: the identity that holds it
7
+ * (`./identity.ts`) and the mesh that granted it. The first has persisted
8
+ * since Task 24. The second has not, and for the Node hub it did not need
9
+ * to -- `httpeers.json` names that mesh on every origin, forever. For a
10
+ * mesh whose hub is a BROWSER PAGE it does need to: that hub's peerId
11
+ * exists only in a tab's IndexedDB and reaches other pages exactly once, in
12
+ * the join blob that admitted them (`./join-blob.ts`). Reload such a page
13
+ * without its `?join=` query and it holds a membership in a mesh it can no
14
+ * longer name. This module is that missing half -- and it is genuinely only
15
+ * a memory: nothing here is a credential, and every field in it was
16
+ * published in the blob anyway.
17
+ *
18
+ * WHAT IT DOES NOT CHANGE: WHAT A BARE INVITATION ID MEANS. `./join-blob.ts`
19
+ * is explicit that a `?invite=` id with no blob means "the mesh
20
+ * `httpeers.json` names", i.e. the Node hub's -- both deployments are
21
+ * supported deliberately. So this memory is consulted ONLY when resuming
22
+ * (no invitation in hand). The moment someone supplies an invitation, the
23
+ * invitation decides: a blob names its own mesh, and a bare id still means
24
+ * the deployment's. Reversing that would have silently pointed the Node-hub
25
+ * path at whatever mesh the page had last seen. See `./session.ts`, which
26
+ * is where the two rules meet.
27
+ *
28
+ * WRITTEN ONLY AFTER A JOIN ACTUALLY SUCCEEDS. A remembered mesh that was
29
+ * never reachable would turn one failed paste into a page that keeps
30
+ * failing the same way on every later load, with the operator's original
31
+ * mistake no longer visible anywhere.
32
+ */
33
+ import { parseMeshConfig } from "./mesh-config.js";
34
+ /** Where the remembered mesh lives, namespaced like `./identity.ts`'s key and `./snapshot-store.ts`'s snapshot. */
35
+ export const MESH_STORAGE_KEY = "httpeers:mesh";
36
+ /**
37
+ * Validate rather than trust, and return `null` rather than throw.
38
+ *
39
+ * The same tolerance `./snapshot-store.ts` applies to a corrupt snapshot,
40
+ * for the same reason and with a smaller cost: a page whose remembered mesh
41
+ * is unreadable has simply not got one, which is the first-run state it
42
+ * already knows how to render (say so, offer the invitation field). Throwing
43
+ * would take out the page -- including the reset control -- over a value
44
+ * that is a convenience.
45
+ */
46
+ function parseMesh(raw) {
47
+ if (raw == null)
48
+ return null;
49
+ try {
50
+ // The SAME validator the HTTP channel uses. It was a second copy here,
51
+ // which is how the two channels came to disagree in the first place.
52
+ return parseMeshConfig(JSON.parse(raw));
53
+ }
54
+ catch (err) {
55
+ console.warn(`mesh-memory: the stored mesh at "${MESH_STORAGE_KEY}" is not readable JSON -- this page ` +
56
+ "will ask for an invitation instead of resuming.", err);
57
+ return null;
58
+ }
59
+ }
60
+ export function createMeshMemory(init) {
61
+ const backend = init.backend;
62
+ const storageKey = init.storageKey ?? MESH_STORAGE_KEY;
63
+ return {
64
+ async read() {
65
+ return parseMesh(await backend.get(storageKey));
66
+ },
67
+ async write(config) {
68
+ await backend.set(storageKey, JSON.stringify(config));
69
+ },
70
+ async clear() {
71
+ await backend.delete(storageKey);
72
+ },
73
+ };
74
+ }