渐进式教程

从原理到生产,8 个章节掌握 TypeScript ↔ Rust 双向 FFI

1. 为什么需要双向 FFI

单向 FFI:TS → Rust

大多数 Node.js 原生模块只做单向调用:TypeScript 调用 Rust 函数,拿到返回值,结束。

text
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
text
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,它是一个线程安全的消息传递桥梁

text
Rust 子线程                N-API 消息队列               V8 主线程
─────────────            ──────────────────           ──────────────

  fn callback()  ──┐
                   │    enqueue(call_args)
  spawn(thread) ───┤  ──────────────────>  [Queue]
                   │                          │
  fn callback()  ──┘                          │
                                               v
                                         dequeue & call
                                         js_callback()
                                         on V8 main thread

工作流程:

  1. 注册:主线程创建 TSFN,绑定一个 JS 回调函数
  2. 入队:Rust 子线程调用 napi_call_threadsafe_function,将参数打包放入队列
  3. 调度:N-API 在 V8 主线程空闲时从队列取出任务
  4. 执行:在 V8 主线程上调用 JS 回调,传入参数
  5. 释放:所有线程完成后,释放 TSFN 资源

TSFN 本质上是一个生产者-消费者队列:Rust 子线程是生产者,V8 主线程是消费者。

3. 从零手写双向回调(痛苦版)

不用 tsffib,纯 napi-rs v2 手写一个"带进度回调的文件处理":

rust
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 端:

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 只需:

rust
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. 类型是怎么映射的

完整链路

text
Rust f64  ──napi_create_double──>  napi_value  ──V8──>  V8 Number  ──tsc──>  TS number

一次回调调用经历 4 层转换:

  1. Rust 值通过 N-API C 函数包装为 napi_value
  2. napi_value 在 V8 堆上创建对应的 JS 值
  3. TypeScript 类型系统识别该值的类型
  4. tsffib 的类型生成器根据 Rust 签名生成 .d.ts

类型映射表

Rust 类型N-APIV8TypeScript
i32napi_valueNumbernumber
i64napi_valueBigIntbigint
u32napi_valueNumbernumber
u64napi_valueBigIntbigint
f64napi_valueNumbernumber
f32napi_valueNumbernumber
boolnapi_valueBooleanboolean
Stringnapi_valueStringstring
Vec<T>napi_valueArrayT[]
Option<T>napi_valueT | nullT | null
Structnapi_valueObjectinterface
Result<T>napi_valueT | ErrorT | Error
ThreadsafeFunction<T,R>napi_threadsafe_functionFunction(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. 回调生命周期

注册 → 调用 → 释放

text
    注册                    调用                    释放
     │                      │                      │
     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 核心方法

rust
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

text
Rust 子线程 panic!
        │
        v
  unwind 栈跨越 FFI 边界
        │
        v
  C 层未定义行为
        │
        v
  Node.js 进程 abort (SIGABRT)

解决:catch_unwind + set_hook

tsffib 在每个子线程入口用 std::panic::catch_unwind 包裹整个执行体,并在启动时设置自定义 panic hook:

rust
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);
    }));
}

效果:

text
Rust 子线程 panic!
        │
        v
  catch_unwind 捕获
        │
        v
  panic_hook 打印日志
        │
        v
  返回 Err(msg) 给调用方
        │
        v
  Node.js 收到 napi Error,进程继续运行

panic-test 示例

rust
#[tsffi::export]
fn panic_test() -> Result<String> {
    panic!("intentional panic for testing");
}
typescript
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 暴露:

typescript
import { contextBridge } from 'electron'
import { processFile, onProgress } from '@tsffib/example'

contextBridge.exposeInMainWorld('tsffi', {
  processFile,
  onProgress,
})

preload.ts:

typescript
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 指定目标平台:

toml
[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 目标:

sh
npx napi build --cross-compile --target x86_64-unknown-linux-gnu

Cross.toml 配置:

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):

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 字段自动选择。

发布命令:

sh
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 publish

tsffib doctor 诊断

部署前运行诊断,检查环境是否就绪:

sh
npx tsffib doctor

输出示例:

text
[✓] 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 已安装)

[!] 表示可选依赖缺失,[✗] 表示必须依赖缺失需修复。