cloudflare-os 这个名字听着很唬人,但我更愿意叫它“跑在边缘节点上的个人工作台”。事情的起因其实很朴素:我平时要管一堆零散的小工具——备份脚本、笔记、文件分享、API 调试记录,散在各个网盘和本地目录里,找起来特别累。于是决定用 Cloudflare 的 Workers、KV、R2 这套东西,在浏览器里搭一个带窗口、带文件管理器、能直接用的“Web 桌面”。跑通之后发现,这件事的工程量比想象中小得多,但坑也比想象中多。这篇就完整复盘一下 cloudflare-os 的架构设计、核心模块实现、上线前后的关键问题,以及我能给你的所有实操建议。
这套方案适合谁?如果你会一点 JavaScript,能跑 npm 命令,想给自己或团队做一个统一入口式的内部工具台,或者对“Web OS”“边缘存储”这类玩法感兴趣,那这篇文章可以直接当你的落地参考。不需要运维背景,也不需要一台自己的服务器。
1. 为什么非要在边缘云里搭一个“桌面系统”
1.1 不是仿 Windows,而是给自己做一个统一工作台
“桌面系统”听起来像是要复刻一套 Windows,实际上没那么复杂。我做的 cloudflare-os,就是浏览器打开后出现一个带壁纸、带任务栏、能拖拽窗口的界面。每一个“应用”都是一个独立的小面板:
- “文件”应用:浏览 R2 桶里的文件、上传下载、删除旧备份;
- “记事本”应用:在浏览器里编辑文本,自动保存到 R2;
- “设置”应用:修改 KV 里记录的偏好配置,比如主题色、默认排序;
- “状态”应用:看一眼当前 R2 对象数量、KV 读写量、最近的 API 日志。
这些窗口在实现上就是一组 React 组件实例,由窗口管理器负责摆放位置、控制层叠顺序。有人可能会说,这不就是个花架子?但对个人工具来说,好看和顺手本身就是生产力。我把所有入口都收进一个桌面,打开浏览器就是一个完整工作台,不用再记一长串子域名,也不用开十几个标签页来回切。
1.2 为什么选 Cloudflare 这套,而不是租一台 VPS
说白了就是三个字:省、快、闲。
- 省:Workers 免费计划每天有 10 万次请求额度,个人使用根本用不完。R2 的存储费用很低,而且读取文件不收流量费——对一个要频繁下载备份包、预览图片的个人云盘来说,这笔账非常划算。
- 快:边缘分发,静态资源和 API 都从离用户最近的节点返回。北方和南方的朋友同时打开页面,体验几乎没有差别。
- 闲:不用关心系统更新、磁盘清理、进程守护。所谓“服务器”就是一组绑定存储的 JavaScript 函数,睡觉也不会自己崩。
这几个平台组件各干什么,我列一张表。这也是很多初学者最容易蒙的地方:
| 组件 | 角色 | 我在 cloudflare-os 里的用途 |
|---|---|---|
| Workers | 计算层,处理 HTTP 请求和业务逻辑 | 提供前端静态资源 + 全部 /api 接口 |
| KV | 全球复制的键值数据库 | 存用户设置、界面偏好、临时配置对象 |
| R2 | 对象存储 | 存笔记、图片、备份包等真正的文件 |
| 静态资源绑定(Assets) | 让 Worker 直接托管前端构建产物 | 把 Vite 构建出的 dist 目录交给边缘节点 |
这里有个认知误区必须点出来:KV 不是“数据库”,它只保证最终一致性,你在 A 节点写入,在 B 节点读到的很可能是几秒钟前的旧值。所以我只把主题色、窗口位置这种不敏感的信息放在 KV,所有关键文件一律进 R2。
1.3 它真正解决了什么需求
拿我最常用的一条工作流举例。每个月我要把服务器上的数据库备份拉回本地存档,以前是登录面板、找到备份文件、下载、再手动传到网盘,四步操作。现在我在 cloudflare-os 里加了一个“备份收件箱”:备份脚本跑完直接把文件 PUT 进 R2,我打开桌面上的“文件”应用,新对象已经躺在那了,点一下下载即可。
团队场景也能用。我们小组维护一堆 API 网关配置,经常要协作看最新文档。我把 markdown 文档按日期推进 R2,前端渲染成带目录和代码高亮的页面,大家打开桌面的“文档”应用就能看,再也不用维护一套单独的 Wiki 系统。
2. 整体架构:一个 Worker 撑起前端、API 和存储
2.1 设计思路:单 Worker 部署,还是拆开部署
最早我想过拆成两部分:前端放 Cloudflare Pages,后端单独跑一个 Worker。后来发现个人项目完全没必要。现在 Wrangler 支持静态资源绑定,我可以把前端构建产物直接挂到 Worker 下面,前端和 API 天然就在同一个服务里。一个wrangler.toml管到底,一行wrangler deploy就全部上线。
拆开不是不行,但多了一个跳转链路,还要处理跨域、路由拼接,复杂度全是我自己扛。单 Worker 的心态更简单:所有请求进同一个入口,我按路径分流就是了。
2.2 工程目录与构建链路
先看我最终落地的目录结构:
cloudflare-os/ ├── src/ │ ├── worker.ts // Worker 入口:路由 + API 处理 │ ├── auth.ts // JWT 签发与验签 │ ├── r2.ts // R2 文件操作封装 │ └── kv.ts // KV 设置读写封装 ├── web/ // 前端源码(React + Vite) ├── dist/ // 前端构建产物,由 assets 绑定托管 └── wrangler.toml构建顺序是这样的:在web/目录里跑npm run build,产物输出到根目录dist/;然后wrangler deploy时,Wrangler 把dist/自动关联到[assets]绑定;之后任何针对/或静态资源的请求,直接由env.ASSETS.fetch(req)响应;针对/api/*的请求,则由 fetch 事件处理函数接管。这个模式非常顺,开发时前端走 Vite dev server,部署时走边缘静态资源,中间不需要任何妥协。
2.3 在 Worker 里分路由,代码可以很清爽
核心入口代码不长,但逻辑要理清楚。它本质上就是一个迷你路由器:
export default { async fetch(req: Request, env: Env): Promise<Response> { const url = new URL(req.url); // 1. API 路由 if (url.pathname.startsWith('/api/')) { return handleApi(req, env, url.pathname); } // 2. 其余全部交给静态资源 return env.ASSETS.fetch(req); }, };我有意保持简单:所有 API 都走/api/*,剩下的让静态资源绑定来处理。图片、CSS、JS 的缓存策略和版本指纹,平台会自动处理,不需要我手动设置Cache-Control。真正需要操心的只有 API 的鉴权,以及存储读写的成本控制。
2.4 存储层怎么落到 R2
R2 的接口跟 S3 很像,但调用方式更简单。列出某个目录下的文件,只需要:
async function listFiles(env: Env) { const listed = await env.OS_FILES.list({ prefix: 'notes/', limit: 100 }); return listed.objects.map((o) => ({ key: o.key, size: o.size, uploaded: o.uploaded, })); }R2 的list默认按对象 key 的字典序返回。想要按时间倒序,我一开始还自作聪明在 key 前面拼时间戳,后来发现直接读对象的uploaded字段,在前端排序就行,完全没必要把存储命名搞复杂。这里想提醒大家:R2 的对象 key 就是你的“目录结构”,设计 key 的命名规则要想清楚,比如用user/ahuang/notes/diary.md这种带前缀的样式,后面做多用户隔离会省很多事。
3. 最核心的交互模块:窗口管理器与文件管理器
3.1 窗口管理器:不只是 zIndex 那么简单
窗口管理器是 Web 桌面最核心的部分。很多同学觉得“不就是 position: absolute 再加个点击置顶吗”?真做起来,有几个坑是绕不开的。
第一,拖拽时的性能。直接给mousemove事件改left和top,会导致浏览器频繁回流,肉眼可见的掉帧。我选择在拖拽过程中把新位置写到transform: translate(...)上——GPU 合成层不触发回流,结束拖拽后再提交一次最终坐标到left/top。这个细节是拖过几个 Notion-like 项目之后总结出来的,效果立竿见影。
第二,窗口焦点管理。点击任意窗口,不能只改自身样式,还要通知其他所有窗口“你失焦了”。我把所有窗口状态集中到一个 reducer 管起来:
function windowReducer(state, action) { switch (action.type) { case 'open': return { ...state, [action.id]: { z: nextZ(state), minimized: false } }; case 'focus': return { ...state, [action.id]: { ...state[action.id], z: nextZ(state) } }; case 'close': const next = { ...state }; delete next[action.id]; return next; default: return state; } }nextZ保证每次聚焦的窗口拿到的 z-index 都是全局最大,其他窗口自动退到背景。这个模式支持任意数量的窗口,状态清晰,也方便持久化窗口坐标和层级。
第三,关闭窗口后要不要保留内部状态。我的方案是:点关闭按钮只是把窗口挂起(minimized),真正的销毁必须有用户确认。这样切后台任务再回来,编辑器里的内容还在,不会因为 React 组件重挂载而清掉。这个取舍在纯 Web 环境比原生桌面系统更重要,因为浏览器标签页随时可能被系统回收。
3.2 文件管理器:用 R2 当“C 盘”
桌面上的“文件”应用,左边是目录树,右边是文件列表。目录树不是 R2 原生的概念——R2 里的对象都是扁平 key,比如notes/2024/diary.md。我在前端把 key 按/拆开,拼出一棵虚拟目录树。
这个设计的好处在于,不需要后端额外维护“目录”对象,一切视图都由前缀过滤生成:
// 获取 notes 目录下所有对象 const list = await env.OS_FILES.list({ prefix: 'notes/' });坑在于:如果你希望空目录本身也显示在树里,R2 里并没有一个真实对象代表它。我的做法是,一个目录下没有任何文件时,它就不出现在目录树里。个人使用完全够用,团队场景如果一定要空目录概念,可以额外放一个.keep占位对象,这种思路在 Linux 下也很常见。
上传文件是另一个高频需求。这里必须强调:不要把整个文件读进内存再写入 R2。刚开始我写的是:
await env.OS_FILES.put(key, await req.arrayBuffer());这个写法对小型文件没问题,但一旦传入 200MB 的备份包,await req.arrayBuffer()会把整段数据压进 Worker 内存,很容易触发 128MB 的内存上限。正确做法是把req.body这个流对象直接透传给 R2:
await env.OS_FILES.put(key, req.body);Request.body本身就是ReadableStream,R2 的 put 支持直接接收流,平台会自动处理背压和分片。这样无论文件多大,都不占用太多执行内存。这个改动,直接把我的上传成功率从 85% 拉到了接近 100%。
3.3 编辑器与存储联动:自动保存的节奏感
“记事本”应用是我日用的主力。它本质上是一个textarea,但保存策略很讲究。我提供一条调整过两轮才稳定的经验:不要在每次onChange时都调 API,而是加一层“停止输入 2 秒后保存”的防抖,并且在visibilitychange(切走标签页)和beforeunload(关页面)时强制执行一次保存。
简单实现如下:
const timer = useRef<number>(); const save = useCallback(async () => { await fetch(`/api/files/notes/${currentFile}`, { method: 'PUT', body: text, }); }, [text]); function onChange(e) { setText(e.target.value); clearTimeout(timer.current); timer.current = setTimeout(save, 2000); } window.addEventListener('beforeunload', save);这个模式适用于一切带编辑性质的 Web 应用。不要小看那两秒钟,它能帮你省掉大约 90% 的写请求,R2 的写次数和费用都降下来了。更重要的是,即时保存会带来一种“纸笔不会丢”的安心感,这对写作类工具是核心体验。
4. 鉴权与 CORS:给文件桶开锁、给 API 上锁
4.1 R2 的 CORS 是一套独立配置,别漏了
前端浏览器直接读 R2 对象(比如图片预览)时,如果没设置 CORS,就会看到典型的跨域报错。R2 的 CORS 不是在后台上点几个按钮就能搞定的,正确姿势是命令行:
wrangler r2 bucket cors set cloudflare-os-files \ --allowed-origins "https://os.example.com" \ --allowed-methods "GET,PUT,DELETE" \ --allowed-headers "content-type,authorization"这里的--allowed-origins要填你的真实域名,不要图省事写*。因为你后面还要用Authorization头做鉴权,*会让预检行为变得不可控。--allowed-headers里必须带上authorization,否则前端带 token 的请求会被预检拦截。
4.2 用 JWT 给 API 加一把轻量锁
桌面系统看起来只是无害的前端页面,但只要接入了 R2 的读写能力,API 就必须设访问门槛。我不想维护一个完整的用户表,所以选了一个最轻的方案:自己在 Worker 上做签发和验签,用一个 HMAC 密钥签 JWT。
签发阶段的代码大致如下:
async function signToken(payload: object, secret: string) { const encoder = new TextEncoder(); const key = await crypto.subtle.importKey( 'raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'] ); const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' })); const body = btoa(JSON.stringify({ ...payload, exp: Math.floor(Date.now() / 1000) + 60 * 60 * 24, })); const message = `${header}.${body}`; const sigBuf = await crypto.subtle.sign('HMAC', key, encoder.encode(message)); const sig = btoa(String.fromCharCode(...new Uint8Array(sigBuf))); return `${message}.${sig}`; }验签就用crypto.subtle.verify,把 token 里的签名还原成 Uint8Array 再比对。验证通过后,取出payload.sub作为当前用户标识,访问 R2 时用它拼进 key 前缀,比如user/ahuang/notes/diary.md。这样即使将来多人使用,不同账号的数据天然隔离,不会互相看到。
4.3 前端怎么配合:带着 Token 请求
前端每一次请求都从 localStorage 里取 token,放在Authorization: Bearer <token>头里。服务端在/api/*入口统一做一次拦截:
function requireAuth(req: Request, env: Env): Response | null { const auth = req.headers.get('Authorization'); if (!auth) return new Response('no token', { status: 401 }); const token = auth.replace('Bearer ', ''); if (!verifyToken(token, env.SECRET)) return new Response('bad token', { status: 401 }); return null; }这里有个细节容易忽略:Workers 处理预检请求时,如果Access-Control-Allow-Headers里没有authorization,浏览器会在正式请求发出前就把请求拦掉。你需要在 CORS 配置里明确列出这个头,上面 4.1 节的命令已经包含了。
不过必须提醒一句:JWT 的exp只防重放,不防盗用。如果前端 localStorage 被 XSS 打穿,token 一样会泄露。所以我还给前端页面配置了严格的内容安全策略(CSP),所有渲染都走框架默认转义,坚决不碰dangerouslySetInnerHTML。
5. 上线后踩过的几个实际问题与排查链路
代码在开发机上是不会成长的,真上线跑一个月,各种怪问题就冒出来了。我挑三个最有代表性的问题,把完整排查过程拆开给你看,方便你以后遇事不慌。
5.1 上传大文件时 Worker 被“掐断”
现象:上传一个 300MB 的备份包,进度条走到 90% 直接失败,浏览器提示网络错误。
排查链路:
- 先看 Workers 实时日志,发现有
Memory limit exceeded提示。 - 回头检查代码,发现 PUT 时我用了
await req.arrayBuffer(),等于把整个请求体吞进内存。 - 查阅文档确认 Worker 有 128MB 内存上限。于是把 R2 写入改成流式
await env.OS_FILES.put(key, req.body)。 - 改完再测,300MB 备份顺利跑完,浏览器端也没有卡顿。
这个问题的本质不是存储容量,而是中间处理层吞了完整 IO。凡是文件型上传,中间代码都应该保持流式,不要自己动手缓冲全部数据。
5.2 新构建删掉了旧版本,前端却还是旧界面
现象:wrangler deploy之后,第二次打开依然是几分钟前的旧页面,强制刷新也无效。
排查链路:
- 怀疑是浏览器缓存,清掉再看,还是旧的。
- 打开无痕模式,依旧是旧的,排除本地缓存。
- 查看页面响应头,发现
cache-control并没有被平台设为预期值。 - 最后意识到,问题出在 Worker 对
/index.html的路由上:我手动返回了一个字符串 HTML,而不是走静态资源绑定,导致平台没有对静态资源做版本指纹。 - 修复方式:把 HTML 也交给
env.ASSETS.fetch处理,由平台根据发布版本自动设置指纹和缓存头。
教训很明确:能交给平台缓存策略解决的问题,不要自己手搓响应体。尤其单 Worker 场景,静态文件一律走静态资源绑定。
5.3 KV 里的“设置”存了一分钟才生效
现象:在“设置”应用里把主题色改成深色,另一台设备刷新页面,看到的依然是浅色。
排查链路:
- 检查代码,写操作返回了成功,没有报错。
- 连续读好几次 KV,有时是新值,有时是旧值,完全看运气。
- 翻文档看到“KV is eventually consistent”这句话,心里一下就明白了——边缘节点上的读请求可能命中旧缓存。
- 解决方案:对“配置即时生效”这种需求,把配置项沉到 R2 里,读写都走同一个存储后端;KV 只用来存不敏感、可容忍延迟的数据。
顺手记下我的设计原则:能用 R2 做重要状态,就不要用 KV 做即时一致性业务;能用 KV 做的,也别拿 R2 大材小用。知道每种存储背后的取舍,比背 API 重要得多。
6. 后续扩展方向:把 Web 桌面变成生产力前台
6.1 多用户与团队空间
R2 的 key 前缀天然适合划分用户空间。前面提到的user/{uid}/...设计,再加一个共享目录就能演变成团队空间:team/shared/...。如果要支持多人协作编辑同一个笔记,可以引入 WebSocket 和 Durable Objects 做状态同步与冲突合并。这个方向我打算单独开一篇来说,因为涉及实时同步的细节非常多。
6.2 接入 AI 做“桌面助手”
既然桌面系统本质上是前端加 API 的组合,那加一个“AI 应用”不过是多开一个窗口的事。把笔记内容、文件名、KV 里的偏好数据串起来,对话时带着这些上下文,让模型帮你整理周报、批量重命名文件、总结某个目录下的文档要点。这类“套壳”应用这两年被人说烂了,但自己做一个贴合自己数据的小工具,体验完全不一样。
6.3 把 Web 终端放进来
如果你真的想要一个能敲命令行的“终端”体验,最实在的办法是让 Worker 开一个 WebSocket 中继,把消息转发到你私有网络里的一台真实机器。可行,但一定要给 WebSocket 地址也加上鉴权,别图方便只做内网限制就把边界裸奔到公网。这个项目涉及的安全细节不少,我建议单独评估,不要直接照搬。
最后聊两句
我对 cloudflare-os 最大的体会是,一切技术选型都该以“少养服务器、少理运维、能把事情办完”为目标。每次打开浏览器,直接进入自己的桌面,点开文件管理器看看备份是不是到了,再把新写的笔记自动同步一遍,这种顺滑感,是以前在各种面板里打卡式操作给不了的。
如果你准备动手,我的建议是:先搭出“文件管理器 + 编辑器”这两个应用,其余窗口以后按需加。窗口、路由、存储的骨架一旦立住,扩展一个应用真的只需要半小时。把 cloudflare-os 当成一个可以随手改造的工具箱,而不是又一个沉重的平台,它才会真正为你所用。