@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
|
@@ -0,0 +1,307 @@
|
|
|
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 { peerIdFromString } from "@libp2p/peer-id";
|
|
37
|
+
import { RevocationCache, withAccess } from "@statewalker/httpeers-access";
|
|
38
|
+
import { ANONYMOUS, forwardLocalOnly, lookupPeer } from "@statewalker/httpeers-core";
|
|
39
|
+
import { dialRelay, HubReservationError, leaveRelay, reachHub, reachHubRelayed, reserveOnHub, servePeer, superviseHubReservation, } from "@statewalker/httpeers-libp2p";
|
|
40
|
+
import { classifyConnection } from "./connection-kind.js";
|
|
41
|
+
import { createEdgeDispatch } from "./edge-dispatch.js";
|
|
42
|
+
import { createRouteEnsurer, nextInitialSeq, REVOCATION_MAX_STALENESS_MS, redeemInvitation, resumeMembership, startJoin, } from "./join.js";
|
|
43
|
+
export class MemberJoinError extends Error {
|
|
44
|
+
reason;
|
|
45
|
+
constructor(reason, message, options) {
|
|
46
|
+
super(message, options);
|
|
47
|
+
this.reason = reason;
|
|
48
|
+
this.name = "MemberJoinError";
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Does a refused reservation on the hub leave the join standing, in relay
|
|
53
|
+
* mode? Only when the refusal says nothing about THIS member:
|
|
54
|
+
*
|
|
55
|
+
* - `store-full` (`RESERVATION_REFUSED`) and `resource-limit`
|
|
56
|
+
* (`RESOURCE_LIMIT_EXCEEDED`) -- the hub is out of capacity. This is the
|
|
57
|
+
* production failure: a hub's relay grants a fixed number of
|
|
58
|
+
* reservations, and once they were spent every direct-linked page failed
|
|
59
|
+
* to join while relay-mode pages worked.
|
|
60
|
+
* - `no-answer` -- a timeout or a dropped link; nothing was refused.
|
|
61
|
+
*
|
|
62
|
+
* The reservation only lets OTHER members reach this one; everything the
|
|
63
|
+
* member does itself works in relay mode, so failing the join over it is
|
|
64
|
+
* strictly worse than degrading.
|
|
65
|
+
*
|
|
66
|
+
* NOT for `not-a-member` (`PERMISSION_DENIED`): the hub has just accepted
|
|
67
|
+
* this peer, so a hub that now denies it membership contradicts itself (a
|
|
68
|
+
* revocation in between, a gater reading a different store) -- a real fault
|
|
69
|
+
* to report, not a condition to route around. NOT for `no-relay`: a hub
|
|
70
|
+
* deployed without its relay service is a configuration error that would put
|
|
71
|
+
* every member in relay mode silently. NOT for any other status, which is a
|
|
72
|
+
* protocol fault. And not for errors that are not refusals at all.
|
|
73
|
+
*/
|
|
74
|
+
export function fallsBackToRelay(err) {
|
|
75
|
+
if (!(err instanceof HubReservationError))
|
|
76
|
+
return false;
|
|
77
|
+
return (err.refusal === "store-full" || err.refusal === "resource-limit" || err.refusal === "no-answer");
|
|
78
|
+
}
|
|
79
|
+
export async function startMember(init) {
|
|
80
|
+
const onState = init.onState ?? (() => { });
|
|
81
|
+
const relayAddr = init.config.relayAddrs[0];
|
|
82
|
+
if (relayAddr == null)
|
|
83
|
+
throw new Error("startMember: config.relayAddrs is empty.");
|
|
84
|
+
const hubPeerId = init.config.hubPeerId;
|
|
85
|
+
onState("connecting-relay");
|
|
86
|
+
const node = await init.platform.createNode({ privateKey: init.privateKey });
|
|
87
|
+
const unwind = [async () => await node.stop()];
|
|
88
|
+
const startFailed = async () => {
|
|
89
|
+
for (const step of [...unwind].reverse())
|
|
90
|
+
await step().catch(() => { });
|
|
91
|
+
};
|
|
92
|
+
try {
|
|
93
|
+
await dialRelay(node, relayAddr);
|
|
94
|
+
onState("starting-peer");
|
|
95
|
+
const revocationCache = new RevocationCache({ maxStalenessMs: REVOCATION_MAX_STALENESS_MS });
|
|
96
|
+
// `createPeer` in the prototype did transport + binding + policy + router
|
|
97
|
+
// in one closed function. Here the three are separate packages that meet
|
|
98
|
+
// at a `FetchHandler`: `servePeer` owns the wire and the router,
|
|
99
|
+
// `withAccess` owns who may do what, and neither imports the other.
|
|
100
|
+
const peer = await servePeer({
|
|
101
|
+
node,
|
|
102
|
+
mounts: init.mounts,
|
|
103
|
+
// WITHOUT THIS A MEMBER CANNOT CALL ANOTHER MEMBER AT ALL. The edge
|
|
104
|
+
// hands `/{peerId}/{path}` to `peer.dispatch`, and the router refuses to
|
|
105
|
+
// forward unless asked — deny by default, which is right (R-2: relaying
|
|
106
|
+
// is its own capability). `createPeer` in the prototype supplied this
|
|
107
|
+
// policy; the extraction split it into `servePeer` + `withAccess` and
|
|
108
|
+
// dropped it, and every type check stayed green because a missing
|
|
109
|
+
// optional callback is not a type error. A real mesh found it in one
|
|
110
|
+
// call: 403 `this peer does not relay for you`, from the CALLER's own
|
|
111
|
+
// router.
|
|
112
|
+
allowForward: forwardLocalOnly,
|
|
113
|
+
// Calls TO THE HUB may ride a limited connection, and only those. Set
|
|
114
|
+
// before the link mode is known, and harmless when it turns out to be
|
|
115
|
+
// `"direct"`: the link prefers an unlimited connection whenever one is
|
|
116
|
+
// open. Serving stays closed to limited connections -- the hub's own
|
|
117
|
+
// relay limits are what protect member-to-member traffic.
|
|
118
|
+
callOnLimitedConnection: (peerId) => peerId === hubPeerId,
|
|
119
|
+
access: withAccess({
|
|
120
|
+
issuer: hubPeerId,
|
|
121
|
+
rules: init.rules,
|
|
122
|
+
selfPeer: node.peerId.toString(),
|
|
123
|
+
// What the TRANSPORT proved. `serveTransport` registers it on the
|
|
124
|
+
// request before dispatch, so reading it back here is the same fact,
|
|
125
|
+
// not a second opinion.
|
|
126
|
+
provenPeer: (req) => lookupPeer(req) ?? ANONYMOUS,
|
|
127
|
+
revocation: revocationCache,
|
|
128
|
+
}),
|
|
129
|
+
});
|
|
130
|
+
unwind.push(async () => await peer.stop());
|
|
131
|
+
// WEBRTC FIRST, ONCE PER JOIN, THEN THE RELAY CIRCUIT. Any failure of the
|
|
132
|
+
// upgrade counts: a hub that WebRTC cannot reach fails in more ways than
|
|
133
|
+
// one (no route, ICE timeout, no transport), and the fallback is the same
|
|
134
|
+
// for all.
|
|
135
|
+
onState("dialing-hub");
|
|
136
|
+
let hubLink;
|
|
137
|
+
let hubLinkNote;
|
|
138
|
+
try {
|
|
139
|
+
await reachHub(node, relayAddr, hubPeerId);
|
|
140
|
+
hubLink = "direct";
|
|
141
|
+
}
|
|
142
|
+
catch (upgradeErr) {
|
|
143
|
+
try {
|
|
144
|
+
await reachHubRelayed(node, relayAddr, hubPeerId);
|
|
145
|
+
hubLink = "relay";
|
|
146
|
+
}
|
|
147
|
+
catch (relayErr) {
|
|
148
|
+
throw new Error(`startMember: the hub (${hubPeerId}) could not be reached over WebRTC ` +
|
|
149
|
+
`(${String(upgradeErr)}) nor over a relay circuit (${String(relayErr)}).`, { cause: relayErr });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
onState("resuming");
|
|
153
|
+
const seq = nextInitialSeq();
|
|
154
|
+
const resume = await resumeMembership({
|
|
155
|
+
peer,
|
|
156
|
+
hubPeerId,
|
|
157
|
+
addrs: node.getMultiaddrs().map((addr) => addr.toString()),
|
|
158
|
+
advertisements: init.advertisements?.(),
|
|
159
|
+
seq,
|
|
160
|
+
});
|
|
161
|
+
let initialToken;
|
|
162
|
+
let joinedBy;
|
|
163
|
+
if (resume.status === "resumed") {
|
|
164
|
+
initialToken = resume.token;
|
|
165
|
+
joinedBy = "resumed";
|
|
166
|
+
}
|
|
167
|
+
else if (resume.refusal.kind === "duplicate-identity") {
|
|
168
|
+
throw new MemberJoinError("duplicate-identity", `another live peer is already using this identity (${peer.peerId}) on this mesh.`);
|
|
169
|
+
}
|
|
170
|
+
else if (resume.refusal.kind !== "not-a-member") {
|
|
171
|
+
throw new MemberJoinError("presence-refused", `the hub (${hubPeerId}) refused this peer's first heartbeat: ${resume.refusal.message}`);
|
|
172
|
+
}
|
|
173
|
+
else if (init.invitationId == null) {
|
|
174
|
+
throw new MemberJoinError("not-a-member", `the hub (${hubPeerId}) does not list this peer (${peer.peerId}) as a member, and no ` +
|
|
175
|
+
"invitation was supplied.");
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
onState("joining");
|
|
179
|
+
try {
|
|
180
|
+
const redemption = await redeemInvitation(peer, hubPeerId, init.invitationId);
|
|
181
|
+
initialToken = redemption.token;
|
|
182
|
+
joinedBy = "redeemed";
|
|
183
|
+
}
|
|
184
|
+
catch (err) {
|
|
185
|
+
throw new MemberJoinError("invitation-refused", `the hub (${hubPeerId}) would not accept that invitation. ${String(err)}`, { cause: err });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
// A MEMBER RESERVES ON ITS HUB, NOT ON THE PUBLIC RELAY, and only once
|
|
189
|
+
// the hub has accepted it — `hub-relay.ts`'s membership gater refuses
|
|
190
|
+
// everyone else. This is the ordering `feat/hub-relay` established.
|
|
191
|
+
//
|
|
192
|
+
// NOT OVER THE RELAY CIRCUIT. There is nothing to reserve over (the
|
|
193
|
+
// circuit carries only this member's calls), and `leaveRelay` would hang
|
|
194
|
+
// up the circuit itself, since it runs through the relay.
|
|
195
|
+
if (hubLink === "direct") {
|
|
196
|
+
onState("awaiting-reservation");
|
|
197
|
+
const refused = await reserveOnHub(node, hubPeerId).then(() => null, (err) => err);
|
|
198
|
+
if (refused != null && !fallsBackToRelay(refused))
|
|
199
|
+
throw refused;
|
|
200
|
+
if (refused != null) {
|
|
201
|
+
// DOWN TO RELAY MODE, INTO THE SAME STATE AS A MEMBER WHOSE UPGRADE
|
|
202
|
+
// FAILED: one kept circuit, no supervisor, no `leaveRelay`, the
|
|
203
|
+
// relay-mode re-link below. The WebRTC connection goes, too -- kept, it
|
|
204
|
+
// would make this a "direct" member with none of direct mode's
|
|
205
|
+
// reservation duties, and the hub would report it as direct while this
|
|
206
|
+
// handle says relay. The failed circuit listener stays behind, empty
|
|
207
|
+
// (`reserveOnHub`'s note).
|
|
208
|
+
await node.hangUp(peerIdFromString(hubPeerId)).catch(() => { });
|
|
209
|
+
try {
|
|
210
|
+
await reachHubRelayed(node, relayAddr, hubPeerId);
|
|
211
|
+
}
|
|
212
|
+
catch (relayErr) {
|
|
213
|
+
throw new Error(`startMember: the hub (${hubPeerId}) refused a reservation (${String(refused)}), ` +
|
|
214
|
+
`and the relay circuit to it could not be opened (${String(relayErr)}).`, { cause: relayErr });
|
|
215
|
+
}
|
|
216
|
+
hubLink = "relay";
|
|
217
|
+
hubLinkNote =
|
|
218
|
+
"Connected through the relay only: the hub refused this member a reservation, so " +
|
|
219
|
+
"other members cannot reach it; its own calls are unaffected. Reconnecting tries the " +
|
|
220
|
+
`direct link again. (${refused.message})`;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if (hubLink === "direct") {
|
|
224
|
+
const supervisor = superviseHubReservation({ node, relayAddr, hubPeerId });
|
|
225
|
+
const unwatchWake = init.platform.watchWake?.(() => supervisor.poke()) ?? (() => { });
|
|
226
|
+
unwind.push(async () => {
|
|
227
|
+
unwatchWake();
|
|
228
|
+
supervisor.stop();
|
|
229
|
+
});
|
|
230
|
+
await leaveRelay(node, relayAddr).catch(() => { });
|
|
231
|
+
}
|
|
232
|
+
const join = startJoin({
|
|
233
|
+
peer,
|
|
234
|
+
node,
|
|
235
|
+
hubPeerId,
|
|
236
|
+
relayAddr,
|
|
237
|
+
initialToken,
|
|
238
|
+
initialSeq: seq,
|
|
239
|
+
revocationCache,
|
|
240
|
+
advertisements: init.advertisements,
|
|
241
|
+
onPresenceRefused: init.onPresenceRefused,
|
|
242
|
+
heartbeatIntervalMs: init.heartbeatIntervalMs,
|
|
243
|
+
keepaliveIntervalMs: init.keepaliveIntervalMs,
|
|
244
|
+
// A relay-mode member re-links over a NEW CIRCUIT ONLY, and stays in
|
|
245
|
+
// relay mode: retrying WebRTC here could land it "direct" with none of
|
|
246
|
+
// the reservation duties above. One that joined direct keeps `reachHub`,
|
|
247
|
+
// with the supervisor restoring its reservation.
|
|
248
|
+
relinkHub: hubLink === "relay"
|
|
249
|
+
? async () => {
|
|
250
|
+
await reachHubRelayed(node, relayAddr, hubPeerId);
|
|
251
|
+
}
|
|
252
|
+
: undefined,
|
|
253
|
+
});
|
|
254
|
+
unwind.push(async () => join.stop());
|
|
255
|
+
onState("mounting-edge");
|
|
256
|
+
const dispatchInit = {
|
|
257
|
+
dispatch: peer.dispatch,
|
|
258
|
+
key: init.key,
|
|
259
|
+
token: () => join.token(),
|
|
260
|
+
ensureRoute: createRouteEnsurer({
|
|
261
|
+
node,
|
|
262
|
+
relayAddr,
|
|
263
|
+
selfPeerId: peer.peerId,
|
|
264
|
+
hubPeerId,
|
|
265
|
+
meshView: () => join.meshView(),
|
|
266
|
+
}),
|
|
267
|
+
};
|
|
268
|
+
const edgeFetch = createEdgeDispatch(dispatchInit);
|
|
269
|
+
const edge = await init.platform.mountEdge?.({ key: init.key, dispatch: edgeFetch });
|
|
270
|
+
if (edge != null)
|
|
271
|
+
unwind.push(async () => await edge.stop());
|
|
272
|
+
onState("ready");
|
|
273
|
+
return {
|
|
274
|
+
peerId: peer.peerId,
|
|
275
|
+
hubPeerId,
|
|
276
|
+
relayAddr,
|
|
277
|
+
joinedBy,
|
|
278
|
+
fetch: edgeFetch,
|
|
279
|
+
baseUrl: edge?.baseUrl,
|
|
280
|
+
node,
|
|
281
|
+
meshView: () => join.meshView(),
|
|
282
|
+
token: () => join.token(),
|
|
283
|
+
hubLink: () => hubLink,
|
|
284
|
+
hubLinkNote,
|
|
285
|
+
connectionKind(peerIdStr) {
|
|
286
|
+
try {
|
|
287
|
+
return classifyConnection(node
|
|
288
|
+
.getConnections(peerIdFromString(peerIdStr))
|
|
289
|
+
.filter((c) => c.status === "open")
|
|
290
|
+
.map((c) => c.remoteAddr.toString()));
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
return "none";
|
|
294
|
+
}
|
|
295
|
+
},
|
|
296
|
+
async stop() {
|
|
297
|
+
for (const step of [...unwind].reverse())
|
|
298
|
+
await step().catch(() => { });
|
|
299
|
+
onState("stopped");
|
|
300
|
+
},
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
catch (err) {
|
|
304
|
+
await startFailed();
|
|
305
|
+
throw err;
|
|
306
|
+
}
|
|
307
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@statewalker/httpeers-member",
|
|
3
|
+
"version": "0.1.4",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Everything a participant does: hold an identity, redeem or resume, connect, keep the link, advertise, discover, call, and serve.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+ssh://git@github.com/statewalker/httpeers.git",
|
|
10
|
+
"directory": "packages/httpeers-member"
|
|
11
|
+
},
|
|
12
|
+
"main": "./dist/index.js",
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"exports": {
|
|
15
|
+
".": {
|
|
16
|
+
"source": "./src/index.ts",
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"import": "./dist/index.js"
|
|
19
|
+
},
|
|
20
|
+
"./node": {
|
|
21
|
+
"source": "./src/node.ts",
|
|
22
|
+
"types": "./dist/node.d.ts",
|
|
23
|
+
"import": "./dist/node.js"
|
|
24
|
+
},
|
|
25
|
+
"./browser": {
|
|
26
|
+
"source": "./src/browser.ts",
|
|
27
|
+
"types": "./dist/browser.d.ts",
|
|
28
|
+
"import": "./dist/browser.js"
|
|
29
|
+
},
|
|
30
|
+
"./reset": {
|
|
31
|
+
"source": "./src/reset.ts",
|
|
32
|
+
"types": "./dist/reset.d.ts",
|
|
33
|
+
"import": "./dist/reset.js"
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"dist",
|
|
38
|
+
"src"
|
|
39
|
+
],
|
|
40
|
+
"sideEffects": false,
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"@libp2p/crypto": "^5.1.23",
|
|
43
|
+
"@libp2p/interface": "^3.3.0",
|
|
44
|
+
"@libp2p/peer-id": "^6.0.15",
|
|
45
|
+
"@multiformats/multiaddr": "^13.0.3",
|
|
46
|
+
"@statewalker/httpeers-access": "^0.1.3",
|
|
47
|
+
"@statewalker/httpeers-libp2p": "^0.1.3",
|
|
48
|
+
"@statewalker/httpeers-core": "^0.1.2"
|
|
49
|
+
},
|
|
50
|
+
"peerDependencies": {
|
|
51
|
+
"@chainsafe/libp2p-noise": "^17.0.0",
|
|
52
|
+
"@chainsafe/libp2p-yamux": "^8.0.1",
|
|
53
|
+
"@libp2p/circuit-relay-v2": "^4.2.13",
|
|
54
|
+
"@libp2p/identify": "^4.1.14",
|
|
55
|
+
"@libp2p/tcp": "^11.0.28",
|
|
56
|
+
"@libp2p/webrtc": "^6.0.33",
|
|
57
|
+
"@libp2p/websockets": "^10.1.21",
|
|
58
|
+
"@statewalker/webrun-http-browser": "^0.6.0",
|
|
59
|
+
"idb-keyval": "^6.3.0",
|
|
60
|
+
"libp2p": "^3.3.11"
|
|
61
|
+
},
|
|
62
|
+
"peerDependenciesMeta": {
|
|
63
|
+
"@chainsafe/libp2p-noise": {
|
|
64
|
+
"optional": true
|
|
65
|
+
},
|
|
66
|
+
"@chainsafe/libp2p-yamux": {
|
|
67
|
+
"optional": true
|
|
68
|
+
},
|
|
69
|
+
"@libp2p/circuit-relay-v2": {
|
|
70
|
+
"optional": true
|
|
71
|
+
},
|
|
72
|
+
"@libp2p/identify": {
|
|
73
|
+
"optional": true
|
|
74
|
+
},
|
|
75
|
+
"@libp2p/tcp": {
|
|
76
|
+
"optional": true
|
|
77
|
+
},
|
|
78
|
+
"@libp2p/webrtc": {
|
|
79
|
+
"optional": true
|
|
80
|
+
},
|
|
81
|
+
"@libp2p/websockets": {
|
|
82
|
+
"optional": true
|
|
83
|
+
},
|
|
84
|
+
"@statewalker/webrun-http-browser": {
|
|
85
|
+
"optional": true
|
|
86
|
+
},
|
|
87
|
+
"idb-keyval": {
|
|
88
|
+
"optional": true
|
|
89
|
+
},
|
|
90
|
+
"libp2p": {
|
|
91
|
+
"optional": true
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
"devDependencies": {
|
|
95
|
+
"@biomejs/biome": "^2.5.15",
|
|
96
|
+
"@chainsafe/libp2p-noise": "^17.0.0",
|
|
97
|
+
"@chainsafe/libp2p-yamux": "^8.0.1",
|
|
98
|
+
"@libp2p/circuit-relay-v2": "^4.2.13",
|
|
99
|
+
"@libp2p/identify": "^4.1.14",
|
|
100
|
+
"@libp2p/tcp": "^11.0.28",
|
|
101
|
+
"@libp2p/webrtc": "^6.0.33",
|
|
102
|
+
"@libp2p/websockets": "^10.1.21",
|
|
103
|
+
"@statewalker/webrun-http-browser": "^0.6.0",
|
|
104
|
+
"@types/node": "^26.6.4",
|
|
105
|
+
"idb-keyval": "^6.3.0",
|
|
106
|
+
"libp2p": "^3.3.11",
|
|
107
|
+
"typescript": "^7.0.2",
|
|
108
|
+
"vitest": "^5.0.3"
|
|
109
|
+
},
|
|
110
|
+
"publishConfig": {
|
|
111
|
+
"access": "public"
|
|
112
|
+
},
|
|
113
|
+
"scripts": {
|
|
114
|
+
"build": "tsc -p tsconfig.build.json",
|
|
115
|
+
"test": "pnpm run build && pnpm run typecheck && vitest run",
|
|
116
|
+
"typecheck": "tsc --noEmit",
|
|
117
|
+
"lint": "biome check src tests",
|
|
118
|
+
"lint:check": "biome check src tests"
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The three things a page does differently, as one `MemberPlatform`.
|
|
3
|
+
*
|
|
4
|
+
* THIS IS WHAT THE EXTRACTION IS FOR. The prototype had two lifecycles —
|
|
5
|
+
* `startBrowserPeer` (644 lines) and a Node equivalent — that agreed on every
|
|
6
|
+
* step and diverged on three: how a node is built, whether an edge is mounted,
|
|
7
|
+
* and whether the process gets wake events. Rung 01 replaced both with
|
|
8
|
+
* `startMember` plus an injected platform, and porting `peer-runtime.ts` here
|
|
9
|
+
* would have brought the duplication back through the front door.
|
|
10
|
+
*
|
|
11
|
+
* So there is no browser peer runtime in this package. There is one lifecycle
|
|
12
|
+
* and this object.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { Ed25519PrivateKey } from "@libp2p/interface";
|
|
16
|
+
import { createBrowserNode } from "./browser-profile.js";
|
|
17
|
+
import { mountEdge } from "./edge.js";
|
|
18
|
+
import { watchPageWake } from "./page-wake.js";
|
|
19
|
+
import type { MemberPlatform } from "./start-member.js";
|
|
20
|
+
|
|
21
|
+
export interface BrowserPlatformInit {
|
|
22
|
+
/**
|
|
23
|
+
* Where the ServiceWorker script lives. Omitting it is not an option the
|
|
24
|
+
* edge survives — see `mountEdge`.
|
|
25
|
+
*/
|
|
26
|
+
serviceWorkerUrl?: string;
|
|
27
|
+
/**
|
|
28
|
+
* Relax libp2p's dial gater for loopback and private addresses.
|
|
29
|
+
*
|
|
30
|
+
* A page dialling `127.0.0.1` is a development setup, and the default gater
|
|
31
|
+
* refuses it silently. Off in production, where refusing is right.
|
|
32
|
+
*/
|
|
33
|
+
dev?: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function browserPlatform(init: BrowserPlatformInit = {}): MemberPlatform {
|
|
37
|
+
return {
|
|
38
|
+
createNode: async ({ privateKey }: { privateKey: Ed25519PrivateKey }) =>
|
|
39
|
+
createBrowserNode({ privateKey, dev: init.dev ?? false }),
|
|
40
|
+
|
|
41
|
+
// A PAGE ONLY. `MemberHandle.fetch` is the edge on both platforms; what a
|
|
42
|
+
// page additionally gets is a same-origin URL a plain `fetch()` reaches it
|
|
43
|
+
// through, which is what `baseUrl` is and why it is optional on the handle.
|
|
44
|
+
mountEdge: async ({ key, dispatch }) =>
|
|
45
|
+
mountEdge({ key, dispatch, serviceWorkerUrl: init.serviceWorkerUrl }),
|
|
46
|
+
|
|
47
|
+
// A PAGE ONLY. A Node process gets no wake events, so there the
|
|
48
|
+
// supervisor's backstop timer is the whole story. A tab that was suspended
|
|
49
|
+
// and is now visible again should re-check its hub link immediately rather
|
|
50
|
+
// than wait out a backoff the user can feel.
|
|
51
|
+
watchWake: (onWake) => watchPageWake(onWake),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The browser libp2p profile.
|
|
3
|
+
*
|
|
4
|
+
* A browser cannot listen for inbound TCP the way `../hub/main.ts` /
|
|
5
|
+
* `../relay/main.ts` do (`transport-duplex.ts`'s `createNode`, TCP-only,
|
|
6
|
+
* is Node-side and stays Node-side). Instead this node reserves a relayed
|
|
7
|
+
* address on the deployment's relay and accepts a WebRTC upgrade brokered
|
|
8
|
+
* over it -- WebSockets to dial the relay in the first place,
|
|
9
|
+
* Circuit-Relay-v2 for the reservation and for dialling other peers'
|
|
10
|
+
* relayed addresses, WebRTC for the peer-to-peer upgrade once a relayed
|
|
11
|
+
* connection exists. `addresses.listen: ['/p2p-circuit', '/webrtc']` and
|
|
12
|
+
* this transport list are the exact configuration design note 17 §4 (the
|
|
13
|
+
* relay prototype) and the validated browser-WebRTC prototype (repo notes,
|
|
14
|
+
* `httpeers-plan/prototypes/21-browser-webrtc-prototype-validated/app.ts`)
|
|
15
|
+
* both landed on -- not a guess assembled from the libp2p docs.
|
|
16
|
+
*
|
|
17
|
+
* DIALING THE RELAY AND WAITING FOR THE RESERVATION LIVE IN
|
|
18
|
+
* `../reservation.ts`, re-exported below so this module still reads as the
|
|
19
|
+
* one place a page asks "how do I get a browser node onto the mesh". They
|
|
20
|
+
* moved there in Task 20 because they touch nothing transport-specific and
|
|
21
|
+
* three callers now need them -- this file, `../hub/main.ts`, and
|
|
22
|
+
* `tests/e2e/harness.ts`, the last of which had hand-copied the poll loop
|
|
23
|
+
* rather than import it through this module's `@libp2p/webrtc` dependency.
|
|
24
|
+
*
|
|
25
|
+
* IDENTITY PERSISTS PER ORIGIN, IN INDEXEDDB -- and it lives in
|
|
26
|
+
* `./identity.ts` now, re-exported below. It moved there in Task 24
|
|
27
|
+
* because the hub page needs the key itself, not just a node built from
|
|
28
|
+
* it: `createLibp2p` never hands a generated key back out, and the hub must
|
|
29
|
+
* pass the very same key to `createPeer` so its `mintToken` closure signs
|
|
30
|
+
* as the mesh. See that module for the format contract and for why
|
|
31
|
+
* per-origin identities are deliberate.
|
|
32
|
+
*/
|
|
33
|
+
import { noise } from "@chainsafe/libp2p-noise";
|
|
34
|
+
import { yamux } from "@chainsafe/libp2p-yamux";
|
|
35
|
+
import { circuitRelayTransport } from "@libp2p/circuit-relay-v2";
|
|
36
|
+
import { identify } from "@libp2p/identify";
|
|
37
|
+
import type { Ed25519PrivateKey, Libp2p } from "@libp2p/interface";
|
|
38
|
+
import { webRTC } from "@libp2p/webrtc";
|
|
39
|
+
import { webSockets } from "@libp2p/websockets";
|
|
40
|
+
import { hubRelayService, type IsMember, membershipGater } from "@statewalker/httpeers-libp2p";
|
|
41
|
+
import { createLibp2p, type ServiceFactoryMap } from "libp2p";
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Re-exported from `./identity.ts`, which is where they live now -- see
|
|
45
|
+
* this module's own note on the move. A page that imports
|
|
46
|
+
* `IDENTITY_STORAGE_KEY` from here keeps working.
|
|
47
|
+
*/
|
|
48
|
+
export {
|
|
49
|
+
clearIdentity,
|
|
50
|
+
decodeIdentity,
|
|
51
|
+
encodeIdentity,
|
|
52
|
+
IDENTITY_STORAGE_KEY,
|
|
53
|
+
loadOrCreateIdentity,
|
|
54
|
+
peerIdOf,
|
|
55
|
+
} from "./identity.js";
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Does dialing this address need the permissive gater?
|
|
59
|
+
*
|
|
60
|
+
* libp2p's browser default refuses to dial loopback and private-range
|
|
61
|
+
* addresses. `dev` used to be derived from the PAGE's hostname
|
|
62
|
+
* (`localhost`/`127.0.0.1`), which asks the wrong question: what the gater
|
|
63
|
+
* objects to is the address being DIALLED, not where the page came from. Open
|
|
64
|
+
* the same page at `http://192.168.1.5:5177` and the hostname test says
|
|
65
|
+
* "production" while `httpeers.json` still names a loopback relay -- so every
|
|
66
|
+
* address in the dial is denied and the failure surfaces as
|
|
67
|
+
* `DialDeniedError`, whose message blames the relay for being unreachable
|
|
68
|
+
* when the relay is running perfectly well.
|
|
69
|
+
*
|
|
70
|
+
* Asking it of the address fixes both that case and the LAN one (a page on
|
|
71
|
+
* `192.168.1.5` dialling a relay on `192.168.1.5`, also a private address,
|
|
72
|
+
* also denied). A public relay address returns false and the gater stays at
|
|
73
|
+
* its default, which is the behaviour a deployment wants.
|
|
74
|
+
*/
|
|
75
|
+
export function dialNeedsPermissiveGater(addr: string): boolean {
|
|
76
|
+
const ip = /\/ip4\/([0-9.]+)/.exec(addr)?.[1];
|
|
77
|
+
if (ip == null) return /\/dns4?\/localhost(\/|$)/.test(addr);
|
|
78
|
+
if (ip === "127.0.0.1" || ip.startsWith("127.")) return true;
|
|
79
|
+
if (ip.startsWith("10.") || ip.startsWith("192.168.")) return true;
|
|
80
|
+
const m = /^172\.(\d+)\./.exec(ip);
|
|
81
|
+
return m != null && Number(m[1]) >= 16 && Number(m[1]) <= 31;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface CreateBrowserNodeInit {
|
|
85
|
+
/**
|
|
86
|
+
* Relaxes the libp2p connection gater to allow insecure (`ws://`) and
|
|
87
|
+
* private/loopback dials. libp2p 3.x's browser-only default gater denies
|
|
88
|
+
* BOTH on its own (`libp2p/dist/src/config/connection-gater.browser.js`)
|
|
89
|
+
* -- fine in production, where a real deployment's relay is `wss://` on
|
|
90
|
+
* a real DNS name (`../setup/main.ts`'s `relayAddrFamily`), but fatal in
|
|
91
|
+
* local development, where the relay is `ws://127.0.0.1:<port>/ws`: with
|
|
92
|
+
* the default gater EVERY dial this node makes -- to the relay itself,
|
|
93
|
+
* and to every derived `/p2p-circuit/webrtc/p2p/...` peer address -- is
|
|
94
|
+
* denied before any handshake even starts. Precedent:
|
|
95
|
+
* `workspaces/webrun-wire/apps/p2p-demo/lib/browser-node.ts`, which
|
|
96
|
+
* applies exactly this relaxation gated on `import.meta.env.DEV`.
|
|
97
|
+
* Required here (no default), rather than silently inferred from
|
|
98
|
+
* `import.meta.env.DEV`, because THIS file has no bundler-injected `env`
|
|
99
|
+
* of its own to read -- inferring it here would mean guessing at a value
|
|
100
|
+
* only the caller (a Vite-built page, see `../static-server/main.ts`'s
|
|
101
|
+
* "DIST DIRECTORY LAYOUT" note) actually knows.
|
|
102
|
+
*/
|
|
103
|
+
dev: boolean;
|
|
104
|
+
/**
|
|
105
|
+
* This node's signing key. Defaults to this origin's persisted identity
|
|
106
|
+
* (`./identity.ts`'s `loadOrCreateIdentity`) -- which is what both
|
|
107
|
+
* ordinary pages want and neither has to say.
|
|
108
|
+
*
|
|
109
|
+
* SUPPLIED BY A CALLER THAT NEEDS THE KEY FOR SOMETHING ELSE TOO, which
|
|
110
|
+
* today means the hub page: `createLibp2p` never gives a key back out,
|
|
111
|
+
* and the hub must hand the SAME key to `createPeer` (`mintToken` signs
|
|
112
|
+
* with it, and a mesh whose hub node and hub signer were different keys
|
|
113
|
+
* would mint tokens no peer could match to the hub it is talking to).
|
|
114
|
+
* Loading it once and passing it here is the only way to guarantee they
|
|
115
|
+
* are the same key rather than two reads that happen to agree.
|
|
116
|
+
*/
|
|
117
|
+
privateKey: Ed25519PrivateKey;
|
|
118
|
+
/**
|
|
119
|
+
* Makes this node a HUB: it relays signalling for the peers this answers
|
|
120
|
+
* true for, and for nobody else. See `../hub-relay.ts`. Only the hub page
|
|
121
|
+
* passes it.
|
|
122
|
+
*/
|
|
123
|
+
isMember?: IsMember;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Build this origin's browser libp2p node -- identity loaded/generated,
|
|
128
|
+
* transports and encryption wired, NOT yet dialled anywhere. See
|
|
129
|
+
* `dialRelay` / `waitForCircuitReservation` for the next two steps, kept
|
|
130
|
+
* separate so a caller (`peer-runtime.ts`) can report distinct lifecycle
|
|
131
|
+
* states for "connecting to the relay" and "waiting for the reservation"
|
|
132
|
+
* rather than one opaque await.
|
|
133
|
+
*/
|
|
134
|
+
export async function createBrowserNode(init: CreateBrowserNodeInit): Promise<Libp2p> {
|
|
135
|
+
// REQUIRED now. It used to fall back to `loadOrCreateIdentity()`, which
|
|
136
|
+
// reached for IndexedDB from a function a Node member also calls — see
|
|
137
|
+
// `./kv.js`. The caller holds the key; this only starts a node with it.
|
|
138
|
+
const privateKey = init.privateKey;
|
|
139
|
+
|
|
140
|
+
return createLibp2p({
|
|
141
|
+
privateKey,
|
|
142
|
+
// ONLY A HUB RESERVES ON THE PUBLIC RELAY. A member is reached through
|
|
143
|
+
// its hub (`../hub-link.ts`), so it listens for WebRTC upgrades and
|
|
144
|
+
// nothing else; a `/p2p-circuit` entry here would make libp2p reserve on
|
|
145
|
+
// the first relay it meets -- the public one -- and spend its capacity.
|
|
146
|
+
addresses: { listen: init.isMember != null ? ["/p2p-circuit", "/webrtc"] : ["/webrtc"] },
|
|
147
|
+
transports: [webSockets(), webRTC(), circuitRelayTransport()],
|
|
148
|
+
connectionEncrypters: [noise()],
|
|
149
|
+
streamMuxers: [yamux()],
|
|
150
|
+
connectionGater: {
|
|
151
|
+
...(init.dev ? { denyDialMultiaddr: async () => false } : {}),
|
|
152
|
+
// The thunk is read at DECISION time, so it closes the ordering cycle:
|
|
153
|
+
// this node must exist before the hub that answers `isMember` does.
|
|
154
|
+
...(init.isMember != null ? membershipGater(() => init.isMember as IsMember) : {}),
|
|
155
|
+
},
|
|
156
|
+
services: nodeServices(init.isMember),
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The relay service only on a hub, and only behind its membership test. */
|
|
161
|
+
function nodeServices(isMember: IsMember | undefined): ServiceFactoryMap {
|
|
162
|
+
return isMember != null
|
|
163
|
+
? { identify: identify(), relay: hubRelayService() }
|
|
164
|
+
: { identify: identify() };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Re-exported from `../reservation.ts`, which is where they live now -- see
|
|
169
|
+
* this module's own note on the move. A page that imports them from here
|
|
170
|
+
* keeps working, and a reader looking for "what happens after
|
|
171
|
+
* `createBrowserNode`" still finds the answer named in this file.
|
|
172
|
+
*/
|
|
173
|
+
export {
|
|
174
|
+
dialRelay,
|
|
175
|
+
RESERVATION_POLL_ATTEMPTS,
|
|
176
|
+
RESERVATION_POLL_INTERVAL_MS,
|
|
177
|
+
type WaitForCircuitReservationInit,
|
|
178
|
+
waitForCircuitReservation,
|
|
179
|
+
} from "@statewalker/httpeers-libp2p";
|