Next.js Deploy on Cloudflare Pages 原理 (路由篇)
Next.js 部署到 Cloudflare 原理
路由
为了在 Cloudflare Pages 上为部署的 Next.js 应用程序提供服务,worker 需要能够准确地将请求指向正确的路由。Next.js 应用中的路由可以构建为需要在服务器上运行的函数,或者可以直接提供服务的静态文件。在这两种情况下,都有一系列步骤需要确定正确的目的地。
当构建应用程序时, Vercel CLI 会获取一个项目并生成一个定义应用程序如何提供服务的配置文件。这个配置文件在确定路由过程中每个阶段发生的事情时非常有用。
为了提供对 Next.js 应用程序的最佳支持, worker 需要尝试复制 Vercel 使用的路由过程。
路由阶段
为了解释路由如何工作,首先理解系统经历的基本阶段很重要。这些阶段取自 Vercel 构建输出配置(根据 Vercel 文档),并以 worker 可以使用的方式进行解释。
路由过程分为一系列不同的阶段。
- none (未指定阶段)
- filesystem
- rewrite
- resource
- miss
- error
- hit
每个阶段都以某种顺序处理,具体取决于前一阶段的结果。在不同的点上,某些配置选项被应用,最终目标可能会更新,或最终响应对象被修改。
在检查每个阶段的源路由之后,系统将检查构建输出以确定文件路径是否存在。如果它可以找到该路径的记录,它将运行命中阶段,然后跳出路由系统以向客户端返回响应。如果在路由期间发生错误(例如使用中间件),我们将进入错误阶段以检查相关的错误页面。
none
在路由过程开始时,通常有一系列源路由打算在任何其他路由之前处理。这些路由没有指定阶段,因此采用了名称 none。这样它们就可以被分组并像其他阶段一样被引用。
在此阶段,可能会发生许多事件。它处理的一些 Next.js 功能如下。
next.config.mjs
- 应用标头。
- 应用重定向。
- 应用 beforeFiles 重写。
中间件函数。
对 RSC 请求重写到 RSC 页面。
在检查构建输出中是否存在路由时,它只查找静态资产和非动态路由。
filesystem
如果在路由开始时(在 none 阶段之后)在构建输出中没有找到文件,系统将进入 filesystem 阶段。在这里,它将处理另一个 Next.js 功能。
next.config.mjs
- 应用 afterFiles 重写。
在此阶段,构建输出中唯一检查的路由是 afterFiles 重写产生的路由。
rewrite
在 filesystem 阶段之后,我们将检查所有可能的非动态路由。如果仍然没有找到匹配项,我们将进入重写阶段。在这里,我们使用正则表达式来查找动态路由的匹配项—— Vercel 构建输出配置指定将路径转换为带有搜索参数的文件名的源路由。
例如,以下源路由将把路径 /blog/hello-world 转换为 /blog/[slug]?slug=hello-world。从中,我们可以使用 /blog/[slug] 在构建输出中找到函数。
{
"src": "^/blog/(?<slug>[^/]+?)(?:/)?$",
"dest": "/blog/[slug]?slug=$slug"
}
重写发生后,会在构建输出中检查与动态路由的匹配项。
resource
这可以被认为是路由系统中倒数第二个阶段,因为它是将传入请求映射到有效路由的最后一点,然后再将其声明为未命中。
resource 阶段处理任何剩余的用户指定的重写,然后检查构建输出中的任何剩余路由。此外,它将尚未匹配的任何其他路由的状态设置为 404。
next.config.mjs
- 应用回退重写。
miss
此时,我们已经检查了所有可能的路由,没有一个匹配。这最终意味着在任何时候都无法在构建输出中找到该路径,因此我们需要向客户端返回 404 响应。
error
error 阶段是一个相当有趣的阶段,因为它似乎需要完全自定义的行为才能正常运行。它定义了一个带有状态码的源路由列表,通常用于将请求映射到自定义错误页面。
然而,不寻常的部分是,如果不改变默认行为,就没有明确的方法来区分应该匹配哪一个。我们必须将状态码属性视为我们试图匹配的东西,而不是将其视为应用于响应的东西。
hit
在匹配结束时,我们最终进入命中阶段。在这里,会应用其他标头,例如 x-matched-path 标头。无论匹配最终是否导致未命中,我们都会进入此阶段。
通常,当匹配导致未命中时,它将使用 /404 上的页面来处理响应。这仍然需要从命中文件应用相关的标头。
路由过程
现在我们对不同阶段有了更好的理解,我们可以来看看路由过程作为一个整体是如何工作的。为处理这个问题而设计的实际系统有点复杂,但它可以分解为一系列步骤。
在此过程中,有几次必须引入自定义行为。这在一定程度上是为了最大限度地支持我们的功能,但也是因为 Vercel 内部使用的路由系统没有那么多文档。我们的系统是根据我们对 Vercel 系统如何与 Vercel 构建输出一起工作的理解而定制构建的,用于提供请求服务。
路由过程的简化概述如下。
- 使用请求调用处理程序。
- 检查构建输出配置中的每个阶段。
- 检查阶段中的每个源路由是否匹配。
- 如果找到匹配项,请应用任何相关的响应修饰符。
- 确定下一步做什么;返回匹配项,或继续路由。
- 运行匹配的函数,或获取匹配的静态资产。
- 返回最终响应。
为了防止阶段检查失控,即无限地循环遍历阶段,我们有检查来确定我们是否处理了太多次阶段。这表明构建输出配置中的记录导致了无限循环,并且每次为请求运行路由器时计数器都会重置。
检查阶段
当我们收到请求时,路由器会从 none 阶段开始查找不同阶段中的匹配项。
对于阶段中的每个源路由,我们都会检查路由是否匹配,并处理相关细节(有关源检查的更多详细信息,请参阅"检查源路由"部分)。当我们从路由检查器收到响应时,如果遇到错误(通常是从运行中间件时),我们会中断路由过程并返回错误响应。否则,我们禁用路由继续的唯一其他时间是路由检查器返回最终匹配时。
在检查完当前阶段的所有源路由后,我们继续确定接下来会发生什么。如果当前阶段是 hit 阶段,或者路由在某个时候被重写为 URL,则路由完成,我们准备好提供响应。
如果当前阶段为 miss,我们检查构建输出,如果没有与请求的文件匹配的文件,我们将状态码设置为 404。之后,如果在输出中找到文件,或者当前阶段是 error,我们移动到 hit 阶段。重要的是,每次在构建输出中找到匹配项或当前阶段为 miss 或 error 时都要运行 hit 阶段,因为这允许我们使用当前匹配的路径更新响应的标头。
在所有其他情况下,我们尝试根据当前阶段检索下一个阶段,并向前迭代。
检查源路由
对于阶段中的每个源路由,我们需要检查匹配项并相应地处理它。
源匹配
这部分过程的第一步是检查路由的值是否与当前路径匹配。同时,这也提取任何动态参数。
检查路由是否匹配涉及验证请求方法是否相同,指定或未指定正确的字段,以及当前路径名是否匹配。
如果路由不匹配,我们只需跳过它(即它不涉及当前请求)。
覆盖
在不同的阶段,可能会将不同的内容应用于我们的响应对象,或修改当前请求的详细信息。我们首先检查的是(匹配的)源路由是否要覆盖当前的标头和状态码,如果是,则重置它们。
语言环境
为了支持国际化重定向,Vercel 构建输出配置包含将语言环境映射到子路径和域路由重定向的源路由。由于某些记录的格式,处理这些有点具有挑战性,因此涉及自定义行为。
当我们遇到语言环境重定向列表时,我们必须检查作为覆盖请求的 Accept-Language 标头的方式提供的任何 cookie 值。Cookie 和标头都必须解析为表示语言环境的形式,按质量排序。Vercel 认为 cookie 中的语言环境优先于标头。
如果我们找到与当前请求匹配的语言环境,我们会为其应用重定向并退出路由过程。同时,每当我们遇到具有 locale 属性的源路由时,我们都会存储语言环境列表,以便稍后在处理边缘情况时使用。
上面提到的自定义行为在处理子路径语言环境源路由时发挥作用。用于匹配请求的 src 值似乎只适用于 /,而不是 ^/$。这意味着我们必须检查 src 值是否是正则表达式,如果不是,则以另一种方式处理它,以确保我们在根目录中。这是因为自动语言环境检测应仅在应用程序的根目录中进行。
当启用国际化时,我们必须处理的一个边缘情况是在 miss 阶段。有时,构建输出配置具有将包含 /{locale} 的所有路径重写为 / 的记录,而不是 ^/{locale}$。这是一个重要的区别,因为它防止我们到达处理语言环境之后的所有内容的下一个源路由。因此,在 miss 阶段检查源路由时,我们必须使用之前存储的语言环境列表来检查当前 src 值是否是已知语言环境,如果是,则将匹配器更改为 ^/{locale}$ 而不是 /{locale}。
中间件
接下来,运行任何中间件。中间件发生在 none 阶段,即路由过程的最开始。中间件的响应标头被提取并应用于请求和响应对象(在适当的地方)。
此外,检查中间件是否为重写很重要,以便可以更新当前路径。
如果运行中间件失败,我们必须中止路由并进入 error 阶段,然后将错误响应返回给客户端。
标头和状态码
源路由能够指定要应用于响应的标头和状态码。这些在内部进行更新,以便可以将它们应用于最终响应对象。
构建输出配置可以提供两种不同类型的标头。有重要的标头和普通标头。源路由上 important 属性的存在表示标头应被视为重要。我们认为这表明标头应优先于最终响应对象上的所有其他标头。
目标
如果源路由想要将当前路径重写为指向不同的文件,它将指定目标属性。我们从(匹配)源路由中获取这样的目标,并将当前路径与它们一起替换,同时应用我们可能收集的任何搜索参数。
这是路由的动态参数作为搜索参数应用的地方。
在将当前路径更新到新目标时,需要额外的自定义解释以确保与RSC页面的兼容性。有时,构建输出可能不会生成RSC页面,以避免中断路由,我们宁愿重写路径并返回非RSC页面。
在其他情况下,构建输出配置可能会尝试将路径从^/重写为/index.rsc,当路径在/之后具有附加字符时。这似乎是应用此重写时的误判。为了确保只有索引页面受到此重写的影响,我们检查重写的路径是否替换了索引路径。
检查新目标
有时,路由可能会重写路径并要求在继续之前在文件系统中验证其存在。在 Vercel 文档中,它指出 check 属性应该触发文件系统和重写阶段。
这可能会带来一些问题。
请求应该被解释为请求 RSC 版本的页面需要进入 none 阶段以接收重写到 RSC 路径。但是,如果我们假设 check 应该将我们带回文件系统,而不是不在阶段的路由,我们将最终不支持 RSC 页面。
因此,当重写的路径发生变化且我们不在 miss 阶段时,我们将请求发送回 none 阶段,以便从头开始处理。
如果重写的路径与当前路径相同,我们选择在当前阶段不是 miss 时将请求发送到下一个阶段。对于 miss 阶段的自定义检查是必要的,以防止对 /_next/static/... 和 /_next/data/... 的请求的无限循环,因为它们可能在调用 check 时被重写为自己。如果我们在 miss 阶段,我们将状态代码更新为 404。
如果重写的路径不等于当前路径但当前阶段是 miss,我们将请求发送到文件系统阶段,如果路径不存在于构建输出中。如果它存在,我们只是重置状态代码,如果它之前是 404。这是为了处理 none 阶段中的重写与 miss 阶段中的重写相矛盾的情况,例如国际化(i18n)。
继续路由过程
在上述所有步骤完成后,检查路由的最后一步是检查路由是否希望我们继续匹配(它通过将其 continue 字段设置为 true 来实现)。如果不是,我们已经达到当前阶段的匹配结束。