RandallFlare
by Randall · 12 Jun 2026
← /u/randall/blog
by Randall · 12 Jun 2026
Workers / Pages / R2 / KV / D1 / Queues / Durable Objects / Analytics / Service Bindings / Cron / Cache API —— 全栈,一台机器,一行 systemd,零 CF 账号依赖。
RandallFlare 是一个把 Cloudflare 主要数据面 + 编程接口克隆出来的开源自托管平台。控制面跑一份 Next.js,边缘节点跑一个 Go 写的 agent,所有 V8 隔离都用 Cloudflare 自家开源的 workerd 跑 —— 也就是说你的 worker / pages 代码真的就是 CF Workers 写法,改个 host 部署进去能跑。
它不是"另一个 Vercel"也不是"另一个 Fly.io"。它的设计目标是:给个人 / 小团队一个"CF 服务挂了 / 被 ban 了我能直接搬"的退路,以及把 CF 那种"几行 JS 加 binding 就能跑"的开发体验原地复制到自家服务器上。
如果你熟 CF Workers,RandallFlare 的开发者体验对你来说会零迁移成本;如果你不熟,看后面 demo 就明白了。
控制面跟边缘节点是完全解耦的两层:
中间是一条 5 秒一次的 poll loop,plane 把"该跑啥"推过来,agent 自己拉源码 + 启 workerd。崩了就重启,不丢状态。
关键选择是plane = Next.js + 一个 PG。这意味着 plane 本身可以部署到任何能跑 Next 的地方(包括 Vercel,虽然我没这么做),也意味着 admin UI 就是 plane 的 server component,没有独立的 API server。
Plane 干的事:
/me/edge/workers 写代码 / 配 binding / 改 hostnameknown[workerId] == version,不等就把整包(files + entryFile + envJson + bindings + crons + compat flags + CORS allow list)塞回去EdgeDeployment.status 翻 OK整套没有 webhook / push 通道,纯 pull。简单粗暴,跨节点容灾近乎免费。
每个节点跑一个 systemd 单元,二进制 < 14 MB,启动后做三件事:
/edge/agent/register 拿 nodeID + agent token:80 + :443,把入站请求 dispatch 到本地 workerd 子进程workerd 子进程怎么管:
worker.capnp(动态生成,内含 service + Fetcher + bindings + compat date/flags)os/exec 起 workerd,捕获 stderr 5 KB ring buffer 用于死亡诊断/edge/agent/runtime-logs → 实时 live tail入流量经过的唯一一层就是 agent 的 router。其判定优先级:
<bucket>-r2-<user>.<edge-zone> 或自定义)<branch>--<project>-<user>.<edge-zone> preview)no worker or pages project bound to host每条都按"hostname → projectID/workerID/bucketID"哈希表查,O(1)。
同 hostname 路由顺序是 R2 > Pages > Workers。如果你把同一个域名绑了 R2 又绑了 worker,会被 R2 抢走 —— admin UI 已经禁止重复绑定。
CF Workers 该有的全有:
export default { fetch, scheduled, queue, email })写法:
跟 CF 一模一样。env.MY_KV 就是 KV namespace,env.DB.prepare(...) 就是 D1。
支持两种入口:
_routes.json / _headers / _redirects_worker.js/index.js + 全部 chunk(Nuxt / SvelteKit / Astro 都打这种)Functions simple mode (
_worker.js单文件)我也支持,但是上述框架基本不再产出这种格式。
Manifest 形如 { files: { "/index.html": {hash,size,mime}, ... } },文件 body 走 content-addressed blob(assets/<aa>/<sha256>),跨 deployment 跨项目共享。两个 Nuxt 站用同一个 vendor.js → 磁盘上只一份。
Preview 部署走 <branchSlug>--<project>-<user>.<edge-zone>,跟 CF 一模一样。
存储模型:
EdgeR2Blob (sha256 → bytes, refCount)
↑ many-to-one
EdgeR2Object (bucketId, key) → sha256
↑ many-to-one
EdgeR2Bucket (slug, publicAccess, corsManualOrigins, expireObjectsAfterDays)
复制策略:rendezvous hashing,默认 replica = 2(blob 写到两个最近节点)。读时优先打本地 cache,miss 时 fallback 到 plane fetch + 写盘。
R2 binding 的 .put / .get / .head / .delete / .list / multipart upload 全实现,包括 Range header(视频 seek / iOS Live Photo / curl -C 续传都需要)。
公开桶有三种访问路径:
| 形态 | URL |
|---|
PG 表 EdgeKvEntry(namespaceId, key, value, expiresAt, metadata)。
支持:
put(k, v, { expirationTtl, expiration, metadata })get(k) / getWithMetadata(k)list({ prefix, limit })每个 D1 库就是一份 SQLite 文件,跑在 plane 端的 better-sqlite3。binding shim 把 db.prepare(...).bind(...).all() / first() / run() RPC 进 plane。
加了一条管理面 .sql 导入:admin UI 上传 → plane 直接 db.exec(sql)。Schema migration / 旧 wrangler dump 直接搬。
env.MY_QUEUE.send(body) → Redis LPUSH。
消费侧:每 (queue, workerLoaded) 对一个 goroutine,BLPOP 拿一批 → POST 到 worker 的 /__edge_queue__/<queueId> synthetic URL → 解析 ack / retry 状态。
batchSize / maxWaitMs / maxRetries / visibilityTimeoutMs 都跟 CF 一致。
每个 DO instance 有一个"owner node"(最早创建它的节点)。其它节点收到 env.DO.idFromName("foo").get().fetch(...) 调用时,通过 plane 查 owner,然后跨节点用 fleet shared secret 反代过去。
storage.put/get 都落在 owner 节点的 SQLite 文件里。owner 节点挂了 → plane 触发 ownership migration。
简单不完美,但能用。
env.AE.writeDataPoint({blobs, doubles, indexes}) → 批量进 PG EdgeAnalyticsEvent。admin UI 有 SQL-lite 查询面板,聚合走 PG 自家 time_bucket (TimescaleDB 可选)。
caches.default.put(req, resp) → agent 把 response body + headers 序列化进 cache/<sha-of-cacheKey> 文件。TTL 走 Cache-Control: max-age,过期 lazy delete。
跨节点 NOT shared(同 CF)。
最爽的功能之一。worker A 想调 worker B → admin 加一条 bindingName=API, target=B,代码里:
const resp = await env.API.fetch(new Request("https://internal/anything"))
agent 把这个 fetch 不走公网,直接 HTTP loopback 到 B 的 workerd 端口。延迟亚毫秒,无 DNS,无 CORS,token 不需要漏出。
Pages 也能绑 worker。这就是 "pages 怎么调 worker 最方便" 的答案 —— 不要走公网 fetch,绑一下就完事。
<edge-zone> (*.edge.bigrandall.io) 走一张 wildcard cert,所有 auto-sub 共用。自定义域走 per-host cert + alias CNAME 流程,operator 不需要 DNS API token,只需要在自家 DNS 加一条 CNAME。
实测一条 hostname 从创建到 cert 落地 ~30 秒。
跟 R2 / Workers / Pages 都做了平台级 CORS allow-list:
| 层 | baseline 自动算 |
|---|
agent 在 ModifyResponse 注 Access-Control-Allow-Origin: <echo> + Vary: Origin,但只在用户代码没自己设 ACAO 时(operator-wins 语义,跟 CF Workers 一致)。OPTIONS preflight 在 router 层直接 204 短路,不打扰 workerd。
* 作为 manual entry 触发 wildcard 模式。
要点:
不上 SSE / WebSocket 是有意为之:1Hz polling + DB 撑得住几十 worker 同时直播。
前端 RuntimeLogStream 还能解 JSON 结构化日志,按 level 自动染色 + 把 msg 提到前面(其他 binding 折成 k=v 行尾)。pino / 自家 logger 想用都行。
GH Actions 在每次 edge-agent/** push 跑:cross-compile linux-amd64 / linux-arm64 / darwin → 算 sha256 → SCP 到 plane /app/bigrandall.io/public/edge-agent/<tag>/ → 翻 latest symlink。
agent 每次 heartbeat 收 desiredAgentVer,跟自身比对 → 不等就 selfupdate.Apply:
<tag>/<binaryName> + .sha256syscall.Exec 自己 → 新二进制接管,所有 workerd 子进程不动(parent 切换无关 child)整个过程对入流量近乎零影响(切换 < 100 ms)。
症状: env.MY_BUCKET.put(key, value) 抛 TypeError: Fetch API cannot load: <key>。
根因: workerd 新 compat date / RPC compat flag 下,raw Service Binding 上任何 property 访问都被当 RPC method。.put(key, value) 被翻译成 fetch(key, value) → key 不是合法 URL → throw。
特别坑的是 Nitro CF Pages preset(以及任何把 env 存进 context 再读的 framework):{...env} / Object.entries(env) / getOwnPropertyDescriptor 这些访问路径绕开 Proxy 的 get trap,拿到 raw binding。
最终方案: env wrap 别用 Proxy,用急切 copy。每次 fetch 直接遍历 own keys,命中的 binding 立刻实例化成 shim,生成 plain object:
任何访问路径都拿到 wrapped 实例,没有 Proxy 可绕。代价 O(bindings) 每请求,微秒级,看不见。
node:* 前缀的内建模块#症状: bundle 装进 workerd 起来时报 No such module "crypto"(即使加了 nodejs_compat flag)。
根因: 很多老 npm 包(常见于 2020 年前的代码)用 bare 形式 import e from "crypto",没加 node: 前缀。workerd 的 nodejs_compat 注册 module 表时只挂 prefixed 形式(node:crypto、node:url 等),bare 名根本不存在。
最终方案: esbuild alias 在 bundle 阶段把所有 bare 内建名改写成 node:*:
emitted bundle 里全是 from "node:crypto",workerd 直接 resolve。
createCipheriv 拒绝 null IV#症状:
TypeError: The "iv" argument must be of type string or an instance of
Buffer, TypedArray, or DataView or null. Received null
(报错文本自相矛盾 —— 说明明 "or null" 但实际拒 null。这是 workerd polyfill 的 bug,不是设计)
根因: Node 的 crypto.createCipheriv("aes-128-ecb", key, null) 是合法的(ECB 不用 IV),workerd node:crypto 实现更严格,ECB 也要求 IV 是 Buffer 之类。
最终方案: shim createCipheriv / createDecipheriv,null/undefined IV → Buffer.alloc(0)(OpenSSL 自家约定下等价于"不用 IV")。esbuild plugin 把所有 crypto / node:crypto import 路由到 shim,shim 自己用 node:crypto external import 拿真实现:
症状: crypto.generateKeyPairSync('ed25519', { privateKeyEncoding: { type: 'pkcs8', format: 'pem' } }) 出来的私钥,喂给 ssh-keygen -p -m RFC4716 -f <path> 转 OpenSSH envelope 时报 Failed to load key: invalid format。
根因: ssh-keygen 不实现 PKCS8 PEM 读取(至少对 ed25519 是这样),只接受 OpenSSH 自家私钥格式。Node crypto 模块只能产 PKCS8/SPKI PEM 或 DER,出不了 OpenSSH 格式。
最终方案: 别在 Node 里生成,直接 shell 出 ssh-keygen 让 openssh 自己生成:
ssh-keygen -t ed25519 -N "" -C "<comment>" -f /tmp/key -q
产物 /tmp/key 是 OpenSSH 私钥,/tmp/key.pub 是单行 public key,git / sshd / GitHub Deploy Keys 全认。
npm install 在 NODE_ENV=production 下跳 devDependencies#症状: build runner 跑 npm install && npm run build,esbuild 找不到 —— 即使它写在 devDependencies 里。
根因: npm 看到 NODE_ENV=production 自动 omit devDeps。如果 build env 继承了 runtime env(常见做法,避免重复配置),里面又有 NODE_ENV=production,esbuild / tsc 这种 build 时工具会消失。
最终方案: build 必需的工具挪到 dependencies。语义上 esbuild / tsc 就是 build pipeline 的依赖,跟运行时无关但跟 build 强相关,放 devDeps 其实不准确 —— devDeps 应该是只在"本地开发"用的(lint / test / typecheck);CI 也要的就该是 deps。
optional() 不接受 null#症状: 用 zod 校验 FormData,某字段表单里没出现时 schema 报 invalid_type: expected string, got null。
根因: z.string().optional() 等价于 z.union([z.string(), z.undefined()]),不接 null。FormData.get(key) 在字段缺席时返回 null,跟 undefined 字面不同。
最终方案: 表单收集层统一 null → undefined 归一化:
每个 optional 字段都得这么处理,直接散在 formData.get(...) 一定会漏一两个 —— TypeScript 也提示不到,跑起来才炸。
症状: 把现成 Node 项目移植到 workerd,import pino from 'pino' 起来时报 Cannot find module 'worker_threads' 或一堆 fs / process 引用失败。
根因: pino 内部 lazy-load worker_threads 做 async transport;pino-pretty 直接吃 fs / process 控制 stdout。workerd 这两类原生模块都不开。
最终方案: 自己 ~100 行实现 pino 表面 API,内里就 console.log(JSON.stringify(...))。agent 端 stdout 是 workerd 自带的捕获通道:
logger.info({reqId, status}, 'request done') 这种 pino 风格调用全保留,改原项目代码量近乎为零。控制面那边 log tail 解 JSON 按 level 染色,体验跟 pino-pretty 没区别。
迁移测试床(都跑在自家 plane 上):
写这个项目最大的收获是把分布式系统里所有'设计权衡'都体会一遍:
每一条 select 都意味着放弃了别的可能,而且每条都在实测中被坑过两次以上。这就是平台层软件好玩的地方 —— 一个表面"用着像 CF"的产品,内里全是一连串"知道这些权衡之后怎么选"的过程。
https://www.blogsclub.org/members.html
| 层 | 技术 | 为啥 |
|---|
| 控制面 web | Next.js 16 App Router | server actions 让 admin UI 写得跟普通 React 一样;无独立 BFF |
| ORM | Prisma + PostgreSQL | migration 系统是这次项目最大依赖之一,迭代极快 |
| 任务队列 | Redis (BLPOP/RPUSH) | 比 SQS 轻;比内存队列耐崩溃 |
| 边缘 agent | Go | 静态二进制 + 单文件分发 + syscall 直接 exec workerd;依赖最少 |
| V8 runtime | workerd | CF 官方开源,等于跑同一个 isolate;兼容 nodejs_compat flag |
| capnp 配置 | workerd 自带 .capnp DSL | 每个 worker 一个动态生成的配置文件 |
| TLS | ACME DNS-01 (Let's Encrypt) | wildcard 一证全 cover,自定义域名走 alias CNAME |
| 静态资源 | content-addressed blob (sha256) | 跨 deployment 跨项目共享同一份 vendor.js |
| 对象存储 | 本地磁盘 + rendezvous hashing | 多节点复制,replica = 2 默认 |
| 日志面 | DB ring buffer + 1Hz 前端轮询 | 不上 SSE/WebSocket,SSR friendly |
| 自更新 |
User
├── EdgeWorker (slug, files, envJson, kvBindings, …, corsManualOrigins)
│ ├── EdgeHostname (hostname, isAutoSub, certIssuedAt, …)
│ ├── EdgeDeployment (per (worker, node) pair, status PENDING/OK/FAIL)
│ └── EdgeWorkerBuild (git build artefacts + log)
├── EdgePagesProject (similar shape + static manifest)
│ ├── EdgePagesDeployment (versioned manifest = { files: { path → {hash, size, mime} } })
│ ├── EdgePagesHostname
│ └── EdgePagesPreviewAlias (per-branch preview URL)
├── EdgeKvNamespace → EdgeKvEntry (expiresAt, metadata)
├── EdgeD1Database (SQLite file per DB, lives on plane)
├── EdgeR2Bucket → EdgeR2Object → EdgeR2Blob (content-addressed)
│ └── EdgeR2BucketHostname (public direct URLs)
├── EdgeQueue → EdgeQueueMessage
└── EdgeAnalyticsDataset → EdgeAnalyticsEvent
EdgeNode (one per VPS, agentTokenHash, drain flag)
EdgeBuildSshKey (singleton, ed25519 deploy key for SSH git clones)
EdgeRuntimeLog (stdout/stderr ring buffer, 5 min retention)compatibility_date + compatibility_flags(nodejs_compat 等)request.cf.country 等(从 cf-ipcountry header / 入站 IP 地理推断)process.env 自动同步 string bindings(Nuxt / Nitro 友好)/_edge/*.js shim,把 raw service binding 包成 D1Database / R2Bucket / KVNamespace 等熟悉的对象// main.js
export default {
async fetch (request, env, ctx) {
const url = new URL(request.url)
if (url.pathname === '/') {
const v = await env.MY_KV.get('hits')
ctx.waitUntil(env.MY_KV.
| 谁服务 |
|---|
| Auto-sub | <bucket>-r2-<user>.edge.bigrandall.io/<key> | edge agent 直接发 |
| Custom domain | cdn.your-domain.com/<key> | 同上,DNS CNAME 进来 |
| Central fallback | bigrandall.io/r2/<user>/<bucket>/<key> | plane 直接 stream(冷启用) |
scripts/notify-worker.ts 每 60 秒批 1000 条)agent 端 shim 还有节点本地内存 cache,热 key 命中亚毫秒。
| 加什么 |
|---|
| R2 bucket | 桶自己 hostnames + 绑这个桶的 workers/pages 全部 hostname | 手动 list |
| Worker | 自己 hostnames + 同 owner 所有 workers/pages | 手动 list |
| Pages | 同上 | 同上 |
{...envJson, ...gitBuildEnv}/var/lib/edge-builds-cache/{kind}/{id},跨 build 共享 node_modules / pnpm-storeEDGE_BUILD_USE_DOCKER=1),build 跑在 node:22-bookworm-slim 隔离环境,适合多租户 planefunction __wrapEnv(env) {
const out = {};
for (const k of Reflect.ownKeys(env)) {
out[k] = __wrap[k] ? __wrap[k](env) : env[k];
}
return out;
}const NODE_BUILTINS = ['crypto', 'url', 'buffer', 'util', 'stream', 'fs', 'path', 'os', 'process', /* ... */]
build({
alias: Object.fromEntries(NODE_BUILTINS.map(n => [n, `node:${n}`])),
external: NODE_BUILTINS
// node-crypto-shim.js
import nodeCrypto from 'node:crypto'
function safeCreateCipheriv(algo, key, iv, options) {
if (iv == null) iv = Buffer.alloc(0)
return nodeCrypto.createCipheriv(algo, key, iv, options)
}
// Proxy 让其它 API(createHash / randomBytes / …)原样透传
export default new Proxy(
function pickStringOrUndefined(fd: FormData, key: string): string | undefined {
const v = fd.get(key)
return typeof v === 'string' && v.length > 0 ? v : undefined
}
// usage
const parsed =
const LEVEL_PRIORITY = { trace: 10, debug: 20, info: 30, warn: 40, error: 50, fatal: 60 }
function makeLogger(bindings = {}, minLevel = 'info') {
const threshold = LEVEL_PRIORITY[minLevel] ?? 30
function emit(level, payload, message) {
| GH Actions → public/edge-agent/latest/ + heartbeat 比对 |
| 一推就推,无需 docker pull |