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