从 git push 到浏览器:私有空间的数据链路
起点:一个矛盾的需求
我想做一个像 QQ 空间一样的东西:发文章,但每篇能单独控制"谁能看"。有的给所有人,有的只给好友,有的要口令,有的只有我自己。
听起来简单,但一旦落到"托管在 GitHub Pages 或 Cloudflare"这种静态方案上,就会撞上一个硬约束:
静态托管的产物是公开的。任何被构建进 HTML 的内容,F12 一看就露。
所以"用前端把它藏起来"根本不是访问控制,只是障眼法。真正的访问控制必须满足一个条件:内容在到达浏览器之前就被拦下。
方案:把守门人放在服务端
架构只有三层:
浏览器 → Cloudflare Worker(守门人) → 私有仓库
- 文章存在一个私有仓库里,只有 markdown
- 网站代码在另一个公开仓库里(Worker + 前端)
- Cloudflare Worker 持有读私有仓库的 token,在前端和仓库之间当唯一的门
关键在于:Worker 先验证访客身份,再决定"给不给他这篇文章的正文"。不给,正文压根不进入响应体。
为什么选 Cloudflare Worker
市面上能当守门人的东西不少:一台 VPS、一个 Vercel Serverless Function、一个 AWS Lambda。但 Cloudflare Worker 在这件事上有几个别人比不了的特性:
它跑在边缘。 Cloudflare 的网络覆盖 330 多个城市,你的请求会被路由到离你最近的那台机器上执行。一个在汕头的人访问,代码就在香港或新加坡跑,而不是绕到美国再绕回来。对于"鉴权 + 回源 GitHub"这种轻量逻辑,网络往返时间(RTT)才是大头,CPU 执行时间几乎可以忽略。
它没有传统意义上的冷启动。 传统 Serverless(比如 AWS Lambda)每次请求可能要等 100ms 到 2 秒,因为要现起一个容器、加载运行时。Worker 用的是 V8 isolate——和 Chrome 每个标签页用的同一套隔离机制。一个 V8 进程里可以塞几百上千个 isolate,互相切换几乎零开销。一个 isolate 的启动速度大约比容器里的 Node 进程快 100 倍,启动时内存消耗也低一个数量级。 你的代码"醒来"的时间在 5 毫秒以内,而不是几百毫秒。
它天然适合做"轻逻辑中间层"。 这个项目里 Worker 干的活其实很简单:读一个 cookie、查一次 KV、调一次 GitHub API、把 JSON 过滤一下返回。没有重计算,没有大内存占用,不需要长连接。这种"轻、快、短"的负载正是 Workers 设计的目标场景。
反过来说,如果你要做的是视频转码、大规模数据分析、或者需要持久化状态的复杂业务,Workers 不是好选择。但对一个个人站点的访问控制网关来说,它几乎是量身定做的。
完整链路:一次访问到底发生了什么
这是本文的重点。假设你在浏览器里访问 https://space.s9y.workers.dev/api/posts/hello。
第 0 段:文章怎么进入系统
- 在本地
posts/hello.md写文章,头部带 frontmatter git push到私有仓库- 没有构建步骤。没有 CI 生成 HTML,没有预渲染
这是和传统静态博客最大的区别:文章不是"构建产物",而是"数据源"。改权限、加口令、隐藏文章,都不需要重新构建部署。
第 1 段:请求到达 Worker
因为配置了 run_worker_first = true,Cloudflare 不会先用静态资源匹配,而是先交给 Worker。
这里有个坑:默认情况下 Cloudflare 会先返回匹配到的静态文件,只有没匹配才走 Worker。那样
/api/*会被 SPA 回退成index.html,鉴权直接失效。必须显式打开run_worker_first。
请求到达边缘节点后,Cloudflare 内部的边缘代理(代号 FL,Front Line)会看请求的 host 和路由,找到绑定的 Worker,然后把它派发到一个已经预热好的 V8 isolate 里执行。没有容器,没有虚拟机,没有从磁盘加载代码的过程。 你的 JavaScript 代码在部署的那一刻就已经被编译成字节码,分布到了全球每个节点上。请求来的时候,只需要在已有的 V8 进程里开一个新的 isolate——相当于在已经打开的书上翻到新的一页,而不是重新去图书馆借一本书。
这也解释了为什么 Worker 的"冷启动"几乎不存在。传统 Serverless 的冷启动慢,是因为要起一个进程、初始化运行时、加载代码。Worker 的运行时(V8 进程)一直在跑,你的代码只是其中一个 isolate,启动成本约等于零。一个 isolate 的启动速度大约比容器中的 Node 进程快 100 倍,内存消耗低一个数量级。每个 isolate 有自己独立的内存空间,和其他租户的代码完全隔离。一个 V8 进程可以同时托管数百甚至数千个 isolate,无缝切换。
顺便澄清一个常见的误解:isolate 并不是"长命"的。它可能因为机器资源紧张、脚本行为可疑、或者触发了资源限制而被回收。所以不要把可变状态存在全局变量里——它可能在下一个请求来之前就没了。这也是为什么这个项目把缓存做成 isolate 内的短期缓存(60 秒),而不是假设它永久存在。
第 1.5 段:Worker 的运行时边界
Worker 和浏览器、Node.js 一样用 V8,但它不是浏览器,也不是 Node.js。这是很多人在写 Worker 代码时第一个撞上的墙。
没有 window,没有 document,没有 fs,没有 process.exit()。你不能在 Worker 里读文件系统,不能用 Node 的 http 模块,不能起一个后台线程。Worker 的 API 是一套刻意裁剪过的子集:fetch、Request/Response、Headers、URL、crypto、TextEncoder,以及 Cloudflare 自己加的 KV、D1、R2、Durable Objects 等绑定。
这套限制是刻意的。V8 isolate 的安全模型要求代码不能访问宿主环境的任何非显式授权资源——没有文件系统就没有越权读写文件的风险,没有 process 就没有篡改宿主状态的可能。每个 isolate 的内存完全隔离,一个 Worker 的代码无法读取同一进程里另一个 isolate 的内存。
顺带说一句,Cloudflare 为 Spectre 类侧信道攻击做了大量工程工作。2021 年他们和格拉茨工业大学合作,首次在 Workers 上远程复现了 Spectre 攻击,之后上线了 DyPrIs(Dynamic Process Isolation)——自动把行为可疑的脚本隔离到独立进程。2026 年 Cloudflare 在内部重新评估远程 Spectre 攻击时发现了一个 DyPrIs 的实现局限,并在生产环境中实现了最高 12 bit/s、99% 准确率的数据泄漏。作为应对,他们改进了 DyPrIs、集成了 V8 Sandbox,并部署了基于 MPK(Memory Protection Keys)的进程内隔离机制,用硬件强制的内存边界把 Worker 堆保护起来。截至 2026 年,Cloudflare 仍在持续加码防御,包括冻结本地计时器、禁止多线程和共享内存、定期打乱内存布局等手段。
对我们的场景来说,这套安全模型反而帮了忙:Worker 天然只能通过显式的网络请求去拿外部数据,这意味着读私有仓库这件事必须经过 GitHub API + token 的显式授权,不存在"Worker 悄悄读本地缓存的仓库副本"这种灰色路径。
第 2 段:Worker 识别访客
Worker 从 Cookie 里读会话(一个 HMAC 签名的 token),解出访客身份,得到三种之一:
- 站主 —— GitHub 用户名匹配
SITE_OWNER_GITHUB - 好友 —— GitHub 用户名在 KV 白名单里
- 访客 —— 其他所有人
身份解析完,Worker 得到一个 viewer 对象。
第 3 段:Worker 去仓库取数据
Worker 用环境变量里的 GITHUB_TOKEN 调 GitHub API:
GET /repos/.../git/trees/main?recursive=1拿到整个文件树- 筛出
posts/*.md - 对每个文件
GET /repos/.../contents/<path>取原始内容
为了不每次都打 GitHub,列表结果在 Worker 内存里缓存 60 秒。
这里有个细节值得展开:为什么是 60 秒,而不是更久?
GitHub 对未认证请求的速率限制是 60 次/小时,对认证请求是 5000 次/小时。我们的 Worker 每次列表要调一次 tree API + N 次 contents API,一个 20 篇文章的仓库就是 21 次请求。如果每个访客都触发一次完整的列表加载,5000 次/小时大概只能撑 238 个访客——对于个人站点可能够,但没必要浪费。
60 秒的内存缓存把"每个请求都打 GitHub"变成"每分钟最多打一次 GitHub",实际消耗降了至少两个数量级。代价是改了文章权限后最多 1 分钟才生效。这个延迟对个人站点完全可以接受——你又不是在运营一个秒级发布新闻的平台。
注意这是 isolate 内存缓存,不是 Cloudflare 的全局缓存。每个边缘节点上的每个 isolate 有自己的缓存副本。你在汕头访问时命中的是香港节点的缓存,朋友在纽约访问时命中的是另一个节点上的缓存。这对于"降低 GitHub API 调用"这个目标完全够用,因为目的不是让所有用户共享一份缓存,而是让同一个节点上的重复请求不重复回源。
如果想做跨节点的全局缓存,可以用 Cloudflare 的 Cache API 或 Workers Caching。但对于这个项目,isolate 内存缓存已经够了——全局缓存会引入"改了权限但全球节点还没同步"的一致性问题,反而让事情变复杂。
说到一致性,Cloudflare KV 本身是最终一致的。写入在发起写入的节点上立即可见,但在全球其他节点上最多可能需要 60 秒才能同步。这和上面提到的文章列表 60 秒内存缓存是两回事:前者是 KV 的数据同步机制,后者是你自己代码里主动加的内存缓存。两者叠加意味着,如果你在 KV 里更新了好友白名单,地球上不同位置的好友可能在不同时间点才能看到权限生效。对个人站点来说这不是问题,但如果做的是实时协作类应用,就需要考虑用 Durable Objects 之类的强一致方案。
第 4 段:解析 + 过滤
对每篇 markdown:
- 用
js-yaml解析---之间的 frontmatter draft: true的直接丢弃- 读
visibility字段,结合viewer身份判断这篇该不该给
这是整个系统的核心。一张表说清:
| visibility | 出现在列表 | 谁能读正文 |
|---|---|---|
public |
是 | 任何人 |
unlisted |
否 | 知道链接的人 |
friends |
是 | 好友 + 站主 |
password |
是 | 口令正确者 + 站主 |
private |
否 | 仅站主 |
注意 unlisted:它不出现在列表,但知道链接就能读——这就是"隐藏但可分享"。
第 5 段:返回
- 列表接口只返回可见文章的元数据(标题、日期、摘要),不返回正文
- 详情接口逐个鉴权,通过了才把渲染好的 HTML 放进响应
访客请求一篇 friends 文章,得到的是 403,响应体里没有正文;请求 password 文章,得到 401,前端弹出口令框。
两道身份轨道
好友不一定是开发者,所以登录做了两条轨:
- A 轨 · GitHub OAuth —— 有 GitHub 的好友,登录后用户名进白名单
- B 轨 · 口令 —— 没 GitHub 的好友,给一个分享链接 + 文章口令
两轨最终都归一到 Worker 里的同一个 viewer 对象,权限判断只有一份逻辑。
会话是怎么签的
两条轨道产出的都是同一个东西:一个名为 space_session 的 Cookie。这个 Cookie 的值是一个 base64url 编码的 JSON,里面存了 viewer 的身份信息(kind、login、slug),外加一个 HMAC-SHA256 签名。签名密钥用的是 GITHUB_CLIENT_SECRET。
Worker 每次请求读到这个 Cookie 时,先验签——用同样的密钥重算一遍 HMAC,跟 Cookie 里的签名比对。比对通过才信任里面的身份信息。这意味着访客改不了自己的身份:他想把自己从 guest 改成 owner,就得能伪造 HMAC 签名,而密钥只存在于 Worker 的环境变量里,永远不会发到浏览器。
这里有一个需要留意的副作用:轮换 GITHUB_CLIENT_SECRET 会让所有已签发的 Cookie 立即失效。 因为验签用的密钥变了,旧 Cookie 的签名对不上了,所有人被强制登出。对个人站点来说这不是大问题,但如果你哪天要轮换 secret,记得提前知道这个行为。
口令校验(B 轨)用的是另一套逻辑。口令不存明文,而是存 HMAC-SHA256 的哈希值在 KV 里,key 是 postpass:<slug>。验证时用 timing-safe compare(常数时间比较)而不是普通的 ===,避免通过响应时间差被逐字节爆破。这跟密码学库里 crypto.timingSafeEqual 的思路一样——比较时间不随匹配前缀长度变化。
免费额度够用吗
这是很多人问的第一个问题。直接给答案:够,而且远远够。
先看 Cloudflare Workers 免费计划的核心限制:
| 项目 | Free | Paid($5/月) |
|---|---|---|
| 请求数 | 100,000/天 | 不限量 |
| CPU 时间 | 10 ms/次 | 5 分钟/次(默认 30 秒) |
| 内存 | 128 MB/isolate | 128 MB/isolate |
| 子请求 | 50 次外部请求/次调用 | 10,000 次/次调用 |
| Worker 代码体积 | 64 MiB | 64 MiB |
| 静态资源文件数 | 20,000 个/版本 | 100,000 个/版本 |
关键数字是 CPU 时间:免费计划每次调用 10 毫秒。 听起来很少,但注意 Cloudflare 对 CPU time 的定义——等待网络请求的时间不计入 CPU time。 也就是说,你的 Worker 调 GitHub API 时那几百毫秒的等待,不算在 10ms 里。真正消耗 CPU 的是你的 JavaScript 逻辑:解析 frontmatter、过滤数组、拼 JSON、算 HMAC。这些操作在这个项目里加起来大概 2-5 毫秒。
Cloudflare 官方给的平均数据是:普通 Worker 每次请求约 2.2 毫秒 CPU 时间。涉及鉴权、服务端渲染、大 payload 解析的重负载,通常在 10-20 毫秒。我们的 Worker 属于前者——它就是一个轻量网关,没有模板渲染,没有重计算。
有一点需要特别说明:免费计划的 10ms 并不是一个硬性的断崖。 每个 isolate 都有一定的弹性空间,允许偶尔超出限制。Cloudflare 会在 Worker 持续稳定超限时才终止执行并返回 Error 1102。所以只要你的平均 CPU 消耗在 10ms 以内,偶尔的波动不会导致请求失败。
再看 KV 免费额度:
| 操作 | Free | Paid |
|---|---|---|
| 读取 | 100,000 次/天 | 1000 万次/月,$0.50/百万次 |
| 写入(不同 key) | 1,000 次/天 | 100 万次/月,$5.00/百万次 |
| 写入(同一 key) | 1 次/秒 | 1 次/秒 |
| 存储 | 1 GB | 1 GB,超出 $0.50/GB/月 |
| key 大小上限 | 512 字节 | 512 字节 |
| value 大小上限 | 25 MiB | 25 MiB |
KV 写入的 1,000 次/天 是这里最紧的约束。但想一下我们的实际使用:好友白名单每加一个人是一次写入,口令每设一次是一次写入。这些操作一天能发生几次?对个人站点来说,写操作几乎为零。真正的写流量来自会话管理——但这个项目把会话做成了 HMAC 签名的 Cookie,不写 KV,纯无状态。所以 KV 的写入额度基本用不到。
读操作呢?每次鉴权要读一次白名单或口令哈希。假设你的站点每天有 100 个访客,每人访问 10 个页面,每次页面加载触发 2 次 API 调用(列表 + 详情),那就是 2000 次 KV 读。加上鉴权读,撑死一天几千次。距离 100,000 次/天还有两个数量级的余量。
顺带提一个重要的计费细节:静态资源的请求是完全免费的,且不限量。 你的
index.html、app.js、style.css、字体文件——这些都不消耗 Worker 的 100,000 次/天请求额度。只有/api/*这些真正触发 Worker 代码的请求才计入。这意味着网站前端再怎么刷新,也不会吃掉你的配额。
还有一个容易被忽略的细节:免费计划的子请求限制是 50 次外部请求/次调用,同时还有 1000 次 Cloudflare 内部服务子请求的限制。我们的 Worker 在列表接口里要调 1 次 tree + N 次 contents。如果文章超过 50 篇,就会撞到这个限制。所以生产环境的正确做法是用 tree API 拿到文件列表,只对可见的文章逐个取 contents,而不是把所有文件都拉一遍。这个项目目前在 50 篇以内,但如果文章量上去了,需要做分批或合并请求的优化。
顺带提一下,这个限制对 Cloudflare 内部服务(比如 KV 读写、R2 读取)的计数是单独的。免费计划允许每次调用最多 1000 次内部子请求。在我们的场景里,每次鉴权读一次 KV 白名单——远不到 1000 次,完全安全。
实际成本结论: 在个人站点的流量水平下(日均几百到几千次 API 调用),这套架构的月度成本是 $0。只有当你日请求超过 10 万次,或者需要超过 10ms 的 CPU 时间跑重逻辑时,才需要考虑 $5/月的付费计划。对一个私人空间来说,免费额度是绰绰有余的。
小结
整条链路可以压缩成一句话:
文章是数据,不是产物;权限判断发生在服务端,正文只在该出现时才出现。
这套结构不依赖任何框架,没有运行时数据库,成本几乎为零——Cloudflare Workers 免费计划每天 10 万次请求、10 毫秒 CPU 时间、KV 十万次读取,对一个个人站点来说余量巨大。静态资源请求完全免费,不占用任何配额。只有当你日请求超过 10 万次,或者 Worker 需要跑超过 10 毫秒的 CPU 逻辑时,才需要考虑升级到 $5/月的付费计划。
技术选型上,V8 isolate 架构提供了接近零的冷启动和天然的安全隔离,边缘网络把执行位置放到了离访客最近的地方。这些特性在"做一个轻量鉴权网关"这件事上,比传统 Serverless 更合适。
对个人站点来说,这是"够用且正确"的平衡点——而且成本是零。