@statewalker/webrun-streams-libp2p 0.1.1
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 +82 -0
- package/dist/connect-serve.d.ts +96 -0
- package/dist/connect-serve.d.ts.map +1 -0
- package/dist/duplex-over-stream.d.ts +68 -0
- package/dist/duplex-over-stream.d.ts.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +405 -0
- package/package.json +61 -0
- package/src/connect-serve.ts +309 -0
- package/src/duplex-over-stream.ts +367 -0
- package/src/index.ts +15 -0
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Connection,
|
|
3
|
+
DialProtocolOptions,
|
|
4
|
+
Libp2p,
|
|
5
|
+
PeerId,
|
|
6
|
+
Stream,
|
|
7
|
+
StreamHandlerOptions,
|
|
8
|
+
} from "@libp2p/interface";
|
|
9
|
+
import type { Multiaddr } from "@multiformats/multiaddr";
|
|
10
|
+
import type { Connect, Duplex, Serve } from "@statewalker/webrun-streams";
|
|
11
|
+
import { closeStream, duplexOverStream } from "./duplex-over-stream.js";
|
|
12
|
+
|
|
13
|
+
export const DEFAULT_PROTOCOL = "/webrun-streams/1.0.0";
|
|
14
|
+
|
|
15
|
+
export interface ConnectLibp2pParams {
|
|
16
|
+
node: Libp2p;
|
|
17
|
+
peer: PeerId | Multiaddr;
|
|
18
|
+
/** libp2p protocol id; defaults to `/webrun-streams/1.0.0`. */
|
|
19
|
+
protocol?: string;
|
|
20
|
+
/**
|
|
21
|
+
* How long to wait for a backpressured stream to drain before dropping the
|
|
22
|
+
* peer; defaults to `DEFAULT_DRAIN_TIMEOUT_MS` (5 minutes).
|
|
23
|
+
*/
|
|
24
|
+
drainTimeoutMs?: number;
|
|
25
|
+
/**
|
|
26
|
+
* How many outgoing streams for this protocol libp2p allows to be open at
|
|
27
|
+
* the same time on one connection; passed through to `node.dialProtocol`.
|
|
28
|
+
* Left unset, libp2p's own default (64) applies.
|
|
29
|
+
*/
|
|
30
|
+
maxOutboundStreams?: number;
|
|
31
|
+
/**
|
|
32
|
+
* Opt-in to dialing over a connection with limits on how much data can be
|
|
33
|
+
* transferred or how long it can be open for (e.g. a relayed circuit);
|
|
34
|
+
* passed through to `node.dialProtocol`. Left unset, libp2p refuses to open
|
|
35
|
+
* this protocol's stream on such a connection — this package does not
|
|
36
|
+
* decide that trade-off for the caller, it only exposes the knob.
|
|
37
|
+
*/
|
|
38
|
+
runOnLimitedConnection?: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ServeLibp2pParams {
|
|
42
|
+
node: Libp2p;
|
|
43
|
+
/** libp2p protocol id; defaults to `/webrun-streams/1.0.0`. */
|
|
44
|
+
protocol?: string;
|
|
45
|
+
/**
|
|
46
|
+
* How long to wait for a backpressured stream to drain before dropping the
|
|
47
|
+
* peer; defaults to `DEFAULT_DRAIN_TIMEOUT_MS` (5 minutes). This is the only
|
|
48
|
+
* bound on a peer that requests something and then stops reading without
|
|
49
|
+
* closing, since the caller-side `.return`/abort escape does not exist here.
|
|
50
|
+
*/
|
|
51
|
+
drainTimeoutMs?: number;
|
|
52
|
+
/**
|
|
53
|
+
* How many incoming streams for this protocol libp2p allows to be open at
|
|
54
|
+
* the same time on one connection; passed through to `node.handle`. Left
|
|
55
|
+
* unset, libp2p's own default (32) applies — past it, a new inbound stream
|
|
56
|
+
* is reset rather than queued, so a caller that opens one stream per
|
|
57
|
+
* in-flight request will start seeing rejected calls above that count.
|
|
58
|
+
*/
|
|
59
|
+
maxInboundStreams?: number;
|
|
60
|
+
/**
|
|
61
|
+
* How many outgoing streams for this protocol libp2p allows to be open at
|
|
62
|
+
* the same time on one connection; passed through to `node.handle`. Left
|
|
63
|
+
* unset, libp2p's own default (64) applies.
|
|
64
|
+
*/
|
|
65
|
+
maxOutboundStreams?: number;
|
|
66
|
+
/**
|
|
67
|
+
* Opt-in to accepting streams for this protocol over a connection with
|
|
68
|
+
* limits on how much data can be transferred or how long it can be open
|
|
69
|
+
* for (e.g. a relayed circuit); passed through to `node.handle`. Left
|
|
70
|
+
* unset, libp2p refuses to open this protocol's stream on such a
|
|
71
|
+
* connection — this package does not decide that trade-off for the
|
|
72
|
+
* consumer, it only exposes the knob.
|
|
73
|
+
*/
|
|
74
|
+
runOnLimitedConnection?: boolean;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Builds the options object passed to `node.dialProtocol`/`node.handle`,
|
|
79
|
+
* omitting any field the caller didn't supply rather than setting it to
|
|
80
|
+
* `undefined` — so an unset field falls back to libp2p's own default instead
|
|
81
|
+
* of an explicit `undefined` overriding it.
|
|
82
|
+
*/
|
|
83
|
+
function definedOptions<T extends Record<string, unknown>>(fields: T): Partial<T> | undefined {
|
|
84
|
+
const options: Partial<T> = {};
|
|
85
|
+
let any = false;
|
|
86
|
+
for (const key of Object.keys(fields) as (keyof T)[]) {
|
|
87
|
+
const value = fields[key];
|
|
88
|
+
if (value !== undefined) {
|
|
89
|
+
options[key] = value;
|
|
90
|
+
any = true;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return any ? options : undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Caller-side: each `call(input)` opens a new libp2p `Stream` via
|
|
98
|
+
* `node.dialProtocol(peer, [protocol])` and runs the call over it.
|
|
99
|
+
*/
|
|
100
|
+
export const connect: Connect<ConnectLibp2pParams> = async ({
|
|
101
|
+
node,
|
|
102
|
+
peer,
|
|
103
|
+
protocol,
|
|
104
|
+
drainTimeoutMs,
|
|
105
|
+
maxOutboundStreams,
|
|
106
|
+
runOnLimitedConnection,
|
|
107
|
+
}) => {
|
|
108
|
+
const proto = protocol ?? DEFAULT_PROTOCOL;
|
|
109
|
+
const dialOptions: DialProtocolOptions | undefined = definedOptions({
|
|
110
|
+
maxOutboundStreams,
|
|
111
|
+
runOnLimitedConnection,
|
|
112
|
+
});
|
|
113
|
+
const open = new Set<Stream>();
|
|
114
|
+
const call: Duplex = (input) => {
|
|
115
|
+
let streamRef: Stream | null = null;
|
|
116
|
+
const gen = (async function* () {
|
|
117
|
+
const stream = await node.dialProtocol(peer, [proto], dialOptions);
|
|
118
|
+
streamRef = stream;
|
|
119
|
+
open.add(stream);
|
|
120
|
+
let sourceCompleted = false;
|
|
121
|
+
try {
|
|
122
|
+
yield* duplexOverStream(stream, input, {
|
|
123
|
+
drainTimeoutMs,
|
|
124
|
+
onSourceCompleted: () => {
|
|
125
|
+
sourceCompleted = true;
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
} finally {
|
|
129
|
+
open.delete(stream);
|
|
130
|
+
if (sourceCompleted) {
|
|
131
|
+
// Natural end on both sides. Graceful close, bounded so a peer
|
|
132
|
+
// that stops reading without resetting can't hang this forever.
|
|
133
|
+
await closeStream(stream);
|
|
134
|
+
}
|
|
135
|
+
// Else: consumer cancelled. The .return override below has already
|
|
136
|
+
// called stream.abort to send RST; nothing more to do here.
|
|
137
|
+
}
|
|
138
|
+
})();
|
|
139
|
+
|
|
140
|
+
// Send a yamux RST to peer when the consumer cancels (i.e., calls .return
|
|
141
|
+
// on this generator). Doing it here is essential because by the time the
|
|
142
|
+
// generator's own finally runs, the for-await teardown chain has already
|
|
143
|
+
// marked the stream's status as `closed` — and AbstractStream.abort is a
|
|
144
|
+
// no-op on closed streams.
|
|
145
|
+
const origReturn = gen.return.bind(gen);
|
|
146
|
+
gen.return = async (value: unknown) => {
|
|
147
|
+
if (streamRef) {
|
|
148
|
+
try {
|
|
149
|
+
streamRef.abort(new Error("call cancelled"));
|
|
150
|
+
} catch {
|
|
151
|
+
/* ignore */
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return origReturn(value as undefined);
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
return gen;
|
|
158
|
+
};
|
|
159
|
+
return {
|
|
160
|
+
call,
|
|
161
|
+
async close() {
|
|
162
|
+
for (const s of open) {
|
|
163
|
+
try {
|
|
164
|
+
s.abort(new Error("connection close"));
|
|
165
|
+
} catch {
|
|
166
|
+
/* ignore */
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Server-side: registers `node.handle(protocol, ...)`. Each inbound stream is
|
|
175
|
+
* wrapped as a `Duplex` and handed to `handler`. Identity-unaware; use
|
|
176
|
+
* `serveConnections` when the handler needs to know who is calling.
|
|
177
|
+
*/
|
|
178
|
+
export const serve: Serve<ServeLibp2pParams> = async (params, handler: Duplex) =>
|
|
179
|
+
serveConnections(params, () => handler);
|
|
180
|
+
|
|
181
|
+
/** What the serving side knows about the connection a stream arrived on. */
|
|
182
|
+
export interface ConnectionContext {
|
|
183
|
+
/**
|
|
184
|
+
* The peer id libp2p's Noise handshake proved for this connection. This is
|
|
185
|
+
* the only identity claim on the serving side that cannot be forged by the
|
|
186
|
+
* request payload.
|
|
187
|
+
*/
|
|
188
|
+
remotePeer: PeerId;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Builds a handler for one inbound connection. Called once per stream, so the
|
|
193
|
+
* connection stays reachable in the returned Duplex's closure — `Duplex` is
|
|
194
|
+
* bytes-only (ADR-0004) and gains no new parameter.
|
|
195
|
+
*/
|
|
196
|
+
export type ServeConnectionsHandler = (context: ConnectionContext) => Duplex;
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Like `serve`, but the handler is built per inbound stream and is told which
|
|
200
|
+
* peer libp2p proved on that connection.
|
|
201
|
+
*/
|
|
202
|
+
export async function serveConnections(
|
|
203
|
+
{
|
|
204
|
+
node,
|
|
205
|
+
protocol,
|
|
206
|
+
drainTimeoutMs,
|
|
207
|
+
maxInboundStreams,
|
|
208
|
+
maxOutboundStreams,
|
|
209
|
+
runOnLimitedConnection,
|
|
210
|
+
}: ServeLibp2pParams,
|
|
211
|
+
makeHandler: ServeConnectionsHandler,
|
|
212
|
+
): Promise<() => Promise<void>> {
|
|
213
|
+
const proto = protocol ?? DEFAULT_PROTOCOL;
|
|
214
|
+
const handleOptions: StreamHandlerOptions | undefined = definedOptions({
|
|
215
|
+
maxInboundStreams,
|
|
216
|
+
maxOutboundStreams,
|
|
217
|
+
runOnLimitedConnection,
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
const onStream = (stream: Stream, connection: Connection): void => {
|
|
221
|
+
void (async () => {
|
|
222
|
+
const handler = makeHandler({ remotePeer: connection.remotePeer });
|
|
223
|
+
const inputQueue = makeInputQueue();
|
|
224
|
+
const output = handler(inputQueue.iter());
|
|
225
|
+
try {
|
|
226
|
+
for await (const chunk of duplexOverStream(stream, output, {
|
|
227
|
+
drainTimeoutMs,
|
|
228
|
+
onPeerInputEnd: (err) => inputQueue.done(err),
|
|
229
|
+
})) {
|
|
230
|
+
inputQueue.push(chunk);
|
|
231
|
+
}
|
|
232
|
+
} finally {
|
|
233
|
+
inputQueue.done();
|
|
234
|
+
await closeStream(stream);
|
|
235
|
+
}
|
|
236
|
+
})().catch((err: unknown) => {
|
|
237
|
+
// One inbound stream failing must not take down the process serving
|
|
238
|
+
// every other peer. `duplexOverStream` rejects on the read side
|
|
239
|
+
// whenever the peer sends an ERROR frame or resets mid-request — which
|
|
240
|
+
// happens in ordinary use, not just under attack (a browser tab closed
|
|
241
|
+
// mid-request resets its streams). Without this catch that rejection is
|
|
242
|
+
// unhandled, and Node's default for `unhandledRejection` is to
|
|
243
|
+
// terminate. Logged rather than rethrown, and never rethrown from here:
|
|
244
|
+
// there is no caller left to receive it.
|
|
245
|
+
const e = err instanceof Error ? err : new Error(String(err));
|
|
246
|
+
console.warn(
|
|
247
|
+
`[webrun-streams-libp2p] serve: inbound stream on protocol ${proto} failed: ${e.message}`,
|
|
248
|
+
e,
|
|
249
|
+
);
|
|
250
|
+
});
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
await node.handle(proto, onStream, handleOptions);
|
|
254
|
+
|
|
255
|
+
let torn = false;
|
|
256
|
+
return async () => {
|
|
257
|
+
if (torn) return;
|
|
258
|
+
torn = true;
|
|
259
|
+
await node.unhandle(proto);
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
interface InputQueue {
|
|
264
|
+
iter(): AsyncGenerator<Uint8Array>;
|
|
265
|
+
push(chunk: Uint8Array): void;
|
|
266
|
+
done(err?: Error): void;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function makeInputQueue(): InputQueue {
|
|
270
|
+
type Slot = { type: "value"; value: Uint8Array } | { type: "done"; err?: Error };
|
|
271
|
+
const slots: Slot[] = [];
|
|
272
|
+
let wake: (() => void) | null = null;
|
|
273
|
+
let closed = false;
|
|
274
|
+
return {
|
|
275
|
+
iter(): AsyncGenerator<Uint8Array> {
|
|
276
|
+
return (async function* () {
|
|
277
|
+
try {
|
|
278
|
+
while (true) {
|
|
279
|
+
if (slots.length === 0) {
|
|
280
|
+
await new Promise<void>((r) => {
|
|
281
|
+
wake = r;
|
|
282
|
+
});
|
|
283
|
+
wake = null;
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
const s = slots.shift() as Slot;
|
|
287
|
+
if (s.type === "done") {
|
|
288
|
+
if (s.err) throw s.err;
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
yield s.value;
|
|
292
|
+
}
|
|
293
|
+
} finally {
|
|
294
|
+
closed = true;
|
|
295
|
+
}
|
|
296
|
+
})();
|
|
297
|
+
},
|
|
298
|
+
push(chunk: Uint8Array): void {
|
|
299
|
+
if (closed) return;
|
|
300
|
+
slots.push({ type: "value", value: chunk });
|
|
301
|
+
wake?.();
|
|
302
|
+
},
|
|
303
|
+
done(err?: Error): void {
|
|
304
|
+
if (closed) return;
|
|
305
|
+
slots.push({ type: "done", err });
|
|
306
|
+
wake?.();
|
|
307
|
+
},
|
|
308
|
+
};
|
|
309
|
+
}
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
import type { Stream, StreamCloseEvent } from "@libp2p/interface";
|
|
2
|
+
import { deserializeError, serializeError } from "@statewalker/webrun-streams";
|
|
3
|
+
|
|
4
|
+
const TYPE_DATA = 0x00;
|
|
5
|
+
const TYPE_ERROR = 0x02;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Default bound for {@link closeStream}'s wait for a graceful close. Matches
|
|
9
|
+
* 2.x's own default (`DEFAULT_SEND_CLOSE_WRITE_TIMEOUT`,
|
|
10
|
+
* `@libp2p/utils@6.7.2/dist/src/abstract-stream.js:7`) — this restores that
|
|
11
|
+
* bound rather than inventing a new number.
|
|
12
|
+
*/
|
|
13
|
+
const DEFAULT_CLOSE_TIMEOUT_MS = 5000;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Default bound for {@link waitForDrain}'s wait for the peer to make room in
|
|
17
|
+
* its receive window. Without a bound, a peer that requests something and then
|
|
18
|
+
* simply stops reading — alive, so no `close` event ever fires — parks the
|
|
19
|
+
* serving side's outbound pump forever, holding the stream, the handler and
|
|
20
|
+
* whatever the handler buffered. On the serving side there is no escape hatch:
|
|
21
|
+
* `connect`'s `.return`/abort override is a *caller*-side affordance.
|
|
22
|
+
*
|
|
23
|
+
* The number is deliberately generous, because a bound that is too tight
|
|
24
|
+
* resets a slow-but-alive peer mid-transfer — exactly what backpressure exists
|
|
25
|
+
* to avoid. Five minutes covers a peer draining a full yamux receive window
|
|
26
|
+
* (256 KiB by default) at under 1 KiB/s, i.e. slower than any link on which
|
|
27
|
+
* the transfer would complete anyway; a peer slower than that is
|
|
28
|
+
* indistinguishable from one that has stopped reading altogether. Override via
|
|
29
|
+
* `drainTimeoutMs` when a deployment knows better.
|
|
30
|
+
*/
|
|
31
|
+
export const DEFAULT_DRAIN_TIMEOUT_MS = 300_000;
|
|
32
|
+
|
|
33
|
+
const textEncoder = new TextEncoder();
|
|
34
|
+
const textDecoder = new TextDecoder();
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Options for {@link duplexOverStream}. The `onPeerInputEnd` hook is the seam
|
|
38
|
+
* that lets the server side close its input queue as soon as the peer's source
|
|
39
|
+
* exhausts — without it, the server-side `serve` would deadlock waiting for the
|
|
40
|
+
* outbound pump to finish, which itself waits for the handler, which waits for
|
|
41
|
+
* inputQueue.done.
|
|
42
|
+
*/
|
|
43
|
+
export interface DuplexOverStreamOptions {
|
|
44
|
+
/**
|
|
45
|
+
* Fired when the peer's source ends (peer closed write, sent an ERROR frame,
|
|
46
|
+
* or the stream itself was torn down). Idempotent. The optional `err`
|
|
47
|
+
* argument carries the deserialized error from an ERROR frame, if any.
|
|
48
|
+
*/
|
|
49
|
+
onPeerInputEnd?(err?: Error): void;
|
|
50
|
+
/**
|
|
51
|
+
* Fired only when the peer's source ended naturally — i.e., consumer did not
|
|
52
|
+
* `.return()` mid-stream. Connect/serve uses this to decide whether to
|
|
53
|
+
* gracefully close vs forcibly abort the underlying stream on teardown.
|
|
54
|
+
*/
|
|
55
|
+
onSourceCompleted?(): void;
|
|
56
|
+
/**
|
|
57
|
+
* How long to wait for a backpressured stream to drain before giving up on
|
|
58
|
+
* the peer. Defaults to {@link DEFAULT_DRAIN_TIMEOUT_MS}.
|
|
59
|
+
*/
|
|
60
|
+
drainTimeoutMs?: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Drive one `Duplex` over one libp2p `Stream` using a small in-band framing
|
|
65
|
+
* protocol:
|
|
66
|
+
*
|
|
67
|
+
* [1-byte type][varint length][payload bytes]
|
|
68
|
+
*
|
|
69
|
+
* Types are `DATA` (0x00, body bytes) and `ERROR` (0x02, followed by a
|
|
70
|
+
* JSON-serialised `Error`). Normal end-of-input is signalled by libp2p's
|
|
71
|
+
* `close()`. The frame layer exists so we can preserve `Error` fidelity
|
|
72
|
+
* across the wire (yamux's native stream reset only carries "stream reset").
|
|
73
|
+
*/
|
|
74
|
+
export async function* duplexOverStream(
|
|
75
|
+
stream: Stream,
|
|
76
|
+
input: AsyncIterable<Uint8Array> | Iterable<Uint8Array>,
|
|
77
|
+
opts: DuplexOverStreamOptions = {},
|
|
78
|
+
): AsyncGenerator<Uint8Array> {
|
|
79
|
+
let peerEndedCalled = false;
|
|
80
|
+
const firePeerInputEnd = (err?: Error): void => {
|
|
81
|
+
if (peerEndedCalled) return;
|
|
82
|
+
peerEndedCalled = true;
|
|
83
|
+
opts.onPeerInputEnd?.(err);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const outboundSource = framedOutbound(input);
|
|
87
|
+
const outbound = (async () => {
|
|
88
|
+
try {
|
|
89
|
+
// libp2p 3.x streams are push-based (`send()` + drain) rather than
|
|
90
|
+
// the pull-based `sink(AsyncIterable)` of 2.x, so we pump the framed
|
|
91
|
+
// outbound generator by hand and honour backpressure by waiting for
|
|
92
|
+
// the stream's `'drain'` event whenever `send()` reports its buffer
|
|
93
|
+
// is full. We deliberately do NOT use `stream.onDrain()`: it
|
|
94
|
+
// memoises a single promise for the stream's whole lifetime and
|
|
95
|
+
// never clears it (`@libp2p/utils@7.3.2/dist/src/abstract-message-stream.js`),
|
|
96
|
+
// so past the first backpressure cycle it resolves instantly while
|
|
97
|
+
// the buffer is still full, defeating flow control entirely.
|
|
98
|
+
for await (const chunk of outboundSource) {
|
|
99
|
+
const canAcceptMore = stream.send(chunk);
|
|
100
|
+
if (!canAcceptMore) {
|
|
101
|
+
await waitForDrain(stream, opts.drainTimeoutMs ?? DEFAULT_DRAIN_TIMEOUT_MS);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
await closeStream(stream);
|
|
105
|
+
} catch (err) {
|
|
106
|
+
const e = err instanceof Error ? err : new Error(String(err));
|
|
107
|
+
try {
|
|
108
|
+
stream.abort(e);
|
|
109
|
+
} catch {
|
|
110
|
+
/* ignore */
|
|
111
|
+
}
|
|
112
|
+
} finally {
|
|
113
|
+
// Always return the framed-outbound generator so its `input` (often a
|
|
114
|
+
// handler async generator) sees `.return()` and runs its finally —
|
|
115
|
+
// otherwise an unbounded handler keeps running after the transport dies.
|
|
116
|
+
try {
|
|
117
|
+
await outboundSource.return?.(undefined);
|
|
118
|
+
} catch {
|
|
119
|
+
/* ignore */
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
})();
|
|
123
|
+
|
|
124
|
+
let sourceCompleted = false;
|
|
125
|
+
try {
|
|
126
|
+
// libp2p 3.x `Stream` is itself the readable `AsyncIterable` (it no
|
|
127
|
+
// longer exposes a separate `.source`).
|
|
128
|
+
// INVARIANT: nothing may `await` between acquiring `stream` and the first pull
|
|
129
|
+
// below. In libp2p 3.x end-of-inbound is an EVENT ('remoteCloseWrite'), and the
|
|
130
|
+
// async iterator only subscribes on its first next(). Buffered payload bytes are
|
|
131
|
+
// re-dispatched on subscribe, but a 'remoteCloseWrite' delivered before we
|
|
132
|
+
// subscribe is lost and this loop would never end. Every current path from
|
|
133
|
+
// dialProtocol/onStream to here is microtask-only, so a socket FIN cannot
|
|
134
|
+
// interleave. Inserting an await above would break that.
|
|
135
|
+
for await (const frame of parseFrames(stream)) {
|
|
136
|
+
if (frame.type === TYPE_DATA) {
|
|
137
|
+
yield frame.payload;
|
|
138
|
+
} else if (frame.type === TYPE_ERROR) {
|
|
139
|
+
const err = decodeError(frame.payload);
|
|
140
|
+
firePeerInputEnd(err);
|
|
141
|
+
throw err;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
sourceCompleted = true;
|
|
145
|
+
firePeerInputEnd();
|
|
146
|
+
opts.onSourceCompleted?.();
|
|
147
|
+
} finally {
|
|
148
|
+
firePeerInputEnd();
|
|
149
|
+
// If the consumer aborted before the source completed, force the outbound
|
|
150
|
+
// generator to return so `await outbound` doesn't hang on a still-pumping
|
|
151
|
+
// handler. On natural source completion we DO NOT cut outbound short —
|
|
152
|
+
// peer closing write doesn't entitle us to silence our own writes.
|
|
153
|
+
if (!sourceCompleted) {
|
|
154
|
+
try {
|
|
155
|
+
await outboundSource.return?.(undefined);
|
|
156
|
+
} catch {
|
|
157
|
+
/* ignore */
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
await outbound;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Wait for `stream` to signal it can accept more data, per the real
|
|
166
|
+
* `'drain'` event rather than the broken {@link Stream.onDrain}. Rejects if
|
|
167
|
+
* the stream closes first (including a remote reset) so a peer that goes
|
|
168
|
+
* away while we're backpressured unwinds into the caller's existing
|
|
169
|
+
* `catch` instead of hanging forever — and rejects after `timeoutMs` for the
|
|
170
|
+
* peer that neither reads nor closes, which produces no event at all. The
|
|
171
|
+
* expiry is loud (a `console.warn` naming the protocol and the bound) rather
|
|
172
|
+
* than a silent stall, per this project's "silent failures deserve loud
|
|
173
|
+
* guards" rule; the rejection itself reaches the outbound pump's `catch`,
|
|
174
|
+
* which aborts the stream so the peer learns it was dropped.
|
|
175
|
+
*/
|
|
176
|
+
function waitForDrain(stream: Stream, timeoutMs: number): Promise<void> {
|
|
177
|
+
return new Promise<void>((resolve, reject) => {
|
|
178
|
+
const cleanup = (): void => {
|
|
179
|
+
clearTimeout(timer);
|
|
180
|
+
stream.removeEventListener("drain", onDrain);
|
|
181
|
+
stream.removeEventListener("close", onClose);
|
|
182
|
+
};
|
|
183
|
+
const onDrain = (): void => {
|
|
184
|
+
cleanup();
|
|
185
|
+
resolve();
|
|
186
|
+
};
|
|
187
|
+
const onClose = (evt: StreamCloseEvent): void => {
|
|
188
|
+
cleanup();
|
|
189
|
+
reject(evt.error ?? new Error("stream closed"));
|
|
190
|
+
};
|
|
191
|
+
const timer = setTimeout(() => {
|
|
192
|
+
cleanup();
|
|
193
|
+
const message =
|
|
194
|
+
`[webrun-streams-libp2p] waitForDrain: peer on protocol ${stream.protocol} ` +
|
|
195
|
+
`did not accept more data within ${timeoutMs}ms and never closed the stream; ` +
|
|
196
|
+
`dropping it so this side's pump is not parked forever`;
|
|
197
|
+
console.warn(message);
|
|
198
|
+
reject(new Error(message));
|
|
199
|
+
}, timeoutMs);
|
|
200
|
+
stream.addEventListener("drain", onDrain);
|
|
201
|
+
stream.addEventListener("close", onClose);
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Close a `Stream`'s writable end, bounded by `timeoutMs`. Plain
|
|
207
|
+
* `stream.close()` awaits the write queue draining and the peer
|
|
208
|
+
* acknowledging with no bound of its own — a peer that stops reading
|
|
209
|
+
* without resetting (a suspended tab, a paused container, `SIGSTOP`) means
|
|
210
|
+
* it never settles, which would otherwise hang every caller waiting on it
|
|
211
|
+
* (the outbound pump here, and `connect`/`serve`'s teardown in
|
|
212
|
+
* `connect-serve.ts`). On timeout we fall back to a hard `abort()` so the
|
|
213
|
+
* caller is never left hanging.
|
|
214
|
+
*/
|
|
215
|
+
export async function closeStream(
|
|
216
|
+
stream: Stream,
|
|
217
|
+
timeoutMs: number = DEFAULT_CLOSE_TIMEOUT_MS,
|
|
218
|
+
): Promise<void> {
|
|
219
|
+
try {
|
|
220
|
+
await stream.close({ signal: AbortSignal.timeout(timeoutMs) });
|
|
221
|
+
} catch (err) {
|
|
222
|
+
const e = err instanceof Error ? err : new Error(String(err));
|
|
223
|
+
// The bound tripped (or close() otherwise failed): fall back to a hard
|
|
224
|
+
// abort so the caller is never left hanging. That abort sends a reset to
|
|
225
|
+
// the peer, silently truncating whatever was still in flight — this
|
|
226
|
+
// side must not report clean completion without a trace of that, per
|
|
227
|
+
// this project's own "silent failures deserve loud guards" rule. Not
|
|
228
|
+
// rethrown: closeStream is called from `finally` blocks, and throwing
|
|
229
|
+
// here would mask whatever error the caller was already unwinding from.
|
|
230
|
+
console.warn(
|
|
231
|
+
`[webrun-streams-libp2p] closeStream: graceful close of protocol ${stream.protocol} ` +
|
|
232
|
+
`failed (bound ${timeoutMs}ms), aborting instead (peer may see truncated data): ${e.message}`,
|
|
233
|
+
);
|
|
234
|
+
try {
|
|
235
|
+
stream.abort(e);
|
|
236
|
+
} catch {
|
|
237
|
+
/* ignore */
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function* framedOutbound(
|
|
243
|
+
input: AsyncIterable<Uint8Array> | Iterable<Uint8Array>,
|
|
244
|
+
): AsyncGenerator<Uint8Array> {
|
|
245
|
+
try {
|
|
246
|
+
for await (const chunk of toAsyncIterable(input)) {
|
|
247
|
+
yield frameData(chunk);
|
|
248
|
+
}
|
|
249
|
+
} catch (err) {
|
|
250
|
+
const e = err instanceof Error ? err : new Error(String(err));
|
|
251
|
+
yield frameError(e);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function* parseFrames(
|
|
256
|
+
source: AsyncIterable<unknown>,
|
|
257
|
+
): AsyncGenerator<{ type: number; payload: Uint8Array }> {
|
|
258
|
+
let buf = new Uint8Array(0);
|
|
259
|
+
for await (const item of source) {
|
|
260
|
+
const incoming = normalizeChunk(item);
|
|
261
|
+
if (incoming.byteLength === 0) continue;
|
|
262
|
+
if (buf.byteLength === 0) {
|
|
263
|
+
buf = new Uint8Array(incoming);
|
|
264
|
+
} else {
|
|
265
|
+
const merged = new Uint8Array(buf.byteLength + incoming.byteLength);
|
|
266
|
+
merged.set(buf, 0);
|
|
267
|
+
merged.set(incoming, buf.byteLength);
|
|
268
|
+
buf = merged;
|
|
269
|
+
}
|
|
270
|
+
while (buf.byteLength > 0) {
|
|
271
|
+
if (buf.byteLength < 2) break; // need at least type + 1 varint byte
|
|
272
|
+
const type = buf[0]!; // buf.byteLength >= 2, checked above, so index 0 exists
|
|
273
|
+
let lenInfo: { value: number; offset: number };
|
|
274
|
+
try {
|
|
275
|
+
lenInfo = decodeVarint(buf, 1);
|
|
276
|
+
} catch {
|
|
277
|
+
break; // varint truncated, need more bytes
|
|
278
|
+
}
|
|
279
|
+
const total = lenInfo.offset + lenInfo.value;
|
|
280
|
+
if (buf.byteLength < total) break;
|
|
281
|
+
const payload = new Uint8Array(buf.subarray(lenInfo.offset, total));
|
|
282
|
+
yield { type, payload };
|
|
283
|
+
buf = buf.byteLength === total ? new Uint8Array(0) : new Uint8Array(buf.subarray(total));
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function normalizeChunk(item: unknown): Uint8Array {
|
|
289
|
+
if (item instanceof Uint8Array) return item;
|
|
290
|
+
const asList = item as { subarray?: () => Uint8Array };
|
|
291
|
+
if (typeof asList.subarray === "function") {
|
|
292
|
+
return new Uint8Array(asList.subarray());
|
|
293
|
+
}
|
|
294
|
+
return new Uint8Array(0);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function frameData(payload: Uint8Array): Uint8Array {
|
|
298
|
+
const lenEnc = encodeVarint(payload.byteLength);
|
|
299
|
+
const out = new Uint8Array(1 + lenEnc.byteLength + payload.byteLength);
|
|
300
|
+
out[0] = TYPE_DATA;
|
|
301
|
+
out.set(lenEnc, 1);
|
|
302
|
+
out.set(payload, 1 + lenEnc.byteLength);
|
|
303
|
+
return out;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function frameError(err: Error): Uint8Array {
|
|
307
|
+
const payload = textEncoder.encode(JSON.stringify(serializeError(err)));
|
|
308
|
+
const lenEnc = encodeVarint(payload.byteLength);
|
|
309
|
+
const out = new Uint8Array(1 + lenEnc.byteLength + payload.byteLength);
|
|
310
|
+
out[0] = TYPE_ERROR;
|
|
311
|
+
out.set(lenEnc, 1);
|
|
312
|
+
out.set(payload, 1 + lenEnc.byteLength);
|
|
313
|
+
return out;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function decodeError(payload: Uint8Array): Error {
|
|
317
|
+
if (payload.byteLength === 0) return new Error("unknown stream error");
|
|
318
|
+
try {
|
|
319
|
+
return deserializeError(JSON.parse(textDecoder.decode(payload)));
|
|
320
|
+
} catch {
|
|
321
|
+
return new Error(textDecoder.decode(payload));
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function encodeVarint(value: number): Uint8Array {
|
|
326
|
+
if (!Number.isInteger(value) || value < 0) {
|
|
327
|
+
throw new RangeError(`encodeVarint: ${value} is not a non-negative integer`);
|
|
328
|
+
}
|
|
329
|
+
const out: number[] = [];
|
|
330
|
+
let v = value;
|
|
331
|
+
while (v >= 0x80) {
|
|
332
|
+
out.push((v & 0x7f) | 0x80);
|
|
333
|
+
v >>>= 7;
|
|
334
|
+
}
|
|
335
|
+
out.push(v & 0x7f);
|
|
336
|
+
return new Uint8Array(out);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function decodeVarint(buf: Uint8Array, start: number): { value: number; offset: number } {
|
|
340
|
+
let value = 0;
|
|
341
|
+
let shift = 0;
|
|
342
|
+
let i = start;
|
|
343
|
+
while (i < buf.length) {
|
|
344
|
+
const b = buf[i++]!; // i < buf.length, checked by the while condition, so this index exists
|
|
345
|
+
value |= (b & 0x7f) << shift;
|
|
346
|
+
if ((b & 0x80) === 0) return { value: value >>> 0, offset: i };
|
|
347
|
+
shift += 7;
|
|
348
|
+
if (shift > 28) throw new Error("decodeVarint: too long");
|
|
349
|
+
}
|
|
350
|
+
throw new Error("decodeVarint: truncated");
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function toAsyncIterable(
|
|
354
|
+
input: AsyncIterable<Uint8Array> | Iterable<Uint8Array>,
|
|
355
|
+
): AsyncIterable<Uint8Array> {
|
|
356
|
+
if ((input as AsyncIterable<Uint8Array>)[Symbol.asyncIterator]) {
|
|
357
|
+
return input as AsyncIterable<Uint8Array>;
|
|
358
|
+
}
|
|
359
|
+
const it = (input as Iterable<Uint8Array>)[Symbol.iterator]();
|
|
360
|
+
return {
|
|
361
|
+
[Symbol.asyncIterator]() {
|
|
362
|
+
return {
|
|
363
|
+
next: () => Promise.resolve(it.next()),
|
|
364
|
+
};
|
|
365
|
+
},
|
|
366
|
+
};
|
|
367
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export {
|
|
2
|
+
type ConnectionContext,
|
|
3
|
+
type ConnectLibp2pParams,
|
|
4
|
+
connect,
|
|
5
|
+
DEFAULT_PROTOCOL,
|
|
6
|
+
type ServeConnectionsHandler,
|
|
7
|
+
type ServeLibp2pParams,
|
|
8
|
+
serve,
|
|
9
|
+
serveConnections,
|
|
10
|
+
} from "./connect-serve.js";
|
|
11
|
+
export {
|
|
12
|
+
DEFAULT_DRAIN_TIMEOUT_MS,
|
|
13
|
+
type DuplexOverStreamOptions,
|
|
14
|
+
duplexOverStream,
|
|
15
|
+
} from "./duplex-over-stream.js";
|