首页 / 文章 / 在 Node 和 Cloudflare Workers 上使用同一个 Astro 应用:配置中的易错点

在 Node 和 Cloudflare Workers 上使用同一个 Astro 应用:配置中的易错点

Node与Worker的双重Astro配置:React去重功能、Prisma边缘别名、Vite外部依赖处理、CI内存设置,以及Worker的fetch入口点。

1027 词

同一个代码库既可在 Node(Docker 自托管)环境中运行,也可在 Cloudflare Workers(边缘计算环境)中运行。共享的配置文件位于 astro.config.mjs 和 astro.config.cloudflare.mjs 旁边。

这种架构是可行的,但要实现它需要多次针对性的调试,每次调试都会发现一些仅 Cloudflare 环境需要的设置,而 Node 版本则完全不需要。入门教程很少会提及这些差异。

这两个配置文件有90%的相似度,而这正是问题所在

在单个配置文件中划分不同模块起初看起来很整洁,但当还需要另一个适配器、另一套外部依赖策略、另一份别名映射表,以及针对构建过程的不同内存设置时,结构就会变得混乱。用一段冗长的条件语句来处理所有这些内容,其可读性反而比使用两个独立文件还要差。

其代价在于维护工作:共享设置必须手动保持同步。React模块解析机制存在缺陷,这也是Cloudflare的文件中会保留警告注释的原因:

// Must mirror astro.config.mjs's React handling. Without dedupe the
// production Rollup client build resolves react-dom's internal react to a
// different chunk than the islands' react, yielding two React instances ->
// "Cannot read properties of null (reading 'useEffect')" when IslandHydrator
// calls createRoot().render() on a hooked component.

请记住:React的resolve.dedupe功能仅能保证正确性,无法提升代码质量。重复的React副本会在使用第一个hook时出错,且错误堆栈通常会指向你的组件而非打包器配置。

Prisma生成的客户端无法在workerd环境中解析

Prisma 7的客户端依赖诸如#main-entry-point这样的Node子路径导入方式,而针对workerd的Rollup构建无法解析这类路径。应直接将该模块别名指向边缘节点的入口点:

const PRISMA_CLIENT_DIR = path.dirname(require.resolve('@prisma/client/package.json'));
const PRISMA_EDGE_ENTRY = path.resolve(PRISMA_CLIENT_DIR, '../../.prisma/client/edge.js');
resolve: {
  alias: {
    '.prisma/client/default': PRISMA_EDGE_ENTRY,
  },
}

路径的生成方式很重要。从 Prisma 7.8 开始,生成的 .prisma/client/ 文件位于 @prisma/client 包中。在 pnpm 环境下,该路径会变成类似 .pnpm/@prisma+client@<hash>/node_modules/.prisma/client/ 的哈希化路径,而非固定的 node_modules 目录。在笔记本电脑上输入一次的路径,在其他不同的打包配置或存储哈希环境下往往无法使用。而从 @prisma/client/package.json 引用路径则可以克服这些差异。

外部依赖列表,以及一个刻意省略的条目

所有仅适用于 Node 的依赖都必须保留在 workerd 打包之外。许多项目只需要一个简短的排除列表即可:

const NODE_ONLY_EXTERNALS = ['ioredis'];

ioredis 是通过 isCloudflareRuntime() 后面的动态 import() 引入的,因此 Worker 永远不会获取该模块,从而确保了安全。

pg 被刻意排除在那个列表之外。@prisma/adapter-pg 会通过静态导入的方式引入 pg,而 PrismaPg 仍然在 Cloudflare Hyperdrive 路径上运行,所以驱动程序必须包含在代码包中。当开启 nodejs_compat 时,那个 TCP 客户端会借助 Cloudflare 的 Node 兼容层运行。如果将 pg 标记为外部模块,那么在 Worker 加载时就会出现 Uncaught Error: No such module "chunks/pg" 的错误。

一条重要的原则:只有当引入该依赖的每条路由都是动态且受控时,才将其视为外部依赖。 只要存在一处静态导入,就会让原本正常的构建过程在部署后出现故障,且更难排查原因。

适配器会覆盖你的外部依赖,需重新添加

这个问题是最难定位的。在astro:build:setup内部,@astrojs/cloudflare会强制设置vite.ssr.noExternal = true,并将vite.build.rollupOptions.external重置为['sharp']。因此你在ssr.external中设置的任何内容在Rollup启动前就会消失。

使用enforce: 'post'选项的Vite插件可以将外部依赖重新写回,从而解决此问题:

{
  name: 'autonnel:cf-extra-externals',
  enforce: 'post',
  config(conf) {
    const existing = conf.build?.rollupOptions?.external;
    if (Array.isArray(existing)) {
      conf.build.rollupOptions.external = [...new Set([...existing, ...NODE_ONLY_EXTERNALS])];
    } else if (typeof existing === 'function') {
      const existingFn = existing;
      conf.build.rollupOptions.external = (id, parentId, isResolved) =>
        NODE_ONLY_EXTERNALS.includes(id) || existingFn(id, parentId, isResolved);
    }
    // ...string / RegExp / undefined branches
  },
}

需要多个参数:external 可能已经是数组、字符串、RegExp、函数或 undefined,而且未来的适配器版本还可能再次发生变化。该插件旁的说明指出,一旦 @astrojs/cloudflare 停止覆盖 ssr.external,这个参数就应该不再存在——这只是为某个版本的行为而做的临时解决方案。

在 CI 环境中构建时内存不足,但在本地却正常

将所有 SSR 内容合并到一个 workerd 包中,导致内存使用量超过了 Node 的默认 2 GB 额度。在 Cloudflare 上的 CI 环境中,内存使用量在达到约 1.99 GB 时就出现了问题,而开发者的笔记本电脑却能正常完成构建——这确实是一类棘手的错误。

通过设置两个 Vite 参数便解决了这个问题:

build: {
  sourcemap: false,
  reportCompressedSize: false,
},

源映射占用了大量内存,而 Worker 会忽略它们。reportCompressedSize还会为每个数据块生成压缩后的副本,仅用于输出更美观的汇总表。在这个目标平台上,这两种做法都无法带来实际收益。

Worker 实现了 Node 所没有的两项功能

Node 会免费提供请求生命周期管理功能,而在 Worker 中则需自行实现:

export default {
  async fetch(request, env, ctx) {
    setRuntimeEnv(env);
    return runWithRequestDb(async () => {
      try {
        return await ssrHandler.fetch(request, env, ctx);
      } finally {
        ctx.waitUntil(disposeRequestDb());
      }
    });
  },
  async scheduled(_event, env) { /* ... */ },
};

由于 Worker 上不存在process.env,因此才有了setRuntimeEnv(env)这个函数。各种配置项会以处理程序参数的形式出现,因此访问配置需要通过针对每个请求的桥梁机制来实现。将 Node 服务移植到 Worker 上时通常需要修改大量文件;尽早搭建好这种桥梁机制,比之后到处寻找零散的process.env.FOO读取代码要高效得多。

ctx.waitUntil(disposeRequestDb())用于处理清理工作:在响应发送之后才释放数据库客户端。过早进行清理可能会导致仍在使用该客户端的流式输出功能无法正常工作。

是否值得重复使用双重目标?

是的——前提是第二个目标有明确的职责。Workers并非一个可以随意“到处部署”的功能开关。它会增加另一个具有不同故障模式的构建流程,而且这些故障大多会在部署阶段出现,而非测试阶段。

只要将不同配置限制在特定范围内——即两种配置加一个入口模块——工作量就能保持在可控范围。既然缓存、存储和数据库都已经通过适配器实现,领域代码就应该避免随意使用if (isWorkers)。如果缺少这些分隔机制,应在添加第二个运行时环境之前先创建它们。反之,则会将运行时检查功能引入代码提交流程和其他核心服务中。