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/orcrates/directory — it is a single Rust crate mapped to a single npm package - Generated
index.jsandindex.d.tsare committed to the repo (auto-generated bynapi build) - Platform-specific
.nodebinaries 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
avaframework 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.nodeshared librarynapiandnapi-deriveare separate crates with matched major versions (both 3.0.0)- The
async+tokio_rtfeatures on napi are essential if you expose async Rust functions napi-build = "2"is a minimal build dependency that just callsnapi_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
napisection in package.json is the core napi-rs config — it defines:binaryName: the base name for the.nodefile (must match the crate name)targets: which platform triples to build for (used by CI andnapiCLI)
--platformflag onnapi buildmakes it generate the correct platform-specific filenameoptionalDependencieslists the per-platform npm packages — npm auto-selects the right one- These are separate npm packages published from the
npm/directory created bynapi create-npm-dirs - Names follow the pattern:
@scope/{binaryName}-{platform}-{arch}-{abi}
- These are separate npm packages published from the
@emnapi/coreand@emnapi/runtimeare for WASI/WASM support (optional, for browser targets)- No
@napi-rs/clientdependency — the CLI (@napi-rs/cli) is the only napi-rs JS dependency needed - The
prepublishOnlyscript runsnapi prepublishwhich 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 usere-exports from the crate root - Free functions annotated with
#[napi]become top-level JS exports Option<String>maps tostring | undefined | nullin 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:
- Struct + impl separation: The struct definition gets
#[napi], AND a separate#[napi] implblock exposes methods Arc<InnerType>pattern: Rust objects shared across JS/Rust boundary are wrapped inArc. 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
#[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)snake_caseRust →camelCaseJS: napi-rs auto-convertsnode_idtonodeId,create_with_optionstocreateWithOptions- Return types:
Result<T>maps to JST | 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 theArc<Mutex<>>is cheaply cloneableBuffertype fromnapi::bindgen_prelude::*maps to Node.jsBuffer- Manual
as u32cast because napi-rs doesn't supportusize(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 fieldsOption<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→.sendproperty 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 becomedeclare classwith methods#[napi(object)]structs becomeinterface#[napi(factory)]methods becomestaticmethods#[napi(getter)]methods becomegetaccessor properties- Doc comments from Rust (
/// ...) are preserved as JSDoc in the.d.ts - Async Rust functions correctly become
Promise<T>return types - The
@ts-nocheckand/* eslint-disable */inindex.jssuppress 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:
- Invokes
cargo build --releaseto compile the Rust crate ascdylib - Copies the resulting
.so/.dylib/.dllto the project root as{binaryName}.{platform}.node- e.g.,
iroh.linux-x64-gnu.node
- e.g.,
- Generates
index.d.ts(TypeScript declarations) from the#[napi]annotated code - Generates
index.js(CommonJS loader) that resolves the correct.nodefile 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-toolchainwithtargetsparameter; macOS cross-compilation "just works" with Rust - Linux x64: Uses
--use-napi-crossflag which downloads cross-compilation toolchains via@napi-rs/cross-toolchain - Musl targets: Would use
cargo-zigbuildvia Zig (configured in CI but not in the current target matrix) - Windows: Uses MSVC target;
.cargo/config.tomlsets+crt-staticfor static CRT linking
6.3 Artifacts and Publishing
- Each matrix job uploads its
.nodefile as a GitHub artifact - The
publishjob:- Downloads all artifacts
- Runs
napi create-npm-dirsto createnpm/directory structure - Runs
napi artifactsto move.nodefiles into per-platform npm packages undernpm/ - 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
.nodefile first, then fall back to@scope/pkg-platformnpm package NAPI_RS_NATIVE_LIBRARY_PATHenvironment 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 JSErrorobjects with a.messageproperty thiserroris used on the Rust side for structured error categories, but these are flattened to strings at the boundary- The
to_napi_errorhelper is more flexible than theFromimpl because it works with anyDisplaytype - 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 explicitResulttype 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:
lint(ubuntu-latest):cargo fmt -- --check+cargo clippybuild(matrix): Cross-compile for 4 targets, upload.nodeartifactspublish(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@stablewithcomponents: clippy, rustfmt - Cargo cache keyed by
target-hostpair - 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 truefor supply chain security- Sequential
needsdependency:buildrequireslint,publishrequires 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 functionstokio_rt: Uses the tokio runtime for async napi-rs tasks (there is alsonapi4feature for custom threads)
10. Patterns Worth Replicating and Avoiding
Patterns to Replicate
-
Arc wrapping for shared Rust types —
Arc<InnerType>for structs that are shared across the JS/Rust boundary. This is essential for correct lifetime management. -
#[napi(factory)]for async constructors — Since JS constructors can't be async, use factory methods that returnPromise<T>. TheEndpoint::create()pattern is clean and idiomatic. -
Module-per-domain — Separate files for each domain concept (endpoint, connection, stream, error). Clean separation of concerns.
-
#[napi(object)]for options structs — Clean mapping of Rust option structs to JS plain objects without class overhead. -
to_napi_errorhelper — Generic error conversion function that works with anyDisplaytype. Much more ergonomic than writingFromimpls for every error type. -
Release profile optimization —
lto = true+strip = "symbols"significantly reduces binary size. -
Doc comments preserved — Rust doc comments (
///) flow through to TypeScript declarations, providing IDE autocomplete. -
.gitattributesmarking generated files — Usinglinguist-detectable=falseforindex.js,index.d.ts, and WASM files keeps GitHub language stats clean. -
Separate
implblocks — Having a non-#[napi]implblock for thenew()constructor and a#[napi] implblock for JS-exposed methods cleanly separates internal Rust API from external JS API. -
Platform-specific cargo config — The
.cargo/config.tomlwith+crt-staticfor Windows avoids runtime DLL issues.
Patterns to Improve Upon
-
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::Errorwith customStatusvalues - A richer error object on the JS side
-
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
-
No TypeScript wrapper layer —
index.d.tsis purely auto-generated. Consider adding a hand-writtensrc/index.tsthat:- Re-exports from the native module
- Adds convenience methods
- Provides typed error classes
- Adds input validation before calling native code
-
Missing musl/Linux ARM64 targets — Only 4 targets in the
napiconfig. For broader compatibility, consider adding:aarch64-unknown-linux-gnu(ARM Linux, AWS Graviton)x86_64-unknown-linux-musl(Alpine Docker images)
-
No
Cargo.lockcommitted — The.gitignoreexcludesCargo.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. -
No
napi.config.jsornapi.config.ts— Some larger napi-rs projects use a dedicated config file. For a small project,package.jsonnapisection is fine, but it doesn't scale well. -
Arc<Mutex<T>>everywhere — The SendStream/RecvStream usetokio::sync::Mutexeven for read-only operations (likeid()). Consider whether some operations could useArc<tokio::sync::RwLock>or evenstd::sync::RwLockfor non-async read paths. -
init_loggingas a public API — Exposingtracing_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. -
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. -
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.
Summary: Recommended Architecture for taskgraph-ts
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
Arc<T>for all napi struct inner types#[napi(factory)]for async constructors#[napi(object)]for option/config structsto_napi_errorgeneric helper for error conversion- Preserve doc comments for auto-generated
.d.ts - Always match
napiandnapi-derivemajor versions - Use
tokio::sync::Mutexfor async interior mutability - Add
.cargo/config.tomlwith Windows CRT static linking - Use
bunas the JS runtime (fast, napi-rs compatible) - Commit generated
index.jsandindex.d.tsto the repo