跳到內容

React Router v8 on Workers

查證日期
驗證環境react-router@8.3.0·@react-router/dev@8.3.0·@cloudflare/vite-plugin@1.50.0·vite@8.2.0·react@19.2.8·node@22.22.2·wrangler@4.118.0·compatibility_date: 2026-07-24

這是全系列時效性最強的一篇。

React Router v8 在 2026-06-17 GA。它移除了 AppLoadContext,而 AppLoadContext 正是 2025 年每一篇 Cloudflare + React Router 教學存取 binding 的方式 —— 包含 Cloudflare 官方的 framework guide,以及官方的 deploy button 範本。

這不是型別警告。實測結果是每一個請求都回 HTTP 500

本章要做的第一件事,就是把這個 500 跑出來給你看。


Cloudflare 官方 React Router 指南(頁面顯示最後更新 2026-06-19,也就是 v8 GA 之後兩天)現在仍然這樣寫:

// workers/app.ts —— 官方文件現行內容,在 v8 上會壞
export default {
async fetch(request, env, ctx) {
return requestHandler(request, {
cloudflare: { env, ctx },
});
},
} satisfies ExportedHandler<CloudflareEnvironment>;
// app/routes/home.tsx —— 官方文件現行內容,在 v8 上會壞
export function loader({ context }: Route.LoaderArgs) {
return { message: context.cloudflare.env.VALUE_FROM_CLOUDFLARE };
}

範例專案裡我在 Worker 入口留了一條探針路由,故意把那個普通物件傳下去:

if (url.pathname === "/__probe/plain-object") {
const res = await requestHandler(
new Request(new URL("/", url), request),
{ cloudflare: { env, ctx } } as unknown as RouterContextProvider,
);
return Response.json({ status: res.status, body: (await res.text()).slice(0, 200) });
}

實測輸出:

{
"status": 500,
"body": "Unexpected Server Error\n\nError: Invalid `context` value provided to `handleRequest`. You must return an instance of `RouterContextProvider` from your `getLoadContext` function."
}

這段檢查在 react-router@8.3.0dist/development/lib/server-runtime/server.js 裡是硬編碼的:

if (initialContext && !(initialContext instanceof RouterContextProvider)) {
let error = new Error("Invalid `context` value provided to `handleRequest`. ...");
handleError(error);
return returnLastResortErrorResponse(error, serverMode);
}

AppLoadContext 這個字串在 react-router@8.3.0 的整個 dist/ 裡出現 0 次。不是 deprecated,不是有 shim —— 是徹底移除。

本章存在的理由: React Router 官方的部署頁把 Cloudflare 的部分指向 Cloudflare 文件;Cloudflare 文件展示的是 v8 會拒絕的 v7 程式碼;Cloudflare 的 deploy button 範本目前還停在 React Router 7.9.6。而唯一正確的來源 —— npm create cloudflare 產生的程式碼 —— 沒有任何一頁文字說明它為什麼那樣寫。三個官方來源、三個不同答案,全都從同一頁連得到。


實測 npm registry:

套件版本備註
react-router8.3.0(2026-07-22)v8.0.0 GA 2026-06-17
@react-router/dev8.3.0peer: vite: ^7 || ^8wrangler: ^4(optional)
@cloudflare/vite-plugin1.50.0
vite8.2.0
react / react-dom19.2.8
react-router-dom7.18.2沒有 8.x,這個套件已被移除

react-router@8.3.0package.json 實測:

{
"type": "module",
"engines": { "node": ">=22.22.0" },
"peerDependencies": { "react": ">=19.2.7", "react-dom": ">=19.2.7" },
"exports": { ".": ..., "./dom": ..., "./internal": ..., "./internal/react-server-client": ... }
}

沒有 require 條件 —— v8 是 ESM-only。 這一點在 Workers 上有實際後果,24.9 會談。

Node 最低 22.22.0。Vite 最低 7(v8 的 changelog:「Require Vite 7+ and make the Vite Environment API build path mandatory」)。

⚠️ React Router 自己的升級指南在同一頁上自相矛盾:future.v8_viteEnvironmentApi 那一節還寫著「This is only available when using Vite 6+」,而三個畫面之上的 Minimum Versions 要求 Vite 7+。那句話是 v7 時代留下來的。


24.3 v8 存取 binding 的正確方式:完全不用 context

Section titled “24.3 v8 存取 binding 的正確方式:完全不用 context”

npm create cloudflare@latest -- --framework=react-router 現在產出的 loader 長這樣 —— 注意它根本沒有 context 參數

import { env } from "cloudflare:workers";
import type { Route } from "./+types/home";
export function loader() {
return { message: env.VALUE_FROM_CLOUDFLARE };
}

Worker 入口也只有一行:

import { createRequestHandler } from "react-router";
const requestHandler = createRequestHandler(
() => import("virtual:react-router/server-build"),
import.meta.env.MODE,
);
export default {
async fetch(request) {
return requestHandler(request);
},
} satisfies ExportedHandler<Env>;

requestHandler 的第二個參數不傳。沒有 declare module "react-router",沒有 getLoadContext

範例專案的 app/routes/home.tsx 用同樣的方式讀 D1,實測輸出:

{
"message": "hello from wrangler.jsonc",
"d1": { "one": 1 },
"bindingsSeenFromModuleImport": ["DB", "VALUE_FROM_CLOUDFLARE"]
}

D1 查詢在 loader 裡正常執行。這件事之所以成立,回到第 3 章的規則:

官方 bindings 文件:「Workers 不允許在請求 context 之外做 I/O。這代表即使 env 在頂層 scope 可存取,你也無法呼叫每一個 binding 的方法。」

loader 就在請求 context 裡,所以沒問題。但同一份 env 如果在模組頂層拿來呼叫 env.DB.prepare(...),會炸 —— 這正是第 1 章那個 crypto.randomUUID() 在 global scope 失敗的同一條規則。

這裡有一個必須誠實說明的地方: 沒有任何一頁官方文字說「v8 請改用 cloudflare:workers」。這個建議只存在於官方產生器產出的程式碼裡。所以本章的說法是「這是官方 CLI 產生的寫法」,而不是「文件這樣說」。


24.4 需要 ctx.waitUntilrequest.cf

Section titled “24.4 需要 ctx.waitUntil 或 request.cf 時”

import { env } 給不了兩樣東西:ExecutionContextrequest.cf。這兩個都是 per-request 的,模組層的 env 本質上拿不到。

需要它們時,才需要建立 context。v8 的正確做法是三步:

workers/app.ts
import { createContext, createRequestHandler, RouterContextProvider } from "react-router";
// 1. createContext 建立的是「有型別的鍵」,不是 provider 本身
export const cfContext = createContext<{
ctx: ExecutionContext;
cf: IncomingRequestCfProperties | undefined;
}>();
const requestHandler = createRequestHandler(
() => import("virtual:react-router/server-build"),
import.meta.env.MODE,
);
export default {
async fetch(request, env, ctx) {
// 2. provider 用 new 建立
const context = new RouterContextProvider();
// 3. set 進去
context.set(cfContext, { ctx, cf: request.cf });
return requestHandler(request, context);
},
} satisfies ExportedHandler<Env>;
app/routes/probe.tsx
export async function loader({ context }: Route.LoaderArgs) {
const cf = context.get(cfContext);
cf.ctx.waitUntil(logSomething());
return { colo: cf.cf?.colo ?? null };
}

實測 /probe 的輸出:

{
"contextIsProvider": "RouterContextProvider",
"hasWaitUntil": true,
"colo": "DFW",
"envKeys": ["DB", "VALUE_FROM_CLOUDFLARE"]
}

request.cf 在本地 wrangler dev 也有值(colo: "DFW")。

兩個容易講錯的細節:

  1. createRequestHandler 本身不吃 context。 它的簽章是 (build, mode?)。拒絕發生在它回傳的那個 handler 上:
    type RequestHandler = (request: Request, loadContext?: RouterContextProvider) => Promise<Response>;
  2. RouterContextProvider 不是「用 createContext() 建出來的」。 createContext<T>() 產生的是一個型別化的 key;provider 用 new RouterContextProvider() 建立,兩者是不同的東西。
需求做法
讀 KV / D1 / R2 / 環境變數import { env } from "cloudflare:workers"
ctx.waitUntil()createContext + RouterContextProvider
request.cf(國家、colo、TLS 資訊)同上,或直接在 route 裡用 request
測試時要注入假的 bindingcontext(依賴注入比模組 import 好測)

官方 bindings 文件自己也提了這個取捨:「雖然從 cloudflare:workersenv 比一層層傳遞簡單,但把 env 當參數傳是一個對依賴注入與測試有幫助的模式。」第 38 章講測試時會回到這一點。


v8 移除了 future.v8_middleware 旗標,middleware 現在是預設功能。實測 react-router@8.3.0 的 runtime export 裡沒有任何名稱含 middleware 的值 —— MiddlewareFunction 是純型別 export,所以只出現在 .d.ts 裡。沒有 unstable_ 前綴。

import { createContext } from "react-router";
import type { Route } from "./+types/probe";
export const beforeNext = createContext<string>("never-set");
export const afterNext = createContext<string>("never-set");
const timing: Route.MiddlewareFunction = async ({ context }, next) => {
const started = Date.now();
context.set(beforeNext, "set-before-next"); // loader 看得到
const res = await next();
context.set(afterNext, "set-after-next"); // loader 看不到,太晚了
res.headers.set("x-ch24-middleware-ms", String(Date.now() - started));
return res;
};
export const middleware: Route.MiddlewareFunction[] = [timing];
export async function loader({ context }: Route.LoaderArgs) {
return {
seenBeforeNext: context.get(beforeNext),
seenAfterNext: context.get(afterNext),
};
}

實測:

{
"seenBeforeNext": "set-before-next",
"seenAfterNext": "never-set"
}

回應標頭:x-ch24-middleware-ms: 4

await next() 是分界線。 之前的程式碼在 loader 之前跑,之後的在 loader 之後跑。這是 onion model,和你熟悉的 Hono middleware(第 5 章)語意一致。所以「認證」要寫在 next() 之前,「加標頭 / 記錄耗時」寫在之後。

middleware 與 loader 共用同一個 RouterContextProvider 實例,這就是 context 的主要用途:middleware set,loader get

執行順序是 parent → child 往下,child → parent 往上。route module 有兩個 export:middleware(server)與 clientMiddleware(client)。

官方文件有一句對 Workers 特別重要的說明:

「Document request 不論有沒有 loader 都會執行 server middleware,因為我們仍然在一個 handler 裡渲染 UI。Client-side navigation 只有在向伺服器發出 .data 請求(因為有 action/loader)時才會執行 server middleware。」


24.6 v8 改變的兩件事會影響你的 Worker 層

Section titled “24.6 v8 改變的兩件事會影響你的 Worker 層”

這兩點在 React Router 的 changelog 裡,但沒有任何 Cloudflare 文件提到,而它們直接影響你在 Worker 裡寫的路由與快取邏輯。

(一)request.url 現在是原始 URL,含 .data 後綴

Section titled “(一)request.url 現在是原始 URL,含 .data 後綴”

future.v8_passThroughRequests 成為預設。實測,對 /probe.data 發請求,loader 裡的 request.url 是:

http://localhost:9026/probe.data

不是 http://localhost:9026/probe

任何在 Worker 層用 request.url 做 pattern matching 的邏輯都要重新檢查 —— 快取鍵、A/B 分流、租戶解析。v8 給了 loader 一個新的 url 參數放正規化後的 URL,route 內部請用它。

實測:

GET /_root.data -> 404
GET /_.data -> 200

v8 把 root data 請求從 /_root.data 改成 /_.data,並新增 /path/_.data 形式。如果你在 Cloudflare 上設了任何比對 .data 的 Cache Rule、WAF 規則,或第 7 章的 _routes.json / run_worker_first glob,全部要更新。


import { reactRouter } from "@react-router/dev/vite";
import { cloudflare } from "@cloudflare/vite-plugin";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [
cloudflare({ viteEnvironment: { name: "ssr" } }),
reactRouter(),
],
resolve: { tsconfigPaths: true },
});

viteEnvironment: { name: "ssr" } 是官方明確為 v8 記載的:

「如果你把 Cloudflare Vite plugin 與 TanStack Start 或 React Router v8 一起使用,你的 Worker 會被用於 server-side rendering 並與框架緊密整合。為此你應該透過 viteEnvironment.name 把它指派到 ssr 環境…這會把 Worker 的環境設定與框架的 SSR 設定合併,並確保 Worker 被納入框架的建置輸出。」

cloudflare() 放在 reactRouter() 之前。所有官方範例都這樣寫,但沒有任何文件說這是必須的順序 —— 本章只陳述慣例。

兩個 2025 年教學會寫錯的地方:

  • vite-tsconfig-paths 已經不需要了,改用 Vite 內建的 resolve: { tsconfigPaths: true }
  • react-router.config.ts 裡不再有 future 旗標區塊。 實測 @react-router/dev@8.3.0FutureConfig 只剩 unstable_enableNodeReadableStreamunstable_optimizeDeps沒有任何 v8_* 旗標。預設範本的內容就只有 { ssr: true }

官方 static assets 文件:

「執行 vite build 時,會產生一個 wrangler.json 設定檔作為建置輸出的一部分。這個檔案裡的 assets.directory 欄位會自動填入你的 client 建置輸出路徑。因此不需要在你的輸入 Worker 設定裡提供 assets.directory 欄位。

實測 npx react-router build 之後,build/server/wrangler.json 的關鍵欄位:

{
"name": "ch24-react-router",
"main": "index.js",
"assets": { "directory": "../client" },
"no_bundle": true,
"rules": [{ "type": "ESModule", "globs": ["**/*.js", "**/*.mjs"] }],
"compatibility_date": "2026-07-24",
"compatibility_flags": ["nodejs_compat"],
"d1_databases": [{ "binding": "DB", ... }],
"vars": { "VALUE_FROM_CLOUDFLARE": "hello from wrangler.jsonc" }
}

輸出結構:

build/
client/
.assetsignore <- 自動產生,內容是 "wrangler.json" 與 ".dev.vars"
assets/...
server/
index.js <- 你的 Worker
wrangler.json <- 自動產生的部署設定
assets/...
.wrangler/
deploy/config.json <- {"configPath":"../../build/server/wrangler.json"}

三件事值得單獨記住:

  1. main 有兩個不同的值,這不是矛盾。 你手寫的 wrangler.jsoncmain 指向 TypeScript 原始碼(./workers/app.ts);產生的 build/server/wrangler.jsonmain 是建置後的 chunk(index.js)。
  2. no_bundle: true Vite 已經打包過,Wrangler 不可以再打包一次。
  3. .wrangler/deploy/config.json 是關鍵。 它讓 wrangler deploy 自動找到產生的設定檔。所以部署時不要加 -c wrangler.jsonc —— 那是輸入設定,main 指向 .ts,會失敗。
Terminal window
npm run build && npx wrangler deploy

API狀態取代
AppLoadContext移除(dist 裡 0 次)RouterContextProvider
cloudflareDevProxy()移除@cloudflare/vite-plugincloudflare()
@react-router/dev/vite/cloudflare整個 export 移除同上
react-router-dom套件移除(最後版本 7.18.2)react-router / react-router/dom
json() / defer()移除(實測 runtime export 裡沒有)data() 或直接回傳物件 / Response
future.v8_middleware 旗標移除middleware 永遠開啟
MiddlewareEnabled 型別移除不再需要
meta / useMatchesdata 欄位移除loaderData
hasErrorBoundary(route 物件)移除內部欄位

升級指南裡的 vite.config 遷移 diff:

import { reactRouter } from "@react-router/dev/vite";
import { cloudflareDevProxy } from "@react-router/dev/vite/cloudflare";
import { cloudflare } from "@cloudflare/vite-plugin";
export default defineConfig({
plugins: [
cloudflareDevProxy(),
cloudflare(),
reactRouter(),
],
});

json() / defer() 有一個文件陷阱: 它們是在 v7 的版本線裡被移除的,所以 v8 的升級指南與 changelog 完全沒有提到它們。從 v6 直接跳到 v8 的人不會收到任何警告,只會看到 json is not exported

@react-router/cloudflare@8.3.0 仍然存在、仍然在發布 —— 但它是 Cloudflare Pages Functions 的 adapter,不是 Workers 的。

它 export 的是 createWorkersKVSessionStorage 與一個型別為 PagesFunction<Env>、使用 EventContextcreateRequestHandler。Workers 這條路徑完全不會用到它,C3 範本的 dependencies 裡也沒有它。

如果有教學叫你 npm i @react-router/cloudflare 來做 Workers 應用,那是錯的。


官方 React Router 指南原文:

「使用 Cloudflare Vite plugin 時,目前不支援 SPA mode 與 prerendering。如果你想在 SPA 中使用 React Router,我們建議從 React 範本開始,並把 React Router 當成函式庫使用。」

不過這句話正在鬆動:@cloudflare/vite-plugin 已經有 experimental.prerenderWorker 選項(「這是實驗性功能,可能隨時改變或移除」)。所以這是「目前不支援的組態」,不是永久限制。

v8 是 ESM-only,而 workerd 也強制 ESM。這對只發 CJS 的第三方套件(MUI 是最常見的例子)是個問題。

有一個到 2026-08-01 為止仍然開啟的 issue(workers-sdk#14555,2026-07-05 開啟)描述了一個很惡毒的連鎖反應:CJS 套件會拋 require is not defined;用 noExternal: true 繞過,會把 React Router 拉進 Vite 的 dep cache,造成兩份 React Router 實例,於是 24.1 那個 instanceof RouterContextProvider 檢查失敗,又回到 500。

這一點沒有出現在任何官方文件裡。 如果你的 dashboard 打算引入大型 CJS UI 套件,先確認它有沒有 ESM 版本。

C3 產生的專案一定會帶 compatibility_flags: ["nodejs_compat"] —— 這是 C3 的 addNodejsCompatFlag()所有 JS/TS 專案無條件加上的,不是 React Router 特有的需求。

沒有任何官方文件說 React Router v8 需要 nodejs_compat 本章的範例保留了它(與範本一致),但這是「範本這樣做,關掉未經測試」,不是「必須」。

  • C3:scaffold 當天的日期(實測 getWorkerdCompatibilityDate() 就是 formatCompatibilityDate(new Date()),儘管 spinner 文字寫著「Retrieving current workerd compatibility date」,並沒有向 workerd 版本對齊)。
  • Wrangler autoconfig:文件範本寫 "$today"
  • 過時的官方 starter template:釘在 "2025-10-08"

複製 starter template 的人會拿到一個十個月前的 runtime baseline。第 2 章講過 compatibility_date 是 runtime 的版本鎖 —— 這不是小事。


把前面各章的 binding 接上來。目錄結構(第 26 章會把它變成正式的 monorepo):

apps/dashboard/
app/
root.tsx
routes.ts
routes/
_index.tsx 儀表板總覽
links.tsx 短網址列表(loader 讀 D1)
links.$slug.tsx 單一短網址 + 點擊分析(讀 Analytics Engine)
api.reports.$.tsx resource route,代理報表查詢
context.ts 共用的 context keys
workers/app.ts
wrangler.jsonc

app/context.ts 把所有 context key 集中在一處,避免 route 之間互相 import 造成循環:

import { createContext } from "react-router";
export const cfContext = createContext<{
ctx: ExecutionContext;
cf: IncomingRequestCfProperties | undefined;
}>();
export const sessionContext = createContext<{ tenantId: string; userId: string } | null>(null);

根 route 的 middleware 做認證,把 session 放進 context:

app/root.tsx
import { redirect } from "react-router";
import { env } from "cloudflare:workers";
import { sessionContext } from "./context";
import type { Route } from "./+types/root";
const auth: Route.MiddlewareFunction = async ({ request, context }, next) => {
const session = await readSession(env.KV, request);
if (!session && new URL(request.url).pathname.startsWith("/app")) {
throw redirect("/login");
}
context.set(sessionContext, session); // 一定要在 next() 之前
return next();
};
export const middleware: Route.MiddlewareFunction[] = [auth];

loader 拿 session 與 binding:

app/routes/links.tsx
import { env } from "cloudflare:workers";
import { sessionContext } from "../context";
import type { Route } from "./+types/links";
export async function loader({ context }: Route.LoaderArgs) {
const session = context.get(sessionContext);
if (!session) throw new Response("Unauthorized", { status: 401 });
const { results } = await env.DB
.prepare("select slug, url, created_at from links where tenant_id = ?1 order by created_at desc limit 50")
.bind(session.tenantId)
.all();
return { links: results };
}

注意 tenantId 來自 middleware 設定的 session,不是 URL 參數 —— 第 22 章談 SQL 注入時強調過同一件事。

官方沒有任何 React Router v8 + TanStack Query on Workers 的指引。 我確認過三件事:React Router 文件與 changelog 裡 “TanStack” 出現 0 次;Cloudflare 的 React Router 指南沒有提到;Cloudflare 的 TanStack Start 指南是另一個框架,與 React Router 無關。

所以以下是本章的原創建議,不是官方立場:

loader 負責首屏,TanStack Query 負責之後的互動。 兩者的職責不要重疊:

app/routes/links.tsx
import { useLoaderData } from "react-router";
import { useQuery } from "@tanstack/react-query";
export default function Links() {
const { links } = useLoaderData<typeof loader>();
// 首屏用 loader 的資料當 initialData,之後的輪詢/失效交給 Query
const { data } = useQuery({
queryKey: ["links"],
queryFn: () => fetch("/api/links").then((r) => r.json()),
initialData: links,
staleTime: 30_000,
});
return <LinkTable links={data} />;
}

一個 Workers 特有的地雷:絕對不要在模組層建立 QueryClient 第 1 章與第 3 章講過 isolate 會在多個請求之間重用,模組層的物件會跨請求共享 —— 一個模組層的 QueryClient 會讓 A 租戶的快取資料洩漏給 B 租戶。伺服器端如果需要 QueryClient,用 createContext<QueryClient>() 加上根 middleware 每個請求建一個新的。

另外,v8 的 splitRouteModules 現在預設為 true,這會改變 clientLoader 落在哪個 chunk。如果你在 clientLoader 裡做 hydration,注意這件事。


#結論影響
1v7 的 requestHandler(request, { cloudflare: {...} }) 在 v8 實測回 HTTP 500,body 為 Invalid \context` value provided to `handleRequest“這是 Cloudflare 官方指南現行展示的程式碼
2AppLoadContextreact-router@8.3.0 的 dist 裡出現 0 次完全移除,非 deprecated
3拒絕發生在 createRequestHandler 回傳的 handler 上,不是 createRequestHandler 本身簽章是 (request, loadContext?: RouterContextProvider)
4RouterContextProvidernew 建立;createContext() 產生的是型別化的 key兩者是不同的東西
5import { env } from "cloudflare:workers" 在 loader 裡實測可正常查 D1官方 CLI 產出的寫法,但無任何文字文件背書
6request.cf 在本地 wrangler dev 有值(實測 colo: "DFW"
7middleware 中 next() 之前的 context.set loader 看得到,之後的看不到實測 set-before-next / never-set
8runtime export 裡沒有任何含 middleware 的值MiddlewareFunction 是純型別 export
9.data 請求的 request.url 實測含 .data 後綴v8_passThroughRequests 成為預設,Worker 層路由要檢查
10/_root.data 實測 404/_.data 實測 200Cache Rule / WAF / _routes.json 都要更新
11vite build 產生 build/server/wrangler.jsonmain: "index.js"assets.directory: "../client"no_bundle: true不要手寫 assets 區塊
12.assetsignore 自動產生,內容是 wrangler.json.dev.vars避免把設定檔上傳成靜態資源
13.wrangler/deploy/config.json 指向產生的設定部署時不要-c wrangler.jsonc
14react-router@8.3.0 是 ESM-only(exports 無 require 條件)CJS 相依套件會出問題
15json() / defer() 實測不在 runtime export 裡,且 v8 升級文件完全沒提從 v6 跳 v8 的人不會收到警告
16react-router-dom 最後版本 7.18.2,沒有 8.x套件已移除
17@react-router/cloudflare@8.3.0 仍存在,但是 Pages Functions adapterWorkers 專案不該安裝它
18@react-router/dev@8.3.0FutureConfig 沒有任何 v8_* 旗標react-router.config.ts 只要 { ssr: true }
19Node ≥ 22.22.0、React ≥ 19.2.7、Vite ≥ 7(實測範本用 Vite 8.2.0)Vite 6 不再相容
20官方 starter template 仍是 React Router 7.9.6,compatibility_date 釘在 2025-10-08deploy button 與 CLI 產出互不相容

  1. 把範例的 /__probe/plain-object 路由拿掉 as unknown as RouterContextProvider,看 TypeScript 在編譯期就攔下來 —— 然後想想為什麼官方文件的程式碼在 v7 型別下不會報錯。
  2. middlewarenext() 之前之後各設一個 context,用 .data 請求(curl /probe.data)觀察與 document request 的差異。
  3. 寫一個 Worker 層的租戶解析邏輯,先用 request.url 做 pattern matching,然後對 /x.data 發請求,觀察它如何壞掉;再改用 loader 的 url 參數修正。
  4. 把 LinkForge dashboard 的 session 從 middleware 改成 import { env } 直接讀 KV,比較兩種寫法在第 38 章的測試裡各自有多好寫。

下一章(第 25 章):Astro on Workers。同樣有大版本斷層 —— @astrojs/cloudflare v14 移除了 Astro.locals.runtime.*,而 npm create cloudflare -- --framework=astro 目前產出的範本仍然塞入已不存在的 platformProxy