将 Rust Clap 二进制程序发布到 npm:一场跨越语言边界的冒险
最近,我用 Rust 打造了一款终端小说阅读器TRNovel,并迫不及待地分享给了一位前端开发者朋友试用。然而,他面对的第一个挑战就是——没有安装 Rust 环境!于是,他问我:“能不能把这个工具发到 npm 上?”
这个问题点燃了我的好奇心,也开启了我将 Rust Clap 项目带到 npm 的探索之旅。
在摸索的过程中,我发现 Tauri CLI 使用了 napi-rs 来构建 Node.js 原生插件,这一发现如同灯塔照亮了前行的道路。深入研究后,我还发现了 neon 和 node-bindgen 这两个基于 NAPI 的 Rust crate,它们各自为创建 Node.js 插件提供了独特的解决方案和优势,就像探险途中遇到的宝藏一样令人兴奋。
1. 原理解密:从 Rust 到 npm 的魔法转换
为了让trnovel(终端小说阅读器),能够无缝融入 JavaScript 生态系统,我们需要施展一点“魔法”。首先,我们将原本作为独立可执行文件的 Clap 项目重构为一个库(lib),使其能够被其他代码轻松调用,而不再仅仅是一个孤立的英雄。接下来,借助 napi-rs、Neon 或 node-bindgen 这些强大的工具,我们为 Rust 库披上一层 Node.js 的外衣,让两者之间的交流变得像朋友间的对话一样自然流畅。
然后,通过编译过程,Rust 代码摇身一变,成为 Node.js 可加载的原生模块(通常是 .node 文件),仿佛是穿越到了另一个世界的使者。最后,我们将这些生成的绑定和必要的辅助文件打包成一个整洁的 npm 包,使用 npm publish 发布出去。这样一来,朋友们只需轻轻一句 npm install,就能立即拥有并使用这款小说阅读器,完全不需要担心任何 Rust 环境的问题。
2. 实践操作:让 TRNovel 跃入 Node.js 的奇妙世界
1. 改造 TRNovel,来看看我们原来的 lib.rs 文件:
// 关键部分
pub async fn run() -> anyhow::Result<()> {
let args = TRNovel::parse();
if let Some(Commands::Clear) = args.subcommand {
fs::remove_dir_all(novel_cach_dir()?)?;
return Ok(());
}
let terminal = ratatui::init();
App::new(args).await?.run(terminal).await?;
ratatui::restore();
Ok(())
}
#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
pub struct TRNovel {
/// 小说文件夹路径,默认为当前目录
#[arg(default_value = "./")]
pub path: PathBuf,
#[command(subcommand)]
pub subcommand: Option<Commands>,
}
这段代码已经做得很好了,但它只能依赖环境中的命令行参数。现在,我们要让它变得更加灵活,接受来自外部世界的召唤。以下是我们的魔法改造方案:
use std::env;
use std::ffi::OsString;
use std::path::PathBuf;
use anyhow::Result;
// 供原来的 main.rs 调用,使用环境参数
pub async fn run() -> Result<()> {
try_run(env::args()).await
}
// 给 Node.js 调用,接受外部传入的参数
pub async fn try_run<I, A>(args: I) -> Result<()>
where
I: IntoIterator<Item = A> + Debug,
A: Into<OsString> + Clone,
{
let trnovel = TRNovel::parse_from(args);
if let Some(Commands::Clear) = trnovel.subcommand {
fs::remove_dir_all(novel_cach_dir()?)?;
return Ok(());
}
let terminal = ratatui::init();
App::new(trnovel).await?.run(terminal).await?;
ratatui::restore();
Ok(())
}
/// TRNovel (Terminal reader for novel),一个终端小说阅读器
#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
pub struct TRNovel {
/// 小说文件夹路径,默认为当前目录
#[arg(default_value = "./")]
pub path: PathBuf,
#[command(subcommand)]
pub subcommand: Option<Commands>,
}
魔法揭秘
-
新增
try_run函数:- 我们创造了一个全新的
try_run函数,它就像一个万能钥匙,可以接收任何形式的参数列表。无论你是从命令行还是从 Node.js 传递参数,它都能轻松应对。
- 我们创造了一个全新的
-
重构
run函数:- 原有的
run函数现在变成了try_run的忠实粉丝,它简单地将环境参数传递给try_run,保持了原有的功能,同时增加了无限的可能性。
- 原有的
-
TRNovel 结构体依旧坚挺:
TRNovel结构体及其字段保持不变,就像一位老朋友,始终如一地陪伴着你,确保 API 的一致性,同时也简化了后续的维护工作。
2. 新建napi-rs项目
既然已经用上了魔法,那来点仙法也不过分吧?我们将使用 napi-rs 这个强大的工具,让 TRNovel 成为一个真正的 npm 项目。想象一下,通过几行命令和一点代码,TRNovel 就能轻松跨越语言障碍,在 JavaScript 的世界里大放异彩。让我们开始这段奇妙之旅吧!
第一步:安装 napi-rs CLI
首先,我们需要召唤出 @napi-rs/cli 这个魔法棒,它将帮助我们快速搭建项目框架。
pnpm add -g @napi-rs/cli
第二步:创建新项目
接下来,就像变魔术一样,只需要一条命令就能创建一个新的 napi-rs 项目:
napi new
在这个过程中,你会被引导选择要支持的平台。别担心,这里会通过 GitHub 工作流自动在不同环境下编译 .node 文件并发布到 npm 包中,整个过程既省时又省力。
第三步:配置与依赖安装
确保你的 GitHub Secrets 中已经配置了 NPM_TOKEN,这一步非常重要,但具体细节就留给各位自行探索啦。
然后,安装项目所需的依赖,并添加 TRNovel 作为依赖项:
yarn install
cargo add trnovel
第四步:修改 src/lib.rs —— 赋予 TRNovel 新能力
现在是时候赋予 TRNovel 新的能力了。我们需要修改 src/lib.rs 文件,让它能够接收来自 Node.js 的参数调用。
#![deny(clippy::all)]
use napi::Result;
use napi_derive::napi;
#[napi]
async fn run(args: Vec<String>) -> Result<()> {
Ok(
trnovel::try_run(args)
.await
.map_err(|e| napi::Error::from_reason(e.to_string()))?,
)
}
然后运行
yarn build:debug
构建完成后,你会发现项目目录下多了一些神奇的文件。其中最引人注目的是
index.js 文件,它是一个自动生成的模块加载器,负责根据当前的操作系统和架构加载适当的本地绑定模块。这个加载器非常聪明,可以根据不同的环境自动选择正确的 .node 文件,确保 TRNovel 在任何地方都能正常工作。
细心的你可能会注意到,在 npm 目录下出现了多个包,每个包都有自己的 package.json 文件,指定了特定的操作系统和架构。例如:
{
"name": "@trnovel/trnovel-darwin-arm64",
"version": "0.0.0",
"os": [
"darwin"
],
"cpu": [
"arm64"
],
"main": "trnovel.darwin-arm64.node",
"files": [
"trnovel.darwin-arm64.node"
],
"license": "MIT",
"engines": {
"node": ">= 10"
}
}
这些包中的 main 字段指向了一个 .node 文件,它们由 GitHub 工作流自动编译并放置在对应的目录下。当你发布主包时,这些子包也会一同发布,确保用户在任何环境中都能顺利安装和使用 TRNovel。
3. 编写脚本调用 TRNovel:让魔法生效
现在,我们已经为 TRNovel 穿上了 Node.js 的隐形斗篷,是时候编写一个简单的 JavaScript 脚本来召唤它的力量了。这个脚本将作为命令行工具的入口点,使得用户可以通过简单的命令来启动 TRNovel。
创建 bin/trnovel.js 文件
我们将创建一个名为 trnovel.js 的文件,并赋予它执行权限。这个文件将负责接收命令行参数并传递给 Rust 编写的 run 函数。
#!/usr/bin/env node
const { run } = require("../index.js");
const path = require("path");
const [_bin, script, ...args] = process.argv;
run([path.parse(script).name, ...args])
.then(() => process.exit(0))
.catch((e) => {
console.error(e);
process.exit(1);
});
这段代码做了以下几件事:
- 使用
require导入我们之前生成的index.js模块中的run函数。 - 使用
path模块解析当前脚本的名字(即命令名),并将它与命令行参数一起传递给run函数。 - 根据
run函数的结果决定退出状态码:成功则退出状态码为0,失败则输出错误信息并设置退出状态码为1。
测试实际效果
为了确保一切正常工作,我们可以直接通过 Node.js 来运行这个脚本:
node bin/trnovel.js
如果一切顺利,TRNovel 将按照预期启动,就像它一直都在 JavaScript 的世界里一样自然。
更新根 package.json 文件
为了让 npm 认识到我们的新命令行工具,我们需要在根目录下的 package.json 文件中添加 bin 字段。这一步就像是为 TRNovel 注册了一个新的身份,让它能够被全局安装和使用。
{
"name": "@trnovel/trnovel",
"version": "0.1.2",
"main": "index.js",
"types": "index.d.ts",
"napi": {
"name": "trnovel",
"triples": {
"additional": [
"aarch64-apple-darwin"
]
}
},
"bin": {
"trn": "./bin/trn.js",
"trnovel": "./bin/trnovel.js"
},
// ...
}
这里特别注意的是 bin 字段,它定义了两个命令行工具:
trn:指向./bin/trn.jstrnovel:指向./bin/trnovel.js
这意味着用户安装此包后,可以直接通过 trn 或 trnovel 命令来启动终端小说阅读器。
提交并等待 GitHub 工作流运行
最后,提交所有更改并推送到 GitHub。GitHub 工作流将自动检测到这些变化,并开始编译 .node 文件,最终发布到 npm。一旦工作流完成,你就可以看到发包成功的消息,就像魔法成真一样!
最后
非常高兴能够向大家介绍并推荐我的终端小说阅读器 TRNovel。现在,TRNovel 已经可以通过 npm 全局安装,只需一条简单的命令即可将它带回家:
# node环境
npm install -g @trnovel/trnovel
# rust环境
cargo install trnovel
如果您觉得这个教程系列对您有帮助,欢迎点赞、收藏并关注。感谢您的阅读!
另外,本文章涉及的代码都能在以下仓库中找到