跳到主要内容

如何将 Deno 模块发布到 NPM

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

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

注意:本文在发布时是准确的,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
  • 在 Node.js 中通过支持 Deno.test() API 的测试运行器运行最终输出。

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

设置完成后,您可以继续迭代您的包,轻松地将其发布以供 Deno 和 npm 使用。

使 oak 在 Node.js 上运行

在使其工作的过程中,我们吸取了很多教训,但并非所有事情都已完成。HTTP/2、web sockets 和 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 为其许多类提供的自定义 inspect 逻辑。在 Deno 中,自定义检查方法由 "Deno.customInspect" 符号定义,但在 Node.js 中,它作为 "nodejs.util.inspect.custom" 符号可用,并且 API 也略有不同。因此,我为我进行了 Deno 自定义检查的每个类添加了一个 "nodejs.util.inspect.custom" 符号方法,并对其进行了调整,以更好地与 Node.js API 对齐。

即使如此,inspect 的输出仍然存在细微差异,因此这是 oak 中我实际必须根据它是在 Deno 还是 Node.js 下运行来分支测试代码的少数几个地方之一。

此外,undici Headers 类与 Deno Headers 类有所不同。Deno 的版本通过了所有 WHATWG 测试,而 undici 的设计首先考虑了 Node.js。Web 标准和服务器端 JavaScript 以及 Headers 的使用存在一个根本问题,特别是它与 Set-Cookie 标头的特殊处理有关,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 的一大障碍是如何在 Deno 中开发代码,但又可以与 Node.js 共享,而无需复制大量代码,或者自己做大量工作才能使之工作。我认为 dnt 在消除这个障碍方面做得非常出色,以 oak 为例,能够在 Deno 中编写一次代码,但可以轻松地与 Node.js 共享,就是一个很好的例子。

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