@statewalker/httpeers-member 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +203 -0
  3. package/dist/browser-platform.d.ts +30 -0
  4. package/dist/browser-platform.d.ts.map +1 -0
  5. package/dist/browser-platform.js +30 -0
  6. package/dist/browser-profile.d.ts +85 -0
  7. package/dist/browser-profile.d.ts.map +1 -0
  8. package/dist/browser-profile.js +120 -0
  9. package/dist/browser.d.ts +48 -0
  10. package/dist/browser.d.ts.map +1 -0
  11. package/dist/browser.js +51 -0
  12. package/dist/connection-kind.d.ts +36 -0
  13. package/dist/connection-kind.d.ts.map +1 -0
  14. package/dist/connection-kind.js +52 -0
  15. package/dist/describe-error.d.ts +18 -0
  16. package/dist/describe-error.d.ts.map +1 -0
  17. package/dist/describe-error.js +72 -0
  18. package/dist/edge-control.d.ts +50 -0
  19. package/dist/edge-control.d.ts.map +1 -0
  20. package/dist/edge-control.js +69 -0
  21. package/dist/edge-dispatch.d.ts +188 -0
  22. package/dist/edge-dispatch.d.ts.map +1 -0
  23. package/dist/edge-dispatch.js +142 -0
  24. package/dist/edge-guard.d.ts +45 -0
  25. package/dist/edge-guard.d.ts.map +1 -0
  26. package/dist/edge-guard.js +62 -0
  27. package/dist/edge.d.ts +97 -0
  28. package/dist/edge.d.ts.map +1 -0
  29. package/dist/edge.js +104 -0
  30. package/dist/gateway.d.ts +49 -0
  31. package/dist/gateway.d.ts.map +1 -0
  32. package/dist/gateway.js +102 -0
  33. package/dist/identity.d.ts +78 -0
  34. package/dist/identity.d.ts.map +1 -0
  35. package/dist/identity.js +117 -0
  36. package/dist/index.d.ts +39 -0
  37. package/dist/index.d.ts.map +1 -0
  38. package/dist/index.js +37 -0
  39. package/dist/join-blob.d.ts +125 -0
  40. package/dist/join-blob.d.ts.map +1 -0
  41. package/dist/join-blob.js +195 -0
  42. package/dist/join.d.ts +343 -0
  43. package/dist/join.d.ts.map +1 -0
  44. package/dist/join.js +409 -0
  45. package/dist/kv.d.ts +20 -0
  46. package/dist/kv.d.ts.map +1 -0
  47. package/dist/kv.js +10 -0
  48. package/dist/mesh-config.d.ts +26 -0
  49. package/dist/mesh-config.d.ts.map +1 -0
  50. package/dist/mesh-config.js +39 -0
  51. package/dist/mesh-memory.d.ts +55 -0
  52. package/dist/mesh-memory.d.ts.map +1 -0
  53. package/dist/mesh-memory.js +74 -0
  54. package/dist/node.d.ts +29 -0
  55. package/dist/node.d.ts.map +1 -0
  56. package/dist/node.js +42 -0
  57. package/dist/page-wake.d.ts +24 -0
  58. package/dist/page-wake.d.ts.map +1 -0
  59. package/dist/page-wake.js +31 -0
  60. package/dist/peer-request.d.ts +29 -0
  61. package/dist/peer-request.d.ts.map +1 -0
  62. package/dist/peer-request.js +11 -0
  63. package/dist/reset.d.ts +56 -0
  64. package/dist/reset.d.ts.map +1 -0
  65. package/dist/reset.js +103 -0
  66. package/dist/session.d.ts +214 -0
  67. package/dist/session.d.ts.map +1 -0
  68. package/dist/session.js +403 -0
  69. package/dist/start-member.d.ts +176 -0
  70. package/dist/start-member.d.ts.map +1 -0
  71. package/dist/start-member.js +307 -0
  72. package/package.json +120 -0
  73. package/src/browser-platform.ts +53 -0
  74. package/src/browser-profile.ts +179 -0
  75. package/src/browser.ts +103 -0
  76. package/src/connection-kind.ts +53 -0
  77. package/src/describe-error.ts +76 -0
  78. package/src/edge-control.ts +94 -0
  79. package/src/edge-dispatch.ts +295 -0
  80. package/src/edge-guard.ts +67 -0
  81. package/src/edge.ts +175 -0
  82. package/src/gateway.ts +141 -0
  83. package/src/identity.ts +146 -0
  84. package/src/index.ts +95 -0
  85. package/src/join-blob.ts +229 -0
  86. package/src/join.ts +632 -0
  87. package/src/kv.ts +21 -0
  88. package/src/mesh-config.ts +41 -0
  89. package/src/mesh-memory.ts +104 -0
  90. package/src/node.ts +49 -0
  91. package/src/page-wake.ts +59 -0
  92. package/src/peer-request.ts +39 -0
  93. package/src/reset.ts +127 -0
  94. package/src/session.ts +579 -0
  95. package/src/start-member.ts +476 -0
@@ -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";