@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/dist/index.js ADDED
@@ -0,0 +1,405 @@
1
+ import { deserializeError, serializeError } from "@statewalker/webrun-streams";
2
+ //#region src/duplex-over-stream.ts
3
+ const TYPE_DATA = 0;
4
+ const TYPE_ERROR = 2;
5
+ /**
6
+ * Default bound for {@link closeStream}'s wait for a graceful close. Matches
7
+ * 2.x's own default (`DEFAULT_SEND_CLOSE_WRITE_TIMEOUT`,
8
+ * `@libp2p/utils@6.7.2/dist/src/abstract-stream.js:7`) — this restores that
9
+ * bound rather than inventing a new number.
10
+ */
11
+ const DEFAULT_CLOSE_TIMEOUT_MS = 5e3;
12
+ /**
13
+ * Default bound for {@link waitForDrain}'s wait for the peer to make room in
14
+ * its receive window. Without a bound, a peer that requests something and then
15
+ * simply stops reading — alive, so no `close` event ever fires — parks the
16
+ * serving side's outbound pump forever, holding the stream, the handler and
17
+ * whatever the handler buffered. On the serving side there is no escape hatch:
18
+ * `connect`'s `.return`/abort override is a *caller*-side affordance.
19
+ *
20
+ * The number is deliberately generous, because a bound that is too tight
21
+ * resets a slow-but-alive peer mid-transfer — exactly what backpressure exists
22
+ * to avoid. Five minutes covers a peer draining a full yamux receive window
23
+ * (256 KiB by default) at under 1 KiB/s, i.e. slower than any link on which
24
+ * the transfer would complete anyway; a peer slower than that is
25
+ * indistinguishable from one that has stopped reading altogether. Override via
26
+ * `drainTimeoutMs` when a deployment knows better.
27
+ */
28
+ const DEFAULT_DRAIN_TIMEOUT_MS = 3e5;
29
+ const textEncoder = new TextEncoder();
30
+ const textDecoder = new TextDecoder();
31
+ /**
32
+ * Drive one `Duplex` over one libp2p `Stream` using a small in-band framing
33
+ * protocol:
34
+ *
35
+ * [1-byte type][varint length][payload bytes]
36
+ *
37
+ * Types are `DATA` (0x00, body bytes) and `ERROR` (0x02, followed by a
38
+ * JSON-serialised `Error`). Normal end-of-input is signalled by libp2p's
39
+ * `close()`. The frame layer exists so we can preserve `Error` fidelity
40
+ * across the wire (yamux's native stream reset only carries "stream reset").
41
+ */
42
+ async function* duplexOverStream(stream, input, opts = {}) {
43
+ let peerEndedCalled = false;
44
+ const firePeerInputEnd = (err) => {
45
+ if (peerEndedCalled) return;
46
+ peerEndedCalled = true;
47
+ opts.onPeerInputEnd?.(err);
48
+ };
49
+ const outboundSource = framedOutbound(input);
50
+ const outbound = (async () => {
51
+ try {
52
+ for await (const chunk of outboundSource) if (!stream.send(chunk)) await waitForDrain(stream, opts.drainTimeoutMs ?? 3e5);
53
+ await closeStream(stream);
54
+ } catch (err) {
55
+ const e = err instanceof Error ? err : new Error(String(err));
56
+ try {
57
+ stream.abort(e);
58
+ } catch {}
59
+ } finally {
60
+ try {
61
+ await outboundSource.return?.(void 0);
62
+ } catch {}
63
+ }
64
+ })();
65
+ let sourceCompleted = false;
66
+ try {
67
+ for await (const frame of parseFrames(stream)) if (frame.type === TYPE_DATA) yield frame.payload;
68
+ else if (frame.type === TYPE_ERROR) {
69
+ const err = decodeError(frame.payload);
70
+ firePeerInputEnd(err);
71
+ throw err;
72
+ }
73
+ sourceCompleted = true;
74
+ firePeerInputEnd();
75
+ opts.onSourceCompleted?.();
76
+ } finally {
77
+ firePeerInputEnd();
78
+ if (!sourceCompleted) try {
79
+ await outboundSource.return?.(void 0);
80
+ } catch {}
81
+ await outbound;
82
+ }
83
+ }
84
+ /**
85
+ * Wait for `stream` to signal it can accept more data, per the real
86
+ * `'drain'` event rather than the broken {@link Stream.onDrain}. Rejects if
87
+ * the stream closes first (including a remote reset) so a peer that goes
88
+ * away while we're backpressured unwinds into the caller's existing
89
+ * `catch` instead of hanging forever — and rejects after `timeoutMs` for the
90
+ * peer that neither reads nor closes, which produces no event at all. The
91
+ * expiry is loud (a `console.warn` naming the protocol and the bound) rather
92
+ * than a silent stall, per this project's "silent failures deserve loud
93
+ * guards" rule; the rejection itself reaches the outbound pump's `catch`,
94
+ * which aborts the stream so the peer learns it was dropped.
95
+ */
96
+ function waitForDrain(stream, timeoutMs) {
97
+ return new Promise((resolve, reject) => {
98
+ const cleanup = () => {
99
+ clearTimeout(timer);
100
+ stream.removeEventListener("drain", onDrain);
101
+ stream.removeEventListener("close", onClose);
102
+ };
103
+ const onDrain = () => {
104
+ cleanup();
105
+ resolve();
106
+ };
107
+ const onClose = (evt) => {
108
+ cleanup();
109
+ reject(evt.error ?? /* @__PURE__ */ new Error("stream closed"));
110
+ };
111
+ const timer = setTimeout(() => {
112
+ cleanup();
113
+ const message = `[webrun-streams-libp2p] waitForDrain: peer on protocol ${stream.protocol} did not accept more data within ${timeoutMs}ms and never closed the stream; dropping it so this side's pump is not parked forever`;
114
+ console.warn(message);
115
+ reject(new Error(message));
116
+ }, timeoutMs);
117
+ stream.addEventListener("drain", onDrain);
118
+ stream.addEventListener("close", onClose);
119
+ });
120
+ }
121
+ /**
122
+ * Close a `Stream`'s writable end, bounded by `timeoutMs`. Plain
123
+ * `stream.close()` awaits the write queue draining and the peer
124
+ * acknowledging with no bound of its own — a peer that stops reading
125
+ * without resetting (a suspended tab, a paused container, `SIGSTOP`) means
126
+ * it never settles, which would otherwise hang every caller waiting on it
127
+ * (the outbound pump here, and `connect`/`serve`'s teardown in
128
+ * `connect-serve.ts`). On timeout we fall back to a hard `abort()` so the
129
+ * caller is never left hanging.
130
+ */
131
+ async function closeStream(stream, timeoutMs = DEFAULT_CLOSE_TIMEOUT_MS) {
132
+ try {
133
+ await stream.close({ signal: AbortSignal.timeout(timeoutMs) });
134
+ } catch (err) {
135
+ const e = err instanceof Error ? err : new Error(String(err));
136
+ console.warn(`[webrun-streams-libp2p] closeStream: graceful close of protocol ${stream.protocol} failed (bound ${timeoutMs}ms), aborting instead (peer may see truncated data): ${e.message}`);
137
+ try {
138
+ stream.abort(e);
139
+ } catch {}
140
+ }
141
+ }
142
+ async function* framedOutbound(input) {
143
+ try {
144
+ for await (const chunk of toAsyncIterable(input)) yield frameData(chunk);
145
+ } catch (err) {
146
+ yield frameError(err instanceof Error ? err : new Error(String(err)));
147
+ }
148
+ }
149
+ async function* parseFrames(source) {
150
+ let buf = /* @__PURE__ */ new Uint8Array(0);
151
+ for await (const item of source) {
152
+ const incoming = normalizeChunk(item);
153
+ if (incoming.byteLength === 0) continue;
154
+ if (buf.byteLength === 0) buf = new Uint8Array(incoming);
155
+ else {
156
+ const merged = new Uint8Array(buf.byteLength + incoming.byteLength);
157
+ merged.set(buf, 0);
158
+ merged.set(incoming, buf.byteLength);
159
+ buf = merged;
160
+ }
161
+ while (buf.byteLength > 0) {
162
+ if (buf.byteLength < 2) break;
163
+ const type = buf[0];
164
+ let lenInfo;
165
+ try {
166
+ lenInfo = decodeVarint(buf, 1);
167
+ } catch {
168
+ break;
169
+ }
170
+ const total = lenInfo.offset + lenInfo.value;
171
+ if (buf.byteLength < total) break;
172
+ yield {
173
+ type,
174
+ payload: new Uint8Array(buf.subarray(lenInfo.offset, total))
175
+ };
176
+ buf = buf.byteLength === total ? /* @__PURE__ */ new Uint8Array(0) : new Uint8Array(buf.subarray(total));
177
+ }
178
+ }
179
+ }
180
+ function normalizeChunk(item) {
181
+ if (item instanceof Uint8Array) return item;
182
+ const asList = item;
183
+ if (typeof asList.subarray === "function") return new Uint8Array(asList.subarray());
184
+ return /* @__PURE__ */ new Uint8Array(0);
185
+ }
186
+ function frameData(payload) {
187
+ const lenEnc = encodeVarint(payload.byteLength);
188
+ const out = new Uint8Array(1 + lenEnc.byteLength + payload.byteLength);
189
+ out[0] = TYPE_DATA;
190
+ out.set(lenEnc, 1);
191
+ out.set(payload, 1 + lenEnc.byteLength);
192
+ return out;
193
+ }
194
+ function frameError(err) {
195
+ const payload = textEncoder.encode(JSON.stringify(serializeError(err)));
196
+ const lenEnc = encodeVarint(payload.byteLength);
197
+ const out = new Uint8Array(1 + lenEnc.byteLength + payload.byteLength);
198
+ out[0] = TYPE_ERROR;
199
+ out.set(lenEnc, 1);
200
+ out.set(payload, 1 + lenEnc.byteLength);
201
+ return out;
202
+ }
203
+ function decodeError(payload) {
204
+ if (payload.byteLength === 0) return /* @__PURE__ */ new Error("unknown stream error");
205
+ try {
206
+ return deserializeError(JSON.parse(textDecoder.decode(payload)));
207
+ } catch {
208
+ return new Error(textDecoder.decode(payload));
209
+ }
210
+ }
211
+ function encodeVarint(value) {
212
+ if (!Number.isInteger(value) || value < 0) throw new RangeError(`encodeVarint: ${value} is not a non-negative integer`);
213
+ const out = [];
214
+ let v = value;
215
+ while (v >= 128) {
216
+ out.push(v & 127 | 128);
217
+ v >>>= 7;
218
+ }
219
+ out.push(v & 127);
220
+ return new Uint8Array(out);
221
+ }
222
+ function decodeVarint(buf, start) {
223
+ let value = 0;
224
+ let shift = 0;
225
+ let i = start;
226
+ while (i < buf.length) {
227
+ const b = buf[i++];
228
+ value |= (b & 127) << shift;
229
+ if ((b & 128) === 0) return {
230
+ value: value >>> 0,
231
+ offset: i
232
+ };
233
+ shift += 7;
234
+ if (shift > 28) throw new Error("decodeVarint: too long");
235
+ }
236
+ throw new Error("decodeVarint: truncated");
237
+ }
238
+ function toAsyncIterable(input) {
239
+ if (input[Symbol.asyncIterator]) return input;
240
+ const it = input[Symbol.iterator]();
241
+ return { [Symbol.asyncIterator]() {
242
+ return { next: () => Promise.resolve(it.next()) };
243
+ } };
244
+ }
245
+ //#endregion
246
+ //#region src/connect-serve.ts
247
+ const DEFAULT_PROTOCOL = "/webrun-streams/1.0.0";
248
+ /**
249
+ * Builds the options object passed to `node.dialProtocol`/`node.handle`,
250
+ * omitting any field the caller didn't supply rather than setting it to
251
+ * `undefined` — so an unset field falls back to libp2p's own default instead
252
+ * of an explicit `undefined` overriding it.
253
+ */
254
+ function definedOptions(fields) {
255
+ const options = {};
256
+ let any = false;
257
+ for (const key of Object.keys(fields)) {
258
+ const value = fields[key];
259
+ if (value !== void 0) {
260
+ options[key] = value;
261
+ any = true;
262
+ }
263
+ }
264
+ return any ? options : void 0;
265
+ }
266
+ /**
267
+ * Caller-side: each `call(input)` opens a new libp2p `Stream` via
268
+ * `node.dialProtocol(peer, [protocol])` and runs the call over it.
269
+ */
270
+ const connect = async ({ node, peer, protocol, drainTimeoutMs, maxOutboundStreams, runOnLimitedConnection }) => {
271
+ const proto = protocol ?? "/webrun-streams/1.0.0";
272
+ const dialOptions = definedOptions({
273
+ maxOutboundStreams,
274
+ runOnLimitedConnection
275
+ });
276
+ const open = /* @__PURE__ */ new Set();
277
+ const call = (input) => {
278
+ let streamRef = null;
279
+ const gen = (async function* () {
280
+ const stream = await node.dialProtocol(peer, [proto], dialOptions);
281
+ streamRef = stream;
282
+ open.add(stream);
283
+ let sourceCompleted = false;
284
+ try {
285
+ yield* duplexOverStream(stream, input, {
286
+ drainTimeoutMs,
287
+ onSourceCompleted: () => {
288
+ sourceCompleted = true;
289
+ }
290
+ });
291
+ } finally {
292
+ open.delete(stream);
293
+ if (sourceCompleted) await closeStream(stream);
294
+ }
295
+ })();
296
+ const origReturn = gen.return.bind(gen);
297
+ gen.return = async (value) => {
298
+ if (streamRef) try {
299
+ streamRef.abort(/* @__PURE__ */ new Error("call cancelled"));
300
+ } catch {}
301
+ return origReturn(value);
302
+ };
303
+ return gen;
304
+ };
305
+ return {
306
+ call,
307
+ async close() {
308
+ for (const s of open) try {
309
+ s.abort(/* @__PURE__ */ new Error("connection close"));
310
+ } catch {}
311
+ }
312
+ };
313
+ };
314
+ /**
315
+ * Server-side: registers `node.handle(protocol, ...)`. Each inbound stream is
316
+ * wrapped as a `Duplex` and handed to `handler`. Identity-unaware; use
317
+ * `serveConnections` when the handler needs to know who is calling.
318
+ */
319
+ const serve = async (params, handler) => serveConnections(params, () => handler);
320
+ /**
321
+ * Like `serve`, but the handler is built per inbound stream and is told which
322
+ * peer libp2p proved on that connection.
323
+ */
324
+ async function serveConnections({ node, protocol, drainTimeoutMs, maxInboundStreams, maxOutboundStreams, runOnLimitedConnection }, makeHandler) {
325
+ const proto = protocol ?? "/webrun-streams/1.0.0";
326
+ const handleOptions = definedOptions({
327
+ maxInboundStreams,
328
+ maxOutboundStreams,
329
+ runOnLimitedConnection
330
+ });
331
+ const onStream = (stream, connection) => {
332
+ (async () => {
333
+ const handler = makeHandler({ remotePeer: connection.remotePeer });
334
+ const inputQueue = makeInputQueue();
335
+ const output = handler(inputQueue.iter());
336
+ try {
337
+ for await (const chunk of duplexOverStream(stream, output, {
338
+ drainTimeoutMs,
339
+ onPeerInputEnd: (err) => inputQueue.done(err)
340
+ })) inputQueue.push(chunk);
341
+ } finally {
342
+ inputQueue.done();
343
+ await closeStream(stream);
344
+ }
345
+ })().catch((err) => {
346
+ const e = err instanceof Error ? err : new Error(String(err));
347
+ console.warn(`[webrun-streams-libp2p] serve: inbound stream on protocol ${proto} failed: ${e.message}`, e);
348
+ });
349
+ };
350
+ await node.handle(proto, onStream, handleOptions);
351
+ let torn = false;
352
+ return async () => {
353
+ if (torn) return;
354
+ torn = true;
355
+ await node.unhandle(proto);
356
+ };
357
+ }
358
+ function makeInputQueue() {
359
+ const slots = [];
360
+ let wake = null;
361
+ let closed = false;
362
+ return {
363
+ iter() {
364
+ return (async function* () {
365
+ try {
366
+ while (true) {
367
+ if (slots.length === 0) {
368
+ await new Promise((r) => {
369
+ wake = r;
370
+ });
371
+ wake = null;
372
+ continue;
373
+ }
374
+ const s = slots.shift();
375
+ if (s.type === "done") {
376
+ if (s.err) throw s.err;
377
+ return;
378
+ }
379
+ yield s.value;
380
+ }
381
+ } finally {
382
+ closed = true;
383
+ }
384
+ })();
385
+ },
386
+ push(chunk) {
387
+ if (closed) return;
388
+ slots.push({
389
+ type: "value",
390
+ value: chunk
391
+ });
392
+ wake?.();
393
+ },
394
+ done(err) {
395
+ if (closed) return;
396
+ slots.push({
397
+ type: "done",
398
+ err
399
+ });
400
+ wake?.();
401
+ }
402
+ };
403
+ }
404
+ //#endregion
405
+ export { DEFAULT_DRAIN_TIMEOUT_MS, DEFAULT_PROTOCOL, connect, duplexOverStream, serve, serveConnections };
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "@statewalker/webrun-streams-libp2p",
3
+ "version": "0.1.1",
4
+ "private": false,
5
+ "type": "module",
6
+ "description": "libp2p native multi-stream Connect/Serve adapter in the webrun-streams-* family",
7
+ "homepage": "https://github.com/statewalker/webrun-wire",
8
+ "author": {
9
+ "name": "Mikhail Kotelnikov",
10
+ "email": "mikhail.kotelnikov@gmail.com"
11
+ },
12
+ "license": "MIT",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git@github.com:statewalker/webrun-wire.git"
16
+ },
17
+ "exports": {
18
+ ".": "./src/index.ts"
19
+ },
20
+ "files": [
21
+ "dist",
22
+ "src"
23
+ ],
24
+ "dependencies": {
25
+ "@statewalker/webrun-streams": "0.1.1"
26
+ },
27
+ "peerDependencies": {
28
+ "@libp2p/interface": "^3.0.0",
29
+ "@multiformats/multiaddr": "^13.0.0",
30
+ "libp2p": "^3.0.0"
31
+ },
32
+ "peerDependenciesMeta": {
33
+ "libp2p": {
34
+ "optional": true
35
+ }
36
+ },
37
+ "devDependencies": {
38
+ "@chainsafe/libp2p-noise": "17.0.0",
39
+ "@chainsafe/libp2p-yamux": "8.0.1",
40
+ "@libp2p/interface": "3.2.5",
41
+ "@libp2p/tcp": "11.0.26",
42
+ "@libp2p/utils": "7.3.2",
43
+ "@multiformats/multiaddr": "13.0.3",
44
+ "@types/node": "^26.2.0",
45
+ "libp2p": "3.3.8",
46
+ "rimraf": "^6.1.3",
47
+ "rolldown": "^1.2.4",
48
+ "typescript": "^7.0.2",
49
+ "vitest": "^4.1.10",
50
+ "@statewalker/webrun-streams-conformance": "0.1.1"
51
+ },
52
+ "sideEffects": false,
53
+ "publishConfig": {
54
+ "access": "public"
55
+ },
56
+ "scripts": {
57
+ "build": "rimraf dist && rolldown -c && tsc --emitDeclarationOnly --declaration",
58
+ "test": "vitest run",
59
+ "lint": "biome check src tests"
60
+ }
61
+ }