跳到内容

把 Next 博客迁到 Cloudflare Workers:一次真实的升级和补锅记录

升级框架不难,难的是看清哪些事情应该发生在构建期,哪些事情绝对不能留到 Cloudflare Workers 运行时。

作者hopes75 分钟nextjs, react, typescript, cloudflare-workers, opennext, mdx, pnpm

先说结论

这次迁移表面上是一次技术栈升级:Node.js 22.22.2、pnpm 11、Next 16、React 19、TypeScript strict、Biome、Zod,再加上 OpenNext 和 Wrangler,把一个博客部署到 Cloudflare Workers。

但真正值得记下来的,不是版本号。

真正的问题有两个:

  1. Cloudflare Workers 运行时不能假设项目目录还在那里,所以不能在请求时 readdir content/posts。
  2. Workers 禁止字符串代码生成,所以不能在请求时用 next-mdx-remote/rsc 的 compileMDX。

这两个点都指向同一个原则:内容型网站尽量把内容发现、frontmatter 校验、Markdown/MDX 编译放到构建期。运行时应该拿已经准备好的数据渲染,而不是临时扫文件、临时编译文章。

升级本身其实挺顺

项目最后固定成了这一套:

{
  "engines": {
    "node": ">=22.22.2"
  },
  "packageManager": "pnpm@11.0.9"
}

核心版本也到了新的主线:

{
  "next": "16.2.6",
  "react": "19.2.6",
  "react-dom": "19.2.6",
  "typescript": "^6.0.3",
  "@biomejs/biome": "2.4.14",
  "zod": "^4.4.3"
}

TypeScript 继续开 strict: true,lint 交给 Biome:

{
  "scripts": {
    "lint": "biome check .",
    "typecheck": "tsc --noEmit"
  }
}

这些改动不算刺激,但它们很重要。依赖升级以后,项目需要一个稳定的基本盘:同一个 Node 版本、同一个包管理器、同一个格式和 lint 入口。否则后面排查部署问题时,很容易分不清到底是代码问题、依赖问题,还是环境问题。

第一处坑:Cloudflare 部署不是直接跑 next build

最早的部署日志里,next build 已经成功了,但 npx wrangler deploy 进入了一个自动迁移流程:它发现这是 Next 项目,于是想安装 Wrangler、创建配置、跑 OpenNext migrate。

这在本地交互式终端里可能没问题,但在 Cloudflare 的非交互构建环境里就很危险。pnpm 11 又正好更严格,遇到了这个错误:

ERR_PNPM_IGNORED_BUILDS Ignored build scripts: workerd
Run "pnpm approve-builds" to pick which dependencies should be allowed to run scripts.

部署环境不能停下来等你手动选。这个问题的处理方式也很明确:不要让部署命令临时“猜”项目该怎么部署,把配置提交进仓库。

现在项目里有明确的 wrangler.jsonc:

{
  "name": "hopes",
  "main": ".open-next/worker.js",
  "compatibility_date": "2026-05-09",
  "compatibility_flags": ["nodejs_compat"],
  "assets": {
    "binding": "ASSETS",
    "directory": ".open-next/assets"
  },
  "observability": {
    "logs": {
      "enabled": true,
      "invocation_logs": true
    },
    "traces": {
      "enabled": false
    }
  }
}

OpenNext 的配置也很薄:

import { defineCloudflareConfig, type OpenNextConfig } from '@opennextjs/cloudflare'

export default {
  ...defineCloudflareConfig(),
  buildCommand: 'pnpm next:build',
} satisfies OpenNextConfig

这里的思路是:构建过程由项目自己定义,Wrangler 只负责部署已经生成好的 Worker。

第二处坑:Workers 里没有你的 content/posts 目录

第一次部署后,页面访问失败,日志里有一句很直接:

no such file or directory, readdir '/bundle/content/posts'

这句话基本把问题说完了。原来的内容模块在运行时读取 content/posts,本地 Node 环境当然没问题;但到了 Workers bundle 里,请求时已经没有那个源码目录可以扫了。

于是内容索引被前移到构建期:

const files = (await fs.readdir(postsDir))
  .filter(filename => filename.endsWith('.md') || filename.endsWith('.mdx'))
  .sort((a, b) => a.localeCompare(b))

脚本读取 MDX 文件、解析 frontmatter 和正文,然后写入:

lib/generated/content-index.json

运行时只 import 这个 JSON:

import generatedContentPosts from './generated/content-index.json'

这个变化让部署稳定了很多。它不是一个华丽改造,但边界变清楚了:文件系统属于构建期,Worker 请求属于运行时。

第三处坑:MDX 编译里藏着 new Function

修完 readdir 后,文章列表能打开,但所有文章详情页还是 500。Cloudflare 日志给了关键线索:

Code generation from strings disallowed for this context
at new Function (<anonymous>)
at compileMDX

这一步挺容易误判。因为本地 Next dev 里文章详情页是正常的,next build 也能过。问题只在 Workers 运行时暴露,因为 Workers 不允许 eval / new Function 这类字符串代码生成。

当时详情页里还在请求时调用:

compileMDX(...)

所以每次访问文章,Worker 都会临时编译 MDX,然后撞上运行时限制。

最后的修法还是同一个方向:继续把事情前移。content:generate 不只生成 frontmatter 和正文,也把文章内容提前转成 HTML:

const markdownProcessor = remark()
  .use(remarkGfm)
  .use(remarkToc, { heading: 'h2' })
  .use(remarkRehype)
  .use(rehypeSlug)
  .use(rehypeHighlight)
  .use(rehypeStringify)

详情页不再运行时编译 MDX,而是渲染构建期生成的 contentHtml。同时移除了不再使用的 next-mdx-remote,避免以后又不小心把同一个问题带回来。

这一步以后,Worker preview 里的博客列表、旧文章详情页、新文章详情页都能正常返回 200。

顺手补上的内容规范

这次还补了几篇文章的 publishedAt frontmatter,并把规则写进了 BLOG.md。

原因也很实际:内容索引读取的是 publishedAt。如果文章只有 date,就可能出现 frontmatter 校验失败,文章也可能不进入列表。

现在至少有几条规则是明确的:

  • publishedAt 必须写,推荐 "YYYY-MM-DD"。
  • locale 只能是 "zh" 或 "en"。
  • categories 和 tags 用字符串数组。
  • 文件名建议是 <slug>.<locale>.mdx。

这些规则写给人看,也写给未来的自动化流程看。内容站最怕“看起来只是少了一个字段”,最后排查半天才发现列表没收录。

最后验证了什么

最终我用这些命令收口:

pnpm lint
pnpm typecheck
pnpm build
npx wrangler deploy --dry-run

另外还用 OpenNext 的本地 Worker preview 直接访问了几个路径:

/zh/blog
/zh/blog/multica-shared-human-ai-board
/en/blog/multica-shared-human-ai-board

要看的不是页面“应该可以”,而是 Workers 运行时真的不会再报:

Code generation from strings disallowed for this context

这次真正学到的东西

我以前也知道 Cloudflare Workers 不是普通 Node 服务器,但这次迁移把这个差异压得很实。

在 Node 里很自然的事情,比如扫目录、读本地文件、请求时编译 MDX,到了 Workers 上就要重新判断:它是构建期工作,还是运行时工作?

我的经验是,内容型博客可以尽量保守一点:

  • 构建期扫描文章。
  • 构建期校验 frontmatter。
  • 构建期编译 Markdown/MDX。
  • 运行时只读 bundle 里的数据。

这会少一些“动态”的弹性,但换来的是更少的线上惊喜。对一个博客来说,这笔交易挺划算。

最后还有个小提醒:迁移成功不代表文章和样式就自然好看。渲染方式从 MDX React components 换成 HTML 后,原来组件里的 Tailwind class 不会自动跟过去,.prose 样式也要跟着补齐。

框架升级是一半,内容体验是另一半。博客嘛,最终还是要让人愿意读。

#nextjs#react#typescript#cloudflare-workers#opennext#mdx#pnpm

相关文章