Both tunnel sides track live IrohConns; status() attaches
{direction, rtt, paths} per connection (upstream ConnectionStats/paths).
Pair test asserts the upstream link is visible from both nodes.
Also: flake.nix + scripts/cross-build.sh for the prebuilt matrix
(Linux x86_64/aarch64 + Windows x86_64 via cargo-zigbuild).- commit
- e96485a648e9079e98a57601c3a94315eb94e510
- parent
- 98487260818f473261cef1928dd2132bf4d0583e
- author
- Dami <dami.etoile@gmail.com>
| A | native/ | 38 | +++++++++++++++++++++++++++ |
| A | native/ | 39 | +++++++++++++++++++++++++++ |
| M | src/ | 4 | +++ |
| M | src/ | 23 | +++++++++++++--- |
| M | src/ | 43 | ++++++++++++++++++++++++++++++ |
| M | tests/ | 13 | +++++++++ |
@@ -0,0 +1,38 @@
1+{2+ description = "lofi-node iroh-js cross-compilation dev shell (Linux + Windows via zig; macOS builds on a mac)";3+4+ inputs = {5+ nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";6+ rust-overlay.url = "github:oxalica/rust-overlay";7+ flake-utils.url = "github:numtide/flake-utils";8+ };9+10+ outputs = { self, nixpkgs, rust-overlay, flake-utils }:11+ flake-utils.lib.eachDefaultSystem (system:12+ let13+ pkgs = import nixpkgs {14+ inherit system;15+ overlays = [ (import rust-overlay) ];16+ };17+ rust = pkgs.rust-bin.stable.latest.default.override {18+ targets = [19+ "x86_64-unknown-linux-gnu"20+ "aarch64-unknown-linux-gnu"21+ "x86_64-pc-windows-gnu"22+ ];23+ };24+ in {25+ devShells.default = pkgs.mkShell {26+ packages = [27+ rust28+ pkgs.zig # cross cc/linker for libc + ring's C/asm29+ pkgs.cargo-zigbuild # cargo <-> zig glue, per-target30+ pkgs.pkg-config31+ ];32+ shellHook = ''33+ echo "lofi-node iroh-js cross shell — cargo-zigbuild $(cargo-zigbuild --version 2>/dev/null | head -1)"34+ echo " run: ./scripts/cross-build.sh"35+ '';36+ };37+ });38+}@@ -0,0 +1,39 @@
1+2+# Cross-build the vendored iroh-js napi addon for lofi-node's platform matrix.3+#4+# Linux + Windows build on any x86_64 Linux host via cargo-zigbuild; macOS5+# targets build natively on a mac (Apple SDK is not redistributable). Run6+# inside the flake dev shell: nix develop -c ./scripts/cross-build.sh7+#8+# Artifacts land in dist/<triple>/ (cargo names; the Deno loader stages its9+# own .node shim at load time).10+set -euo pipefail11+cd "$(dirname "$0")/.."12+13+linux_win_targets=(14+ x86_64-unknown-linux-gnu15+ aarch64-unknown-linux-gnu16+ x86_64-pc-windows-gnu17+)18+19+artifact_name() {20+ case "$1" in21+ *windows*) echo "number0_iroh.dll" ;;22+ *) echo "libnumber0_iroh.so" ;;23+ esac24+}25+26+for t in "${linux_win_targets[@]}"; do27+ echo "==> cargo zigbuild --release --target $t"28+ cargo zigbuild --release --target "$t"29+ name="$(artifact_name "$t")"30+ mkdir -p "dist/$t"31+ cp "target/$t/release/$name" "dist/$t/$name"32+ echo " -> dist/$t/$name"33+done34+35+cat <<'EOF'36+37+macOS targets build on a mac (native toolchain, no Nix needed):38+ cargo build --release # -> target/release/libnumber0_iroh.dylib39+EOF@@ -3,7 +3,13 @@
33 44 import { loadIrohAddon } from "./native/addon.ts";55 import { IrohNode } from "./iroh/node.ts";6-import { runTunnelAcceptor, startTunnelListener, type TunnelAcceptor, type TunnelListener } from "./tunnel.ts";6+import {7+ runTunnelAcceptor,8+ startTunnelListener,9+ type TunnelAcceptor,10+ type TunnelConnStat,11+ type TunnelListener,12+} from "./tunnel.ts";713 import { type JazzHandle, startJazz } from "./jazz.ts";814 import { loadOrCreateIrohKey, type UpstreamConfig } from "./config.ts";915 import { MeshUnavailableError } from "./errors.ts";@@ -28,7 +34,7 @@ export interface SyncNodeOptions {
2834 }2935 3036 export type MeshStatus =31- | { state: "up"; nodeId: string; ticket: string }37+ | { state: "up"; nodeId: string; ticket: string; connections: TunnelConnStat[] }3238 | { state: "off" }3339 | { state: "unavailable"; reason: string };3440 @@ -56,7 +62,11 @@ export async function createSyncNode(options: SyncNodeOptions): Promise<SyncNode
5662 const inMemory = options.inMemory ?? options.dataDir === undefined;5763 5864 // 1. Mesh (iroh) — optional unless the upstream election requires it.59- let mesh: MeshStatus = { state: "off" };65+ // Connections are attached at status() time (they live in the tunnels).66+ let mesh:67+ | { state: "up"; nodeId: string; ticket: string }68+ | { state: "off" }69+ | { state: "unavailable"; reason: string } = { state: "off" };6070 let irohNode: IrohNode | null = null;6171 let ticketString: string | null = null;6272 if (meshMode === "auto") {@@ -133,7 +143,12 @@ export async function createSyncNode(options: SyncNodeOptions): Promise<SyncNode
133143 appId: options.appId,134144 jazz: { url: jazz.url, port: jazz.port, storage: inMemory ? "memory" : "persistent" },135145 upstream,136- mesh,146+ mesh: mesh.state === "up"147+ ? {148+ ...mesh,149+ connections: [...(tunnelListener?.stats() ?? []), ...(acceptor?.stats() ?? [])],150+ }151+ : mesh,137152 }),138153 stop: () =>139154 (stopped ??= (async () => {@@ -171,9 +171,18 @@ export function bridge(
171171 return finished;172172 }173173 174+/** Live-connection observability (from upstream ConnectionStats/paths). */175+export interface TunnelConnStat {176+ direction: "out" | "in";177+ rtt: number | null;178+ paths: number;179+}180+174181 export interface TunnelListener {175182 /** Loopback port; use ws://127.0.0.1:<port> as JazzServer upstreamUrl. */176183 port: number;184+ /** Stats for the currently live outbound tunnel connections. */185+ stats(): TunnelConnStat[];177186 close(): Promise<void>;178187 }179188 @@ -184,11 +193,28 @@ export function startTunnelListener(
184193 peerTicket: string,185194 options: { port?: number } = {},186195 ): TunnelListener {196+ const live = new Set<IrohConn>();197+ const track = (conn: IrohConn) => {198+ live.add(conn);199+ };200+ const collectStats = (direction: "out" | "in") => {201+ const out: TunnelConnStat[] = [];202+ for (const conn of live) {203+ if (conn.closed) {204+ live.delete(conn);205+ continue;206+ }207+ out.push({ direction, ...conn.stats() });208+ }209+ return out;210+ };211+187212 async function proxyHttp(req: Request): Promise<Response> {188213 const url = new URL(req.url);189214 let conn: IrohConn;190215 try {191216 conn = await node.connect(peerTicket);217+ track(conn);192218 } catch {193219 return new Response("tunnel dial failed", { status: 502 });194220 }@@ -240,6 +266,7 @@ export function startTunnelListener(
240266 socket.addEventListener("open", async () => {241267 try {242268 const conn = await node.connect(peerTicket);269+ track(conn);243270 const hello: Hello = {244271 kind: "ws",245272 path: url.pathname + url.search,@@ -262,11 +289,14 @@ export function startTunnelListener(
262289 );263290 return {264291 port: (server.addr as Deno.NetAddr).port,292+ stats: () => collectStats("out"),265293 close: () => server.shutdown(),266294 };267295 }268296 269297 export interface TunnelAcceptor {298+ /** Stats for the currently live inbound tunnel connections. */299+ stats(): TunnelConnStat[];270300 /** Stops handing new conns to the bridge; the loop also exits cleanly when271301 * the endpoint closes (accept resolves null). */272302 close(): void;@@ -276,6 +306,7 @@ export interface TunnelAcceptor {
276306 export function runTunnelAcceptor(node: IrohNode, localWsUrl: string): TunnelAcceptor {277307 let stopped = false;278308 const localHttpUrl = localWsUrl.replace(/^ws/, "http");309+ const live = new Set<IrohConn>();279310 280311 async function handleHttp(conn: IrohConn, hello: Hello): Promise<void> {281312 const bodyFrame = await conn.recvMsg();@@ -359,11 +390,23 @@ export function runTunnelAcceptor(node: IrohNode, localWsUrl: string): TunnelAcc
359390 conn.close().catch(() => {});360391 break;361392 }393+ live.add(conn);362394 handleInbound(conn).catch(() => {});363395 }364396 })();365397 366398 return {399+ stats: () => {400+ const out: TunnelConnStat[] = [];401+ for (const conn of live) {402+ if (conn.closed) {403+ live.delete(conn);404+ continue;405+ }406+ out.push({ direction: "in", ...conn.stats() });407+ }408+ return out;409+ },367410 close: () => {368411 stopped = true;369412 },@@ -51,6 +51,19 @@ Deno.test({
5151 assert(await healthy(root.url), "root healthy with tunnel in path");5252 assert(await healthy(leaf.url), "leaf healthy with tunnel in path");5353 54+ // Observability: the live upstream link shows up in both nodes' status.55+ const leafMesh = leaf.status().mesh;56+ const rootMesh = root.status().mesh;57+ assert(leafMesh.state === "up" && rootMesh.state === "up", "both meshes up");58+ assert(59+ leafMesh.connections.some((c) => c.direction === "out" && c.paths >= 1),60+ `leaf shows a live outbound tunnel conn: ${JSON.stringify(leafMesh.connections)}`,61+ );62+ assert(63+ rootMesh.connections.some((c) => c.direction === "in"),64+ `root shows a live inbound tunnel conn: ${JSON.stringify(rootMesh.connections)}`,65+ );66+5467 await leaf.stop();5568 await root.stop();5669 },