一本带你从零读懂 TS × React × Hono × Cloudflare 全栈项目的书 · 共 15 章 + 词汇表 · mentor 领读版
yaf 是一个开源的 flomo 式卡片笔记应用,部署在 Cloudflare 的免费套餐上:整个产品——网页、API、搜索、AI 洞察、微信机器人——打包成一个 Worker。你将在这本书里跟着 mentor 把它从头到尾读一遍:从「一次点击发生了什么」讲到「一行正则为什么这么写」。
传统上,「一个网站 + 一个 API + 定时任务 + 消息机器人」至少需要一台服务器(或四台)。yaf 没有任何服务器,却全部做到了,而且免费。它是怎么做到的?带着这个问题读第 1 章和第 3 章,读完你会对「云计算还能这样做」有一个具体、可检验的答案,而不是一句「Serverless 很方便」。
为你——一个有 web 开发经验、但对 TypeScript 生态和 Cloudflare 平台完全陌生的开发者。所有概念默认零基础:先讲它解决什么问题,再给名字(中文正名 + 英文原词)。同时,这本书是为 vibe coding 的工作方式设计的:你平时让 AI agent 写代码,这本书的目标是让你达到三个具体的能力,而不是背诵语法——
这个仓库没有测试套件(package.json 的 scripts 里没有 test),所以「判断 agent 改得对不对」不靠跑测试,靠观察。下面五件工具会在全书反复出场,先集合点名——以后每章末尾的自检,第一动作都是在这里选一件:
| 工具 | 干什么用 |
|---|---|
git diff | 逐块审读 agent 的改动,只放行你看得懂的块——审读的第一动作,永远先行 |
curl 直打 /api/* | 绕开界面直接验证接口行为(带 Cookie;第 7 章之后可以用 API Token) |
npx wrangler tail | 看后台日志——观察 waitUntil / Workflow / Cron 失败的唯一窗口 |
npx wrangler d1 execute yaf-db --local --command "…" | 直接查库:第 5 章验证事务写入、第 6 章验证「全文索引里存的到底是不是原文」都靠它 |
GET /api/search/status | 第 8 章起的语义索引进度表:已索引多少条、补算任务在不在跑 |
环境前提顺手交代一句:本地 npm run dev 时 AI 与向量库也直连线上(wrangler.jsonc 里标了 remote),所以需要一个登录过(wrangler login)的 Cloudflare 账号。也正因如此,你会亲眼看到一个重要现象:AI 不可用时 memo 照样存得进去,向量晚一点再补——「先落库、后理解」这个时间差,就是下一章的主角。
建议的循环是:读书建立模型 → 让 agent 动手 → 用书里的自检验证 agent。每章的「vibe 提示」会告诉你:这一章的知识里,哪些是 agent 替代不了你懂的(比如「为什么这里必须用事务」),哪些可以放心交给 agent(比如「把这段 SQL 抄成另一个表」)。第 15 章有一份完整的自检清单,读完可以把每个功能当考题自测。
yaf 是一个 flomo 式的卡片笔记应用:打开网页,在输入框写下「明天给妈妈打电话 #家事」,回车,它变成一张卡片;写多了以后,你可以按标签翻、按关键词和意思搜索、让 AI 每周回顾一次并给你一份报告。它的特别之处在于怎么部署:网页、API、搜索索引、AI、定时任务、微信机器人,全部打包成一个 Cloudflare Worker,用免费套餐就能跑,数据都在你自己的云账号里。
它有五个入口,全部通向同一套核心逻辑:
| 入口 | 谁在用 | 走哪条路 |
|---|---|---|
| 网页 | 你自己 | React 单页应用 + Cookie 会话 |
CLI yaf | 终端 / 脚本 | API Token(设备码登录授权) |
| MCP | Claude Code 等 AI 助手 | API Token + MCP 协议 |
| HTTP API | 任何程序 | API Token + scope 权限 |
| 微信 | 手机上发消息 | Durable Objects 长轮询收录 |
读代码最常见失败方式,是从 main.tsx 第一行顺着读——很快就会淹死在细节里。正确的姿势和看地图进城一样:先记住海岸线和几条主干道(本章),再每次只放大一个街区(后面每章)。你随时应该能回答「我在哪、这一块和整体什么关系」。
下面这张图是全书的「主地图」,颜色含义全书一致(见第 0 章图例):蓝色是浏览器,主色是 Worker 进程内部,绿色是存储,紫色是 AI,橙色是后台任务。带虚线动画的箭头表示「数据在流动」。
承载这张图的配置文件是 wrangler.jsonc——它声明「这个 Worker 需要哪些云资源」。现在只需要看懂结构,每个字段的含义后面各章会分别讲:
{
"main": "worker/index.ts", // 入口:整个后端从这里开始
"d1_databases": [{ "binding": "DB", ... }], // 数据库,代码里用 env.DB 访问
"ai": { "binding": "AI", ... }, // Workers AI,env.AI.run(模型, 参数)
"vectorize": [{ "binding": "VECTORS", ... }], // 向量索引,env.VECTORS.query(...)
"triggers": { "crons": ["*/15 * * * *"] }, // 每 15 分钟调用一次 scheduled()
"workflows": [{ "name": "yaf-index", "binding": "INDEX_WORKFLOW", ... },
{ "name": "yaf-insight", "binding": "INSIGHT_WORKFLOW", ... }],
"durable_objects": { "bindings": [{ "name": "WECHAT", ... }] }, // 微信长连接
"r2_buckets": [{ "binding": "MEDIA", ... }] // 图片/语音原文件(可选)
}
env.DB 就能拿到那项资源——不需要配置 IP、账号密码、连接池。第 3 章正式展开。现在放大主地图上的一次点击。你在输入框写下「明天给妈妈打电话 #家事」按保存——接下来的 2 秒里,下面 12 站依次发生。点按钮跟着走,图上会高亮当前这一站(其余变暗)。
①⑦ 是第 4 章(React);②③④ 是第 3、7 章;⑤ 是第 5 章;⑥ 是第 6 章;⑧ 是第 10 章;⑨⑪ 是第 8 章;⑩⑫ 是第 9 章。看不懂图里的某些词(FTS5?RRF?)完全正常——这正是后面各章要补的积木。现在你只需要记住旅程的形状:一条快的请求路径(用户在等),一条慢的后台路径(用户不等),以及一个定期巡逻的定时器。
| 目录 | 角色 | 代表文件 |
|---|---|---|
worker/ | 后端:API 与全部服务端逻辑(跑在 Cloudflare Workers 上) | index.ts 入口、auth.ts 鉴权、memos.ts 接口、memo-service.ts 核心读写、search.ts 检索 |
src/ | 前端:React 单页应用(构建后成为静态文件) | main.tsx 挂载、App.tsx 顶层状态机、api.ts 接口封装、components/ 界面组件 |
shared/ | 前后端共用:标签提取、中文切词 | tags.ts、segment.ts(第 6 章讲它为什么能共用) |
migrations/ | 数据库表结构的变更历史(只增不改) | 0001_init.sql 起步的 4 张表 |
packages/cli/ | yaf 命令行工具(独立的小包) | src/index.ts |
wrangler.jsonc | Cloudflare 资源清单(上面的主地图翻译成配置) | — |
前后端的代码量大致对半,但地位不对等:前后端只通过 /api/* 上的 JSON 接口说话。前端不知道 D1 的存在,后端不关心 React 的存在——这让两边可以独立理解和修改。这个边界是读这个项目最重要的坐标线。
Cookie(登录会话,全部权限);CLI 带的是 Authorization: Bearer yaf_pat_…(API Token,只有被授予的 scope 权限,而且看不到隐私 memo)。同一个中间件 requireScope() 两种都认(worker/auth.ts:82),但下游的 visibilityOf() 会据此决定它能看到的数据范围(worker/memos.ts:31)。「同一个入口,两种身份、两种可见范围」——这是第 7 章的主线。ctx.waitUntil:响应先走,判定后台跑,失败还有 Cron 兜底重判。「用户在等的事」和「可以晚点做的事」分开,是这个项目反复出现的权衡——第 3 章讲机制,第 8 章再遇一次(向量计算)。地图已经在你脑子里了。但上面出现的一切——interface、async、env.DB、{ ok: true }——都是用 TypeScript 写的。下一章我们用这个仓库自己的代码,把 TS 必需的积木一次补齐。
Promise<T>、Partial<…>、phase.kind === "app" 这类写法,能说出它在约束什么。这些积木贯穿后面全部章节。TS 不是另一门语言——它是「JavaScript + 类型标注」。你写的代码运行时仍然是 JS;类型在编辑和构建时被一个检查程序(tsc,即 TypeScript Compiler)核对,然后被剥掉。一句话记住它的价值:
它解决的问题:JS 里把 memo.id 拼错成 memo.ids,要到用户点按钮那一刻才在浏览器里报错。TS 把这类错误提前到你保存文件的那一秒——检查器读你标注的「数据形状」,指出所有对不上的地方。类型标注是写给检查器的注释,运行时不存在。
yaf 在每次提交前跑 npm run typecheck(package.json:12,背后是 tsc --noEmit:只检查、不产出文件)。CI 也跑同一命令(.github/workflows/ci.yml)。这意味着:类型对不对,机器说了算——这正是 vibe coding 的地基:agent 改完代码,typecheck 过没过是一道客观的闸门。
前后端之间传的每一样东西,yaf 都给它起了形状名。看前端最重要的一个(它和后端返回的 JSON 一一对应):
export interface Memo {
id: string; // 形如 "8f3c…" 的随机串
content: string; // Markdown 正文
createdAt: number; // 毫秒时间戳
tags: string[]; // 字符串数组
source: string; // web | wechat | api | mcp | cli
attachments?: Attachment[]; // ← 问号:可选字段,可能不存在
private?: boolean; // 隐私 memo:content 已是遮挡文本
lenses?: string[]; // 命中的视图,如 ["todo"]
}
字段: 类型;? 表示「访问前必须先判断存在」;// 注释在编辑器里会变成这个字段的悬浮文档。后端返回多个字段没关系——形状描述的是「至少有这些」。从此以后 memo.contnet 这种拼写错误在保存时就被抓出来;memo.attachments.length 不先判空也过不了 typecheck。名字是大写驼峰(Memo、ApiError),文件名是小写——这是 TS 生态的普遍约定。
普通字符串装什么都行。但主题只有三种、来源只有五种——这种「封闭集合」用字面量联合表达:
type ThemePref = "system" | "light" | "dark"; // 只能是这三个字符串
const SOURCES = ["web", "wechat", "api", "mcp", "cli"] as const;
type Source = (typeof SOURCES)[number]; // 推导出同样的联合
as const 把数组冻结成只读字面量;(typeof SOURCES)[number] 是「取这个数组所有元素类型」的写法。好处:以后往 SOURCES 加一项,Source 类型自动跟着变——一处改动,全项目受检。联合类型最强大的用法是判别联合(discriminated union):每种情形放一个共同的标签字段,检查器据此知道「此刻你拿到的是哪种情形」。yaf 前端的顶层就是一个四种情形的状态机:
type Phase =
| { kind: "loading" }
| { kind: "setup"; tokenRequired: boolean } // 还没有管理员,去初始化
| { kind: "login" }
| { kind: "app"; user: User }; // 已登录,user 一定存在
const [phase, setPhase] = useState<Phase>({ kind: "loading" });
if (phase.kind === "app") {
console.log(phase.user.email); // ✅ 检查器允许:kind 是 app 时必有 user
}
console.log(phase.user); // ❌ typecheck 报错:其他 kind 没有 user
两者都能描述对象形状,项目里几乎可互换。yaf 的惯例(也是社区常见惯例):对象形状用 interface,别名和组合用 type。组合是 type 独有的舞台——把现有类型变形出新类型:
type PlanInput = Partial<Omit<InsightPlan, "id" | "createdAt">>;
// Omit:去掉某些字段;Partial:全部字段变可选 → 「创建时随便传哪几个都行」
type Memo = ReturnType<typeof toMemo>;
// typeof:取函数的类型;ReturnType:取它的返回值形状
// toMemo 改了返回结构,Memo 自动跟着变,不用手写两遍
Partial、Omit、ReturnType 叫工具类型(utility types):类型层面的函数,输入一个类型、输出一个新类型。读到「尖括号里套类型」的代码,基本都是它们。前端所有请求都经过同一个函数——不管返回的是 memo 列表还是标签列表,发出请求、检查状态码的逻辑完全一样,唯一的变量是「返回值的形状」:
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`/api${path}`, { ...init, credentials: "same-origin" });
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new ApiError(res.status, ...);
return data as T; // 告诉检查器:按调用方说的形状对待
}
// 调用点用「类型的实参」指定 T:
api.listMemos(...) // → request<{ memos: MemoHit[]; nextCursor: string | null }>
const { user } = await api.me(); // user 自动是 User 类型,有补全、拼错即报错
<T> 读作「类型 T 是个参数」。泛型是 TS 的「函数」:值层面的复用用参数,类型层面的复用用泛型。Promise<T> 同理——「将来会给出的 T」。网络请求、读数据库都要等。JS 的处理方式不是「阻塞着等」,而是Promise——一张「值随后就到」的取货单。await 的意思是「在这里等取货单兑现,代码写法上保持从上到下」:
const [kw, sem] = await Promise.all([
keywordCandidates(env, userId, opts.q), // 关键词路(查 D1)
semanticCandidates(env, userId, opts.q), // 语义路(查向量库)
]);
// 两路同时出发,都回来才继续 —— 总耗时 ≈ 最慢的一路,不是两路之和
await 只能出现在 async 函数里;② async 函数的返回值自动包成 Promise;③ 想并行就用 Promise.all,一个 await 接一个 await 是串行。错误用 try { … } catch (e) { … } 接住。await 期间 CPU 可以去做别的(等网络正合适),但同一份 JS 代码永远不会两段同时执行——第 3 章会看到 Workers 正是利用这一点,用一个「单线程小容器」安全地服务成千上万的请求。
TS/JS 没有「全局共用一大坨」的传统,每个文件是一个模块:想给别人用就 export,想用别人的就 import。yaf 里前后端共用标签提取,就是一行 import 的事:
import { extractTags } from "../shared/tags"; // ../ 上一级目录
import type { Source } from "./env"; // type 只导入类型,运行时不存在
// import 的是「绑定的名字」,不是文件路径里的变量;
// 路径不写扩展名,打包器(第 14 章的 Vite)负责找到确切文件并拼装。
import type 明确说「我只要类型」——打包时这行会整个消失。这本书里你会反复看到「编译时存在、运行时消失」的二分,这是理解 TS 项目的钥匙。npm 是 JS 的包管理器(相当于 pip / cargo / bundle)。package.json 声明三样东西:
"scripts": { "dev": "vite", "typecheck": "tsc --noEmit",
"deploy": "node scripts/preflight.mjs && vite build && wrangler deploy" },
"dependencies": { "hono": "^4.13.13", "react": "^19.3.1", "zod": "^4.6.7", ... },
"devDependencies": { "typescript": "^7.0.2", "vite": "^8.3.4", "wrangler": "^4.151.0" },
"workspaces": ["packages/*"]
^4.13.13 表示「4.x 里 ≥4.13.13 都可接受」。精确版本锁在 package-lock.json。workspaces 让 packages/cli 作为子包共用同一份 node_modules(第 11 章)。npm run 名字 执行 scripts 里的命令。检查器多严,由 tsconfig.json 决定。yaf 开了 "strict": true——最严模式:不可选字段不许没赋值、可能为 null 的值必须先判断。读代码时请记住:凡是 typecheck 通过的项目,每个「没写 if 判断」的地方都是真的不用判断——类型系统已经替你证明过了。这是读 TS 代码和读动态语言代码最大的心态差异。
① 会读类型签名(interface、?、联合、泛型调用点)——这是你和 agent 沟通的共同语言,例如「把 visibility 参数加上,类型用 Visibility」;② 懂判别联合与收窄——状态机是前端 bug 的最大来源,审查 agent 的状态代码时你盯着 kind 字段看;③ 懂 Promise.all 与串行 await 的性能差别。而「具体的类型体操」「从报错反推改哪」可以放心交给 agent——typecheck 是客观闸门,错了它自己会修。
const s: Source = "web" 合法,const s: Source = "web "(多个空格)呢?"web " 不是五种来源之一,typecheck 直接报错。想放「脏数据」进类型化世界的入口只有一处:外部输入(JSON.parse、表单),yaf 都在那里做了校验(比如 tokens.ts:33 的 parseScopes 逐项检查)。const a = await f(); const b = await g(); B:const [a, b] = await Promise.all([f(), g()]);积木备齐了。现在回到第 1 章那张图上最奇怪的事实:这个项目没有服务器,env.DB 凭什么在代码里直接可用?下一章进入 Cloudflare Workers 的世界。
你以前部署后端,多半是:租一台云服务器 → 装 Node → 配 Nginx → 跑一个常驻进程 → 操心补丁、内存、扩容。Cloudflare Workers 把这套东西整个收走,你只交出一个 JS/TS 文件,它把你的代码复制到全球三百多个节点;哪里有请求,就在哪里现场开一个极轻量的沙盒运行你的函数,请求结束沙盒即可回收:
整个 yaf 后端的入口,就是导出一个带 fetch 方法的对象。Cloudflare 约定:有请求就调它,把「请求、云资源、执行上下文」作为三个参数递进来:
export default {
fetch(request, env, ctx) { // 每个 HTTP 请求进来这里
if (new URL(request.url).pathname === "/mcp") return handleMcp(request, env, ctx);
return app.fetch(request, env, ctx); // 其余交给 Hono(/api 路由 + 静态资源)
},
async scheduled(_controller, env, ctx) { // 定时触发(Cron Triggers):每 15 分钟进来这里(第 9 章)
ctx.waitUntil(Promise.allSettled([runDuePlans(env), judgeBackfill(env).then(() => backfillAll(env))]));
},
} satisfies ExportedHandler<Env>; // satisfies:检查「形状对不对」,但不改变推断
ctx.waitUntil()——「响应发出后请继续帮我跑这个 Promise」。第 1 章旅程的 ⑧⑨ 站就是它。它解决的问题:程序要连数据库,传统上需要「主机地址 + 账号 + 密码 + 连接配置」这类连接信息——写在哪都危险,泄漏了就是事故。绑定把资源直接挂到 env 对象上:你在 wrangler.jsonc 写 "binding": "DB",代码里 env.DB.prepare(sql) 就是那个数据库。没有地址、没有密码、没有连接池,平台代管一切。
yaf 的全部绑定定义在一处——worker/env.ts:1 的 Env 接口。这个文件是后端的「资源户口本」,值得整份读一遍(很短):
export interface Env {
DB: D1Database; // 必有:D1 数据库
AI: Ai; // 必有:Workers AI
VECTORS: Vectorize; // 必有:向量索引
MEDIA?: R2Bucket; // 可选(问号):R2 没开通就没有
ENCRYPTION_KEY?: string; // secret:加密用户密钥用
SETUP_TOKEN?: string; // secret:防抢先注册管理员
}
npx wrangler secret put 名字 写入,永远不进代码库)。类型上用 ? 表达「可选资源」——代码里就能被检查器逼着写 if (env.MEDIA),R2 没开通的程序逻辑自动降级而不是崩溃。本地 npm run dev 时,Wrangler 会在本机模拟出这些绑定(本地 SQLite 当 D1),secret 则放在 .dev.vars 文件里(已被 .gitignore 忽略)。注意 yaf 的 AI 和 Vectorize 标了 "remote": true(wrangler.jsonc:21-24):这两项没有本地模拟器,本地开发也在调真实云端。
Hono 是一个极小的 Web 框架(定位类似 Express,但为 Workers 这类环境而生),它只干一件事:把「URL + 方法」映射到处理函数。yaf 在入口里建了一个挂在 /api 前缀下的总路由,再把各业务的子路由挂上来:
const app = new Hono<AppEnv>().basePath("/api"); // 之后写 /memos 实际是 /api/memos
app.route("/auth", auth); // 登录相关 → 子路由 auth(worker/auth.ts)
app.route("/", memos); // memo 相关 → 子路由 memos(worker/memos.ts)
// …… tokens / insights / wechat / autoTags / usage 同样挂载
// 子路由里,一个典型接口长这样:
memos.get("/memos/:id", requireScope("memos:read"), async (c) => {
const memo = await getMemo(c.env.DB, c.get("user").id, c.req.param("id"), ...);
if (!memo) return c.json({ error: "不存在" }, 404);
return c.json({ memo });
});
c(context,上下文)是 Hono 给每个请求的随身物件——c.req 取输入(param 路径参数、query 查询串、json 请求体),c.json(值, 状态码) 造响应,c.env 拿绑定,c.get/c.set 存取本次请求内的共享变量。一个接口 = 一个 async 函数,进是 request,出是 response。HTTP 方法在这里是显式的:get/post/patch/delete 对应「读/建/改/删」。yaf 的 URL 设计遵循常见惯例(名词复数 + id),完整对照表在 README 的「API」一节,随读随查即可。
它解决的问题:「先确认登录、再干活」这段逻辑如果复制进 30 个接口,一定会有第 31 个忘掉。中间件是插在路由前的公共环节,可以改写请求、可以直接拦截(不登录就回 401),也可以在放行后追加处理。放行就是调用 await next()。
requireScope("memos:write") 传入所需权限、返回一个配置好的中间件,一份逻辑服务所有接口(worker/auth.ts:82)。export function requireScope(scope: Scope) {
return createMiddleware<AppEnv>(async (c, next) => {
const header = c.req.header("Authorization");
if (header?.startsWith("Bearer ")) { // Token 路线
const found = await findToken(c.env.DB, header.slice(7).trim(), c.executionCtx);
if (!found) return c.json({ error: "Token 无效或已过期" }, 401);
if (!found.scopes.includes(scope)) return c.json({ error: `缺少权限 ${scope}` }, 403);
c.set("user", found.user); // 存进上下文,下游 c.get("user")
return next();
}
const user = await sessionUser(c); // Cookie 路线(第 7 章细讲)
if (!user) return c.json({ error: "未登录" }, 401);
c.set("user", user);
await next();
});
}
构建后(第 14 章),React 应用变成一堆静态文件。wrangler.jsonc 里的 assets 配置让同一个 Worker 顺带发货:run_worker_first: ["/api/*", "/mcp"] 表示这些路径先过代码,其余路径直接发静态文件;not_found_handling: "single-page-application" 表示找不到的路径也返回首页——因为 /insights 这类地址是前端路由(第 4 章),要交给 React 处理。前后端同域还白送一个好处:Cookie 不跨域,第 7 章的会话方案因此变得简单。
① 绑定模型:新接口要用某个资源,先去 env.ts 和 wrangler.jsonc 确认绑定存在——agent 最爱在这里凭空 env.不存在的资源;② 中间件挂载:审查任何接口先看它的第二、三个参数,鉴权缺失是安全审查的第一站;③ waitUntil 语义:放进 waitUntil 的失败不会告诉用户,必须有兜底(yaf 用 Cron 巡逻)。写新接口、加新资源这类事 agent 做得很好——只要你在验收时问出「鉴权呢?失败兜底呢?」两个问题。
env.MEDIA?.delete(files) 和 env.MEDIA.delete(files) 在这个项目里有一个必然出 bug,是哪个?为什么?MEDIA?: R2Bucket(env.ts:10,R2 是可选资源,没开通时整个绑定不存在)。没写 ? / if 的版本对没开通 R2 的部署会直接抛 Cannot read properties of undefined。类型系统早就用问号提醒了这件事——这题考的是「读类型就是在读运行时风险」。后端的骨架立住了。但用户首先看到的是浏览器里的界面——那个输入框、那排卡片是怎么画出来、又怎么在保存后自己更新的?下一章切到蓝区:React。
useState / useEffect / useRef 各管什么;yaf 不用路由库是怎么做页面切换的;以及 Home 组件里那段「请求编号」代码在防什么事故。传统写法是「命令式」:拿到数据后,手动 appendChild、手动更新那一行文字、手动改按钮颜色——改的地方多了必然漏。React 换了一种思路(声明式):你只描述「当前状态长什么样」,界面更新由 React 负责。状态变了 → 重新执行组件函数 → 得到新的界面描述 → React 和旧描述比对 → 只把差异应用到真实 DOM:
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
import "./styles.css";
createRoot(document.getElementById("root")!).render(
<StrictMode><App /></StrictMode>,
);
<div id="root">——整个应用由 JS 现场画进去,这类应用叫 SPA(单页应用):浏览器只加载一次页面,之后一切变化都发生在 JS 里,URL 变了也不重新请求整页。! 是「我保证不是 null」的断言;StrictMode(React 严格模式) 是开发期的严格检查器,会故意把组件函数执行两遍,暴露「不纯」的写法。组件就是「接收 props、返回界面描述」的函数;返回的那种「像 HTML 的东西」叫 JSX——它其实是普通的函数调用和对象,构建时被转成 JS。三条语法要点:标签里用 {} 嵌任意表达式;class 写成 className(避免和 JS 的 class 关键字冲突);组件名必须大写。
if (phase.kind === "setup" || phase.kind === "login")
return <AuthScreen mode={phase.kind} onDone={boot} />;
if (path === "/device") return <DeviceApprove />;
if (path.startsWith("/settings")) return <Settings user={phase.user} path={path} />;
return <Home user={phase.user} path={path} view={...} onLogout={logout} />;
const [value, setValue] = useState(初始值):向 React 登记一块「会变的记忆」。setValue 不仅改值,还通知 React「该重画了」。输入框的 value 绑到状态、onChange 写回状态,就叫受控组件——文本永远以状态为准(Editor.tsx:45)。
发请求、订阅事件、操作定时器都属于副作用。useEffect(函数, 依赖数组) 的含义是:「渲染完成后,如果依赖变了,就执行这个函数;函数若返回另一个函数,下次执行前先调用它做清理」。yaf 最小的教科书在路由里,全文 18 行:
export function navigate(path: string) {
if (location.pathname + location.search + location.hash === path) return;
history.pushState(null, "", path); // 改地址栏,不刷新页面
dispatchEvent(new PopStateEvent("popstate")); // 手动广播「地址变了」
}
export function usePath(): string {
const [path, setPath] = useState(location.pathname);
useEffect(() => {
const on = () => setPath(location.pathname);
addEventListener("popstate", on); // 订阅地址变化
return () => removeEventListener("popstate", on); // 清理:组件卸载时退订
}, []); // 空依赖 = 只在挂载后跑一次
return path;
}
setPhase 调用。用「图先于代码」的方式读状态机,比顺着代码猜可靠得多。Home(src/components/Home.tsx)是全项目最大的组件,集中了你会反复遇到的三种写法。
范式一:搜索防抖。用户每敲一个字母都发请求会打爆接口。Home 用两个状态错开:query 跟随键盘,debounced 慢 250 毫秒,只有 debounced 变化才触发加载(Home.tsx:58-61)。
范式二:竞态守卫(重点,agent 常漏)。防抖也挡不住「先发的请求后回来」:先搜「猫」再搜「猫粮」,「猫」的响应如果慢一步,界面就会显示错误结果。看 yaf 怎么用 useRef 解决:
const reqId = useRef(0); // ref:跨渲染存活、改它不触发重画
const load = useCallback(async (reset: boolean, from: string | null) => {
const id = ++reqId.current; // 发一个新号牌
setLoading(true);
const r = await api.listMemos({...});
if (id !== reqId.current) return; // 号牌过期 = 已有更新的请求 → 丢弃本次结果
setMemos(reset ? r.memos : [...prev, ...r.memos]);
setLoading(false);
}, [debounced, tag, lens, mode, sort]);
[...prev, ...m] 是「旧数组展开 + 新元素」造出新数组:React 靠「引用不同」才发现状态变了,直接 push 进旧数组它是不理你的。范式三:无限滚动。列表底部藏一个哨兵元素,用浏览器原生 IntersectionObserver 观察它,一进视野且还有下一页(cursor 非空)就加载下一页(Home.tsx:115-123)。后端配套的是第 5 章的游标分页。
Vite 干两件事。开发时(npm run dev):起本地服务器,按需编译你正在改的文件,保存后热更新(HMR)——浏览器不刷新,组件原地换新,状态都还在。构建时(npm run build):把几百个模块打包、压缩成几个浏览器直接可用的文件(细节在第 14 章)。yaf 还加了 @cloudflare/vite-plugin,于是本地 dev 时 后端也真的跑在 Cloudflare 的运行时里——本地和线上行为一致,是它「敢只用免费套餐」的底气之一。
① 数据单向流动:审查界面 bug 时,先问「这个数据是谁的 state?谁改它?」——React 的 bug 九成出在「改了没 setState」「setState 了但依赖数组漏了」;② 依赖数组:agent 写的 useEffect,你逐个依赖核对(少了 = 用旧值,多了 = 反复执行);③ 异步竞态:任何「输入触发请求」的代码都要问一句「有号牌/防抖吗?」。至于样式调整、把现有组件抄一个变体,放心交给 agent。
setMemos 改成 memos.push(newMemo),界面会更新吗?filter / map / 展开运算符 造新值(如 Home.tsx:186 的删除:prev.filter((m) => m.id !== id))。术语叫「不可变更新(immutable update)」。界面会动了。按下保存后,api.createMemo 发出的 POST 已经在第 3 章的洋葱里穿行——穿过鉴权后,它落进 memo-service。一条 memo 到底在数据库里长什么样、为什么删一条 memo 要动五张表?下一章进绿区:D1。
SELECT_MEMO 那段子查询聚合的 SQL、并解释 yaf 翻页为什么不用 OFFSET。SQLite 不是「又一个 MySQL」:它没有服务器进程,就是一个读写标准 SQL 的单文件库——你的手机里每个 App 几乎都揣着几个它。D1 是 Cloudflare 托管的 SQLite:帮你管副本、备份、容灾,但使用方式就是 SQL。对你最重要的推论:D1 和你的 Worker 在同一位置,查询没有网络往返,await env.DB.prepare(sql).first() 快得像本地函数调用。
建表不用「连上去敲 SQL」,而是提交 迁移(migration)文件:migrations/0001_init.sql、0002_… 一路编号。每份迁移描述「从上一个状态怎么变到下一个状态」,npm run db:migrate:local / :remote 依次应用。两条铁律写在 CLAUDE.md 里:只新增文件、不改旧文件(旧文件已经在线上执行过了);合并前跑 typecheck。这个「只增不改」让任意版本升级都可回滚——第 14 章会回到这一点。
CREATE TABLE memos (
id TEXT PRIMARY KEY, -- 随机 UUID 字符串,不用自增数字
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
content TEXT NOT NULL,
created_at INTEGER NOT NULL -- 毫秒时间戳
);
CREATE INDEX idx_memos_user_created ON memos(user_id, created_at DESC);
CREATE TABLE memo_tags ( -- 标签拆成独立表,才能按标签筛选
memo_id TEXT NOT NULL REFERENCES memos(id) ON DELETE CASCADE,
user_id TEXT NOT NULL,
tag TEXT NOT NULL, -- 存完整路径,如 "读书/历史"
PRIMARY KEY (memo_id, tag)
);
ON DELETE CASCADE——删 memo 时它名下的标签、附件自动跟着删,不会留孤儿数据。会话表 sessions 同样挂在 users 上级联删除。索引按「查什么」建:最频繁的查询是「某用户的 memo 按时间倒序」,所以索引是 (user_id, created_at DESC)。后来六个迁移各自加东西:0002 加 API Token 与来源字段,0003 加全文搜索表,0004 加洞察,0005 加微信,0006 加自动标签标记(vector_hash),0007 加视图标记 memo_labels。读到任何一张表,按编号回迁移文件即可考古。
const id = crypto.randomUUID();
await db.batch([
db.prepare("INSERT INTO memos (id, user_id, content, source, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)").bind(id, userId, text, source, now, now),
...tagStatements(db, id, userId, text), // 先 DELETE 旧标签,再按 extractTags 结果 INSERT
...ftsStatements(db, id, userId, text), // 全文索引(第 6 章)
]);
return { id, content: text, tags: extractTags(text).sort(), ... };
? 占位符 + .bind(值),值永远不当 SQL 拼接——这就是防 SQL 注入(用户输入 '; DROP TABLE memos;-- 也只是一个无害的字符串参数);② 一组必须同生共死的写操作用 db.batch——memo 写进去了、标签没写进去,搜索就会漏它,batch 保证三组语句要么全部生效、要么全部不生效(事务)。搜索时值会进 LIKE '%词%',而 % 和 _ 在 LIKE 里有特殊含义——用户输入 % 会变成「匹配一切」。所以 yaf 单独有 escapeLike()(memo-service.ts:41)转义这三个字符,并在 SQL 里声明 ESCAPE '\\'。注入防的是「改语句结构」,通配符防的是「语义走样」,两件事。
渲染一张 memo 卡片需要:正文、标签列表、视图标记、附件。天真的写法是先查 memo,再对每条 memo 查 3 次子表——30 条卡片就是 90+ 次查询(所谓 N+1 问题)。yaf 的解法是 SELECT_MEMO 常量:主查询里嵌三个相关子查询,让数据库一次交齐:
SELECT m.id, m.content, ...,
(SELECT group_concat(tag, char(31)) FROM memo_tags WHERE memo_id = m.id) AS tags,
(SELECT json_group_object(label, json_array(score, source))
FROM memo_labels WHERE memo_id = m.id) AS labels,
(SELECT json_group_array(json_object('id', a.id, 'kind', a.kind, ...))
FROM attachments a WHERE memo_id = m.id) AS attachments
FROM memos m
group_concat 把多行标签拼成一个字符串(用 char(31) 即不可见的 ␟ 分隔——它不会出现在正常文本里),json_group_array/json_object 直接在数据库里拼出 JSON 文本。随后 toMemo()(memo-service.ts:64)把这行「扁平结果」拆回结构化对象,顺带做权限处理:隐私 memo 一律换成遮挡文本,只有显式 { raw: true } 才给原文。列表页的筛选条件(关键词、标签、视图、可见范围)任意组合,SQL 得动态拼。yaf 的写法是「条件数组 + 参数数组同步生长」,最后 AND 连接:
const where = ["m.user_id = ?"]; params = [userId]; // 第一条铁律:必带归属
if (opts.visibility === "token") where.push(TOKEN_VISIBLE_SQL); // 🔒 谁是 token、两个条件什么意思:第 7/10 章拆封
if (t) { where.push("EXISTS (SELECT 1 FROM memo_tags t WHERE t.memo_id = m.id
AND (t.tag = ? OR t.tag LIKE ? ESCAPE '\\'))");
params.push(t, `${t}/%`); } // 父标签含所有子标签
return { sql: where.join(" AND "), params };
m.user_id = ?——多用户系统里每条查询都必须带归属条件,漏了就是越权事故。查 #读书 时同时用前缀 读书/% 匹配子标签,所以点父标签能看到全部子标签的 memo(README 承诺的行为在此实现)。LIMIT 取 31 条只给 30 条——多取的一条用来判断「还有没有下一页」,返回 nextCursor 或 null。id 参与比较是为了同一毫秒的多条 memo 之间也能定出严格顺序(tie-break)。统计(memos.ts:154):一次 db.batch 并发跑两条聚合——总数/标签数/天数 一条,16 周热力图按天分组一条。时区处理藏在 SQL 里:date(created_at/1000 + 偏移秒, 'unixepoch') 把毫秒时间戳平移到用户时区再取日期,前端把 getTimezoneOffset() 传进来(api.ts:248)。
导出(memos.ts:174):同一个查询结果拼成 Markdown 或 JSON,用 Content-Disposition: attachment; filename*=UTF-8''… 让浏览器存成文件(filename* 是文件名支持中文的标准写法)。「按标签导出 + 交给 AI 分析」正是 vibe coding 的常用管道。
注意到 listMemos(db, userId, opts) 的签名——数据库是参数而不是全局变量。这让同一套函数被三个入口直接复用:REST(memos.ts)、MCP(mcp.ts:48)、微信桥(bridge.ts:189)。加一个功能(比如自动标签)三端同时受益。这是后端代码最重要的分层习惯:接口层管 HTTP,服务层管业务,互不掺和。
① 每条查询带 user_id:审查 agent 写的任何新 SQL,第一眼找归属条件——它是数据安全的最后闸门;② 写操作的原子边界:一次业务动作动几张表,就该是一个 batch;③ 占位符纪律:看到字符串模板拼 SQL(反引号里塞变量进语句本体)直接打回。让 agent 写 SELECT、加索引、写迁移文件都很安全——迁移只增不改 + typecheck + 本地迁移三道闸兜着。
.bind(id, userId, text…) 换成 JS 模板字符串把值直接拼进 SQL,攻击者能干什么?x'); DELETE FROM memos;-- 之类的值就能改写语句结构——删除整张表、绕过归属条件读别人的数据(经典 SQL 注入)。参数化后它只是一坨无害文本。这题的另一面是:读 agent 产出的 SQL,看到反引号模板里出现变量(而不是整个常量语句)就该警惕。读写都齐了,但搜索框还欠着两笔债:中文没有空格,「妈妈」怎么被切成词?以及第 1 章承诺的「意思相近也能搜到」的另一半。下一章先还第一笔:标签提取与全文检索。
shared/tags.ts 为什么能同时被浏览器和 Worker 使用;#家事。如何被提取成标签而 # 标题 不会;中文全文搜索为什么必须先切词;以及 FTS5 和 LIKE 兜底各管哪一段。「内容里写 #家事」这条规则,至少三个地方要用:后端写库时提取标签(memo-service.ts:215)、前端把卡片里的标签渲染成可点击链接(markdown.ts:23)、搜索时按标签过滤(buildFilter)。如果两端各写一版正则,迟早规则走样——后端存了 家事。,前端却链接到 家事,搜索立刻漏。所以这段逻辑放进 shared/,前后端各自 import:
import { normalizeTag, TAG_RE, TRAILING_PUNCT } from "../shared/tags"; // 前端
import { extractTags } from "../shared/tags"; // 后端
Intl)。export const TAG_RE = /(^|\s)#([^\s#]+)/gu;
export const TRAILING_PUNCT = /[.,;:!?,。;:!?、))\]】"'”’]+$/u;
export function normalizeTag(raw: string): string | null {
const cleaned = raw.replace(TRAILING_PUNCT, ""); // 防线三:去尾部标点
const parts = cleaned.split("/").map((p) => p.trim()).filter(Boolean);
return parts.length ? parts.join("/") : null; // 层级 a/b/c 规整
}
function stripCode(content: string): string { // 防线二:代码不算
return content.replace(/```[\s\S]*?```/g, " ").replace(/`[^`\n]*`/g, " ");
}
export function extractTags(content: string): string[] {
const tags = new Set<string>();
for (const m of stripCode(content).matchAll(TAG_RE)) {
const tag = normalizeTag(m[2]);
if (tag) tags.add(tag);
}
return [...tags];
}
TAG_RE:(^|\s) # 必须在行首或空白之后(防线一,# 标题 因 # 后有空格而天然出局);[^\s#]+ 标签本身不含空白、不嵌套 #;g 全局找所有、u 按Unicode 码点处理中文。matchAll 迭代全部匹配。「写 MD 里 #读书/历史 会得到完整路径」——父标签筛选(第 5 章的前缀查询)就建立在这个存储格式上。跑一遍贯穿案例:「明天给妈妈打电话 #家事」→ stripCode 不变 → TAG_RE 命中 #家事 → normalizeTag 无尾标点 → ["家事"]。而写在行内代码里的 #ff0000 在防线二就被整块删掉了——颜色值不会被当成标签。
数据库怎么知道「明天给妈妈打电话」里含有「妈妈」?暴力法是 LIKE '%妈妈%'——逐字符扫全表,数据一大就慢,而且无法按相关度排序。数据库自带的全文索引(SQLite 的 FTS5 扩展)把文本预先切成「词」建倒排索引,查得又快又能打分——但它的默认分词器按空格切词,对中文整段无效(「明天给妈妈打电话」会被当成一个超长词)。
yaf 的解法:自己切,再存。Intl.Segmenter 是浏览器和 Workers 都内置的标准 API(底层是 ICU 词典),能把中文按语义切词:
const segmenter = new Intl.Segmenter("zh", { granularity: "word" });
export function segmentWords(text: string): string[] {
const out: string[] = [];
for (const s of segmenter.segment(text))
if (s.isWordLike) out.push(s.segment.toLowerCase()); // 去标点,统一小写
return out;
// "明天给妈妈打电话" → ["明天", "给", "妈妈", "打电话"]
}
export function queryTerms(q: string): string[] { // 搜索词也切、也去重
return [...new Set(q.trim().split(/\s+/).filter(Boolean)
.flatMap((w) => segmentWords(w)))];
}
加粗 不该只命中写了星号的文本。export function ftsStatements(db, memoId, userId, content) {
return [
db.prepare("DELETE FROM memos_fts WHERE memo_id = ?").bind(memoId), // 先删
db.prepare("INSERT INTO memos_fts (body, memo_id, user_id) VALUES (?, ?, ?)")
.bind(segmentWords(plainText(content)).join(" "), memoId, userId), // 再插
];
}
function ftsQuery(terms: string[]) {
return terms.map((t) => `"${t.replace(/"/g, '""')}"*`).join(" ");
// → '"妈妈"* "打电话"*':每个词允许前缀扩展,词之间是 AND
}
NEAR 之类会被引号变成普通词),* 让「打电话」命中「打电话了」。整句放进 MATCH,再按 bm25() 排序——词越稀有、出现越多,分数越高。切词不是万能的:刚写入还没来得及进索引的、或切成「电话」却搜「话」的,FTS 会漏。所以 keywordCandidates(search.ts:154)在 FTS 结果之后追加一路 LIKE '%词%' 子串匹配补漏——但只补到候选数上限,且排在 FTS 结果后面。第 8 章你会看到同样的「双保险」哲学再次出现:FTS/LIKE 管字面,向量管意思,两路融合。
① 规则一致性问题:凡是「写的时候做 X、读的时候也做 X」的逻辑(切词、标签、转义),先问 agent「两端共用的是不是同一份代码」——分叉是搜索类 bug 的头号来源;② 正则要能逐段读:TAG_RE 的三段结构值得亲手拆一遍,agent 写的正则你至少要会验「这一段排除了什么」;③ FTS5/LIKE/bm25 各自的角色边界。让 agent 调正则、加一种标签格式,风险低、验证容易(typecheck + 手测两条)。
#家事。」提取出什么标签?「##大标题」呢?家事:TAG_RE 命中「#家事。」,normalizeTag 把尾部中文句号剥掉。后者提取不出任何标签:(^|\s) 要求 # 在行首或空白后,第二个 # 紧跟第一个 #,不满足 [^\s#]+ 对「非#开头」的要求——Markdown 的多级标题因此天然安全。到本章为止,第 1 章旅程的前 6 站已经全部落地。但第 4 站「鉴权」我们只看了洋葱的皮:密码怎么存才不怕拖库?Cookie 里那串东西凭什么能冒充登录态?CLI 又是怎么在没有浏览器的终端里完成登录的?下一章进红区:认证与授权。
这是全书唯一专门讲安全的章节,因为 yaf 的安全设计集中在三处:密码的存储(抗拖库)、会话的发放与验证(抗冒用)、Token 的权限收敛(抗滥用)。理解这三处,你就有了审查任何「agent 写的登录代码」的清单。
哈希函数是单向绞碎机:SHA-256(输入) 得到固定长度指纹,从指纹无法还原输入。但纯哈希防不住彩虹表(预计算的海量「常见密码 → 指纹」对照表),所以要加盐(salt):每个用户生成一段随机字节拼进密码再哈希,让预计算失效。再用 PBKDF2 把哈希重复搅十万次——拖库者每猜一个密码都要付出十万次的代价,而正常登录只是登录时慢 0.1 秒,无感。
export async function hashPassword(password: string): Promise<string> {
const salt = crypto.getRandomValues(new Uint8Array(16)); // 每人一段新盐
const hash = await pbkdf2(password, salt, PBKDF2_ITERATIONS); // 搅十万次
return `pbkdf2$${PBKDF2_ITERATIONS}$${toB64(salt)}$${toB64(hash)}`;
} // 库里存的是这串自带配方的文本,而不是裸指纹
export async function verifyPassword(password: string, stored: string) {
const [algo, iter, salt, hash] = stored.split("$"); // 从存档里读出配方
const actual = await pbkdf2(password, fromB64(salt), Number(iter));
let diff = 0; // 常量时间比较:
for (let i = 0; i < actual.length; i++) diff |= actual[i] ^ expected[i];
return diff === 0;
}
=== 提前返回)——防止攻击者通过「比较耗时差」逐位猜指纹(计时侧信道)。所有密码学原语都来自 crypto.subtle(WebCrypto 标准 API),Workers 原生支持,无需引第三方库。登录成功后,后端生成一串高熵随机数当作会话 ID(sid),发给浏览器存进 Cookie;之后每个请求自动携带,后端据此查人。关键的安全决策在两行代码里:
const sid = randomToken(); // 256 位随机,猜不出来
await c.env.DB.prepare("INSERT INTO sessions (id_hash, ...) VALUES (?, ...)")
.bind(await sha256(sid), ...); // 库里只存 sid 的哈希!
setCookie(c, "yaf_sid", sid, {
httpOnly: true, // JS 读不到 → XSS 偷不走
secure: 协议是 https, // 明文网络上传不了
sameSite: "Lax", // 别的网站发起的请求不带 → CSRF 难
maxAge: 30 天,
});
Cookie 会话是「你本人、全部权限」;CLI、MCP、脚本需要的是「可撤销、可限定、可过期」的钥匙——PAT(Personal Access Token,个人访问令牌)。yaf 的 PAT 长这样:yaf_pat_ 开头 + 24 字节随机串。权限用 scope(权限范围)表达,一共四档:
export const SCOPES = ["memos:read", "memos:write", "export", "insights:read"] as const;
export async function createToken(db, userId, name, scopes, expiresAt) {
const secret = "yaf_pat_" + randomToken(24); // 明文:只此一次,还给用户
await db.prepare(`INSERT INTO api_tokens (..., token_hash, prefix, scopes, ...)
VALUES (..., ?, ?, ?, ...)`)
.bind(..., await sha256(secret), secret.slice(0, 12 + 4), scopes.join(" "), ...);
return { secret, ... };
}
requireScope("memos:write") 时,中间件把 Token 哈希后查库、核对 scope、再把用户放进上下文。前缀 prefix 存明文的前 12 个字符(yaf_pat_ 8 字符 + 4 位随机),让设置页能显示 yaf_pat_a83f… 供辨认——又一个「存哈希 + 存无价值前缀」的组合。另外 Token 验证会顺手记录最后使用时间(pat.ts:22),但用 ctx.waitUntil 挪到响应后、还做了 60 秒节流:高频调用不会每次都写库。还有一个容易忽略的分层:Token 管理本身只允许 Cookie 会话操作(tokens.ts 的所有接口挂 requireUser 而非 requireScope)。一把钥匙不能自己配钥匙、也不能改锁——就算 Token 泄漏,攻击者也拿它撤销不了别的 Token、改不了密码。修改密码接口同理(auth.ts:154)。
CLI 跑在终端里,没有网页可以输密码;把密码敲进终端再发给服务器则太危险。标准解法是设备码流程(OAuth 2.0 Device Flow 的简化版):CLI 只要一张「等批准的号牌」,批准动作由你在浏览器里完成:
改密码(auth.ts:154)体现了一个小而完整的模式:验证当前密码 → 一个 db.batch 同时「更新密码哈希 + 删除除当前会话外的所有会话」——换锁顺动作废旧钥匙。设置页的「会话数 / 退出其他设备」就是对 sessions 表的两个简单查询(auth.ts:173-187)。另外,部署时可选设置 SETUP_TOKEN:创建管理员必须提供这个口令,防止你部署完还没来得及注册、管理员位就被陌生人抢先(auth.ts:112)。
① 敏感操作的挂载:审查登录/Token/密码相关代码时,逐个接口核对中间件——「Token 能不能给自己续期」「改密码要不要 Token」是真实出现过的漏洞形态;② 三类「只存哈希」:密码(加盐慢哈希)、会话 sid、Token——看到明文入库就该打回;③ Cookie 四属性的含义。其他如「把 scope 加一档」「给 Token 加过期提醒」这类改动,agent 做得很好,验收时你检查四档清单即可。
钥匙体系完备了。现在回到第 6 章留下的另一半承诺:LIKE 兜底之外,「意思相近但用词不同」的搜索是怎么实现的?那 1024 个数字是什么?下一章进入 AI 区。
先看第 6 章方案的天花板:你记过「明天给妈妈打电话」,几周后想找它,输入的却是「联系妈妈」——字面上没有共同的词,LIKE 和 FTS 全部落空。人和人的记忆从不按字面索引,这一章补上「按意思」的那一半。
Embedding(嵌入)模型是一个「语义压缩器」:读一段文字,输出一列数字(yaf 用的 bge-m3 输出 1024 个),训练目标保证——意思相近的文本,向量方向也相近。「给妈妈打电话」和「联系妈妈」字面几乎不重叠,但两个向量夹角很小。两列数字的「方向一致程度」用余弦相似度(cosine)衡量:1 表示同向,0 表示无关,yaf 实测无关文本在 0.3–0.45(search.ts:12 的注释),所以低于 0.53 的一律丢弃。
export async function embed(env: Env, texts: string[]): Promise<number[][]> {
const res = (await env.AI.run("@cf/baai/bge-m3", { text: texts })) as { data?: number[][] };
return res.data; // 每段文字 → 1024 个小数
}
// indexMemos(search.ts:79,节选):写一半的那条链路
const texts = rows.map((r) => embedText(r.content + (extra.get(r.id) ?? "")));
const vectors = await embed(env, withText.map((i) => texts[i]));
await env.VECTORS.upsert(withText.map((i, k) => ({
id: rows[i].id, // 向量的主键 = memo 的 id
values: vectors[k], // 1024 维
metadata: { userId: ..., createdAt: ... }, // 随行小卡片,用于过滤
})));
embedText 把标签拼在正文前面再算向量——用户亲手打的 #家事 是最强的语义信号;图片描述、语音转写也拼进去(attachments 的 description),所以「发一张截图」将来能被描述文字搜到。② metadata 过滤:所有人的向量混居一个索引,查询时 filter: { userId } 只在自己的小宇宙里找(Vectorize 为此建了元数据索引,见 wrangler.jsonc:22 的注释)。它解决的问题:一百万个 1024 维向量里,「离这个查询向量最近的 30 个」——暴力算是十亿次乘法,专门的数据结构(近似最近邻算法)把它做成毫秒级。Vectorize 就是 Cloudflare 托管的这类仓库:你 upsert(插入或更新)、query(按向量找近邻)、queryById(按库里已有向量找近邻)。第三种正是「相关笔记」的实现(search.ts:252):拿 memo 自己的向量查邻居、排除自己、按 RELATED_MIN 阈值截断——点开任何一张卡片,都能看到「和它意思相近的旧笔记」。
现在有两条候选路:FTS 关键词(第 6 章)给一个名次表,语义给一个分数表。难点是量纲不同:bm25 分数可能是 7.3,余弦分数是 0.81,直接加权毫无意义。RRF(Reciprocal Rank Fusion,倒数名次融合)的聪明之处:不看分数,只看名次——
const RRF_K = 60;
kw.forEach((id, i) => {
scores.set(id, (scores.get(id) ?? 0) + 1 / (RRF_K + i + 1)); // 关键词第 i 名
});
sem.forEach(({ id, score }, i) => {
scores.set(id, (scores.get(id) ?? 0) + 1 / (RRF_K + i + 1)); // 语义第 i 名
}); // 两路都出现的 memo:两项相加 → 天然排到最前
| 名次 | memo | RRF 分 |
|---|---|---|
| 1 | 给妈妈打的电话号码 #家事 | 1/61 ≈ 0.0164 |
| 2 | 妈妈生日礼物想法 | 1/62 ≈ 0.0161 |
| 名次 | memo | 余弦 | RRF 分 |
|---|---|---|---|
| 1 | 周末和妈妈视频,说好常联系 | 0.71 | 1/61 ≈ 0.0164 |
| 2 | 给妈妈打的电话号码 #家事 | 0.68 | 1/62 ≈ 0.0161 |
| 3 | 项目周会纪要(被 0.53 阈值拦下) | 0.38 | 丢弃 |
| # | memo | 总分 | 命中 |
|---|---|---|---|
| 1 | 给妈妈打的电话号码 #家事 | 0.0325 | 关键词+语义 |
| 2 | 周末和妈妈视频,说好常联系 | 0.0164 | 仅语义 |
| 3 | 妈妈生日礼物想法 | 0.0161 | 仅关键词 |
向量不会自己出现。yaf 用三级机制保证「每条非隐私 memo 终究有向量」,每一级都独立失败、独立补救:
export function scheduleIndex(env, ctx, userId, memo) {
ctx.waitUntil((async () => {
const cur = await env.DB.prepare("SELECT content FROM memos WHERE id = ?")...
const judged = await judgeMemo(env, row); // 先判定(第 10 章)
if (judged && !judged.private) await indexMemos(env, [row]); // 非隐私才算
})());
}
vector_hash 记录「算向量时的文本指纹」(SHA-256)。编辑 memo 会把它清空,再被后台任务看到时就知道「内容变了,要重算」——这是个通用的「用哈希表达待办」技巧。第三级:Cron 每 15 分钟扫一遍「超过 5 分钟还没有向量」的 memo(backfillAll,search.ts:314),把它们交给 Workflow。它解决的问题:批量算 500 条向量的任务跑了 10 分钟后 Worker 被回收,进度全丢,从头再来。Workflows 把任务写成一步步有存档的流水线:step.do("名字", fn) 的每步结果都被持久化,重试时从失败的步继续,已完成的不重跑。写法像普通代码,语义像「自动存盘的游戏」。
export class IndexWorkflow extends WorkflowEntrypoint<Env, { userId: string }> {
async run(event, step) {
for (let i = 0; i < 400; i++) {
const n = await step.do(`batch ${i}`,
{ retries: { limit: 3, delay: "10 seconds", backoff: "exponential" }, timeout: "5 minutes" },
async () => {
const rows = await 取 50 条 vector_hash IS NULL 的 memo;
await indexMemos(this.env, rows);
return rows.length;
});
if (n < 50) break; // 取不满一批 = 全部算完
}
}
}
① 隐私 memo 永不进向量:判定为隐私后 applyPrivate 会删掉已有向量、FTS 里也只留遮挡文本(judge.ts:111)——搜索子系统看到的世界,始终是被隐私策略过滤后的版本;② 本地开发也在调真的 Workers AI(wrangler.jsonc 里 "remote": true),免费额度(每天 1 万 neurons)在本地一样消耗。
① RRF 和阈值:搜索结果「不相关/漏」时,你要能判断是调阈值(SEMANTIC_MIN)、还是换模型、还是索引没建——三条路的排查入口分别是 indexStatus、EMBED_MODEL、vector_hash 计数;② step 的代价:Workflow 每步都有持久化开销,「拆多细」是权衡不是默认;③ 向量只增不查也能跑:语义路失败时代码整体降级成关键词搜索(search.ts:218 的 catch)——「AI 挂了产品不死」是这类功能的底线设计,审查 agent 产出的 AI 功能时必问这句。
搜索解决了「找到某一条」。但笔记的更大价值在「看不清的时候」——写了一个月,你自己的主题、拖延、矛盾,自己看不见。下一章:让 AI 每周替你重读一遍,还能证明它没瞎编。
「洞察计划」是用户在设置页配的定时任务:频率(每天/每周/每月/自定义 cron)、时间、时区、范围标签、回看窗口(windowDays)、分析视角、模型。保存时后端用 croner(一个 cron 表达式解析库)把「每周日 21:00」翻译成「下次运行的时间戳」存进 next_run_at(insights.ts:71)。之后是一条完全由平台驱动的链:
export async function runDuePlans(env: Env) {
const plans = await 查询 next_run_at 到期的计划(最多 20 条);
for (const plan of plans) {
// 先把 next_run_at 推进到下一次,才启动生成 —— 条件里带旧值,
// 若两个 Cron 撞车,只有改到那一条的能成功(乐观锁)
const res = await db.prepare("UPDATE insight_plans SET next_run_at = ?, last_run_at = ?
WHERE id = ? AND next_run_at = ?").bind(nextRun(...), now, plan.id, plan.next_run_at).run();
if (!res.meta.changes) continue;
await startInsight(env, plan.user_id, planConfig(plan), now);
}
}
export async function startInsight(env, userId, plan, now) {
const id = crypto.randomUUID();
await 插入一条 status = 'generating' 的洞察记录; // 先立牌子:正在生成
await env.INSIGHT_WORKFLOW.create({ id, params: { insightId: id, userId, plan, ... } });
}
generating 的记录就能显示「生成中」,Workflow 完成后把它改成 ready。每家的模型 API 长得都不一样(Anthropic 一套、Workers AI 一套)。yaf 用 Vercel AI SDK(ai 包)做统一门面:业务代码只对 generateText({ model, prompt, ... }) 编程,各家的差异由 provider 适配器吸收(@ai-sdk/anthropic、workers-ai-provider)。换模型 = 换一行工厂调用。洞察可以选 Workers AI 的 qwen3(免费用)或 Claude(用户自带 key,密钥经信封加密(AES-GCM)入库,第 12 章拆)。
export const ReportSchema = z.object({
title: z.string().describe("一句话标题,不超过 20 字"),
summary: z.string().describe("2–4 句话的总体摘要"),
themes: z.array(item).describe("反复出现的主题(2–4 条)"),
connections: z.array(item).describe("新的连接……(0–3 条)"),
questions: z.array(z.object({ question: z.string(), why: z.string(), refs }))
.describe("值得继续追问自己的问题(1–3 条)"),
});
const { output } = await generateText({
model, system: SYSTEM, prompt: formatMemos(memos),
output: Output.object({ schema: ClusterSchema }), // 让模型按 schema 说话
maxRetries: 2,
}); // output 的类型自动是 z.infer<typeof ClusterSchema> —— TS 与 zod 在此会师
z.infer),连 describe() 文案都会喂给模型当格式说明。结构化输出(structured output)让「模型返回 JSON 并保证形状」成为可靠工程,而不是字符串祈祷。失败会自动重试两次。心思一:短编号。给模型的材料不标 UUID,而是编成 W1、W2…(窗口内)和 H1、H2…(历史相关)。模型抄 UUID 极易抄错一位,抄错「W3」却一眼可查——给模型的标识符要短、要可校验,这是幻觉治理的第一道工序。
心思二:历史不是按时间拉,是按意思拉。窗口内每条 memo 拿自己的向量去 queryById(第 8 章的 queryById 又出场了),召回过去最相关的最多 30 条——「新的连接」类结论因此有据可依:它真的连接的是历史上意思相近的记录。
心思三:反馈进入提示词。你给过往报告点过「有用/没用」,下一次生成会把「用户觉得没用:『上周总结』,避免类似泛泛而谈」拼进提示(loadFeedbackHint,insight-engine.ts:161)。个人化不是玄学,是把用户信号写进上下文。
幻觉(hallucination):模型一本正经地编造不存在的东西(这里特指编造引用——说「你在 W7 说过」,其实没有 W7)。接地(grounding):强迫每个结论挂靠在可验证的源头上。yaf 的校验(checkReport,insight-engine.ts:346)是硬规则:解析每条结论的 refs → 在材料表里查找 → 找不到或全部无效 → 整条丢弃并计入 stats.dropped;有效的把 W3 换回真实 memo id,前端把引用渲染成可点击的卡片链接。
于是「AI 说的每句话都能点回原文」不是界面装饰,而是数据结构保证:报告里只存在校验通过的引用。你审查任何 AI 功能时,这是含金量最高的问题:「模型说错了,用户怎么发现?」
insight-engine.ts:12 有一段罕见的注释:记录了 2026-10-25 的实测——qwen3-30b-a3b 中文与结构化输出稳定、5–10 秒;qwen3.8-27b 质量更好但长输入超时;deepseek/glm 不在免费套餐;gpt-oss 结构化输出解析失败。这其实就是 vibe coding 的正确打开方式:把实验结论作为团队记忆写进代码,agent 下次改这块时自动继承这些约束,而不是重新踩坑。
① 定时任务三件套:at-least-once 触发、幂等/乐观锁去重、失败可见(failed + error 字段)——agent 写定时逻辑时逐项对照;② 结构化输出 + 运行时校验:模型输出永远不可信,schema 校验不是可选项;③ 引用校验模式:任何「AI 基于你的数据下结论」的功能都该有它。调提示词、换模型、加报告板块这类迭代可以放心交给 agent——反正 schema 校验和引用校验是自动闸门。
WHERE id = ? AND next_run_at = 旧值。第一个触发者改掉了 next_run_at,第二个的 WHERE 匹配不到行,res.meta.changes 为 0,直接 continue。「先占坑再干活」——用数据库行级条件实现单次执行,不需要额外的锁服务。洞察是「重 AI」功能:大模型写报告。但 yaf 还有一类「轻 AI」:每条 memo 写入后 1 秒内,要回答「这是待办吗?写了密码吗?」——这类判断用不上大炮,用的是另一个神奇的小模型。下一章:视图与判定。
第 1 章旅程的 ⑧ 站只说「后台判定」,现在拆开它。yaf 想解决的问题是:懒得打标签的人也该有「待办清单」;写了密码的 memo 不该在 Token 调用、导出、AI 材料里裸奔。解法是在写入后自动给每条 memo 打两个分数。
export interface Lens {
key: string; name: string;
question: string; // 交给判定模型的问题
threshold: number; // 分数 ≥ 它 → 命中
ask?: number; // 分数落在 [ask, threshold) → 卡片上问用户
}
export const LENSES: Lens[] = [
{ key: "private", name: "隐私", threshold: 0.8,
question: "这段文字是否写出了可以直接用来登录或访问账户的凭据的值……?" },
{ key: "todo", name: "待办", threshold: 0.7, ask: 0.4,
question: "这条笔记是否记录了一件作者自己要去做的事……?" },
];
export const LENS_REV = 1; // 视图集版本号:改问题或阈值就 +1
judged_rev < LENS_REV)。改标准 → rev+1 → 全库自动重判,没有一次性脚本。第 9 章的洞察用生成式模型(写报告),这里用 Workers AI 的 Clef——一个只回答「是/否 + 概率」的分类式小模型:你给它一段材料(state)和一批问题,它一次返回每题的概率,不写一个字的正文。便宜、快(百毫秒级)、输出天然结构化。选型直觉:能分类的就别生成——生成会跑题,分类只有对错。
const state = [row.content, ...附件描述].join("\n").trim();
const questions = {}; // 一次把两个视图的问题都带上
for (const lens of LENSES) questions[lens.key] = { type: "noul", instructions: lens.question };
const scores = await askClef(env, state, questions); // → { private: 0.02, todo: 0.81 }
const userPrivate = await 查 memo_labels 里用户手标的 private;
const isPrivate = userPrivate ? userPrivate.score >= 0.5 // 用户优先
: (scores.private ?? 0) >= 0.8;
const masked = isPrivate ? await maskPrivate(env, row.content) : null;
await env.DB.batch([
// 带上 content = ? 条件:判定期间用户又编辑了 → 这批结果全部作废(防竞态)
db.prepare("UPDATE memos SET judged_rev = ?, is_private = ?, masked_content = ?
WHERE id = ? AND content = ?")...,
...LENSES.map((l) => db.prepare(`INSERT INTO memo_labels ... source = 'model', ...
ON CONFLICT(memo_id, label) DO UPDATE SET ... WHERE memo_labels.source = 'model'`)...),
]);
WHERE memo_labels.source = 'model',你手改的标记永远稳如泰山;③ content 条件防竞态——判定是异步的(waitUntil),期间用户可能又编辑了正文,旧判定写回新正文就是数据错乱,用「内容没变才生效」一笔挡住。这个设计把「模型不确定」变成了产品交互:低分安静,高分直接打上「待办」角标(MemoCard 的 lens-chip),中间地带不硬猜,反问用户一句「这是待办吗?」(MemoCard.tsx:186 的 lens-ask 区块)。用户点的答案以 source: "user" 落库——训练信号和即时纠错一次完成。
判定为隐私后,遮挡不是「整段糊死」,而是 maskPrivate(judge.ts:39):把每一行变成一个独立问题(「L3 这一行是否写出了凭据的值?」),一次 Clef 调用批量判完,概率 ≥0.3 的行被遮挡——阈值故意放低,「宁可多遮」。还有一道兜底正则 SECRETY(同时含字母和数字的 8 位以上串):模型漏判时,长得像密钥的串照样被处理。遮挡保留行首标签名(GitHub: ••••),让你还记得这是哪个账号的密码:
GitHub: ghp_9f8e7d6c5b4a3210fedcba → GitHub: ••••
服务器 root 密码 Tr0ub4dor&3 → 服务器 root 密码 ••••
这只是讨论密码管理策略的一行 → (原样保留:没有凭据值)
masked_content,之后 applyPrivate(judge.ts:111)把全文索引换成遮挡版、删掉已有向量——第 8 章说过的「搜索子系统只见过滤后世界」在这里兑现。原文只有网页会话能看(/memos/:id/raw,memos.ts:70),且前端显示原文有 30 秒倒计时自动糊回去(MemoCard.tsx:53)。按来源自动加标签(auto-tags.ts:59):CLI/API/MCP 写入的 memo 可自动补 #cli 等标签,方便「只看机器写入的」。实现是纯文本手术:appendTags 把缺的标签追加到正文末尾——和第 6 章同一套 extractTags 判定「哪些已有」,规则不分叉。
AI 用量圆环(usage.ts):侧栏那个小圆环显示今天整个账户的 Workers AI 用量(每天免费 1 万 neurons)。两个技术点值得记:① 用 Cloudflare 的 GraphQL Analytics API 查用量,按 modelId 归类到「向量/图片/洞察/分类」(usage.ts:16 的 FEATURES 表——又是注册表);② 结果用 Cache API 缓存 5 分钟(usage.ts:83),缓存键里带日期——跨过 UTC 零点自然失效,无需定时清理。这是 Workers 平台自带的边缘缓存,一句 caches.default 就能用。
模型的两个分数永远可能错:漏判的密码靠 SECRETY 兜底但不敢说穷尽(这正是 SETUP/可见性层层设防的原因——遮挡只是第一道,Token 默认看不到未判定 memo 是第二道);误判的待办有一句「问用户」+ 手改覆盖兜底。AI 功能的健壮性不靠模型多准,靠错了之后系统的每条退路。
① 异步写回的竞态:waitUntil 里写库,必须想「这期间数据变了怎么办」——yaf 的答案是条件 UPDATE(content 一致才生效),agent 极易漏;② 用户数据 > 模型输出:任何「AI 自动标 X」的功能都要有用户覆盖且永不被模型覆盖,这是一条可以原样搬走的审查规则;③ 阈值/ask 的产品含义。调阈值、加新视图(复制一条 Lens 配置)很适合交给 agent——LENS_REV 机制保证存量自动重判。
ON CONFLICT … DO UPDATE SET … WHERE memo_labels.source = 'model'(judge.ts:100-101):已存在的记录若是用户标的(source='user'),UPDATE 的 WHERE 不满足,模型的分数写不进去。判定可以反复重跑,用户意志只此一份。至此 yaf 的 AI 三部曲完结:检索(找得到)、洞察(看得透)、判定(管得住)。但你可能一直在想那个问题:这些能力,凭什么只有网页能用?下一章把两把钥匙递出去——给 AI 助手的 MCP,给终端的 CLI。
它解决的问题:想让 Claude Code 查你的笔记、让另一个助手也查你的笔记,各写一套对接?M 个助手 × N 个数据源 = M×N 份适配代码。MCP 是一个开放协议,把对接标准化成 M+N:数据源方实现一次「MCP 服务器」,所有支持 MCP 的助手都能即插即用——像 USB-C,一根线什么设备都能插。三个核心名词:工具(tool)=助手可调用的函数(本项目的全部内容);资源(resource)=可读取的数据(yaf 未用到);传输(transport)=消息怎么走(yaf 用 Streamable HTTP:普通 HTTP POST,模型客户端把「调用哪个工具、什么参数」发到 /mcp)。
用户侧的接线只有一行(README 的 MCP 一节):
claude mcp add --transport http yaf https://你的域名/mcp \
--header "Authorization: Bearer yaf_pat_xxxx"
server.registerTool(
"search_memos",
{
description: "在用户的 yaf 笔记里搜索。传 query 时默认混合检索(关键词 + 语义," +
"按相关度排序),能找到意思相近但用词不同的 memo;tag 不带 #……都不传时按时间倒序。",
inputSchema: z.object({
query: z.string().optional().describe("搜索内容,可以是关键词,也可以是一句话"),
mode: z.enum(["hybrid", "keyword", "semantic"]).optional(),
limit: z.number().int().min(1).max(50).optional().describe("返回条数,默认 20"),
}),
},
async ({ query, mode, tag, limit }) => {
if (!can("memos:read")) return denied("memos:read"); // 工具级权限检查
const r = await search(env, user.id, { q: query, ..., visibility: "token" });
return text(`找到 ${r.memos.length} 条(关键词 ${r.stats.keyword} · 语义 ${r.stats.semantic}):\n\n` + ...);
},
);
search_memos(混合检索)、get_memo、add_memo、get_related、list_tags、list_insights。注意三点:① description 是写给模型看的文档——它会照着决定用不用、怎么传参,参数的 describe 同理;写它就是给 AI 写产品文档,是 MCP 开发的主要手艺;② 处理函数直接调用第 5 章的 search/createMemo——MCP 不是平行世界,是同一套服务层换了件协议外衣;③ visibility: "token"——助手看不到隐私 memo 和未判定的 memo(第 5 章 buildFilter 的第二种可见性)。鉴权在 handleMcp(mcp.ts:160):进来先 findToken 验 Bearer(第 7 章同款函数),然后把「用户 + scopes」传进每个工具闭包。scope 检查在工具内部做而不是路由上,因为 /mcp 只有一个入口、却承载六种能力——权限粒度和工具粒度对齐,模型拿到无权限时收到的是一句它能理解、会转告用户的文本(isError: true),而不是一个它看不懂的 403 状态码。
packages/cli 是 npm workspace 里的独立小包(MIT 单独授权),发布成 npm 上的 yaf-cli,全局安装后提供 yaf 命令:
yaf login --host https://你的域名 # 走第 7 章图 7-1 的设备码流程
echo "灵感:给导读加一张时序图 #yaf" | yaf add # 管道当输入,POSIX 风格
yaf search 关键词 --tag inbox
yaf export --format md -o inbox.md
yaf add 从 stdin 管道读内容——「任何命令的输出都能变成一条 memo」,这是 Unix 哲学;② login 就是把第 7 章的时序图跑一遍:申请设备码 → 打开浏览器 → 轮询 → Token 存进 ~/.config/yaf/;③ 所有命令最终只是拼 HTTP 请求打 /api/*——CLI 与 MCP 一样,没有自己的业务逻辑,服务层唯一,入口繁多。| 入口 | 鉴权 | 来源标记 | 可见性 | 落点 |
|---|---|---|---|---|
| 网页 | Cookie 会话 | web(默认,不显示图标) | 全量(隐私给遮挡) | REST + Hono |
| CLI | PAT(设备码申请) | cli(X-Yaf-Source 头) | token 视角 | REST |
| MCP | PAT | mcp | token 视角 | 工具调用 |
| HTTP API | PAT | api | token 视角 | REST |
| 微信 | 绑定关系(bot_token) | 全量(写入方向) | Durable Objects |
看第二列和第四列的规律:鉴权方式决定可见性。会话=全量、Token=收敛、微信=只进不出。每条 memo 记下 source(卡片上有小图标,SourceIcon.tsx),数据从哪来回哪去,可审计。
① 工具描述即界面:评价 MCP 功能好坏,先读 description——含糊的描述直接导致模型乱传参,这不是代码 bug,是文档 bug;② 权限面最小化:新工具上线前问「这个能力配哪个 scope?Token 视角该不该看到?」;③ 复用检查:agent 写新入口时,盯住「有没有绕过 memo-service 自己写 SQL」。给现有服务加一个 MCP 工具、加一个 CLI 子命令,都是低风险高回报的 agent 任务。
PUT /memos/:id/labels/…,那个接口挂的是 requireUser(memos.ts:77),只认 Cookie 会话,PAT 直接 401;③ 即便它问「这条 memo 内容」,Token 视角下隐私 memo 根本不可见(TOKEN_VISIBLE_SQL)。纵深防御:能力层、路由层、数据层各拦一道。/mcp 单独分流了——它不是 REST 语义(请求体是 MCP 协议消息,不是业务 JSON),鉴权方式也不同(只认 Bearer,不认 Cookie)。把它和业务 API 平级分开,两边各自干净;wrangler.jsonc 的 run_worker_first: ["/api/*", "/mcp"] 也印证了这是两个平级入口。总表第五行还空着原理:微信这个入口为什么需要一个「Durable Objects」?消息怎么进来的、语音怎么变成可播放的文字、bot_token 凭什么加密存着?下一章进入全书最「平台特色」的一章。
先声明边界:微信收录是实验性功能,走的是微信 ClawBot 的非官方协议(protocol.ts 是协议适配层,别当成官方 API)。正式讲机制前,先看清它对平台的特殊要求——它需要一个常驻、有状态、单实例的东西,而这恰好是 Workers 刻意不给你的。
普通 Worker 是「一次请求一个一次性沙盒」:没有常驻内存、没有本地存储、同一秒可能开几百个副本。而微信连接需要一个循环:「拉消息 → 处理 → 200 毫秒后再拉」,还要记住游标拉到哪了——不能开两个副本同时拉(消息会乱)。DO 就是 Cloudflare 给的解法:一个有名字的单实例对象(idFromName(userId) 保证同名永远同一实例),自带存储(键值 + 内置 SQL),支持 alarm——「未来某时刻叫醒我」。它像一台只属于一个用户、按秒计费的微型服务器。
export class WechatBridge extends DurableObject<Env> {
async start(userId: string) { // 扫码确认后由路由调用
await this.ctx.storage.put("userId", userId);
await this.ctx.storage.delete(["cursor", "lastMemo", "failures"]);
await this.ctx.storage.setAlarm(Date.now()); // 立刻唤醒自己
}
async alarm() { // 被叫醒:拉一轮消息
const binding = await 查 wechat_bindings 表(含加密的 bot_token);
const token = await open(this.env.ENCRYPTION_KEY, binding.bot_token_enc);
const cursor = (await this.ctx.storage.get("cursor")) ?? "";
const resp = await getUpdates(base_url, token, cursor, POLL_TIMEOUT_MS);
if (resp.get_updates_buf) await this.ctx.storage.put("cursor", resp.get_updates_buf);
for (const msg of resp.msgs ?? []) await this.handle(userId, binding, token, rules, msg);
await this.ctx.storage.setAlarm(Date.now() + 200); // 200ms 后再来一轮
} // 出错时:failures 计数退避,最长 120 秒;连续失败写进 last_error 供设置页显示
}
ctx.storage)放游标、上次合并的 memo、失败计数——这些「循环状态」有了家。alarm 是心跳:不是常驻进程在死循环,而是「每轮结束预约下一轮」,平台在间隙可以回收计算资源,有消息时毫秒级唤醒。GET 长轮询 30 秒(POLL_TIMEOUT_MS)让消息到达几乎即时——这就是「微信发消息、网页几乎同时出现」的秘密。收到消息后(bridge.ts:149)按顺序过五道关:① 忽略自己说的(BOT 类型);② 只收绑定者本人(from_user_id 不符直接丢弃——防别人拉群污染你的笔记);③ 按 message_id 去重(协议会重复投递,DO 内置 SQL 建了 seen 表只留最近 2000 条,firstSeen 查过才继续);④ 拼正文——文字和语音转写进正文,图片/语音本体走附件;若开了合并窗口,2 分钟内的连续文字直接 updateMemo 追加成一条(连发几段 = 一张卡片);⑤ 落库 + 后续:
if (!memo) memo = await createMemo(this.env.DB, userId, appendTags(body, tags), "wechat",
{ allowEmpty: hasMedia }); // 纯图片允许空正文
if (hasMedia) {
this.ctx.waitUntil(saveMedia(this.env, userId, memo.id, items, ...)
.catch(async (e) => { /* 全失败时补一句说明,避免空白卡片 */ })
.then(() => scheduleIndex(this.env, this.ctx, userId, memo))); // 再进第 8 章的索引链
} else scheduleIndex(this.env, this.ctx, userId, memo);
if (rules.replyConfirm) this.ctx.waitUntil(sendText(..., "已记录 · #微信 #家事", ...));
微信的媒体是加密 CDN 资源,流水线是:下载 → AES-128-ECB 解密 → (R2 开通则)存原文件 → 登记 attachments 表 → 图片再要一句 AI 描述。三个平台知识点:
MEDIA.put(key, bytes) / MEDIA.get(key),key 形如 u/用户/memo/附件.jpg)。它是可选绑定:没开通时 mediaEnabled=false,附件只存描述文字,功能照常降级。读取走 /api/attachments/:id(routes.ts:181):先查附件归属(user_id 对不上就 404),再从 R2 拿流式返回——文件私有,须经鉴权的接口转发,不搞公开 URL。export async function seal(secret: string | undefined, plain: string): Promise<string> {
const iv = crypto.getRandomValues(new Uint8Array(12)); // 每次加密用新随机数
const data = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, await key(secret), ...);
return `v1.${toB64(iv)}.${toB64(data)}`; // 版本号.iv.密文,将来可换算法
} // key(secret):ENCRYPTION_KEY 是部署级 secret(第 3 章),用户 token 是数据
wrangler secret put 管 bot_token?因为 secret 是部署级的——整个应用一份;而 bot_token、Claude key 是每用户一份、存在 D1 里。折中方案:库里只存密文,解密钥匙(ENCRYPTION_KEY)只有部署环境有——拖走数据库拿到的都是 AES-GCM 密文(带认证标签,篡改即解密失败)。这个「部署 secret 当根钥匙、加密用户级秘密入库」的模式可直接搬走。① 什么时候需要 DO:「有状态 + 单实例 + 定时心跳」三个信号同时出现才是它的领地,别让 agent 到处乱用(有免费额度限制);② alarm 不是死循环:每轮预约下一轮的语义决定了「状态放 storage、代码可重入」的写法;③ 用户级秘密的加密模式。扩展微信功能(新消息类型、新规则)适合交给 agent——协议细节在 protocol.ts 适配层里被隔离,业务层动不到。
Cache-Control: private 防中间缓存。代价是每次多一跳 Worker——用延迟换隐私,对笔记应用是显然正确的取舍。后端故事讲完了。回到蓝区收尾:你每天盯着的那张卡片——Markdown 怎么变成安全的 HTML、搜索词怎么高亮、热力图怎么算、⌘K 怎么聚焦——前端还有一半的细节值得抠。下一章把前端从「会动」讲到「耐看」。
memo 内容是用户输入的——如果你写了 <script>偷别人Cookie()</script>,而应用把它原样插进页面,每个看到这条 memo 的浏览器都会执行这段脚本。这就是 XSS:不可信文本混进了「代码通道」。DOMPurify 的职责是白名单洗牌:解析 HTML、只保留安全标签(段落/加粗/表格…)、剥掉一切可执行的东西,然后才准上屏。
export function renderMarkdown(src: string, terms: string[] = []): string {
const html = marked.parse(src, { async: false }); // ① MD → HTML
const clean = DOMPurify.sanitize(html, { RETURN_DOM: true }); // ② 净化,返回 DOM
linkifyTags(clean); // ③ #tag 变可点击链接
highlight(clean, terms); // ④ 命中词包 <mark>
clean.querySelectorAll("a[href]:not(.tag)").forEach((a) => {
a.setAttribute("target", "_blank"); // ⑤ 外链新窗口
a.setAttribute("rel", "noopener noreferrer"); // 且切断反向访问
});
return clean.innerHTML;
}
RETURN_DOM: true 让净化结果直接是可遍历的 DOM 而非字符串——③④ 都是 DOM 操作。React 侧用 dangerouslySetInnerHTML 插入(MemoCard.tsx:172)——这个吓人的名字就是 React 的声明:「我已另行保证安全」,安全保证正是这条管道。③④ 的实现是同一个模式(markdown.ts:8 与 45),值得当成一套手艺学:TreeWalker 只遍历文本节点,并用 acceptNode 跳过 code/pre/a(代码里的 # 不是标签、链接里的词不该二次高亮);命中处不在原节点上硬改,而是用 createDocumentFragment 重组——「保留前段 + 插入链接 + 保留后段」最后 replaceWith 一次替换。为什么不在字符串上跑正则?因为那时文本已经带着合法 HTML 标签,正则会把标签内容也当文本处理,一改就烂——操作结构,用结构的方式。
html = useMemo(() => renderMarkdown(shown, terms), [shown, terms])——渲染管道不便宜(解析+净化+两次遍历),依赖不变就复用上次结果。第 4 章说「组件函数反复执行」,useMemo 就是给「贵的结果」上缓存的标准位置。left ≤ 0 时清空 raw。计时器在 useEffect 里注册、在清理函数里销毁,组件随时卸载都不泄漏。document 的 mousedown,点击目标不在菜单内(!menuRef.current?.contains(e.target))就关闭,Esc 同理。 Sidebar 的用量圆环更进一步:用 createPortal 把弹出卡片挂到 body 上(Sidebar.tsx:246),避免被侧栏的滚动容器裁剪——「弹出层逃出父容器」的标准解法。递归组件:标签是树(读书/历史 嵌在 读书 下),buildTree(Sidebar.tsx:13)把扁平的 tag 计数拼成 TagNode 树,TagItem 组件在自己的 JSX 里渲染 <TagItem>——组件调用自己,树的深度决定递归深度。子标签选中时父级自动展开(containsActive 的 useEffect),这是树形 UI 最常见的两个需求。
热力图(Sidebar.tsx:73):16 周 = 112 个格子,useMemo 里从「今天往前推」生成日期序列,把后端按天分组的计数装进 Map 查表,level() 把条数映射成 0–4 档颜色(l0…l4 class)。没有任何图表库——网格图用 CSS 网格 + 类名切换就是全部,「上库」之前先想想是不是 20 行 CSS 的事。
useTheme(theme.ts)只有 30 行但很完整:偏好存 localStorage(try/catch 包住——隐私模式下写 localStorage 会抛错,功能不能因此挂);「跟随系统」时删掉自定义属性、交给 CSS 的 prefers-color-scheme;切换是三态轮换 system → light → dark。所有颜色都是 CSS 变量,暗色只是变量表换了一组值——主题不是两套样式,是一份样式两本字典。
① XSS 三道闸的位置:净化在中间、后处理在净化后、React 插销是出口——agent 加任何「把用户内容渲染上屏」的代码,按图 13-1 逐站核对;② 贵计算上 useMemo、事件监听必有清理:这两条是 React 代码审查的固定动作;③ 无障碍不是附加题:本项目给图标按钮全配了 aria-label,给图片描述配了 alt——agent 产出的 UI 你抽查这三处。改样式、加角标、调交互细节,大胆交给 agent。
代码之旅到此结束:三个区(前端、后端、AI)、五个入口、三级后台全部走完。最后一章谈「代码之外」——这本书怎么变成你电脑上跑着的服务:构建、部署、以及那套让 agent 替你干活还不出事的护栏。
npm run deploy 的每一步在做什么、preflight 拦下的是哪两种真实事故、升级为什么永远可以回滚、以及「deploy 分支模式」解决了什么问题。"deploy": "node scripts/preflight.mjs && vite build && wrangler deploy"
// ① 检查:现在部署安全吗? ② 打包 ③ 上传发布
&& 是「前一步成功才继续」——preflight 不过,后面根本不会跑。这就是把「流程」写成「代码」:不靠人记住顺序。const dbId = config.match(/^\s*"database_id"\s*:\s*"([0-9a-f-]{36})"/m)?.[1];
if (!dbId) fail("wrangler.jsonc 的 D1 配置缺少 database_id。继续部署会让 wrangler
新建一个空库,原有数据不会被删除,但站点会读不到。……");
const out = spawnSync("npx", ["wrangler", "d1", "migrations", "list", dbName, "--remote"], ...);
if (!out.includes("No migrations to apply")) fail("线上数据库有未应用的迁移。
部署前请先记录 Time Travel 书签,再应用迁移:……");
yaf 的升级策略建立在一条纪律上(CLAUDE.md 明文规定):迁移只增不改。新版本只添加表和列,从不修改或删除旧结构——于是「上一版代码跑在新表结构上」依然正确(它用的列都还在)。由此得到一个漂亮的性质:回滚代码永远安全,不需要「回滚数据库」。数据本身还有第二重保险:D1 Time Travel 可以把数据库恢复到过去 30 天内任意时间点,升级文档要求操作前先记一个「书签」。两层保险合起来:升坏了 → 代码退回上一版 + 数据回到书签,全程可逆。
yaf 是公开仓库,任何人 clone 后自己部署——但有个别扭的小东西:database_id。它是你数据库的身份证(wrangler.jsonc 里那串 UUID),每人不同、且不该提交到 main(main 要保持「任何人拿来即可安装」的干净状态)。解法是流程约定:部署者开一个自己的 deploy 分支,把 id 提交在那里;升级时把新版 main 合并进自己的 deploy 分支,冲突面极小。你此刻读的仓库正处于这个模式(当前分支就是 deploy)。
更妙的是 docs/AGENT-INSTALL.md 和 AGENT-UPGRADE.md 的存在:它们把「安装」「升级」写成给 AI agent 执行的操作手册——检查环境、引导登录、建资源、设 secret、部署、验证,每步都有预期输出和回滚方法。用户只需要对 agent 说「照这个文档帮我装」。这是 vibe coding 时代的「运维文档」形态:写给机器执行、人在关键处点确认。
| 东西 | 存哪 | 为什么 |
|---|---|---|
| ENCRYPTION_KEY / SETUP_TOKEN / CF 分析 Token | wrangler secret put | 部署级秘密,进平台保险箱,代码库零痕迹 |
| 本地开发用同款秘密 | .dev.vars | 本地约定文件,.gitignore 已挡住 |
| database_id | deploy 分支的 wrangler.jsonc | 不是秘密但是「个人配置」,不该进 main |
| 用户的 Claude key / bot_token | D1(AES-GCM 密文) | 每用户一份,用部署级 secret 当根钥匙加密(第 12 章) |
① preflight 的两个检查:它们是事故清单的代码化——agent 替你部署时,你要能看懂它是否真的过了这两关(而不是跳过);② 回滚路径:升级前记 Time Travel 书签、迁移只增不改、回滚只退代码——这套节奏你必须能口头复述,因为它决定你敢不敢让 agent 动线上;③ 什么进什么不进仓库:secret 永不进、个人配置进 deploy 分支。整个 AGENT-INSTALL 流程适合交给 agent,但「点 Allow」和「设管理员密码」两处永远是你。
全项目的地图、代码、部署都走完了。最后一章不做新的阅读,只做两件事:回收第 1 章开头的那个问题,以及把全书散落的「你必须亲自懂」收拢成一张可以随身携带的清单。
第 0 章留过一个问题:传统上要四台服务器才能干的事,yaf 用一个免费 Worker 全做了。现在你可以给出四块拼图组成的完整答案:
env. 上的名字,连接信息归平台管(第 3 章)。| 层 | 必须亲自懂 | 出处 |
|---|---|---|
| 语言 · TS | 类型签名怎么读(interface/联合/泛型调用点);判别联合与收窄;Promise.all 与串行 await 的差别 | 第 2 章 |
| 后端 · 结构 | 绑定模型(env 里有什么先查 env.ts);中间件挂载(鉴权看第 2、3 参数);服务层复用(新入口不许绕过 memo-service) | 第 3、5、11 章 |
| 后端 · 数据 | 每条 SQL 必带 user_id;写操作同生共死用 batch;游标分页的原理;占位符纪律 | 第 5 章 |
| 后端 · 异步 | waitUntil 的失败用户看不见,必须有兜底;异步写回的竞态(条件 UPDATE);「先占坑再干活」的去重 | 第 3、9、10 章 |
| 前端 | 数据单向流动与不可变更新;useEffect 依赖数组逐项核对;异步请求的竞态守卫;XSS 净化管道的顺序 | 第 4、13 章 |
| AI | 模型输出永远要运行时校验(zod);引用必须可校验(防幻觉);AI 挂了要降级不死;能分类别生成 | 第 8、9、10 章 |
| 平台 | secret 与 vars 的边界;Workflow step 的代价与重试;DO 的适用信号(有状态+单实例+心跳);迁移只增不改 | 第 3、8、12、14 章 |
| 流程 | typecheck 是客观闸门;preflight 两检查;升级先记 Time Travel 书签;secret 永不进仓库 | 第 14 章 |
agent 说「改好了」之后,按顺序问(也是你 code review 的固定动线):
npm run typecheck。| # | 任务 | 你会练到 |
|---|---|---|
| 1 | 给统计接口加一个字段(如「本周新增」),并在侧栏显示 | 全链路小改:SQL → 接口 → api.ts 类型 → 组件(第 3、4、5 章的连线) |
| 2 | 给 MCP 加一个工具(如 random_memo:随机返回一条) | 照猫画虎扩展入口:工具注册、scope 检查、服务层复用(第 11 章) |
| 3 | 加一个新视图 Lens(比如「灵感」),走完 LENS_REV 重判 | 注册表模式、阈值与 ask 的产品调参、rev 机制(第 10 章) |
| 4 | 给自己部署一个 yaf,再升级一次版本 | AGENT-INSTALL/UPGRADE 全流程、preflight、Time Travel 书签(第 14 章)——做完你就是这个系统的主人 |
这本书的每个概念都有官方源头,带着书里的中文术语 + 下面的英文关键词去搜,一小时能补一课:TS 官方 Handbook(narrowing / generics / utility types);React 官方文档(thinking in React / useEffect / useMemo);MDN(fetch / IntersectionObserver / Cookie 的 SameSite / Intl.Segmenter);Cloudflare Docs(Workers bindings / D1 / Workflows / Durable Objects alarms / Vectorize / R2 / Wrangler commands);MCP 官网(tools / transports)。查的时候优先一手文档,读代码时回到本书对应的章——两相印证,概念才长在你身上。
按首次出现的章节分组;每条一句话,按「中文正名 + 英文原词」的格式——背不下没关系,读 agent 产出的代码时回来查。
这本书从头到尾只做了一件事:把「一个真实项目」拆成你能消化的依赖顺序,再逐块给你「为什么」。合上书后,你对 yaf 的记忆不该是 15 章的目录,而是那 12 站的旅程——每站你都知道它在哪个文件、为什么在那、坏了会先坏哪。
vibe coding 不会淘汰「懂系统的人」,恰恰相反:agent 把打字的部分变便宜之后,判断的部分变贵了。你现在手里有两样判断的依据:一张能随时在脑中调出的架构地图,和一份对着 agent 产出逐条过的清单。去把练手路线的第 4 条做完——给自己部署一个 yaf。那时这本书才真正读完。