普通视图

发现新文章,点击刷新页面。
昨天 — 2026年4月17日首页

pnpm monorepo 下,如何把 Next.js 应用里的稳定内核拆成内部 workspace 包

作者 倾颜
2026年4月16日 16:18

在一个 Next.js 应用里,当某些模块越来越稳定、越来越可能被复用时,什么时候应该把它们拆成 packages/* 里的内部 workspace 包?

我在 AI Mind v0.0.10 里处理的,就是这样一个问题。

先简单介绍一下这个项目。AI Mind 不是一个一次性做完的 AI 产品,而是一个按版本持续演进的 AI Native Runtime Skeleton。它从本地聊天闭环出发,逐步长出结构化流式协议、Tool Calling、Skill Runtime、MCP 接入,以及后面的 Agent / 数据层能力。

ai-1.gif

当前主应用在 apps/webapp。到 v0.0.10 为止,这个项目已经能跑一条比较完整的聊天链路:请求从 /api/chat 进入,经过 chat-service 和 runtime 编排,再去衔接 skill、tool、MCP,最后以前端可消费的流式 chunk 返回。

也正因为这条主链已经逐渐跑稳,一个更具体、也更工程化的问题才会冒出来:当某一层能力已经明显稳定、也明显可能复用时,我们到底应该什么时候把它拆成 packages/* 里的内部 workspace 包?

这正是 v0.0.10 的主题。 这一版我没有一上来就把整个 Chat Runtime 抽出去,也没有为了 monorepo 先做一个“大而全”的基础包,而是先在 apps/webapp 内把聊天主链收口,再只把真正稳定的流式内核沉成 @ai-mind/stream-core

所以这篇文章不会从 pnpm monorepo 的基础配置讲起,也不会把重点放在“我又拆了一个包”。我更想复盘的是一次真实项目里很常见、也很容易做重的工程判断:

  • 什么样的代码,才值得先拆成内部 workspace 包?
  • 为什么在拆包之前,我们最好先把应用内 Runtime 的边界做稳?

先看结论

拆包不是目标。先把应用内边界收稳,再把已经跑稳的那一小块内核沉淀出来,拆包才会真的带来收益。

apps/webapppackages/stream-core 的结构示意图

v0.0.10-stream-core-cover-01.png


1. 为什么这次拆包不是从 package 开始,而是从 Runtime 收口开始

1.1 拆包不是目标,稳定边界才是目标

真正值得优先解决的,不是“怎么拆包”,而是“边界是不是已经稳了”。

在工程里,拆包本身并不天然代表结构更好。目录拆得更细,也不等于边界就更清楚。真正关键的是,我们能不能先回答下面这几个问题:

  • 这一层的语义是不是已经稳定了?
  • 它是不是已经不再强依赖当前应用里的业务编排?
  • 如果现在把它抽出去,边界会更清楚,还是只会多一层跳转?

如果这些问题还没想明白,拆包通常不会减少复杂度,只会把复杂度换个目录继续保存。

边界没稳时,抽出去的往往不是“可复用内核”,而是一份还在变化中的局部实现。它带来的结果通常也不难想象:

  • app 内还得持续频繁修改它
  • 对外接口会跟着反复抖动
  • 主链职责没有更清楚,反而多了一层跨目录理解成本

所以对我来说,拆包的前提不是“能不能拆”,而是“是不是已经稳到值得拆”。

1.2 这个问题是怎么在我的项目里出现的

AI Mind 当前是一个 Next.js + pnpm monorepo 的 AI Webapp,主应用在 apps/webapp

到这一轮之前,仓库层面的 monorepo 形态其实已经在了,但聊天主链里不少核心逻辑仍然集中在 app 内部。换句话说,目录先搭起来了,Runtime 的边界却还没有完全长清楚。

所以我要解决的,本质上不是“怎么把 monorepo 配起来”,而是“在已经存在的 monorepo 里,哪些东西真的成熟到值得沉淀成内部 workspace 包”。

如果只看目录变化,这一版像是做了两件事:

  1. chat-service 拆薄
  2. 新建了 packages/stream-core

但从工程演进角度看,它们其实是一件事的前后两步:

先把 apps/webapp 里的聊天主链收口成“薄 facade + runtime 编排层”,再把其中已经稳定的流式内核沉淀成内部 workspace 包。

也正因为先做了前一步,后面“到底什么值得拆”这件事才开始变得清楚。

我最后把这版真正要回答的问题,收成了两个判断:

  1. 聊天主链内部边界是否已经足够清晰?
  2. 哪一部分能力已经稳定到值得从 app 内部沉淀成包?

如果这两个问题不先回答,所谓 package 化就很容易退化成“目录迁移”,而不是一次真正有价值的结构升级。


2. 第一步:先把应用内 Chat Runtime 收口出来

2.1 为什么 chat-service 不能继续变胖

这版真正先动的,不是 package,而是 chat-service 这个入口层。

在一个聊天应用里,chat-service 很容易不断吸收新职责:

  • prompt 构建
  • planning / retry / final answer
  • tool / resource 执行
  • chunk 写出
  • 错误收口

短期看这样很方便,因为所有逻辑都能往一个地方放。长期看,它会慢慢变成一个很典型的“胖服务层”:

  • 外部入口和内部编排耦在一起
  • 测试越来越难写
  • 边界越来越难拆

所以 v0.0.10 的第一步不是抽包,而是先把这个入口层重新收回到它该有的位置。

2.2 我怎么把聊天主链收口成“薄 facade + runtime 编排层”

我最后把主链收成了一个更容易解释、也更容易继续演进的结构:

route
  -> chat-service facade
    -> runtime
      -> skills / tools / mcp

对应实现大致分布在这些位置:

  • apps/webapp/app/api/chat/route.ts(聊天 API 入口,负责 HTTP 边界和错误映射)
  • apps/webapp/lib/ai/chat-service.ts(聊天服务 facade,负责对外暴露稳定入口和包装响应)
  • apps/webapp/lib/ai/runtime/(聊天运行时编排层,真正组织 planning、tool 调用和最终回答)

chat-service 现在的角色已经很克制了,它不再承接整条链路的所有细节,而是只负责稳定入口和响应包装:

export function createChatService(deps: ChatServiceDependencies) {
    return {
        async streamChat(request: ChatRequest, context: ChatExecutionContext) {
            const streamResult = await createChatStreamResult(request, context, deps)

            return new Response(streamResult.body, {
                headers: streamResult.headers,
            })
        },
    }
}

这段代码很小,但它表达出来的边界很重要:对外入口留在 facade,真正的运行时编排收回 runtime。

2.3 Runtime 收口后,内部职责怎么重新分配

主链一旦收口,runtime 内部的职责也就开始变清楚了。

当前核心文件主要包括:

  • chat-session.ts(按请求组装会话上下文、模型实例、active tools 和 system prompts)
  • chat-orchestrator.ts(决定 direct-answer、planning、tool-execution、final-answer 这些阶段怎么串起来)
  • assistant-stream.ts(消费模型输出流,把 reasoning / text 等内容写成标准 chunk)
  • tool-runtime/(承接 tool call 的校验、执行,以及 Tool / Resource 展示信息映射)
  • authoritative-answer.ts(判断单工具确定性结果是否可以跳过模型、直接静态回流)

这一节最重要的,不是把文件列出来,而是让我们能明确看见:谁负责外部入口,谁负责运行时编排,谁负责具体执行。

只有当应用内 Runtime 自己先变清楚了,我们才看得见两件事:

  • 什么是真正稳定的内核
  • 什么仍然属于当前应用的编排层

这一步做完以后,后面的拆包判断才不再靠感觉,而是可以基于已经清楚的职责边界来做。


3. 第二步:怎么判断哪些代码才算“稳定内核”

3.1 我给自己用的一组拆包判断标准

这次我给自己定的标准很简单,但非常实用:

  • 语义是否稳定
  • 是否与业务策略弱耦合
  • 是否跨层复用明显
  • 是否具备独立测试价值
  • 是否可以单独 build / typecheck
  • 是否值得被多个 app / 模块消费

只要前面几条还答不清楚,我通常就不会急着拆。

3.2 适合先拆出去的,不是“最大的一块”,而是“最稳定的一块”

这次我很想留下来的一个判断是:先拆出去的,不一定是最大的那块,而应该是最稳定的那块。

很多时候我们天然会盯着最大的模块:

  • 最大的 service
  • 最大的 runtime
  • 最大的 orchestration

但大的东西,往往也是变化最多、业务语义最重的东西。

这次真正适合先拆出去的,反而不是最大块,而是最稳定的一块:

  • 流式协议
  • 生命周期
  • 错误 chunk
  • static writers
  • NDJSON writer

它们不大,却已经足够清楚、足够独立,也足够值得被当成一层内核看待。

3.3 用项目举例:哪些东西我认为还不该拆

先说我明确不打算在这一步就拆出去的部分。

  • chat-orchestrator(负责 planning、tool 执行、authoritative answer 和 final answer 的阶段编排)
  • chat-session(负责按当前请求组装模型、messages、skill prompt 和 active tools)
  • tool-runtime(负责 tool call 校验、执行,以及 Tool / Resource 展示信息映射)
  • Skill 编排(决定当前请求命中哪个 skill,以及这个 skill 允许使用哪些工具)
  • MCP 消费层(把外部 MCP Tool / Resource 接到当前 runtime 和展示语义上)

原因很直接:它们仍然带有明显的应用内语义和业务编排特征。

这些模块继续留在 apps/webapp,反而是更清晰的选择。

3.4 用项目举例:哪些东西已经足够稳定

再看另一边。下面这些内容,已经很接近一层可以单独沉淀的稳定内核:

  • ChatStreamChunk(定义整条流式协议里有哪些 chunk,以及每种 chunk 带什么字段)
  • StreamLifecycle(约束 start / finish / runtime error 这些生命周期终态只发一次)
  • error chunk helper(统一生成和写出 error chunk)
  • static text / reasoning writers(把静态文本或推理内容写成标准流式 part)
  • NDJSON web writer(把 chunk 序列编码成前端可消费的 NDJSON 响应体)

它们的共同点也很明显:

  • 不直接携带业务策略
  • 语义稳定
  • 本身就值得独立测试
  • 很容易被别的 app 或 service 复用

这就是 stream-core 最终被抽出来的基础。


4. 为什么最后拆出来的是 stream-core

4.1 我没有先拆 runtime-core,也没有拆整个 chat runtime

这是很多人看到目录变化之后,第一反应会问的问题:

“既然已经有 runtime 了,为什么不直接抽一个 runtime-core?”

原因很简单:今天的 runtime 还不是一块可以稳定复用的内核,它仍然包含大量应用级判断:

  • planning 阶段怎么走
  • tool 结果什么时候可以直出
  • skill / tool / mcp 怎么组合

这些东西现在抽出去,只会把编排层也一起包化。

4.2 stream-core 代表的是一块已经稳定的流式内核

真正被我拆出去的,不是一个“大 runtime”,而是一块已经跑稳的流式内核。

它的稳定主要体现在几件事上:

  • 协议已经比较稳定
  • 生命周期已经比较稳定
  • writer 的职责已经比较稳定
  • 和具体业务编排之间是弱耦合关系

StreamLifecycle 就是一个很典型的例子:

export class StreamLifecycle {
    private started = false
    private terminated = false

    emitStartOnce() {
        if (this.started || this.terminated || this.isClosed()) {
            return false
        }

        this.started = true
        this.writeChunk({
            type: 'start',
            messageId: createId(),
        })

        return true
    }
}

它不关心 skill、tool、MCP 这些上层语义,只关心流式生命周期本身是否被正确表达。这种代码,就很适合先沉淀下来。

4.3 stream-core 的职责边界是什么

这个包的边界其实非常克制,当前只放这些内容:

  • protocol
  • lifecycle
  • error chunk
  • static parts writer
  • web NDJSON writer

对应源码大致位于:

  • packages/stream-core/src/protocol/(定义 start / text / reasoning / tool / resource / error / finish 这些 chunk 类型)
  • packages/stream-core/src/core/stream-lifecycle.ts(统一处理流开始、结束和 runtime error 的终态收口)
  • packages/stream-core/src/core/stream-error.ts(统一创建和写出错误 chunk)
  • packages/stream-core/src/core/static-parts.ts(把静态文本或推理内容写成标准流式 part)
  • packages/stream-core/src/adapters/web/chunk-writer.ts(把 chunk 逐行编码成 NDJSON 并写进 Web ReadableStream

而这些内容我明确没有放进去:

  • orchestrator(聊天主链的阶段编排和策略判断)
  • session(按请求拼出模型上下文、messages 和 active tools)
  • tool runtime(工具校验、执行与展示映射)
  • skill / MCP 编排(当前应用里的能力路由和外部能力接入层)

因为它们今天仍然属于“应用内编排层”,还不是适合沉淀成公共内核的部分。

4.4 这一版拆包的核心取舍

如果把这一版的取舍压成一句话,我会这样说:

我不是为了让项目“看起来更像架构”而拆包,而是只把已经在应用内跑稳、边界也相对清楚的那部分流式内核,正式沉淀了下来。

这也是为什么它最终叫 stream-core,而不是一个一看就想把所有东西都装进去的名字。


5. 在 pnpm monorepo 里,把它真正落成内部 workspace 包

5.1 packages/stream-core 的目录与包名设计

这个包的目录和命名,我一开始就尽量做得很直白:

  • 目录:packages/stream-core
  • 包名:@ai-mind/stream-core

这个命名本身就在表达边界:它承接的是 stream core,不是整个 chat runtime。

5.2 为什么我给它做了清晰的 exports,而不是只有一个根入口

内部包也需要边界,不能先暴露一个大入口,后面再慢慢补救。

这次我给 stream-core 做了明确的 exports:

  • 根入口(暴露 stream-core 的核心能力)
  • ./protocol(只暴露流式协议类型)
  • ./web(只暴露面向 ReadableStream 的 NDJSON writer 适配器)

对应配置在 packages/stream-core/package.json

"exports": {
  ".": {
    "types": "./build/types/index.d.ts",
    "require": "./build/cjs/index.js",
    "import": "./build/esm/index.mjs"
  },
  "./protocol": {
    "types": "./build/types/protocol/index.d.ts",
    "require": "./build/cjs/protocol/index.js",
    "import": "./build/esm/protocol/index.mjs"
  },
  "./web": {
    "types": "./build/types/adapters/web/index.d.ts",
    "require": "./build/cjs/adapters/web/index.js",
    "import": "./build/esm/adapters/web/index.mjs"
  }
}

这样做的价值不只是“写得更正规”,而是让消费边界从一开始就足够明确:

  • 根入口给稳定基础能力
  • ./protocol 单独暴露协议类型
  • ./web 单独暴露面向 Web 流响应的适配能力

5.3 为什么我选择双产物构建,而不是只做单一格式

我没有把它做成一份“先能跑起来再说”的源码目录,而是直接按一个内部包去收它的产物形态。

当前 stream-core 输出的是三类产物:

  • build/cjs
  • build/esm
  • build/types

我更想强调的不是“格式有几种”,而是内部 workspace 包一旦开始承担复用职责,就应该被当成一个完整工程单元对待。

它不再只是 app 目录里被移动出去的一份代码,而是一层有明确导出、有独立产物、有自己工程边界的内部能力。双产物构建在这里也不是为了“看起来更像公共包”,而是为了先把内部消费形态收规整。

5.4 apps/webapp 是怎么接入这个 workspace 包的

让一个内部包真正落到应用里,不能只停在“把 import 改过去”。

这次 apps/webapp 的接入主要包括三件事:

  • 依赖用 workspace:*
  • Next.js 通过 transpilePackages 消费它
  • TypeScript 侧使用 moduleResolution: "bundler"

对应配置分别落在:

  • apps/webapp/package.json(声明 @ai-mind/stream-core 这个 workspace 依赖)
  • apps/webapp/next.config.ts(通过 transpilePackages 让 Next.js 正常消费内部包)
  • apps/webapp/tsconfig.json(通过 moduleResolution: "bundler" 对齐包导出解析方式)

这三件事放在一起,才算是“这个 workspace 包已经被当前应用稳定接入”。

5.5 拆成包以后,消费边界也要跟着收稳

目录拆开只是第一步,消费关系也必须跟着显式化。

所以这次除了目录和依赖本身,我也尽量把“它是一个独立工程单元”这件事落到日常约束里:包有自己的构建产物,有自己的导出边界,也有自己的验证责任。

这样一来,stream-core 不再只是“从 app 挪出去的一坨代码”,而是真正可以被稳定消费的一层内部能力。


6. 拆包以后,如何保持现有应用主链不被破坏

6.1 外部入口为什么要保持稳定

这次拆包里,我一直守着一个原则:外部入口尽量不动。

当前对外稳定入口仍然是:

  • createChatService().streamChat()
  • /api/chat

也就是说,底层内核在沉淀,但业务调用层的感知应该尽量保持稳定。

6.2 好的拆包,不应该让业务调用层感受到“地震”

我很认同一句话:真正好的拆包,是内部收口,外部少感知。

这次变化主要发生在内部:

  • chat-service 回到了 facade 角色
  • runtime 的职责更清楚了
  • stream core 被正式沉淀到了 workspace 包

而边界以上的消费方式尽量保持不变,这样拆包才是在降低演进成本,而不是把改动面放大。

6.3 这次拆包对前端消费语义有什么影响

对前端来说,这次最关键的不是“代码搬家了”,而是消费语义没有被破坏。

前端仍然消费同一套流式内容:

  • reasoning
  • tool
  • resource
  • text
  • 统一 error chunk

变化发生在底层:这些协议和 writer 能力,现在由 @ai-mind/stream-core 来承接。

也正因为如此,这次拆包带来的不是“前端协议换了一套”,而是“协议终于有了更明确的归属层”。


7. 为什么真正的拆包,不会只停在目录和 import 上

7.1 测试目录为什么要统一到 tests/**

测试目录统一看起来像小事,但它本质上也是边界收口的一部分。

当前 webapp 侧统一到:

  • apps/webapp/tests/**(webapp 主链和前端消费相关的自动化测试)

package 侧独立到:

  • packages/stream-core/tests/**(stream-core 作为内部包的独立单测)

这样做的价值很直接:

  • app 侧测试边界清楚
  • package 侧测试边界清楚
  • 扫描规则清楚

同时,我也补了位置校验脚本,避免测试文件再慢慢散回业务目录。

7.2 一个内部 workspace 包,也应该有自己的 test / typecheck / build

这是我这次很在意的一点,因为这直接决定它是不是一个真正成立的包。

如果一个内部包没有自己的 test / typecheck / build,那它往往还只是“被搬出去的代码”,还称不上真正的工程单元。

packages/stream-core 现在已经有自己独立的:

  • build
  • typecheck
  • test

这会让后面继续演进它的成本低很多。

7.3 为什么文档资产也要一起更新

代码边界变了,文档边界也要跟着一起变。

所以跟着一起更新的内容包括:

  • plan(记录这版的目标、非目标和关键取舍)
  • tasklist(记录这版具体落地了哪些工作)
  • runtime note(解释聊天主链现在的运行时边界)
  • release(总结版本最终结果)
  • architecture note(沉淀跨版本仍然有效的结构判断)
  • blog material(把实现取舍整理成对外可讲的内容)
  • README(同步仓库当前状态和结构)

这样以后再回头看这版,不会只看到代码改动,还能看到当时的判断、边界和取舍是怎么形成的。


8. 我从这次拆包里得到的 4 个结论

8.1 先在应用内收口边界,再拆包

应用内边界都还没稳的时候,包化通常不会让结构更清楚。

8.2 先抽稳定内核,不急着抽业务编排层

最值得先抽出去的,往往不是最大块,而是最稳定、最独立、最少业务语义的那一块。

8.3 拆包不是为了“更像架构”,而是为了更低成本地演进

如果拆完以后每次修改都更困难,那这个包就没有真正帮我们降低复杂度。

8.4 pnpm monorepo 最适合承载“先验证、再沉淀”的内部架构演进

对我来说,pnpm monorepo 最大的价值不是目录看起来更专业,而是它非常适合承接一种克制的演进方式:

先在 app 内验证边界,再把已经跑稳的那部分自然沉淀成内部 workspace 包。


9. 结尾:我为什么觉得这次拆 stream-core 是值得的

9.1 它让我更清楚地看见了 Runtime 的边界

这次最直接的收获,不是仓库里多了一个包,而是 Runtime 的边界终于能被更清楚地说出来。

做完这次拆分之后,我能更明确地区分:

  • facade 在哪
  • runtime 编排层在哪
  • 稳定流式内核在哪

这比“多了一个 package”本身更重要。

9.2 它不是平台化,而是一次克制的沉淀

我很看重这次的一点,是它足够克制。

这次我没有把整个 chat runtime 一口气打成一个“大而全”的基础包。

我只是把已经在应用里跑稳、边界也相对清楚的那部分流式内核,正式沉淀了下来。

我很看重这种节奏。它不是过度设计,而是一种更克制、也更容易继续演进的沉淀方式。

9.3 后面哪些东西,我反而不会急着拆

也正因为这次我更看重“克制”,所以有些东西我反而不会急着拆。

至少在当前阶段,下面这些内容我不会急着拆出去:

  • chat-orchestrator(聊天主链的阶段编排和策略判断)
  • chat-session(按请求组装模型上下文、messages 和 active tools)
  • tool-runtime(工具校验、执行与展示映射)
  • 业务策略层(和当前产品问答体验强绑定的策略判断)

因为它们今天依然带有明显的应用内语义。

如果现在就急着把这些内容一起包化,只会把还在变化中的编排层也一并固化,反而失去边界。

如果用一句话收住这篇文章,我会这么写:

对我来说,这次拆包的意义,不是“多了一个 package”,而是第一次把“应用内已经跑稳的稳定内核”正式沉淀了下来。


项目地址

GitHub: github.com/HWYD/ai-min…

如果这篇文章刚好对正在处理类似 Runtime / monorepo 拆分问题的同路人有一点参考价值,欢迎来仓库里看看。
如果你也对这种按版本持续演进的 AI Runtime Skeleton 感兴趣,顺手点个 Star,也能让我知道这条路线确实对外部读者有帮助。
后面我也会继续沿着 Runtime、MCP、Agent 这些方向,把这套骨架一点点往前推。

开启 Cross-Origin Isolation 后,我的网站"社会性死亡"了

作者 DiffServ
2026年4月16日 11:08

最近在折腾 AudioWorklet + SharedArrayBuffer 的极致优化,被迫卷入了浏览器最底层的 Spectre 漏洞防御机制。MDN 说开启 COOP/COEP 是"最佳实践",Chrome 控制台也在疯狂警告——不开就用不了 SharedArrayBuffer。于是我就开了。

然后网站炸了。

OAuth 登录白屏。Google Analytics 静默死亡。CDN 图片全黑屏。不是 Bug,是隔离的物理代价。

如果你也在折腾 Next.js 性能优化或者 SharedArrayBuffer,这篇避坑指南可能会帮你省下 3 天的排查时间。


0. 动机

我在做 AudioWorklet + SharedArrayBuffer 的无锁通信。SAB 是唯一能让主线程和音频线程共享内存的原生方案——没有它,每帧都要 postMessage 序列化,延迟直接翻倍。

但 SAB 有个前提:浏览器要求页面必须开启 Cross-Origin Isolation。也就是在响应头里加上:

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

MDN 说这是"最佳实践"。Chrome 的控制台也在疯狂警告你不开就用不了 SAB。于是我就开了。

然后网站炸了。

1. 社会性死亡现场

1.1 OAuth 登录阵亡

GitHub OAuth 弹窗登录,点完授权,回调页面白屏。window.openernull

原因很简单:COOP: same-origin 会切断跨域窗口之间的引用。你的 OAuth 弹窗和主页面不同源,window.opener 直接被浏览器置空。授权码拿不回来,登录流程断裂。

这不是 Bug,是隔离的物理代价。

1.2 第三方 SDK 变僵尸

Google Analytics 不报数据了。Sentry 不捕获错误了。不是它们挂了,是 COEP: require-corp 把所有不带 Cross-Origin-Resource-Policy 响应头的跨域资源全部拦截了。

你的页面加载了 analytics.google.com/ga.js,这个脚本没有 CORP 头,浏览器直接拒绝执行。GA 就这样无声无息地死了——没有错误,没有降级,就是静默失败。

1.3 媒体黑屏

CDN 上的图片全变黑块。<img src="https://cdn.example.com/photo.jpg"> 加载不出来。原因同上:CDN 的图片响应没有 Cross-Origin-Resource-Policy 头,被 COEP 一刀切了。

你能控制自己的 Nginx,但你控制不了别人的 CDN。这就是隔离最毒的地方:它的限制是全局的,不区分"你的资源"和"你引用的资源"。

2. 为什么会这样

这一切的根源是 Spectre

2018 年的 Spectre 漏洞证明了:恶意 JavaScript 可以通过侧信道攻击读取同一进程内其他域名的内存。为了防御,Chrome 实施了 Cross-Origin Isolation——用进程级隔离确保不同源的资源不会出现在同一渲染进程里。

代价是:所有跨域资源都必须显式声明"我允许被嵌入"。不声明的,一律拦截。这就是 COEP 的逻辑。

而 COOP 切断 window.opener,是为了防止跨域窗口通过 window.opener 访问原始页面的 DOM。这是同源策略在隔离模式下的强化版。

3. 基础修复

3.1 自己的资源:Nginx 配置

对于你能控制的资源,在 Nginx 里加上 CORP 头:

add_header Cross-Origin-Resource-Policy "cross-origin" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Embedder-Policy "require-corp" always;

这样你自己的图片、脚本、样式就不会被 COEP 拦截。

3.2 OAuth 回调:Credentialless 模式

Chrome 96+ 支持 Cross-Origin-Embedder-Policy: credentialless。这个模式允许不带凭证加载跨域资源,同时保留 COEP 的隔离语义。OAuth 弹窗在这个模式下可以正常回调。

# 替换 require-corp 为 credentialless
add_header Cross-Origin-Embedder-Policy "credentialless" always;

3.3 第三方 SDK:CSP 白名单

对于 GA、Sentry 这类必须执行的跨域脚本,可以用 crossorigin 属性显式声明:

<script src="https://analytics.google.com/ga.js" crossorigin></script>

但这只是声明意图,最终能不能加载还是取决于对方服务器的 CORS 配置。如果对方不支持 CORS,你只能走 Service Worker。

4. Service Worker:给第三方资源"办签证"

这是我找到的最可靠的方案。

原理:Service Worker 可以拦截页面发出的所有请求,包括跨域的。在 SW 里,你可以给任何响应补上缺失的 COEP/CORP 头——相当于在客户端侧给第三方资源"补办签证"。

// service-worker.js
self.addEventListener('fetch', (event) => {
  event.respondWith(
    fetch(event.request).then((response) => {
      // 只给缺少 CORP 头的跨域响应补头
      if (!response.headers.has('Cross-Origin-Resource-Policy')) {
        const newHeaders = new Headers(response.headers)
        newHeaders.set('Cross-Origin-Resource-Policy', 'cross-origin')
        return new Response(response.body, {
          status: response.status,
          statusText: response.statusText,
          headers: newHeaders,
        })
      }
      return response
    })
  )
})

这样,即使第三方 CDN 不支持 CORP,你的 Service Worker 也能在客户端侧把缺失的头补上。页面正常加载,SAB 正常工作,隔离也保持完整。

注意:这个方案只适用于公开资源(图片、公开 JS)。涉及凭证的 OAuth 流程,还是得走 Credentialless 模式。

5. 交互式沙盒

我做了一个基于真实状态机的可交互式跨域隔离沙盒——你可以亲手拨动开关,看 COOP/COEP 一刀切下去,网站是怎么死的,又是怎么被抢救回来的。

由于社区平台限制,无法演示动态拦截效果。欢迎来我的独立博客亲自体验:

👉 交互式跨域隔离沙盒 — diffserv.xyz

6. 完整的隔离策略

把以上方案组合起来,一份生产级配置:

# Nginx:开启隔离
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Embedder-Policy "credentialless" always;
// Service Worker:给公开资源补 CORP 头
// (见上方代码)
<!-- 页面中:显式声明 crossorigin -->
<script src="https://cdn.example.com/lib.js" crossorigin></script>
<img src="https://cdn.example.com/photo.jpg" crossorigin />

COOP: same-origin 隔离窗口引用。COEP: credentialless 允许 OAuth 回调。Service Worker 补齐第三方资源的 CORP 头。三层配合,隔离生效,功能不残。

7. 底线

Cross-Origin Isolation 不是可选项——如果你要用 SharedArrayBuffer,它就是强制的。但隔离的代价是真实的:OAuth 会断、SDK 会死、图片会黑屏。

这些不是 Bug,是浏览器在 Spectre 时代筑起的柏林墙。你推不倒它,但你可以学会在墙这边过日子。

Service Worker 办签证、Credentialless 留后路、Nginx 配自己的地盘。三条路走通,隔离世界就能活。


在线实验:STW Sentinel Lab

NPM:npm i stw-sentinel

GitHub:hlng2002/stw-sentinel

昨天以前首页

我用三道防线封死了 AI 幻觉与人类健忘:Next.js 工程化免疫闭环实战

作者 DiffServ
2026年4月15日 20:17

拒绝 AI 幻觉与人类健忘:Next.js 三重免疫系统实战

前端圈有个巨大的错觉:以为写好了 ESLint 规则、配好了 Prettier,代码质量就有保障了。但当你引入 Cursor、Claude 这些 AI 编程助手后,你会发现一个全新的威胁维度——AI 写代码很快,但它经常悄悄吃掉你最关键的防御逻辑。 一个 <div className="min-h-screen bg-zinc-50"> 被它换成空 Fragment <>,你的移动端背景直接消失,SEO 标签跟着蒸发。等你发现的时候,Google 爬虫已经把废墟抓走了。靠 Code Review 和人类记忆来防这种事?反人类的。必须靠系统防御。

事故现场:静悄悄的线上灾难

事情发生在一个平平无奇的周二下午。

我让 AI 帮我重构两篇新博客文章的页面组件。本地跑得很欢,pnpm dev 一切正常。部署到线上后随手用手机打开一看——背景色没了,页面高度塌陷,和之前几篇文章的风格完全不一致。

排查原因只花了一分钟:

// ❌ AI 生成的版本 — 外层是空 Fragment
return (
  <>
    <Header />
    <article>...</article>
  </>
)

// ✅ 正确版本 — 有完整容器包裹
return (
  <div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
    <Header />
    <article>...</article>
  </div>
)

AI 把最外层的 <div> 容器"优化"成了空 Fragment <>。从 JSX 语法上看完全合法,功能上也不报错。但在视觉层面,min-h-screen(最小视口高度)丢了,bg-zinc-50 dark:bg-zinc-950(亮/暗模式背景色)也丢了。移动端一打开就是白底白字或者灰底灰字的灾难现场。

这还不是最可怕的。更可怕的是——如果我当时没拿手机检查,这篇文章就会带着残缺的布局一直躺在生产环境里。 SEO 爬虫抓到的是一个没有规范链接、没有结构化数据的半成品页面。

这不是 Bug,这是一个系统性漏洞。只要还依赖人工检查,迟早会漏。唯一的解法是:把正确的事情变容易,把错误的事情变不可能。

第一道防线:源头清洗(脚手架约束)

问题的根源是什么?手动创建文件。

当你手动新建一个 page.tsx 时,你有两个选择:

  1. 从旧文件复制粘贴 → 可能复制了过时的模板,也可能漏掉新增的规范
  2. 从头手写 → 必然会忘记某些字段

无论哪种选择,都在依赖人类的短期记忆。而短期记忆是最不可靠的东西。

我的解决方案是一个 CLI 脚手架:

pnpm post:new

运行后会交互式提示输入 slug、标题、描述、关键词,然后自动生成一个满配状态page.tsx 骨架:

$ pnpm post:new

🚀 创建新文章(强制 SEO 规范)

文章 slug: triple-immune-system
文章标题: 拒绝 AI 幻觉与人类健忘
文章描述: Next.js 工程化免疫闭环实战
关键词: Next.js, SEO, 工程化, CI/CD, AI编程

✅ 文章创建成功!
📂 路径: apps/web/src/app/blog/triple-immune-system/page.tsx
👉 已自动注入 Canonical, JSON-LD, OpenGraph 等极致 SEO 标签!

生成的模板长这样(核心骨架):

import { Header } from '@/components/Header'
import type { Metadata } from 'next'
import { generateBlogPostingJsonLd } from '@/lib/jsonld'

export const metadata: Metadata = {
  title: '文章标题 - DiffServ Lab',
  description: '文章描述',
  keywords: ['关键词1', '关键词2'],
  alternates: { canonical: '/blog/slug' },          // ← 规范链接
  openGraph: {                                       // ← 社交媒体卡片
    title: '文章标题',
    description: '文章描述',
    url: 'https://diffserv.xyz/blog/slug',
    siteName: 'DiffServ Lab',
    type: 'article',
  },
  twitter: {                                        // ← Twitter Card
    card: 'summary_large_image',
    title: '文章标题',
    description: '文章描述',
  },
}

export default function BlogPost() {
  const jsonLd = generateBlogPostingJsonLd({        // ← 结构化数据
    title: '文章标题',
    description: '文章描述',
    url: 'https://diffserv.xyz/blog/slug',
    datePublished: '2026-04-15',
  })

  return (
    <div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">  // ← 容器包裹
      <script type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
      <Header />
      <article className="max-w-3xl mx-auto ... overflow-x-hidden">
        {/* 正文 */}
      </article>
    </div>
  )
}

注意这个模板一次性注入了 5 个关键防御层

防御层 作用 缺失后果
alternates.canonical 告诉搜索引擎这是原始地址 被视为重复内容,权重分散
generateBlogPostingJsonLd BlogPosting 结构化数据 Google 富文本搜索结果出不来
openGraph Facebook/Telegram 分享卡片 分享出去只有光秃秃的链接
twitter Twitter/X 分享卡片 推文没有预览图
min-h-screen + bg-* 移动端容器 高度塌陷、背景断层

核心思想:把正确的事情变容易,把错误的事情变困难。 当你运行一条命令就能拿到满配模板时,没有人会选择从头手写。

但这还不够。脚手架只能保证"新建时正确",无法防止后续修改时的退化。比如某个开发者(或 AI)觉得 Fragment 更简洁,顺手就把外层 div 删掉了。这时候需要第二道防线。

第二道防线:构建时铁闸(正则 + 结构强制拦截)

这是整套系统里最具暴力美学的一环。

我在 package.json 的 build 命令上挂了一个前置钩子:

{
  "scripts": {
    "build": "pnpm post:verify && pnpm --filter './apps/*' build"
  }
}

每次执行 pnpm build(无论是本地打包还是服务器 Docker 构建),都会先跑一遍 verify-seo.js。任何不符合规范的文章,构建直接崩溃

这个校验脚本做了什么?它扫描 blog/ 目录下所有文章的 page.tsx,做 3 类检查

SEO 完整性检查

// === SEO 检查 ===
if (!content.includes('generateBlogPostingJsonLd')) {
  errors.push('[SEO] 缺少 JSON-LD 结构化数据');
}
if (!content.includes('alternates: { canonical:')) {
  errors.push('[SEO] 缺少规范链接 (canonical)');
}
if (!content.includes('openGraph:')) {
  errors.push('[SEO] 缺少 OpenGraph 协议标签');
}

布局结构检查

// === 布局结构检查 ===
if (!content.includes('min-h-screen')) {
  errors.push('[布局] 缺少 min-h-screen 容器,移动端背景/高度会异常');
}
if (!content.includes('bg-zinc-50') || !content.includes('dark:bg-zinc-950')) {
  errors.push('[布局] 缺少 bg-zinc-50 dark:bg-zinc-950 背景色');
}

// 最狠的一条:检测外层是否是空 Fragment <>
const returnMatch = content.match(/return\s*(\s*(<[^>]+>)/);
if (returnMatch && returnMatch[1] === '<>') {
  errors.push('[布局] 外层使用了空 Fragment <>,应使用 <div>');
}

最后这条是精准打击。用正则匹配 return ( 后面的第一个 JSX 标签,如果是 <> 就直接报错。这就是导致那次移动端布局异常的元凶。

你可能会问:为什么不用 AST 解析?因为 Next.js 的构建管线已经有 TypeScript 编译器做语法校验了,我们这道防线的定位是结构约束而非语法分析。正则足够精准地捕获这个高频反模式,零依赖,插入任何项目即生效。AST 解析器引入几十个依赖包,为这一个检查杀鸡用牛刀。

执行效果

当有文章违规时,终端输出极具压迫感:

🔍 开始校验博客规范...

❌ [some-post] 规范检查未通过:
   [SEO] 缺少 JSON-LD 结构化数据 (generateBlogPostingJsonLd)
   [布局] 外层使用了空 Fragment <>,应使用 <div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">

✅ [other-post] 全部检查通过

🚫 校验失败!请修复后重试。建议使用 pnpm post:new 创建文章。

然后 process.exit(1) 直接终止进程。Docker 构建失败,CI/CD 流水线变红,部署被无情阻断。

不是 Warning,不是 Hint,是直接枪毙。在生产环境里,宽容就是对用户的残忍。

第三道防线:赛博紧箍咒(CLAUDE.md 规则引擎)

前两道防线已经很强了,但还有一个盲区:AI 编程助手。

现在的主流开发流程是"人机协同"。你用 Cursor、Claude Code、Copilot 写代码,它们很聪明,但也极其狡猾。它们会"自作主张"地帮你"优化"代码——比如把你精心设计的防御性 div 替换成一个更"简洁"的 Fragment。

ESLint 拦不住这种事,因为 Fragment 在语法上是完全合法的。TypeScript 也拦不住,因为类型推导不受影响。唯一能拦住的是告诉 AI 不要这么做

这就是 CLAUDE.md 的作用。它是项目根目录下的规则文件,所有主流 AI 编程助手在接手项目时都会优先读取它:

## 发布文章 SEO 铁律

为了保证博客网站的 SEO 永远维持在"超越 Astra"级别,
**严禁手动复制粘贴创建新文章的 `page.tsx`**1. **新建文章必须使用脚手架**:
   运行 `pnpm post:new`,脚本会自动生成包含 Canonical、
   JSON-LD (BlogPosting)、OpenGraph 和 Twitter Card 的完美骨架。

2. **构建前的自动化拦截**:
   `pnpm build` 已集成 `pnpm post:verify` 卡点。
   任何缺少核心 SEO 配置的文章都会直接阻断构建。

这看起来像是一份普通的文档,但实际上它是一个针对 AI 的 Prompt 注入攻击——正向的。

你在告诉 AI:"在这个项目里,这些是不可触碰的铁律。" AI 助手读取这份文件后,会在生成代码时主动遵守这些规则。相当于给 AI 戴了一个紧箍咒。

三道防线的协同

单独看每一道防线都有绕过的可能:

防线 能拦截 绕过方式
脚手架 新建时的错误模板 手动创建文件
构建卡点 所有已知的违规模式 不走 pnpm build 直接改服务器
CLAUDE.md AI 的自作主张 AI 忽略规则文件

但当三者组合在一起时,绕过的路径被彻底堵死:

手动创建 page.tsx → 忘记加 min-h-screen 容器
    ↓
git commit → git push → CI/CD 触发 docker build
    ↓
docker build 执行 pnpm build → 先跑 verify-seo.js
    ↓
检测到外层是 <> → process.exit(1)
    ↓
❌ 构建崩溃,部署被阻断

即使有人想绕过构建直接改服务器,CLAUDE.md 里还有另一条铁律:

禁止使用 rsync 同步代码到服务器。 所有代码变更必须通过 git push → 服务器 git pull → docker compose build 流程部署。

rsync 绕路会被 Git 历史脱节、构建产物残留等一系列问题反噬。唯一正确的路径就是那条会被 verify-seo.js 审查的路径。

这就是防御纵深(Defense in Depth) 的思想:不依赖单一检查点,而是用多层独立机制互相兜底。每一层都可能失效,但所有层同时失效的概率趋近于零。

为什么这件事值得写

你可能觉得:"这不就是加了几个检查脚本吗?有什么好写的?"

但你换个角度想:

2026 年,AI 编程助手已经成为标准配置。 Cursor 估值冲到 billions 级别,Claude Code、GitHub Copilot Workspace 成了开发者的日常工具。但同时, "AI 悄悄吃掉关键代码" 已经成为团队里最高频的隐形故障来源。

市面上的讨论几乎全停留在理念层面:"我们要 Review AI 的输出"、"我们要建立 AI Code Review 流程"。说得好听,但没有落地的工程化解法。

而我这套方案,是可以用 git clone 跑起来的完整闭环。三个文件,零外部依赖,插入任何 Next.js 项目即可生效。不需要配置 ESLint 插件,不需要买 CI/CD 付费套餐,不需要说服团队改变工作流。

真正的架构师能力,不只是写出高性能的底层代码(那是单兵作战),而是能把从代码生成、到构建拦截、再到 AI 协同的整条流水线打造得绝对防弹(Bulletproof)

前者让你成为一个优秀的工程师,后者让你成为一个能交付靠谱系统的架构师。


开源仓库 → github.com/hlng2002/ne…

三条命令接入你的项目:

# 1. 复制脚本到你的项目
cp create-post.js your-project/scripts/create-post.js
cp verify-seo.js your-project/scripts/verify-seo.js

# 2. 添加 npm scripts
# "post:new": "node scripts/create-post.js",
# "post:verify": "node scripts/verify-seo.js",
# "build": "pnpm post:verify && pnpm --filter './apps/*' build"

# 3. 写入 CLAUDE.md 铁律(见上文)

打开 Live Lab → diffserv.xyz

🥚 彩蛋:就在写这篇文章时,AI 当着我的面绕过了第一道防线

你可能觉得"AI 绕过防御"是理论推演,但它就发生在我部署这篇文章的时候。

我的 AI 助手在生成 page.tsx 时,直接手写了整个文件,完全跳过了 pnpm post:new 脚手架。理由极其嚣张:"你的脚手架是交互式的,我直接写更快。"

是的,我设计的第一个防线,被我的 AI 助手当面绕过了。

但这就是第二道防线存在的意义——就算 AI 绕过了脚手架,构建时校验依然死死守着底线。如果它手写的文件漏了 canonicalgenerateBlogPostingJsonLdpnpm build 会直接报错,部署阻断。第一道防线被破,第二道防线兜底。

这不是理论,这是实战。 三重防御不是为了防一个完美的世界,是为了在 AI 叛逆的时候,还有底线活着。

2026-04-15 · 工程化免疫闭环 · diffserv.xyz

Next.js精通SEO第一章(引言)

作者 小满zs
2026年4月15日 16:02

SEO介绍

SEO(Search Engine Optimization),即搜索引擎优化,是一种通过优化网站结构和内容,提高网站在搜索引擎中的排名,从而吸引更多流量和用户的策略。

tips: SEO是一个长期优化过程(一般优化1-3个月才能看到效果),无需急于求成。

黑帽SEO

黑帽SEO是指通过不正当的手段,如关键词堆砌、隐藏文本、欺诈性链接等,来提高网站在搜索引擎中的排名。这种做法虽然可以在短期内获得较好的效果,但长期来看会对网站造成严重的负面影响,甚至可能导致网站被搜索引擎惩罚。

例如我们在Google搜索笔记本,我们找排名第一的网站

image.png 然后进去网站之后

鼠标右键->查看网页源代码,发现他用了非常多的关键词堆砌(笔记本),这就是黑帽SEO(豆包说的,不是我说的😏)。

image.png

白帽SEO

白帽SEO就是通过正当技术手段,例如优化TDK,优化网站结构,优化robots.txt,优化sitemap.xml,优化JSON-LD,优化Open Graph,优化Web Vitals等,来提高网站在搜索引擎中的排名。

SEO实践

  1. 理解搜索引擎的工作原理
  2. robots.txt 和 sitemap.xml 的配置
  3. TDK优化 + HTML语义化标签
  4. JSON-LD
  5. Open Graph
  6. Web Vitals
  7. SEO工具的使用

Google搜索引擎

Google 搜索是一款全自动搜索引擎,会使用名为“网页抓取工具”的软件定期探索网络,找出可添加到 Google 索引中的网页。实际上,Google 搜索结果中收录的大多数网页都不是手动提交的,而是网页抓取工具在探索网络时找到并自动添加的。

Google搜索引擎原理

Google 搜索的工作流程分为 3 个阶段:

  1. 抓取:Google 会使用名为“抓取工具”的自动程序从互联网上发现各类网页,并下载其中的文本、图片和视频。
  2. 索引编制:Google 会分析网页上的文本、图片和视频文件,并将信息存储在大型数据库 Google 索引中。
  3. 呈现搜索结果:当用户在 Google 中搜索时,Google 会返回与用户查询相关的信息。

所以答案就是:抓取->索引编制->呈现搜索结果

抓取

谷歌会使用(Googlebot)去抓取网页,Googlebot也被称为(抓取工具、漫游器或“蜘蛛”程序),他会通过算法来决定哪些网页需要抓取,并且确保不会过快抓取,以免对网站造成负担。

那么它是怎么抓取的呢?

  1. 通过链接抓取例如你的网站有a标签,那么Googlebot会通过a标签的href属性来抓取网页。<a href="https://www.xxxxxx.com">xxxxx</a>

  2. robots.txt(告诉爬虫机器人哪些页面可以抓取,哪些页面不能抓取,后面会详细讲)

  3. 站点地图 sitemap.xml(列出网站中的网页、文件、视频等 URL,方便爬虫发现和抓取这些资源

  4. 如果网站未收录,可以通过Google Search Console提交网站。

image.png

  1. RSS订阅,例如你的网站有RSS订阅,那么Googlebot会通过RSS订阅来抓取网页。

  2. 重定向,谷歌机器人也会根据你301/302重定向来抓取网页。

  3. JavaScript,现代谷歌浏览器已经可以识别JavaScript代码中动态生成的链接,也会被收录。

索引编制

什么是索引编制?

  1. 索引编制是把抓取到的内容匹配成用户查询的形式,插入到索引数据库中。用户搜索时,Google 是在索引数据库中进行匹配和排序的,并不是实时抓取全网的,所以你修改的网页一般要(2-3周)才会被同步

  2. 被抓取 ≠ 被索引如果你在代码中编写了noindex,则该页面不会加入索引数据库中。

<meta name="robots" content="noindex">
  1. 索引信号 索引信号是指Googlebot分析网页的内容,例如TDKHTML语义化标签JSON-LDOpen GraphWeb Vitalsalt属性,分析这些内容和网站质量,用于进行评估提升排名。

  2. 注意事项 如果你的网站有以下情况,则会被降低排名:伪装真实内容 滥用门页 滥用过期域名 被黑内容 滥用隐藏文字和链接 关键字堆砌 垃圾链接 机器生成的流量 恶意软件和恶意行为 误导性功能 滥用规模化内容 滥用网站声誉 内容贫乏的联属营销 用户生成的垃圾内容

原文链接:developers.google.com/search/docs…

呈现搜索结果

谷歌官方承诺:Google 不会通过收取费用来提高网页排名,网页排名是程序化地完成的(靠的是你对SEO的实力)

image.png

  1. 排名的考量(相关性-内容与搜搜意图的匹配)(权威性-域名权重,外链质量)(用户体验-加载速度SEO友好)
  2. 收录,在被抓如到索引之后,通常是2-3周才会被收录,排名需要一段时间的积累权重,一般是2-3个月。
  3. 结果,搜索的结果会全方面考量,用户的语言,设备,历史记录,SEO优化的是整体,而不是固定某个位置。

return null:Next.js App Router 博客的 14 个 SEO 死穴

作者 DiffServ
2026年4月14日 18:15

return null:Next.js App Router 博客的 14 个 SEO 死穴

Googlebot 爬你的博客,看到的是一片空白。不是服务器挂了,不是页面 404,是你亲手写的 return null 把整个 <body> 清空了。


0. 症状

部署了一个 Next.js 16 + App Router 的技术博客,文章全是 Server Component,metadata 配得整整齐齐,sitemap 也有,robots.txt 也放了。但 Google Search Console 里,收录数是 0。

curl 一看 HTML 源码:

<body>
  <!-- 空的 -->
</body>

6 篇精心写的深度技术文章,Googlebot 一个字都没看到。


1. 元凶:ClientOnly 的 return null

根 layout 里有一个 ClientOnly 组件包裹了整个 {children}

// components/AuthProvider.tsx
'use client'

export function ClientOnly({ children }) {
  const [mounted, setMounted] = useState(false)
  useEffect(() => { setMounted(true) }, [])

  if (!mounted) return null  // ← SSR 阶段永远走这里

  return <AuthProvider>{children}</AuthProvider>
}
// app/layout.tsx
<body>
  <ClientOnly>{children}</ClientOnly>
</body>

SSR 阶段 mounted = falsereturn null → HTML body 为空。

这个组件的原意是等客户端 hydration 完成后再渲染,避免 auth 状态闪烁。但副作用是:所有页面的 SSR 输出为零。Googlebot 虽然能执行 JS,但需要等 hydration 完成才能看到内容,爬取效率和索引优先级大幅下降。

修复:删掉 if (!mounted) return null,让 SSR 阶段也正常输出 children。

export function ClientOnly({ children }) {
  const [mounted, setMounted] = useState(false)
  useEffect(() => { setMounted(true) }, [])
  // 不阻塞 SSR:mounted=false 时也输出 children
  return <AuthProvider>{children}</AuthProvider>
}

Auth 状态在 SSR 阶段是空的,没关系——博客文章不需要登录态。


2. cookies() 暗杀 ISR

修完 SSR 后,给博客列表页配了 ISR:

export const revalidate = 3600 // 每小时重新生成

但发现每次请求仍然走服务端渲染,ISR 缓存完全没生效。

原因:页面里调用了 cookies()

// blog/page.tsx
import { cookies } from 'next/headers'

export default async function BlogPage() {
  const cookieStore = await cookies()  // ← 这行杀死了 ISR
  const token = cookieStore.get('token')?.value
  // ...
}

在 Next.js App Router 中,cookies() 是动态函数(Dynamic Function)。一旦调用,无论你怎么设 revalidate,页面都会强制进入动态渲染模式。ISR 形同虚设。

修复:把 cookie 逻辑移到客户端组件里。博客列表页本来就不需要在服务端读 cookie。


3. 缺 metadataBase,canonical 全废

每篇文章都配了 openGraph.url,但没在根 layout 设 metadataBase

// ❌ 之前
export const metadata: Metadata = {
  title: "DiffServ — V8 Performance Lab",
}

// ✅ 之后
export const metadata: Metadata = {
  metadataBase: new URL("https://diffserv.xyz"),
  title: "DiffServ — V8 Performance Lab",
}

没有 metadataBase,所有相对路径的 canonical URL、OG 图片地址都无法被 Next.js 解析为绝对 URL。搜索引擎拿到的是残缺的 meta 信息。


4. www 和裸域同时响应,权重分裂

Nginx 配置:

server_name diffserv.xyz www.diffserv.xyz;

两个域名同时响应相同内容,Google 视为两个独立站点,PageRank 被一分为二。

修复:www 单独做 301:

server {
    listen 443 ssl http2;
    server_name www.diffserv.xyz;
    return 301 https://diffserv.xyz$request_uri;
}

5. 没有 HSTS,每次首访多一次重定向

有 HTTP→HTTPS 301,但没有 Strict-Transport-Security 头。用户每次输入 diffserv.xyz 都要经历一次 80→443 的重定向,白白多 100-300ms。

add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;

6. 静态资源没有长缓存头

Next.js 的 /_next/static/ 文件名自带 content hash,天然可以永久缓存。但 Nginx 没配:

location /_next/static/ {
    proxy_pass http://web;
    add_header Cache-Control "public, max-age=31536000, immutable" always;
}

没有这行,浏览器每次都要发条件请求验证缓存,白白浪费 RTT。


7. 没有 RSS

技术博客没有 /feed.xml = 放弃了 Feedly、Inoreader 等 RSS 阅读器的整个流量入口。在 Next.js App Router 里用 Route Handler 生成:

// app/feed.xml/route.ts
export async function GET() {
  const items = blogPosts.map(post => `
    <item>
      <title>${post.title}</title>
      <link>https://diffserv.xyz/blog/${post.slug}</link>
      <pubDate>${new Date(post.date).toUTCString()}</pubDate>
      <description>${post.description}</description>
    </item>
  `).join('')

  const xml = `<?xml version="1.0" encoding="UTF-8"?>
    <rss version="2.0">
      <channel>
        <title>DiffServ Lab</title>
        <link>https://diffserv.xyz</link>
        ${items}
      </channel>
    </rss>`

  return new Response(xml, {
    headers: { 'Content-Type': 'application/rss+xml' },
  })
}

8. 没有 OG 图片

所有文章声明了 twitter.card: summary_large_image 但没给图片 URL。社交平台分享是纯文本链接,点击率比带图低 40%+。

Next.js App Router 支持 app/opengraph-image.tsx 动态生成 OG 图片,或者在 public/ 放一张默认图然后在全局 metadata 里引用。


9. JSON-LD 缺 dateModifiedimage

Google Rich Results 要求 BlogPosting 类型至少包含 headlinedatePublisheddateModifiedimageauthor。缺少 dateModifiedimage,搜索结果中不会显示富媒体摘要(发布日期、缩略图)。


10. 没有 404 / 500 页面

Next.js App Router 默认的 404 是一个白底黑字的 "404 | This page could not be found",没有导航、没有推荐内容。用户点到死链直接流失。

创建 app/not-found.tsxapp/error.tsx,至少给一个回首页的链接和几篇推荐文章。


11. next.config.ts 为空

const nextConfig: NextConfig = {};

至少加两行:

const nextConfig: NextConfig = {
  poweredByHeader: false,           // 隐藏 X-Powered-By: Next.js
  images: { formats: ['image/avif', 'image/webp'] },
};

poweredByHeader 暴露技术栈给攻击者;不启用 AVIF 意味着放弃了 30-50% 的图片压缩率。


12. viewport 禁止缩放

export const viewport: Viewport = {
  maximumScale: 1,
  userScalable: false,
}

WCAG 2.1 明确要求用户能放大到至少 200%。这两行让 Lighthouse Accessibility 直接扣分。删掉。


13. sitemap lastModified 每次构建都变

lastModified: new Date(),  // ← 每次 ISR 重生成都是新时间

Google 看到所有 URL 的 lastModified 同时变化,会重新爬取全站,浪费 crawl budget。硬编码真实的修改日期。


14. 内部链接用了 <a> 而不是 <Link>

部分博客文章里的内部跳转(/lab/blog/xxx)用了原生 <a> 标签。Next.js 的 <Link> 组件会自动 prefetch 目标页面,用 <a> 则触发全页刷新,白白丢掉了客户端路由的性能优势。


对标 Astro:Next.js 的额外成本

维度 Astro 默认 Next.js 需要手动做
SSR 输出 纯 HTML,零 JS 需确保不被 ClientOnly 阻断
ISR 默认 SSG 需手动配 revalidate,且不能碰 cookies()
RSS @astrojs/rss 一行配 手写 Route Handler
OG 图片 社区包成熟 opengraph-image.tsx 或手动
零 JS 默认不发送 runtime Server Component 不 hydrate,但仍有 React runtime 开销
sitemap @astrojs/sitemap 自动 手动实现,需注意 lastModified

Astro 的优势是默认值就是最佳实践。Next.js 的优势是灵活性——但灵活性的代价是你必须知道每个默认值背后的坑。

如果你的博客是纯内容站,Astro 确实省心。但如果你的站点同时有博客、交互式 Lab、用户系统、API——Next.js 的全栈能力是 Astro 替代不了的。关键是:把该配的配好,把该删的删掉


修完之后

14 项全部修完后的状态:

  • HTML 源码可见全部文章内容,Googlebot 无需执行 JS
  • ISR 缓存生效,TTFB 从 ~500ms 降到 ~50ms
  • 社交分享带品牌 OG 图片
  • RSS 接入全球阅读器生态
  • HSTS preload + www 301 + immutable 缓存
  • Lighthouse Performance / SEO / Accessibility / Best Practices 全绿

不需要换框架。Next.js 能做到 Astro 做的一切,前提是你知道哪些地方需要手动补。


GitHub: hlng2002/stw-sentinel 在线实验: diffserv.xyz/lab

我用 Cloudflare 搭了一个 FlashInbox 临时邮箱

作者 萑澈
2026年4月13日 17:12

本文转载自个人博客,若内容存在缺失或后续有更新,可通过下方链接跳转至原博客页面,查阅完整内容:

我用 Cloudflare 搭了一个 FlashInbox 临时邮箱 - 萑澈的寒舍

我一直觉得,临时邮箱这种东西,能自己掌握还是自己掌握比较踏实。公共服务不是不能用,而是你用的时候总要多留个心眼:域名不是你的,地址不是你的,哪天规则变了、站点没了,或者数据清了,你也没什么办法。

刚好 FlashInbox 这套项目走的又是我比较喜欢的路线:前端 Next.js,部署在 Cloudflare Workers,收信靠 Cloudflare Email Routing,数据放进 D1。不需要自己折腾一整套传统邮件服务,成本和复杂度都低不少。

项目仓库在这里:https://github.com/CtelSpecu/FlashInbox

演示地址:flashinbox.hxcn.top

我这次就是把它从仓库拉下来,按自己的域名和 Cloudflare 环境跑了一遍。装完之后,手里会有一套自己的临时邮箱服务:能匿名创建地址,能收邮件,能认领邮箱拿 Key,也能靠 username + key 把邮箱找回来。默认 Key 有效期是 15 天,拿来收验证码、接通知、做注册测试都挺顺手。

最后大概会变成这样:网页在 mail.example.com 上打开,临时邮箱地址是 bluepanda23@example.com 这种形式,邮件能正常收进来,后台还能自己管域名、规则和隔离队列。

FlashInbox 是什么

先把边界说清楚,免得装到一半才发现想要的根本不是这个。

FlashInbox 更像一个收件站,不是常规邮箱服务。它现在能做的事情主要有这些:

  • 支持匿名创建临时邮箱
  • 支持接收入站邮件
  • 不存附件
  • 支持认领邮箱并生成一次性明文 Key
  • 支持用 username + key 恢复访问
  • 提供一个管理后台

你如果想要的是“自己域名下的临时邮箱”,那这个方向没问题;如果想要的是“自己做一个 Gmail”,那就是另一回事了。

准备工作

先准备这些东西:

  • 一个 Cloudflare 账号,并且已经开通 WorkersD1Email RoutingTurnstile
  • 一个已经接入 Cloudflare DNS 的域名,例如 example.com
  • 本地环境里已经装好 bun
  • 本地可以使用 wrangler

这里先记住一件事:Web 访问域名可以是 mail.example.com,真正收信的域名可以是 example.com。这两个域名最好分开,因为 Cloudflare Email Routing 的 catch-all 规则吃的是主域,不是 mail.example.com 这种 Web 子域。

架构先看明白

这套东西不是一个 Worker 从头跑到尾,而是三部分一起干活:

  1. flashinbox 这是主应用,负责网页、API、用户收件箱、管理后台。它读的是 wrangler.toml
  2. flashinbox-email 这是入站邮件 Worker,负责接住 Email Routing 转过来的邮件,解析后写进 D1。它读的是 wrangler.email.toml
  3. flashinbox-scheduled 这是定时任务 Worker,负责清理、统计之类的后台工作。它读的是 wrangler.scheduled.toml

这三个 Worker 必须指向同一个 D1。很多“页面能打开但收不到信”的问题,最后查下来都不是代码坏了,而是这里没配对。

第 1 步:克隆仓库并安装依赖

先把仓库拉下来:

git clone https://github.com/CtelSpecu/FlashInbox.git
cd FlashInbox
bun install

项目里已经带了 wrangler,后面直接用 bunx wrangler 也行。

想先确认本地环境没问题,可以顺手看一眼 package.json 里的几个命令:

bun run build:worker
bun run dev
bun test

第 2 步:创建 D1

先登录:

wrangler login

再创建生产数据库:

wrangler d1 create flashinbox-db

执行完以后,Cloudflare 会返回一个 database_id。把它分别填到 wrangler.tomlwrangler.email.tomlwrangler.scheduled.toml 里。这三份配置里的 database_id 必须一致。只改主应用那一份不够,网页是能开,后台也可能能进,但邮件根本不会进到你以为的那个库里。

第 3 步:执行数据库迁移

接着把表建好:

wrangler d1 execute flashinbox-db --remote --file=migrations/0001_init.sql
wrangler d1 execute flashinbox-db --remote --file=migrations/0002_mailboxes_banned.sql

两条都跑,不要省。0001_init.sql 是基础表结构,0002_mailboxes_banned.sql 跟后台禁用邮箱的状态有关,少跑一条,后面多半要踩坑。

如果迁移时碰到 BEGIN TRANSACTIONCOMMIT 之类的报错,说明迁移文件不符合 D1 的要求。当前仓库里的迁移文件已经处理过,照着跑通常没事。

第 4 步:配置域名

wrangler.toml 里有两个地方必须看:

  1. 生产环境路由

例如:

[env.production]
name = "flashinbox"
routes = [
  { pattern = "mail.example.com", custom_domain = true }
]
  1. 默认邮箱域名

例如:

[env.production.vars]
DEFAULT_DOMAIN = "example.com"

这两个值不是一回事。mail.example.com 是用户打开网页的地址,example.com 才是真正生成临时邮箱地址时用的收信域名。很多人第一次配的时候,脑子里只有一个“域名”,结果 Web 域名和收信域名搅在一起,后面查问题会非常累。

第 5 步:创建 Turnstile

FlashInbox 的认领流程用到了 Turnstile,所以这里先建一个站点,绑定主应用域名,比如 mail.example.com。建好之后,把 TURNSTILE_SITE_KEYTURNSTILE_SECRET_KEY 记下来,后面填进主应用配置里。

第 6 步:配置 Secrets

接下来配主应用的 Secrets:

wrangler secret put ADMIN_TOKEN --env production
wrangler secret put KEY_PEPPER --env production
wrangler secret put SESSION_SECRET --env production
wrangler secret put TURNSTILE_SECRET_KEY --env production
wrangler secret put TURNSTILE_SITE_KEY --env production

这里最重要的是 ADMIN_TOKENKEY_PEPPERSESSION_SECRET。前者是后台登录口令,后两个分别用于 Key 哈希和会话签名。尤其是 KEY_PEPPER,这个值别随便改,它跟 Key 哈希和恢复流程绑得很死。你改一次,旧 Key 基本就都失效了。

第 7 步:部署主应用

主应用这一层走的是 Next.js + OpenNext + Cloudflare Workers

先构建:

bun run build:worker

再部署:

wrangler deploy --env production

跑到这里,站点本身应该已经能打开了,比如:

https://mail.example.com

如果这时候网页都打不开,就别急着去查邮件链路,先把主应用的构建、路由和自定义域名检查完。

第 8 步:部署两个 Worker

主应用只是前台,收邮件和做清理的活,还得靠另外两个 Worker。

先部署 Email Worker:

wrangler deploy --config wrangler.email.toml

再部署 Scheduled Worker:

wrangler deploy --config wrangler.scheduled.toml

这一步特别容易漏,而且一漏就很容易把人带偏。页面能开,创建邮箱也不报错,乍一看像是已经部署好了,甚至会让人误以为后面只剩发邮件验证一下。等你真的往那个地址发一封测试邮件,才会发现收件箱安静得像没通电。

页面能开,不代表收信链路已经通了。

第 9 步:初始化 domains

这里也是高频坑。FlashInbox 的 Email Worker 不会见信就收,它会先查 D1 里的 domains 表,看这个域名是不是存在、是不是 enabled。表里没有,或者状态不对,邮件就进不来。

这个表有两种初始化办法。

方案 A:后台添加

部署完成后,访问:

https://mail.example.com/admin

ADMIN_TOKEN 登录,在 Domains 页面里把 example.com 加进去,并设置为 enabled

方案 B:写入 D1

wrangler d1 execute flashinbox-db --remote --command "INSERT INTO domains (name, status, note, created_at, updated_at) VALUES ('example.com', 'enabled', 'prod', strftime('%s','now')*1000, strftime('%s','now')*1000);"

如果你后面打算让多个域名一起收信,比如 example.comexample.net,那每个域名都得写进去,别指望一个默认值把所有事情包圆。

第 10 步:配置 Email Routing

下面开始配真正的收信链路。

在 Cloudflare Dashboard 里打开你的域名,进入 Email Routing,把下面几项配上:

  • 启用 Email Routing
  • 创建一条 Catch-all 规则
  • Action 选择 Send to a Worker
  • Worker 选择 flashinbox-email

逻辑很简单:别人发到 anything@example.com 的邮件,Cloudflare 先接住,再转给你的 Email Worker。

这里最容易混的是两件事:

  1. Catch-all 针对的是主收信域名

也就是说,通常是 example.com,不是 mail.example.com

  1. 如果 Catch-all 没指向 Worker

那邮件就可能直接被丢掉,而且不一定给你多清楚的提示。很多时候你只能在 Activity log 里看到一个 Dropped

第 11 步:验证收信

配置做完之后,最好马上测一遍。

先打开你的站点,创建一个临时邮箱。假设系统给你分配了:

bluepanda23@example.com

然后拿另一个外部邮箱,往这个地址发一封测试邮件。

重点看三件事:前端能不能看到收件箱内容,管理后台里的域名状态是不是正常,以及 Cloudflare Email Routing 的 Activity log 里这封邮件有没有被正确投递。

如果前端没看到邮件,就按这个顺序排:

  • domains 表里有没有 example.com
  • 这个域名状态是不是 enabled
  • Email Routing 的 Catch-all 是否真的指向了 flashinbox-email
  • 三个 Worker 是否都已经部署
  • 三份 Wrangler 配置是不是都绑到了同一个 D1

如果还不行,可以直接看 Email Worker 日志:

wrangler tail flashinbox-email

如果日志里出现 Domain not foundDomain is disabled 这种提示,就别猜了,直接回去查配置。

几个常见坑

1. 主站能开,不等于邮箱已经能收信

这是最容易让人误判的一点。网页能开,只代表主应用部署好了;真正决定能不能收信的,是 Email Routing、Email Worker 和 domains 表有没有一起对上。

2. 三个 Worker 必须共用同一个 D1

别把它们当成三个独立项目。对 FlashInbox 来说,这就是同一套系统的三个入口。

3. DEFAULT_DOMAIN 不是网页访问域名

它代表的是默认收信域名,不是网页访问域名。这个值写错,前端生成出来的邮箱地址就会跟你的预期不一致。

4. 附件不会保存

这不是 bug,而是项目本身就没打算存附件。收验证码、通知、注册链接都没问题,但别拿它当文件邮箱用。

5. Cloudflare Email Routing 有平台限制

大于 25 MiB 的邮件别指望它稳稳接住。这个上限不是 FlashInbox 自己定的,是 Cloudflare Email Routing 本身的限制。

适合哪些场景

这篇方案更适合下面这些场景:

  • 你想用自己的域名收临时邮件
  • 你不想继续依赖公共临时邮箱
  • 你不想自己维护完整邮件服务器
  • 你希望网页、收信、存储和后台都留在 Cloudflare 体系里

如果你的需求差不多就是这些,那 FlashInbox 值得花点时间装一下。

它当然不是正式办公邮箱的替代品,也不适合做复杂邮件协作。但如果你只是想把“临时邮箱”这件事握回自己手里,用自己的域名来收,用自己的后台来管,那这套东西已经够用了。

❌
❌