tr Truffle

Built on Tailscale

Simple network programming on top of 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 for identity & tunnels Node-shaped APIs TCP · HTTP · UDP · WS · QUIC
1 node joins the mesh
Peer handle for every device
0 servers you host
private tailnet app-scoped mesh
Truffle mesh on a Tailscale tailnet Three devices on one encrypted tailnet form an app mesh. Discovery comes from Tailscale; messaging and sockets ride Truffle transports. laptop online · direct server online · direct phone offline Tailscale WireGuard fabric Truffle appId: field-tools

Tailscale owns tunnels, ACLs, and crypto. Truffle owns discovery-aware peers, app isolation (appId), and the APIs your code calls.

Why Truffle

Local-first apps need a mesh — not another hosted backend.

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.

Without Truffle

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
With Truffle

You write app logic on Peers

  • One createMeshNode() per process
  • Interned Peer handles for dial / send / maps
  • Node-shaped net, http, dgram, ws, quic
  • Messages, SHA-256 file transfer, synced stores, serve

Built on Tailscale

Reuse tailnet identity, WireGuard encryption, ACLs, and MagicDNS. No public ports. No “our cloud in the middle.”

Familiar network APIs

If you know Node’s net / http / dgram, you already know most of the transport surface.

App-ready primitives

Namespaced messaging, verified file transfer, device-owned synced stores, reverse proxy, and declarative mesh.serve.

Mental model

One mesh node. Live peers. Transports that feel like Node.

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.

Start a node

createMeshNode({ appId }) boots the native core + Go sidecar (tsnet) inside your process.

Discover peers

Layer 3 Tailscale status is the source of truth. You get interned Peer objects, not raw id strings.

Talk on purpose

Send namespaced messages, open TCP/QUIC, serve HTTP, or push files — always addressed with a Peer (or a query that resolves to one).

Ship features

Build chat, agent tooling, shared state, local dashboards, or device meshes without operating a broker.

Core idea

Networking takes Peers

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.

deviceId is for persistence only

Save peer.deviceId when non-null. Rebind next run with mesh.peer(savedId, { waitMs }). Do not persist peer.ref.

peer handle lifecycle RFC 022
getPeers() events · messages Peer live handle send / dial net · quic · http always safe displayName · online · ip · hostname · os when known deviceId (ULID | null) — persist across restarts

Routing keys stay inside the library. Prefer mesh.peer('bob-laptop') or handles from events.

Architecture

Clean layers. One public Node surface.

Truffle’s core is deliberately layered so discovery, transport, sessions, and application primitives stay separable — while your app mostly sees createMeshNode().

L7
Applications Your code, CLI/TUI, React hooks, Tauri plugins, Swift (RFC 024) — chat, agents, dashboards, file workflows
L6
Envelope Namespace-routed message framing over the session bus
L5
Session Peer registry, lazy connections, reconnect, hello / identity
L4
Transport WebSocket bus, TCP, UDP, QUIC — protocol traits under familiar JS namespaces
L3
Network (Tailscale) Peer discovery via WatchIPNBus, WireGuard tunnels, MagicDNS — source of truth for online

What you can build

From raw sockets to whole local products.

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.

01

Device-to-device messaging

Namespaced envelopes for chat, control planes, agent events, and RPC-style request/reply patterns.

02

Verified file transfer

Push and pull with offers, progress, and SHA-256 verification — great for ops tooling and local agents.

03

Shared presence & state

Device-owned synced stores: each peer publishes its own versioned JSON slice, everyone observes the map.

04

Serve apps on the tailnet

Publish a local dev server, SPA, or Express app with mesh.serve / mesh.http.createServer — phone browsers included, no public internet.

Transport

Node-style TCP over the mesh

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.

echo-over-mesh.ts
// 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');
App API

Messages, files, synced stores

Higher-level helpers share the same node and Peer model. Prefer handles from getPeers(), events, or mesh.peer().

app-primitives.ts
// 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' });
Serve

Publish a local service to the tailnet

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).

serve-tailnet.ts
// 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

From install to first Peer in minutes.

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.

1 · Install

Add the package

Prebuilt native addon + sidecar binary are included. Add ws only if you use mesh.ws.

shell
pnpm add @vibecook/truffle
pnpm add ws   # optional — only for mesh.ws
2 · Join

Create an app-scoped mesh node

appId is the isolation boundary. Different app IDs do not see each other as Truffle peers, even on the same tailnet.

mesh.ts
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');
}
3 · Talk

Send with a Peer handle

Prefer handles. String overloads are queries (name, ULID, hostname, IP) — not a multi-id protocol you must learn.

chat.ts
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' });
CLI companion

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

One mesh stack, several ways to ship.

JavaScript @vibecook/truffle

Primary app API: createMeshNode, Peer handles, transports, files, stores, serve.

React @vibecook/truffle-react

useMesh, useAuth, and useSyncedStore over a started node.

CLI / TUI truffle

Interactive mesh view, slash commands, truffle serve, file transfer, JSONL for agents.

Swift apple/ · Truffle SPM

Apple-native mesh core (RFC 024): Peers, envelopes, MeshNode, SwiftUI MeshModel. Wire-compatible with desktop; live TailscaleKit backend still pending.

Rust truffle-core

Layered native core: network, transport, session, envelope, Node API.

Desktop truffle-tauri-plugin

Tauri v2 plugin for shipping mesh-backed desktop apps.

Native bridge @vibecook/truffle-native

NAPI-RS addon + platform sidecar binaries resolved at install time.

Next

Read the API map, then build something on your tailnet.

The reference documents every namespace, option, and Peer rule — with the same design language as this guide.