跳至主要内容
Deno 2 终于来了 🎉️
了解更多

如何将 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 以及其他可以配置的全局变量注入 垫片
    • 将来自 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、WebSockets 和处理 FormData 文件的支持,但主要功能可以工作。

Web 标准

Deno 充分利用了 Web 标准和 Web 平台。Node.js 正在增加 Web 平台 API,但它们通常不会全局公开,有些仍然需要依赖项。这些是需要在 oak 中解决的问题

  • 与 Web 兼容的流在 Deno 中是全局可用的,但在 Node.js 上仅通过内置模块 "stream/web" 可用。这些需要通过 dnt 配置公开

    {
      shims: {
        custom: [{
          package: {
            name: "stream/web",
          },
          globalNames: ["ReadableStream", "TransformStream"],
        }],
      }
    }
  • Web 加密在 Deno 中是全局可用的,但在 Node.js 上仅通过 webcrypto 符号通过 "crypto" 内置模块可用。这需要通过 dnt 配置的 shims 部分中的 crypto: true 选项公开。

  • 虽然 oak 没有直接使用,但一些依赖项需要 Blob 全局变量从内置的 "buffer" 模块中获取。这通过 dnt 配置的 shims 部分中的 blob: true 选项公开。

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

  • oak 扩展了 Web 标准 ErrorEvent 用于内部错误,该错误在 Deno 中是全局可用的。Node.js 没有这个,所以我不得不创建一个 ErrorEvent 类,它扩展了 Node.js 中全局可用的 Event,并由 dnt 加载为全局垫片。

ESM 和 Node.js

Deno 是围绕 ES 模块构建的,而 Node.js 在很长一段时间内都对 ES 模块提供了未标记的支持,但这仍然是一个复杂的问题。对于许多工作负载,dnt 可以轻松地将您的代码下发出到 CommonJS 和 ESM,以使您的包的使用者可以选择如何在 Node.js 中加载它。对于 oak,这有点复杂。Node.js 无法支持 ES 模块的一种情况是顶层等待。

我使用了顶层等待来初始化一个变量,我需要重构它才能支持 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 对齐。

即使有了所有这些,inspect 的输出仍然存在细微差异,因此它是 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 会在集成的 Deno 类似测试框架下以及 Node.js 下运行你的测试。对于 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 更改或改进的东西,我们希望听到你对它使用体验的反馈。