@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.
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/laninstead 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/peerThe 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.mjsVerified 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 offlineThe 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
| Member | Description |
|---|---|
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
| Member | Description |
|---|---|
address(): string | Full Carrier address (public key + nospam + checksum, base58). Share this. |
userid(): string / pubkey(): string | The public key, base58. Both return the same value. |
sign(message: Uint8Array): Uint8Array | Detached 64-byte XEdDSA signature. Anyone can check it with verifyDetached(base58ToBytes(userid), message, sig). |
setUserInfo(info: { name?: string; description?: string; punkId?: number | null }): void | Change the display name or status line and push it to friends. |
userInfo(): { name: string; description: string; punkId?: number } | Current profile. |
Friends
| Member | Description |
|---|---|
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): void | Decline, and drop any record or session the request created. |
removeFriend(pubkey: string): boolean | Remove 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): boolean | true 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
| Member | Description |
|---|---|
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
| Member | Description |
|---|---|
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 | null | toxcore 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
| Member | Description |
|---|---|
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): CarrierTurnServerInfo | The 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.
| Method | Payload |
|---|---|
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
| Export | Description |
|---|---|
carrierAddressFromPublicKey(publicKey: Uint8Array, nospam?: number): string | Build an address. |
carrierIdFromAddress(address: string): string | Address to userid. |
carrierIdFromPublicKey(publicKey: Uint8Array): string | Public key to userid. |
parseCarrierAddress(address: string): CarrierAddressParts | { publicKey, nospam, checksum }; throws on a bad checksum. |
CARRIER_ADDRESS_SIZE, CARRIER_PUBLIC_KEY_SIZE | Address size in bytes, and 32. |
signDetached(secretKey, message), verifyDetached(publicKey, message, signature), SIGNATURE_LENGTH | XEdDSA 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_SUFFIX | The TURN credential derivation behind Peer.getIceServers(), for use without a Peer. |
createCarrierRendezvousAuthProof(...), RENDEZVOUS_AUTH_KEY_DOMAIN, RENDEZVOUS_AUTH_PROOF_LENGTH | Rendezvous authentication proof. |
decodeCarrierPacket, encodeFriendMessagePacket, encodeFriendRequestPacket, encodeInviteReqPacket, encodeInviteRspPacket, PACKET_TYPE_*, INVITE_DATA_UNIT, CARRIER_MAX_INVITE_DATA_LEN, CARRIER_EXTENSION_NAME | Carrier app-payload (FlatBuffers) codec, for protocol tooling. |
createToxDhtCryptoRequest, openToxDhtCryptoRequest, CRYPTO_PACKET_*, NET_PACKET_*, ONION_FRIEND_REQUEST_ID | Low-level toxcore packet helpers, for protocol tooling. |
LegacyProtocolNotImplementedError | Error 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
| Option | Type | Notes |
|---|---|---|
keyFile | string | Required. 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. |
bootstrapNodes | NetworkNode[] | Required. { host, port, pk } DHT entry points. Every bootstrap node is also a TCP relay. Use several. |
expressNodes | NetworkNode[] | 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. |
expressControlPlaneOnly | boolean | Use Express only for friend requests, never as a fallback for sendText. |
friendStoreFile | string | Default <keyFile>.friends.json. |
udpPort | number | Fixed 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). |
tcpOnlyBootstrap | boolean | Skip the UDP bootstrap and seed nodes from bootstrapNodes, for hosts with no UDP. |
nickname, statusMessage | string | Profile sent to friends. Defaults: DECENT_PEER_NAME or "@decentnetwork/peer", and DECENT_PEER_STATUS_MESSAGE or "decent peer". |
platform, osVersion, appVersion | string | Client metadata in the profile. |
punkId, profile ({ avatarUrl?, url?, ens?, extra? }) | Avatar and profile extension fields. Each is at most 255 bytes. | |
bulkDataPacketId | number | Custom packet id that carries bulk data, so it uses single-path routing like chat. |
fileResumeDir | string | Directory for partial file transfers, so they resume after a restart. |
compatibilityMode | "legacy" | The only value, and the default. |
debug, debugVerbose, debugLabel | boolean, boolean, string | Debug logging without environment variables, and a label for log lines. |
Environment variables
The 0.1.172 build reads these at startup:
| Variable | Default | Effect |
|---|---|---|
DECENT_DEBUG, DECENT_DEBUG_VERBOSE | off | Protocol debug logs |
DECENT_PEER_NAME, DECENT_PEER_STATUS_MESSAGE, DECENT_GREETING_TEXT | see above, "" | Profile defaults; greeting sent to new friends |
DECENT_EXPRESS_PULL_INTERVAL_MS | 4000 | How often the Express relay is polled |
DECENT_TEXT_AUTO_ACK_TIMEOUT_MS | 15000 | How long sendText waits for a protocol-v2 friend's acknowledgement |
DECENT_FRIEND_CONNECTION_LOOP_MS | 250 | Friend-connection loop tick |
DECENT_DHT_PK_ANNOUNCE_COOLDOWN_MS | 25000 | Minimum gap between lookups for the same friend |
DECENT_ONION_LOOKUP_MAX_BACKOFF_MS | 120000 | Backoff cap. This is the longest it can take to notice that a friend has come back online. |
DECENT_FRIEND_ROUTE_MAX_ATTEMPTS | 24 | Nodes queried per discovery sweep |
DECENT_ONION_DATA_ATTEMPTS | 5 | Retries per onion data packet |
DECENT_SELF_ANNOUNCE_INTERVAL_MS, DECENT_SELF_ANNOUNCE_TARGETS | 20000, 16 | Self-announce cadence and breadth |
DECENT_TCP_MAX_RELAYS | 3 | Concurrent TCP relay connections |
DECENT_TURN_SERVERS | built-in | host: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 # JSONThe 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-jsandflatbuffers; 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
Peerper identity. Several peers can share a process if each has its ownkeyFiledirectory, 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
- Verified version: 0.1.172 (npm
lateston 2026-11-06). - npm: https://www.npmjs.com/package/@decentnetwork/peer
- Source: the source repository is not public yet. The published code (compiled JavaScript plus
.d.ts) is in the npm package: https://www.npmjs.com/package/@decentnetwork/peer?activeTab=code - Changelog: none is published for this package.
- License: GPL-3.0-or-later.
- Protocol reference: the native Carrier SDK this package is wire-compatible with is public at https://github.com/decentnetworks/Elastos.NET.Carrier.Native.SDK