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