@statewalker/httpeers-libp2p 0.1.3
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 +173 -0
- package/dist/browser.d.ts +33 -0
- package/dist/browser.d.ts.map +1 -0
- package/dist/browser.js +77 -0
- package/dist/duplex.d.ts +86 -0
- package/dist/duplex.d.ts.map +1 -0
- package/dist/duplex.js +101 -0
- package/dist/hop-reserve.d.ts +105 -0
- package/dist/hop-reserve.d.ts.map +1 -0
- package/dist/hop-reserve.js +269 -0
- package/dist/hub-link.d.ts +131 -0
- package/dist/hub-link.d.ts.map +1 -0
- package/dist/hub-link.js +217 -0
- package/dist/hub-relay.d.ts +129 -0
- package/dist/hub-relay.d.ts.map +1 -0
- package/dist/hub-relay.js +154 -0
- package/dist/identity.d.ts +84 -0
- package/dist/identity.d.ts.map +1 -0
- package/dist/identity.js +91 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +26 -0
- package/dist/link.d.ts +49 -0
- package/dist/link.d.ts.map +1 -0
- package/dist/link.js +88 -0
- package/dist/multiaddr-parts.d.ts +21 -0
- package/dist/multiaddr-parts.d.ts.map +1 -0
- package/dist/multiaddr-parts.js +27 -0
- package/dist/node.d.ts +33 -0
- package/dist/node.d.ts.map +1 -0
- package/dist/node.js +60 -0
- package/dist/reservation.d.ts +245 -0
- package/dist/reservation.d.ts.map +1 -0
- package/dist/reservation.js +396 -0
- package/dist/serve-peer.d.ts +91 -0
- package/dist/serve-peer.d.ts.map +1 -0
- package/dist/serve-peer.js +68 -0
- package/dist/timers.d.ts +36 -0
- package/dist/timers.d.ts.map +1 -0
- package/dist/timers.js +38 -0
- package/dist/transport.d.ts +314 -0
- package/dist/transport.d.ts.map +1 -0
- package/dist/transport.js +410 -0
- package/package.json +73 -0
- package/src/browser.ts +118 -0
- package/src/duplex.ts +172 -0
- package/src/hop-reserve.ts +295 -0
- package/src/hub-link.ts +281 -0
- package/src/hub-relay.ts +188 -0
- package/src/identity.ts +155 -0
- package/src/index.ts +114 -0
- package/src/link.ts +143 -0
- package/src/multiaddr-parts.ts +29 -0
- package/src/node.ts +63 -0
- package/src/reservation.ts +564 -0
- package/src/serve-peer.ts +141 -0
- package/src/timers.ts +47 -0
- package/src/transport.ts +538 -0
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Asking the RELAY whether this node is still reserved -- and renewing the
|
|
3
|
+
* reservation in the same breath.
|
|
4
|
+
*
|
|
5
|
+
* THE QUESTION THIS MODULE EXISTS TO ANSWER. `node.getMultiaddrs()` reports
|
|
6
|
+
* what the node's own transport reservation store believes, and in the
|
|
7
|
+
* 2026-09-19 incident that belief was wrong for hours: the relay held no
|
|
8
|
+
* reservation, the websocket was still up, every member got
|
|
9
|
+
* `NO_RESERVATION`, and the hub saw nothing to repair. The only authority on
|
|
10
|
+
* "is there a reservation" is the relay, so this asks it, over the wire.
|
|
11
|
+
*
|
|
12
|
+
* WHY NOT LIBP2P'S OWN CALL. `@libp2p/circuit-relay-v2`'s transport keeps a
|
|
13
|
+
* `ReservationStore` whose `addRelay(peer, type)` is the renewal entry point,
|
|
14
|
+
* and it is unusable as a probe for three separate reasons:
|
|
15
|
+
*
|
|
16
|
+
* 1. It SHORT-CIRCUITS. If it still holds an entry, the connection is open
|
|
17
|
+
* and the recorded expiry is more than ten minutes away, it returns the
|
|
18
|
+
* cached entry without sending a byte -- so a proactive call answers from
|
|
19
|
+
* the very belief we are trying to check.
|
|
20
|
+
* 2. It is NOT REACHABLE. The store is a private component of the transport;
|
|
21
|
+
* nothing on `Libp2p` exposes it.
|
|
22
|
+
* 3. Its failure paths are SURPRISING. A dial or protocol failure puts the
|
|
23
|
+
* relay in a "previously invalid" cuckoo filter (`relayFilter`) so later
|
|
24
|
+
* attempts are refused locally, and a discovered reservation can be
|
|
25
|
+
* refused with `HadEnoughRelaysError` when no pending slot is free.
|
|
26
|
+
*
|
|
27
|
+
* So we speak HOP ourselves. A `RESERVE` request on the relay's own protocol
|
|
28
|
+
* is two bytes, and the answer is the relay's, not ours.
|
|
29
|
+
*
|
|
30
|
+
* IT REUSES THE SLOT, IT DOES NOT LEAK A SECOND ONE. The relay's reservation
|
|
31
|
+
* store is keyed by peer id: `reserve()` finds the existing entry, resets its
|
|
32
|
+
* expiry signal in place and returns OK (see `server/reservation-store.js` in
|
|
33
|
+
* the installed 4.2.11). A peer cannot hold two reservations on one relay, so
|
|
34
|
+
* "renew" and "re-reserve" are the same request -- which is also why this one
|
|
35
|
+
* call both DETECTS a loss and REPAIRS it.
|
|
36
|
+
*
|
|
37
|
+
* IT DOES NOT DISTURB LIVE CIRCUITS. It opens one short-lived stream on a
|
|
38
|
+
* connection that already exists; already-established circuits are separate
|
|
39
|
+
* connections and are untouched, and the relay's refresh path does not close
|
|
40
|
+
* anything.
|
|
41
|
+
*
|
|
42
|
+
* THE CODEC IS HAND-WRITTEN, four fields wide, because it has to be:
|
|
43
|
+
* `@libp2p/circuit-relay-v2` publishes only `.` in its `exports` map, so its
|
|
44
|
+
* generated `pb/index.js` cannot be imported, and vendoring protons to
|
|
45
|
+
* regenerate a message we send ONE variant of would be more code than this.
|
|
46
|
+
* The wire format is pinned by `tests/hop-reserve.test.ts` against bytes taken
|
|
47
|
+
* from that generated encoder, and end to end by `tests/reservation-renewal.test.ts`
|
|
48
|
+
* against a real relay -- which is the check that actually matters, since a
|
|
49
|
+
* codec that agrees only with its own tests proves nothing.
|
|
50
|
+
*/
|
|
51
|
+
import { RELAY_V2_HOP_CODEC } from "@libp2p/circuit-relay-v2";
|
|
52
|
+
import { peerIdFromString } from "@libp2p/peer-id";
|
|
53
|
+
import { pbStream } from "@libp2p/utils";
|
|
54
|
+
/** circuit-relay v2 `Status`, by number. The relay answers one of these. */
|
|
55
|
+
export const HOP_STATUS_NAMES = {
|
|
56
|
+
0: "UNUSED",
|
|
57
|
+
100: "OK",
|
|
58
|
+
200: "RESERVATION_REFUSED",
|
|
59
|
+
201: "RESOURCE_LIMIT_EXCEEDED",
|
|
60
|
+
202: "PERMISSION_DENIED",
|
|
61
|
+
203: "CONNECTION_FAILED",
|
|
62
|
+
204: "NO_RESERVATION",
|
|
63
|
+
400: "MALFORMED_MESSAGE",
|
|
64
|
+
401: "UNEXPECTED_MESSAGE",
|
|
65
|
+
};
|
|
66
|
+
/** `Status.OK`. */
|
|
67
|
+
export const HOP_STATUS_OK = 100;
|
|
68
|
+
/**
|
|
69
|
+
* `HopMessage{ type: RESERVE }` on the wire: field 1, varint, value 0.
|
|
70
|
+
*
|
|
71
|
+
* protobuf3 would normally omit a zero-valued field; protons generates
|
|
72
|
+
* `if (obj.type != null) { w.uint32(8); ... }` for this `optional` field, so
|
|
73
|
+
* the tag IS written and a relay that requires it (go-libp2p does) sees it.
|
|
74
|
+
* Constant, because every request this module sends is this one.
|
|
75
|
+
*/
|
|
76
|
+
const RESERVE_REQUEST = Uint8Array.from([0x08, 0x00]);
|
|
77
|
+
/** How long one RESERVE round trip may take, matching libp2p's own `DEFAULT_RESERVATION_COMPLETION_TIMEOUT`. */
|
|
78
|
+
export const HOP_RESERVE_TIMEOUT_MS = 5_000;
|
|
79
|
+
/** The relay answered, and the answer was not OK. `status` is its own `Status` number. */
|
|
80
|
+
export class RelayReservationRefusedError extends Error {
|
|
81
|
+
relayPeerId;
|
|
82
|
+
status;
|
|
83
|
+
constructor(relayPeerId, status) {
|
|
84
|
+
super(`hop: the relay (${relayPeerId}) refused a reservation with ` +
|
|
85
|
+
`${HOP_STATUS_NAMES[status] ?? `status ${status}`}`);
|
|
86
|
+
this.relayPeerId = relayPeerId;
|
|
87
|
+
this.status = status;
|
|
88
|
+
this.name = "RelayReservationRefusedError";
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/** There is no connection this node could ask the relay over. Not a refusal: nothing was asked. */
|
|
92
|
+
export class RelayNotConnectedError extends Error {
|
|
93
|
+
relayPeerId;
|
|
94
|
+
constructor(relayPeerId) {
|
|
95
|
+
super(`hop: no open, unlimited connection to the relay (${relayPeerId}) to ask for a reservation over`);
|
|
96
|
+
this.relayPeerId = relayPeerId;
|
|
97
|
+
this.name = "RelayNotConnectedError";
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Read the fields of a `HopMessage` this module needs, skipping the rest.
|
|
102
|
+
*
|
|
103
|
+
* Deliberately lenient about everything it does not use -- an unknown field,
|
|
104
|
+
* a voucher, a limit -- because a decoder that throws on a field a future
|
|
105
|
+
* relay adds would turn a healthy relay into a lost reservation.
|
|
106
|
+
*/
|
|
107
|
+
export function decodeHopAnswer(data) {
|
|
108
|
+
const bytes = data instanceof Uint8Array ? data : data.subarray();
|
|
109
|
+
const answer = {};
|
|
110
|
+
const reader = new Reader(bytes);
|
|
111
|
+
while (reader.hasMore()) {
|
|
112
|
+
const tag = reader.varint();
|
|
113
|
+
const field = tag >>> 3;
|
|
114
|
+
const wire = tag & 7;
|
|
115
|
+
if (field === 1 && wire === 0)
|
|
116
|
+
answer.type = reader.varint();
|
|
117
|
+
else if (field === 5 && wire === 0)
|
|
118
|
+
answer.status = reader.varint();
|
|
119
|
+
else if (field === 3 && wire === 2) {
|
|
120
|
+
const reservation = new Reader(reader.bytes());
|
|
121
|
+
while (reservation.hasMore()) {
|
|
122
|
+
const innerTag = reservation.varint();
|
|
123
|
+
if (innerTag >>> 3 === 1 && (innerTag & 7) === 0)
|
|
124
|
+
answer.expire = reservation.varint();
|
|
125
|
+
else
|
|
126
|
+
reservation.skip(innerTag & 7);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
else
|
|
130
|
+
reader.skip(wire);
|
|
131
|
+
}
|
|
132
|
+
return answer;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Request (or renew) this node's reservation on `relayPeerId`, and return what
|
|
136
|
+
* the relay granted. Throws `RelayReservationRefusedError` on any non-OK
|
|
137
|
+
* status, `RelayNotConnectedError` when there is nothing to ask over, and
|
|
138
|
+
* whatever the stream threw otherwise. Every one of those means "not reserved
|
|
139
|
+
* as far as the relay is concerned", which is the only judgement the caller
|
|
140
|
+
* needs.
|
|
141
|
+
*/
|
|
142
|
+
export async function requestRelayReservation(node, relayPeerId, options = {}) {
|
|
143
|
+
const connection = openConnectionTo(node, relayPeerId);
|
|
144
|
+
if (connection == null)
|
|
145
|
+
throw new RelayNotConnectedError(relayPeerId);
|
|
146
|
+
const signal = options.signal ?? AbortSignal.timeout(HOP_RESERVE_TIMEOUT_MS);
|
|
147
|
+
const stream = await connection.newStream(RELAY_V2_HOP_CODEC, { signal });
|
|
148
|
+
let answer;
|
|
149
|
+
try {
|
|
150
|
+
const hop = pbStream(framed(stream)).pb({
|
|
151
|
+
encode: () => RESERVE_REQUEST,
|
|
152
|
+
decode: decodeHopAnswer,
|
|
153
|
+
});
|
|
154
|
+
await hop.write({}, { signal });
|
|
155
|
+
answer = await hop.read({ signal });
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
stream.abort(error);
|
|
159
|
+
throw error;
|
|
160
|
+
}
|
|
161
|
+
// Closing the write end is enough and is not awaited past its own timeout:
|
|
162
|
+
// the answer is already in hand, and a relay that is slow to acknowledge the
|
|
163
|
+
// close must not turn a granted reservation into a reported loss.
|
|
164
|
+
await stream.close({ signal }).catch(() => stream.abort(new Error("hop: close failed")));
|
|
165
|
+
if (answer.status !== HOP_STATUS_OK) {
|
|
166
|
+
throw new RelayReservationRefusedError(relayPeerId, answer.status ?? 0);
|
|
167
|
+
}
|
|
168
|
+
const expiresAt = answer.expire != null ? answer.expire * 1_000 : null;
|
|
169
|
+
return { expiresAt, ttlMs: expiresAt != null ? expiresAt - Date.now() : null };
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* A ONE-VERSION TYPE GAP, NOT A RUNTIME ONE.
|
|
173
|
+
*
|
|
174
|
+
* This workspace pins `@libp2p/interface` at 3.2.5 everywhere, and
|
|
175
|
+
* `@libp2p/utils` requires `^3.3.0` — whose `MessageStream` grew a
|
|
176
|
+
* `readableEnded` property. `pbStream` therefore refuses a 3.2.5 `Stream` by
|
|
177
|
+
* type while accepting it perfectly at runtime: the property is implemented by
|
|
178
|
+
* `AbstractMessageStream`, which every real libp2p stream extends, and
|
|
179
|
+
* `stream-utils.js` never reads it in the first place (checked in the
|
|
180
|
+
* installed 7.4.1). The narrow cast is the honest statement of that; widening
|
|
181
|
+
* the pin across seven packages to satisfy one call site would be a much
|
|
182
|
+
* larger change for the same behaviour.
|
|
183
|
+
*/
|
|
184
|
+
function framed(stream) {
|
|
185
|
+
return stream;
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* A connection to the relay a reservation may be requested over: open, and not
|
|
189
|
+
* itself relayed.
|
|
190
|
+
*
|
|
191
|
+
* NOT RELAYED, because a relay refuses to reserve over a circuit
|
|
192
|
+
* (libp2p calls it `DoubleRelayError` on its own side), and a limited
|
|
193
|
+
* connection would in any case be spent on the attempt. `limits != null` is
|
|
194
|
+
* how libp2p marks one.
|
|
195
|
+
*/
|
|
196
|
+
function openConnectionTo(node, relayPeerId) {
|
|
197
|
+
let peerId;
|
|
198
|
+
try {
|
|
199
|
+
peerId = peerIdFromString(relayPeerId);
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
return undefined;
|
|
203
|
+
}
|
|
204
|
+
return node
|
|
205
|
+
.getConnections(peerId)
|
|
206
|
+
.find((connection) => connection.status === "open" && connection.limits == null);
|
|
207
|
+
}
|
|
208
|
+
/** A protobuf reader for the handful of field types `HopMessage` uses. */
|
|
209
|
+
class Reader {
|
|
210
|
+
buf;
|
|
211
|
+
pos = 0;
|
|
212
|
+
constructor(buf) {
|
|
213
|
+
this.buf = buf;
|
|
214
|
+
}
|
|
215
|
+
hasMore() {
|
|
216
|
+
return this.pos < this.buf.length;
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* An unsigned varint, as a `number`.
|
|
220
|
+
*
|
|
221
|
+
* Assembled with multiplication rather than `<<`, because `<<` is a 32-bit
|
|
222
|
+
* operation in JavaScript and `Reservation.expire` is a uint64 of Unix
|
|
223
|
+
* seconds -- a shift would wrap it into a negative number and turn a valid
|
|
224
|
+
* expiry into nonsense. Ten bytes is the maximum a uint64 can occupy.
|
|
225
|
+
*/
|
|
226
|
+
varint() {
|
|
227
|
+
let value = 0;
|
|
228
|
+
let shift = 1;
|
|
229
|
+
for (let i = 0; i < 10; i++) {
|
|
230
|
+
if (!this.hasMore())
|
|
231
|
+
throw new Error("hop: truncated varint");
|
|
232
|
+
const byte = this.buf[this.pos++];
|
|
233
|
+
value += (byte & 0x7f) * shift;
|
|
234
|
+
if ((byte & 0x80) === 0)
|
|
235
|
+
return value;
|
|
236
|
+
shift *= 128;
|
|
237
|
+
}
|
|
238
|
+
throw new Error("hop: varint longer than 10 bytes");
|
|
239
|
+
}
|
|
240
|
+
/** A length-delimited field's payload. */
|
|
241
|
+
bytes() {
|
|
242
|
+
const length = this.varint();
|
|
243
|
+
const end = this.pos + length;
|
|
244
|
+
if (end > this.buf.length)
|
|
245
|
+
throw new Error("hop: truncated length-delimited field");
|
|
246
|
+
const slice = this.buf.subarray(this.pos, end);
|
|
247
|
+
this.pos = end;
|
|
248
|
+
return slice;
|
|
249
|
+
}
|
|
250
|
+
/** Step over a field of the given wire type. */
|
|
251
|
+
skip(wire) {
|
|
252
|
+
switch (wire) {
|
|
253
|
+
case 0:
|
|
254
|
+
this.varint();
|
|
255
|
+
return;
|
|
256
|
+
case 1:
|
|
257
|
+
this.pos += 8;
|
|
258
|
+
return;
|
|
259
|
+
case 2:
|
|
260
|
+
this.bytes();
|
|
261
|
+
return;
|
|
262
|
+
case 5:
|
|
263
|
+
this.pos += 4;
|
|
264
|
+
return;
|
|
265
|
+
default:
|
|
266
|
+
throw new Error(`hop: unsupported protobuf wire type ${wire}`);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A member's link to its hub: reach it, reserve on it, and address other
|
|
3
|
+
* members through it. See `./hub-relay.ts` for the hub's side and why.
|
|
4
|
+
*
|
|
5
|
+
* TRANSPORT-NEUTRAL, like `./reservation.ts`: nothing here is browser-only,
|
|
6
|
+
* so the same code runs in a page and under the Node tests.
|
|
7
|
+
*/
|
|
8
|
+
import type { Connection, Libp2p } from "@libp2p/interface";
|
|
9
|
+
import { type RelaySupervisor } from "./reservation.js";
|
|
10
|
+
/**
|
|
11
|
+
* The address a member dials another member at: through their hub, with a
|
|
12
|
+
* WebRTC upgrade. The `/webrtc` step is what turns the hub's circuit into a
|
|
13
|
+
* signalling channel -- dialled without it, the result is a limited
|
|
14
|
+
* connection through the hub, which carries nothing.
|
|
15
|
+
*
|
|
16
|
+
* COMPOSED, NEVER TAKEN FROM A PEER'S OWN ADDRESSES. A reservation on the
|
|
17
|
+
* hub makes libp2p report a double-circuit address
|
|
18
|
+
* (`<relay>/p2p-circuit/webrtc/p2p/<hub>/p2p-circuit/p2p/<member>`) that
|
|
19
|
+
* nothing can dial. This works because the circuit transport reuses the
|
|
20
|
+
* connection it already holds to the hub.
|
|
21
|
+
*/
|
|
22
|
+
export declare function hubRoute(hubPeerId: string, peerId: string): string;
|
|
23
|
+
/**
|
|
24
|
+
* Reach the hub over WebRTC through the public relay, and keep ONLY the
|
|
25
|
+
* WebRTC connection.
|
|
26
|
+
*
|
|
27
|
+
* WHY THE SIGNALLING CIRCUIT IS CLOSED. Reaching the hub leaves two
|
|
28
|
+
* connections to it: the limited circuit through the public relay that
|
|
29
|
+
* carried the SDP exchange, and the WebRTC one. The circuit transport relays
|
|
30
|
+
* through `getConnections(relay)[0]` -- the FIRST connection to the relay,
|
|
31
|
+
* limited or not -- so while the limited one is open, every dial THROUGH the
|
|
32
|
+
* hub fails with `LimitedConnectionError`. Found by the `proto/hub-relay`
|
|
33
|
+
* prototype; the "connects two members directly" test fails without it.
|
|
34
|
+
*/
|
|
35
|
+
export declare function reachHub(node: Libp2p, relayAddr: string, hubPeerId: string): Promise<void>;
|
|
36
|
+
/**
|
|
37
|
+
* Reach the hub through the public relay WITHOUT the WebRTC upgrade, and KEEP
|
|
38
|
+
* the circuit: the fallback for when `reachHub` cannot work (a hub in Docker on
|
|
39
|
+
* a bridge network has nothing a WebRTC dial can reach).
|
|
40
|
+
*
|
|
41
|
+
* The result is a LIMITED connection, and nothing crosses it unless both ends
|
|
42
|
+
* opt in: the hub serves with `serveOnLimitedConnection: true`, and the member
|
|
43
|
+
* calls with `callOnLimitedConnection` allowing the hub (`servePeer`,
|
|
44
|
+
* `createRemote`), which then opens its streams on this connection rather than
|
|
45
|
+
* dialling again.
|
|
46
|
+
*
|
|
47
|
+
* RETURNED AS LIBP2P RETURNS IT, and not always limited: if an unlimited
|
|
48
|
+
* connection to the hub already exists (a WebRTC upgrade that did come up),
|
|
49
|
+
* `dial` hands that back instead of opening a circuit. Check `limits` if it
|
|
50
|
+
* matters; the intended caller only gets here after `reachHub` failed, or
|
|
51
|
+
* after it succeeded but the hub refused a reservation -- and then hangs up
|
|
52
|
+
* the WebRTC connection first (`startMember`).
|
|
53
|
+
*
|
|
54
|
+
* NOT CLOSED, unlike `reachHub`'s circuit, because it is the data path. Two
|
|
55
|
+
* consequences for the caller: `reserveOnHub` has nothing to reserve over, so
|
|
56
|
+
* other members cannot reach this one; and `leaveRelay` would hang up the
|
|
57
|
+
* circuit itself.
|
|
58
|
+
*/
|
|
59
|
+
export declare function reachHubRelayed(node: Libp2p, relayAddr: string, hubPeerId: string): Promise<Connection>;
|
|
60
|
+
/**
|
|
61
|
+
* Why a hub did not grant a reservation, as far as libp2p lets us tell.
|
|
62
|
+
*
|
|
63
|
+
* - `"store-full"` -- `RESERVATION_REFUSED`: the hub's relay already holds
|
|
64
|
+
* as many reservations as it grants (libp2p's `maxReservations`). A
|
|
65
|
+
* capacity condition on the hub; nothing about this peer.
|
|
66
|
+
* - `"resource-limit"` -- `RESOURCE_LIMIT_EXCEEDED`: the same kind of thing,
|
|
67
|
+
* from a relay that meters resources rather than counting reservations.
|
|
68
|
+
* - `"not-a-member"` -- `PERMISSION_DENIED`: the hub's `membershipGater`
|
|
69
|
+
* does not count this peer as a member. (libp2p also answers it to a
|
|
70
|
+
* reservation asked for over a relayed connection, which `reserveOnHub`
|
|
71
|
+
* never does: it runs over `reachHub`'s WebRTC connection.)
|
|
72
|
+
* - `"no-relay"` -- the hub does not speak the relay protocol at all
|
|
73
|
+
* (`UnsupportedProtocolError`): a hub deployed without `hubRelayService`.
|
|
74
|
+
* - `"no-answer"` -- no status came back: a timeout, a link that dropped
|
|
75
|
+
* mid-request. Nothing was refused; nothing was granted either.
|
|
76
|
+
* - `"other"` -- any other status, named verbatim in the message.
|
|
77
|
+
*/
|
|
78
|
+
export type HubReservationRefusal = "store-full" | "resource-limit" | "not-a-member" | "no-relay" | "no-answer" | "other";
|
|
79
|
+
/** `reserveOnHub`'s failure: which refusal, the relay's own status when it sent one, and libp2p's error as `cause`. */
|
|
80
|
+
export declare class HubReservationError extends Error {
|
|
81
|
+
readonly hubPeerId: string;
|
|
82
|
+
/** The circuit-relay v2 status the hub answered (`"RESERVATION_REFUSED"`, ...), or `null` when it answered none. */
|
|
83
|
+
readonly status: string | null;
|
|
84
|
+
readonly refusal: HubReservationRefusal;
|
|
85
|
+
constructor(hubPeerId: string,
|
|
86
|
+
/** The circuit-relay v2 status the hub answered (`"RESERVATION_REFUSED"`, ...), or `null` when it answered none. */
|
|
87
|
+
status: string | null, refusal: HubReservationRefusal, options: {
|
|
88
|
+
cause: unknown;
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Reserve on the hub, over the WebRTC connection `reachHub` left open.
|
|
93
|
+
* Returns the reserved address. Only a member is granted one -- call this
|
|
94
|
+
* after the hub has accepted this peer, not before.
|
|
95
|
+
*
|
|
96
|
+
* A REFUSAL IS A `HubReservationError` that names the relay's own status --
|
|
97
|
+
* see `HubReservationRefusal`. libp2p reports every refusal alike, as "Some
|
|
98
|
+
* configured addresses failed to be listened on"; a full store and a
|
|
99
|
+
* non-member used to read the same, and in production a full store was
|
|
100
|
+
* diagnosed as a membership problem.
|
|
101
|
+
*
|
|
102
|
+
* A CONFIGURED RELAY, NOT A DISCOVERED ONE: libp2p will not restore it on
|
|
103
|
+
* its own if the link to the hub drops, which is `superviseHubReservation`'s
|
|
104
|
+
* job, below. Each call adds a listener; after a lost link -- or a refused
|
|
105
|
+
* reservation -- the old one sits empty. That is one small object per
|
|
106
|
+
* attempt, accepted rather than reaching into libp2p's internals to reuse it.
|
|
107
|
+
*/
|
|
108
|
+
export declare function reserveOnHub(node: Libp2p, hubPeerId: string): Promise<string>;
|
|
109
|
+
/**
|
|
110
|
+
* Close this member's connection to the public relay, once its hub link is
|
|
111
|
+
* up. A member holds no reservation there any more, and other members reach
|
|
112
|
+
* it through the hub, so the socket would only spend the relay's capacity.
|
|
113
|
+
* It is reopened on demand: `reachHub` dials through the relay whenever the
|
|
114
|
+
* hub has to be reached again.
|
|
115
|
+
*/
|
|
116
|
+
export declare function leaveRelay(node: Libp2p, relayAddr: string): Promise<void>;
|
|
117
|
+
export interface SuperviseHubReservationInit {
|
|
118
|
+
node: Libp2p;
|
|
119
|
+
/** The public relay the hub is reached through -- `reachHub`'s. */
|
|
120
|
+
relayAddr: string;
|
|
121
|
+
hubPeerId: string;
|
|
122
|
+
minRetryDelayMs?: number;
|
|
123
|
+
maxRetryDelayMs?: number;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Keep this member's reservation on its hub: whenever it is lost, reach the
|
|
127
|
+
* hub again and re-reserve, with `superviseRelay`'s backoff and `poke()`.
|
|
128
|
+
* Call once the first `reserveOnHub` has succeeded.
|
|
129
|
+
*/
|
|
130
|
+
export declare function superviseHubReservation(init: SuperviseHubReservationInit): RelaySupervisor;
|
|
131
|
+
//# sourceMappingURL=hub-link.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"hub-link.d.ts","sourceRoot":"","sources":["../src/hub-link.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,EAAE,MAAM,mBAAmB,CAAC;AAI5D,OAAO,EAAE,KAAK,eAAe,EAAkB,MAAM,kBAAkB,CAAC;AAExE;;;;;;;;;;;GAWG;AACH,wBAAgB,QAAQ,CAAC,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAElE;AAED;;;;;;;;;;;GAWG;AACH,wBAAsB,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAIhG;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAsB,eAAe,CACnC,IAAI,EAAE,MAAM,EACZ,SAAS,EAAE,MAAM,EACjB,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,UAAU,CAAC,CAErB;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,MAAM,qBAAqB,GAC7B,YAAY,GACZ,gBAAgB,GAChB,cAAc,GACd,UAAU,GACV,WAAW,GACX,OAAO,CAAC;AAEZ,uHAAuH;AACvH,qBAAa,mBAAoB,SAAQ,KAAK;IAE1C,QAAQ,CAAC,SAAS,EAAE,MAAM;IAC1B,oHAAoH;IACpH,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI;IAC9B,QAAQ,CAAC,OAAO,EAAE,qBAAqB;IAJzC,YACW,SAAS,EAAE,MAAM;IAC1B,oHAAoH;IAC3G,MAAM,EAAE,MAAM,GAAG,IAAI,EACrB,OAAO,EAAE,qBAAqB,EACvC,OAAO,EAAE;QAAE,KAAK,EAAE,OAAO,CAAA;KAAE,EAI5B;CACF;AA2ED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAsB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAenF;AAED;;;;;;GAMG;AACH,wBAAsB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAG/E;AAED,MAAM,WAAW,2BAA2B;IAC1C,IAAI,EAAE,MAAM,CAAC;IACb,mEAAmE;IACnE,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED;;;;GAIG;AACH,wBAAgB,uBAAuB,CAAC,IAAI,EAAE,2BAA2B,GAAG,eAAe,CAY1F"}
|
package/dist/hub-link.js
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A member's link to its hub: reach it, reserve on it, and address other
|
|
3
|
+
* members through it. See `./hub-relay.ts` for the hub's side and why.
|
|
4
|
+
*
|
|
5
|
+
* TRANSPORT-NEUTRAL, like `./reservation.ts`: nothing here is browser-only,
|
|
6
|
+
* so the same code runs in a page and under the Node tests.
|
|
7
|
+
*/
|
|
8
|
+
import { peerIdFromString } from "@libp2p/peer-id";
|
|
9
|
+
import { multiaddr } from "@multiformats/multiaddr";
|
|
10
|
+
import { lastPeerIdOf } from "./multiaddr-parts.js";
|
|
11
|
+
import { superviseRelay } from "./reservation.js";
|
|
12
|
+
/**
|
|
13
|
+
* The address a member dials another member at: through their hub, with a
|
|
14
|
+
* WebRTC upgrade. The `/webrtc` step is what turns the hub's circuit into a
|
|
15
|
+
* signalling channel -- dialled without it, the result is a limited
|
|
16
|
+
* connection through the hub, which carries nothing.
|
|
17
|
+
*
|
|
18
|
+
* COMPOSED, NEVER TAKEN FROM A PEER'S OWN ADDRESSES. A reservation on the
|
|
19
|
+
* hub makes libp2p report a double-circuit address
|
|
20
|
+
* (`<relay>/p2p-circuit/webrtc/p2p/<hub>/p2p-circuit/p2p/<member>`) that
|
|
21
|
+
* nothing can dial. This works because the circuit transport reuses the
|
|
22
|
+
* connection it already holds to the hub.
|
|
23
|
+
*/
|
|
24
|
+
export function hubRoute(hubPeerId, peerId) {
|
|
25
|
+
return `/p2p/${hubPeerId}/p2p-circuit/webrtc/p2p/${peerId}`;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Reach the hub over WebRTC through the public relay, and keep ONLY the
|
|
29
|
+
* WebRTC connection.
|
|
30
|
+
*
|
|
31
|
+
* WHY THE SIGNALLING CIRCUIT IS CLOSED. Reaching the hub leaves two
|
|
32
|
+
* connections to it: the limited circuit through the public relay that
|
|
33
|
+
* carried the SDP exchange, and the WebRTC one. The circuit transport relays
|
|
34
|
+
* through `getConnections(relay)[0]` -- the FIRST connection to the relay,
|
|
35
|
+
* limited or not -- so while the limited one is open, every dial THROUGH the
|
|
36
|
+
* hub fails with `LimitedConnectionError`. Found by the `proto/hub-relay`
|
|
37
|
+
* prototype; the "connects two members directly" test fails without it.
|
|
38
|
+
*/
|
|
39
|
+
export async function reachHub(node, relayAddr, hubPeerId) {
|
|
40
|
+
await node.dial(multiaddr(`${relayAddr}/p2p-circuit/webrtc/p2p/${hubPeerId}`));
|
|
41
|
+
const limited = node.getConnections(peerIdFromString(hubPeerId)).filter((c) => c.limits != null);
|
|
42
|
+
await Promise.all(limited.map((c) => c.close()));
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Reach the hub through the public relay WITHOUT the WebRTC upgrade, and KEEP
|
|
46
|
+
* the circuit: the fallback for when `reachHub` cannot work (a hub in Docker on
|
|
47
|
+
* a bridge network has nothing a WebRTC dial can reach).
|
|
48
|
+
*
|
|
49
|
+
* The result is a LIMITED connection, and nothing crosses it unless both ends
|
|
50
|
+
* opt in: the hub serves with `serveOnLimitedConnection: true`, and the member
|
|
51
|
+
* calls with `callOnLimitedConnection` allowing the hub (`servePeer`,
|
|
52
|
+
* `createRemote`), which then opens its streams on this connection rather than
|
|
53
|
+
* dialling again.
|
|
54
|
+
*
|
|
55
|
+
* RETURNED AS LIBP2P RETURNS IT, and not always limited: if an unlimited
|
|
56
|
+
* connection to the hub already exists (a WebRTC upgrade that did come up),
|
|
57
|
+
* `dial` hands that back instead of opening a circuit. Check `limits` if it
|
|
58
|
+
* matters; the intended caller only gets here after `reachHub` failed, or
|
|
59
|
+
* after it succeeded but the hub refused a reservation -- and then hangs up
|
|
60
|
+
* the WebRTC connection first (`startMember`).
|
|
61
|
+
*
|
|
62
|
+
* NOT CLOSED, unlike `reachHub`'s circuit, because it is the data path. Two
|
|
63
|
+
* consequences for the caller: `reserveOnHub` has nothing to reserve over, so
|
|
64
|
+
* other members cannot reach this one; and `leaveRelay` would hang up the
|
|
65
|
+
* circuit itself.
|
|
66
|
+
*/
|
|
67
|
+
export async function reachHubRelayed(node, relayAddr, hubPeerId) {
|
|
68
|
+
return node.dial(multiaddr(`${relayAddr}/p2p-circuit/p2p/${hubPeerId}`));
|
|
69
|
+
}
|
|
70
|
+
/** `reserveOnHub`'s failure: which refusal, the relay's own status when it sent one, and libp2p's error as `cause`. */
|
|
71
|
+
export class HubReservationError extends Error {
|
|
72
|
+
hubPeerId;
|
|
73
|
+
status;
|
|
74
|
+
refusal;
|
|
75
|
+
constructor(hubPeerId,
|
|
76
|
+
/** The circuit-relay v2 status the hub answered (`"RESERVATION_REFUSED"`, ...), or `null` when it answered none. */
|
|
77
|
+
status, refusal, options) {
|
|
78
|
+
super(describeRefusal(hubPeerId, status, refusal, options.cause), options);
|
|
79
|
+
this.hubPeerId = hubPeerId;
|
|
80
|
+
this.status = status;
|
|
81
|
+
this.refusal = refusal;
|
|
82
|
+
this.name = "HubReservationError";
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Read the refusal out of libp2p's error.
|
|
87
|
+
*
|
|
88
|
+
* FROM THE TEXT, BECAUSE THAT IS ALL THERE IS. The transport manager wraps
|
|
89
|
+
* every failed listen in one `UnsupportedListenAddressesError` whose message
|
|
90
|
+
* embeds the inner error's stack -- `reservation failed with status <STATUS>`
|
|
91
|
+
* from the reservation store, or `UnsupportedProtocolError: ...` from protocol
|
|
92
|
+
* negotiation -- and keeps no reference to the inner error itself. The
|
|
93
|
+
* patterns are pinned against a real hub by the conformance suite's
|
|
94
|
+
* `member-reservation-fallback.test.ts`.
|
|
95
|
+
*/
|
|
96
|
+
function classify(err) {
|
|
97
|
+
const text = err instanceof Error ? `${err.name}: ${err.message}\n${err.stack ?? ""}` : String(err);
|
|
98
|
+
const status = /reservation failed with status (\w+)/.exec(text)?.[1] ?? null;
|
|
99
|
+
switch (status) {
|
|
100
|
+
case "RESERVATION_REFUSED":
|
|
101
|
+
return { status, refusal: "store-full" };
|
|
102
|
+
case "RESOURCE_LIMIT_EXCEEDED":
|
|
103
|
+
return { status, refusal: "resource-limit" };
|
|
104
|
+
case "PERMISSION_DENIED":
|
|
105
|
+
return { status, refusal: "not-a-member" };
|
|
106
|
+
case null:
|
|
107
|
+
return {
|
|
108
|
+
status,
|
|
109
|
+
refusal: /UnsupportedProtocolError|could not negotiate/.test(text)
|
|
110
|
+
? "no-relay"
|
|
111
|
+
: "no-answer",
|
|
112
|
+
};
|
|
113
|
+
default:
|
|
114
|
+
return { status, refusal: "other" };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
function describeRefusal(hubPeerId, status, refusal, cause) {
|
|
118
|
+
const hub = `hub-link: the hub (${hubPeerId})`;
|
|
119
|
+
switch (refusal) {
|
|
120
|
+
case "store-full":
|
|
121
|
+
return (`${hub} refused a reservation with RESERVATION_REFUSED: the hub's reservation store is ` +
|
|
122
|
+
"full (it grants a fixed number, each held until it expires). Nothing is wrong with this " +
|
|
123
|
+
"peer; the hub has no slot for it.");
|
|
124
|
+
case "resource-limit":
|
|
125
|
+
return `${hub} refused a reservation with RESOURCE_LIMIT_EXCEEDED: the hub's relay is at a resource limit.`;
|
|
126
|
+
case "not-a-member":
|
|
127
|
+
return (`${hub} refused a reservation with PERMISSION_DENIED: this peer is not a member as far ` +
|
|
128
|
+
"as the hub is concerned (a hub grants reservations to its members only).");
|
|
129
|
+
case "no-relay":
|
|
130
|
+
return `${hub} does not relay at all -- it has no circuit-relay service (UnsupportedProtocolError).`;
|
|
131
|
+
case "no-answer":
|
|
132
|
+
return `${hub} gave no answer to a reservation request. Cause: ${firstLine(cause)}`;
|
|
133
|
+
case "other":
|
|
134
|
+
return `${hub} refused a reservation with ${status}.`;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
/** The inner error libp2p quotes, or the error itself -- without the stack. */
|
|
138
|
+
function firstLine(err) {
|
|
139
|
+
const text = err instanceof Error ? err.message : String(err);
|
|
140
|
+
const lines = text.split("\n").map((line) => line.trim());
|
|
141
|
+
// libp2p's wrapper: a header line, then `<addr>: <inner error>` per address.
|
|
142
|
+
const inner = lines.find((line) => line.startsWith("/") && line.includes(": "));
|
|
143
|
+
return inner != null ? inner.slice(inner.indexOf(": ") + 2) : (lines[0] ?? "");
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Reserve on the hub, over the WebRTC connection `reachHub` left open.
|
|
147
|
+
* Returns the reserved address. Only a member is granted one -- call this
|
|
148
|
+
* after the hub has accepted this peer, not before.
|
|
149
|
+
*
|
|
150
|
+
* A REFUSAL IS A `HubReservationError` that names the relay's own status --
|
|
151
|
+
* see `HubReservationRefusal`. libp2p reports every refusal alike, as "Some
|
|
152
|
+
* configured addresses failed to be listened on"; a full store and a
|
|
153
|
+
* non-member used to read the same, and in production a full store was
|
|
154
|
+
* diagnosed as a membership problem.
|
|
155
|
+
*
|
|
156
|
+
* A CONFIGURED RELAY, NOT A DISCOVERED ONE: libp2p will not restore it on
|
|
157
|
+
* its own if the link to the hub drops, which is `superviseHubReservation`'s
|
|
158
|
+
* job, below. Each call adds a listener; after a lost link -- or a refused
|
|
159
|
+
* reservation -- the old one sits empty. That is one small object per
|
|
160
|
+
* attempt, accepted rather than reaching into libp2p's internals to reuse it.
|
|
161
|
+
*/
|
|
162
|
+
export async function reserveOnHub(node, hubPeerId) {
|
|
163
|
+
try {
|
|
164
|
+
await transportManagerOf(node).listen([multiaddr(`/p2p/${hubPeerId}/p2p-circuit`)]);
|
|
165
|
+
}
|
|
166
|
+
catch (err) {
|
|
167
|
+
const { status, refusal } = classify(err);
|
|
168
|
+
throw new HubReservationError(hubPeerId, status, refusal, { cause: err });
|
|
169
|
+
}
|
|
170
|
+
const reserved = node
|
|
171
|
+
.getMultiaddrs()
|
|
172
|
+
.map((addr) => addr.toString())
|
|
173
|
+
.find((addr) => addr.includes(`/p2p/${hubPeerId}/p2p-circuit`));
|
|
174
|
+
if (reserved == null) {
|
|
175
|
+
throw new Error(`hub-link: listening on the hub (${hubPeerId}) produced no reserved address`);
|
|
176
|
+
}
|
|
177
|
+
return reserved;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Close this member's connection to the public relay, once its hub link is
|
|
181
|
+
* up. A member holds no reservation there any more, and other members reach
|
|
182
|
+
* it through the hub, so the socket would only spend the relay's capacity.
|
|
183
|
+
* It is reopened on demand: `reachHub` dials through the relay whenever the
|
|
184
|
+
* hub has to be reached again.
|
|
185
|
+
*/
|
|
186
|
+
export async function leaveRelay(node, relayAddr) {
|
|
187
|
+
const relayPeerId = lastPeerIdOf(multiaddr(relayAddr));
|
|
188
|
+
if (relayPeerId != null)
|
|
189
|
+
await node.hangUp(peerIdFromString(relayPeerId));
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Keep this member's reservation on its hub: whenever it is lost, reach the
|
|
193
|
+
* hub again and re-reserve, with `superviseRelay`'s backoff and `poke()`.
|
|
194
|
+
* Call once the first `reserveOnHub` has succeeded.
|
|
195
|
+
*/
|
|
196
|
+
export function superviseHubReservation(init) {
|
|
197
|
+
const { node, relayAddr, hubPeerId } = init;
|
|
198
|
+
return superviseRelay({
|
|
199
|
+
node,
|
|
200
|
+
relayAddr: `/p2p/${hubPeerId}`,
|
|
201
|
+
minRetryDelayMs: init.minRetryDelayMs,
|
|
202
|
+
maxRetryDelayMs: init.maxRetryDelayMs,
|
|
203
|
+
restore: async () => {
|
|
204
|
+
await reachHub(node, relayAddr, hubPeerId);
|
|
205
|
+
await reserveOnHub(node, hubPeerId);
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* The node's transport manager -- the only way to add a listen address after
|
|
211
|
+
* start. `components` is a public field of the libp2p 3.x node class but not
|
|
212
|
+
* part of the `Libp2p` interface, hence the narrow cast; pinned by
|
|
213
|
+
* `tests/e2e/hub-relay.test.ts`, which fails if a libp2p upgrade moves it.
|
|
214
|
+
*/
|
|
215
|
+
function transportManagerOf(node) {
|
|
216
|
+
return node.components.transportManager;
|
|
217
|
+
}
|