这篇记录的是 Cloudflare Pages + Worker 的 service binding 该怎么配,以及为什么有些项目在本地能跑、上线后 context.env 却是空的。

先说结论

如果你的架构是:

  • Pages 负责前端静态站点

  • Workers 负责 API 和 /auth/*

  • Pages 通过 service binding 直接调用 Worker

那么要保证三件事:

  1. Pages 项目里要有 [[services]]

  2. 生产环境如果定义了 [env.production],那生产环境也要显式写 [[env.production.services]]

  3. Worker 的 name 必须和 Pages 里的 service 对上

这三项缺一项,Pages Function 里就拿不到绑定,表现为 context.env.WORKER 为空,或者你自己封装的 ctx.env.api 为空。


这类项目里各文件的职责

apps/web/wrangler.toml

Pages 项目的配置源。这里决定:

  • Pages 项目名

  • 静态产物目录

  • service binding

  • 各环境变量

apps/web/functions/[[path]].ts

Pages Functions 入口。它负责:

  • 拦截 /api/*

  • 拦截 /auth/*

  • 通过 service binding 把请求转发到 Worker

apps/api/wrangler.toml

Worker 项目的配置源。这里决定:

  • Worker 名称

  • 生产 / 开发环境变量

  • D1 / KV / R2 等绑定

  • 是否保留 workers.dev 默认地址


推荐目录结构

一个比较清晰的分工是:

apps/
  web/
    wrangler.toml
    functions/[[path]].ts
    src/
  api/
    wrangler.toml
    src/

其中:

  • web 只管 Pages 和转发

  • api 只管业务 API 和认证


Pages 侧配置

1. 顶层配置

apps/web/wrangler.toml

name = "react-hono-starter"
pages_build_output_dir = "dist"
compatibility_date = "2024-09-25"

# Bind to the API worker - proxies /api/* and /auth/* without a network hop
[[services]]
binding = "WORKER"
service = "react-hono-starter-api"

[vars]
# Public vars accessible in the browser

这部分的含义:

  • name 是 Pages 项目名

  • pages_build_output_dir 是构建输出目录

  • [[services]] 是 Pages Functions 可直接使用的 service binding

  • binding = "WORKER" 就是 context.env.WORKER

  • service = "react-hono-starter-api" 必须对应 Worker 的实际脚本名

2. 生产环境配置

如果你写了 [env.production],就要注意:

  • 某些 keys 是 inheritable

  • 某些 keys 是 non-inheritable

  • services 属于需要在环境里显式声明的那类配置

所以生产环境要再补一份:

[env.production]
vars = {}

[[env.production.services]]
binding = "WORKER"
service = "react-hono-starter-api"

这一步很关键。
我这次遇到的问题就是:顶层有 [[services]],但上线后 Bindings 还是空,原因在于生产环境配置没有把 services 明确写出来。


Pages Functions 入口

apps/web/functions/[[path]].ts

interface Env {
  WORKER: Fetcher;
}

export const onRequest: PagesFunction<Env> = (ctx) => {
  const { pathname } = new URL(ctx.request.url);

  if (pathname.startsWith('/api/') || pathname.startsWith('/auth/')) {
    if (!ctx.env.WORKER) {
      return new Response('Missing worker service binding', { status: 500 });
    }
    return ctx.env.WORKER.fetch(ctx.request);
  }

  return ctx.next();
};

这里有两个点:

  • ctx.env.WORKER 不是自动存在的,要靠 wrangler.toml 绑定

  • /api/*/auth/* 都是直接转给 Worker

也就是说,Pages Functions 不是业务逻辑本身,它只是一个转发层。


Worker 侧配置

apps/api/wrangler.toml

name = "react-hono-starter-api"
main = "src/worker.ts"
compatibility_date = "2024-09-25"
compatibility_flags = ["nodejs_compat"]
workers_dev = false

[dev]
port = 8013

这里最重要的是:

  • name = "react-hono-starter-api"

  • 这个名字要和 Pages 的 service 对上

  • workers_dev = false 只影响默认的 *.workers.dev 地址

下面这些绑定是 Worker 自己用的:

  • D1

  • KV

  • R2

  • 各类密钥和环境变量

它们不会直接影响 Pages 的 service binding,但会影响 Worker 能不能正常处理请求。


多环境配置怎么写

推荐思路

如果你有多个环境,最好把它们明确拆开:

  • production

  • dev

  • 或者像参考项目那样拆成 BACKEND_PRODBACKEND_DEV

单 Worker,多环境变量

适合现在这个项目的写法:

[env.production]
vars = { ENVIRONMENT = "production", IS_DEBUG = "false" }

[[env.production.services]]
binding = "WORKER"
service = "react-hono-starter-api"

开发环境也可以写:

[env.dev]
vars = { ENVIRONMENT = "development", IS_DEBUG = "true" }

[[env.dev.services]]
binding = "WORKER"
service = "react-hono-starter-api"

双 Worker,更清晰的写法

如果你想像 sso-serverless 那样区分生产 / 预览,可以写成:

[[services]]
binding = "BACKEND_PROD"
service = "react-hono-starter-api"

[[services]]
binding = "BACKEND_DEV"
service = "react-hono-starter-api-dev"

然后在 Pages Functions 里按 host 选择:

const backend = hostname.includes('localhost') ? env.BACKEND_DEV : env.BACKEND_PROD;
return backend.fetch(request);

这种写法更适合:

  • 生产和预览分开

  • 多域名部署

  • 需要明确区分环境的场景


workers_dev 有什么用

workers_dev 是 Worker 项目自己的开关。

开启时

Worker 会有一个默认的 *.workers.dev 访问地址。

关闭时

Worker 不再暴露这个默认地址。

它不影响什么

不决定

  • Pages 能不能看到 service binding

  • context.env.WORKER 是否存在

  • ctx.env.api 是否为空

什么时候建议关掉

如果你只想让 Worker 作为 Pages 的内部后端使用,不想多出一个公网入口,就可以关掉:

workers_dev = false

这能减少暴露面,也能让部署目标更明确。


为什么这次的问题会出现

这次真正的问题不是代码,而是 Pages 配置。

线上 react-hono-starter 的实际配置里,最开始没有把 service binding 带上,所以:

  • Pages Function 能运行

  • context.env 里没有 WORKER

  • 所以 /auth/me/auth/login 一进来就报缺绑定

修复方式是:

  1. 顶层声明 [[services]]

  2. 生产环境声明 [[env.production.services]]

  3. Worker 的 name 和 Pages 的 service 对齐

  4. 重新部署 Pages


最后给一个最小可用模板

apps/web/wrangler.toml

name = "react-hono-starter"
pages_build_output_dir = "dist"
compatibility_date = "2024-09-25"

[[services]]
binding = "WORKER"
service = "react-hono-starter-api"

[env.production]
vars = {}

[[env.production.services]]
binding = "WORKER"
service = "react-hono-starter-api"

apps/web/functions/[[path]].ts

interface Env {
  WORKER: Fetcher;
}

export const onRequest: PagesFunction<Env> = (ctx) => {
  const { pathname } = new URL(ctx.request.url);

  if (pathname.startsWith('/api/') || pathname.startsWith('/auth/')) {
    return ctx.env.WORKER.fetch(ctx.request);
  }

  return ctx.next();
};

apps/api/wrangler.toml

name = "react-hono-starter-api"
main = "src/worker.ts"
compatibility_date = "2024-09-25"
compatibility_flags = ["nodejs_compat"]
workers_dev = false

小结

这类 Pages + Workers 架构里,最容易踩坑的点不是代码,而是配置继承。

记住这三个关键词就够了:

  • service binding

  • env.production

  • workers_dev

前两个决定 Pages 能不能把请求转给 Worker,最后一个只决定 Worker 自己要不要暴露 workers.dev 默认域名。


本日志MD下载: https://blog-media.ropean.org/2026/06/efd544d8-ff24-4a6e-8ab4-1b22dec2df3d.md