跳到主要内容
Deno 2.4 发布,带来 deno bundle、字节/文本导入、OTel 稳定版等新特性
了解更多

如何将 Deno 模块发布到 NPM

我编写了 oak,一个功能齐全的 HTTP 中间件/路由框架。它是 Deno 最常用的 HTTP 框架,为许多网站提供支持;例如 doc.deno.land

许多人希望将 Deno 作为他们的主要开发平台,但也希望能够在 Node 生态系统内共享代码。使用 dnt 可以轻松实现这一点。在本文中,我将向您展示如何以一种在 Node 中可用的方式将 Oak 发布到 NPM 模块

注意:本文发布时内容准确无误,Deno、oak、dnt 和 Node.js 将持续发展,具体技术细节和声明未来可能不准确。

oak 概述

如果您不熟悉 oak,它是一个受 koa 启发的 HTTP 中间件框架,并带有一个路由器。其主要目的是提供一种结构化的方式来处理 HTTP 请求。虽然 oak 的基本原理自 2018 年 12 月发布以来(当时 Deno 还是 v0.2 版本)一直保持一致,但它随着 Deno CLI 的演进而不断发展,包括迁移到原生 HTTP 服务器并确保支持 Deno Deploy。

Deno 中的基本用法很简单

import { Application } from "https://deno.land/x/oak/mod.ts";

const app = new Application();

app.use((ctx) => {
  ctx.response.body = "Hello from oak";
});

app.listen({ port: 8000 });

但它始终是为 Deno 设计的,利用内置的 Deno API,不仅能够处理 HTTP 请求和响应,还能实现高级功能,例如为静态文件生成 ETag 签名以及处理复杂的表单数据体。在 Node.js 上运行它从未在设计考虑范围之内。

dnt

dnt 提供了一个构建管道,用于将 Deno 代码转换为 Node 兼容的 NPM 包。

该管道在高级别上执行以下操作

  • 将 Deno 代码转换为 Node.js 兼容的 TypeScript 代码
    • 重写 Deno 的带扩展名模块说明符,使其与 Node.js 模块解析兼容。
    • 为检测到的任何 Deno 命名空间 API 以及其他可配置的全局变量注入 shims
    • 将 Skypack 或 esm.sh 中的远程导入重写为裸说明符导入,并将其添加到 package.json 作为依赖项。
    • 其他远程导入将被下载并包含在包中。
  • 使用 tsc 对转换后的 TypeScript 代码进行类型检查。
  • 将包输出为一组 ESM、CommonJS 和 TypeScript 类型声明文件,以及一个 package.json
  • 通过支持 Deno.test() API 的测试运行器在 Node.js 中运行最终输出。

这意味着您可以在 Deno 中开发和测试所有代码,当您想将其发布到 npm 并使其可用于 Node.js 时,可以使用 dnt导出它并验证其是否按预期工作,所有这些都只需对源代码进行最少或无需更改。

设置完成后,您可以继续迭代您的包,轻松地将其发布,以便在 Deno 和通过 npm 消费。

让 oak 在 Node.js 上运行

在使其工作过程中学到了很多经验教训,并非所有工作都已完成。需要添加对 HTTP/2、WebSockets 和 FormData 文件处理的支持,但主要功能已经可用。

Web 标准

Deno 充分利用了 Web 标准和 Web 平台。Node.js 正在越来越多地添加 Web 平台 API,但它们通常不会全局暴露,并且有些仍然需要依赖项。以下是需要在 oak 中解决的问题:

  • Web 兼容的 Streams 在 Deno 中是全局可用的,但在 Node.js 中只能通过内置模块 "stream/web" 获得。这些需要通过 dnt 配置来暴露。

    {
      shims: {
        custom: [{
          package: {
            name: "stream/web",
          },
          globalNames: ["ReadableStream", "TransformStream"],
        }],
      }
    }
  • Web Crypto 在 Deno 中是全局可用的,但在 Node.js 中只能通过 "crypto" 内置模块的 webcrypto 符号获得。这需要通过 dnt 配置的 shims 部分中的 crypto: true 选项来暴露。

  • 虽然未在 oak 中直接使用,但某些依赖项需要从内置 "buffer" 模块提供 Blob 全局变量。这通过 dnt 配置的 shims 部分中的 blob: true 选项来暴露。

  • Deno 暴露了 oak 使用的 Web 标准 fetch()Headers。Node.js 目前没有内置这些(尽管正在改变),因此需要通过 "undici" 包来暴露它们,dnt 通过 dnt 配置的 shims 部分中的 undici: true 选项支持这一点。

  • oak 为内部错误扩展了 Web 标准 ErrorEvent,该事件在 Deno 中是全局可用的。Node.js 没有此功能,因此我必须创建一个 ErrorEvent 类,该类扩展了 Node.js 中全局可用的 Event,并由 dnt 作为全局 shim 加载。

ESM 和 Node.js

Deno 是围绕 ES 模块构建的,虽然 Node.js 已在很长一段时间内取消了对 ES 模块的标志支持,但这仍然是一个复杂的问题。对于许多工作负载,dnt 可以轻松地将您的代码降级为 CommonJS 和 ESM,从而使您的包的使用者可以选择如何在 Node.js 中加载它。对于 oak 来说,这要复杂一些。Node.js 有一种情况不支持 ES 模块,那就是顶层 await 的情况。

我有一个使用顶层 await 初始化变量的地方,为了支持 CommonJS,我需要对其进行重构。

在 Node.js 上支持 HTTP

Deno 和 Node.js 之间最大的区别在于它们处理 HTTP 请求的方式。Deno 已经发展,最初它只通过 Deno std 库支持 HTTP,现在它提供了一个围绕 Web 平台 fetch() API 构建的原生实现。Node.js 的内置 "http" 模块是一个非常低级的 API,自 Node.js 早期以来基本保持不变。

当 oak 从 std 库 HTTP 服务器迁移到原生服务器时,我实现了一个抽象层来处理请求和响应。即使在 oak 中放弃了对 std 库 HTTP 服务器的支持,该抽象层仍得以保留。这个抽象层,经过一些重构和改进,使得对低级 Node.js "http" 服务器的支持成为可能,并且对代码库的其余部分几乎没有改动。

如果您感兴趣,代码位于 http_server_node.ts 模块中。

经过一些重构,我能够使用 dnt 和 mapping 功能来“替换” Deno 抽象与 Node.js 抽象。这是一个最小量的平台特定代码(Deno 大约 160 行,Node.js 大约 220 行)。

其他小知识

在 Node.js 下运行测试时,我遇到的一个初始测试失败是针对 oak 许多类中的自定义检查逻辑。在 Deno 中,自定义检查方法由 "Deno.customInspect" 符号定义,但在 Node.js 中,它作为 "nodejs.util.inspect.custom" 符号可用,并且 API 略有不同。因此,我为每个具有 Deno 自定义检查的类添加了一个 "nodejs.util.inspect.custom" 符号方法,并对其进行了调整,以更好地与 Node.js API 对齐。

即便如此,检查的输出仍然存在细微差别,因此这是 oak 中少数几个我不得不根据代码是在 Deno 还是 Node.js 下运行来分支测试代码的地方之一。

此外,undici 的 Headers 类与 Deno 的 Headers 类有所不同。Deno 的版本通过了所有 WHATWG 测试,而 undici 的设计则优先考虑 Node.js。然而,Web 标准和服务器端 JavaScript 在使用 Headers 方面存在一个根本问题,特别是在处理 Set-Cookie 头部方面,该头部只在服务器端设置。Deno 和 undici 都独立地解决了这个问题,因此在 Deno 和 Node.js 上运行时会导致不同的行为。我认为这些行为不会直接影响用户,但它们可能会,我将来可能需要在 oak 中进行更改以适应这种情况。

构建、测试和 CI

dnt 是一个构建管道,您通常会将其集成到构建过程中。对于 oak,我选择创建一个 _build_npm.ts 脚本,该脚本除了运行 dnt 管道外,还执行一些超出 dnt 范围的其他构建步骤。

通常,您只需编写一次测试,dnt 默认会在 Node.js 下为您运行类似 Deno 的集成测试工具。对于 oak,我有一些特定于 Deno 或 Node.js 的测试,以及一些在不同平台之间结果有所不同的测试。我尽力避免分支或忽略测试,但在某些情况下它们是不可避免的。我创建了一个简单的实用函数来检测环境

export function isNode(): boolean {
  return "process" in globalThis && "global" in globalThis;
}

将所有这些集成到 CI 中非常简单,因为只需将构建脚本作为 CI 过程的一部分运行即可,它将为我构建、类型检查和测试包。

发布到 npm

一旦 dnt 为您构建了包,它就已准备好发布到 npm。我只需在 oak 中切换到构建脚本的输出目录,然后运行 npm publish 即可。

结论

我显然有所偏颇,但我认为 Deno 是一个很棒的开发平台,也是一个很棒的 JavaScript 和 TypeScript 运行时。它拥有一套丰富的现代化内置 API,对 Web 平台 API 有大量支持,并且内置了 TypeScript 代码编写支持,是一个出色的运行时。再加上“开箱即用”的测试、二进制分发打包、调试和 IDE 语言服务器,很难找到更好的体验。

然而,阻碍采用的一大障碍是如何在 Deno 中开发代码,但又能在不大量复制代码或自己做大量工作的情况下与 Node.js 共享。我感到 dnt 在消除这一障碍方面做得非常出色,以 oak 这样的项目为例,能够在 Deno 中编写一次代码,然后轻松地与 Node.js 共享,这是一个很好的例子。

我们非常希望看到更多项目开始遵循这条路径。不可避免地,我们将会发现 dnt 需要更改或改进的地方,我们很乐意听取有关它的反馈和经验。