tr Truffle

API reference · @vibecook/truffle

Simple network programming on top of Tailscale.

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

Install

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';
Requires Node.js 18+ and a Tailscale tailnet. First run authenticates through a Tailscale URL unless you provide an authKey.

Orientation

API map

Everything hangs off a started MeshNode. Use this map to choose the right altitude for a feature.

MeshNode API surface map createMeshNode produces a MeshNode with peer APIs, transport namespaces, app primitives, and publish helpers. createMeshNode(options) → MeshNode MeshNode mesh · Peer registry Peers getPeers · peer() onPeerChange · ping getLocalInfo · health RFC 022 Transports net · http · dgram ws · quic openTcp · connectQuic RFC 021 App APIs send · broadcast onMessage fileTransfer · syncedStore RFC 014 · 016 Publish serve({ target|dir|routes }) http.createServer proxy() RFC 023

Rule of thumb: you wrote the HTTP handler → mesh.http.createServer. Bytes are a directory or another process → mesh.serve.

Discover Peer · getPeers · peer()
Stream bytes net · quic · dgram · ws
App data messages · files · stores
Expose HTTP serve · http · proxy

Mesh node

createMeshNode(options)

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'));

CreateMeshNodeOptions

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.

MeshNode shape

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

Peer handles (RFC 022)

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

Display

Safe fields

displayName, online, ip, hostname, os. Do not gate send on wsConnected.

Lookup

mesh.peer(query)

Name, ULID (or unique prefix ≥4), hostname slug, Tailscale id, or IP. waitMs blocks until resolvable.

Persist

deviceId only

Save when non-null. Do not persist peer.ref (process-local generation token).

class Peer

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

mesh.getLocalInfo(): NapiNodeIdentity

Returns local appId, deviceId, deviceName, Tailscale hostname, Tailscale ID, DNS name, and IP when available.

await mesh.getPeers(): Peer[]

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.

await mesh.peer(query, opts?: { waitMs?: number }): Peer | null

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.

await mesh.ping(to: PeerLike): NapiPingResult

Handle-first ping. String form is a query, resolved internally.

mesh.onPeerChange(callback)

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.

await mesh.health(): NapiHealthInfo

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;
Prefer mesh.peer(query) or pass a PeerLike into networking APIs. Raw NapiNode.getPeers() returns snapshot objects without hard ===; use createMeshNode() for interned handles.

Transport

mesh.net — TCP streams

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

mesh.http — HTTP over mesh TCP

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.
A served port is reachable by the whole tailnet (Tailscale ACLs), not only same-appId Truffle peers. Mesh messaging remains app-scoped.

Transport

mesh.dgram — UDP datagrams

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.
UDP is unreliable and unordered. Keep payloads around 1,200 bytes or smaller to avoid tailnet MTU fragmentation problems.

Transport

mesh.ws — WebSocket over mesh TCP

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

mesh.quic — multiplexed streams

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.
Ports 443 and 9417 are reserved. QUIC port 0 is not supported over the tsnet relay.

Publish

mesh.serve — declarative HTTP on the tailnet

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.
Common options: tls (default true), name, id, allow (loginName globs), allowNonLoopback (opt-in pivot). Prefer mesh.dnsName / ServeHandle.url over hand-built hostnames.

Application API

Namespaced messaging

Messages are routed by namespace over Truffle’s internal session transport. Prefer explicit wire typessendJson / 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

File transfer

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

Synced stores

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

Reverse proxy

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

@vibecook/truffle-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

Native classes

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.

Node

NapiNode

Manual lifecycle, discovery, messaging, raw TCP/UDP/QUIC, file transfer, proxy, and synced stores. String peer parameters are route/query strings.

Peer

Peer (packages/core)

User-facing handle with hard === via PeerRegistry. Exported with isPeer and peerLikeToQuery.

TCP

NapiTcpSocket, NapiTcpListener

Pull-model raw TCP handles. The high-level package wraps these as streams.

UDP / QUIC

NapiUdpSocket, NapiQuic*

Raw datagram and QUIC connection/listener/stream handles.

Most application code should start with createMeshNode(), use Peer handles, and the mesh.* namespaces.

Companion

CLI commands

Useful for manual operations, debugging, and agent automation. Apps should integrate the JavaScript API as the product surface.

Install

# 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

Quick start

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

Additional commands

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

Truffle Swift (RFC 024)

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.
Status: Phase 0/1 exit criteria are not met — the product core and wire contracts are tested end-to-end on macOS via an in-memory loopback backend; 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.