将 Rust Clap 二进制程序发布到 npm:一场跨越语言边界的冒险

364 阅读8分钟

将 Rust Clap 二进制程序发布到 npm:一场跨越语言边界的冒险

最近,我用 Rust 打造了一款终端小说阅读器TRNovel,并迫不及待地分享给了一位前端开发者朋友试用。然而,他面对的第一个挑战就是——没有安装 Rust 环境!于是,他问我:“能不能把这个工具发到 npm 上?”

1211734678150_.pic.jpg

1231734678152_.pic.jpg

这个问题点燃了我的好奇心,也开启了我将 Rust Clap 项目带到 npm 的探索之旅。

在摸索的过程中,我发现 Tauri CLI 使用了 napi-rs 来构建 Node.js 原生插件,这一发现如同灯塔照亮了前行的道路。深入研究后,我还发现了 neonnode-bindgen 这两个基于 NAPI 的 Rust crate,它们各自为创建 Node.js 插件提供了独特的解决方案和优势,就像探险途中遇到的宝藏一样令人兴奋。

1. 原理解密:从 Rust 到 npm 的魔法转换

为了让trnovel(终端小说阅读器),能够无缝融入 JavaScript 生态系统,我们需要施展一点“魔法”。首先,我们将原本作为独立可执行文件的 Clap 项目重构为一个库(lib),使其能够被其他代码轻松调用,而不再仅仅是一个孤立的英雄。接下来,借助 napi-rsNeonnode-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>,
}

魔法揭秘

  1. 新增 try_run 函数

    • 我们创造了一个全新的 try_run 函数,它就像一个万能钥匙,可以接收任何形式的参数列表。无论你是从命令行还是从 Node.js 传递参数,它都能轻松应对。
  2. 重构 run 函数

    • 原有的 run 函数现在变成了 try_run 的忠实粉丝,它简单地将环境参数传递给 try_run,保持了原有的功能,同时增加了无限的可能性。
  3. TRNovel 结构体依旧坚挺

    • TRNovel 结构体及其字段保持不变,就像一位老朋友,始终如一地陪伴着你,确保 API 的一致性,同时也简化了后续的维护工作。

2. 新建napi-rs项目

既然已经用上了魔法,那来点仙法也不过分吧?我们将使用 napi-rs 这个强大的工具,让 TRNovel 成为一个真正的 npm 项目。想象一下,通过几行命令和一点代码,TRNovel 就能轻松跨越语言障碍,在 JavaScript 的世界里大放异彩。让我们开始这段奇妙之旅吧!

1221734678151_.pic.jpg 第一步:安装 napi-rs CLI

首先,我们需要召唤出 @napi-rs/cli 这个魔法棒,它将帮助我们快速搭建项目框架。

pnpm add -g @napi-rs/cli

第二步:创建新项目

接下来,就像变魔术一样,只需要一条命令就能创建一个新的 napi-rs 项目:

napi new

在这个过程中,你会被引导选择要支持的平台。别担心,这里会通过 GitHub 工作流自动在不同环境下编译 .node 文件并发布到 npm 包中,整个过程既省时又省力。

20241220150927_rec_.gif 第三步:配置与依赖安装

确保你的 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

image-20241220152237512.png 构建完成后,你会发现项目目录下多了一些神奇的文件。其中最引人注目的是 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.js
  • trnovel:指向 ./bin/trnovel.js

这意味着用户安装此包后,可以直接通过 trntrnovel 命令来启动终端小说阅读器。

提交并等待 GitHub 工作流运行

最后,提交所有更改并推送到 GitHub。GitHub 工作流将自动检测到这些变化,并开始编译 .node 文件,最终发布到 npm。一旦工作流完成,你就可以看到发包成功的消息,就像魔法成真一样!

image-20241220155253493.png

image-20241220154729884.png

最后

非常高兴能够向大家介绍并推荐我的终端小说阅读器 TRNovel。现在,TRNovel 已经可以通过 npm 全局安装,只需一条简单的命令即可将它带回家:

# node环境
npm install -g @trnovel/trnovel
# rust环境
cargo install trnovel

如果您觉得这个教程系列对您有帮助,欢迎点赞、收藏并关注。感谢您的阅读!

另外,本文章涉及的代码都能在以下仓库中找到