我们如何构建 JSR
我们最近 发布了 JavaScript Registry - JSR。这是一个新的 JavaScript 和 TypeScript 注册表,旨在为包作者和用户提供比 npm 更好的体验
- 它原生支持发布 TypeScript 源代码,用于自动生成包的文档
- 它是默认安全的,支持来自 GitHub Actions 的无令牌发布和使用 Sigstore 的包来源
- 它使用我们的“JSR 评分”对包进行评级,以便用户可以“一目了然”地了解包的质量
我们知道,只有当 JSR 与现有的 npm 生态系统互操作时,它才能获得采用。以下是 JSR 需要满足的“npm 互操作性”要求集
- JSR 包可以被使用
node_modules/
文件夹的任何工具无缝使用 - 您可以在 Node 项目中逐步采用 JSR 包
- npm 包可以从 JSR 包中导入,JSR 包可以从 npm 包中导入
- 大多数使用 ESM 或 TypeScript 编写的现有 npm 包只需很少的努力即可发布到 JSR - 只需运行
npx jsr publish
- 您可以使用您喜欢的 npm 兼容包管理器,例如
yarn
或pnpm
与 JSR
我们对 JSR 的公开测试版发布受到了社区的热烈欢迎,因为我们已经看到了一些优秀的包被发布,例如类型安全的验证和解析库 @badrap/valita
和多运行时 HTTP 框架 @oak/oak
。
但是这篇博客文章不是关于您为什么要使用 JSR。它讲述的是我和 JSR 团队的其他成员在几个月的时间里是如何构建 JSR 的,以满足现代、高性能、高可用 JavaScript 注册表的技术要求。这篇文章涵盖了 JSR 的几乎所有部分
- 技术规格概述
- 将 jsr.io 网站的延迟降到最低
- 构建现代发布流程(即说不探测!)
- 以 100% 的可靠性提供模块
- 就这样吗?
我将尝试解释我们如何做某事,以及我们为什么以这种方式做事情。让我们开始吧。
技术规格概述
为了让 JSR 成为一个成功的现代 JavaScript 注册表,它必须具备很多功能
- 一个全球 CDN,用于提供包源代码和 NPM tarball
- 一个网站,用户可以使用它浏览包并管理他们在 JSR 上的存在
- 一个 API,CLI 工具可以与它进行通信以发布包
- 一个系统,在发布期间分析源代码以检查语法错误或无效依赖项,生成文档并计算包评分
构建一个满足所有这些条件的事物的挑战在于,它们各自都有不同的约束条件。
例如,全球 CDN 必须具有近 100% 的正常运行时间。如果即使 0.1% 的用户也无法下载包,那也是不可接受的。如果包下载失败,那就是 CI 运行失败,开发人员感到困惑,以及非常糟糕的用户体验。任何规模相当大的项目在某个时候都会遇到 CI 不稳定的问题 - 我不想增加这种问题 😅。
另一方面,如果用户在被邀请后 10 分钟才收到范围邀请邮件,那就不算什么大问题。这不是很好的用户体验(我们仍然会尽量避免这种情况),但这并不是一个需要在半夜呼叫值班工程师的严重缺陷。
由于可靠性是 JSR 的核心部分,因此这些权衡决定了 JSR 及其各个组件的架构方式。每个部分都有不同的服务级别目标(SLO - 通常用来定义可靠性):我们针对提供包源代码和 NPM 包的目标 SLO 为 100% 的正常运行时间,而其他服务(如我们的数据库)则针对更保守的 99.9% 的正常运行时间。在整篇文章中,我们将使用 SLO 作为确定我们如何设计系统一部分的起点。
大部分数据使用 Postgres
JSR 使用高度可用的 Postgres 集群来存储大部分数据。我们有用于明显事物(如 users
、scopes
或 packages
)的表。但我们还有更大的表,比如我们的 package_version_files
表,它包含所有上传到 JSR 的文件的元数据,例如 path
、hash
和 size
。由于 Postgres 是一个关系数据库,因此我们可以使用 JOIN 将这些表组合起来,以检索各种有趣的信息
- 这个用户占用了多少存储空间?
- JSR 包之间有多少个文件重复?
- 创建者在注册 JSR 后的一小时内发布了哪些包?
我们使用优秀的 sqlx
Rust 包 来处理迁移。您可以在 GitHub 上查看 创建 JSR 数据库的所有迁移。
我们的 Postgres 数据库托管在 Google Cloud 上(与 JSR 的其余部分一样!)。我们过去对 Google Cloud 有过很好的体验,因此我们决定在这里再次使用它。Google Cloud 为 Terraform 提供了非常好的工具和文档,Terraform 是我们用来部署 JSR 的基础设施即代码工具(可以写一篇关于它的完整博客文章)。
API
我们的 API 服务器位于 Postgres 数据库之上。JSR 不会将 Postgres 数据库直接暴露给客户端。相反,我们通过 HTTP REST API 上的 JSON 来公开数据库中的数据。API 请求可以来自多种不同的客户端,包括来自用户的浏览器或来自 jsr publish
/ deno publish
工具。
JSR API 服务器是用 Rust 编写的,使用 Hyper HTTP 服务器。它通过 sqlx
Rust 包 与 Postgres 数据库进行通信。该服务部署到 Google Cloud Run 中的一个区域,紧挨着 Postgres 数据库。
除了在数据库和 JSON API 接口之间代理数据外,API 服务器还实施身份验证和授权策略,例如要求只有范围成员才能更新包的描述,或确保只有正确的 GitHub Actions 作业才能用于发布包。
归根结底,API 服务器是一个相对标准的服务,它可以以非常类似的形式存在于数百个其他 Web 应用程序中。它与 SQL 数据库交互,使用电子邮件服务(在我们的案例中是 Postmark)发送电子邮件,与 GitHub API 交谈以验证存储库所有权,与 Sigstore 交谈以验证发布证明,等等。
jsr.io 网站的延迟降到最低
将如果您正在编写一项供人类使用的服务,您会很快发现大多数人类实际上并不想手动使用 curl
调用 API。因此,JSR 有一个 Web 前端,它可以让您执行 API 公开的所有操作(除了发布包 - 稍后会详细介绍)。
为了让 jsr.io 网站感觉快速而敏捷,我们使用 Fresh 构建了它,这是一个现代的“服务器端渲染优先” Web 框架,由 Deno 团队在这里构建。这意味着 JSR 网站上的每个页面都是按需在运行在您附近的 Google Cloud 数据中心中的 Deno 进程中为您渲染的。让我们探讨 Fresh 使用的一些方法来确保网站访问者获得最佳体验。
为了性能的“岛屿渲染”
Fresh 非常独特,因为它不像 Next.js 或 Remix 这样的其他 Web 框架,它不会在客户端和服务器上渲染整个应用程序。 相反,每个页面始终完全在服务器上渲染,只有标记为交互的部分在客户端上渲染。 这就是所谓的 “岛屿渲染”,因为我们在一片服务器渲染的内容中拥有交互的小岛。
这是一个非常强大的模型,它使我们能够为所有用户提供极其快速的页面渲染,无论他们的地理位置、互联网速度、设备性能和内存可用性如何,并且提供非常好的交互式用户流程。 例如,由于我们的岛屿架构,即使使用服务器端渲染,JSR 网站仍然可以支持“边输入边搜索”,以及在提交表单之前对范围名称进行客户端验证。 所有这些都不需要向客户端提供 Markdown 渲染器、样式库或组件框架。
这真的很有用
来自 Chrome UX 报告(Chrome 在后台收集的有关性能的匿名统计信息)的真实用户数据表明,https://jsr.deno.org.cn 在各种设备和各种网络上都具有出色的性能。 特别令人兴奋的是,新的交互到下一帧绘制 (INP) 指标获得了极佳的得分,该指标衡量主线程在渲染方面的竞争程度,并表示交互的“感觉”有多快。 由于 Fresh 的岛屿架构,JSR 在此类指标方面表现非常出色。
优化首字节时间 (TTFB)
您可能在这里注意到的另一件事是 TTFB 性能。 TTFB 指标表示“首字节时间”的第 75 个百分位数 - 从在 URL 栏中按下 Enter 键到浏览器从服务器接收响应的开始之间的时间。 我们的 TTFB 得分为 0.5 秒,这非常出色,特别是对于动态服务器端渲染的网站来说。(服务器端渲染的网站往往会有更高的 TTFB,因为它们会等待服务器上的动态数据,而不是在客户端提供静态外壳,然后从客户端获取动态数据。)
我们在优化 TTFB 上花费了大量时间,这对服务器端渲染的页面来说可能是一个真正的挑战。 由于整个页面渲染被阻塞,直到所有数据都在服务器上出现,因此服务器检索所有所需数据所需的时间需要尽可能减少。 在我们开始 JSR 工作的前几周,我经常会收到来自印度和日本同事的报告,称 JSR 包页面加载速度慢得令人难以忍受 - 一个简单的包设置页面需要几秒钟。
我们设法将问题缩小到渲染页面的服务器和我们的 API 服务器之间的请求瀑布。 我们首先获取用户配置文件,然后获取包元数据,然后获取用户是否是范围的成员,等等。 由于我们的 API 服务器托管在美国(距离印度很远),甚至互联网也需要遵守光速等物理限制,因此我们需要几秒钟才能获得渲染所需的所有数据。
我们现在已经到了 JSR 网站上没有一条路由需要在渲染服务器和 API 服务器之间进行多次网络往返的地步。 我们设法将许多 API 调用并行化,在其他情况下,以微妙的方式改进 API,使其不再需要多次请求。 例如,最初,包页面会先获取包版本列表,确定最新版本是什么,然后获取该版本的自述文件。 现在,您只需告诉返回自述文件的 API 端点您希望获取“最新”版本的自述文件,它就可以从其位于同一位置的数据库中快速确定最新版本。
<form>
尽可能使用依赖内置的浏览器<form>
提交可能看起来很奇怪。(即使在不寻常的地方,我们也使用<form>
,比如范围的范围成员列表中用户旁边的“删除”按钮。) 但是,这样做可以让我们显着减少需要编写、交付给用户和审核其可访问性的代码量。
可访问性是 Web 开发的重要方面,使用更多内置的浏览器原语可以显着减少您自己要做的工作才能获得良好的结果。
我已经提到过所有这些都是开源的吗? JSR 前端可能是最容易贡献的部分
git clone https://github.com/jsr-io/jsr.git
cd jsr
echo "\n127.0.0.1 jsr.test" >> /etc/hosts
deno task prod:frontend
这将启动前端的本地副本,以便您使用它,并将其连接到生产 API。 如果您对frontend/
文件夹进行更改,您在 http://jsr.test 上的本地前端将自动重新加载。 您甚至可以使用您网站的本地副本从生产 JSR 服务中管理您的范围和包!
构建现代发布流程(又称拒绝探测!)
我们已经讨论了很多关于管理包的元数据(API 服务器)和查看这些元数据(前端),但实际上 - 如果您不能发布到包注册表,那么包注册表是什么?
构建一个必须与 npm 交互的现代 JavaScript 注册表意味着能够接受各种各样的包。 模块作者应该能够发布
- 使用
package.json
在 TypeScript 中编写的现有 NPM 包 - 使用
import_map.json
为 Deno 编写的包 - 甚至一个从 npm 导入的包并重新导出它的单个 JS 文件
构建一个支持接受各种包的注册表的挑战意味着我们必须理解并支持各种模块解析方法
- 使用它们实际使用的扩展名导入文件 (
.ts
用于导入 TS 文件) - 完全不带扩展名导入文件
- 使用错误的扩展名导入文件 (
.js
导入实际上是指扩展名为.ts
的 TS 文件) - 在导入映射中解析的裸规范
- 通过 package.json 解析的裸规范
- … 等等
尽管支持发布各种包的复杂性,但我们很早就知道 JSR 包使用者不应该了解这些复杂的差异。 我们试图将生态系统推向一个一致、简单得多的方向:仅限 ESM 且非常明确的解析行为。 为了为包使用者提供一流的开发人员体验,JSR 必须确保代码下载的一致格式。
那么,我们如何才能在支持接受各种包的同时,仍然提供简单的标准包使用体验呢?
当作者发布到 JSR 时,我们通过将其转换为注册表的一致格式,自动“修复它”。 作为包作者,您无需了解、关心或理解它正在发生。 但这种代码转换会加快注册表的速度并简化注册表。
那么,问题是这个“一致标准”格式是什么?
拒绝探测
在进一步讨论之前,先说一下“探测”。 (哦……我说出来就发抖。) 探测是指对某人给出不明确的指示,然后他们尝试很多事情,直到找到有效的做法。 困惑了吗? 举个例子。
想象一下您在超市工作,今天负责摆放水果。 回到家后,您决定想吃点菠萝。 您派人去超市买一些,但您没有告诉他们买菠萝,而是说“给我任何有库存的水果,以字母“p”开头,以下是我的优先顺序:木瓜、梨、菠萝、百香果、桃子”。
您的跑腿者来到超市的水果区开始寻找。 今天没有木瓜。 梨? 没有。 但幸运的是 - 有菠萝! 他们拿了一些,然后胜利地返回到您身边。
但那是街坊邻居的杂货店,您可以一眼看到整个水果展示架,因此检查水果很快。 如果我们是在批发水果市场怎么办? 每个大陆的水果现在都在一个巨大的仓库中各自的区域。 在木瓜、梨和菠萝之间走动需要几分钟。 在这种情况下,“探测”水果就行不通了,因为这需要很长时间,以至于变得不可行。
那么,这种笨拙的购买水果方式与一致的标准代码格式有什么关系呢?嗯,许多解析算法会探测:import './foo'
需要检查是否存在名为 ./foo/index.tsx
、./foo/index.ts
、./foo/index.js
、./foo.tsx
、./foo.ts
、./foo.js
甚至仅仅 ./foo
的文件。作为包作者,你知道要解析到哪个文件。但你让解析器踏上了一个有趣的探险之旅:“尝试打开一堆文件,看看它们是否存在”。糟糕。
当你在本地文件系统上执行此操作时,性能通常是可以接受的。在现代 SSD 上读取一个文件需要几百纳秒。但是,在网络驱动器上的“探测” - 速度要慢得多。而且通过 HTTP - 你需要等待每次读取调用 10 毫秒以上。对于包中的数百个文件来说,这是完全不可接受的。
由于 JSR 包不仅可以从文件系统导入,还可以使用 HTTP 导入(如浏览器和 Deno),探测是绝对不行的。(巧合的是,你现在也知道为什么浏览器永远无法像 Node 那样交付 node_modules/
解析:探测太多。)
publish
期间本地重写导入语句以移除探测
在 由于探测是不可能的,因此我们针对最终用户和 JSR 消费的,一致的标准格式必须遵循以下一条主要规则
仅凭模块的标识符和内容,你就可以准确地确定从该包中导入的所有文件的精确名称,以及所有外部包的名称和版本约束。
实际上,这意味着我们不能依赖 package.json
来解析依赖项,并且所有相对导入必须具有显式的扩展名和路径。因此,为了支持接受广泛且多样化的包集,我们首先必须在代码到达 JSR API 层之前重写代码。
当你调用 jsr publish
或 deno publish
时,发布工具将检查你的代码,探测 package.json
、导入映射以及你用于配置解析的任何其他内容,然后遍历包中的所有文件,从你的 jsr.json
中的 "exports"
开始。然后它会找到任何需要探测才能解析的导入,并将它们重写为不需探测的一致格式。
- import "./foo";
+ import "./foo.ts";
- import "chalk";
+ import "npm:chalk@^5";
- import "oak";
+ import "jsr:@oak/oak@^14";
这一切都在内存中完成,包作者无需看到或知道这一点。这种将本地代码转换为一致格式的魔法,使作者能够以他们想要的任何方式编写代码,而用户能够以简单、标准化的方式使用包,从而使现代、灵活的发布体验成为可能。
使用后台队列来确保可用性
接下来,发布脚本将所有文件放入一个 .tar.gz
文件中。在用户交互式地验证发布之后(这本身可能是一篇完整的博文 👀),它会将压缩包上传到 API 服务器。
API 服务器然后进行一些初步验证,例如检查压缩包是否小于 允许的 20MB,以及你是否有权发布此版本。然后它将压缩包存储在存储桶中,并将发布任务添加到后台队列。
为什么将它添加到队列中,而不是立即处理压缩包呢?为了可靠性。
可靠性的一部分是处理流量高峰。由于发布是一个密集型过程,需要大量 CPU 和内存资源,因此一个大型的单一存储库同时发布 100 个不同包的新版本可能会使整个系统陷入停顿。
因此,我们存储压缩包,将它们放入队列中,然后后台工作者从队列中取出发布任务并处理它们,以保证可用性。99% 的发布从提交到由后台工作者出队的时间在 30 毫秒内。但是,如果我们确实看到了流量高峰,我们可以通过稍微降低处理速度来优雅地处理它们。
当后台工作者接收到发布任务时,它首先解压缩压缩包,并检查里面的所有文件是否都在我们的可接受范围内。然后它验证 jsr.json
文件是否具有有效的 "name"
、"version"
和 "exports"
字段。之后,我们构建整个模块的模块图,这有助于验证包代码。模块图映射了包中每个模块之间的关系,它检查
- 你的代码是有效的 JavaScript 或 TypeScript
- 你导入的所有模块实际上都存在
- 你所有的依赖项都是带版本号的
对于大多数包来说,这一切都在 10 毫秒内完成。
自动生成文档并将模块上传到存储
验证完成后,我们根据该模块图生成包的文档。这完全使用 Rust 中的 TypeScript 语法分析完成。结果会上传到存储桶中。我们将在某个时候写另一篇博文来介绍它的工作原理,因为这很有趣!
然后,我们将每个模块分别上传到 modules
存储桶(完全保持原样,这里没有进行转换)。这个存储桶很重要,记住它!
.js
和 .d.ts
文件,以供 npm 使用
将 TypeScript 转换为 接下来,我们为 JSR 的 npm 兼容层生成一个压缩包。为此,我们将你的 TypeScript 源代码转换为 .js
代码文件和 .d.ts
声明文件。这完全在 Rust 中完成 - 据我们所知,这是第一个不使用用 JavaScript 编写的 Microsoft 的 TypeScript 编译器(令人兴奋!)的大规模部署 .d.ts
生成。这个压缩包中的代码从 JSR 的一致模块格式重新编写为 node_modules/
解析可以理解的导入,以及一个 package.json
。完成后,它也会上传到存储桶中。
最后,我们完成了。Postgres 数据库已更新至新版本,jsr publish
/ deno publish
命令已收到发布完成的通知,并且包已在互联网上发布。当您访问 https://jsr.deno.org.cn 时,更新后的版本将显示在包页面上。
🚨️ 旁注 🚨️
我们对能够在不使用
tsc
的情况下从.ts
源代码生成.d.ts
文件感到非常兴奋。这是 TypeScript 正在积极努力鼓励的事情。例如,Bloomberg 和 Google 与 TypeScript 团队合作,一直在努力为 TypeScript 添加一个isolatedDeclarations
选项,这将使在 TSC 之外发出声明变得轻而易举。我们很有信心,很快,将会有更多工具能够在不使用tsc
的情况下发出.d.ts
文件。
以 100% 的可靠性提供模块
我现在已经绕弯子说了很多了 - 这篇博文一开始我就告诉你,我们的模块/npm 压缩包服务必须非常健壮。那么,秘诀是什么呢?
实际上,没有任何秘诀。我们使用了非常无聊,非常了解,而且非常可靠的云基础设施。
https://jsr.deno.org.cn 托管在 Google Cloud 上。流量通过 anycast IP 地址,被 Google Cloud L7 负载均衡器接收。它会终止 TLS。然后它会 查看路径、请求方法和头部,以确定请求应该发送到 API 服务器、前端,还是直接面向包含源代码和 npm 压缩包的 Cloud Storage 存储桶的 Google Cloud CDN 后端。
那么,我们如何确保提供模块的可靠性呢?我们将整个问题委托给 Google Cloud。与提供 google.com 和 YouTube 相同的基础设施用于在 JSR 上托管模块。我们没有任何自定义代码位于这条热路径中 - 这很重要,因为这意味着我们无法破坏它。只有当 Google 本身宕机时,JSR 才会宕机。但到那时 - 大概有一半的互联网都宕机了,所以你根本不会注意到 😅。
就这样?
对于注册表方面,是的,大部分内容都包括了。我没有提到我们如何进行文档渲染、如何计算 JSR 分数、计算包依赖项和被依赖项、处理与 GitHub Actions 的 OIDC 集成、与 Sigstore 集成进行来源证明……但我们下次可以讨论这些。🙂
如果您对本文中提到的任何内容感兴趣——从 API 服务器,到 前端 和 Google Cloud 的 Terraform 配置,再到 deno publish
的实现——您可以自己查看它们,因为 JSR 是完全开源的,并根据 MIT 许可证发布!
我们欢迎所有贡献者!您可以打开问题、提交 PR,或在 Twitter 上向我提问。
在 JSR 上见!
您喜欢这篇关于 JSR 内部机制的文章吗?
我们很快会发布另一篇文章,详细介绍
deno
如何从 JSR 安装包。请在 Twitter 上关注我们以获取更新:@jsr_io 或 @deno_land。