@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
package/src/duplex.ts
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 09 — the second altitude: duplex streams over the same mesh.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS. `Handler = (Request) => Promise<Response>` cannot express a
|
|
5
|
+
* WebSocket, an A2UI message stream, or anything else where both ends keep
|
|
6
|
+
* talking. It never could: `fetch` is not the foundation of this stack but a
|
|
7
|
+
* NARROWING of it. `@statewalker/webrun-http-streams` builds fetch semantics on
|
|
8
|
+
* a `Duplex`, and `httpeers.core`'s transport performs that narrowing at two
|
|
9
|
+
* call sites. So adding duplex REMOVES a restriction rather than adding a
|
|
10
|
+
* layer — webrun-wire's ADR-0004 calls `Duplex` "the canonical adapter seam".
|
|
11
|
+
*
|
|
12
|
+
* Duplex = (input: AsyncIterable<Uint8Array>) => AsyncGenerator<Uint8Array>
|
|
13
|
+
*
|
|
14
|
+
* THREE THINGS THIS FILE IS CAREFUL ABOUT:
|
|
15
|
+
*
|
|
16
|
+
* 1. IDENTITY IS AN ARGUMENT, NOT A LOOKUP. The fetch path carries the
|
|
17
|
+
* proven peer in a `WeakMap<Request, …>` written inside the per-stream
|
|
18
|
+
* closure. A duplex has no `Request` to key on, so `DuplexContext.peerId`
|
|
19
|
+
* is passed to the handler. `serveConnections` gives us the only claim
|
|
20
|
+
* that cannot be forged by the payload: `ConnectionContext.remotePeer`.
|
|
21
|
+
* 2. A SEPARATE PROTOCOL ID. `/httpeers-duplex/1.0.0` lives beside
|
|
22
|
+
* `/httpeers/1.0.0`, so the fetch path is untouched and cannot regress.
|
|
23
|
+
* 3. THE OPEN FRAME IS THE EXISTING ENVELOPE. `encodeMessage`/`decodeMessage`
|
|
24
|
+
* from webrun-http-streams — JSON line, then body bytes. Nothing new on
|
|
25
|
+
* the wire.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import type { Libp2p } from "@libp2p/interface";
|
|
29
|
+
import { peerIdFromString } from "@libp2p/peer-id";
|
|
30
|
+
import { decodeMessage, encodeMessage } from "@statewalker/webrun-http-streams";
|
|
31
|
+
import type { Duplex } from "@statewalker/webrun-streams";
|
|
32
|
+
import {
|
|
33
|
+
type ConnectionContext,
|
|
34
|
+
connect,
|
|
35
|
+
serveConnections,
|
|
36
|
+
} from "@statewalker/webrun-streams-libp2p";
|
|
37
|
+
|
|
38
|
+
export const DUPLEX_PROTOCOL = "/httpeers-duplex/1.0.0";
|
|
39
|
+
|
|
40
|
+
/** What a duplex handler is told. The peer is an argument because there is no Request to key a map on. */
|
|
41
|
+
export interface DuplexContext {
|
|
42
|
+
/** Proven by the Noise handshake. Never read from the payload. */
|
|
43
|
+
readonly peerId: string;
|
|
44
|
+
readonly path: string;
|
|
45
|
+
/** Whatever the caller put in the open frame. A CLAIM, never proof — see `peerId`. */
|
|
46
|
+
readonly token?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type DuplexHandler = (
|
|
50
|
+
input: AsyncIterable<Uint8Array>,
|
|
51
|
+
ctx: DuplexContext,
|
|
52
|
+
) => AsyncIterable<Uint8Array>;
|
|
53
|
+
|
|
54
|
+
export interface DuplexMounts {
|
|
55
|
+
/** Longest-prefix on segment boundaries, like the fetch router. */
|
|
56
|
+
provide(prefix: string, handler: DuplexHandler): () => void;
|
|
57
|
+
match(path: string): { handler: DuplexHandler; prefix: string } | null;
|
|
58
|
+
list(): string[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function createDuplexMounts(): DuplexMounts {
|
|
62
|
+
const table: { prefix: string; handler: DuplexHandler }[] = [];
|
|
63
|
+
const norm = (p: string): string => (p.endsWith("/") && p !== "/" ? p.slice(0, -1) : p);
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
provide(prefix, handler) {
|
|
67
|
+
const entry = { prefix: norm(prefix), handler };
|
|
68
|
+
table.push(entry);
|
|
69
|
+
table.sort((a, b) => b.prefix.length - a.prefix.length);
|
|
70
|
+
return () => {
|
|
71
|
+
const at = table.indexOf(entry);
|
|
72
|
+
if (at >= 0) table.splice(at, 1);
|
|
73
|
+
};
|
|
74
|
+
},
|
|
75
|
+
match(path) {
|
|
76
|
+
const target = norm(path);
|
|
77
|
+
const hit = table.find(
|
|
78
|
+
(m) => m.prefix === "/" || target === m.prefix || target.startsWith(`${m.prefix}/`),
|
|
79
|
+
);
|
|
80
|
+
return hit == null ? null : { handler: hit.handler, prefix: hit.prefix };
|
|
81
|
+
},
|
|
82
|
+
list: () => table.map((m) => m.prefix),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Thrown into the caller's stream when no mount claims the path. */
|
|
87
|
+
export const NO_MOUNT = "httpeers-duplex: no mount for ";
|
|
88
|
+
|
|
89
|
+
export interface ServeDuplexInit {
|
|
90
|
+
node: Libp2p;
|
|
91
|
+
mounts: DuplexMounts;
|
|
92
|
+
protocol?: string;
|
|
93
|
+
/** Left unset, libp2p's default (32) applies — the ceiling on concurrent inbound streams. */
|
|
94
|
+
maxInboundStreams?: number;
|
|
95
|
+
/** A relayed circuit is a limited connection; libp2p refuses this protocol on one unless told otherwise. */
|
|
96
|
+
runOnLimitedConnection?: boolean;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Register the duplex protocol. Returns the teardown `serveConnections` gives us. */
|
|
100
|
+
export async function serveDuplex(init: ServeDuplexInit): Promise<() => Promise<void>> {
|
|
101
|
+
return await serveConnections(
|
|
102
|
+
{
|
|
103
|
+
node: init.node,
|
|
104
|
+
protocol: init.protocol ?? DUPLEX_PROTOCOL,
|
|
105
|
+
...(init.maxInboundStreams == null ? {} : { maxInboundStreams: init.maxInboundStreams }),
|
|
106
|
+
...(init.runOnLimitedConnection == null
|
|
107
|
+
? {}
|
|
108
|
+
: { runOnLimitedConnection: init.runOnLimitedConnection }),
|
|
109
|
+
},
|
|
110
|
+
// Called ONCE PER INBOUND STREAM, which is what keeps the proven peer in
|
|
111
|
+
// the closure and out of the payload.
|
|
112
|
+
(context: ConnectionContext): Duplex => {
|
|
113
|
+
const peerId = context.remotePeer.toString();
|
|
114
|
+
return async function* (input): AsyncGenerator<Uint8Array> {
|
|
115
|
+
const { envelope, body } = await decodeMessage<{ path: string; token?: string }>(input);
|
|
116
|
+
const found = init.mounts.match(envelope.path);
|
|
117
|
+
if (found == null) throw new Error(`${NO_MOUNT}${envelope.path}`);
|
|
118
|
+
yield* found.handler(body, {
|
|
119
|
+
peerId,
|
|
120
|
+
path: envelope.path,
|
|
121
|
+
...(envelope.token == null ? {} : { token: envelope.token }),
|
|
122
|
+
});
|
|
123
|
+
};
|
|
124
|
+
},
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface OpenDuplexInit {
|
|
129
|
+
node: Libp2p;
|
|
130
|
+
peerId: string;
|
|
131
|
+
path: string;
|
|
132
|
+
token?: string;
|
|
133
|
+
protocol?: string;
|
|
134
|
+
runOnLimitedConnection?: boolean;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export interface PeerDuplex {
|
|
138
|
+
readonly peerId: string;
|
|
139
|
+
readonly path: string;
|
|
140
|
+
/** Feed it outbound bytes, iterate the inbound ones. */
|
|
141
|
+
readonly call: Duplex;
|
|
142
|
+
close(): Promise<void>;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Open a duplex stream to `(peerId, path)`.
|
|
147
|
+
*
|
|
148
|
+
* ADDRESSED BY A PAIR, NEVER BY A URL. A URL is the fetch layer's addressing
|
|
149
|
+
* scheme and a duplex stream has no request-line; reusing `peer://…` here
|
|
150
|
+
* would also re-open the URL-derived-peer hole the ghost's pin exists to
|
|
151
|
+
* close (`new URL("peer://a@b/x").host === "b"`, measured in rung 06).
|
|
152
|
+
*/
|
|
153
|
+
export async function openDuplex(init: OpenDuplexInit): Promise<PeerDuplex> {
|
|
154
|
+
const conn = await connect({
|
|
155
|
+
node: init.node,
|
|
156
|
+
peer: peerIdFromString(init.peerId),
|
|
157
|
+
protocol: init.protocol ?? DUPLEX_PROTOCOL,
|
|
158
|
+
...(init.runOnLimitedConnection == null
|
|
159
|
+
? {}
|
|
160
|
+
: { runOnLimitedConnection: init.runOnLimitedConnection }),
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
const call: Duplex = (input) =>
|
|
164
|
+
conn.call(
|
|
165
|
+
encodeMessage(
|
|
166
|
+
{ path: init.path, ...(init.token == null ? {} : { token: init.token }) },
|
|
167
|
+
input,
|
|
168
|
+
),
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
return { peerId: init.peerId, path: init.path, call, close: conn.close };
|
|
172
|
+
}
|
|
@@ -0,0 +1,295 @@
|
|
|
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
|
+
|
|
52
|
+
import { RELAY_V2_HOP_CODEC } from "@libp2p/circuit-relay-v2";
|
|
53
|
+
import type { Connection, Libp2p, Stream } from "@libp2p/interface";
|
|
54
|
+
import { peerIdFromString } from "@libp2p/peer-id";
|
|
55
|
+
import { pbStream } from "@libp2p/utils";
|
|
56
|
+
|
|
57
|
+
/** circuit-relay v2 `Status`, by number. The relay answers one of these. */
|
|
58
|
+
export const HOP_STATUS_NAMES: Readonly<Record<number, string>> = {
|
|
59
|
+
0: "UNUSED",
|
|
60
|
+
100: "OK",
|
|
61
|
+
200: "RESERVATION_REFUSED",
|
|
62
|
+
201: "RESOURCE_LIMIT_EXCEEDED",
|
|
63
|
+
202: "PERMISSION_DENIED",
|
|
64
|
+
203: "CONNECTION_FAILED",
|
|
65
|
+
204: "NO_RESERVATION",
|
|
66
|
+
400: "MALFORMED_MESSAGE",
|
|
67
|
+
401: "UNEXPECTED_MESSAGE",
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/** `Status.OK`. */
|
|
71
|
+
export const HOP_STATUS_OK = 100;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* `HopMessage{ type: RESERVE }` on the wire: field 1, varint, value 0.
|
|
75
|
+
*
|
|
76
|
+
* protobuf3 would normally omit a zero-valued field; protons generates
|
|
77
|
+
* `if (obj.type != null) { w.uint32(8); ... }` for this `optional` field, so
|
|
78
|
+
* the tag IS written and a relay that requires it (go-libp2p does) sees it.
|
|
79
|
+
* Constant, because every request this module sends is this one.
|
|
80
|
+
*/
|
|
81
|
+
const RESERVE_REQUEST = Uint8Array.from([0x08, 0x00]);
|
|
82
|
+
|
|
83
|
+
/** How long one RESERVE round trip may take, matching libp2p's own `DEFAULT_RESERVATION_COMPLETION_TIMEOUT`. */
|
|
84
|
+
export const HOP_RESERVE_TIMEOUT_MS = 5_000;
|
|
85
|
+
|
|
86
|
+
/** What the relay granted. `expiresAt` is `null` when the relay sent no reservation body. */
|
|
87
|
+
export interface RelayReservationGrant {
|
|
88
|
+
/** ms since the epoch, from the relay's own `Reservation.expire` (Unix seconds). */
|
|
89
|
+
expiresAt: number | null;
|
|
90
|
+
/** `expiresAt` minus now, at the moment the answer arrived. `null` when unknown. */
|
|
91
|
+
ttlMs: number | null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The relay answered, and the answer was not OK. `status` is its own `Status` number. */
|
|
95
|
+
export class RelayReservationRefusedError extends Error {
|
|
96
|
+
constructor(
|
|
97
|
+
readonly relayPeerId: string,
|
|
98
|
+
readonly status: number,
|
|
99
|
+
) {
|
|
100
|
+
super(
|
|
101
|
+
`hop: the relay (${relayPeerId}) refused a reservation with ` +
|
|
102
|
+
`${HOP_STATUS_NAMES[status] ?? `status ${status}`}`,
|
|
103
|
+
);
|
|
104
|
+
this.name = "RelayReservationRefusedError";
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** There is no connection this node could ask the relay over. Not a refusal: nothing was asked. */
|
|
109
|
+
export class RelayNotConnectedError extends Error {
|
|
110
|
+
constructor(readonly relayPeerId: string) {
|
|
111
|
+
super(
|
|
112
|
+
`hop: no open, unlimited connection to the relay (${relayPeerId}) to ask for a reservation over`,
|
|
113
|
+
);
|
|
114
|
+
this.name = "RelayNotConnectedError";
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The subset of `HopMessage` this module reads. Everything else is skipped. */
|
|
119
|
+
export interface HopAnswer {
|
|
120
|
+
/** `HopMessage.Type`: 0 RESERVE, 1 CONNECT, 2 STATUS. */
|
|
121
|
+
type?: number;
|
|
122
|
+
status?: number;
|
|
123
|
+
/** `Reservation.expire`, Unix SECONDS as the relay sent it. */
|
|
124
|
+
expire?: number;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Read the fields of a `HopMessage` this module needs, skipping the rest.
|
|
129
|
+
*
|
|
130
|
+
* Deliberately lenient about everything it does not use -- an unknown field,
|
|
131
|
+
* a voucher, a limit -- because a decoder that throws on a field a future
|
|
132
|
+
* relay adds would turn a healthy relay into a lost reservation.
|
|
133
|
+
*/
|
|
134
|
+
export function decodeHopAnswer(data: Uint8Array | { subarray(): Uint8Array }): HopAnswer {
|
|
135
|
+
const bytes = data instanceof Uint8Array ? data : data.subarray();
|
|
136
|
+
const answer: HopAnswer = {};
|
|
137
|
+
const reader = new Reader(bytes);
|
|
138
|
+
while (reader.hasMore()) {
|
|
139
|
+
const tag = reader.varint();
|
|
140
|
+
const field = tag >>> 3;
|
|
141
|
+
const wire = tag & 7;
|
|
142
|
+
if (field === 1 && wire === 0) answer.type = reader.varint();
|
|
143
|
+
else if (field === 5 && wire === 0) answer.status = reader.varint();
|
|
144
|
+
else if (field === 3 && wire === 2) {
|
|
145
|
+
const reservation = new Reader(reader.bytes());
|
|
146
|
+
while (reservation.hasMore()) {
|
|
147
|
+
const innerTag = reservation.varint();
|
|
148
|
+
if (innerTag >>> 3 === 1 && (innerTag & 7) === 0) answer.expire = reservation.varint();
|
|
149
|
+
else reservation.skip(innerTag & 7);
|
|
150
|
+
}
|
|
151
|
+
} else reader.skip(wire);
|
|
152
|
+
}
|
|
153
|
+
return answer;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Request (or renew) this node's reservation on `relayPeerId`, and return what
|
|
158
|
+
* the relay granted. Throws `RelayReservationRefusedError` on any non-OK
|
|
159
|
+
* status, `RelayNotConnectedError` when there is nothing to ask over, and
|
|
160
|
+
* whatever the stream threw otherwise. Every one of those means "not reserved
|
|
161
|
+
* as far as the relay is concerned", which is the only judgement the caller
|
|
162
|
+
* needs.
|
|
163
|
+
*/
|
|
164
|
+
export async function requestRelayReservation(
|
|
165
|
+
node: Libp2p,
|
|
166
|
+
relayPeerId: string,
|
|
167
|
+
options: { signal?: AbortSignal } = {},
|
|
168
|
+
): Promise<RelayReservationGrant> {
|
|
169
|
+
const connection = openConnectionTo(node, relayPeerId);
|
|
170
|
+
if (connection == null) throw new RelayNotConnectedError(relayPeerId);
|
|
171
|
+
|
|
172
|
+
const signal = options.signal ?? AbortSignal.timeout(HOP_RESERVE_TIMEOUT_MS);
|
|
173
|
+
const stream = await connection.newStream(RELAY_V2_HOP_CODEC, { signal });
|
|
174
|
+
let answer: HopAnswer;
|
|
175
|
+
try {
|
|
176
|
+
const hop = pbStream(framed(stream)).pb<HopAnswer>({
|
|
177
|
+
encode: () => RESERVE_REQUEST,
|
|
178
|
+
decode: decodeHopAnswer,
|
|
179
|
+
});
|
|
180
|
+
await hop.write({}, { signal });
|
|
181
|
+
answer = await hop.read({ signal });
|
|
182
|
+
} catch (error) {
|
|
183
|
+
stream.abort(error as Error);
|
|
184
|
+
throw error;
|
|
185
|
+
}
|
|
186
|
+
// Closing the write end is enough and is not awaited past its own timeout:
|
|
187
|
+
// the answer is already in hand, and a relay that is slow to acknowledge the
|
|
188
|
+
// close must not turn a granted reservation into a reported loss.
|
|
189
|
+
await stream.close({ signal }).catch(() => stream.abort(new Error("hop: close failed")));
|
|
190
|
+
|
|
191
|
+
if (answer.status !== HOP_STATUS_OK) {
|
|
192
|
+
throw new RelayReservationRefusedError(relayPeerId, answer.status ?? 0);
|
|
193
|
+
}
|
|
194
|
+
const expiresAt = answer.expire != null ? answer.expire * 1_000 : null;
|
|
195
|
+
return { expiresAt, ttlMs: expiresAt != null ? expiresAt - Date.now() : null };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* A ONE-VERSION TYPE GAP, NOT A RUNTIME ONE.
|
|
200
|
+
*
|
|
201
|
+
* This workspace pins `@libp2p/interface` at 3.2.5 everywhere, and
|
|
202
|
+
* `@libp2p/utils` requires `^3.3.0` — whose `MessageStream` grew a
|
|
203
|
+
* `readableEnded` property. `pbStream` therefore refuses a 3.2.5 `Stream` by
|
|
204
|
+
* type while accepting it perfectly at runtime: the property is implemented by
|
|
205
|
+
* `AbstractMessageStream`, which every real libp2p stream extends, and
|
|
206
|
+
* `stream-utils.js` never reads it in the first place (checked in the
|
|
207
|
+
* installed 7.4.1). The narrow cast is the honest statement of that; widening
|
|
208
|
+
* the pin across seven packages to satisfy one call site would be a much
|
|
209
|
+
* larger change for the same behaviour.
|
|
210
|
+
*/
|
|
211
|
+
function framed(stream: Stream): Parameters<typeof pbStream>[0] {
|
|
212
|
+
return stream as unknown as Parameters<typeof pbStream>[0];
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* A connection to the relay a reservation may be requested over: open, and not
|
|
217
|
+
* itself relayed.
|
|
218
|
+
*
|
|
219
|
+
* NOT RELAYED, because a relay refuses to reserve over a circuit
|
|
220
|
+
* (libp2p calls it `DoubleRelayError` on its own side), and a limited
|
|
221
|
+
* connection would in any case be spent on the attempt. `limits != null` is
|
|
222
|
+
* how libp2p marks one.
|
|
223
|
+
*/
|
|
224
|
+
function openConnectionTo(node: Libp2p, relayPeerId: string): Connection | undefined {
|
|
225
|
+
let peerId: ReturnType<typeof peerIdFromString>;
|
|
226
|
+
try {
|
|
227
|
+
peerId = peerIdFromString(relayPeerId);
|
|
228
|
+
} catch {
|
|
229
|
+
return undefined;
|
|
230
|
+
}
|
|
231
|
+
return node
|
|
232
|
+
.getConnections(peerId)
|
|
233
|
+
.find((connection) => connection.status === "open" && connection.limits == null);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** A protobuf reader for the handful of field types `HopMessage` uses. */
|
|
237
|
+
class Reader {
|
|
238
|
+
private pos = 0;
|
|
239
|
+
constructor(private readonly buf: Uint8Array) {}
|
|
240
|
+
|
|
241
|
+
hasMore(): boolean {
|
|
242
|
+
return this.pos < this.buf.length;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* An unsigned varint, as a `number`.
|
|
247
|
+
*
|
|
248
|
+
* Assembled with multiplication rather than `<<`, because `<<` is a 32-bit
|
|
249
|
+
* operation in JavaScript and `Reservation.expire` is a uint64 of Unix
|
|
250
|
+
* seconds -- a shift would wrap it into a negative number and turn a valid
|
|
251
|
+
* expiry into nonsense. Ten bytes is the maximum a uint64 can occupy.
|
|
252
|
+
*/
|
|
253
|
+
varint(): number {
|
|
254
|
+
let value = 0;
|
|
255
|
+
let shift = 1;
|
|
256
|
+
for (let i = 0; i < 10; i++) {
|
|
257
|
+
if (!this.hasMore()) throw new Error("hop: truncated varint");
|
|
258
|
+
const byte = this.buf[this.pos++];
|
|
259
|
+
value += (byte & 0x7f) * shift;
|
|
260
|
+
if ((byte & 0x80) === 0) return value;
|
|
261
|
+
shift *= 128;
|
|
262
|
+
}
|
|
263
|
+
throw new Error("hop: varint longer than 10 bytes");
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** A length-delimited field's payload. */
|
|
267
|
+
bytes(): Uint8Array {
|
|
268
|
+
const length = this.varint();
|
|
269
|
+
const end = this.pos + length;
|
|
270
|
+
if (end > this.buf.length) throw new Error("hop: truncated length-delimited field");
|
|
271
|
+
const slice = this.buf.subarray(this.pos, end);
|
|
272
|
+
this.pos = end;
|
|
273
|
+
return slice;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Step over a field of the given wire type. */
|
|
277
|
+
skip(wire: number): void {
|
|
278
|
+
switch (wire) {
|
|
279
|
+
case 0:
|
|
280
|
+
this.varint();
|
|
281
|
+
return;
|
|
282
|
+
case 1:
|
|
283
|
+
this.pos += 8;
|
|
284
|
+
return;
|
|
285
|
+
case 2:
|
|
286
|
+
this.bytes();
|
|
287
|
+
return;
|
|
288
|
+
case 5:
|
|
289
|
+
this.pos += 4;
|
|
290
|
+
return;
|
|
291
|
+
default:
|
|
292
|
+
throw new Error(`hop: unsupported protobuf wire type ${wire}`);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|