@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.
@@ -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";