Deno官方博客:我们是如何构建JSR站点的?

原文:Deno官方博客

作者:Luca Casonato 2024年4月12日


我们最近推出了 JavaScript 注册表 - JSR。这是一个新的 JavaScript 和 TypeScript 注册表,旨在为包作者和用户提供比 npm 更好的体验:

  • 它原生支持发布 TypeScript 源代码,用于为您的包自动生成文档
  • 它默认支持安全发布,支持使用 Sigstore 进行 GitHub Actions 的无令牌发布和包来源验证
  • 它使用我们的"JSR 分数"对包进行评级,为用户提供"一目了然"的包质量指示

我们知道只有当 JSR 与现有的 npm 生态系统互操作时,它才能被广泛采用。以下是我们需要满足的 JSR 与 npm 互操作性要求:

  • JSR 包可以无缝地被任何使用 node_modules/ 文件夹的工具消耗
  • 您可以在 Node 项目中逐步采用 JSR 包
  • npm 包可以从 JSR 包导入,JSR 包也可以从 npm 包导入
  • 大多数使用 ESM 或 TypeScript 编写的现有 npm 包可以通过非常少的工作发布到 JSR ------ 只需运行 npx jsr publish
  • 您可以在 JSR 中使用您喜爱的 npm 兼容包管理器,如 yarnpnpm

我们的 JSR 公开测试受到了社区的热情响应,因为我们已经看到一些很棒的包被发布了,比如类型安全的验证与解析库 @badrap/valita 和多运行时 HTTP 框架 @oak/oak

但是这篇博客文章不是关于为什么您应该使用 JSR。而是关于我和 JSR 团队其他成员在几个月的时间里如何构建 JSR 以满足现代、高性能、高可用性 JavaScript 注册表的技术要求。

本文涵盖了 JSR 的几乎所有部分:

  • 技术规格概述
  • 降低 jsr.io 网站的延迟
  • 构建现代发布流程(又名拒绝探测!)
  • 提供 100% 可靠性的模块服务
  • 那么就这样了吗?

我将尽力解释我们不仅是如何 做某事,还是为什么以这种方式做事。让我们开始吧。

技术规格概述

要成为一个成功的现代 JavaScript 注册表,JSR 必须是多种东西:

  • 一个全球 CDN,用于提供包源代码和 NPM tarballs
  • 一个用户用于浏览包和管理它们在 JSR 上的存在的网站
  • CLI 工具用于发布包的 API
  • 一个在发布时分析源代码以检查语法错误或无效依赖项、生成文档并计算包分数的系统

这个图表显示了 JSR 系统的高级概述。如果你现在还不理解这里的一切,不要担心。我希望到文章末尾,你将完全理解这里发生的一切。

构建满足所有这些条件的东西的挑战在于它们各自具有不同的约束条件。

例如,全球 CDN 必须接近 100% 的正常运行时间。即使对于 0.1% 的用户下载包失败也是不可接受的。如果包下载失败,这将导致 CI 运行失败、开发者困惑,以及非常糟糕的用户体验。任何规模较大的项目都可能会在某个时候遇到不稳定的 CI ------ 我们不想增加这种情况 😅。

另一方面,如果用户在受邀之后的 10 分钟内收到作用域邀请邮件,那么这就不太成问题了。这不是一个很好的用户体验(我们仍然努力避免这种情况),但这不是一个会在半夜叫醒值班工程师的严重缺陷。

由于可靠性是 JSR 的核心部分,这些权衡决定了 JSR 及其各种组件的架构。每个部分都有不同的服务水平目标(SLO ------ 可靠性通常的定义方式):我们针对为包源代码和 NPM 包提供 100% 正常运行时间的 SLO,而其他服务(如我们的数据库)则针对更保守的 99.9% 正常运行时间。在本文的整个过程中,我们将使用 SLO 作为确定我们如何设计系统部分的出发点。

大多数数据使用 Postgres

JSR 使用高可用的Postgres集群存储大多数数据。我们有一些明显的表,比如 usersscopespackages。但是我们也有更大的表,比如我们的 package_version_files 表,其中包含了上传到 JSR 的所有文件的 pathhashsize等元数据。由于 Postgres 是一个关系数据库,我们可以使用 JOINs 结合这些表来检索各种有趣的信息:

  • 这个用户使用了多少存储空间?
  • JSR 包中有多少文件是重复的?
  • 哪些包是在创建者注册到 JSR 后一个小时内发布的?

我们使用优秀的 Rust 包 sqlx 来处理迁移。您可以在 GitHub 上查看创建 JSR 数据库的所有迁移

我们的 Postgres 数据库托管在 Google Cloud 上(就像 JSR 的其余部分一样!)。我们过去在 Google Cloud 上有很好的经验,所以我们决定再次在这里使用它。Google Cloud 为 Terraform 提供了非常好的工具和文档,这是我们用来部署 JSR 的基础即代码工具(可以在这方面撰写整篇博文)。

API

位于Postgres数据库之上的是我们的API服务器。JSR不会直接向客户端公开Postgres数据库。而是通过HTTP REST API以JSON的形式公开数据库中的数据。API请求可以来自多种类型的客户端,包括用户的浏览器,以及jsr publish/ deno publish工具。

JSR API服务器使用Rust编写,使用Hyper HTTP服务器进行通信。它通过sqlx Rust cratePostgres数据库通信。该服务部署在Google Cloud Run中的一个地区,就在Postgres数据库旁边。

除了在数据库和JSON API接口之间代理数据外,API服务器还强制执行认证和授权策略,例如要求只有作用域成员才能更新软件包的描述,或者确保只有适当的GitHub操作作业可以用于发布软件包。

最终,API服务器是一个相对标准的服务,可以以非常相似的形式存在于其他100多个Web应用程序中。它与SQL数据库交互,使用电子邮件服务发送电子邮件(在我们的案例中是Postmark),与GitHub API交互以验证存储库所有权,与Sigstore交互以验证发布证明等等。

减少 jsr.io 网站延迟

如果您正在为人类编写服务,您很快会发现,大多数人实际上并不想使用curl手动调用 API。正因为如此,JSR 拥有一个 Web 前端,让您执行 API 公开的每个操作(除了发布软件包 - 关于此后续说明)。

为了保持jsr.io网站的快速和灵敏,我们构建了 Fresh,并且这是在 Deno 构建的现代"服务器端渲染优先" Web 框架。这意味着 JSR 网站上的每个页面都会根据您的要求在临近您的 Google Cloud 数据中心运行的 Deno 进程中渲染,专门为您提供。让我们探讨一些 Fresh 用于确保访问者获得最佳体验的方法。

关于性能的岛屿渲染

Fresh 在很多方面都非常独特,与Next.jsRemix 等其他网络框架不同,它不会在客户端和服务器上都渲染整个应用程序。相反,每个页面始终完全在服务器上渲染,只有标记为互动的部分才在客户端上渲染。这被称为"岛屿渲染",因为我们在一片服务器渲染内容的海洋中有少许互动的小岛。

这是一个非常强大的模型,使我们能够为所有用户提供非常快速的页面渲染,而无论其地理位置、互联网速度、设备性能和内存供应情况如何,并且提供非常好的互动用户流。例如,由于我们的岛屿架构,JSR 网站仍然可以支持"搜索即时显示"和在提交表单之前针对范围名称进行客户端验证,即使使用服务器端渲染也可以实现。这一切都可以在不必向客户端发送 markdown 渲染器、样式库或组件框架的情况下实现。

这真的很值得。

Chrome收集的数据显示jsr.io网站为用户提供了出色的性能。您可以在PageSpeed Insights上查看结果。检索日期为2024年3月28日,下午1:22:52

当查看整个来源时,而不仅仅是具有大型动画主页的主页,情况变得更好!

通过 Chrome UX 报告的真实用户数据(Chrome 在后台收集的匿名性能统计),显示 jsr.io 在各种设备和各种网络上表现出色。尤其令人振奋的是新的交互到下一帧绘制(INP)指标的优异得分,该指标衡量主线程在渲染过程中的争用程度,并代表交互的"感觉"有多快。由于 Fresh 的岛屿架构,JSR 在这类指标上表现非常出色。

优化首字节时间

你可能在这里注意到的另一件事是TTFB性能。TTFB指标代表"到达第一个字节的时间"的第75个百分位数------在您的URL栏中输入后,浏览器从服务器接收响应开始之间的时间。我们的TTFB得分为0.5秒,这对于动态服务器端呈现的站点来说非常好。

服务器呈现的站点往往具有更高的TTFB,因为它们在服务器上等待动态数据,而不是在客户端上提供静态外壳,然后从客户端获取动态数据。

我们花了很多时间来优化TTFB,这对于服务器端呈现的页面来说确实是一个真正的挑战。因为直到服务器收到全部数据之前,整个页面呈现都会被阻塞,服务器获取所需数据的时间需要尽可能减少。在我们为JSR工作的前几周,我经常会收到来自印度和日本同事的报告,称JSR包页面加载速度非常慢------对于一个简单的包设置页面需要多秒钟。

我们成功将这一问题缩小到服务器呈现页面和我们的API服务器之间的一系列请求瀑布流。我们首先获取用户配置文件,然后获取包元数据,然后获取用户是否属于该范围,依此类推。因为我们的API服务器托管在美国(距离印度很远),而且即使是互联网也需要遵守光速之类的物理约束,我们需要几秒钟的时间才能获取渲染所需的所有数据。

现在,JSR站点上没有一个路径需要在渲染服务器和API服务器之间进行多次网络往返。我们设法并行化许多API调用,在其他情况下通过微妙的方法改进API,以完全不再需要多次请求。例如,最初包页面会首先获取包版本列表,确定哪个版本是最新版,然后获取相关的元数据,这样就需要多次请求。现在,我们已经对这些流程进行了优化,使得只需要进行单次请求即可获取所需的所有数据。这些优化措施包括并行化多个API调用,以及对API进行微妙的改进,从而完全消除了对多次请求的需求。这些努力使得我们的JSR网站在全球范围内加载速度更快,为用户带来更好的体验。

在研究 TTFB 之前,我们在呈现服务器和 API 服务器之间有大量的请求:

现在,我们一次性发出许多请求,并通过改善 API 的响应来减少调用的总数:

尽可能使用<form>

依赖于内置浏览器<form>提交可能看起来有些奇怪。(我们甚至在不寻常的地方使用<form>,比如在作用域成员列表旁边的"移除"按钮。)

然而,这样做可以显著减少我们需要编写、提供给用户并审核无障碍性的代码量。 无障碍性是网页开发的重要方面,而使用更多内置浏览器原语可以显著减少自己必须做的工作量,以获得良好的结果。

可访问性是 Web 开发的一个重要方面,使用更多的内置浏览器原语可以显著减少为获得良好结果而必须完成的工作量。

我有没有提到过所有这些都是开源的?JSR 前端可能是最容易贡献的部分:

bash 复制代码
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 连接。如果您在前端文件夹中进行更改,您的本地前端 jsr.test 将会自动重新加载。您甚至可以使用本地站点的副本管理来自生产 JSR 服务的范围和包!

构建现代发布流程(又名拒绝探测!)

我们已经讨论了如何在软件包上管理元数据(API 服务器),以及查看这些元数据(前端),但实际上 ------ 如果你不能发布到软件包注册表中,那么软件包注册表到底是什么?

构建一个现代的 JavaScript 注册表,必须与 npm 进行互操作,这意味着必须能够接受各种各样的软件包。模块作者应该能够发布:

  • 使用 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 publishdeno publish 时,发布工具将检查你的代码,探测 package.json、导入映射,以及你用于配置解析的任何其他内容,然后遍历你软件包中的所有文件,从你的 jsr.json 中的 "exports" 开始。然后找到任何需要探测才能解析的导入,并将它们重写为不需要探测的一致格式:

diff 复制代码
- import "./foo";
+ import "./foo.ts";
diff 复制代码
- import "chalk";
+ import "npm:chalk@^5";
diff 复制代码
- import "oak";
+ import "jsr:@oak/oak@^14";

所有这些都是在内存中进行的,软件包作者无需看到或知道正在发生的情况。这种本地代码转换为一致格式的魔法使得现代、灵活的发布体验成为可能,作者可以以任何他们想要的方式编写代码,用户可以以简单、标准化的方式使用软件包。

使用后台队列确保可用性

接下来,发布脚本将所有文件放入一个 .tar.gz 文件中。在让用户进行交互式身份验证发布后(这可能是一个完整的博客文章 👀),它会将 tarball 上传到 API 服务器。

然后,API 服务器执行一些初始验证,比如检查 tarball 是否小于 允许的 20MB,以及您是否有权限发布此版本。然后,它将 tarball 存储在一个存储桶中,并将发布任务添加到后台队列中。

为什么要将其添加到队列而不是立即处理 tarball?出于可靠性考虑。

可靠性的一部分是处理流量高峰。由于发布是一个需要大量 CPU 和内存资源的密集型过程,一个大型的单一仓库发布 100 个不同软件包的新版本可能会将整个系统拖慢。

因此,我们存储 tarballs,将它们放入队列中,然后后台工作人员会在可用时从队列中取出发布任务并处理它们。99% 的发布都会在提交后不到 30 毫秒内由后台工作人员完成。但是如果我们看到大量的峰值,我们可以通过稍微减慢处理来优雅地处理这些峰值。

当后台工作者接手一个发布任务时,它首先会解压 tarball 并检查其中的所有文件是否在我们的可接受限制范围内。然后,它验证 jsr.json 文件是否具有有效的 "name""version""exports" 字段。之后,我们构建整个模块的模块图,这有助于验证软件包代码。模块图,它映射了软件包中每个模块之间的关系,检查以下内容:

  • 你的代码是有效的 JavaScript 或 TypeScript
  • 你所有导入的模块都实际存在
  • 你所有的依赖项都有版本

对于大多数软件包来说,这一切都在 10 毫秒内完成。

自动生成文档并将模块上传到存储

验证完成后,我们根据这个模块图生成软件包的文档。这完全使用 Rust 中的 TypeScript 语法分析完成。结果上传到一个存储桶。我们将在某个时候写另一篇博客文章,介绍这是如何工作的,因为这非常有趣!

然后,我们将每个模块单独上传(完全按照它是什么,这里不会发生任何转换)到一个 modules 存储桶中。稍后这个存储桶将变得很重要,记得它!

将 TypeScript 转换为 .js.d.ts 文件以用于 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 命令被通知发布完成,并且软件包已经上线了。当你现在访问 jsr.io,更新的版本将出现在软件包页面上。

🚨️ 旁注 🚨️

我们对于能够从 .ts 源文件生成 .d.ts 文件而不使用 tsc 的能力非常激动。这是 TypeScript 正在积极鼓励的一点。例如,彭博社和谷歌与 TypeScript 团队合作,正在努力添加一个 isolatedDeclarations 选项到 TypeScript,这将使得在 TSC 之外进行声明发射变得非常容易。我们相信,很快许多其他工具也将具备生成 .d.ts 文件的能力,而无需使用 tsc

提供 100% 可靠性的模块服务

我已经绕了这么多弯了 ------ 这篇博客文章开始于我告诉你模块/npm tarball服务的可靠性非常重要。那么魔法酱料是什么呢?

实际上,没有魔法。我们使用非常无聊、非常成熟、非常可靠的云基础设施。

jsr.io 托管在 Google Cloud 上。 通过任播 IP 地址,由 Google Cloud L7 负载均衡器接受流量。 它终止 TLS。 然后检查路径、请求方法和标题,以确定请求应该转到 API 服务器、前端还是直接面向包含源代码和 npm tarballs 的 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 见!

相关推荐
JamesZhang800785 小时前
Chrome相关知识点
前端
jayson.h6 小时前
python——pdf编辑
前端·python·pdf
顶级自由人6 小时前
本地正常、线上正常,为什么一个 Hook 仍会报错?
前端·javascript·程序员
Android小行家6 小时前
Android APK 加固原理(五):SO `.text` 段加密、ELF 加载与运行时动态解密
前端
lerhxx6 小时前
R3F 第一人称漫游与碰撞检测:Pointer Lock + 不穿墙的滑墙秘诀(中)
前端·javascript·three.js
LSCLikeApple6 小时前
入门promise
前端
爬楼的猪6 小时前
DeepSeek Harness 本地安装和对话过程的网络问题
前端
labixiong6 小时前
button按钮原生开关弹窗,零 JS 搞定80%交互场景
前端·javascript·html
悟空瞎说6 小时前
Vue3 应用实例 API
前端
隔岸观火烧连营6 小时前
如何用 WebCodecs 在浏览器里实现高清录屏 —— 无插件、无水印、直接导出 MP4
前端·javascript