JSR 是如何构建的
我们最近发布了 JavaScript 注册表 - JSR。这是一个新的 JavaScript 和 TypeScript 注册表,旨在为包作者和用户提供比 npm 显著更好的体验
- 它原生支持发布 TypeScript 源代码,用于为您的包自动生成文档
- 它默认是安全的,支持从 GitHub Actions 进行无令牌发布,并使用 Sigstore 进行包来源验证
- 它使用我们的“JSR 评分”对包进行评级,让消费者“一目了然”地了解包的质量
我们知道,只有当 JSR 与现有的 npm 生态系统互操作时,才能获得采用。以下是我们 JSR 需要满足的一系列“npm 互操作性”要求
- 任何使用
node_modules/
文件夹的工具都可以无缝使用 JSR 包 - 您可以在 Node 项目中逐步采用 JSR 包
- npm 包可以从 JSR 包中导入,JSR 包也可以从 npm 包中导入
- 大多数现有的用 ESM 或 TypeScript 编写的 npm 包都可以轻松发布到 JSR - 只需运行
npx jsr publish
- 您可以将您最喜欢的 npm 兼容包管理器(如
yarn
或pnpm
)与 JSR 一起使用
我们的 JSR 公开 Beta 版发布受到了社区的热烈欢迎,我们已经看到一些很棒的包被发布,例如类型安全的验证和解析库 @badrap/valita
和多运行时 HTTP 框架 @oak/oak
。
但这篇博文不是关于您为什么要使用 JSR。而是关于我和 JSR 团队的其他成员,在几个月的时间里,如何构建 JSR 以满足现代、高性能、高可用性 JavaScript 注册表的技术要求。这篇文章涵盖了 JSR 的几乎每个部分
- 技术规格概述
- 最大限度地减少 jsr.io 网站的延迟
- 构建现代发布流程(又名,对探测说不!)
- 以 100% 的可靠性提供模块服务
- 就这些了吗?
我将尝试解释的不仅仅是我们如何做某事,还有我们为什么以我们现在的方式做事。让我们深入了解一下。
技术规格概述
为了使 JSR 成为一个成功的现代 JavaScript 注册表,它必须是多方面的
- 一个用于服务包源代码和 NPM tarball 的全球 CDN
- 一个供用户浏览包并管理他们在 JSR 上的存在的网站
- 一个 CLI 工具用来发布包的 API
- 一个在发布期间分析源代码以检查语法错误或无效依赖项、生成文档并计算包得分的系统
构建满足所有这些条件的东西的挑战在于,它们各自都有不同的约束。
例如,全球 CDN 必须具有接近 100% 的正常运行时间。即使 0.1% 的用户下载包失败也是不可接受的。如果包下载失败,那将意味着 CI 运行中断、开发人员困惑以及非常糟糕的用户体验。任何规模适中的项目都已经在某个时候与不稳定的 CI 作斗争 - 我不想雪上加霜 😅。
另一方面,如果用户在被邀请后 10 分钟才收到作用域邀请电子邮件,那问题就小得多。这不是一个很棒的用户体验(我们仍然尝试避免这种情况),但这不是一个需要半夜呼叫随叫随到工程师的严重缺陷。
由于可靠性是 JSR 的核心部分,这些权衡决定了 JSR 及其各种组件的架构方式。每个部分都有不同的服务级别目标(SLO - 通常定义可靠性的方式):我们针对提供包源代码和 NPM 包的 100% 正常运行时间的 SLO,而其他服务(如我们的数据库)则针对更保守的 99.9% 正常运行时间。在整篇文章中,我们将使用 SLO 作为确定我们如何设计系统一部分的起点。
Postgres 用于大多数数据
JSR 使用高可用的 Postgres 集群来存储大多数数据。我们有用于显而易见的事物的表,例如 users
、scopes
或 packages
。但我们也有更大的表,例如我们的 package_version_files
表,其中包含元数据,例如 path
、hash
和 size
,这些元数据是所有上传到 JSR 的文件的。由于 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 crate 与 Postgres 数据库通信。该服务部署到 Google Cloud Run,位于一个区域,紧邻 Postgres 数据库。
除了在数据库和 JSON API 接口之间代理数据外,API 服务器还强制执行身份验证和授权策略,例如要求只有作用域成员才能更新包的描述,或确保只有正确的 GitHub Actions 作业才能用于发布包。
归根结底,API 服务器是一个相对标准的服务,可以以非常相似的形式存在于其他 100 多个 Web 应用程序中。它与 SQL 数据库交互,使用电子邮件服务(在我们的例子中是 Postmark)发送电子邮件,与 GitHub API 通信以验证存储库所有权,与 Sigstore 通信以验证发布证明等等。
jsr.io 网站的延迟
最大限度地减少如果您正在编写一个供人类使用的服务,您很快就会发现大多数人实际上并不想使用 curl
手动调用 API。因此,JSR 有一个 Web 前端,可让您执行 API 公开的每一项操作(发布包除外 - 稍后详细介绍)。
为了使 jsr.io 网站感觉快速而流畅,我们使用 Fresh 构建了它,Fresh 是一个现代“服务器端优先渲染”的 Web 框架,由 Deno 团队在此构建。这意味着 JSR 网站上的每个页面都是在靠近您的 Google Cloud 数据中心中的 Deno 进程中按需渲染的,专为您服务。让我们探讨一下 Fresh 使用的一些方法,以确保网站访问者获得最佳体验。
Island rendering for performance (岛屿渲染以提高性能)
Fresh 非常独特,与其他 Web 框架(如 Next.js 或 Remix)不同,它不会在客户端和服务器端都渲染整个应用程序。相反,每个页面始终完全在服务器上渲染,只有标记为交互的部分才在客户端上渲染。这被称为 “岛屿渲染”,因为我们在服务器渲染内容的海洋中拥有少量交互岛屿。
这是一个非常强大的模型,它使我们能够为所有用户提供令人难以置信的快速页面渲染,无论他们的地理位置、互联网速度、设备性能和内存可用性如何,并且提供非常好的交互式用户流程。例如,由于我们的岛屿架构,即使在使用服务器端渲染时,JSR 站点仍然可以支持“边输入边搜索”以及表单提交前客户端的作用域名称验证。所有这些都不需要向客户端运送 markdown 渲染器、样式库或组件框架。
这真的很有价值
来自 Chrome UX 报告(Chrome 在后台收集的关于性能的匿名统计数据)的真实用户数据显示,https://jsr.deno.org.cn 在各种设备和各种网络上都具有出色的性能。特别令人兴奋的是新的交互到下一个绘制 (INP) 指标的优秀得分,该指标衡量主线程在渲染方面的竞争程度,并表示交互“感觉”有多快。由于 Fresh 的岛屿架构,JSR 在这些指标上得分非常高。
Optimizing for time-to-first-byte (优化首字节时间)
您可能注意到的另一件事是 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
文件中。在让用户交互式地验证发布后(这可能是另一篇完整的博文 👀),它将 tarball 上传到 API 服务器。
然后,API 服务器进行一些初始验证,例如检查 tarball 是否小于 允许的 20MB,以及您是否有权发布此版本。然后,它将 tarball 存储在存储桶中,并将发布任务添加到后台队列。
为什么要将其添加到队列而不是立即处理 tarball?为了可靠性。
可靠性的一部分是处理流量高峰。由于发布是一个密集型过程,需要大量的 CPU 和内存资源,因此一个大型 mono repo 同时为 100 个不同的包发布新版本可能会使整个系统运行缓慢。
因此,我们存储 tarball,将它们放入队列中,然后后台工作程序从队列中提取发布任务并根据它们的可用性处理它们。99% 的发布从提交到被后台工作程序出队的时间不到 30 毫秒。但是,如果我们确实看到大的峰值,我们可以通过稍微减慢处理速度来优雅地处理这些峰值。
当后台工作程序提取发布任务时,它首先解压缩 tarball 并检查其中的所有文件是否在我们的可接受限制范围内。然后,它验证 jsr.json
文件是否具有有效的 "name"
、"version"
和 "exports"
字段。在此之后,我们构建整个模块的模块图,这有助于验证包代码。模块图(它映射包中每个模块之间的关系)检查
- 您的代码是否是有效的 JavaScript 或 TypeScript
- 您导入的所有模块是否实际存在
- 您的所有依赖项是否已版本化
对于大多数包来说,所有这些都在 10 毫秒内完成。
自动生成文档并将模块上传到存储
验证完成后,我们基于此模块图为包生成文档。这完全是使用 Rust 中的 TypeScript 语法分析完成的。结果将上传到存储桶。我们将在某个时候写另一篇关于这如何工作的博文,因为它非常有趣!
然后,我们将每个模块单独上传(完全按照原样,此处不进行转换)到 modules
存储桶。这个存储桶稍后会很重要,请记住它!
.js
和 .d.ts
文件
将 TypeScript 转换为 npm 的 接下来,我们为 JSR 的 npm 兼容层生成一个 tarball。为此,我们将您的 TypeScript 源代码转换为 .js
代码文件和 .d.ts
声明文件。这完全在 Rust 中完成 - 据我们所知,这是首次大规模部署不使用 Microsoft 的 JavaScript 编写的 TypeScript 编译器来生成 .d.ts
文件(令人兴奋!)。此 tarball 中的代码从 JSR 的一致模块格式重写为 node_modules/
解析可以理解的导入,以及一个 package.json
文件。完成后,它也会上传到一个存储桶。
最后,我们完成了。Postgres 数据库已更新为新版本,jsr publish
/ deno publish
命令收到发布完成的通知,并且该软件包已在互联网上发布。当您现在访问 https://jsr.deno.org.cn 时,更新后的版本将显示在软件包页面上。
🚨️ 旁注 🚨️
我们对我们能够从
.ts
源代码生成.d.ts
文件而无需使用tsc
感到 非常 兴奋。这是 TypeScript 正在积极鼓励的事情。例如,彭博和谷歌与 TypeScript 团队合作,一直在努力向 TypeScript 添加isolatedDeclarations
选项,这将使在 TSC 之外发出声明变得轻而易举。我们看好很快会有更多工具能够无需使用tsc
即可发出.d.ts
文件。
以 100% 的可靠性服务模块
我已经绕圈子绕得够久了 - 这篇博文一开始就告诉你,我们的模块/npm tarball 服务非常健壮是多么重要。那么秘诀是什么呢?
实际上,没有什么魔法。我们使用极其普通、非常容易理解且非常可靠的云基础设施。
https://jsr.deno.org.cn 托管在 Google Cloud 上。流量由 Google Cloud L7 负载均衡器通过任播 IP 地址接收。它终止 TLS。然后,它查看路径、请求方法和标头,以确定请求应发送到 API 服务器、前端,还是直接位于包含源代码和 npm tarball 的 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 许可证下完全开源!
我们欢迎所有贡献者!您可以提出 issue、提交 PR,或在 Twitter 上向我提问。
JSR 上见!
您喜欢这篇关于 JSR 内部机制的文章吗?
我们即将发布另一篇文章,详细介绍
deno
如何从 JSR 安装软件包。在 Twitter 上关注我们以获取更新:@jsr_io 或 @deno_land。