Skip to content
minip2p
Esc
navigateopen⌘Jpreview
On this page

Connections and streams

Connect with cancellable Promises, inspect direct or relayed paths, and exchange bytes over custom protocol streams.

The TypeScript SDK separates finding a usable network path from opening an application protocol. Connect first, wait for Identify readiness, then open a negotiated stream.

Connect with a Promise

Use connectAddr when you have one complete peer multiaddress:

const result = await endpoint.connectAddr(remoteAddress, {
  timeoutMs: 15_000,
});

await endpoint.waitPeerReady(result.peerId, {
  timeoutMs: 10_000,
});

console.log(result.connectId, result.path);
Input you have Method
Peer ID already in the discovery book connect(peerId, options?)
Peer ID plus an ordered candidate list connectWithAddrs(peerId, addresses, options?)
One address ending in /p2p/<peer-id> connectAddr(address, options?)

All three resolve with { connectId, peerId, path }. The first path may be directDialed, directPunched, or relayed. Call path(peerId) when you need the current answer; a relayed connection can upgrade after the original Promise resolves.

The lower-level dial, dialIp4, and dialIp6 methods only start direct TCP or QUIC dials and return connection IDs immediately. They do not run the relay race or return a Path. Observe peerReady or call waitPeerReady afterward.

Timeouts and cancellation

connect*, waitPeerReady, ping, openStream, once, and waitFor accept:

interface OpOptions {
  timeoutMs?: number;
  signal?: AbortSignal;
}

The default timeout is 65 seconds. Set timeoutMs: 0 only when a caller truly wants an unbounded wait.

const controller = new AbortController();

const connectPromise = endpoint.connectAddr(remoteAddress, {
  signal: controller.signal,
  timeoutMs: 20_000,
});

// A user action or a shutdown path can stop this attempt.
controller.abort();
await connectPromise; // rejects with AbortError

For a connect* Promise, an abort or timeout cancels the underlying connection attempt. Concurrent ping calls to the same peer share one Ping request while each caller keeps its own timeout and abort signal.

Use the split-phase API when the caller must retain the connection-attempt ID:

const connectId = endpoint.startConnectAddr(remoteAddress);
const resultPromise = endpoint.waitConnectResult(connectId, {
  timeoutMs: 20_000,
});

// A separate action can cancel by ID.
const cancel = () => endpoint.cancelConnect(connectId);

const result = await resultPromise;
console.log(result.path);

One terminal result can be consumed once. Waiting twice, or waiting for an unknown attempt, rejects with ConnectResultUnavailableError.

Register a protocol

Protocol IDs are versioned strings owned by your application. Register them in the initial configuration or before a remote peer opens an inbound stream.

const endpoint = Minip2p.create({
  secretKey,
  protocols: ["/example/files/1"],
});

endpoint.addProtocol("/example/chat/1");

Both peers must support the same ID. Wait for peerReady before opening so the remote Identify snapshot is available.

Exchange one request and response

This Node.js example uses one stream for a request and its reply. From a directory where you installed @minip2p/node, create receiver.mjs:

import { Minip2p, generateSecretKey } from "@minip2p/node";

const protocol = "/example/echo/1";
const decoder = new TextDecoder();
const endpoint = Minip2p.create({
  protocols: [protocol],
  secretKey: generateSecretKey(),
  transports: {
    quic: { listen: ["/ip4/127.0.0.1/udp/4002/quic-v1"] },
  },
});

try {
  console.log(endpoint.listenAddrs()[0]);

  const stream = await endpoint.once("stream", { timeoutMs: 0 });
  let request = "";
  for await (const chunk of stream) {
    request += decoder.decode(chunk, { stream: true });
  }
  request += decoder.decode();

  console.log(`received: ${request}`);
  stream.write(`echo: ${request}`);
  stream.closeWrite();

  await new Promise((resolve) => stream.on("closed", resolve));
} finally {
  endpoint.close();
}

Run the receiver in the first terminal and copy its complete printed address:

node receiver.mjs

In the same directory, create sender.mjs:

import { Minip2p, generateSecretKey } from "@minip2p/node";

const remoteAddress = process.argv[2];
if (remoteAddress === undefined) {
  throw new Error("pass the receiver's peer multiaddress");
}

const protocol = "/example/echo/1";
const decoder = new TextDecoder();
const endpoint = Minip2p.create({
  protocols: [protocol],
  secretKey: generateSecretKey(),
});

try {
  const connected = await endpoint.connectAddr(remoteAddress, {
    timeoutMs: 10_000,
  });
  await endpoint.waitPeerReady(connected.peerId, { timeoutMs: 10_000 });

  const stream = await endpoint.openStream(connected.peerId, protocol, {
    timeoutMs: 10_000,
  });
  stream.write("hello");
  stream.closeWrite();

  let reply = "";
  for await (const chunk of stream) {
    reply += decoder.decode(chunk, { stream: true });
  }
  console.log(reply + decoder.decode());
} finally {
  endpoint.close();
}
node sender.mjs /ip4/127.0.0.1/udp/4002/quic-v1/p2p/12D3KooW...

Run the sender in the second terminal with the receiver’s address. The receiver prints received: hello. The sender prints echo: hello.

The sender closes its endpoint after reading the complete reply. The receiver waits for the stream to close before stopping its own endpoint.

Open and write

const stream = await endpoint.openStream(
  peerId,
  "/example/files/1",
  { timeoutMs: 10_000 }
);

stream.write("header\n");
stream.write(new Uint8Array([1, 2, 3]));
stream.closeWrite();

write accepts UTF-8 text, Uint8Array, or ArrayBuffer. closeWrite() half-closes only the local write side; reads stay available until the remote half-closes or the stream terminates.

Claim inbound streams

A named stream handler receives the live handle:

const removeInbound = endpoint.on("stream", (stream) => {
  if (stream.protocolId !== "/example/files/1") {
    stream.abandon();
    return;
  }

  void receiveFile(stream);
});

Unclaimed inbound streams are abandoned. A catch-all event subscriber receives only inboundStream metadata and cannot claim the live handle.

Choose one read mode

Pull mode is natural for framed or sequential decoders. The Stream type comes from the binding package for your runtime:

import type { Stream } from "@minip2p/node";
import type { Stream } from "@minip2p/react-native";
async function receiveFile(stream: Stream) {
  try {
    for (;;) {
      const chunk = await stream.read();
      if (chunk === undefined) break;
      consume(chunk);
    }
    stream.closeWrite();
  } catch (error) {
    stream.reset();
    throw error;
  }
}

Flowing mode delivers chunks to a handler:

const removeData = stream.on("data", (chunk) => consume(chunk));
const removeRemoteClose = stream.on("remoteWriteClosed", () => {
  removeData();
  finishIncomingMessage();
});

// Call this if the owner goes away first; both callbacks are idempotent.
const stopFlowing = () => {
  removeData();
  removeRemoteClose();
};

Pull reads and flowing data handlers are mutually exclusive. Calling read() after subscribing to data, or subscribing to data after the first read(), rejects or throws.

End a stream deliberately

Operation Result
closeWrite() Graceful local half-close; reading remains possible.
reset() Abruptly reset the stream and emit closed.
abandon() Reset, relinquish the handle, and suppress later events.
stream[Symbol.dispose]() Equivalent to abandon().

remoteWriteClosed means no more inbound bytes will arrive. closed is the terminal lifecycle event. Pull reads use a bounded buffer; if the application does not keep up, dataOverflow reports dropped chunks and bytes. Treat that as a protocol-level recovery condition rather than silently continuing a framed transfer.

Next: Networking and events.

Last updated on September 7, 2026

Was this page helpful?