Safe fields
displayName, online, ip,
hostname, os. Do not gate send on
wsConnected.
API reference · @vibecook/truffle
Start one mesh node, get Peer handles, then use Node-shaped APIs for TCP,
HTTP, UDP, WebSocket, and QUIC — plus messaging, files, stores, and serve. Networking takes
Peer | string; you do not pick id strings for the happy path.
Install & import
The main package is @vibecook/truffle. WebSocket support
uses the standard ws package and is optional.
pnpm add @vibecook/truffle
pnpm add ws # optional, only for mesh.ws
import {
createMeshNode,
Peer,
resolveSidecarPath,
NapiNode,
} from '@vibecook/truffle';
authKey.
Orientation
Everything hangs off a started MeshNode. Use this map to
choose the right altitude for a feature.
Rule of thumb: you wrote the HTTP handler → mesh.http.createServer.
Bytes are a directory or another process → mesh.serve.
Mesh node
Creates and starts a Truffle node, resolves the sidecar binary, wires the Tailscale auth callback before startup, and attaches transport namespaces.
const mesh = await createMeshNode({
appId: 'my-app',
deviceName: 'alice-laptop',
onAuthRequired: (url) => console.log(url),
});
const peers = await mesh.getPeers();
const bob = await mesh.peer('bob-laptop', { waitMs: 5000 });
if (bob) await bob.send('chat', Buffer.from('hello'));
| Option | Type | Purpose |
|---|---|---|
appId |
string |
Required app namespace. Must match ^[a-z][a-z0-9-]{1,31}$.
Peers only see nodes with the same app ID.
|
deviceName |
string |
Human-readable device name. Defaults to the OS hostname. |
deviceId |
string |
Stable per-device ULID override. Generated and persisted when omitted. |
hostname |
string |
Explicit Tailscale hostname (RFC 023) for pretty serving URLs. Single DNS label
[a-z0-9-], 1–63 chars. Read the granted name from
mesh.dnsName.
|
sidecarPath |
string |
Override sidecar path. Defaults to resolveSidecarPath(). |
stateDir |
string |
Tailscale state directory. Defaults under user data for the app/device. |
authKey |
string |
Tailscale auth key for headless or CI startup. |
ephemeral |
boolean |
Whether the node is removed from the tailnet on shutdown. |
wsPort |
number |
Internal WebSocket listener port. Defaults to 9417. |
eagerIdentity |
boolean |
When true (default), proactively complete hello with online peers so durable
deviceId is learned without an app send.
|
autoAuth |
boolean |
Auto-open the Tailscale auth URL. Defaults to true. |
openUrl |
(url) => void |
Custom URL opener (Electron / desktop shells). |
onAuthRequired |
(url) => void |
Called when Tailscale auth is required. |
onPeerChange |
(event: MeshPeerEvent) => void |
Peer lifecycle events after startup. Events carry an interned
peer when available.
|
type PeerLike = Peer | string; // string = query, resolved internally
type MeshNode = {
// Namespaces
net: TruffleNet;
http: TruffleHttp;
quic: TruffleQuic;
dgram: TruffleDgram;
ws: TruffleWs;
serve: TruffleServe;
native: NapiNode;
// Identity / discovery
readonly dnsName: string | null;
getLocalInfo(): NapiNodeIdentity;
getPeers(): Promise<Peer[]>;
peer(query: string, opts?: { waitMs?: number }): Promise<Peer | null>;
ping(to: PeerLike): Promise<NapiPingResult>;
health(): Promise<NapiHealthInfo>;
onPeerChange(cb: (event: MeshPeerEvent) => void): void;
// Messaging — prefer explicit wire types over legacy send/broadcast
send(to: PeerLike, namespace: string, data: Buffer | Uint8Array): Promise<void>;
sendJson(peerId: string, namespace: string, payload: unknown): Promise<void>;
sendBytes(peerId: string, namespace: string, data: Buffer): Promise<void>;
broadcast(namespace: string, data: Buffer): Promise<void>;
broadcastJson(namespace: string, payload: unknown): Promise<NapiBroadcastReport>;
broadcastBytes(namespace: string, data: Buffer): Promise<NapiBroadcastReport>;
onMessage(namespace: string, cb: (msg: MeshNamespacedMessage) => void): void;
// Raw dials
openTcp(to: PeerLike, port: number): Promise<NapiTcpSocket>;
connectQuic(to: PeerLike, port: number): Promise<NapiQuicConnection>;
// Subsystems
fileTransfer(): MeshFileTransfer;
syncedStore(id: string): NapiSyncedStore;
proxy(): NapiProxy;
};
type MeshPeerEvent = {
type: string; // joined | left | updated | identity | ws_connected | …
peerId: string; // Tailscale routing key when peer-related
peer?: Peer; // interned handle when present
authUrl?: string;
};
type MeshNamespacedMessage = {
from: Peer | string; // Peer when sender is interned
namespace: string;
msgType: string;
payload: unknown;
timestamp?: number;
};
Identity & discovery
Networking takes Peer handles (or a query string). Do not
pick between ULID and Tailscale id for send / connect. A peer’s
deviceId is an honest ULID or null
— never a Tailscale id fallback. Use deviceId only to
persist across process restarts; rebind with
mesh.peer(savedId, { waitMs }).
displayName, online, ip,
hostname, os. Do not gate send on
wsConnected.
Name, ULID (or unique prefix ≥4), hostname slug, Tailscale id, or IP.
waitMs blocks until resolvable.
Save when non-null. Do not persist peer.ref (process-local generation token).
| Member | Type | Notes |
|---|---|---|
displayName |
string |
Best UI label (identity name → hostname slug → short ref). |
online |
boolean |
L3 peer liveness from Tailscale discovery. |
wsConnected |
boolean |
Envelope-bus WebSocket up. Advanced — do not gate send on this. |
ip, hostname, os, connectionType, lastSeen |
various | Display / diagnostics. |
deviceId |
string | null |
Durable ULID once known; null until identity is learned. Never equals
tailscaleId. Persist only when non-null.
|
deviceName |
string | null |
App-level device name from identity, when known. |
tailscaleId |
string |
Advanced routing key. Not for happy-path tutorials. |
ref |
PeerRef |
Process-local token for Map keys / IPC. Do not persist across restarts. |
generation |
number |
Increments when a peer leaves and rejoins. |
equals(other) |
boolean |
Same registry generation. Left+rejoin → false even with same Tailscale id. |
sendJson(namespace, payload) |
Promise<void> |
Preferred for structured data. Explicit JSON wire type — never content-sniffs. |
sendBytes(namespace, data) |
Promise<void> |
Preferred for opaque binary. Base64 "bytes" envelope on the wire. |
send(namespace, data) |
Promise<void> |
Legacy content-sniffing: UTF-8 JSON bytes travel as JSON, everything else as a
byte array. Prefer sendJson / sendBytes.
|
ping() |
Promise<NapiPingResult> |
Sugar for mesh.ping(this). |
Returns local appId, deviceId, deviceName,
Tailscale hostname, Tailscale ID, DNS name, and IP when available.
Interned Peer handles. The same object is returned from events and
messages for a live registry generation, so Map<Peer, T> and
=== work. Fields update in place as discovery and identity change.
Resolve a query to an interned Peer. Accepts display/device name, ULID (or unique
prefix ≥4 chars), hostname slug, Tailscale id, or tailnet IP. Not found →
null. Ambiguous names throw. waitMs blocks until resolvable
or timeout.
Handle-first ping. String form is a query, resolved internally.
Events include type (joined, left,
updated, identity, ws_connected,
ws_disconnected, auth-related), peerId, optional interned
peer, and optional authUrl. Prefer event.peer
for app state keyed by handle.
Returns Tailscale backend state, health warnings, key expiry, and a healthy flag.
// Preferred collections
const sessions = new Map(); // Map<Peer, AppSession>
mesh.onPeerChange((ev) => {
if (ev.type === 'joined' && ev.peer) sessions.set(ev.peer, openSession(ev.peer));
if (ev.type === 'left' && ev.peer) sessions.delete(ev.peer);
});
// Persistence across restarts — only place durable ids appear
if (peer.deviceId) localStorage.setItem('lastPeer', peer.deviceId);
const id = localStorage.getItem('lastPeer');
const again = id ? await mesh.peer(id, { waitMs: 5000 }) : null;
mesh.peer(query) or pass a
PeerLike into networking APIs. Raw
NapiNode.getPeers() returns snapshot objects without hard
===; use createMeshNode()
for interned handles.
Transport
Node-style TCP over the mesh. TruffleSocket is a
stream.Duplex; TruffleServer
emits listening, connection,
close, and error. Connect
targets are PeerLike.
const server = mesh.net.createServer((socket) => {
socket.pipe(socket);
});
server.listen(7000);
const bob = await mesh.peer('bob-laptop', { waitMs: 5000 });
const socket = mesh.net.connect({ peer: bob ?? 'bob-laptop', port: 7000 });
// host: PeerLike also accepted — mesh.net.connect({ host: bob, port: 7000 })
// Node-style: mesh.net.connect(7000, bob)
socket.on('connect', () => socket.write('ping'));
socket.on('data', (chunk) => console.log(chunk.toString()));
| Method | Description |
|---|---|
mesh.net.connect(options) |
Open a TCP stream to { peer?, host?, port }.
peer is an alias of host; both accept
PeerLike. Returns immediately.
|
mesh.net.connect(port, host) |
Node-compatible overload. host is PeerLike. |
mesh.net.createConnection(...) |
Alias of connect. |
mesh.net.createServer(listener) |
Create a mesh TCP server. Call listen(port) to bind. |
Transport
Outbound HTTP is a node:http agent that dials peers
through mesh.net. Inbound HTTP uses
mesh.http.createServer (drop-in
http.Server on the mesh) or an ordinary server fed by
mesh TCP sockets. Every mesh connection carries verified Tailscale identity on
req.socket.
// Serve (Express / Fastify / plain handlers attach unchanged)
const server = mesh.http.createServer((req, res) => {
res.end(JSON.stringify({ ok: true, path: req.url }));
});
server.listen(8080, () => {
console.log(`http://${mesh.dnsName ?? 'device'}:8080/`);
});
// Client helpers
const res = await mesh.http.fetchText('bob-laptop', 8080, '/health');
// Or dial with a Peer yourself
const bob = await mesh.peer('bob-laptop', { waitMs: 5000 });
if (bob) mesh.net.connect({ peer: bob, port: 8080 });
| Member | Description |
|---|---|
mesh.http.createServer(handler?) |
Drop-in node:http.createServer whose listen() binds the
mesh. Accepts Express/Fastify apps.
|
mesh.http.agent |
Shared MeshAgent instance for http.request. |
mesh.http.Agent |
Class for constructing a custom agent (e.g. keep-alive options). |
mesh.http.request(...) / get(...) |
http.request / http.get with the mesh agent injected. |
mesh.http.fetchText(host, port, path) |
GET helper buffering UTF-8 text. host is a query string resolved by
the mesh agent.
|
appId Truffle peers. Mesh messaging remains
app-scoped.
Transport
Node-style datagram API. Preserves message boundaries; emits
message, listening,
error, and close. Send
address is PeerLike.
const sock = mesh.dgram.createSocket();
sock.on('message', (msg, rinfo) => {
console.log(rinfo.peerName ?? rinfo.address, msg.toString());
});
await sock.bind(9999);
const bob = await mesh.peer('bob-laptop', { waitMs: 5000 });
sock.send(Buffer.from('ping'), 9999, bob ?? 'bob-laptop');
| Method | Description |
|---|---|
mesh.dgram.createSocket() |
Create an unbound UDP socket. |
await socket.bind(port = 0) |
Bind to a mesh port and start the receive pump. |
socket.send(msg, port, address, callback?) |
Send to a PeerLike address and port. Call bind() first. |
socket.close(callback?) |
Close the socket and emit close once. |
socket.address() |
Returns an object with the bound port. Mesh sockets have no local IP. |
Transport
Thin wrappers over the standard ws package. Optional and
loaded lazily — install ws only in apps that use this
namespace. connect host is
PeerLike.
const wss = await mesh.ws.createServer({ port: 7788, path: '/chat' });
wss.on('connection', (ws, req) => {
ws.on('message', (data) => ws.send('echo: ' + data.toString()));
});
const bob = await mesh.peer('bob-laptop', { waitMs: 5000 });
const ws = await mesh.ws.connect(bob ?? 'bob-laptop', 7788, '/chat');
ws.once('open', () => ws.send('hello'));
| Method | Description |
|---|---|
await mesh.ws.connect(host, port, path?, options?) |
Return a connecting ws client. host is
PeerLike. Wait for its open event.
|
await mesh.ws.createServer({ port, path? }) |
Start a WebSocket server on a mesh port. Returns a ws server with a port property. |
Transport
QUIC connections carry many concurrent bidirectional byte streams. Streams are
stream.Duplex instances; connections and servers are
async iterable. connect takes
PeerLike.
const server = await mesh.quic.listen(9420);
(async () => {
for await (const conn of server.connections()) {
(async () => {
for await (const stream of conn.streams()) {
stream.pipe(stream);
}
})();
}
})();
const bob = await mesh.peer('bob-laptop', { waitMs: 5000 });
const conn = await mesh.quic.connect(bob ?? 'bob-laptop', 9420);
const stream = await conn.openStream();
stream.write('hello');
stream.end();
| Method or class | Description |
|---|---|
await mesh.quic.connect(host, port) |
Open a QUIC connection to a PeerLike host. |
await mesh.quic.listen(port) |
Listen for QUIC connections. Use an explicit port. |
conn.openStream() |
Open a bidirectional stream. |
conn.acceptStream() |
Accept the next peer-opened stream, or null after close. |
conn.streams() |
Async iterator over peer-opened streams. |
server.connections() |
Async iterator over incoming connections. |
Publish
Publish a local service or directory to the whole tailnet (RFC 023). TLS defaults
on (browser audience / MagicDNS certs). Resolves with a
ServeHandle
({ id, url, port, config, close() }.
// Expose a local dev server
const h = await mesh.serve({ port: 443, target: 'http://localhost:3000' });
console.log(h.url); // https://myapp.tail1234.ts.net
// Static SPA
await mesh.serve({ port: 443, dir: './public', fallback: '/index.html' });
// Mixed routes (SPA + API)
await mesh.serve({
port: 443,
routes: {
'/api': 'http://localhost:8000',
'/grafana': { target: 'http://localhost:3001' },
'/': { dir: './public', fallback: '/index.html' },
},
});
| Config shape | When to use |
|---|---|
{ port, target } |
Forward a whole tailnet port to one local backend URL. |
{ port, dir, fallback? } |
Serve a static directory (SPA fallback optional). |
{ port, routes } |
Path map of proxies and static dirs under one origin. |
tls (default true),
name, id,
allow (loginName globs),
allowNonLoopback (opt-in pivot). Prefer
mesh.dnsName / ServeHandle.url
over hand-built hostnames.
Application API
Messages are routed by namespace over Truffle’s internal session transport. Prefer
explicit wire types — sendJson /
sendBytes and their broadcast counterparts — so the
wire type never depends on the data’s contents. Legacy
send / broadcast
still content-sniff UTF-8 JSON.
mesh.onMessage('chat', async (msg) => {
if (typeof msg.from === 'string') return;
console.log(msg.from.displayName, msg.msgType, msg.payload);
await msg.from.sendJson('chat', { text: 'ack' });
});
const peer = (await mesh.getPeers())[0];
if (peer) {
await peer.sendJson('chat', { text: 'hello' });
await peer.sendBytes('bin', Buffer.from([0x01, 0x02]));
}
// Broadcast only reaches peers with an active session (no lazy dial).
// broadcastJson / broadcastBytes return a NapiBroadcastReport.
const report = await mesh.broadcastJson('chat', { text: 'hello everyone' });
console.log(report.queued, '/', report.attempted, 'queued');
| Method | Description |
|---|---|
await peer.sendJson(namespace, payload) |
Preferred for structured data. Explicit JSON wire type. Also
mesh.sendJson(peerId, namespace, payload) on the native surface
(string route/query).
|
await peer.sendBytes(namespace, data) |
Preferred for opaque binary. Base64 "bytes" envelope;
receivers see msgType === "bytes". Also
mesh.sendBytes(peerId, …).
|
await mesh.send(to, namespace, data) |
Legacy content-sniffing. to is Peer | string. Prefer
peer.sendJson / sendBytes.
|
await mesh.broadcastJson(namespace, payload) |
Broadcast JSON to currently connected peers. Returns
{ attempted, queued, failed }. Does not lazy-connect peers.
|
await mesh.broadcastBytes(namespace, data) |
Broadcast opaque bytes; same report shape as broadcastJson. |
await mesh.broadcast(namespace, data) |
Legacy content-sniffing broadcast. Prefer broadcastJson / broadcastBytes. |
mesh.onMessage(namespace, callback) |
Subscribe to one namespace. msg.from is a Peer when the
sender is interned; otherwise a string id.
|
Application API
mesh.fileTransfer() returns a handle for push, pull,
offers, progress events, max-size policy, and pull-root policy. Targets accept
PeerLike (Peer | string).
const ft = mesh.fileTransfer();
ft.onOffer((offer, responder) => {
responder.accept('/tmp/' + offer.fileName);
});
ft.onEvent((event) => console.log(event.eventType, event.progress));
const peer = (await mesh.getPeers())[0];
if (peer) {
await ft.sendFile(peer, './report.pdf', '/tmp/report.pdf');
}
| Method | Description |
|---|---|
sendFile(to, localPath, remotePath) |
Push a local file to a peer (PeerLike). Resolves with bytes, SHA-256,
and elapsed time.
|
pullFile(to, remotePath, localPath) |
Pull a file from a peer into a local path. |
autoAccept(outputDir) |
Accept every incoming offer into a directory. |
autoReject() |
Reject every incoming offer. |
onOffer((offer, responder) => ...) |
Manually accept or reject incoming offers. |
onEvent(callback) |
Subscribe to transfer lifecycle and progress events. |
setMaxTransferSize(bytes) |
Set a maximum allowed transfer size. |
addPullRoot(root) |
Allow files under a canonicalized directory to be served to pull requests. |
pullRoots(), clearPullRoots() |
Inspect or clear the pull-serving allowlist. |
Application API
A synced store holds device-owned JSON slices. Each device updates its own slice and
observes slices from peers. Slice keys are durable device ULIDs — store identity, not a
networking address. Prefer Peer handles for dial/send; use store
deviceId to correlate slices after identity is known.
const store = mesh.syncedStore('presence');
store.onChange((event) => console.log(event));
await store.set({ status: 'online', file: 'notes.md' });
console.log(await store.local());
console.table(await store.all());
// Correlate a slice with a live Peer after identity is known
const me = mesh.getLocalInfo().deviceId;
for (const slice of await store.all()) {
if (slice.deviceId === me) continue;
const peer = await mesh.peer(slice.deviceId, { waitMs: 0 });
if (peer) console.log(peer.displayName, slice.data);
}
| Method | Description |
|---|---|
set(data) |
Update this device's JSON slice and broadcast it. |
local() |
Return this device's slice data or null. |
get(deviceId) |
Return one peer slice by durable ULID, or null. |
all() |
Return all known local and remote slices. |
deviceIds() |
Return durable device ULIDs that currently have data in the store. |
version() |
Return the current local version number. |
onChange(callback) |
Subscribe to local and peer slice events. |
stop() |
Stop the store and cancel event-forwarding tasks. |
Publish
mesh.proxy() exposes local services on mesh ports and can
announce them for discovery. Prefer mesh.serve for the
common “publish a directory or localhost process” cases — serve is sugar over this engine.
const proxy = mesh.proxy();
const info = await proxy.add({
id: 'local-api',
name: 'Local API',
listenPort: 8443,
targetHost: 'localhost',
targetPort: 3000,
targetScheme: 'http',
announce: true,
});
| Method | Description |
|---|---|
add(config) |
Add a reverse proxy and return its URL and status. |
remove(id) |
Remove a proxy by ID. |
list() |
List active proxies. |
onEvent(callback) |
Subscribe to proxy started, stopped, and error events. |
React
Thin hooks around an already-started node. Prefer a
MeshNode from
createMeshNode() and pass
mesh.native where hooks still expect
NapiNode. Hook peer snapshots currently track native
NapiPeer fields; send helpers still take a peer query
string. For handle-first networking, keep Peers from
mesh.getPeers() / events and call
peer.send yourself.
import { useMesh, useAuth, useSyncedStore } from '@vibecook/truffle-react';
function Peers({ node }) {
const { peers, send, broadcast } = useMesh(node);
const auth = useAuth(node);
const presence = useSyncedStore(node, 'presence');
// send still takes a query string today
// await send(peers[0]?.deviceId ?? peers[0]?.displayName, 'chat', { text: 'hi' });
}
| Hook | Returns |
|---|---|
useMesh(node) |
Peer list, local info, started flag, JSON send(peerQuery, …) helper,
JSON broadcast helper.
|
useAuth(node) |
Auth status, auth URL, and boolean convenience flags. |
useSyncedStore<T>(node, storeId) |
Local data, all slices, device ULIDs, version, and a setter. |
Lower-level handles
The package re-exports classes and types from
@vibecook/truffle-native for apps that need the lower-level
pull-model handles. Prefer createMeshNode() for the TS
Peer registry and stream wrappers.
Manual lifecycle, discovery, messaging, raw TCP/UDP/QUIC, file transfer, proxy, and synced stores. String peer parameters are route/query strings.
User-facing handle with hard === via PeerRegistry.
Exported with isPeer and peerLikeToQuery.
Pull-model raw TCP handles. The high-level package wraps these as streams.
Raw datagram and QUIC connection/listener/stream handles.
createMeshNode(),
use Peer handles, and the
mesh.* namespaces.
Companion
Useful for manual operations, debugging, and agent automation. Apps should integrate the JavaScript API as the product surface.
# macOS / Linux
curl -fsSL https://vibecook-dev.github.io/truffle/install.sh | sh
# Windows
iwr -useb https://vibecook-dev.github.io/truffle/install.ps1 | iex
truffle up # start node / daemon
truffle ls # list peers
truffle send laptop "deploy is done"
truffle cp ./report.pdf server:/tmp/
truffle serve http://localhost:3000 --port 443
truffle ping laptop
truffle
truffle up --device-name alice-laptop
truffle down
truffle status --json
truffle ls --long
truffle ping bob-laptop -c 4
truffle tcp bob-laptop:7000
truffle send bob-laptop "hello"
truffle cp ./report.pdf bob-laptop:/tmp/
truffle serve http://localhost:3000 --port 443
truffle serve ./public --port 443 --fallback /index.html
truffle serve status
truffle serve stop serve-443
truffle watch --json
truffle wait bob-laptop --timeout 30
truffle recv --from bob-laptop --timeout 30
truffle proxy add --name api --target-port 3000 --listen-port 8443
truffle proxy list
truffle doctor
truffle update
truffle serve publishes one route on one port (URL
reverse-proxy or static directory). Compose multi-route origins with
mesh.serve({ routes }) in JavaScript. See
serving-http.md.
Platforms
An Apple-native SPM package under
apple/
implements the same mesh product model — Peers, appId
isolation, namespaced messages — wire-compatible with truffle-core.
It is designed for in-process embedding on iOS/macOS via libtailscale / TailscaleKit
(no desktop sidecar).
| Target | Role |
|---|---|
Truffle |
Identity, hello v2 + envelope codecs, session handshake, generation-checked
Peer / PeerRef, MeshNode actor,
NetworkBackend seam + loopback test backend.
|
TruffleSwiftUI |
MeshModel (@Observable) and
AuthSafariView for interactive login.
|
TruffleTailscale |
TailscaleKit / libtailscale glue. Production
TailscaleKitBackend compiles when the xcframework is wired up.
|
MeshNode.start (production entry) throws until
TailscaleKit lands. Example:
MeshChatDemo
(iOS Simulator). Cross-runtime fixtures live in
apple/Tests/TruffleTests/Fixtures/ and are decoded by
both Swift and Rust. See
RFC 024
and the
apple README.