Files
taskgraph_ts/docs/research/iroh_ts_reference.md

25 KiB

iroh-ts Reference Implementation Research Report

Project: iroh-ts — TypeScript/JavaScript bindings for iroh (peer-to-peer QUIC networking) Date: 2026-04-23 Purpose: Reference for building taskgraph-ts napi-rs wrapper


1. Project Structure

iroh-ts is a minimal, single-crate napi-rs project with a flat layout:

iroh-ts/
├── .cargo/
│   └── config.toml          # Cross-compilation rustflags (Windows static CRT)
├── .github/
│   └── workflows/
│       └── CI.yml            # GitHub Actions CI/CD
├── .gitattributes            # EOL normalization + mark generated files
├── .gitignore                # Node + Rust ignores
├── build.rs                  # napi-build setup (1 line)
├── Cargo.toml                # Rust crate config
├── bun.lock                  # Bun lockfile
├── index.d.ts                # Auto-generated TypeScript declarations
├── index.js                  # Auto-generated JS loader (platform resolution)
├── package.json              # npm package config
├── src/
│   ├── lib.rs                # Crate root: module declarations + init_logging
│   ├── error.rs              # IrohError enum + conversion helpers
│   ├── endpoint.rs           # Endpoint class (napi-wrapped)
│   ├── connection.rs         # Connection class + BiStreamResult struct
│   └── stream.rs             # SendStream + RecvStream classes
├── tsconfig.json             # TypeScript config (bundler mode, noEmit)
└── README.md

Key observations:

  • No separate npm/ or crates/ directory — it is a single Rust crate mapped to a single npm package
  • Generated index.js and index.d.ts are committed to the repo (auto-generated by napi build)
  • Platform-specific .node binaries are not committed; they are built in CI and published as separate npm packages
  • No test directory exists in the repo (tests would use the ava framework listed in bun.lock)

2. Cargo.toml Configuration

[package]
name = "iroh"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]              # Critical: produces a C dynamic library for Node

[dependencies]
napi-derive = "3.0.0"                # Procedural macro crate (#[napi] attribute)
iroh = "0.95.1"                      # The wrapped Rust library
iroh-base = "0.95.1"
bytes = "1.11.0"
thiserror = "2.0.17"
tracing = "0.1.41"

[dependencies.napi]
version = "3.0.0"
features = ["async", "tokio_rt"]     # Required for async Rust functions exposed to JS

[dependencies.tokio]
version = "1.48.0"
features = ["sync"]                  # Only sync feature (tokio_rt comes from napi)

[dependencies.tracing-subscriber]
version = "0.3.20"
features = ["env-filter"]

[build-dependencies]
napi-build = "2"                      # Build script helper

[profile.release]
lto = true                            # Link-Time Optimization for smaller binaries
strip = "symbols"                     # Strip debug symbols for smaller binaries

Key takeaways:

  • crate-type = ["cdylib"]mandatory for napi-rs; produces .node shared library
  • napi and napi-derive are separate crates with matched major versions (both 3.0.0)
  • The async + tokio_rt features on napi are essential if you expose async Rust functions
  • napi-build = "2" is a minimal build dependency that just calls napi_build::setup()
  • Release profile uses LTO + symbol stripping — standard practice for napi-rs to minimize binary size

3. package.json Configuration

{
  "name": "@rayhanadev/iroh",
  "version": "0.1.1",
  "main": "index.js",
  "types": "index.d.ts",

  "napi": {
    "binaryName": "iroh",
    "targets": [
      "x86_64-pc-windows-msvc",
      "x86_64-apple-darwin",
      "x86_64-unknown-linux-gnu",
      "aarch64-apple-darwin"
    ]
  },

  "scripts": {
    "artifacts": "napi artifacts",
    "build": "napi build --platform --release",
    "build:debug": "napi build --platform",
    "prepublishOnly": "napi prepublish -t npm",
    "preversion": "napi build --platform && git add .",
    "version": "napi version"
  },

  "devDependencies": {
    "@emnapi/core": "^1.5.0",
    "@emnapi/runtime": "^1.5.0",
    "@napi-rs/cli": "^3.2.0",
    "@tybys/wasm-util": "^0.10.0"
  },

  "peerDependencies": {
    "typescript": "^5"
  },

  "optionalDependencies": {
    "@rayhanadev/iroh-win32-x64-msvc": "0.1.0",
    "@rayhanadev/iroh-darwin-x64": "0.1.0",
    "@rayhanadev/iroh-linux-x64-gnu": "0.1.0",
    "@rayhanadev/iroh-darwin-arm64": "0.1.0"
  }
}

Key takeaways:

  • The napi section in package.json is the core napi-rs config — it defines:
    • binaryName: the base name for the .node file (must match the crate name)
    • targets: which platform triples to build for (used by CI and napi CLI)
  • --platform flag on napi build makes it generate the correct platform-specific filename
  • optionalDependencies lists the per-platform npm packages — npm auto-selects the right one
    • These are separate npm packages published from the npm/ directory created by napi create-npm-dirs
    • Names follow the pattern: @scope/{binaryName}-{platform}-{arch}-{abi}
  • @emnapi/core and @emnapi/runtime are for WASI/WASM support (optional, for browser targets)
  • No @napi-rs/client dependency — the CLI (@napi-rs/cli) is the only napi-rs JS dependency needed
  • The prepublishOnly script runs napi prepublish which handles version alignment across platform packages

4. Rust Function/Class Export Patterns via napi-rs Macros

4.1 Crate Root (lib.rs)

#![deny(clippy::all)]

use napi_derive::napi;

mod connection;
mod endpoint;
mod error;
mod stream;

pub use connection::*;
pub use endpoint::*;
pub use error::*;
pub use stream::*;

#[napi]
pub fn init_logging(level: Option<String>) {
    let filter = level.unwrap_or_else(|| "info".to_string());
    let _ = tracing_subscriber::fmt().with_env_filter(filter).try_init();
}

Patterns:

  • #![deny(clippy::all)] — strict linting at crate level
  • Module-based organization with pub use re-exports from the crate root
  • Free functions annotated with #[napi] become top-level JS exports
  • Option<String> maps to string | undefined | null in TypeScript

4.2 Class Pattern (Endpoint)

#[napi]
pub struct Endpoint {
    inner: Arc<iroh::Endpoint>,    // Wrap the Rust type in Arc for shared ownership
}

#[napi]
impl Endpoint {
    #[napi(factory)]                       // Static factory method → Endpoint.create()
    pub async fn create() -> Result<Endpoint> { ... }

    #[napi(factory)]                       // With options → Endpoint.createWithOptions(opts)
    pub async fn create_with_options(options: EndpointOptions) -> Result<Endpoint> { ... }

    #[napi]                                // Instance method → endpoint.nodeId()
    pub fn node_id(&self) -> String { ... }

    #[napi]                                // Instance method → endpoint.connect(addr, alpn)
    pub async fn connect(&self, addr: String, alpn: String) -> Result<Connection> { ... }
}

Key patterns:

  1. Struct + impl separation: The struct definition gets #[napi], AND a separate #[napi] impl block exposes methods
  2. Arc<InnerType> pattern: Rust objects shared across JS/Rust boundary are wrapped in Arc. This is critical because:
    • Node.js is single-threaded but napi-rs creates a reference that may outlive the original scope
    • The Arc allows the JS wrapper to own a reference-counted Rust object
  3. #[napi(factory)]: Creates a static factory method instead of a constructor. This is the idiomatic way to handle async construction (since WASM/native constructors can't be async)
  4. snake_case Rust → camelCase JS: napi-rs auto-converts node_id to nodeId, create_with_options to createWithOptions
  5. Return types: Result<T> maps to JS T | Error (throws on Err)

4.3 Struct with Mutex for Interior Mutability (SendStream/RecvStream)

#[derive(Clone)]
#[napi]
pub struct SendStream {
    inner: Arc<Mutex<iroh::endpoint::SendStream>>,   // Arc<Mutex<T>> for mutable access
}

#[napi]
impl SendStream {
    pub async fn write(&self, data: Buffer) -> Result<u32> {
        let mut stream = self.inner.lock().await;     // tokio Mutex for async-compatible locking
        let written = stream.write(&data).await.map_err(to_napi_error)?;
        Ok(written as u32)
    }
}

Key patterns:

  • Arc<Mutex<T>> with tokio's async Mutex — required when async methods need mutable access
  • #[derive(Clone)] because the Arc<Mutex<>> is cheaply cloneable
  • Buffer type from napi::bindgen_prelude::* maps to Node.js Buffer
  • Manual as u32 cast because napi-rs doesn't support usize (platform-dependent size)

4.4 Object Struct (Options)

#[napi(object)]
#[derive(Default)]
pub struct EndpointOptions {
    pub alpns: Option<Vec<String>>,
    pub secret_key: Option<String>,
}

Pattern:

  • #[napi(object)] makes this a plain JS object (not a class) — fields become properties
  • #[derive(Default)] allows easy construction on the Rust side when JS omits fields
  • Option<T> makes fields optional in the TypeScript definition

4.5 Getter Properties (BiStreamResult)

#[napi]
pub struct BiStreamResult {
    send: SendStream,
    recv: RecvStream,
}

#[napi]
impl BiStreamResult {
    #[napi(getter)]
    pub fn get_send(&self) -> SendStream {
        self.send.clone()
    }

    #[napi(getter)]
    pub fn get_recv(&self) -> RecvStream {
        self.recv.clone()
    }
}

Pattern:

  • Use #[napi(getter)] to expose Rust struct fields as JS getter properties
  • The get_ prefix is stripped: get_send.send property in JS
  • Must return cloned values since the Rust struct owns the data

4.6 Type Mappings (Rust → TypeScript)

Rust Type TypeScript Type
String string
u32, f64 number
bool boolean
Option<T> T | null | undefined
Option<u32> number | null
Vec<String> Array<string>
Buffer (from napi) Buffer
Result<T> T (throws on Err)
Result<Option<T>> T | null
#[napi] struct class
#[napi(object)] struct interface

5. TypeScript Type Definitions

Auto-Generated index.d.ts

The index.d.ts file is entirely auto-generated by napi build --platform. It is not hand-written.

Generated output structure:

/* auto-generated by NAPI-RS */
/* eslint-disable */

export declare class BiStreamResult {
  get send(): SendStream
  get recv(): RecvStream
}

export declare class Connection {
  remoteNodeId(): string
  alpn(): Buffer
  openBi(): Promise<BiStreamResult>
  openUni(): Promise<SendStream>
  // ... etc
}

export declare class Endpoint {
  static create(): Promise<Endpoint>
  static createWithOptions(options: EndpointOptions): Promise<Endpoint>
  nodeId(): string
  // ... etc
}

export interface EndpointOptions {
  alpns?: Array<string>
  secretKey?: string
}

export declare function initLogging(level?: string | undefined | null): void

Key observations:

  • #[napi] structs become declare class with methods
  • #[napi(object)] structs become interface
  • #[napi(factory)] methods become static methods
  • #[napi(getter)] methods become get accessor properties
  • Doc comments from Rust (/// ...) are preserved as JSDoc in the .d.ts
  • Async Rust functions correctly become Promise<T> return types
  • The @ts-nocheck and /* eslint-disable */ in index.js suppress linting of generated code

tsconfig.json

{
  "compilerOptions": {
    "lib": ["ESNext"],
    "target": "ESNext",
    "module": "Preserve",
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "verbatimModuleSyntax": true,
    "noEmit": true,
    "strict": true,
    "skipLibCheck": true
  }
}

This is a development-only config — the package doesn't ship compiled TS, only .d.ts declarations.


6. Build Pipeline

6.1 Local Development Build

bun install                    # Install @napi-rs/cli + deps
bun run build                  # napi build --platform --release

What napi build --platform --release does:

  1. Invokes cargo build --release to compile the Rust crate as cdylib
  2. Copies the resulting .so/.dylib/.dll to the project root as {binaryName}.{platform}.node
    • e.g., iroh.linux-x64-gnu.node
  3. Generates index.d.ts (TypeScript declarations) from the #[napi] annotated code
  4. Generates index.js (CommonJS loader) that resolves the correct .node file per platform

6.2 CI Build (Cross-Compilation)

The CI matrix builds for 4 targets:

Host Runner Target Build Command
macos-latest x86_64-apple-darwin bun run build --target x86_64-apple-darwin
windows-latest x86_64-pc-windows-msvc bun run build --target x86_64-pc-windows-msvc
ubuntu-latest x86_64-unknown-linux-gnu bun run build --target x86_64-unknown-linux-gnu --use-napi-cross
macos-latest aarch64-apple-darwin bun run build --target aarch64-apple-darwin

Cross-compilation notes:

  • macOS → aarch64: Uses dtolnay/rust-toolchain with targets parameter; macOS cross-compilation "just works" with Rust
  • Linux x64: Uses --use-napi-cross flag which downloads cross-compilation toolchains via @napi-rs/cross-toolchain
  • Musl targets: Would use cargo-zigbuild via Zig (configured in CI but not in the current target matrix)
  • Windows: Uses MSVC target; .cargo/config.toml sets +crt-static for static CRT linking

6.3 Artifacts and Publishing

  1. Each matrix job uploads its .node file as a GitHub artifact
  2. The publish job:
    • Downloads all artifacts
    • Runs napi create-npm-dirs to create npm/ directory structure
    • Runs napi artifacts to move .node files into per-platform npm packages under npm/
    • Publishes the main package + all platform packages to npm

6.4 The index.js Loader

The auto-generated index.js is ~580 lines and handles:

  • Platform detection (process.platform + process.arch)
  • musl vs glibc detection on Linux (tries filesystem, process report, and child process)
  • Resolution ordering: try local .node file first, then fall back to @scope/pkg-platform npm package
  • NAPI_RS_NATIVE_LIBRARY_PATH environment variable override for custom paths
  • WASI/WASM fallback for browser environments
  • Version mismatch checking between main package and platform packages

7. Error Handling Across the Rust/TS Boundary

7.1 Error Definition Pattern

#[derive(Debug, Error)]
pub enum IrohError {
    #[error("Bind error: {0}")]
    Bind(String),
    #[error("Connect error: {0}")]
    Connect(String),
    #[error("Stream error: {0}")]
    Stream(String),
    #[error("Parse error: {0}")]
    Parse(String),
    #[error("Connection closed: {0}")]
    ConnectionClosed(String),
    #[error("Internal error: {0}")]
    Internal(String),
}

7.2 Error Conversion

Two approaches are used:

Approach 1: From trait implementation

impl From<IrohError> for napi::Error {
    fn from(err: IrohError) -> Self {
        napi::Error::from_reason(err.to_string())
    }
}

Approach 2: Generic helper function (preferred for inline use)

pub fn to_napi_error<E: std::fmt::Display>(err: E) -> napi::Error {
    napi::Error::from_reason(err.to_string())
}

7.3 Usage in Methods

pub async fn connect(&self, addr: String, alpn: String) -> Result<Connection> {
    let endpoint_id: iroh::EndpointId = addr
        .parse()
        .map_err(|e| to_napi_error(format!("Invalid node ID: {e}")))?;  // Add context

    let conn = self.inner.connect(endpoint_id, alpn.as_bytes())
        .await
        .map_err(to_napi_error)?;  // Simple conversion

    Ok(Connection::new(conn))
}

Key patterns:

  • All Rust errors are converted to napi::Error::from_reason(string) — they become JS Error objects with a .message property
  • thiserror is used on the Rust side for structured error categories, but these are flattened to strings at the boundary
  • The to_napi_error helper is more flexible than the From impl because it works with any Display type
  • Context is added manually with format!("Context: {e}") wrapping, since the error types lose structure at the boundary
  • Result<T> in Rust maps to throwing in JS (no explicit Result type in TypeScript)

7.4 TypeScript Side

On the TS side, errors appear as standard JavaScript Error objects thrown from native code. There is no typed error handling — everything is a string message:

try {
  const conn = await endpoint.connect(addr, alpn);
} catch (err) {
  // err is a standard Error with a message like "Connect error: connection refused"
}

Critique: This is a weak point of iroh-ts. Structured error information (error kind, codes) is lost at the boundary. For taskgraph-ts, we should consider:

  • Using napi::Error::from_status_with_message() with custom status codes
  • Or defining a custom error class on the JS side that parses the error string
  • Or using napi-rs's built-in #[napi(ts_type)] for more detailed type info

8. CI/CD and Cross-Compilation Setup

8.1 GitHub Actions Workflow

Jobs:

  1. lint (ubuntu-latest): cargo fmt -- --check + cargo clippy
  2. build (matrix): Cross-compile for 4 targets, upload .node artifacts
  3. publish (ubuntu-latest): Download artifacts, create npm dirs, publish to npm

Key CI details:

  • Bun is used instead of npm/yarn (oven-sh/setup-bun@v1)
  • Rust toolchain via dtolnay/rust-toolchain@stable with components: clippy, rustfmt
  • Cargo cache keyed by target-host pair
  • Zig + cargo-zigbuild installed conditionally for musl targets (though no musl targets in current matrix)
  • Version-based publishing: only publishes if the latest commit message matches a semver pattern
  • npm config set provenance true for supply chain security
  • Sequential needs dependency: build requires lint, publish requires both

8.2 Cross-Compilation Config

.cargo/config.toml:

[target.x86_64-pc-windows-msvc]
rustflags = ["-C", "target-feature=+crt-static"]

This ensures the Windows MSVC build statically links the C runtime, avoiding DLL dependency issues.

8.3 Platform Package Structure

When published, napi-rs creates a directory structure like:

npm/
├── darwin-arm64/     → @rayhanadev/iroh-darwin-arm64
├── darwin-x64/       → @rayhanadev/iroh-darwin-x64
├── linux-x64-gnu/    → @rayhanadev/iroh-linux-x64-gnu
└── win32-x64-msvc/   → @rayhanadev/iroh-win32-x64-msvc

Each contains a minimal package.json with os + cpu constraints and the .node file.


9. napi-rs Version and Dependency Pinning

Package Version Notes
napi (Rust) 3.0.0 Core runtime crate
napi-derive (Rust) 3.0.0 Procedural macros (must match napi major)
napi-build (Rust) 2 Build script helper
@napi-rs/cli (JS) ^3.2.0 CLI tool (resolved to 3.4.1 in lock)

Important: napi and napi-derive MUST share the same major version. Version 3.x is the latest stable line.

The features = ["async", "tokio_rt"] on the napi crate are critical:

  • async: Enables #[napi] on async functions
  • tokio_rt: Uses the tokio runtime for async napi-rs tasks (there is also napi4 feature for custom threads)

10. Patterns Worth Replicating and Avoiding

Patterns to Replicate

  1. Arc wrapping for shared Rust typesArc<InnerType> for structs that are shared across the JS/Rust boundary. This is essential for correct lifetime management.

  2. #[napi(factory)] for async constructors — Since JS constructors can't be async, use factory methods that return Promise<T>. The Endpoint::create() pattern is clean and idiomatic.

  3. Module-per-domain — Separate files for each domain concept (endpoint, connection, stream, error). Clean separation of concerns.

  4. #[napi(object)] for options structs — Clean mapping of Rust option structs to JS plain objects without class overhead.

  5. to_napi_error helper — Generic error conversion function that works with any Display type. Much more ergonomic than writing From impls for every error type.

  6. Release profile optimizationlto = true + strip = "symbols" significantly reduces binary size.

  7. Doc comments preserved — Rust doc comments (///) flow through to TypeScript declarations, providing IDE autocomplete.

  8. .gitattributes marking generated files — Using linguist-detectable=false for index.js, index.d.ts, and WASM files keeps GitHub language stats clean.

  9. Separate impl blocks — Having a non-#[napi] impl block for the new() constructor and a #[napi] impl block for JS-exposed methods cleanly separates internal Rust API from external JS API.

  10. Platform-specific cargo config — The .cargo/config.toml with +crt-static for Windows avoids runtime DLL issues.

Patterns to Improve Upon

  1. Error handling is lossy — All structured Rust errors are flattened to strings. Consider:

    • Custom napi error classes that preserve error categories
    • Error status codes using napi::Error with custom Status values
    • A richer error object on the JS side
  2. No test infrastructure — The project has no unit tests. For taskgraph-ts, we should add:

    • Rust unit tests for internal logic
    • JS/TS integration tests that verify the napi boundary
    • CI test step that runs after build
  3. No TypeScript wrapper layerindex.d.ts is purely auto-generated. Consider adding a hand-written src/index.ts that:

    • Re-exports from the native module
    • Adds convenience methods
    • Provides typed error classes
    • Adds input validation before calling native code
  4. Missing musl/Linux ARM64 targets — Only 4 targets in the napi config. For broader compatibility, consider adding:

    • aarch64-unknown-linux-gnu (ARM Linux, AWS Graviton)
    • x86_64-unknown-linux-musl (Alpine Docker images)
  5. No Cargo.lock committed — The .gitignore excludes Cargo.lock. For a binary crate (as opposed to a library), it's generally recommended to commit the lock file for reproducible builds. However, napi-rs projects often skip this since the CI builds from scratch.

  6. No napi.config.js or napi.config.ts — Some larger napi-rs projects use a dedicated config file. For a small project, package.json napi section is fine, but it doesn't scale well.

  7. Arc<Mutex<T>> everywhere — The SendStream/RecvStream use tokio::sync::Mutex even for read-only operations (like id()). Consider whether some operations could use Arc<tokio::sync::RwLock> or even std::sync::RwLock for non-async read paths.

  8. init_logging as a public API — Exposing tracing_subscriber::fmt().try_init() is fragile: it can only succeed once, and multiple calls silently fail. Consider returning a boolean/result indicating whether initialization succeeded.

  9. No version consistency checks — The Rust crate version (0.1.0) and npm package version (0.1.1) differ. This could cause confusion. Consider aligning them or using a script to keep them in sync.

  10. No #[napi(custom_finalize)] — Missing custom finalize/destructor logic. If Rust objects need cleanup (e.g., flushing streams before drop), the default finalization may not be sufficient.


Based on this research, here is a recommended approach:

Cargo.toml

[package]
name = "taskgraph"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]

[dependencies]
napi-derive = "3.0.0"
# taskgraph = "..." (the Rust library being wrapped)
thiserror = "2"
tracing = "0.1"

[dependencies.napi]
version = "3.0.0"
features = ["async", "tokio_rt"]

[build-dependencies]
napi-build = "2"

[profile.release]
lto = true
strip = "symbols"

package.json

{
  "name": "@alkdev/taskgraph",
  "main": "index.js",
  "types": "index.d.ts",
  "napi": {
    "binaryName": "taskgraph",
    "targets": [
      "x86_64-apple-darwin",
      "aarch64-apple-darwin",
      "x86_64-unknown-linux-gnu",
      "aarch64-unknown-linux-gnu",
      "x86_64-pc-windows-msvc"
    ]
  },
  "scripts": {
    "build": "napi build --platform --release",
    "build:debug": "napi build --platform",
    "artifacts": "napi artifacts",
    "prepublishOnly": "napi prepublish -t npm",
    "version": "napi version"
  },
  "devDependencies": {
    "@napi-rs/cli": "^3.2.0"
  }
}

Rust Source Layout

src/
├── lib.rs          # Module declarations + any free functions
├── error.rs        # Error types + to_napi_error helper
├── graph.rs        # Graph class (#[napi] struct + impl)
├── task.rs         # Task class
├── executor.rs     # Executor class (async factory methods)
└── types.rs        # Shared types, #[napi(object)] structs

Key Conventions to Adopt

  1. Arc<T> for all napi struct inner types
  2. #[napi(factory)] for async constructors
  3. #[napi(object)] for option/config structs
  4. to_napi_error generic helper for error conversion
  5. Preserve doc comments for auto-generated .d.ts
  6. Always match napi and napi-derive major versions
  7. Use tokio::sync::Mutex for async interior mutability
  8. Add .cargo/config.toml with Windows CRT static linking
  9. Use bun as the JS runtime (fast, napi-rs compatible)
  10. Commit generated index.js and index.d.ts to the repo