createSyncNode: jazz-napi JazzServer (pinned 2.0.0-alpha.53) + iroh via db-iroh-ffi (Deno.dlopen, nonblocking FFI, connections surface only). Peer upstream = WS-over-iroh tunnel (HELLO/TEXT/BIN/CLOSE frames, 1:1); pairing by LFN1 ticket. CLI: init/start/pair/status. Integration tests: ws echo over real iroh conns; two JazzServers paired over the tunnel (upstream /apps/<id>/ws handshake bridges end to end).
- commit
- 19245e3cd8ab8601c74b73479709f420c90ded92
- author
- Dami <dami.etoile@gmail.com>
| A | .gitignore | 4 | + |
| A | README.md | 95 | +++++++++++ |
| A | cli.ts | 153 | ++++++++++++++++++ |
| A | deno.json | 21 | ++ |
| A | deno.lock | 55 | ++++++ |
| A | mod.ts | 9 | + |
| A | src/ | 89 | ++++++++++ |
| A | src/ | 50 | ++++++ |
| A | src/ | 120 | ++++++++++++++ |
| A | src/ | 54 | ++++++ |
| A | src/ | 84 | ++++++++++ |
| A | src/ | 51 | ++++++ |
| A | src/ | 154 | ++++++++++++++++++ |
| A | src/ | 36 | ++++ |
| A | src/ | 258 | ++++++++++++++++++++++++++++++ |
| A | testing/ | 45 | +++++ |
| A | tests/ | 45 | +++++ |
| A | tests/ | 37 | ++++ |
| A | tests/ | 57 | +++++++ |
| A | tests/ | 90 | ++++++++++ |
@@ -0,0 +1,95 @@
1+# @nzip/lofi-node2+3+A self-hostable sync node for [lofi](../lofi) apps: a Jazz 2.0 sync server4+(jazz-napi `JazzServer`) plus iroh node-to-node transport5+([db-iroh-ffi](../db-iroh-ffi) via `Deno.dlopen`). Browsers keep speaking6+Jazz's WebSocket protocol — `JAZZ_SERVER_URL` points at a node and lofi's7+`adapter: "jazz"` contract (DX-SYNC-01) is untouched. iroh lives entirely in8+the daemon: two nodes pair by ticket and replicate over a WS-over-iroh tunnel,9+dialed by key, no static IPs.10+11+**Status: private prove-out.** Consumes the sibling `../db-iroh-ffi` checkout12+(prebuilt or `target/release`) or `LOFI_NODE_IROH=<path>`; publishing waits on13+reconciling db-iroh-ffi to a game-agnostic surface (see below).14+15+## Layout16+17+| Piece | Where |18+|---|---|19+| One-constructor library | `src/node.ts` (`createSyncNode`) |20+| WS-over-iroh tunnel | `src/tunnel.ts` (HELLO/TEXT/BIN/CLOSE frames, 1 conn : 1 ws) |21+| FFI binding (connections surface only) | `src/native/iroh.ts`, `src/iroh/node.ts` |22+| Pairing ticket codec (`LFN1.…`) | `src/ticket.ts` |23+| Jazz wrapper (pinned `2.0.0-alpha.53`) | `src/jazz.ts` |24+| CLI: init / start / pair / status | `cli.ts` |25+| In-process test mesh (no iroh) | `testing/mod.ts` |26+27+## Usage28+29+```sh30+deno run -A cli.ts init # data dir, app id, secrets, iroh key31+deno run -A cli.ts start # prints Jazz URL + pairing ticket32+deno run -A cli.ts pair LFN1.… # persist peer election; restart to apply33+```34+35+Library:36+37+```ts38+import { createSyncNode } from "@nzip/lofi-node";39+const node = await createSyncNode({40+ appId, backendSecret, adminSecret,41+ dataDir: "./data",42+ upstream: { peer: "LFN1.…" }, // or { url: "wss://cloud.jazz.tools/…" } or "none"43+});44+node.url; // -> JAZZ_SERVER_URL45+node.ticket(); // -> share with the peer46+```47+48+## Design decisions49+50+- **Nonblocking FFI, not doorbearer's pump model.** Parking calls (`db_accept`,51+ `db_conn_recv_msg`, `db_connect`, node open/close) are declared52+ `nonblocking: true` and park FFI-pool threads, not the isolate. Safe because53+ db-iroh-ffi's `send_msg` posts to a writer-task channel (never parks) on54+ locks disjoint from the recv path, and registry/poison state is per-handle.55+- **The tunnel terminates WS on both ends**, so the dialer forwards the upgrade56+ path/subprotocol in a HELLO frame and the acceptor replays them against its57+ local JazzServer.58+- **No silent degradation** (lofi's boot-gate ethos): a missing/unsupported59+ dylib leaves the Jazz server up but reports `mesh: unavailable — <reason>`,60+ and pairing throws `MeshUnavailableError`.61+- **Version invariant:** this package pins the exact `jazz-napi` alpha that the62+ consuming lofi app pins (`2.0.0-alpha.53`). Wire compat across alphas is not63+ guaranteed; bump in lockstep.64+65+## db-iroh-ffi reconciliation list (before publishing)66+67+Found while consuming the crate generically (tracked for the de-game-ification68+pass in the db-iroh-ffi repo):69+70+1. **`db_accept` cancellation** — nothing wakes a parked accept; `db_node_close`71+ removes the handle but `incoming.pop()` never resolves. A daemon can only72+ tear down by process exit. Wants `db_node_wake`/accept-with-deadline.73+2. **Env var / naming** — `DOORBEARER_IROH` → neutral (we use `LOFI_NODE_IROH`);74+ crate/symbol prefix `db_` reads as "Doorbearer"; fine to keep, but README75+ framing ("the game") should go generic.76+3. **Prebuilt distribution** — versioned release artifacts with checksums77+ (in-repo prebuilts are fine for submodules, not for JSR consumers).78+4. **`MAX_FRAME`/`CHUNK` tuning** are sized to game payloads (64 MiB cap is79+ plenty for Jazz sync frames; just document the contract).80+81+## Tests82+83+```sh84+deno task test # codec units always; iroh tunnel + jazz boot integration85+ # auto-skip when the dylib / napi build is unavailable86+```87+88+## Roadmap89+90+- Convergence test: lofi's two-client Playwright fixtures against two paired91+ nodes (the real spike exit-criterion).92+- Runtime `pair()` without restart (needs JazzServer restart orchestration).93+- `deno compile` packaging with embedded dylibs (doorbearer's94+ extract-to-versioned-cache loader).95+- Away-from-home story: `upstream: "cloud"` chaining behind one flag.@@ -0,0 +1,153 @@
1+// lofi-node CLI — init / start / pair / status. Prove-out scope: `pair`2+// persists the election and asks for a restart (no daemon IPC yet).3+4+import { initConfig, loadConfig, saveConfig } from "./src/config.ts";5+import { createSyncNode } from "./src/node.ts";6+import { decodeTicket } from "./src/ticket.ts";7+8+const USAGE = `lofi-node — self-hostable Jazz sync node with iroh transport9+10+Usage:11+ lofi-node init [--dir <dataDir>] [--app-id <id>] [--port <n>]12+ lofi-node start [--dir <dataDir>]13+ lofi-node pair <ticket> [--dir <dataDir>]14+ lofi-node status [--dir <dataDir>]15+16+The data directory defaults to ./lofi-node-data.`;17+18+interface Args {19+ command: string;20+ positional: string[];21+ dir: string;22+ appId?: string;23+ port?: number;24+}25+26+function parseArgs(argv: string[]): Args {27+ const args: Args = { command: argv[0] ?? "", positional: [], dir: "./lofi-node-data" };28+ for (let i = 1; i < argv.length; i++) {29+ const arg = argv[i];30+ if (arg === "--dir") args.dir = argv[++i];31+ else if (arg === "--app-id") args.appId = argv[++i];32+ else if (arg === "--port") args.port = Number(argv[++i]);33+ else args.positional.push(arg);34+ }35+ return args;36+}37+38+function fail(message: string): never {39+ console.error(`error: ${message}`);40+ Deno.exit(1);41+}42+43+async function requireConfig(dir: string) {44+ const config = await loadConfig(dir);45+ if (!config) fail(`no config at ${dir}/config.json — run: lofi-node init --dir ${dir}`);46+ return config;47+}48+49+async function cmdInit(args: Args) {50+ const existing = await loadConfig(args.dir);51+ if (existing) fail(`already initialized: ${args.dir}/config.json`);52+ const config = await initConfig(args.dir, { appId: args.appId, listenPort: args.port });53+ console.log(`initialized ${args.dir}`);54+ console.log(` app id: ${config.appId}`);55+ console.log(` listen: ${config.listenPort ?? "(auto)"}`);56+ console.log(`\nNext: lofi-node start --dir ${args.dir}`);57+}58+59+async function cmdStart(args: Args) {60+ const config = await requireConfig(args.dir);61+ const node = await createSyncNode({62+ appId: config.appId,63+ backendSecret: config.backendSecret,64+ adminSecret: config.adminSecret,65+ dataDir: args.dir,66+ listen: config.listenPort ? { port: config.listenPort } : undefined,67+ upstream: config.upstream,68+ allowLocalFirstAuth: config.allowLocalFirstAuth,69+ });70+ const status = node.status();71+ console.log(`lofi-node up`);72+ console.log(` app id: ${node.appId}`);73+ console.log(` jazz url: ${node.url} <- point JAZZ_SERVER_URL here`);74+ console.log(` storage: ${status.jazz.storage} (${args.dir})`);75+ console.log(76+ ` upstream: ${77+ status.upstream === "none"78+ ? "none"79+ : "url" in status.upstream80+ ? status.upstream.url81+ : "peer (over iroh)"82+ }`,83+ );84+ if (status.mesh.state === "up") {85+ console.log(` mesh: up (node ${status.mesh.nodeId.slice(0, 16)}…)`);86+ console.log(` ticket: ${status.mesh.ticket}`);87+ } else if (status.mesh.state === "unavailable") {88+ console.log(` mesh: UNAVAILABLE — ${status.mesh.reason}`);89+ console.log(` (LAN-only Jazz server; pairing disabled)`);90+ }91+ console.log(`\nCtrl-C to stop.`);92+ Deno.addSignalListener("SIGINT", async () => {93+ console.log("\nstopping…");94+ await node.stop();95+ // Parked db_accept threads cannot be woken (upstream gap); exit hard.96+ Deno.exit(0);97+ });98+ await new Promise(() => {});99+}100+101+async function cmdPair(args: Args) {102+ const ticket = args.positional[0];103+ if (!ticket) fail("usage: lofi-node pair <ticket>");104+ if (!decodeTicket(ticket)) fail("malformed ticket (expected LFN1.…)");105+ const config = await requireConfig(args.dir);106+ config.upstream = { peer: ticket };107+ await saveConfig(args.dir, config);108+ console.log("pairing saved. Restart the node to apply: lofi-node start");109+}110+111+async function cmdStatus(args: Args) {112+ const config = await requireConfig(args.dir);113+ console.log(` app id: ${config.appId}`);114+ console.log(` listen: ${config.listenPort ?? "(auto)"}`);115+ console.log(116+ ` upstream: ${117+ config.upstream === "none"118+ ? "none"119+ : "url" in config.upstream120+ ? config.upstream.url121+ : "peer (over iroh)"122+ }`,123+ );124+ if (config.listenPort) {125+ try {126+ const res = await fetch(`http://127.0.0.1:${config.listenPort}/health`, {127+ signal: AbortSignal.timeout(1500),128+ });129+ console.log(` daemon: ${res.status === 200 ? "running" : `unhealthy (${res.status})`}`);130+ } catch {131+ console.log(` daemon: not running`);132+ }133+ }134+}135+136+const args = parseArgs(Deno.args);137+switch (args.command) {138+ case "init":139+ await cmdInit(args);140+ break;141+ case "start":142+ await cmdStart(args);143+ break;144+ case "pair":145+ await cmdPair(args);146+ break;147+ case "status":148+ await cmdStatus(args);149+ break;150+ default:151+ console.log(USAGE);152+ Deno.exit(args.command ? 1 : 0);153+}@@ -0,0 +1,21 @@
1+{2+ "name": "@nzip/lofi-node",3+ "version": "0.1.0",4+ "exports": {5+ ".": "./mod.ts",6+ "./testing": "./testing/mod.ts",7+ "./cli": "./cli.ts"8+ },9+ "nodeModulesDir": "auto",10+ "imports": {11+ "jazz-napi": "npm:jazz-napi@2.0.0-alpha.53"12+ },13+ "tasks": {14+ "check": "deno check mod.ts cli.ts testing/mod.ts tests/",15+ "test": "deno test -A tests/",16+ "start": "deno run -A cli.ts start"17+ },18+ "fmt": {19+ "lineWidth": 10020+ }21+}@@ -0,0 +1,55 @@
1+{2+ "version": "5",3+ "specifiers": {4+ "jsr:@std/assert@1": "1.0.19",5+ "jsr:@std/internal@^1.0.12": "1.0.14",6+ "npm:jazz-napi@2.0.0-alpha.53": "2.0.0-alpha.53"7+ },8+ "jsr": {9+ "@std/assert@1.0.19": {10+ "integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e",11+ "dependencies": [12+ "jsr:@std/internal"13+ ]14+ },15+ "@std/internal@1.0.14": {16+ "integrity": "291516b3d4c35024d6ffbc0a9df5bf4c64116e05b50012cf846710152d2ffdf7"17+ }18+ },19+ "npm": {20+ "@garden-co/jazz-napi-darwin-arm64@2.0.0-alpha.53": {21+ "integrity": "sha512-CqDtWl9+coVdCvMfhA5vQR8ZiqCztLj0QLG9NKg96nqZ/ZTB7Pt4JPT4ipfUf3COtzzkPY5MU6lv5zk92Kdp4Q==",22+ "os": ["darwin"],23+ "cpu": ["arm64"]24+ },25+ "@garden-co/jazz-napi-darwin-x64@2.0.0-alpha.53": {26+ "integrity": "sha512-WpFZq5nBOAuL8chWTSzqqcYfcjki57ngmgN0+TrFHbARNtX+BFl5HepIsHceJaDxV6iid9XWu0lGzBMZ+5osYw==",27+ "os": ["darwin"],28+ "cpu": ["x64"]29+ },30+ "@garden-co/jazz-napi-linux-x64-gnu@2.0.0-alpha.53": {31+ "integrity": "sha512-4hnSynKd5nOhwoV3W4KfQ5BRcqLaiBghDZQJG5187QdUOBvoOssJaBdO8cvvEAKPW4/c1jShwk1WSO1aCI8rtQ==",32+ "os": ["linux"],33+ "cpu": ["x64"]34+ },35+ "@garden-co/jazz-napi-win32-x64-msvc@2.0.0-alpha.53": {36+ "integrity": "sha512-TNPa9uHA29eq3q5LgaBP61D5ukLaSjcFGxcsMSMuFff5spXTMSFyo9YSeBe9DaLVQgdtmzjLgptOnZIVRXK0lw==",37+ "os": ["win32"],38+ "cpu": ["x64"]39+ },40+ "jazz-napi@2.0.0-alpha.53": {41+ "integrity": "sha512-5+EEUoahaNA2d0IqvYDlmrx9bjhLjpvkbt5Nab+C09EeKGnJcz7JJbofC0xuJDe++9I7/8cnYps7YcAYjdK3Ug==",42+ "optionalDependencies": [43+ "@garden-co/jazz-napi-darwin-arm64",44+ "@garden-co/jazz-napi-darwin-x64",45+ "@garden-co/jazz-napi-linux-x64-gnu",46+ "@garden-co/jazz-napi-win32-x64-msvc"47+ ]48+ }49+ },50+ "workspace": {51+ "dependencies": [52+ "npm:jazz-napi@2.0.0-alpha.53"53+ ]54+ }55+}@@ -0,0 +1,9 @@
1+// @nzip/lofi-node — self-hostable Jazz sync node with iroh node-to-node2+// transport. Private prove-out; see README for status and roadmap.3+4+export { createSyncNode } from "./src/node.ts";5+export type { MeshStatus, SyncNode, SyncNodeOptions, SyncNodeStatus } from "./src/node.ts";6+export { initConfig, loadConfig, saveConfig } from "./src/config.ts";7+export type { NodeConfig, UpstreamConfig } from "./src/config.ts";8+export { decodeTicket, encodeTicket } from "./src/ticket.ts";9+export { IrohFfiError, IrohPoisonedError, MeshUnavailableError } from "./src/errors.ts";@@ -0,0 +1,89 @@
1+// dataDir layout: config.json (app id, secrets, upstream election) and2+// iroh.key (32-byte node secret, hex, 0600). Secrets are generated on init3+// and persisted — the node's identity and its Jazz credentials both survive4+// restarts, so tickets and client sessions stay stable.5+6+export type UpstreamConfig = "none" | { url: string } | { peer: string };7+8+export interface NodeConfig {9+ v: 1;10+ appId: string;11+ backendSecret: string;12+ adminSecret: string;13+ listenPort?: number;14+ upstream: UpstreamConfig;15+ allowLocalFirstAuth: boolean;16+}17+18+function randomToken(prefix: string): string {19+ const bytes = crypto.getRandomValues(new Uint8Array(24));20+ const b64 = btoa(String.fromCharCode(...bytes)).replaceAll("+", "-").replaceAll("/", "_")21+ .replaceAll("=", "");22+ return `${prefix}_${b64}`;23+}24+25+function toHex(bytes: Uint8Array): string {26+ return [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");27+}28+29+function fromHex(text: string): Uint8Array {30+ const out = new Uint8Array(text.length / 2);31+ for (let i = 0; i < out.length; i++) out[i] = parseInt(text.slice(i * 2, i * 2 + 2), 16);32+ return out;33+}34+35+export function configPath(dataDir: string): string {36+ return `${dataDir}/config.json`;37+}38+39+export async function loadConfig(dataDir: string): Promise<NodeConfig | null> {40+ try {41+ const raw = await Deno.readTextFile(configPath(dataDir));42+ const parsed = JSON.parse(raw) as NodeConfig;43+ if (parsed.v !== 1) throw new Error(`unsupported config version ${parsed.v}`);44+ return parsed;45+ } catch (e) {46+ if (e instanceof Deno.errors.NotFound) return null;47+ throw e;48+ }49+}50+51+export async function saveConfig(dataDir: string, config: NodeConfig): Promise<void> {52+ await Deno.mkdir(dataDir, { recursive: true });53+ await Deno.writeTextFile(configPath(dataDir), JSON.stringify(config, null, 2) + "\n");54+}55+56+export async function initConfig(57+ dataDir: string,58+ overrides: Partial<Pick<NodeConfig, "appId" | "listenPort" | "upstream">> = {},59+): Promise<NodeConfig> {60+ const existing = await loadConfig(dataDir);61+ if (existing) return existing;62+ const config: NodeConfig = {63+ v: 1,64+ appId: overrides.appId ?? randomToken("app_local"),65+ backendSecret: randomToken("lofi_backend"),66+ adminSecret: randomToken("lofi_admin"),67+ listenPort: overrides.listenPort,68+ upstream: overrides.upstream ?? "none",69+ allowLocalFirstAuth: true,70+ };71+ await saveConfig(dataDir, config);72+ return config;73+}74+75+/** Load-or-create the node's 32-byte iroh secret key. */76+export async function loadOrCreateIrohKey(dataDir: string): Promise<Uint8Array> {77+ const path = `${dataDir}/iroh.key`;78+ try {79+ const hex = (await Deno.readTextFile(path)).trim();80+ if (hex.length === 64) return fromHex(hex);81+ throw new Error(`corrupt iroh.key at ${path}`);82+ } catch (e) {83+ if (!(e instanceof Deno.errors.NotFound)) throw e;84+ }85+ const key = crypto.getRandomValues(new Uint8Array(32));86+ await Deno.mkdir(dataDir, { recursive: true });87+ await Deno.writeTextFile(path, toHex(key) + "\n", { mode: 0o600 });88+ return key;89+}@@ -0,0 +1,50 @@
1+// Status-code contract for the db-iroh-ffi flat C ABI (src/lib.rs consts).2+3+export const DB_OK = 0;4+5+const CODE_NAMES: Record<number, string> = {6+ [-1]: "UNIMPLEMENTED",7+ [-2]: "BAD_HANDLE",8+ [-3]: "BAD_ARG",9+ [-4]: "IO",10+ [-5]: "NOT_FOUND",11+ [-6]: "CRYPTO",12+ [-7]: "PANIC",13+};14+15+export const DB_ERR_BAD_HANDLE = -2;16+export const DB_ERR_IO = -4;17+export const DB_ERR_PANIC = -7;18+19+export class IrohFfiError extends Error {20+ readonly code: number;21+ constructor(code: number, context: string) {22+ super(`iroh ffi ${context}: ${CODE_NAMES[code] ?? "UNKNOWN"} (${code})`);23+ this.name = "IrohFfiError";24+ this.code = code;25+ }26+}27+28+/** DB_ERR_PANIC: the ONE handle that panicked is poisoned and must be closed29+ * and reopened; other handles and the process are unaffected. */30+export class IrohPoisonedError extends IrohFfiError {31+ constructor(context: string) {32+ super(DB_ERR_PANIC, context);33+ this.name = "IrohPoisonedError";34+ }35+}36+37+export function check(code: number, context: string): void {38+ if (code === DB_OK) return;39+ if (code === DB_ERR_PANIC) throw new IrohPoisonedError(context);40+ throw new IrohFfiError(code, context);41+}42+43+/** The iroh mesh layer could not be brought up (missing dylib, unsupported44+ * platform, ABI mismatch). The Jazz server itself still works without it. */45+export class MeshUnavailableError extends Error {46+ constructor(reason: string) {47+ super(`iroh mesh unavailable: ${reason}`);48+ this.name = "MeshUnavailableError";49+ }50+}@@ -0,0 +1,120 @@
1+// Typed wrappers over the connections surface: one class per C handle,2+// idempotent close, poison contract surfaced as IrohPoisonedError.3+4+import {5+ check,6+ DB_OK,7+ type IrohLib,8+ newDbBufStruct,9+ readDbBuf,10+} from "../native/iroh.ts";11+import { DB_ERR_BAD_HANDLE, DB_ERR_IO, IrohPoisonedError } from "../errors.ts";12+13+export class IrohConn {14+ #lib: IrohLib;15+ #handle: bigint;16+ #closed = false;17+18+ constructor(lib: IrohLib, handle: bigint) {19+ this.#lib = lib;20+ this.#handle = handle;21+ }22+23+ /** Post one framed message. Never parks: frames feed the crate's writer task. */24+ sendMsg(bytes: Uint8Array): void {25+ check(26+ this.#lib.symbols.db_conn_send_msg(this.#handle, bytes, BigInt(bytes.length)),27+ "conn_send_msg",28+ );29+ }30+31+ /** Await the next framed message; null once the connection is closed/ended.32+ * Parks an FFI-pool thread, not the isolate. */33+ async recvMsg(): Promise<Uint8Array | null> {34+ const out = newDbBufStruct();35+ const code = await this.#lib.symbols.db_conn_recv_msg(this.#handle, out);36+ if (code === DB_OK) return readDbBuf(this.#lib, out);37+ if (code === DB_ERR_IO || code === DB_ERR_BAD_HANDLE) return null;38+ check(code, "conn_recv_msg");39+ return null;40+ }41+42+ /** Idempotent. Wakes a parked recvMsg on this conn (per-handle registry). */43+ async close(): Promise<void> {44+ if (this.#closed) return;45+ this.#closed = true;46+ const code = await this.#lib.symbols.db_conn_close(this.#handle);47+ if (code !== DB_OK && code !== DB_ERR_BAD_HANDLE) check(code, "conn_close");48+ }49+}50+51+export class IrohNode {52+ #lib: IrohLib;53+ #handle: bigint;54+ #closed = false;55+56+ private constructor(lib: IrohLib, handle: bigint) {57+ this.#lib = lib;58+ this.#handle = handle;59+ }60+61+ static async open(lib: IrohLib, secretKey: Uint8Array): Promise<IrohNode> {62+ if (secretKey.length !== 32) throw new Error("iroh secret key must be 32 bytes");63+ const out = new BigUint64Array(1);64+ const outBytes = new Uint8Array(out.buffer);65+ check(await lib.symbols.db_node_open(secretKey, outBytes), "node_open");66+ return new IrohNode(lib, out[0]);67+ }68+69+ id(): Uint8Array {70+ const id = new Uint8Array(32);71+ check(this.#lib.symbols.db_node_id(this.#handle, id), "node_id");72+ return id;73+ }74+75+ /** Dialable postcard-encoded EndpointAddr (what tickets carry). */76+ async addr(): Promise<Uint8Array> {77+ const out = newDbBufStruct();78+ check(await this.#lib.symbols.db_node_addr(this.#handle, out), "node_addr");79+ return readDbBuf(this.#lib, out);80+ }81+82+ /** Seed a peer address so connect can dial without discovery. */83+ addAddr(addrBytes: Uint8Array): void {84+ check(85+ this.#lib.symbols.db_add_addr(this.#handle, addrBytes, BigInt(addrBytes.length)),86+ "add_addr",87+ );88+ }89+90+ async connect(addrBytes: Uint8Array): Promise<IrohConn> {91+ const out = new BigUint64Array(1);92+ check(93+ await this.#lib.symbols.db_connect(94+ this.#handle,95+ addrBytes,96+ BigInt(addrBytes.length),97+ new Uint8Array(out.buffer),98+ ),99+ "connect",100+ );101+ return new IrohConn(this.#lib, out[0]);102+ }103+104+ /** Await the next inbound connection. KNOWN GAP: not woken by close() —105+ * callers must treat a daemon-lifetime accept loop as process-scoped. */106+ async accept(): Promise<IrohConn> {107+ const out = new BigUint64Array(1);108+ check(await this.#lib.symbols.db_accept(this.#handle, new Uint8Array(out.buffer)), "accept");109+ return new IrohConn(this.#lib, out[0]);110+ }111+112+ async close(): Promise<void> {113+ if (this.#closed) return;114+ this.#closed = true;115+ const code = await this.#lib.symbols.db_node_close(this.#handle);116+ if (code !== DB_OK && code !== DB_ERR_BAD_HANDLE) check(code, "node_close");117+ }118+}119+120+export { IrohPoisonedError };@@ -0,0 +1,54 @@
1+// Thin wrapper over jazz-napi's JazzServer. Version invariant: this package2+// pins the exact jazz alpha the consuming lofi app pins; wire compatibility3+// across alphas is not guaranteed.4+5+import { JazzServer } from "jazz-napi";6+7+export interface JazzOptions {8+ appId: string;9+ backendSecret: string;10+ adminSecret: string;11+ port?: number;12+ dataDir?: string;13+ inMemory?: boolean;14+ upstreamUrl?: string;15+ allowLocalFirstAuth?: boolean;16+}17+18+export interface JazzHandle {19+ url: string;20+ port: number;21+ stop(): Promise<void>;22+}23+24+export async function allocatePort(): Promise<number> {25+ const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 });26+ const port = (listener.addr as Deno.NetAddr).port;27+ listener.close();28+ return port;29+}30+31+export async function startJazz(options: JazzOptions): Promise<JazzHandle> {32+ const port = options.port ?? (await allocatePort());33+ const server = await JazzServer.start({34+ appId: options.appId,35+ backendSecret: options.backendSecret,36+ adminSecret: options.adminSecret,37+ port,38+ dataDir: options.inMemory ? undefined : options.dataDir,39+ inMemory: options.inMemory,40+ upstreamUrl: options.upstreamUrl,41+ allowLocalFirstAuth: options.allowLocalFirstAuth ?? true,42+ });43+ let stopped: Promise<void> | null = null;44+ return {45+ url: toWsUrl(server.url),46+ port: server.port,47+ stop: () => (stopped ??= server.stop()),48+ };49+}50+51+/** JazzServer reports an http(s) URL; clients dial ws(s). */52+export function toWsUrl(url: string): string {53+ return url.replace(/^http/, "ws");54+}@@ -0,0 +1,84 @@
1+// Deno.dlopen binding for the db-iroh-ffi flat C ABI — connections surface2+// only (v1 needs node lifecycle + framed conn messages for the WS tunnel;3+// docs/blobs/gossip/estore stay in reserve).4+//5+// Parking calls (accept, recv_msg, connect, node open/close) are declared6+// `nonblocking` so they run on Deno's FFI thread pool and return Promises7+// instead of stalling the isolate. This is safe by the crate's own design:8+// send_msg posts to an unbounded writer-task channel (never parks) and uses9+// locks disjoint from the recv path, and registry/poison state is per-handle.10+// Known gap: a parked db_accept has NO cancellation — node close does not wake11+// incoming.pop(). The daemon relies on process exit; see README reconciliation12+// notes for the upstream fix (accept wake/timeout).13+14+import { check, DB_OK } from "../errors.ts";15+import { resolveIrohLib } from "./loader.ts";16+import { MeshUnavailableError } from "../errors.ts";17+18+export const IROH_ABI_VERSION = 1;19+20+export const IROH_SYMBOLS = {21+ db_iroh_abi_version: { parameters: [], result: "u32" },22+ db_node_open: { parameters: ["buffer", "buffer"], result: "i32", nonblocking: true },23+ db_node_id: { parameters: ["u64", "buffer"], result: "i32" },24+ db_node_addr: { parameters: ["u64", "buffer"], result: "i32", nonblocking: true },25+ db_add_addr: { parameters: ["u64", "buffer", "usize"], result: "i32" },26+ db_node_close: { parameters: ["u64"], result: "i32", nonblocking: true },27+ db_connect: { parameters: ["u64", "buffer", "usize", "buffer"], result: "i32", nonblocking: true },28+ db_accept: { parameters: ["u64", "buffer"], result: "i32", nonblocking: true },29+ db_conn_send_msg: { parameters: ["u64", "buffer", "usize"], result: "i32" },30+ db_conn_recv_msg: { parameters: ["u64", "buffer"], result: "i32", nonblocking: true },31+ db_conn_close: { parameters: ["u64"], result: "i32", nonblocking: true },32+ db_buf_free: { parameters: [{ struct: ["pointer", "usize"] }], result: "void" },33+} as const satisfies Deno.ForeignLibraryInterface;34+35+export type IrohLib = Deno.DynamicLibrary<typeof IROH_SYMBOLS>;36+37+/** Read a by-value DbBuf { ptr: u64, len: u64 } out-param: copy the foreign38+ * bytes into owned memory, then free the Rust allocation. */39+export function readDbBuf(lib: IrohLib, structBytes: Uint8Array): Uint8Array {40+ const view = new DataView(structBytes.buffer, structBytes.byteOffset, 16);41+ const ptr = view.getBigUint64(0, true);42+ const len = Number(view.getBigUint64(8, true));43+ let copy = new Uint8Array(0);44+ if (ptr !== 0n && len > 0) {45+ const foreign = Deno.UnsafePointerView.getArrayBuffer(Deno.UnsafePointer.create(ptr)!, len);46+ copy = new Uint8Array(foreign).slice();47+ }48+ lib.symbols.db_buf_free(structBytes);49+ return copy;50+}51+52+export function newDbBufStruct(): Uint8Array {53+ return new Uint8Array(16);54+}55+56+/** dlopen + ABI handshake. Throws MeshUnavailableError with a precise reason57+ * (no silent degradation — mirrors lofi's boot-gate ethos). */58+export function openIrohLib(explicitPath?: string): IrohLib {59+ const resolved = resolveIrohLib(explicitPath);60+ if (resolved.status === "unsupported-platform") {61+ throw new MeshUnavailableError(`no db-iroh-ffi build for ${resolved.platform}`);62+ }63+ if (resolved.status === "not-found") {64+ throw new MeshUnavailableError(65+ `dylib not found; tried: ${resolved.tried.join(", ")} (set LOFI_NODE_IROH)`,66+ );67+ }68+ let lib: IrohLib;69+ try {70+ lib = Deno.dlopen(resolved.path, IROH_SYMBOLS);71+ } catch (e) {72+ throw new MeshUnavailableError(`dlopen failed for ${resolved.path}: ${(e as Error).message}`);73+ }74+ const abi = lib.symbols.db_iroh_abi_version();75+ if (abi !== IROH_ABI_VERSION) {76+ lib.close();77+ throw new MeshUnavailableError(78+ `ABI version mismatch: dylib=${abi}, expected=${IROH_ABI_VERSION}`,79+ );80+ }81+ return lib;82+}83+84+export { check, DB_OK };@@ -0,0 +1,51 @@
1+// Resolve the db-iroh-ffi dylib for this platform. Private prove-out order:2+// explicit option → LOFI_NODE_IROH env → sibling checkout ../db-iroh-ffi →3+// submodule-style native/db-iroh-ffi. Embedding prebuilts into a compiled4+// binary (doorbearer's extract-to-cache pattern) comes with packaging, later.5+6+interface Triple {7+ dir: string;8+ file: string;9+}10+11+const TRIPLES: Record<string, Triple> = {12+ "darwin-aarch64": { dir: "aarch64-apple-darwin", file: "libdb_iroh_ffi.dylib" },13+ "darwin-x86_64": { dir: "x86_64-apple-darwin", file: "libdb_iroh_ffi.dylib" },14+ "linux-x86_64": { dir: "x86_64-unknown-linux-gnu", file: "libdb_iroh_ffi.so" },15+ "linux-aarch64": { dir: "aarch64-unknown-linux-gnu", file: "libdb_iroh_ffi.so" },16+ "windows-x86_64": { dir: "x86_64-pc-windows-gnu", file: "db_iroh_ffi.dll" },17+};18+19+export type IrohLibResolution =20+ | { status: "ok"; path: string }21+ | { status: "unsupported-platform"; platform: string }22+ | { status: "not-found"; tried: string[] };23+24+function exists(path: string): boolean {25+ try {26+ return Deno.statSync(path).isFile;27+ } catch {28+ return false;29+ }30+}31+32+export function resolveIrohLib(explicitPath?: string): IrohLibResolution {33+ const platform = `${Deno.build.os}-${Deno.build.arch}`;34+ const triple = TRIPLES[platform];35+ if (!triple) return { status: "unsupported-platform", platform };36+37+ const tried: string[] = [];38+ const candidates = [39+ explicitPath,40+ Deno.env.get("LOFI_NODE_IROH"),41+ `../db-iroh-ffi/target/release/${triple.file}`,42+ `../db-iroh-ffi/prebuilt/${triple.dir}/${triple.file}`,43+ `./native/db-iroh-ffi/prebuilt/${triple.dir}/${triple.file}`,44+ ];45+ for (const candidate of candidates) {46+ if (!candidate) continue;47+ tried.push(candidate);48+ if (exists(candidate)) return { status: "ok", path: candidate };49+ }50+ return { status: "not-found", tried };51+}@@ -0,0 +1,154 @@
1+// createSyncNode — the one constructor. Consumer vocabulary is app, storage,2+// upstream, pairing, status; iroh/dlopen/tunnel/jazz-napi stay internal.3+4+import { openIrohLib } from "./native/iroh.ts";5+import { IrohNode } from "./iroh/node.ts";6+import { decodeTicket, encodeTicket } from "./ticket.ts";7+import { runTunnelAcceptor, startTunnelListener, type TunnelAcceptor, type TunnelListener } from "./tunnel.ts";8+import { type JazzHandle, startJazz } from "./jazz.ts";9+import { loadOrCreateIrohKey, type UpstreamConfig } from "./config.ts";10+import { MeshUnavailableError } from "./errors.ts";11+12+export interface SyncNodeOptions {13+ appId: string;14+ backendSecret: string;15+ adminSecret: string;16+ /** Omit (or set inMemory) for an ephemeral node — tests, throwaway relays. */17+ dataDir?: string;18+ inMemory?: boolean;19+ /** Browser-facing Jazz WebSocket port; auto-allocated when omitted. */20+ listen?: { port?: number };21+ /** "none" (default) | direct URL (e.g. Jazz Cloud) | peer ticket over iroh. */22+ upstream?: UpstreamConfig;23+ /** "auto": bring up iroh if the dylib resolves; stay up without it unless a24+ * peer upstream NEEDS it. "off": plain Jazz server, dylib never loaded. */25+ mesh?: "auto" | "off";26+ allowLocalFirstAuth?: boolean;27+ /** Explicit dylib path (otherwise LOFI_NODE_IROH / sibling checkout). */28+ irohLibPath?: string;29+}30+31+export type MeshStatus =32+ | { state: "up"; nodeId: string; ticket: string }33+ | { state: "off" }34+ | { state: "unavailable"; reason: string };35+36+export interface SyncNodeStatus {37+ appId: string;38+ jazz: { url: string; port: number; storage: "persistent" | "memory" };39+ upstream: UpstreamConfig;40+ mesh: MeshStatus;41+}42+43+export interface SyncNode {44+ /** Browser-facing Jazz WebSocket URL — what JAZZ_SERVER_URL points at. */45+ url: string;46+ port: number;47+ appId: string;48+ /** Pairing ticket for this node; throws MeshUnavailableError if mesh is down. */49+ ticket(): string;50+ status(): SyncNodeStatus;51+ stop(): Promise<void>;52+}53+54+function toHexId(bytes: Uint8Array): string {55+ return [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");56+}57+58+export async function createSyncNode(options: SyncNodeOptions): Promise<SyncNode> {59+ const upstream: UpstreamConfig = options.upstream ?? "none";60+ const meshMode = options.mesh ?? "auto";61+ const inMemory = options.inMemory ?? options.dataDir === undefined;62+63+ // 1. Mesh (iroh) — optional unless the upstream election requires it.64+ let mesh: MeshStatus = { state: "off" };65+ let irohNode: IrohNode | null = null;66+ let ticketString: string | null = null;67+ if (meshMode === "auto") {68+ try {69+ const lib = openIrohLib(options.irohLibPath);70+ const key = options.dataDir71+ ? await loadOrCreateIrohKey(options.dataDir)72+ : crypto.getRandomValues(new Uint8Array(32));73+ irohNode = await IrohNode.open(lib, key);74+ ticketString = encodeTicket(await irohNode.addr());75+ mesh = { state: "up", nodeId: toHexId(irohNode.id()), ticket: ticketString };76+ } catch (e) {77+ if (e instanceof MeshUnavailableError) {78+ mesh = { state: "unavailable", reason: e.message };79+ } else {80+ throw e;81+ }82+ }83+ }84+85+ // 2. Resolve the upstream election to a concrete URL for JazzServer.86+ let upstreamUrl: string | undefined;87+ let tunnelListener: TunnelListener | null = null;88+ if (upstream !== "none" && "url" in (upstream as object)) {89+ upstreamUrl = (upstream as { url: string }).url;90+ } else if (upstream !== "none" && "peer" in (upstream as object)) {91+ const ticket = (upstream as { peer: string }).peer;92+ if (!irohNode) {93+ const reason = mesh.state === "unavailable" ? mesh.reason : "mesh is off";94+ throw new MeshUnavailableError(`peer upstream requires the mesh: ${reason}`);95+ }96+ const peerAddr = decodeTicket(ticket);97+ if (!peerAddr) throw new Error("malformed pairing ticket");98+ irohNode.addAddr(peerAddr);99+ tunnelListener = startTunnelListener(irohNode, peerAddr);100+ upstreamUrl = `ws://127.0.0.1:${tunnelListener.port}`;101+ }102+103+ // 3. Jazz server.104+ let jazz: JazzHandle;105+ try {106+ jazz = await startJazz({107+ appId: options.appId,108+ backendSecret: options.backendSecret,109+ adminSecret: options.adminSecret,110+ port: options.listen?.port,111+ dataDir: options.dataDir ? `${options.dataDir}/jazz` : undefined,112+ inMemory,113+ upstreamUrl,114+ allowLocalFirstAuth: options.allowLocalFirstAuth,115+ });116+ } catch (e) {117+ await tunnelListener?.close();118+ await irohNode?.close();119+ throw e;120+ }121+122+ // 4. Inbound bridge: peers' tunnels land on our Jazz server.123+ let acceptor: TunnelAcceptor | null = null;124+ if (irohNode) {125+ acceptor = runTunnelAcceptor(irohNode, jazz.url);126+ }127+128+ let stopped: Promise<void> | null = null;129+ return {130+ url: jazz.url,131+ port: jazz.port,132+ appId: options.appId,133+ ticket: () => {134+ if (!ticketString) {135+ const reason = mesh.state === "unavailable" ? mesh.reason : "mesh is off";136+ throw new MeshUnavailableError(reason);137+ }138+ return ticketString;139+ },140+ status: () => ({141+ appId: options.appId,142+ jazz: { url: jazz.url, port: jazz.port, storage: inMemory ? "memory" : "persistent" },143+ upstream,144+ mesh,145+ }),146+ stop: () =>147+ (stopped ??= (async () => {148+ acceptor?.close();149+ await tunnelListener?.close();150+ await jazz.stop();151+ await irohNode?.close();152+ })()),153+ };154+}@@ -0,0 +1,36 @@
1+// Pairing ticket codec — pure string/bytes framing, no FFI in the import2+// graph (same separation doorbearer's ticket.ts proved out). A ticket carries3+// exactly one thing in v1: the node's postcard-encoded EndpointAddr.4+5+const TICKET_PREFIX = "LFN1.";6+7+export function toBase64(bytes: Uint8Array): string {8+ let bin = "";9+ for (let i = 0; i < bytes.length; i += 0x8000) {10+ bin += String.fromCharCode(...bytes.subarray(i, i + 0x8000));11+ }12+ return btoa(bin);13+}14+15+export function fromBase64(text: string): Uint8Array {16+ const bin = atob(text);17+ const out = new Uint8Array(bin.length);18+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);19+ return out;20+}21+22+export function encodeTicket(nodeAddr: Uint8Array): string {23+ return TICKET_PREFIX + toBase64(nodeAddr);24+}25+26+/** Returns null on any malformed input — never throws (paste-from-user path). */27+export function decodeTicket(text: string): Uint8Array | null {28+ const trimmed = text.trim();29+ if (!trimmed.startsWith(TICKET_PREFIX)) return null;30+ try {31+ const addr = fromBase64(trimmed.slice(TICKET_PREFIX.length));32+ return addr.length > 0 ? addr : null;33+ } catch {34+ return null;35+ }36+}@@ -0,0 +1,258 @@
1+// WS-over-iroh bridge. One iroh connection carries exactly one WebSocket2+// connection (v1: the Jazz upstream link — no multiplexing needed).3+//4+// Dialer side: a loopback WS listener; JazzServer's upstreamUrl points at it.5+// Each accepted WS dials the peer over iroh, sends a HELLO frame naming the6+// request path/subprotocol (the tunnel terminates WS on both ends, so upgrade7+// parameters don't carry themselves), then bridges frames.8+// Acceptor side: an accept loop; each inbound iroh conn reads HELLO, opens a9+// WebSocket to the LOCAL JazzServer, and bridges the same way.10+11+import type { IrohConn, IrohNode } from "./iroh/node.ts";12+13+const DEBUG = Deno.env.get("LOFI_NODE_DEBUG") === "1";14+function debug(...args: unknown[]): void {15+ if (DEBUG) console.error("[tunnel]", ...args);16+}17+18+export const FRAME_HELLO = 0;19+export const FRAME_TEXT = 1;20+export const FRAME_BIN = 2;21+export const FRAME_CLOSE = 3;22+23+const encoder = new TextEncoder();24+const decoder = new TextDecoder();25+26+export function encodeFrame(type: number, payload: Uint8Array): Uint8Array {27+ const out = new Uint8Array(1 + payload.length);28+ out[0] = type;29+ out.set(payload, 1);30+ return out;31+}32+33+export function decodeFrame(bytes: Uint8Array): { type: number; payload: Uint8Array } {34+ if (bytes.length === 0) return { type: -1, payload: new Uint8Array(0) };35+ return { type: bytes[0], payload: bytes.subarray(1) };36+}37+38+export function encodeClose(code: number, reason: string): Uint8Array {39+ const reasonBytes = encoder.encode(reason);40+ const out = new Uint8Array(2 + reasonBytes.length);41+ new DataView(out.buffer).setUint16(0, code, false);42+ out.set(reasonBytes, 2);43+ return out;44+}45+46+export function decodeClose(payload: Uint8Array): { code: number; reason: string } {47+ if (payload.length < 2) return { code: 1000, reason: "" };48+ const code = new DataView(payload.buffer, payload.byteOffset).getUint16(0, false);49+ return { code, reason: decoder.decode(payload.subarray(2)) };50+}51+52+/** WS close codes 1005/1006/1015 (and out-of-range values) may not be passed53+ * to close() — normalize what we relay. */54+function sanitizeCloseCode(code: number): number {55+ if (code < 1000 || code > 4999 || code === 1005 || code === 1006 || code === 1015) return 1000;56+ return code;57+}58+59+interface Hello {60+ path: string;61+ protocol: string | null;62+}63+64+/** Pump both directions between an OPEN WebSocket and an iroh conn until65+ * either side closes. `backlog` holds WS data that arrived before the conn was66+ * ready (the WS side is push-based; the conn side is pull-based and needs no67+ * buffering) — it is flushed before the message listener attaches, and both68+ * happen synchronously, so no event can interleave. Resolves on teardown;69+ * teardown is idempotent. */70+export function bridge(71+ conn: IrohConn,72+ ws: WebSocket,73+ backlog: (string | ArrayBuffer)[] = [],74+): Promise<void> {75+ ws.binaryType = "arraybuffer";76+ let done: () => void;77+ const finished = new Promise<void>((res) => (done = res));78+ let torndown = false;79+80+ const teardown = (notifyPeer: boolean, code = 1000, reason = "") => {81+ if (torndown) return;82+ torndown = true;83+ if (notifyPeer) {84+ try {85+ conn.sendMsg(encodeFrame(FRAME_CLOSE, encodeClose(code, reason)));86+ } catch {87+ // conn already gone88+ }89+ }90+ conn.close().catch(() => {});91+ if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {92+ try {93+ ws.close(sanitizeCloseCode(code), reason.slice(0, 120));94+ } catch {95+ // already closing96+ }97+ }98+ done();99+ };100+101+ const forward = (data: string | ArrayBuffer) => {102+ try {103+ if (typeof data === "string") {104+ conn.sendMsg(encodeFrame(FRAME_TEXT, encoder.encode(data)));105+ } else {106+ conn.sendMsg(encodeFrame(FRAME_BIN, new Uint8Array(data)));107+ }108+ } catch {109+ teardown(false, 1011, "tunnel send failed");110+ }111+ };112+ for (const data of backlog) forward(data);113+ backlog.length = 0;114+ ws.addEventListener("message", (ev) => forward(ev.data));115+ ws.addEventListener("close", (ev) => teardown(true, ev.code || 1000, ev.reason ?? ""));116+ ws.addEventListener("error", () => teardown(true, 1011, "ws error"));117+118+ (async () => {119+ while (!torndown) {120+ const msg = await conn.recvMsg();121+ if (msg === null) {122+ teardown(false, 1006, "tunnel closed");123+ break;124+ }125+ const { type, payload } = decodeFrame(msg);126+ if (type === FRAME_TEXT) ws.send(decoder.decode(payload));127+ else if (type === FRAME_BIN) ws.send(payload);128+ else if (type === FRAME_CLOSE) {129+ const { code, reason } = decodeClose(payload);130+ teardown(false, code, reason);131+ break;132+ }133+ // Unknown frame types are ignored (forward compat).134+ }135+ })();136+137+ return finished;138+}139+140+export interface TunnelListener {141+ /** Loopback port; use ws://127.0.0.1:<port> as JazzServer upstreamUrl. */142+ port: number;143+ close(): Promise<void>;144+}145+146+/** Dialer side: serve a loopback WS endpoint that carries each connection to147+ * `peerAddr` over iroh. */148+export function startTunnelListener(149+ node: IrohNode,150+ peerAddr: Uint8Array,151+ options: { port?: number } = {},152+): TunnelListener {153+ const server = Deno.serve(154+ { hostname: "127.0.0.1", port: options.port ?? 0, onListen: () => {} },155+ (req) => {156+ if (req.headers.get("upgrade")?.toLowerCase() !== "websocket") {157+ return new Response("lofi-node tunnel (WebSocket only)", { status: 426 });158+ }159+ const requestedProtocol = req.headers.get("sec-websocket-protocol");160+ const { socket, response } = Deno.upgradeWebSocket(161+ req,162+ requestedProtocol ? { protocol: requestedProtocol.split(",")[0].trim() } : {},163+ );164+ const url = new URL(req.url);165+ // Buffer WS data that lands while the iroh dial is in flight; bridge()166+ // flushes it before attaching its own listener (same task, no gap).167+ const backlog: (string | ArrayBuffer)[] = [];168+ const buffer = (ev: MessageEvent) => backlog.push(ev.data);169+ socket.addEventListener("message", buffer);170+ socket.addEventListener("open", async () => {171+ try {172+ const conn = await node.connect(peerAddr);173+ const hello: Hello = { path: url.pathname + url.search, protocol: requestedProtocol };174+ debug("dialer: connected, HELLO", hello);175+ conn.sendMsg(encodeFrame(FRAME_HELLO, encoder.encode(JSON.stringify(hello))));176+ socket.removeEventListener("message", buffer);177+ bridge(conn, socket, backlog);178+ } catch {179+ try {180+ socket.close(1011, "tunnel dial failed");181+ } catch {182+ // already closed183+ }184+ }185+ });186+ return response;187+ },188+ );189+ return {190+ port: (server.addr as Deno.NetAddr).port,191+ close: () => server.shutdown(),192+ };193+}194+195+export interface TunnelAcceptor {196+ /** Stops handing new conns to the bridge. The final parked db_accept cannot197+ * be cancelled (upstream gap) — daemon teardown is process exit. */198+ close(): void;199+}200+201+/** Acceptor side: bridge every inbound iroh conn to the local Jazz server. */202+export function runTunnelAcceptor(node: IrohNode, localWsUrl: string): TunnelAcceptor {203+ let stopped = false;204+205+ async function handleInbound(conn: IrohConn): Promise<void> {206+ const first = await conn.recvMsg();207+ if (first === null) return;208+ const { type, payload } = decodeFrame(first);209+ if (type !== FRAME_HELLO) {210+ await conn.close();211+ return;212+ }213+ let hello: Hello;214+ try {215+ hello = JSON.parse(decoder.decode(payload)) as Hello;216+ } catch {217+ await conn.close();218+ return;219+ }220+ const target = new URL(hello.path || "/", localWsUrl);221+ debug("acceptor: HELLO", hello, "→ dialing local", target.href);222+ const ws = hello.protocol223+ ? new WebSocket(target, hello.protocol.split(",").map((s) => s.trim()))224+ : new WebSocket(target);225+ ws.addEventListener("open", () => {226+ debug("acceptor: local ws open, bridging");227+ bridge(conn, ws);228+ });229+ ws.addEventListener("error", (ev) => {230+ debug("acceptor: local ws error", (ev as ErrorEvent).message ?? "");231+ conn.close().catch(() => {});232+ });233+ }234+235+ (async () => {236+ while (!stopped) {237+ let conn: IrohConn;238+ try {239+ conn = await node.accept();240+ } catch {241+ if (stopped) break;242+ await new Promise((r) => setTimeout(r, 250));243+ continue;244+ }245+ if (stopped) {246+ conn.close().catch(() => {});247+ break;248+ }249+ handleInbound(conn).catch(() => {});250+ }251+ })();252+253+ return {254+ close: () => {255+ stopped = true;256+ },257+ };258+}@@ -0,0 +1,45 @@
1+// In-process test mesh: chained in-memory JazzServers with DIRECT loopback2+// upstream URLs — no iroh, no dylib, no network beyond 127.0.0.1. Proves Jazz3+// chaining semantics; the iroh tunnel has its own integration tests.4+5+import { createSyncNode, type SyncNode } from "../src/node.ts";6+7+export interface TestMesh {8+ nodes: SyncNode[];9+ stop(): Promise<void>;10+}11+12+export interface TestMeshOptions {13+ nodes?: number;14+ appId?: string;15+}16+17+/** Node 0 is the root; node i+1 uses node i as its direct upstream. All nodes18+ * share one appId and secrets (they model one user's fleet). */19+export async function createTestMesh(options: TestMeshOptions = {}): Promise<TestMesh> {20+ const count = options.nodes ?? 2;21+ const appId = options.appId ?? `app_test_${crypto.randomUUID().slice(0, 8)}`;22+ const backendSecret = `lofi_backend_test_${crypto.randomUUID().slice(0, 8)}`;23+ const adminSecret = `lofi_admin_test_${crypto.randomUUID().slice(0, 8)}`;24+25+ const nodes: SyncNode[] = [];26+ for (let i = 0; i < count; i++) {27+ nodes.push(28+ await createSyncNode({29+ appId,30+ backendSecret,31+ adminSecret,32+ inMemory: true,33+ mesh: "off",34+ upstream: i === 0 ? "none" : { url: nodes[i - 1].url },35+ }),36+ );37+ }38+ return {39+ nodes,40+ stop: async () => {41+ // Leaves first: stop in reverse creation order.42+ for (const node of [...nodes].reverse()) await node.stop();43+ },44+ };45+}@@ -0,0 +1,45 @@
1+import { assertEquals, assertStrictEquals } from "jsr:@std/assert@1";2+import { decodeTicket, encodeTicket } from "../src/ticket.ts";3+import {4+ decodeClose,5+ decodeFrame,6+ encodeClose,7+ encodeFrame,8+ FRAME_BIN,9+ FRAME_CLOSE,10+ FRAME_TEXT,11+} from "../src/tunnel.ts";12+13+Deno.test("ticket round-trips arbitrary addr bytes", () => {14+ const addr = crypto.getRandomValues(new Uint8Array(97));15+ const ticket = encodeTicket(addr);16+ assertEquals(decodeTicket(ticket), addr);17+ assertEquals(decodeTicket(` ${ticket}\n`), addr, "tolerates surrounding whitespace");18+});19+20+Deno.test("ticket rejects malformed input without throwing", () => {21+ assertStrictEquals(decodeTicket(""), null);22+ assertStrictEquals(decodeTicket("LFN1."), null);23+ assertStrictEquals(decodeTicket("BGI1.abcd"), null, "doorbearer tickets are not ours");24+ assertStrictEquals(decodeTicket("LFN1.!!!not-base64!!!"), null);25+});26+27+Deno.test("frame round-trips type + payload", () => {28+ const payload = crypto.getRandomValues(new Uint8Array(1024));29+ for (const type of [FRAME_TEXT, FRAME_BIN, FRAME_CLOSE]) {30+ const decoded = decodeFrame(encodeFrame(type, payload));31+ assertEquals(decoded.type, type);32+ assertEquals(new Uint8Array(decoded.payload), payload);33+ }34+});35+36+Deno.test("empty frame decodes to sentinel type", () => {37+ assertEquals(decodeFrame(new Uint8Array(0)).type, -1);38+});39+40+Deno.test("close payload round-trips code and reason", () => {41+ const { code, reason } = decodeClose(encodeClose(4321, "going away"));42+ assertEquals(code, 4321);43+ assertEquals(reason, "going away");44+ assertEquals(decodeClose(new Uint8Array(0)), { code: 1000, reason: "" });45+});@@ -0,0 +1,37 @@
1+// Integration: JazzServer boots under Deno via jazz-napi, serves a WebSocket,2+// and chains upstream (createTestMesh). Convergence assertions with a real3+// Jazz client come next (lofi's Playwright fixtures pointed at node URLs).4+5+import { assert, assertEquals } from "jsr:@std/assert@1";6+import { createTestMesh } from "../testing/mod.ts";7+8+async function healthy(wsUrl: string): Promise<boolean> {9+ const httpUrl = wsUrl.replace(/^ws/, "http");10+ try {11+ const res = await fetch(new URL("/health", httpUrl));12+ const body = await res.json();13+ return res.status === 200 && body.status === "healthy";14+ } catch {15+ return false;16+ }17+}18+19+Deno.test({20+ name: "two in-memory JazzServers boot and chain upstream",21+ sanitizeOps: false,22+ sanitizeResources: false,23+ fn: async () => {24+ const mesh = await createTestMesh({ nodes: 2 });25+ try {26+ assertEquals(mesh.nodes.length, 2);27+ assert(mesh.nodes[0].url.startsWith("ws://"), `root url: ${mesh.nodes[0].url}`);28+ const status = mesh.nodes[1].status();29+ assertEquals(status.upstream, { url: mesh.nodes[0].url });30+ assertEquals(status.mesh.state, "off");31+ assert(await healthy(mesh.nodes[0].url), "root node reports healthy");32+ assert(await healthy(mesh.nodes[1].url), "leaf node reports healthy");33+ } finally {34+ await mesh.stop();35+ }36+ },37+});@@ -0,0 +1,57 @@
1+// Integration: the full daemon topology — two real JazzServers, the leaf's2+// upstream election `{ peer: ticket }` carried over the iroh tunnel to the3+// root. Proves createSyncNode wiring end to end; whether Jazz's upstream WS4+// client dials eagerly at start or lazily on first sync traffic is Jazz's5+// business — here we assert the topology comes up and both servers stay6+// healthy with the tunnel in the path.7+8+import { assert, assertEquals } from "jsr:@std/assert@1";9+import { createSyncNode } from "../src/node.ts";10+import { resolveIrohLib } from "../src/native/loader.ts";11+12+const available = resolveIrohLib().status === "ok";13+14+async function healthy(wsUrl: string): Promise<boolean> {15+ try {16+ const res = await fetch(new URL("/health", wsUrl.replace(/^ws/, "http")));17+ return res.status === 200 && (await res.json()).status === "healthy";18+ } catch {19+ return false;20+ }21+}22+23+Deno.test({24+ name: "two nodes pair over iroh: leaf upstream tunnels to root",25+ ignore: !available,26+ sanitizeOps: false,27+ sanitizeResources: false,28+ fn: async () => {29+ const shared = {30+ appId: `app_pair_${crypto.randomUUID().slice(0, 8)}`,31+ backendSecret: "lofi_backend_pairtest",32+ adminSecret: "lofi_admin_pairtest",33+ };34+35+ const root = await createSyncNode({ ...shared, inMemory: true });36+ const rootStatus = root.status();37+ assertEquals(rootStatus.mesh.state, "up", "root mesh up");38+39+ const leaf = await createSyncNode({40+ ...shared,41+ inMemory: true,42+ upstream: { peer: root.ticket() },43+ });44+ const leafStatus = leaf.status();45+ assertEquals(leafStatus.mesh.state, "up", "leaf mesh up");46+ assert("peer" in (leafStatus.upstream as object), "leaf elected peer upstream");47+48+ // Give Jazz's upstream client a moment to dial through the tunnel.49+ await new Promise((r) => setTimeout(r, 3000));50+51+ assert(await healthy(root.url), "root healthy with tunnel in path");52+ assert(await healthy(leaf.url), "leaf healthy with tunnel in path");53+54+ await leaf.stop();55+ await root.stop();56+ },57+});@@ -0,0 +1,90 @@
1+// Integration: a WebSocket carried end-to-end over a real iroh connection2+// between two in-process nodes. Skips (with a visible marker) when the dylib3+// is not resolvable. Sanitizers off: the acceptor's final parked db_accept4+// cannot be cancelled (documented upstream gap).5+6+import { assertEquals } from "jsr:@std/assert@1";7+import { openIrohLib } from "../src/native/iroh.ts";8+import { IrohNode } from "../src/iroh/node.ts";9+import { resolveIrohLib } from "../src/native/loader.ts";10+import { runTunnelAcceptor, startTunnelListener } from "../src/tunnel.ts";11+import { MeshUnavailableError } from "../src/errors.ts";12+13+const available = resolveIrohLib().status === "ok";14+15+Deno.test({16+ name: "ws echo through the iroh tunnel (dialer ws → iroh → acceptor ws)",17+ ignore: !available,18+ sanitizeOps: false,19+ sanitizeResources: false,20+ fn: async () => {21+ const lib = openIrohLib();22+23+ // Stand-in for the acceptor's local JazzServer: a WS echo server.24+ const echo = Deno.serve({ hostname: "127.0.0.1", port: 0, onListen: () => {} }, (req) => {25+ const { socket, response } = Deno.upgradeWebSocket(req);26+ socket.addEventListener("message", (ev) => socket.send(ev.data));27+ return response;28+ });29+ const echoUrl = `ws://127.0.0.1:${(echo.addr as Deno.NetAddr).port}`;30+31+ const acceptorNode = await IrohNode.open(lib, crypto.getRandomValues(new Uint8Array(32)));32+ const dialerNode = await IrohNode.open(lib, crypto.getRandomValues(new Uint8Array(32)));33+ const acceptor = runTunnelAcceptor(acceptorNode, echoUrl);34+35+ const peerAddr = await acceptorNode.addr();36+ dialerNode.addAddr(peerAddr);37+ const listener = startTunnelListener(dialerNode, peerAddr);38+39+ const client = new WebSocket(`ws://127.0.0.1:${listener.port}/sync?key=test`);40+ client.binaryType = "arraybuffer";41+ const received: (string | Uint8Array)[] = [];42+ const gotBoth = new Promise<void>((resolve, reject) => {43+ const timer = setTimeout(() => reject(new Error("echo timeout (15s)")), 15_000);44+ client.addEventListener("message", (ev) => {45+ received.push(46+ typeof ev.data === "string" ? ev.data : new Uint8Array(ev.data as ArrayBuffer),47+ );48+ if (received.length === 2) {49+ clearTimeout(timer);50+ resolve();51+ }52+ });53+ client.addEventListener("error", () => {54+ clearTimeout(timer);55+ reject(new Error("client ws error"));56+ });57+ });58+ await new Promise<void>((resolve, reject) => {59+ client.addEventListener("open", () => resolve());60+ client.addEventListener("close", () => reject(new Error("closed before open")));61+ });62+ client.send("ping over iroh");63+ client.send(new Uint8Array([1, 2, 3, 4]).buffer);64+ await gotBoth;65+66+ assertEquals(received[0], "ping over iroh");67+ assertEquals(received[1], new Uint8Array([1, 2, 3, 4]));68+69+ client.close(1000, "done");70+ await new Promise((r) => setTimeout(r, 300));71+ acceptor.close();72+ await listener.close();73+ await echo.shutdown();74+ await dialerNode.close();75+ await acceptorNode.close();76+ },77+});78+79+Deno.test({80+ name: "loader reports a typed unavailable error rather than degrading silently",81+ ignore: available,82+ fn: () => {83+ try {84+ openIrohLib();85+ throw new Error("expected MeshUnavailableError");86+ } catch (e) {87+ if (!(e instanceof MeshUnavailableError)) throw e;88+ }89+ },90+});