原文链接

JSR:解决 JavaScript 生态系统问题的包仓库

Deno 团队推出了一款名为 JSR 的新包仓库,旨在解决 JavaScript 生态系统中的诸多问题。我受邀抢先体验了这款仓库,并想分享一下我的感受。

历史渊源

我是 Deno 的早期贡献者,后来 Ry 和 Bert 获得种子基金后,我加入了 Deno 团队。 两年后我离开了 Deno,去澳大利亚追求新的工作机会。

在我加入 Deno 社区期间,我创建了 oak,这是一个专为 Deno 设计的类似于 Express 的中间件框架。时至今日,它仍然是一款非常流行的框架。当我还在 Deno 工作时,我们经常用它来测试新功能,例如自动生成文档、确保它可以在 Deno Deploy 上运行,以及对生态系统的其他更改。

我认为 oak 持续的流行和使用是 Deno 团队联系我并给我 JSR 超前体验的原因之一,这样我就可以看到它如何与 oak 这样的框架配合使用。

JSR 是什么?

JSR 是一个 Javascript/TypeScript 注册中心。它不是一个包管理器。显然,npm 是 Javascript 生态系统中第一个可行的包管理器和注册中心。从那时起,我们看到了包管理器方面有很多创新,例如 yarn, pnpm, 甚至像 Bun 这样的运行时也集成了包管理器。

这些都是包管理方面的创新。在仍然以 npm 注册中心为核心的注册中心方面几乎没有变化或创新。现在我们有处理私有仓库和包的企业解决方案;还有其他增强 npm 注册中心的解决方案,例如 unpkgesm.sh, 尽管它们很有用,但仍然属于 npm 生态系统。

我们在 Deno 尝试使用 deno.land/x 来创建一个没有包管理器的注册中心,它对专注于“代码即包管理”的 Deno 来说效果不错,但它也面临挑战。在我离开 Deno 之前我们曾讨论过这些问题。

JSR 似乎试图在包注册中心方面有所作为。自 npm 早期以来,Javascript 已经走过了漫长的道路。npm 注册中心和包管理器是专门构建用于服务单个运行时(Node.js),它向世界引入了 Javascript 模块概念,该概念源自社区为扩展 Javascript 而做出的努力。我们现在只是因为它被 Node.js 采用作为模块语法而记得 CommonJS。

现在 Javascript 生态系统已经大不相同。我们有一种能自己转译自己的语言;我们有同时适用于服务器运行时和客户端浏览器的模块标准;我们生活在一个由 TypeScript 主导的编写 Javascript 代码方式的世界;我们拥有各种各样的 js 运行时: 本地服务器运行时、无服务器运行时和浏览器。

JSR 试图解决什么问题?

我没有从 Deno 团队那里收到有关 JSR 以及其路线图的相关信息,因此以下内容大部分都是基于我使用 JSR 所观察到的。

它似乎试图解决的第一个问题是我们在使用 deno.land/x 和代码即包管理器方法管理依赖项时遇到的挑战。当代码即包管理器的方式起作用时,这是一个很棒的开发体验,只需要专注于编写代码即可。问题在于尝试表达版本依赖关系。deno.land/x 上的每个外部依赖项都“固定”到一个确切的版本。从“稳定性”方法来看,这很好,但当公共库在包之间共享时,这会变得更加困难。**JSR 支持依赖项的 semver 表达式。**虽然这从一开始就在 npm 世界中存在,但对于 Deno 用户来说,它允许了代码即管理器方法的灵活性和好处,但又不必总是固定到特定版本。

JSR 似乎试图解决的另一个问题是完全支持 TypeScript 和 Javascript。TTypeScript 在最初并不存在,但随着 TypeScript 的诞生,社区围绕它所形成的解决方案实际上有效地应对了各种挑战。 但对于包维护者来说,这很困惑,“我是否应该同时发布已转换的代码和源 TypeScript 代码?如果我同时发布,我如何帮助用户使用包?” 这真是太混乱了。在 JSR 中,你发布你的源代码,无论是 TypeScript 还是 Javascript,注册表都会确保用户使用正确版本的代码。

此外,已发布的代码被“zapped”(这似乎最初称为 FastCheck,但正在迁移到 Zap,我怀疑这是因为测试库与“fast check”冲突)。它可以完成 TypeScript 类型检查,只需要查看包的公开的 API,而无需进行控制流分析。Zap 的好处是它非常快,我在 oak 上的证据表明它大约能快 10 倍到 15 倍。它还确保可以通过自动文档生成对已发布的代码进行充分记录。

对于包作者来说,JSR 追求一次编写,随处运行。虽然它不在我用过的早期版本中,而且还有很长的路要走,但我最近获得了通过 Node.js 和 npm 加载 JSR 包的能力。JSR 注册表具有运行时感知,当包创建者发布了一个版本,注册表负责提供特定于目标运行时的版本。目前,需要代码“了解”运行时中的差异,但我怀疑在不久的将来,将有办法启用处理此问题不同方式的方法。Deno 团队创建了 dnt,希望我们看到那里的一些功能和想法融入 JSR。

丰富包开发体验是一个明显的目标。当我还在 Deno 时,我们努力通过 deno.land/x 改善开发体验,以便包维护者拥有一整套功能来改善其下游用户开发体验。更大的 npm 生态系统对此进行了创新,npmjs.org 在一定程度上基于这些创新丰富了数据,但它仍然不是完整的解决方案。我们使用 deno.land/x 所做的重要事情之一并已引入 JSR,就是自动生成文档。包作者用于通过 JSDoc 和 TypeScript 类型注释丰富 IDE 体验的相同机制用于为每个包生成丰富的在线文档。

Deno 的优势之一始终是能够使用 TypeScript 代码作为一等公民。这意味着作为包创建者,我不必想办法将我的类型信息与我的运行时代码一起分发(或让包的使用者这样做)。这种非 Deno 生态系统挑战导致一些包作者放弃使用 TypeScript 进行开发,而倾向于使用 “types-as-comments” JavaScript 进行编写。这种方法有其自身的复杂性。我得到可靠消息称,虽然它目前不是 JSR 的一部分,但很快JSR 将在针对 npm 生态系统时生成类型定义以及转换 Javascript 代码。这意味着包作者可以使用 TypeScript 编写,而用户将能够使用包,而无需担心转换,但能够保留使用 TypeScript 带来的完整的 IDE 体验提升和类型检查。

功能和限制

在 JSR 上发布的代码只能依赖于其他 JSR 包、npm 包或 Node.js 内置组件。这个“封闭花园”似乎是一种有意为之的限制。这确保了注册表生态系统是受控的。

但是,访问更广泛的 npm 生态系统对于包作者和下游用户来说都是透明的,并且它使用 “包管理器作为代码” 的管理方式。例如,我在 oak 中使用了优秀的 path-to-regexp 库。对于我发布的 deno.land/x 版本,我使用的是某人发布到 deno.land/x 的版本。在 JSR 的封闭花园中,必须将一个版本发布到 JSR(我确信包维护者不太可能在短期内对此感兴趣),或者我只能使用 npm 上的版本。这是我在 npm 上发布时选择的方法。我从 oak 中的 ./deps.ts 文件重新导出了所有依赖项,如下所示:

export {
  compile,
  type Key,
  match as pathMatch,
  parse as pathParse,
  type ParseOptions,
  pathToRegexp,
  type TokensToRegexpOptions,
} from "npm:[email protected]";

另一个有意为之的限制是,所有已发布的包都必须为内部代码使用完全限定的模块名称。这是为了避免 Node.js 和生态系统中的其他部分试图解析模块时遇到的挑战,即如何解释没有明确解析的模块。

npm 比较薄弱的一点是,它在很大程度上依赖于包的公共 API 服务的约定。通过在 package.json 中添加 "exports",这个问题得到了逐步解决。在 JSR 中,这也是显式的。作为作者,您必须只提供一个“main”导出模块,或者提供一个包含各种命名导出及其模块的映射。对于 oak,我导出了所有内容,但为 oak 的各个公共部分添加了命名导出。在 deno.json(这是 JSR 包的元数据文件)中,它看起来像这样:

{
  "name": "@oak/oak",
  "version": "13.1.0",
  "exports": {
    ".": "./mod.ts",
    "./application": "./application.ts",
    "./body": "./body.ts",
    "./context": "./context.ts",
    "./helpers": "./helpers.ts",
    "./etag": "./etag.ts",
    "./form_data": "./form_data.ts",
    "./http_server_native": "./http_server_native.ts",
    "./proxy": "./middleware/proxy.ts",
    "./range": "./range.ts",
    "./request": "./request.ts",
    "./response": "./response.ts",
    "./router": "./router.ts",
    "./send": "./send.ts",
    "./serve": "./middleware/serve.ts",
    "./testing": "./testing.ts"
  }
}

如上所示,有 "name""version" 字段在 deno.json 文件中,同时也用于发布。

Deno CLI 也是用于在 JSR 上发布包的工具。它包含了作为包作者所需的一切。要发布一个版本,您只需执行 deno publish,然后就可以开始了。CLI 将执行所有检查等操作,甚至包括 --dry-run 选项以确保其行为。虽然界面足够用,但显然仍在完善中。

JSR 的用户界面

JSR 的 Web 界面已经非常丰富了。它显然建立在我们为 deno.land/x 所做的大量工作之上,但它显然将其工作提升到了一个新的水平。对于那些熟悉 deno.land/x 的人来说,很多内容都会很熟悉,但对于那些不熟悉的人来说,您可以看到可用的信息量超出了我们目前通过 npm 生态系统获得的信息量,所有这些都在一处。

这是 oak 软件包在 JSR 上的页面:

jsr.io 上 oak 软件包页面的截图

因为我在 oak 的主入口点有一个模块 JSDoc 块,所以它将它显示为主文档,而不是 README。如果没有它,它将显示 README。

有关于如何在 npm(或其他 npm 生态系统包管理器)下安装软件包的说明:

oak 在 npm 上的安装说明

说明是可行的,但问题是发布到 oak 的代码还不支持 Node.js。虽然我目前直接将 oak 发布到 npm,并且该版本可以在 Node.js 下运行并且经过测试,但 JSR 中没有内置提供该功能的相同解决方案。我已向团队提供了我的反馈,希望我们将在该领域看到改进。我也会看看我是否可以找到一种方法来在单个 JSR 版本下支持这两种运行时。

我们在 deno.land/x 上尝试过的一件很棒的事情,但很难正确实现,似乎已直接集成到 JSR 中,即搜索符号的能力:

在 oak 上搜索“router”

这是 JSR 上 oak 的依赖项的视图,您可以在其中将所有依赖项表示为代码,包括 semver 约束,这些约束在您发布时会自动分析:

oak 的依赖项列表

显然有机会用其他数据进一步丰富它,例如依赖项是否是最新的,或者 semver 实际解析了哪些更改。

您还可以查看软件包在注册表上发布的版本,因为我是软件包的所有者,所以我也有能力“取消”一个版本:

在 JSR 上发布的 oak 版本

文档的自动生成是我为 deno.land/x 努力过的最有意义的事情之一,我很高兴它一直持续到 JSR:

oak 的 Application 类的 JSR 文档

当被接受时,这对开发人员的生产力是一个巨大的提升。它还使软件包维护人员的生活变得更加轻松,您不必担心编写代码,并且提升 IDE 中开发人员体验的相同机制成为记录软件包的机制。即使没有软件包作者的努力,您也可以提供正确的最新文档作为基线。

总结

JSR 仍处于早期阶段。一个月前,我的初步印象是,我需要做大量的工作才能将 oak 迁移到 JSR。其中一些是早期阶段,我遇到了现在已解决的问题,还有一些仍在解决中。

最初,我错过了“是的,那又怎样”,但随着更多愿景开始显现,我变得越发兴奋。

它解决了 deno.land/x 的许多问题,但保留了“代码即包管理”带来的所有优势。除此之外,它还具有与 Javascript 生态系统其他部分互操作的能力,这确实解决了大量的实际问题。

至少,拥有一个与 Javascript 平起平坐的 TypeScript 注册表是很重要的。这是我们在 Deno 领域多年来一直拥有的优势,但直到现在,Javascript 生态系统中的其他人还无法轻易获得。

因此,虽然现在还为时过早,而且生态系统显然会用行动投票,但我看到了一种方法,表面上看,不久前这只是疯狂。

使用 JSR 的 oak 示例

这是一个使用 JSR 的 oak 版本的示例。这应该可以在最新版本的 Deno CLI 中完全运行。(Deno Deploy 目前不支持 JSR,但我听说它很快就会到来。)

import {
  Application,
  Context,
  isHttpError,
  Router,
  RouterContext,
  Status,
} from "jsr:@oak/oak@13";

interface Book {
  id: string;
  title: string;
  author: string;
}

const books = new Map<string, Book>();

books.set("1234", {
  id: "1234",
  title: "The Hound of the Baskervilles",
  author: "Conan Doyle, Author",
});

function notFound(context: Context) {
  context.response.status = Status.NotFound;
  context.response.body =
    `<html><body><h1>404 - Not Found</h1><p>Path <code>${context.request.url}</code> not found.`;
}

const router = new Router();
router
  .get("/", (context) => {
    context.response.body = "Hello world!";
  })
  .get("/book", (context) => {
    context.response.body = Array.from(books.values());
  })
  .get("/book/:id", (context) => {
    if (context.params && books.has(context.params.id)) {
      context.response.body = books.get(context.params.id);
    } else {
      return notFound(context);
    }
  });

const app = new Application();

// Logger
app.use(async (context, next) => {
  await next();
  const rt = context.response.headers.get("X-Response-Time");
  console.log(
    `%c${context.request.method} %c${
      decodeURIComponent(context.request.url.pathname)
    }%c - %c${rt}`,
    "color:green",
    "color:cyan",
    "color:none",
    "font-weight: bold",
  );
});

// Response Time
app.use(async (context, next) => {
  const start = Date.now();
  await next();
  const ms = Date.now() - start;
  context.response.headers.set("X-Response-Time", `${ms}ms`);
});

// Error handler
app.use(async (context, next) => {
  try {
    await next();
  } catch (err) {
    if (isHttpError(err)) {
      context.response.status = err.status;
      const { message, status, stack } = err;
      if (context.request.accepts("json")) {
        context.response.body = { message, status, stack };
        context.response.type = "json";
      } else {
        context.response.body = `${status} ${message}\n\n${stack ?? ""}`;
        context.response.type = "text/plain";
      }
    } else {
      console.log(err);
      throw err;
    }
  }
});

// Use the router
app.use(router.routes());
app.use(router.allowedMethods());

// A basic 404 page
app.use(notFound);

app.addEventListener("listen", ({ hostname, port, serverType }) => {
  console.log(
    `%cStart listening on %c${hostname}:${port}`,
    "font-weight:bold",
    "color:yellow",
  );
  console.log(`  using HTTP server: %c${serverType}`, "color:yellow");
});

await app.listen();