You re-build mesh plumbing
- Track device IDs, hostnames, and IPs by hand
- Roll your own discovery, reconnect, and framing
- Stand up a coordination server or brittle NAT tricks
- Re-implement file transfer, state sync, HTTP serving
Built on Tailscale
Truffle gives your Node apps familiar networking APIs — TCP, HTTP, UDP, WebSocket, QUIC — that run across devices on the same tailnet. Discover peers, open sockets, send messages, sync state, transfer files. No central server. No new protocol to learn.
Tailscale owns tunnels, ACLs, and crypto. Truffle owns discovery-aware peers, app
isolation (appId), and the APIs your code calls.
Why Truffle
If your users already run Tailscale (or you can put devices on a tailnet), the hard parts of private connectivity are solved. What is still hard is application networking: finding the right device, talking to it with normal protocols, and shipping higher-level features without inventing a protocol stack.
createMeshNode() per processPeer handles for dial / send / mapsnet, http, dgram, ws, quicReuse tailnet identity, WireGuard encryption, ACLs, and MagicDNS. No public ports. No “our cloud in the middle.”
If you know Node’s net / http / dgram, you already know most of the transport surface.
Namespaced messaging, verified file transfer, device-owned synced stores, reverse proxy, and declarative mesh.serve.
Mental model
Truffle does not invent a new identity system. It sits on your tailnet and gives each process a high-level node that discovery, sessions, and transports hang off of.
createMeshNode({ appId }) boots the native core +
Go sidecar (tsnet) inside your process.
Layer 3 Tailscale status is the source of truth. You get interned
Peer objects, not raw id strings.
Send namespaced messages, open TCP/QUIC, serve HTTP, or push files — always addressed with a Peer (or a query that resolves to one).
Build chat, agent tooling, shared state, local dashboards, or device meshes without operating a broker.
Discovery, events, and messages yield the same
Peer object for a live registry generation — so
Map<Peer, T> and ===
work. You do not pick between ULID, Tailscale id, and hostname for the happy path.
Save peer.deviceId when non-null. Rebind next run with
mesh.peer(savedId, { waitMs }). Do not persist
peer.ref.
Routing keys stay inside the library. Prefer
mesh.peer('bob-laptop') or handles from events.
Architecture
Truffle’s core is deliberately layered so discovery, transport, sessions, and
application primitives stay separable — while your app mostly sees
createMeshNode().
What you can build
Pick the altitude that matches the job. Low-level transports when you want protocol control. Higher-level helpers when you want local-first workflows with less plumbing.
mesh.net duplex streams & servers
HTTP
mesh.http + Express / Fastify
UDP
mesh.dgram lightweight signals
WebSocket
mesh.ws via the ws package
QUIC
many streams, no HOL blocking
Namespaced envelopes for chat, control planes, agent events, and RPC-style request/reply patterns.
Push and pull with offers, progress, and SHA-256 verification — great for ops tooling and local agents.
Device-owned synced stores: each peer publishes its own versioned JSON slice, everyone observes the map.
Publish a local dev server, SPA, or Express app with mesh.serve /
mesh.http.createServer — phone browsers included, no public internet.
Sockets are standard duplex streams. Replace the dial/listen layer; keep the rest of your stream pipeline.
Same idea for HTTP, UDP, WebSocket, and QUIC — see the API reference for full signatures.
// Bob listens
const server = mesh.net.createServer((socket) => {
socket.on('data', (chunk) => {
socket.write(Buffer.from('echo: ' + chunk.toString()));
});
});
server.listen(7000);
// Alice dials a Peer handle
const bob = await mesh.peer('bob-laptop', { waitMs: 5000 });
if (!bob) throw new Error('bob not found');
const socket = mesh.net.connect({ peer: bob, port: 7000 });
socket.write('hello over private TCP');
Higher-level helpers share the same node and Peer model. Prefer handles from
getPeers(), events, or mesh.peer().
// Namespaced messaging
mesh.onMessage('chat', async (msg) => {
if (typeof msg.from === 'string') return;
console.log(msg.from.displayName, msg.payload);
await msg.from.sendJson('chat', { text: 'ack' });
});
// File transfer (PeerLike)
const ft = mesh.fileTransfer();
ft.onOffer((offer, responder) => {
responder.accept('/tmp/' + offer.fileName);
});
const peer = (await mesh.getPeers())[0];
if (peer) await ft.sendFile(peer, './report.pdf', '/tmp/report.pdf');
// Device-owned state
const presence = mesh.syncedStore('presence');
await presence.set({ status: 'editing', file: 'notes.md' });
Use mesh.serve when the bytes are a directory or another process.
Use mesh.http.createServer when you wrote the handler
(Express, Fastify, plain node:http).
// Expose a local Vite/API process
const h = await mesh.serve({
port: 443,
target: 'http://localhost:3000',
});
console.log(h.url); // https://myapp.tail1234.ts.net
// Or a static SPA + API under one origin
await mesh.serve({
port: 443,
routes: {
'/api': 'http://localhost:8000',
'/': { dir: './public', fallback: '/index.html' },
},
});
Quick start
Requires Node.js 18+ and Tailscale installed & authenticated on the devices you want to connect. First run opens or reports a Tailscale auth URL unless you pass an auth key.
Prebuilt native addon + sidecar binary are included. Add
ws only if you use mesh.ws.
pnpm add @vibecook/truffle
pnpm add ws # optional — only for mesh.ws
appId is the isolation boundary. Different app IDs
do not see each other as Truffle peers, even on the same tailnet.
import { createMeshNode } from '@vibecook/truffle';
const mesh = await createMeshNode({
appId: 'field-tools',
deviceName: 'alice-laptop',
onAuthRequired: (url) => console.log('Auth URL:', url),
});
console.log(mesh.getLocalInfo());
for (const p of await mesh.getPeers()) {
console.log(p.displayName, p.online ? 'online' : 'offline');
}
Prefer handles. String overloads are queries (name, ULID, hostname, IP) — not a multi-id protocol you must learn.
mesh.onMessage('chat', async (msg) => {
if (typeof msg.from === 'string') return; // not interned yet
console.log(msg.from.displayName, msg.payload);
await msg.from.sendJson('chat', { text: 'ack' });
});
const bob = await mesh.peer('bob-laptop', { waitMs: 5000 });
if (bob) await bob.sendJson('chat', { text: 'hello' });
Humans and agents can also drive the mesh with
truffle up, truffle ls,
truffle send, truffle cp,
truffle serve,
and truffle watch --json. Apps should integrate the
JavaScript API; the CLI is for ops, debugging, and interactive use. Install with
curl -fsSL https://vibecook-dev.github.io/truffle/install.sh | sh
(or install.ps1 on Windows).
Surfaces
@vibecook/truffle
Primary app API: createMeshNode, Peer handles, transports, files, stores, serve.
@vibecook/truffle-react
useMesh, useAuth, and useSyncedStore over a started node.
truffle
Interactive mesh view, slash commands, truffle serve, file transfer, JSONL for agents.
apple/ · Truffle SPM
Apple-native mesh core (RFC 024): Peers, envelopes, MeshNode, SwiftUI
MeshModel. Wire-compatible with desktop; live TailscaleKit backend still pending.
truffle-core
Layered native core: network, transport, session, envelope, Node API.
truffle-tauri-plugin
Tauri v2 plugin for shipping mesh-backed desktop apps.
@vibecook/truffle-native
NAPI-RS addon + platform sidecar binaries resolved at install time.
Next
The reference documents every namespace, option, and Peer rule — with the same design language as this guide.