Combining TypeScript and Rust in the foss.global Ecosystem: The Best of Both Worlds
TypeScript is great for building applications. The type system catches bugs, the tooling is mature, and you can hire for it. But if you've ever tried to build a high-throughput TLS proxy, a DNS server with DNSSEC, or an SMTP security pipeline that needs to verify DKIM signatures at wire speed, you know where Node.js starts to hurt: CPU-bound work, memory-sensitive long-running daemons, and anything that needs to talk to the kernel.
We ran into this repeatedly at foss.global, where we build open-source infrastructure tools for startups and SMEs. Our answer wasn't to rewrite everything in Rust. Instead, we built two packages that let TypeScript and Rust coexist in the same project, compiled with a single pnpm build, and connected by typed IPC at runtime. Today, eight of our production services use this pattern.
The Two Packages
@git.zone/tsrust is the build side. It follows the same convention as @git.zone/tsbuild: TypeScript source lives in ts/ and compiles to dist_ts/. Rust source lives in rust/ and compiles to dist_rust/. Running tsrust from your project root parses the Cargo workspace, calls cargo build --release, and copies the binaries into dist_rust/.
@push.rocks/smartrust is the runtime side. It provides RustBridge<T>, a generic class that spawns a Rust binary (or connects to an existing daemon via Unix socket), talks to it over newline-delimited JSON, and gives you typed sendCommand() calls.
Your build script looks like this:
{
"scripts": {
"build": "tsbuild tsfolders && tsrust"
}
}
Both languages compile in one step.
No Rust Installation Required
tsrust can install Rust for you. If cargo isn't on the system, it downloads a minimal toolchain (~70-90 MB) to /tmp/tsrust_toolchain/ and reuses it on subsequent runs. This means pnpm install && pnpm build works on a fresh CI runner or a teammate's machine that has never seen Rust before.
Cross-compilation uses friendly target names:
tsrust --target linux_arm64 --target linux_amd64
The output binaries get platform suffixes (rustproxy_linux_arm64, rustproxy_linux_amd64), and the TypeScript bridge knows how to find the right one at runtime based on process.platform and process.arch.
How the Bridge Works
You start by defining a type map that describes every command the Rust binary supports:
type TDnsCommands = {
start: {
params: { config: IDnsConfig };
result: Record<string, never>;
};
stop: {
params: Record<string, never>;
result: Record<string, never>;
};
ping: {
params: Record<string, never>;
result: { pong: boolean };
};
};
Then you create a bridge and call commands on it:
import { RustBridge } from '@push.rocks/smartrust';
const bridge = new RustBridge<TDnsCommands>({
binaryName: 'rustdns-server',
cliArgs: ['--management'],
requestTimeoutMs: 30_000,
localPaths: [
path.join(packageDir, 'dist_rust', 'rustdns-server'),
],
});
const ok = await bridge.spawn();
if (!ok) throw new Error('Failed to start DNS server');
// params and return type are inferred from TDnsCommands
await bridge.sendCommand('start', { config: myDnsConfig });
const { pong } = await bridge.sendCommand('ping', {});
Under the hood, the protocol is newline-delimited JSON over stdin/stdout. The Rust binary reads a JSON request, does its work, and writes a JSON response. No FFI, no native addons, no node-gyp. Two processes talking over a pipe.
Stdio vs. Socket Mode
bridge.spawn() launches the Rust binary as a child process. Your TypeScript app owns the lifecycle.
bridge.connect('/var/run/daemon.sock') connects to a Rust process that's already running, typically as a system service. This matters when the Rust side needs root privileges (binding to port 443, managing TUN devices for a VPN) but the TypeScript control plane should run unprivileged. Socket mode has auto-reconnect with exponential backoff:
await bridge.connect('/var/run/smartvpn.sock', {
autoReconnect: true,
reconnectMaxDelayMs: 30_000,
maxReconnectAttempts: 10,
});
The sendCommand() API is the same in both modes.
Streaming
Some operations produce output over time: a traceroute returning hops one by one, a log tail, a chunked file transfer. For these, you add a chunk type to your command definition and use sendCommandStreaming:
type TNetworkCommands = {
traceroute: {
params: { host: string; maxHops?: number };
chunk: { ttl: number; ip: string; rtt: number | null };
result: { totalHops: number };
};
};
const stream = bridge.sendCommandStreaming('traceroute', { host: 'example.com' });
for await (const hop of stream) {
console.log(`Hop ${hop.ttl}: ${hop.ip} (${hop.rtt}ms)`);
}
const result = await stream.result;
console.log(`Trace complete: ${result.totalHops} hops`);
The timeout resets on each chunk, so it works as an inactivity timer rather than an absolute deadline.
What's Running in Production
This pattern isn't theoretical. Here's what we've shipped with it:
smartproxy is an HTTP/TLS reverse proxy. The Rust side is 8 workspace crates handling TLS termination (with automatic Let's Encrypt via ACME), domain-based routing, SNI passthrough, nftables integration, and Prometheus metrics export. TypeScript handles configuration and route management.
smartmta is a mail transfer agent. Rust runs the SMTP server and client, connection pooling, and a full security pipeline: DKIM signing and verification, SPF validation, DMARC policy enforcement, IP reputation checks, and content scanning. It uses tokio for async I/O and mimalloc to avoid memory fragmentation in long-running processes.
smartdns is a DNS server with DNSSEC. The Rust workspace handles protocol parsing, UDP and DNS-over-HTTPS serving, key management, and zone signing. TypeScript provides the configuration and zone management API.
smartfs wraps filesystem operations in a Rust daemon that handles file reads, writes, atomic operations, and filesystem watching (via the notify crate). The TypeScript side connects over a Unix socket.
smartnetwork does ICMP ping and traceroute. It uses cross-compiled, platform-suffixed binaries (rustnetwork_linux_amd64, rustnetwork_macos_arm64) so the right binary is picked up at runtime.
smartvpn is a VPN daemon using the Noise protocol for encryption. It runs as a privileged system service; TypeScript connects over a Unix socket to manage tunnels.
Why JSON-over-IPC Instead of FFI or WASM
There are other ways to call Rust from TypeScript. napi-rs gives you native Node.js addons. You can compile to WebAssembly. You can use direct FFI with ffi-napi. We went with JSON-over-stdio for specific reasons.
Isolation. The Rust binary has its own process and its own memory. Node.js GC pauses don't stall packet processing. A segfault in Rust doesn't crash your TypeScript app. You can profile and restart each side independently.
No code generation. RustBridge<TCommands> uses TypeScript generics to infer parameter and return types from a single type map. There are no protobuf schemas to maintain, no generated files, no extra build steps. You define the types once and sendCommand() is fully typed.
Debuggability. The protocol is newline-delimited JSON. You can pipe stdout to jq. You can write a test harness in Python or bash. On the Rust side, you need serde_json and a loop reading from stdin. That's it.
Lifecycle handling. The bridge takes care of binary discovery (checking env vars, platform npm packages, local dev builds, and system PATH in that order), ready-signal handshakes, request timeouts, and clean shutdown (SIGTERM, followed by SIGKILL after 5 seconds).
Cross-platform support. tsrust produces platform-suffixed binaries. smartrust's binary locator searches for the right one based on OS and architecture. Socket mode works on Linux and macOS (Unix sockets) and Windows (named pipes) without platform-specific code.
Getting Started
Here's the minimal setup to add Rust to an existing TypeScript project:
1. Create the Rust project:
my-project/
├── rust/
│ ├── Cargo.toml
│ └── src/
│ └── main.rs
├── ts/
│ └── index.ts
└── package.json
2. Implement the Rust binary with the management protocol:
use serde::{Deserialize, Serialize};
use std::io::{self, BufRead, Write};
#[derive(Deserialize)]
struct Request {
id: String,
method: String,
params: serde_json::Value,
}
#[derive(Serialize)]
struct Response {
id: String,
success: bool,
#[serde(skip_serializing_if = "Option::is_none")]
result: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
}
fn main() {
// Signal readiness to the TypeScript bridge
println!(r#"{{"event":"ready","data":{{"version":"1.0.0"}}}}"#);
io::stdout().flush().unwrap();
let stdin = io::stdin();
for line in stdin.lock().lines() {
let line = line.unwrap();
let req: Request = serde_json::from_str(&line).unwrap();
let resp = match req.method.as_str() {
"greet" => {
let name = req.params["name"].as_str().unwrap_or("world");
Response {
id: req.id,
success: true,
result: Some(serde_json::json!({ "message": format!("Hello, {}!", name) })),
error: None,
}
}
_ => Response {
id: req.id,
success: false,
result: None,
error: Some(format!("Unknown method: {}", req.method)),
},
};
println!("{}", serde_json::to_string(&resp).unwrap());
io::stdout().flush().unwrap();
}
}
3. Define the TypeScript bridge:
import { RustBridge } from '@push.rocks/smartrust';
import * as path from 'path';
type TMyCommands = {
greet: {
params: { name: string };
result: { message: string };
};
};
const bridge = new RustBridge<TMyCommands>({
binaryName: 'my-binary',
localPaths: [
path.join(__dirname, '..', 'dist_rust', 'my-binary'),
],
});
async function main() {
const ok = await bridge.spawn();
if (!ok) {
console.error('Could not start Rust binary');
process.exit(1);
}
const { message } = await bridge.sendCommand('greet', { name: 'TypeScript' });
console.log(message); // "Hello, TypeScript!"
bridge.kill();
}
main();
4. Add dependencies and build:
pnpm install @push.rocks/smartrust
pnpm install --save-dev @git.zone/tsrust
{
"scripts": {
"build": "tsbuild tsfolders && tsrust"
}
}
Run pnpm build. TypeScript and Rust compile together. The binary lands in dist_rust/, the bridge finds it, and you have typed, process-isolated Rust integration without touching FFI.
Our take
What makes this approach work in practice is that it's boring. JSON over stdin is something every developer already understands. There's no magic, no binding generator, no WASM glue code. You write a Rust binary that reads JSON and writes JSON. You write a TypeScript type map. The bridge connects the two with full type safety.
We now run TLS termination, email security pipelines, DNS with DNSSEC, and VPN tunnels through this pattern. The TypeScript side handles what TypeScript is good at: configuration, APIs, developer ergonomics. The Rust side handles what Rust is good at: throughput, memory control, and talking to the OS. Neither side pretends to be the other.
If you want to add Rust to a TypeScript project without the weight of native bindings, give @push.rocks/smartrust and @git.zone/tsrust a try. Install them, drop a rust/ directory in your project, and run pnpm build. That's genuinely all it takes.