如何在 npm 上发布二进制文件?

今天不走,明天要跑

大家好,我是柒八九 。一个专注于前端开发技术/RustAI应用知识分享Coder

写在最前面

📢📢📢号外,号外。我们的f_cli现在有了npm版本了。有两种主流的方式来访问

  1. 全局安装
    1. npm i -g f_cli_f
    2. f_cli_f create 你的项目名称
  2. npx 操作
    1. npx f_cli_f create 你的项目名称

随意选中任意一个方式,不出意外的话,就在指定的文件路径下,生成了一个功能完备的前端项目。

前言

Rust 赋能前端-开发一款属于你的前端脚手架我们介绍了如何用Rust来写一个前端脚手架,主要的精力放在了Rust方面。

前端项目里都有啥?我们主要的精力放在如何配置一个功能全备的前端项目。

然后,有些同学说,既然cli都有了,但是下载二进制文件很麻烦。最好是将f_cli发布到npm上。毕竟,在前端开发中,npm大家都熟悉。

所以,今天我们就来讲讲如何将二进制文件发布到npm

好了,天不早了,干点正事哇。

我们能所学到的知识点

  1. Rust项目交叉编译
  2. 构建&发布目标npm项目
  3. 构建&发布主包
  4. 本地应用

1. Rust项目交叉编译

要将源代码编译到与本地平台不同的平台上,需要指定一个目标(target)。这将告诉编译器应该为哪个平台编译代码。

确定target

作为一个cli工具,我们的f_cli需要发配给团队伙伴使用。此时就会出现一个问题,团队伙伴的开发环境(处理器架构/操作系统)可能和我们本机不一样,所以我们需要将Rust编译成适配不同的处理器架构操作系统

以下是我们工作中比较常见的开发环境。

  • Darwin(arm)
  • Darwin(arm64)
  • Darwin(x64)
  • Linux (arm)
  • Windows (i686)
  • Windows (x64)

针对f_cli我们只兼容比较场景的开发环境。(后期有需要会兼容更多版本)

  1. Darwin(arm64) - MacOS的M1版本
  2. Darwin(x64) - MacOS的Intel版本
  3. Windows (x64) - Windows

安装指定target

我们要想将Rust项目编译成指定的目标二进制,我们可以在cargo build时,使用--target xxx参数来指定目标环境。

还记得rustup吗?我们在Rust环境配置和入门指南中有过介绍。

rustup的命令行工具来完成Rust的下载和安装,这个工具被用来管理不同的Rust发行版本及其附带工具链。

其实rustup除了安装和更新Rust,它还可以查看rust在交叉编译时,能够转换的目标环境。

我们可以通过rustup target list来查看这些信息。

上图是我本机已经安装的target。(我多加了一个参数--installed)

  1. aarch64-apple-darwin -支持Mac Arm
  2. x86_64-apple-darwin - 支持Mac Intel(也是我本机环境)
  3. x86_64-pc-windows-gnu - 支持Windows环境

其中wasm32-unknown-unknown是我们处理RustWebAssembly时,才用到。关于这点,可以参考我们之前的文章Rust 编译为WebAssembly 在前端项目中使用

既然,目标环境已经确定,那我们就需要将目标环境加入到Rust环境中。

rust 复制代码
rustup target add xxxx

通过上述命令,我们就将xxxx的环境加入到Rust中。除了像上面使用rustup target list --installed来查看已经安装的目标环境

我们也可以使用rustup show来查看本机的工具环境。

执行编译

其实这步也没啥可说的。要想Rust编译成目标环境我们仅需在cargo build时,新增target参数即可。

rust 复制代码
cargo build --release ----target = xxxx

在执行完build后,会在Rust项目中target目录下生成对应的编译结果。

由于我本机属于x86_64-apple-darwin,所以在build时可以不加target参数。

然后我们可以在目标目录中的release中找到f_cli二进制文件。

针对Windows环境的特殊处理

MacOS中将Rust编译为可以在Windows环境下执行的二进制时,需要做额外的处理。

更多详情可以参考如何在 Mac 上为 Windows 编译 Rust 程序


2. 构建&发布目标npm项目

我们的目标是- 将build后的二进制文件放置到npm包中,然后通过node进行下载安装。

如果将所有平台的二进制放到一个npm是极其耗费流量的。所以,我们采用的是按需下载的方式。

所以,我们就把上一节中交叉编译的三个二进制文件分别发布 成一个npm包。

  1. f_cli_darwin_arm64
  2. f_cli_darwin_x64
  3. f_cli_windows_x64

对于快速构建一个npm目录我们可以使用npm init然后一路回车。但是,我们不这样做,我们这里采用手动构建package.json。然后配置一些参数即可。关于package.json中各个字段的含义,可以参考package.json的字段信息

子包的目录结构

由于我们子包的作用就是存储二进制文件,所以我们采用最简单的目录结构

由于子包的处理逻辑很类似,我们下文中除了要特殊说明,都是按照一个子包的处理方式来讲解

go 复制代码
"f_cli_darwin_arm64"/"f_cli_darwin_x64"
 ├── package.json
 └── bin/
     └── f_cli

"f_cli_windows_x64"
 ├── package.json
 └── bin/
     └── f_cli.exe

bin文件夹中就是存放我们二进制源文件的,这里没啥可说的。我们来简单聊聊package.json

package.json

下面的package.json的内容是f_cli_darwin_arm64的。其他两个子包的信息也是大差不差的。

json 复制代码
{
  "name": "f_cli_darwin_arm64",
  "version": "1.0.0",
  "description": "f_cli适配MACOS_ARM64架构",
  "keywords": [
    "f_cli",
    "MACOS_ARM64"
  ],
  "author": "",
  "license": "ISC",
  "os": ["darwin"],
  "cpu": ["arm64"]
}

其中有几个属性我们需要额外说明一下:

  1. name该字段是我们发布npm包时,最主要的字段,你可以将起认为是数据库中的主键,我们平时通过npm install xxx安装包时,xxx就是此处的name的值
    • 在发布包之前,我们可以为其指定具有特殊含义的名称,同时该名称需要在npm仓库中唯一,不然在npm publish时就会发生错误
    • 同时该名称的格式也有要求,它需要符合^(?:(?:@(?:[a-z0-9-*~][a-z0-9-*._~]*)?/[a-z0-9-._~])|[a-z0-9-~])[a-z0-9-._~]*$正则规则
  2. os:指定模块将在哪些操作系统上运行
    • 该值由node中的process.platform决定,用于获取操作系统平台信息。
    • 值为aix, android, darwin, freebsd, linux, openbsd, sunprocess, win32
  3. cpu:指定代码只能在某些 CPU 架构上运行
    • 该值由node中的process.arch决定,用于获取操作系统平台信息。
    • 值为x32, x64, arm, arm64, s390, s390x, mipsel, ia32, mips, ppc, ppc64.

我们后期会有关于package.json各个字段的介绍文章

发布子包到npm

其实这步特别简单就是两个命令

  1. npm login
  2. npm publish

对于如何发布一个npm包,这里我们就不再赘述。后期如果有需求可以单写一篇。

通过上述的操作,我们就把三个二进制文件发布到npm上了。

上面还有一个f_cli_f,别着急,我们马上会讲到。


3. 构建&发布主包

上面我们通过各自上传子包到npm,实现了资源的分离处理。下面我们就需要通过一些方式让主包在被安装时,能够自动识别出工作平台所需要目标并且执行对应的下载和安装任务。

简而言之,我们需要在主包被安装时,实现按需下载

npm 按需下载原理

package.json中有两种方式可以下载特定于平台的二进制文件,而无需下载所有二进制文件。

optionalDependencies

所有常用的 JavaScript 包管理器都支持 package.json 中的 optionalDependencies 字段。包管理器通常会安装 optionalDependencies 中列出的所有软件包,但他们可能会根据某些条件选择不安装。

其中一个标准就是依赖项 package.json 文件中的 oscpu 字段。(我们在处理子包时就已经把这些值赋值了)

只有当这些字段的值与当前系统的操作系统和架构相匹配时,才会安装依赖包 。这意味着我们可以发布单独的软件包,每个软件包只包含一个特定于平台的二进制文件,但其中的oscpu字段指明了这些软件包适用的体系结构,软件包管理器将自动安装正确的软件包。

postinstall 脚本

如果在 package.json 中包含一个名为 postinstall 的脚本,则该脚本将在包安装后立即执行 ,即使它是作为安装包安装的一种依赖。(在前端项目里都有啥?,我们讲过prepare,其实他们的作用是类似的)

我们可以使用 postinstall 脚本下载当前平台的二进制文件并将其存储在系统上的某个位置。其实我们可以把这个包的位置存放到任何你信得过的地方,此处我们为了方便将二进制文件都放置到了npm仓库了。

最优解

这两种方法都有缺点,可能不适用于所有设置。

  • 如果禁用optionalDependencies可能会遇到问题(例如,通过yarn--ignore-optional标志)。
  • postinstall 脚本也可以被禁用,并且可能会出现更多问题,因为通常建议禁用它们,因为它们容易受到攻击。

为了最大限度地提高成功的可能性,我们将两种方式都融合进主包中。

目录结构

其实主包的目录结构也很简单。和子包类似,有package.json/bin/二进制源文件

go 复制代码
 f_cli
 ├── install.js
 ├── package.json
 └── bin/
     └── f_cli

那么下面我们就依次解释上面文件的含义。

package.json

json 复制代码
{
  "name": "f_cli_f",
  "version": "1.0.3",
  "description": "针对f_cli的npm 包",
  "scripts": {
    "postinstall": "node ./install.js"
  },
  "bin": {
    "f_cli_f": "bin/cli"
  },
  "optionalDependencies": {
    "f_cli_darwin": "1.0.0",
    "f_cli_linux": "1.0.0",
    "f_cli_win32": "1.0.0"
  }
}

上面出现的scripts.postinstalloptionalDependencies我们在本节刚开始就解释了。这里就不再啰嗦。

在这里我们来讲讲bin字段。

bin

bin 字段允许将包中的特定文件链接到全局的可执行路径,使其成为全局命令,方便用户在命令行中直接调用。

binpackage.json 文件中的一个字段,用于定义将包安装为全局命令时的可执行文件

bin 字段是一个对象,其中是要创建的全局命令的名称是要执行的本地文件的路径。

当用户全局安装该包时,bin 字段允许将指定的本地文件链接到全局的可执行路径,使用户可以在命令行中直接运行该文件。

像上文中bin 字段为 { "f_cli_f": "bin/cli" },那么在全局安装该包后,用户可以直接在命令行中运行 f_cli_f,实际上会执行 bin/cli 文件。

shell 复制代码
# 方式1: 全局按照
$ npm i -g f_cli_f
$ f_cli_f create xxx

# 方式2:包管理器
$ npx f_cli_f

install.js

js 复制代码
// 引入必要的Node.js模块
const fs = require('fs'); // 文件系统模块
const path = require('path'); // 路径模块
const zlib = require('zlib'); // 压缩模块
const https = require('https'); // HTTPS模块


// 所有平台和二进制分发包的查找表
const BINARY_DISTRIBUTION_PACKAGES = {
  'darwin-x64': 'f_cli_darwin_x64',
  'darwin-arm64': 'f_cli_darwin_arm64',
  'win32-x64': 'f_cli_windows_x64',
}

// 调整你想要安装的版本。也可以将其设置为动态的。
const BINARY_DISTRIBUTION_VERSION = '1.0.0';

// Windows平台的二进制文件以.exe结尾,因此需要特殊处理。
const binaryName = process.platform === 'win32' ? 'f_cli.exe' : 'f_cli';

// 确定当前平台的包名
const platformSpecificPackageName =
  BINARY_DISTRIBUTION_PACKAGES[`${process.platform}-${process.arch}`];

// 计算我们要生成的备用二进制文件的路径
const fallbackBinaryPath = path.join(__dirname, binaryName);

// 创建HTTP请求的Promise函数
function makeRequest(url) {
  return new Promise((resolve, reject) => {
    https
      .get(url, (response) => {
        if (response.statusCode >= 200 && response.statusCode < 300) {
          const chunks = [];
          response.on('data', (chunk) => chunks.push(chunk));
          response.on('end', () => {
            resolve(Buffer.concat(chunks));
          });
        } else if (
          response.statusCode >= 300 &&
          response.statusCode < 400 &&
          response.headers.location
        ) {
          // 跟随重定向
          makeRequest(response.headers.location).then(resolve, reject);
        } else {
          reject(
            new Error(
              `npm在下载包时返回状态码 ${response.statusCode}!`
            )
          );
        }
      })
      .on('error', (error) => {
        reject(error);
      });
  });
}

// 从tarball中提取文件的函数
function extractFileFromTarball(tarballBuffer, filepath) {
  let offset = 0
  while (offset < tarballBuffer.length) {
    const header = tarballBuffer.subarray(offset, offset + 512)
    offset += 512

    const fileName = header.toString('utf-8', 0, 100).replace(/\0.*/g, '')
    const fileSize = parseInt(header.toString('utf-8', 124, 136).replace(/\0.*/g, ''), 8)

    if (fileName === filepath) {
      return tarballBuffer.subarray(offset, offset + fileSize)
    }

    // 将offset固定到512的上限倍数
    offset = (offset + fileSize + 511) & ~511
  }
}

// 从Npm下载二进制文件的异步函数
async function downloadBinaryFromNpm() {
  // 下载正确二进制分发包的tarball
  const tarballDownloadBuffer = await makeRequest(
    `https://registry.npmjs.org/${platformSpecificPackageName}/-/${platformSpecificPackageName}-${BINARY_DISTRIBUTION_VERSION}.tgz`
  )

  const tarballBuffer = zlib.unzipSync(tarballDownloadBuffer)

  // 从软件包中提取二进制文件并写入磁盘
  fs.writeFileSync(
    fallbackBinaryPath,
    extractFileFromTarball(tarballBuffer, `package/bin/${binaryName}`),
    { mode: 0o755 } // 使二进制文件可执行
  )
}

// 检查是否已安装平台特定的软件包
function isPlatformSpecificPackageInstalled() {
  try {
    // 如果optionalDependency未安装,解析将失败
    require.resolve(`${platformSpecificPackageName}/bin/${binaryName}`)
    return true
  } catch (e) {
    return false
  }
}

// 如果不支持当前平台,抛出错误
if (!platformSpecificPackageName) {
  throw new Error('不支持的平台!')
}

// 如果通过optionalDependencies已安装二进制文件,则跳过下载
if (!isPlatformSpecificPackageInstalled()) {
  console.log('未找到平台特定的软件包。将手动下载二进制文件。')
  downloadBinaryFromNpm()
} else {
  console.log(
    '平台特定的软件包已安装。将回退到手动下载二进制文件。'
  )
}

这段代码的作用是根据当前的操作系统和架构,从 Npm 下载特定平台的二进制文件,并将其写入磁盘。

大部分的代码都有注释,具体的功能也一目了然,这里就不再过多解释。我们挑几个比较重要的点来说明一下。

  1. BINARY_DISTRIBUTION_PACKAGES: 用于存储所有平台和二进制包的信息
  2. 使用process.platformprocess.arch用于确定符合当前工作环境的二进制包名称
  3. isPlatformSpecificPackageInstalled方法用于判断是否根据optionalDependency安装了指定的包,如果因为特殊原因没安装成功,我们就需要执行手动下载操作(downloadBinaryFromNpm)

如果上述操作一切顺利的话,我们就会在主包的根目录下,按照了我们的二进制文件。


bin/cli

js 复制代码
#!/usr/bin/env node

const path = require("path");
const childProcess = require("child_process");

// 存储所有平台和二进制分发包的查找表
const BINARY_DISTRIBUTION_PACKAGES = {
  'darwin-x64': 'f_cli_darwin_x64',
  'darwin-arm64': 'f_cli_darwin_arm64',
  'win32-x64': 'f_cli_windows_x64',
};

// Windows平台的二进制文件以.exe结尾,因此需要特殊处理
const binaryName = process.platform === "win32" ? "f_cli.exe" : "f_cli";

// 确定此平台的软件包名称
const platformSpecificPackageName =
  BINARY_DISTRIBUTION_PACKAGES[`${process.platform}-${process.arch}`]

function getBinaryPath() {
  try {
    // 如果optionalDependency未安装,解析将失败
    return require.resolve(`${platformSpecificPackageName}/bin/${binaryName}`);
  } catch (e) {
    // 如果未安装,返回二进制文件的路径
    return path.join(__dirname, "..", binaryName);
  }
}

// 使用child_process模块执行二进制文件并传递命令行参数
childProcess.execFileSync(getBinaryPath(), process.argv.slice(2), {
  stdio: "inherit",
});

上面的具体逻辑和我们install.js是类似的,都是基于process.platformprocess.arch确定当前工作环境匹配的二进制源文件,并且执行下载操作。

就像上面说的一样,bin/cli这个方式是可以在命令行直接执行的。npx f_cli_f create xxx

有一个点还是忍不住的想介绍一下

  1. #!/usr/bin/env node 是一个称为"shebang"的特殊注释,通常出现在Unix或类Unix系统中的脚本文件的开头。
    • 这行代码告诉操作系统使用/usr/bin/env来查找node命令,并使用它来解释和执行该脚本文件。这样做的好处是,它允许脚本在不同的系统上找到正确的node解释器,而不需要硬编码node的路径。

注意点

像使用bin/cli这种方式在命令行执行命令时,有一点需要额外的注意。如果你当前工作环境中只有一个Node环境,因为我们cli中存在文件的写入操作,此时在执行命令时,会有一个写入操作权限的错误警告。

其实这是一类错误,也就是npm在执行时候需要sudo的操作权限。

stackoverflow中有很多关于npmthrowing error without sudo的解决方案

其中一个高赞回答就是让我们使用nvmnode版本管理工具。在之前我们写过文章如何更优雅的使用node版本管理工具 - fnm 高阶版的nvm

发布主包到npm

其实这步特别简单就是两个命令

  1. npm login
  2. npm publish

这样我们所有的资源都上传到npm了。然后,我们就可以通过我们熟悉的包管理器yarn/npm来安装了。

额外说明

在上面的处理逻辑中我们只依据process.platfromprocess.arch做了最简单的环境适配,其实这里还可以有很多的分支处理。

如果大家看过oxlint-npm的源码的话,它就对环境有很多的处理。


4. 本地应用

npm中我们已经看到我们的cli已经上传成功了。

接下来,我们就可以利用yarn/npm等执行下载操作了。

全局安装

shell 复制代码
npm i -g f_cli_f

在控制台中执行上述操作,然后我们就将f_cli_f安装到npm全局环境了。

我们可以通过npm list -g来查看是否在全局按照成功。

然后我们就可以下面的命令在本地使用我们的cli创建项目了。

lua 复制代码
f_cli_f create project

npx

除了全局安装,我们也可以使用npx f_cli_f create project进行项目的初始化。


后记

分享是一种态度

全文完,既然看到这里了,如果觉得不错,随手点个赞和"在看"吧。

相关推荐
白墨阳2 分钟前
vue3:瀑布流
前端·javascript·vue.js
霍先生的虚拟宇宙网络32 分钟前
webp 网页如何录屏?
开发语言·前端·javascript
jessezappy1 小时前
jQuery-Word-Export 使用记录及完整修正文件下载 jquery.wordexport.js
前端·word·jquery·filesaver·word-export
旧林8431 小时前
第八章 利用CSS制作导航菜单
前端·css
yngsqq2 小时前
c#使用高版本8.0步骤
java·前端·c#
Myli_ing2 小时前
考研倒计时-配色+1
前端·javascript·考研
余道各努力,千里自同风2 小时前
前端 vue 如何区分开发环境
前端·javascript·vue.js
软件小伟2 小时前
Vue3+element-plus 实现中英文切换(Vue-i18n组件的使用)
前端·javascript·vue.js
醉の虾3 小时前
Vue3 使用v-for 渲染列表数据后更新
前端·javascript·vue.js
张小小大智慧3 小时前
TypeScript 的发展与基本语法
前端·javascript·typescript