1. 为什么需要双向 FFI
单向 FFI:TS → Rust
大多数 Node.js 原生模块只做单向调用:TypeScript 调用 Rust 函数,拿到返回值,结束。
TS ──call──> Rust ──return──> TS单向够用的场景:
- 文件编解码(传入路径,返回结果)
- 数据序列化 / 反序列化
- 密码学运算(hash、encrypt)
- 图片压缩(传入 buffer,返回 buffer)
这些场景的共同特征:调用一次,拿到结果,没有中途反馈。
双向 FFI:TS ↔ Rust
真实业务中,大量场景需要 Rust 主动向 TypeScript 推送信息:
| 场景 | 单向能否实现 | 说明 |
|---|---|---|
| 进度条 | 否 | Rust 处理耗时任务时,需持续回调 TS 更新进度 |
| 流式数据 | 否 | Rust 逐行读取大文件,逐条推送给 TS |
| 常驻监听 | 否 | Rust 启动长驻线程监听文件/端口变化,事件发生时回调 TS |
| AI 推理反馈 | 否 | Rust 调用推理引擎,token 逐个生成时回调 TS 渲染 |
| 日志收集 | 勉强 | 可用共享内存轮询,但延迟高、浪费 CPU |
TS ──call──> Rust ──callback──> TS ──callback──> Rust ...
│
└── 子线程持续运行,随时回调 TS核心区别:单向是"问答",双向是"对话"。 当 Rust 需要在执行过程中主动通知 TypeScript 时,双向 FFI 是唯一选择。
2. ThreadsafeFunction 原理
为什么不能直接从子线程调 JS 函数
V8 引擎是单线程的。所有 JavaScript 代码都在 V8 主线程上执行。如果 Rust 的子线程直接调用一个 JS 函数指针,会导致:
- 数据竞争:V8 堆内存没有加锁,并发访问会 UB
- 段错误:V8 内部状态不一致,直接崩溃
- 未定义行为:C++ 层面的 UB,无法预测后果
ThreadsafeFunction 的桥梁作用
N-API 提供了 napi_threadsafe_function,它是一个线程安全的消息传递桥梁:
Rust 子线程 N-API 消息队列 V8 主线程
───────────── ────────────────── ──────────────
fn callback() ──┐
│ enqueue(call_args)
spawn(thread) ───┤ ──────────────────> [Queue]
│ │
fn callback() ──┘ │
v
dequeue & call
js_callback()
on V8 main thread工作流程:
- 注册:主线程创建 TSFN,绑定一个 JS 回调函数
- 入队:Rust 子线程调用 napi_call_threadsafe_function,将参数打包放入队列
- 调度:N-API 在 V8 主线程空闲时从队列取出任务
- 执行:在 V8 主线程上调用 JS 回调,传入参数
- 释放:所有线程完成后,释放 TSFN 资源
TSFN 本质上是一个生产者-消费者队列:Rust 子线程是生产者,V8 主线程是消费者。
3. 从零手写双向回调(痛苦版)
不用 tsffib,纯 napi-rs v2 手写一个"带进度回调的文件处理":
use napi::bindgen_prelude::*;
use napi::threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode};
use std::fs;
use std::thread;
#[napi(object)]
struct ProcessOptions {
path: String,
on_progress: ThreadsafeFunction<f64, bool>,
}
#[napi]
fn process_with_progress(opts: ProcessOptions) -> Result<String> {
let on_progress = opts.on_progress.clone();
let path = opts.path.clone();
thread::spawn(move || {
let data = fs::read_to_string(&path).unwrap_or_default();
let total = data.len() as f64;
let chunk = (total / 10.0).ceil() as usize;
for i in 1..=10 {
let pct = (i as f64 / 10.0) * 100.0;
let should_continue = on_progress.call(
Ok(pct),
ThreadsafeFunctionCallMode::Blocking,
);
if should_continue.is_err() {
break;
}
let offset = i * chunk;
let _ = &data[..offset.min(data.len())];
}
});
Ok("processing".to_string())
}TypeScript 端:
import { processWithProgress } from './index'
processWithProgress({
path: './big-file.txt',
onProgress: (pct: number) => {
console.log(`进度: ${pct}%`)
return pct < 100
},
})痛点清单:
- 手动 clone ThreadsafeFunction 传入闭包
- 手动 thread::spawn 管理线程
- 手动处理 ThreadsafeFunctionCallMode
- 手动处理返回值 Result 判断是否继续
- 没有生命周期管理,回调可能泄漏
- 没有异常隔离,子线程 panic 直接崩溃进程
- 没有类型自动生成,TS 端需要手写类型
4. 用 tsffib 重写(极简版)
同样的功能,用 tsffib 只需:
use tsffi::prelude::*;
#[tsffi::callback]
fn on_progress(pct: f64) -> bool {
pct < 100.0
}
#[tsffi::export]
fn process_file(path: String) -> Result<String> {
let data = fs::read_to_string(&path)?;
Ok(transform(&data))
}TypeScript 端完全不变,类型自动生成。
对比
| 项目 | 手写 | tsffib |
|---|---|---|
| Rust 代码行数 | ~40 | ~5 |
| clone TSFN | 手动 | 自动 |
| spawn 线程 | 手动 | 自动 |
| 生命周期管理 | 无 | LifecycleManager |
| 异常隔离 | 无 | catch_unwind + PanicHook |
| 类型生成 | 手写 .d.ts | 自动生成 |
| 回调释放 | 手动 | 自动 track/untrack |
tsffib 把 40 行样板代码压缩到 5 行,同时增加了生命周期管理和异常隔离。
5. 类型是怎么映射的
完整链路
Rust f64 ──napi_create_double──> napi_value ──V8──> V8 Number ──tsc──> TS number一次回调调用经历 4 层转换:
- Rust 值通过 N-API C 函数包装为 napi_value
- napi_value 在 V8 堆上创建对应的 JS 值
- TypeScript 类型系统识别该值的类型
- tsffib 的类型生成器根据 Rust 签名生成 .d.ts
类型映射表
| Rust 类型 | N-API | V8 | TypeScript |
|---|---|---|---|
| i32 | napi_value | Number | number |
| i64 | napi_value | BigInt | bigint |
| u32 | napi_value | Number | number |
| u64 | napi_value | BigInt | bigint |
| f64 | napi_value | Number | number |
| f32 | napi_value | Number | number |
| bool | napi_value | Boolean | boolean |
| String | napi_value | String | string |
| Vec<T> | napi_value | Array | T[] |
| Option<T> | napi_value | T | null | T | null |
| Struct | napi_value | Object | interface |
| Result<T> | napi_value | T | Error | T | Error |
| ThreadsafeFunction<T,R> | napi_threadsafe_function | Function | (arg: T) => R |
复合类型映射规则
Vec<T>:Rust Vec 映射为 TS 数组。元素类型 T 递归映射。Vec<u8> 特殊优化为 Buffer。
Option<T>:Rust Option 映射为 TS 联合类型 T | null。Some(v) 正常传递,None 传递 null。
ThreadsafeFunction<T, R>:Rust 端是线程安全函数句柄,TS 端是普通函数签名 (arg: T) => R。T 是回调参数类型,R 是回调返回类型。
6. 回调生命周期
注册 → 调用 → 释放
注册 调用 释放
│ │ │
v v v
track(id, fn) ──> call_via_tsfns ──> untrack(id)
│ │ │
│ │ v
│ │ cleanup_dead()
v v │
Weak<Fn> 存入 Weak.upgrade() 移除已 GC 的条目
active map 获取强引用 active_count() 减少LifecycleManager 核心方法
impl LifecycleManager {
pub fn track(&self, id: CallbackId, tsfn: Weak<ThreadsafeFunction>) {
let mut map = self.active.write();
map.insert(id, tsfn);
}
pub fn untrack(&self, id: CallbackId) {
let mut map = self.active.write();
map.remove(&id);
}
pub fn cleanup_dead(&self) {
let mut map = self.active.write();
map.retain(|_, weak| weak.strong_count() > 0);
}
pub fn active_count(&self) -> usize {
let map = self.active.read();
map.len()
}
}Weak 句柄追踪
LifecycleManager 存储的是 Weak<ThreadsafeFunction> 而非强引用。原因:
- 不强占生命周期:如果存强引用,即使 JS 端已不再引用该回调,Rust 端仍持有引用,V8 GC 无法回收,造成泄漏
- 检测 GC 回收:weak.strong_count() == 0 表示 V8 已回收该函数,可以安全移除记录
- 调用时升级:weak.upgrade() 尝试获取强引用,如果返回 None 说明已被 GC,跳过调用
为什么需要手动 cleanup
V8 GC 是惰性的,不会在回收 JS 函数时通知 Rust 端。cleanup_dead 需要在适当时机手动调用:
- 每次 untrack 后
- 定期(如每 100 次回调后)
- 模块卸载时
不调用 cleanup_dead 不会导致错误,但 active map 会持续增长,造成内存浪费。
7. 异常隔离原理
问题:子线程 panic 会怎样
Rust 的 panic! 默认行为是 unwind 栈并终止线程。在 FFI 边界上,unwind 跨越 Rust → C → JS 的调用栈会导致整个 Node.js 进程 abort。
Rust 子线程 panic!
│
v
unwind 栈跨越 FFI 边界
│
v
C 层未定义行为
│
v
Node.js 进程 abort (SIGABRT)解决:catch_unwind + set_hook
tsffib 在每个子线程入口用 std::panic::catch_unwind 包裹整个执行体,并在启动时设置自定义 panic hook:
use std::panic::{self, AssertUnwindSafe};
fn spawn_isolated<F, R>(f: F) -> JoinHandle<Result<R, String>>
where
F: FnOnce() -> R + Send + 'static,
R: Send + 'static,
{
thread::spawn(move || {
panic::catch_unwind(AssertUnwindSafe(f))
.map_err(|e| {
if let Some(s) = e.downcast_ref::<&str>() {
s.to_string()
} else if let Some(s) = e.downcast_ref::<String>() {
s.clone()
} else {
"unknown panic".to_string()
}
})
})
}
fn install_panic_hook() {
panic::set_hook(Box::new(|info| {
eprintln!("[tsffi] panic captured: {}", info);
}));
}效果:
Rust 子线程 panic!
│
v
catch_unwind 捕获
│
v
panic_hook 打印日志
│
v
返回 Err(msg) 给调用方
│
v
Node.js 收到 napi Error,进程继续运行panic-test 示例
#[tsffi::export]
fn panic_test() -> Result<String> {
panic!("intentional panic for testing");
}import { panicTest } from './index'
try {
panicTest()
} catch (e) {
console.error('Rust panic 被安全捕获:', e.message)
}进程不会崩溃,e.message 包含 "intentional panic for testing"。
8. 生产部署
Electron 集成
Electron 的渲染进程无法直接调用 Node.js 原生模块,需要通过 contextBridge 暴露:
import { contextBridge } from 'electron'
import { processFile, onProgress } from '@tsffib/example'
contextBridge.exposeInMainWorld('tsffi', {
processFile,
onProgress,
})preload.ts:
window.tsffi.processFile('./data.bin')
window.tsffi.onProgress((pct: number) => {
document.getElementById('progress-bar')!.style.width = `${pct}%`
return pct < 100
})- contextBridge 只能传递可序列化数据,ThreadsafeFunction 通过 N-API 传递,不受此限制
- 主进程和渲染进程各自有独立的 V8 实例,TSFN 在创建它的进程中有效
- 多窗口场景下,每个窗口需要独立注册回调
跨平台编译
napi-rs 通过 triples 指定目标平台:
[package.metadata.napi]
name = "tsffi_example"
[[package.metadata.napi.triples]]
triple = "x86_64-pc-windows-msvc"
[[package.metadata.napi.triples]]
triple = "x86_64-apple-darwin"
[[package.metadata.napi.triples]]
triple = "aarch64-apple-darwin"
[[package.metadata.napi.triples]]
triple = "x86_64-unknown-linux-gnu"
[[package.metadata.napi.triples]]
triple = "aarch64-unknown-linux-gnu"使用 cross 在 Docker 中编译 Linux 目标:
npx napi build --cross-compile --target x86_64-unknown-linux-gnuCross.toml 配置:
[target.x86_64-unknown-linux-gnu]
image = "ghcr.io/napi-rs/napi-rs:napi-debian"
[target.aarch64-unknown-linux-gnu]
image = "ghcr.io/napi-rs/napi-rs:napi-debian-aarch64"NPM 发布流程
prepublish 脚本(package.json):
{
"scripts": {
"prepublishOnly": "npx napi build --release --platform"
},
"napi": {
"binaryName": "tsffi_example",
"package": {
"name": "@tsffib/example-darwin-x64",
"platforms": {
"darwin-x64": "darwin-x64",
"darwin-arm64": "darwin-arm64",
"win32-x64": "win32-x64",
"linux-x64": "linux-x64-gnu",
"linux-arm64": "linux-arm64-gnu"
}
}
}
}platform packages 策略:主包 @tsffib/example 不含二进制,各平台包 @tsffib/example-darwin-x64 等含编译产物。安装时 npm 根据 os + cpu 字段自动选择。
发布命令:
npx napi prepublish -t x86_64-pc-windows-msvc
npx napi prepublish -t x86_64-apple-darwin
npx napi prepublish -t aarch64-apple-darwin
npx napi prepublish -t x86_64-unknown-linux-gnu
npx napi prepublish -t aarch64-unknown-linux-gnu
npm publishtsffib doctor 诊断
部署前运行诊断,检查环境是否就绪:
npx tsffib doctor输出示例:
[✓] Node.js >= 18 (v20.11.0)
[✓] Rust stable (1.77.0)
[✓] napi-cli (2.16.0)
[✓] cross (0.2.90)
[!] pnpm (未安装, 可选)
[✓] @tsffib/core (0.1.0)
[✓] 目标平台工具链 (5/5 已安装)[!] 表示可选依赖缺失,[✗] 表示必须依赖缺失需修复。