All docs / npm packages

@decentnetwork/peer

Pure TypeScript Carrier peer: self-generated identity, friends, DHT discovery, direct/relay transport, messages, files and Express offline delivery. Wire-compatible with the Beagle apps and the native SDKs.

Version
0.1.172 · npm
Platform
Node.js 20+ · TypeScript
Source
Source repository not public yet
npm install @decentnetwork/peer

@decentnetwork/peer is a pure TypeScript implementation of the Carrier protocol (the toxcore-derived protocol the Beagle apps and the native Carrier SDKs speak). A Peer owns a self-generated keypair, joins the public DHT through bootstrap nodes, becomes friends with other peers, and exchanges end-to-end encrypted text, files and custom packets with them — directly over UDP when a hole can be punched, through a TCP relay when it cannot, and through an HTTPS Express relay when the friend is offline. It is wire-compatible with the iOS and Android Beagle apps and the C SDK. There are no native dependencies; it runs on Node.js 20+.

When to use it

Use @decentnetwork/peer when your program needs its own identity on the Decent Network: a bot, a backend service, a CLI, a desktop daemon, or a test harness that talks to Beagle users or to other peers.

  • It is the transport. Every other npm package on this site that talks to the network sits on top of it.
  • For audio and video calls, add @decentnetwork/peer-webrtc. It uses this package's friend-invite channel for call signaling.
  • For forms inside chat messages, add @decentnetwork/chat-components. It only formats message text; it never touches the transport.
  • For a VPN-style virtual LAN between peers, use @decentnetwork/lan instead of building one on custom packets.
  • For a ready-made desktop chat client, see @decentnetwork/beagle.
  • On Android, iOS or in C, use the native SDKs (Android, Swift, C/C++). They interoperate with this package on the wire.

Install

npm install @decentnetwork/peer

The package is ESM only ("type": "module", no require export). Use import, an .mjs file, or "type": "module" in your package.json. TypeScript declarations ship in the package.

Quickstart

This single file creates two identities, Alice and Bob, in separate directories. Alice sends Bob a friend request, Bob accepts it, and Alice sends Bob a text message over the live network. It exits on its own, or after 3 minutes if the network cannot complete the exchange.

// quickstart.mjs — two peers, one friendship, one message, over the live network.
// Run: node quickstart.mjs
import { Peer, carrierIdFromAddress } from "@decentnetwork/peer";

// Public Carrier bootstrap nodes (the same list the Beagle apps ship with).
const bootstrapNodes = [
  { host: "47.100.103.201", port: 33445, pk: "CX1XH419p4xJ5SV4KvDxBeKYSRdMJW9QpdWJY8owUxHd" },
  { host: "154.64.235.176", port: 33445, pk: "GdNtV2N74fZnLjhH7NhQ18nGdxb1k8jRM9dQaK7WnxmL" },
  { host: "45.207.220.155", port: 33445, pk: "37PXijTXkyX9rUcRFjGo7PYpwf732KXQJgMtcKuMu3ym" },
  { host: "13.58.208.50", port: 33445, pk: "89vny8MrKdDKs7Uta9RdVmspPjnRMdwMmaiEW27pZ7gh" },
];
// Optional HTTPS store-and-forward relay: carries the friend request (and
// messages) when the other side cannot be reached directly yet.
const expressNodes = [
  { host: "lens.beagle.chat", port: 443, pk: "ECbs4GxwGzxGerNkmqDJFibEmevu8jAXqAZtikccvD95" },
];

// Never hang: give up after 3 minutes.
const watchdog = setTimeout(() => { console.error("timed out"); process.exit(1); }, 180_000);

// Each identity is a key file, created on first run; keep it to keep the identity.
// Give every peer its own directory (created if missing): start() also writes
// announce-data-key.json and <keyFile>.friends.json next to the key file.
const alice = await Peer.create({ keyFile: "./alice/peer.save", bootstrapNodes, expressNodes, nickname: "alice" });
const bob = await Peer.create({ keyFile: "./bob/peer.save", bootstrapNodes, expressNodes, nickname: "bob" });

// Bob accepts every friend request and resolves when a text arrives.
bob.onFriendRequest((req) => {
  console.log(`bob   <- friend request from ${req.userid}: "${req.hello}"`);
  void bob.acceptFriendRequest(req.pubkey);
});
const received = new Promise((resolve) => bob.onText((msg) => resolve(msg)));

await alice.start();
await bob.start();
await Promise.all([alice.joinNetwork(), bob.joinNetwork()]);

console.log(`alice address ${alice.address()}`);
console.log(`alice userid  ${alice.userid()}`);
console.log(`bob   address ${bob.address()}`);
console.log(`bob   userid  ${bob.userid()}`);

// Alice adds Bob by his address, then waits for the encrypted session.
await alice.sendFriendRequest(bob.address(), "hi bob, it's alice");
const bobId = carrierIdFromAddress(bob.address()); // === bob.userid()
const online = await alice.waitForFriendConnected(bobId, 120_000);
console.log(`alice -> session with bob: ${online ? "connected" : "not connected (yet)"}`);

const result = await alice.sendText(bobId, "hello from alice");
console.log(`alice -> sendText delivery: ${result.delivery}`);

const msg = await received;
console.log(`bob   <- "${msg.text}" from ${msg.pubkey} via ${msg.via}`);

await alice.stop();
await bob.stop();
clearTimeout(watchdog);
process.exit(0);
npm install @decentnetwork/peer@0.1.172
node quickstart.mjs

Verified output (npm @decentnetwork/peer@0.1.172, 2026-11-06):

alice address dp4jcRUUoAjKHkfkCUr7TpbvAhtyemXe42kCQhVNVXEeU6Mr3Ff2
alice userid  HkPpHmCJ7R31A2BHyaNWexHJvMdJP5w8UzySdQKEUrid
bob   address S8ZYCF6aBufgA6nDuGJUFjM1imrNb32ZPmstSbDLZSkXwqUZT9yQ
bob   userid  CS9d5xdUuSz4XGKnL9cJWdHkQar862hkTvPpXG8pTmDG
bob   <- friend request from HkPpHmCJ7R31A2BHyaNWexHJvMdJP5w8UzySdQKEUrid: "hi bob, it's alice"
alice -> session with bob: connected
alice -> sendText delivery: offline
bob   <- "hello from alice" from HkPpHmCJ7R31A2BHyaNWexHJvMdJP5w8UzySdQKEUrid via offline

The whole exchange took about 45 seconds on a home network (macOS, Node.js 24.3.0). Repeated runs end the same way, but the delivery path varies: in some runs sendText returned acked and Bob saw via online. Right after a session comes up, sendText may still choose the Express relay because no keepalive has arrived from the friend yet. See Messages and delivery results.

What the output shows:

  • An address (alice address …) is what you share so others can add you. It is the public key plus a 4-byte nospam and a checksum, in base58.
  • A userid (alice userid …) is the public key alone. Every per-friend method (sendText, waitForFriendConnected, removeFriend, …) takes the userid. carrierIdFromAddress(address) converts one to the other.

API reference

Everything below is exported from the package root (import { … } from "@decentnetwork/peer"), as declared in dist/index.d.ts of 0.1.172.

Class Peer

Lifecycle

MemberDescription
static create(opts: PeerOptions): Promise<Peer>Builds a peer. Opens no sockets. Attach event handlers before start().
start(): Promise<void>Loads or creates the key file, loads friends, binds UDP and opens TCP relays.
joinNetwork(): Promise<BootstrapResult>DHT bootstrap. Resolves with { respondingNode, discoveredNodes }. Rejects with bootstrap timed out after 30000ms when no bootstrap node answers.
stop(): Promise<void>Persists friends and closes sockets and relays.
announceSelf(timeoutMs?: number): Promise<NetworkNode[]>Runs a self-announce sweep now (default 15000 ms) and returns the nodes that stored the announce.
lookup(pubkey: string): Promise<LookupResult>DHT lookup. In 0.1.172 the LookupResult type is { pubkey, status: "unknown", records: [] }.
addKnownNodes(nodes: NetworkNode[]): void / knownNodes(): NetworkNode[]Seed or read this peer's DHT node table.
dhtHealth()Snapshot: bootstrapsConfigured, knownNodesCount, lastSelfAnnounceMs, selfAnnounceStoredOn, udpLocalPort, tcpRelayConnected, tcpOnionSent, tcpOnionRecv, tcpRelayEndpoints. selfAnnounceStoredOn === 0 means other peers cannot find you through the DHT yet.

Identity and profile

MemberDescription
address(): stringFull Carrier address (public key + nospam + checksum, base58). Share this.
userid(): string / pubkey(): stringThe public key, base58. Both return the same value.
sign(message: Uint8Array): Uint8ArrayDetached 64-byte XEdDSA signature. Anyone can check it with verifyDetached(base58ToBytes(userid), message, sig).
setUserInfo(info: { name?: string; description?: string; punkId?: number | null }): voidChange the display name or status line and push it to friends.
userInfo(): { name: string; description: string; punkId?: number }Current profile.

Friends

MemberDescription
sendFriendRequest(pubkey: string, hello?: string, opts?: { force?: boolean }): Promise<void>Pass the friend's address. The request goes over onion routing and, when expressNodes is set, the Express relay. force: true re-sends to a friend that already looks accepted.
acceptFriendRequest(pubkey: string, opts?: { name?; description?; hello?; address?; nospam? }): Promise<void>Accept by userid. Works for a request remembered by your app across a restart; pass back what you stored in opts.
rejectFriendRequest(pubkey: string): voidDecline, and drop any record or session the request created.
removeFriend(pubkey: string): booleanRemove a friend by userid or address. Returns true if it existed.
friends(): FriendRecord[]Current friend list (pubkey, status: "requested" | "offline" | "online", name, description, address, timestamps, profile fields).
pendingFriendRequests(): FriendRequest[]Inbound requests not yet answered (this process's memory only).
waitForFriendRequest(timeoutMs?: number): Promise<FriendRequest>Resolves on the next inbound request.
waitForFriendConnected(pubkey: string, timeoutMs?: number): Promise<boolean>true once a session with that userid is up, false on timeout.
isNativeFriend(pubkey: string): booleantrue when the friend is a native Carrier client (iOS, Android, C SDK).
sessionStatus(pubkey: string)Live session snapshot or null: established, transport ("udp" | "tcp-relay" | "both" | "none"), udpRemote, hasTcpRoute, lastPingRecvMs, endpoint candidates and more.

Messages and delivery results

MemberDescription
sendText(pubkey: string, text: string): Promise<TextDeliveryResult>Resolves with { delivery: "acked" | "accepted" | "offline" }. Throws Not a friend: … for a non-friend. For a friend whose profile reports protocol version 2 or later (current JS peers), it first waits up to 15 s (DECENT_TEXT_AUTO_ACK_TIMEOUT_MS) for an SDK acknowledgement and rejects if none arrives. For other friends it does not throw when delivery is unconfirmed.
sendTextUntilAck(pubkey, text, opts?: SendTextUntilAckOptions): Promise<{ deliveryId: string }>Retransmits until the far side's SDK acknowledges. Options: deliveryId, timeoutMs (default 5 min), retryIntervalMs (default 5 s).
sendTextViaExpress(pubkey, text, opts?: { deliveryId?: string }): Promise<{ delivery: "offline"; deliveryId?: string }>Post straight to the Express relay. Throws when no Express node is configured or the post fails.

TextDeliveryResult.delivery means:

  • "acked": the far side confirmed it. You can mark the message sent.
  • "accepted": it went into a live session but was not confirmed in time. Do not resend on this alone, or the recipient may see a duplicate.
  • "offline": it was stored on an Express relay. The friend collects it the next time it polls.

Files

MemberDescription
sendInlineFile(pubkey, opts: { name: string; data: Uint8Array; fileType?: "image" | "audio" | "text" | "unknown"; deliveryTimeoutMs?: number }): Promise<InlineSendResult>Sends a file inside the message channel, the way the iOS and C Beagle apps send images and audio. This is the only file path native clients receive online. The protocol limit is about 5 MB after base64.
sendFile(userid, data: Uint8Array, opts: { name: string; kind?: number }): string | nulltoxcore file transfer between JS peers. Returns the fileId (hex), or null if no slot is free. Native Carrier clients do not see these transfers.
acceptFile(userid, fileNumber), cancelFile(userid, fileNumber, isSending?), cancelFileById(userid, fileId, isSending?), cancelSendsMatching(userid, { name?, size })Control file transfers.

Calls, invites and custom packets

MemberDescription
sendInvite(pubkey, data: Uint8Array | string, opts?: { ext?: string | null; bundle?: string; establishTimeoutMs?: number }): Promise<bigint>Carrier friend-invite (ext defaults to "carrier"). This is the channel the native WebRTC SDKs use for call signaling. It needs a live session and never falls back to Express. Payloads are split into 1280-byte fragments.
getIceServers(opts?: { limit?: number; includeTcpTurn?: boolean }): RtcIceServer[]STUN and TURN entries for WebRTC on the bootstrap nodes' TURN service (port 3478). Credentials are derived from your identity, with a fresh nonce on every call.
getTurnServerInfo(bootnodeIndex?: number): CarrierTurnServerInfoThe raw { server, port, username, password, realm } for one bootstrap node.
sendCustomPacket(pubkey, id: number, data: Uint8Array): Promise<void>App-defined packet. id 160–191 is lossless (reliable, ordered); 192–254 is lossy. Throws when the session has no transport.

Events

Register these before start(). Each takes one callback.

MethodPayload
onFriendRequest(cb)FriendRequest: pubkey, userid, address?, nospam?, name?, description?, hello?, punkId?, avatarUrl?
onFriendConnection(cb)FriendConnectionEvent: { pubkey, status: "connected" | "disconnected" }
onFriendInfo(cb)FriendInfoEvent: pubkey, name?, description?, punkId?, platform?, osVersion?, appVersion?, protoVersion?, avatarUrl?, url?, ens?, extra?
onText(cb)TextMessage: pubkey, text, via?: "online" | "offline", deliveryId?, ack?()
onInlineFile(cb)InlineFileEvent: pubkey, name, fileType?, data, via
onFile(cb) / onFileProgress(cb) / onFileComplete(cb) / onFileCancel(cb)toxcore file-transfer offer, progress, completion (data on the receiver) and cancellation
onInvite(cb)InviteEvent: pubkey, ext?, bundle?, data
onInviteResponse(cb)InviteResponseEvent: pubkey, ext?, bundle?, status, reason?, data
onCustomPacket(cb)CustomPacketEvent: { pubkey, id, data }
expressHealth() / onExpressUnreachable(cb)Per-relay { node, fails, lastError, lastOkMs }, and an event when a relay keeps failing

Methods whose names start with _test or __test are test hooks, not public API.

Functions and constants

ExportDescription
carrierAddressFromPublicKey(publicKey: Uint8Array, nospam?: number): stringBuild an address.
carrierIdFromAddress(address: string): stringAddress to userid.
carrierIdFromPublicKey(publicKey: Uint8Array): stringPublic key to userid.
parseCarrierAddress(address: string): CarrierAddressParts{ publicKey, nospam, checksum }; throws on a bad checksum.
CARRIER_ADDRESS_SIZE, CARRIER_PUBLIC_KEY_SIZEAddress size in bytes, and 32.
signDetached(secretKey, message), verifyDetached(publicKey, message, signature), SIGNATURE_LENGTHXEdDSA over X25519 keys. verifyDetached returns false instead of throwing.
base58ToBytes(value), bytesToBase58(bytes)Base58 helpers.
getIceServers(opts: BuildIceServersOptions), getTurnServerInfoForBootnode(...), iceServersFromTurnInfo(info, opts?), deriveCarrierTurnCreds(...), CARRIER_TURN_PORT (3478), CARRIER_TURN_REALM, CARRIER_TURN_USER_SUFFIXThe TURN credential derivation behind Peer.getIceServers(), for use without a Peer.
createCarrierRendezvousAuthProof(...), RENDEZVOUS_AUTH_KEY_DOMAIN, RENDEZVOUS_AUTH_PROOF_LENGTHRendezvous authentication proof.
decodeCarrierPacket, encodeFriendMessagePacket, encodeFriendRequestPacket, encodeInviteReqPacket, encodeInviteRspPacket, PACKET_TYPE_*, INVITE_DATA_UNIT, CARRIER_MAX_INVITE_DATA_LEN, CARRIER_EXTENSION_NAMECarrier app-payload (FlatBuffers) codec, for protocol tooling.
createToxDhtCryptoRequest, openToxDhtCryptoRequest, CRYPTO_PACKET_*, NET_PACKET_*, ONION_FRIEND_REQUEST_IDLow-level toxcore packet helpers, for protocol tooling.
LegacyProtocolNotImplementedErrorError class.

Exported types: PeerOptions, NetworkNode, CompatibilityMode, FriendRequest, FriendConnectionEvent, FriendConnectionStatus, FriendInfoEvent, TextMessage, TextDeliveryResult, SendTextUntilAckOptions, InlineFileEvent, InlineSendResult, InviteEvent, InviteResponseEvent, CustomPacketEvent, LookupResult, CarrierAddressParts, CarrierTurnCreds, CarrierTurnServerInfo, RtcIceServer, BuildIceServersOptions, CarrierPacket, FriendMessagePacket, FriendRequestPacket, InviteReqPacket, InviteRspPacket, ToxDhtCryptoRequest.

Configuration

PeerOptions

OptionTypeNotes
keyFilestringRequired. Identity file, created on first start() along with its directory. announce-data-key.json is written to the same directory, so give each identity its own directory.
bootstrapNodesNetworkNode[]Required. { host, port, pk } DHT entry points. Every bootstrap node is also a TCP relay. Use several.
expressNodesNetworkNode[]Express offline relays. There is no default: leave it unset and nothing goes through Express (no offline messages or offline friend requests). Set tls: false on a node to use plain HTTP.
expressControlPlaneOnlybooleanUse Express only for friend requests, never as a fallback for sendText.
friendStoreFilestringDefault <keyFile>.friends.json.
udpPortnumberFixed UDP port for long-lived services. Default: an ephemeral port.
keyPair{ publicKey: Uint8Array; secretKey: Uint8Array }Use this keypair instead of reading keyFile (for example, a key kept in IndexedDB).
tcpOnlyBootstrapbooleanSkip the UDP bootstrap and seed nodes from bootstrapNodes, for hosts with no UDP.
nickname, statusMessagestringProfile sent to friends. Defaults: DECENT_PEER_NAME or "@decentnetwork/peer", and DECENT_PEER_STATUS_MESSAGE or "decent peer".
platform, osVersion, appVersionstringClient metadata in the profile.
punkId, profile ({ avatarUrl?, url?, ens?, extra? })Avatar and profile extension fields. Each is at most 255 bytes.
bulkDataPacketIdnumberCustom packet id that carries bulk data, so it uses single-path routing like chat.
fileResumeDirstringDirectory for partial file transfers, so they resume after a restart.
compatibilityMode"legacy"The only value, and the default.
debug, debugVerbose, debugLabelboolean, boolean, stringDebug logging without environment variables, and a label for log lines.

Environment variables

The 0.1.172 build reads these at startup:

VariableDefaultEffect
DECENT_DEBUG, DECENT_DEBUG_VERBOSEoffProtocol debug logs
DECENT_PEER_NAME, DECENT_PEER_STATUS_MESSAGE, DECENT_GREETING_TEXTsee above, ""Profile defaults; greeting sent to new friends
DECENT_EXPRESS_PULL_INTERVAL_MS4000How often the Express relay is polled
DECENT_TEXT_AUTO_ACK_TIMEOUT_MS15000How long sendText waits for a protocol-v2 friend's acknowledgement
DECENT_FRIEND_CONNECTION_LOOP_MS250Friend-connection loop tick
DECENT_DHT_PK_ANNOUNCE_COOLDOWN_MS25000Minimum gap between lookups for the same friend
DECENT_ONION_LOOKUP_MAX_BACKOFF_MS120000Backoff cap. This is the longest it can take to notice that a friend has come back online.
DECENT_FRIEND_ROUTE_MAX_ATTEMPTS24Nodes queried per discovery sweep
DECENT_ONION_DATA_ATTEMPTS5Retries per onion data packet
DECENT_SELF_ANNOUNCE_INTERVAL_MS, DECENT_SELF_ANNOUNCE_TARGETS20000, 16Self-announce cadence and breadth
DECENT_TCP_MAX_RELAYS3Concurrent TCP relay connections
DECENT_TURN_SERVERSbuilt-inhost:port,host:port TURN list for the peer's own relay path

To save CPU when many friends are offline, raise the interval or the backoff cap. Do not lower DECENT_FRIEND_ROUTE_MAX_ATTEMPTS: phones are only online in short windows, and a narrower sweep misses them.

CLI: decent-peer

The package ships a small CLI for diagnostics. Run it with npx; there is no need for a global install.

npx decent-peer init                       # write ~/.decent-peer/config.json
npx decent-peer address                    # print this peer's address
npx decent-peer userid                     # print this peer's userid
npx decent-peer listen                     # join and print incoming text until Ctrl-C
npx decent-peer add-friend <address> [hello]
npx decent-peer accept <userid>
npx decent-peer send <userid> <text>
npx decent-peer list-friends               # JSON

The config file is ~/.decent-peer/config.json; set DECENT_PEER_CONFIG to use another path. It accepts keyFile, bootstrapNodes and compatibilityMode. The CLI's default bootstrap list has one node, and the CLI never configures an Express relay. init, address, userid and list-friends were verified with 0.1.172.

Platforms

  • Node.js 20 or newer (engines.node >= 20.0.0). Verified on Node.js 24.3.0, macOS (Apple Silicon).
  • Pure JavaScript. The dependencies are tweetnacl, curve25519-js and flatbuffers; nothing is compiled.
  • Network: outbound UDP and TCP to the bootstrap nodes (port 33445; some use 443), and outbound HTTPS to the Express relay if you configure one. No inbound port is needed.
  • One Peer per identity. Several peers can share a process if each has its own keyFile directory, as in the quickstart.
  • Browsers: the API has hooks for hosts without UDP or a filesystem (tcpOnlyBootstrap, keyPair, debug), but the package root imports Node built-ins and is not published as a browser bundle. The browser build was not verified for this page.

Version, source and changelog