默认场景:H5 嵌在第三方宿主(App WebView / 小游戏平台)里,页面域名不归你,
connect-src 'self'锁死,fetch、WebSocket、EventSource全部不可用。 全文按这个前提写。如果你有自己的页面域名,第 9 节有更省事的路。 更新:2026-11-08 · 第二版:全部状态类设计挪进 Durable Object,轮询改服务端挂起,补成本与合规章节
0. 先盘点:你手里到底有什么
宿主内嵌场景里,四样东西的归属决定了你能走哪条路:
| 资源 | 通常归谁 | 你能不能改 | 影响 |
|---|---|---|---|
| 页面所在的源 | 宿主(如 app.builda.game) |
✗ | 决定能不能用同源反代 |
| 页面的 CSP | 宿主 | ✗ | 决定 fetch / script / iframe 哪条通道活着 |
| 一个自己的域名 / Worker | 你 | ✓ | 决定你能不能有个"接应点" |
| 服务端出口 IP | 你的云厂商 | ✓ | 决定会不会被上游按 IP 限流 |
一句话结论:
问过宿主、确认加不了白名单,再看页面源:不归你 → 同源反代出局 → 只剩"让浏览器用别的方式发请求",也就是
<script src>(JSONP)。
0.1 第一步不是写代码,是问宿主
技术上能绕,不代表该绕。先发一个工单/一封邮件,问平台能不能把你的域名加进 connect-src,或者有没有官方 API 能直接用。这是成本最低、后果最轻的路。
| 宿主答复 | 你该做什么 |
|---|---|
| 可以加白名单 | 走第 9 节同源反代,或者直接 fetch,别折腾 JSONP |
| 有官方 API | 用官方的,别自建中转 |
只能放行 script-src |
走第 3–5 节 |
| 什么都不放行 | 放弃联网功能,或换平台 |
把这一步放在最前面,原因有三个:
- 数据责任:自建中转意味着用户对话经过你的服务器,你就多了一份数据合规义务(第 12.4 节)
- 条款风险:平台明确禁止外连时,绕过去违反的是平台条款,封号成本由你承担
- 维护成本:白名单只是平台改一个字符串,而你要维护 DO、域名、证书、日志、限流(第 11 节算过账)
只有谈不下来,再读后面。
0.2 决策树
能不能让宿主加白名单 / 用官方 API?
├─ 能 ──▶ 直接用,到此为止
└─ 不能
├─ 页面源是你的吗?
│ ├─ 是 ──▶ 同源反代(第 9 节)
│ └─ 否
│ ├─ script-src 放行你的域名?
│ │ ├─ 是 ──▶ JSONP + 长轮询(第 3–5 节)
│ │ └─ 否
│ │ ├─ frame-src 放行? ──▶ iframe 桥(第 10 节)
│ │ └─ 都锁 ──▶ 没有技术手段能绕
│ └─ 你的 Worker/服务器能出网吗?
│ ├─ 能 ──▶ 用它做接应点
│ └─ 被上游按 IP 限 ──▶ 再加一级出口(第 6 节)
0.3 先跑这三行
在宿主页面的控制台里:
location.origin // 页面在哪个源(不是你的就认命)
document.querySelector('meta[http-equiv="Content-Security-Policy"]')?.content
再看响应头(浏览器 Network → 文档请求 → Response Headers):
content-security-policy: default-src 'self'; script-src 'self' https://cdn.xxx.com; connect-src 'self'
把 connect-src 和 script-src 抄下来,后面每一步都要对照它。
0.4 域名到底怎么来的(别被"二级域名"绕晕)
Cloudflare 的「添加站点」只接受根域,例如 mydomain.com。relay.mydomain.com、docs.mydomain.com、bridge.mydomain.com 都不是单独添加的站点,它们只是根域 zone 里的一条 DNS 记录。
| 你想要 | 在 Cloudflare 怎么来 | 是不是"添加站点" |
|---|---|---|
mydomain.com |
Add a site → 改 NS → 等生效 | ✓ 是,且只需一次 |
relay.mydomain.com |
根域 → DNS → 加 CNAME(指向 Pages)或 A(指向 VPS) | ✗ 否 |
docs.mydomain.com(Pages) |
Pages 项目 → Custom domains 填子域,再建对应 CNAME | ✗ 否 |
Worker 路由 relay.mydomain.com/* |
根域 zone → Workers Routes 绑定 | ✗ 否 |
所以:
- 有根域
mydomain.com→ 想开几个子域就开几个,不用再"添加站点" - 没有根域 → 直接用平台给的
项目名.pages.dev/项目名.workers.dev,一样能跑,只是域名不好看 - 想让
relay.别人的域名.com生效而根域不在你账号里 → 需要对方在 DNS 里给你加 CNAME,你这边做不了
后文所有 mydomain.com 都是指你自己的根域,relay. / bridge. / docs. 是加在它下面的子域。
1. 判断被哪道门拦住
1.1 四种"连不上"
| 类型 | 现象 | 控制台报错 | 请求发出了吗 |
|---|---|---|---|
| A 跨域 | curl 正常,网页报错 | blocked by CORS policy: No 'Access-Control-Allow-Origin' |
✓ 发了,响应被扣 |
| B CSP | Network 里根本没有这条请求 | violates ... "connect-src 'self'" |
✗ 没发 |
| C 出口被封 | 服务端日志有请求,返回 429/403 | HTTP 429 |
✓ 发了,对方不认 |
| D 网络不可达 | 一直转圈或立刻失败 | ERR_NAME_NOT_RESOLVED / ERR_CERT_* |
✗ 或半路断 |
宿主内嵌场景,九成是 B。因为 CSP 是宿主统一下发的,所有嵌进去的页面都吃同一套策略。
1.2 三十秒确认
# 服务端视角通不通
curl -i -X POST https://api.example.com/v1/x \
-H "content-type: application/json" -d '{"q":"test"}'
| curl | 浏览器 | 结论 |
|---|---|---|
| 200 | 报 CORS | A |
| 200 | 请求没发出 | B |
| 429 | 同样 429 | C |
| 超时 | 同样超时 | D |
1.3 CSP 指令对照
| 指令 | 管什么 | 被锁后 |
|---|---|---|
connect-src |
fetch、XHR、WebSocket、EventSource、sendBeacon |
所有"取数据"的方式全死 |
script-src |
<script src>、动态插入的 script、eval |
JSONP 也死 |
img-src |
<img>、CSS 背景图 |
图片裂 |
frame-src |
<iframe> |
嵌不了外部页面 |
form-action |
表单提交 | 表单跳转被拦 |
default-src |
以上没写时的兜底 | 全锁 |
核心事实:connect-src 只锁"连接",不锁"加载资源"。所以 fetch 死了,<script src> 往往还活着——这是宿主内嵌场景唯一可用的出口。
2. 唯一通道:<script src>
2.1 为什么它能出去
<!-- 受 connect-src 管,锁了就死 -->
<script>fetch('https://relay.mydomain.com/q')</script>
<script>new WebSocket('wss://relay.mydomain.com')</script>
<script>new EventSource('https://relay.mydomain.com/sse')</script>
<!-- 受 script-src 管,connect-src 管不着 -->
<script src="https://relay.mydomain.com/q?cb=cb1"></script>
浏览器把 src 指向的地址当成脚本去加载并执行。只要 script-src 放行了这个域名,这段代码就会跑。
而 JS 能执行任意语句——只要服务端把数据包成 cb1({...}),执行时就把数据交给了页面。
这不是漏洞,是 JSONP。 它之所以在宿主内嵌场景里还活着,是因为 CSP 的 connect-src 从来没打算管脚本加载。
2.2 能力边界(先认清,再动手)
| 能力 | 能不能 | 说明 |
|---|---|---|
| 发 GET | ✓ | 参数全塞 URL |
| 发 POST | ✗ | script 标签只支持 GET |
| 自定义请求头 | ✗ | 带不了 Authorization |
| 读到 HTTP 状态码 | ✗ | 401/429 都表现为"脚本没执行" |
| 读到响应头 | ✗ | 同上 |
| 流式读取 | ✗ | 脚本一次性执行完 |
| 带 Cookie | ! | 受 SameSite 限制,跨站基本带不上 |
所以 JSONP 拿到的永远是"要么成功执行,要么没执行",所有业务错误必须由服务端包进 payload,HTTP 状态永远返 200。
2.3 什么时候连这条路也没了
控制台出现这句,就是 script-src 也锁了:
Refused to load the script 'https://relay.mydomain.com/q' because it violates
the following Content Security Policy directive: "script-src 'self'"
此时只剩三条路:同源反代(前提你有页面源)、iframe 桥(前提 frame-src 放行)、让宿主加白名单。前两条在 9、10 节。
3. JSONP 服务端:你自己的接应点
宿主场景里,接应点通常是一个 Cloudflare Worker(免费、自带 HTTPS)。想用自己的域名,就在根域下开一个子域(如 relay.mydomain.com)指过来;没有根域就用 xxx.workers.dev,不影响功能。
3.1 请求流程
宿主页面
│ ① <script src=".../tk?cb=__jp0"> 取一次性令牌
│ ② <script src=".../q?tk=...&reqId=...&q=你好&cb=__jp1"> 发起
▼
Worker(持上游 Key)
│ 1. Guard DO 核销 tk(原子,用一次就废)+ 令牌桶限流
│ 2. sid 直接用 reqId 派生 → 重试落回同一个会话,不会重复扣费
│ 3. Session DO 写状态、设 alarm,立刻返回 {sid, key}
▼
宿主页面拿到 sid 和 key
│ ③ <script src=".../poll?sid=...&key=...&rev=0&rrev=0&cb=__jp2">
▼
Session DO:没有新增量就挂起 25 秒(不是空转)
│ 有增量立刻返回 → __jp2({"d":"增量","next":7,"done":0})
三个设计决定,各有各的原因:
| 决定 | 原因 |
|---|---|
| 先返回 sid 再取结果 | script 加载有超时,生成要几十秒,一次请求等不起 |
| sid + key 双因子 | sid 会进宿主网络日志、CDN 日志、用户截图,单靠它等于把对话挂在公网 |
| 服务端挂起而不是客户端轮询 | 300 ms 一次、180 秒上限意味着单次问答最多 600 个请求,绝大部分是空轮询 |
3.2 Worker 入口(完整)
先纠正一个常见误解:Worker 本身是无状态、多 isolate 的。你在模块顶层写 const map = new Map(),每个 isolate 各有一份,互相看不见,实例还会被随时销毁。所以令牌、限流、会话这三样东西全部要放进 Durable Object,Worker 只做路由和参数校验。
/tk → Guard DO 签发一次性令牌(5 分钟)
/q → Guard 核销令牌 + 限流 → Session DO 启动生成 → 返回 {sid, key}
/poll → Session DO 校验 key → 没有新增量就挂起 25s → 返回增量
// src/index.js
const CB_RE = /^[A-Za-z_$][A-Za-z0-9_$.]{0,63}$/;
const SID_RE = /^[a-f0-9]{32}$/;
const URL_BUDGET = 2000; // URL 上留给 q 的字节数(不是字符)
const REF_OK = ['app.builda.game', 'game.builda.game'];
// Referer 只是筛子,不是鉴权:能伪造、能被隐私设置抹掉
function refOk(req) {
const ref = req.headers.get('referer') || '';
try { return REF_OK.includes(ref ? new URL(ref).host : ''); }
catch (e) { return false; }
}
function js(cb, obj, status = 200) {
const body = cb
? cb + '(' + JSON.stringify(obj) + ')'
: JSON.stringify(obj);
return new Response(body, {
status,
headers: {
'content-type': cb ? 'application/javascript; charset=utf-8' : 'application/json',
'cache-control': 'no-store',
'x-content-type-options': 'nosniff',
},
});
}
export default {
async fetch(req, env) {
const u = new URL(req.url);
const cb = u.searchParams.get('cb') || '';
// ① callback 名白名单,防反射型 XSS
if (cb && !CB_RE.test(cb)) return new Response('bad callback', { status: 400 });
// ② 只放行三条路径,别做成任意代理
const p = u.pathname;
if (p !== '/tk' && p !== '/q' && p !== '/poll') return new Response('not found', { status: 404 });
const guard = env.GUARD.get(env.GUARD.idFromName('guard'));
// ── 签发一次性令牌 ───────────────────────────────
if (p === '/tk') {
if (!refOk(req)) return js(cb, { ok: false, err: 'forbidden' });
// cid 是客户端首次生成的设备标识,用来分桶限流。
// 不要用 IP 分桶:移动网络大量用户共享同一个出口 IP,会互相误伤。
const cid = (u.searchParams.get('cid') || '').toLowerCase();
if (!SID_RE.test(cid)) return js(cb, { ok: false, err: 'bad-cid' });
const r = await guard.fetch('https://g/issue?uid=' + cid);
return js(cb, await r.json());
}
// ── 发起 ─────────────────────────────────────────
if (p === '/q') {
if (!refOk(req)) return js(cb, { ok: false, err: 'forbidden' });
const q = u.searchParams.get('q') || '';
const tk = u.searchParams.get('tk') || '';
const reqId = (u.searchParams.get('reqId') || '').toLowerCase();
if (!q.trim()) return js(cb, { ok: false, err: 'empty' });
if (!SID_RE.test(reqId)) return js(cb, { ok: false, err: 'bad-req' });
// 按"编码后字节数"算,中文一个字 9 字节,别拿字符数当预算
if (encodeURIComponent(q).length > URL_BUDGET) {
return js(cb, { ok: false, err: 'too-long' });
}
// 令牌核销:DO 内串行,先删后判,天然没有 TOCTOU
const rr = await guard.fetch('https://g/redeem?tk=' + encodeURIComponent(tk));
const rj = await rr.json();
if (!rj.ok) return js(cb, { ok: false, err: 'bad-tk' });
// 限流:同一个 DO 里的令牌桶,跨 isolate 有效
const ar = await guard.fetch('https://g/allow?k=' + encodeURIComponent(rj.uid));
if (!(await ar.json()).ok) return js(cb, { ok: false, err: 'rate' });
// sid 直接用客户端传来的 reqId:重试落回同一个 DO,重复扣费自然消失
const sid = reqId;
const stub = env.SESSION.get(env.SESSION.idFromName('s:' + sid));
const t = new URL('https://s/start');
t.searchParams.set('sid', sid);
t.searchParams.set('q', q);
t.searchParams.set('think', u.searchParams.get('think') || '');
const st = await (await stub.fetch(t.toString())).json();
if (!st.ok) return js(cb, st);
return js(cb, { ok: true, sid, key: st.key });
}
// ── 取增量 ───────────────────────────────────────
const sid = (u.searchParams.get('sid') || '').toLowerCase();
const key = u.searchParams.get('key') || '';
if (!SID_RE.test(sid)) return js(cb, { ok: false, err: 'bad-sid' });
const stub = env.SESSION.get(env.SESSION.idFromName('s:' + sid));
const t = new URL('https://s/poll');
t.searchParams.set('key', key);
t.searchParams.set('rev', u.searchParams.get('rev') || '0');
t.searchParams.set('rrev', u.searchParams.get('rrev') || '0');
const r = await stub.fetch(t.toString());
// 关键:先 JSON.parse 再 stringify。
// 上游 502 时会吐一整页 HTML,直接拼进 JS 就是语法错误 + 可控注入面。
let obj;
try { obj = JSON.parse(await r.text()); }
catch (e) { obj = { ok: false, err: 'bad-payload' }; }
return js(cb, obj);
},
};
REF_OK 是宿主域名列表:
const REF_OK = ['app.builda.game', 'game.builda.game'];
3.3 会话状态放 Durable Object
三个理由,任何一条都足以否掉 KV:
| 存储 | 问题 |
|---|---|
| KV | 最终一致:刚写进去,另一个边缘节点可能读到旧值,轮询永远拿不到最新增量 |
| KV | 没有原子 get-delete:一次性令牌在两步之间能被并发用掉多次 |
模块级 Map |
每个 isolate 一份,实例随时销毁,限流和会话都会"偶尔失效" |
| Durable Object | 单实例串行、强一致、能起 alarm、能持久化,天生适合"一个会话一个对象" |
Session:一个会话一个 DO
// src/session.js
export class Session {
// 构造函数里恢复一次:DO 被驱逐后重新拉起时内存是空的,
// 不恢复就会出现"轮询永远返回空、done 永远是 0"。
constructor(state, env) {
this.state = state;
this.env = env;
this.chunks = []; // 正文增量,按块存,游标是块序号
this.rchunks = []; // 思考增量
this.rev = 0;
this.rrev = 0;
this.done = 0;
this.err = '';
this.key = '';
this.at = 0;
this.waiters = [];
state.blockConcurrencyWhile(async () => {
const meta = await state.storage.get('meta');
if (!meta) return;
this.key = meta.key || '';
this.done = meta.done || 0;
this.err = meta.err || '';
this.chunks = (await state.storage.get('chunks')) || [];
this.rchunks = (await state.storage.get('rchunks')) || [];
this.rev = this.chunks.length;
this.rrev = this.rchunks.length;
this.at = meta.at || 0;
});
}
async fetch(req) {
const u = new URL(req.url);
if (u.pathname === '/start') return this.start(u);
return this.poll(u);
}
// ── 启动:只写状态、起 alarm,不在这里跑生成 ──
async start(u) {
// 幂等:同一个 sid 重试多少次,都返回同一把 key
if (this.key) return Response.json({ ok: true, key: this.key });
this.key = crypto.randomUUID().replace(/-/g, '');
await this.state.storage.put('meta', {
sid: u.searchParams.get('sid'),
key: this.key,
q: u.searchParams.get('q') || '',
think: u.searchParams.get('think') === '1',
at: Date.now(),
});
// 用 alarm 触发,而不是 waitUntil:
// waitUntil 在响应后只有约 30 秒,生成动辄 60 秒以上,长回答会被腰斩,
// 而且表现为"永远没有 done",客户端只能等到自己的总超时。
await this.state.storage.setAlarm(Date.now() + 50);
return Response.json({ ok: true, key: this.key });
}
// ── 生成在 alarm 里跑 ──
async alarm() {
const meta = await this.state.storage.get('meta');
if (!meta) return;
// alarm 至少投递一次:如果上次跑到一半 DO 被驱逐,这次会重入。
// 不挡一下就会重复调用上游、重复计费。
if (meta.running && !this.done) {
this.err = 'interrupted';
this.done = 1;
await this.state.storage.put('meta', { ...meta, done: 1, err: this.err, at: Date.now() });
return;
}
if (this.done) {
// 已完成:再等 10 分钟无人访问就回收,DO 不会自己过期
if (Date.now() - (this.at || meta.at) > 600000) {
await this.state.storage.deleteAll();
return;
}
await this.state.storage.setAlarm(Date.now() + 60000);
return;
}
await this.state.storage.put('meta', { ...meta, running: 1 });
await this.run(meta.q, meta.think);
this.done = 1;
this.at = Date.now();
await this.state.storage.put('meta', { ...meta, done: 1, err: this.err, at: this.at });
this.wake();
// 留一个 alarm 做回收
await this.state.storage.setAlarm(Date.now() + 60000);
}
async run(q, think) {
const ctl = new AbortController();
try {
const up = await fetch('https://api.example.com/v1/chat', {
method: 'POST',
headers: {
'content-type': 'application/json',
authorization: 'Bearer ' + this.env.UP_KEY,
},
body: JSON.stringify({ q, think, stream: true }),
signal: ctl.signal,
});
if (!up.ok) throw new Error('HTTP ' + up.status);
const reader = up.body.getReader();
const dec = new TextDecoder();
let buf = '';
let dirty = 0;
let lastFlush = Date.now();
for (;;) {
// 20 秒没有任何新数据就判死,否则永远转圈
let idle = null;
const guard = new Promise((_, rej) => {
idle = setTimeout(() => rej(new Error('idle-timeout')), 20000);
});
let it;
try {
it = await Promise.race([reader.read(), guard]);
} finally {
clearTimeout(idle);
}
if (it.done) break;
buf += dec.decode(it.value, { stream: true });
let cut;
while ((cut = buf.indexOf('\n')) >= 0) {
const line = buf.slice(0, cut).trim();
buf = buf.slice(cut + 1);
if (!line.startsWith('data:')) continue;
const payload = line.slice(5).trim();
if (payload === '[DONE]') { this.done = 1; return; }
let j;
try { j = JSON.parse(payload); } catch (e) { continue; }
const d = j.choices && j.choices[0] && j.choices[0].delta;
if (!d) continue;
// 一块一块追加,游标就是块的序号,永远不会切在半个 emoji 上
if (d.reasoning_content) { this.rchunks.push(d.reasoning_content); this.rrev++; }
if (d.content) { this.chunks.push(d.content); this.rev++; }
dirty++;
this.wake();
if (dirty >= 20 || Date.now() - lastFlush > 2000) {
await this.state.storage.put('chunks', this.chunks);
await this.state.storage.put('rchunks', this.rchunks);
dirty = 0;
lastFlush = Date.now();
}
}
}
this.done = 1;
} catch (e) {
this.err = String((e && e.message) || e);
this.done = 1;
}
}
// ── 取增量:没有新内容就挂起 ──
async poll(u) {
const key = u.searchParams.get('key') || '';
if (!this.key) return Response.json({ ok: false, err: 'no-session' });
// sid 会出现在宿主网络日志、CDN 日志、用户截图里,
// 必须有第二因子,否则拿到 sid 的人能 from=0 拉走整段对话。
if (key !== this.key) return Response.json({ ok: false, err: 'forbidden' });
const rev = Math.max(0, Number(u.searchParams.get('rev')) || 0);
const rrev = Math.max(0, Number(u.searchParams.get('rrev')) || 0);
// 服务端挂起:有增量立刻回,没有就等,最多 25 秒。
// 客户端从"每 300ms 轮一次、一次问答 600 个请求"降到 6–10 个请求。
await this.waitFor(rev, rrev, 25000);
return Response.json({
d: this.chunks.slice(rev).join(''),
next: this.rev, // 不透明游标:客户端只负责原样回传
rd: this.rchunks.slice(rrev).join(''),
rnext: this.rrev,
done: this.done,
err: this.err,
});
}
waitFor(rev, rrev, ms) {
if (this.rev > rev || this.rrev > rrev || this.done) return Promise.resolve();
return new Promise((resolve) => {
const done = () => {
clearTimeout(timer);
const i = this.waiters.indexOf(done);
if (i >= 0) this.waiters.splice(i, 1);
resolve();
};
const timer = setTimeout(done, ms);
this.waiters.push(done);
});
}
wake() {
const list = this.waiters.splice(0);
for (const f of list) f();
}
}
Guard:令牌和限流都放这里
// src/guard.js
const RATE = 20 / 60000; // 20 次/分钟
const CAP = 20;
export class Guard {
constructor(state) {
this.state = state;
this.tk = new Map(); // tk → { uid, exp }
this.bk = new Map(); // uid → { tokens, at }
}
async fetch(req) {
const u = new URL(req.url);
const now = Date.now();
if (u.pathname === '/issue') {
const tk = crypto.randomUUID().replace(/-/g, '');
const uid = u.searchParams.get('uid') || 'anon';
this.tk.set(tk, { uid, exp: now + 300000 });
await this.state.storage.setAlarm(now + 60000);
return Response.json({ ok: true, tk });
}
if (u.pathname === '/redeem') {
const tk = u.searchParams.get('tk') || '';
const v = this.tk.get(tk);
// 先删再看:DO 单线程串行,两次并发核销一定一前一后,
// 第二次拿到 undefined。这是 KV 的 get-delete 做不到的。
this.tk.delete(tk);
if (!v || v.exp < now) return Response.json({ ok: false, err: 'bad-tk' });
return Response.json({ ok: true, uid: v.uid });
}
if (u.pathname === '/allow') {
const k = u.searchParams.get('k') || 'anon';
let b = this.bk.get(k) || { tokens: CAP, at: now };
b.tokens = Math.min(CAP, b.tokens + (now - b.at) * RATE);
b.at = now;
const ok = b.tokens >= 1;
if (ok) b.tokens -= 1;
this.bk.set(k, b);
return Response.json({ ok });
}
return Response.json({ ok: false }, { status: 404 });
}
async alarm() {
const now = Date.now();
for (const [k, v] of this.tk) if (v.exp < now) this.tk.delete(k);
for (const [k, v] of this.bk) if (now - v.at > 3600000) this.bk.delete(k);
if (this.tk.size || this.bk.size) await this.state.storage.setAlarm(now + 60000);
}
}
单实例是瓶颈:一个 Guard DO 大约能扛每秒几百次核销。真要上量,按
idFromName('g:' + hash(uid) % 8)分 8 片——代价是配额变成每片 20 次,等于 160 次/分钟,得自己权衡。
wrangler.toml:
name = "my-relay"
main = "src/index.js"
compatibility_date = "2026-11-01"
[[durable_objects.bindings]]
name = "SESSION"
class_name = "Session"
[[durable_objects.bindings]]
name = "GUARD"
class_name = "Guard"
[[migrations]]
tag = "v1"
new_sqlite_classes = ["Session", "Guard"]
[[routes]]
pattern = "relay.mydomain.com/*"
zone_name = "mydomain.com" # 根域(已接入 Cloudflare 的那个),不是子域
wrangler secret put UP_KEY
wrangler deploy
免费版跑不了这套:Workers 免费计划 CPU 上限 10 ms/请求,长流式生成必然超限;Durable Object 的 alarm 虽然能跑 15 分钟 wall-clock,但 CPU 仍是 10 ms。要跑 60 秒的生成,得有 Workers Paid($5/月,CPU 30 s 起),或者把生成搬到 VPS / 云函数,DO 只做状态与分发——第 6 节的二级出口顺便就能干这件事。
3.4 没有 Cloudflare 时:Node 版
同样的模型,用 Express 写一遍。注意这里能直接用 Map,是因为单进程、单线程;一旦上 PM2 cluster 或多实例,所有 Map 都要换成 Redis,并且令牌核销要用 GETDEL 之类的原子命令。
import express from 'express';
import crypto from 'node:crypto';
const app = express();
const SESS = new Map(); // sid → 会话
const TK = new Map(); // tk → 过期时间
const BUCKET = new Map(); // uid → 令牌桶
const CB_RE = /^[A-Za-z_$][A-Za-z0-9_$.]{0,63}$/;
const RATE = 20 / 60000, CAP = 20;
function reply(req, res, payload) {
const cb = String(req.query.cb || '');
res.set('Cache-Control', 'no-store');
res.set('X-Content-Type-Options', 'nosniff');
if (!cb) return res.json(payload);
if (!CB_RE.test(cb)) return res.status(400).type('text/plain').send('bad callback');
res.type('application/javascript').send(`${cb}(${JSON.stringify(payload)})`);
}
function allow(k) {
const now = Date.now();
const b = BUCKET.get(k) || { tokens: CAP, at: now };
b.tokens = Math.min(CAP, b.tokens + (now - b.at) * RATE);
b.at = now;
const ok = b.tokens >= 1;
if (ok) b.tokens -= 1;
BUCKET.set(k, b);
return ok;
}
// ① 发一次性令牌
app.get('/tk', (req, res) => {
const tk = crypto.randomBytes(16).toString('hex');
TK.set(tk, Date.now() + 300000);
reply(req, res, { ok: true, tk });
});
// ② 发起
app.get('/q', (req, res) => {
const tk = String(req.query.tk || '');
const exp = TK.get(tk);
TK.delete(tk); // 单进程同步执行,天然原子
if (!exp || exp < Date.now()) return reply(req, res, { ok: false, err: 'bad-tk' });
const reqId = String(req.query.reqId || '');
if (!/^[a-f0-9]{32}$/.test(reqId)) return reply(req, res, { ok: false, err: 'bad-req' });
if (!allow('u')) return reply(req, res, { ok: false, err: 'rate' });
const q = String(req.query.q || '');
if (!q.trim()) return reply(req, res, { ok: false, err: 'empty' });
if (encodeURIComponent(q).length > 2000) return reply(req, res, { ok: false, err: 'too-long' });
let st = SESS.get(reqId);
if (!st) {
// 同一个 reqId 只跑一次:客户端超时重试不会重复触发上游、重复扣费
st = {
chunks: [], rchunks: [], done: 0, err: '',
key: crypto.randomBytes(16).toString('hex'),
at: Date.now(), waiters: [],
};
SESS.set(reqId, st);
runUpstream(q, st);
}
reply(req, res, { ok: true, sid: reqId, key: st.key });
});
// ③ 取增量(没有新内容就挂起)
app.get('/poll', async (req, res) => {
const st = SESS.get(String(req.query.sid));
if (!st) return reply(req, res, { ok: false, err: 'no-session' });
if (String(req.query.key) !== st.key) return reply(req, res, { ok: false, err: 'forbidden' });
const rev = Math.max(0, Number(req.query.rev) || 0);
const rrev = Math.max(0, Number(req.query.rrev) || 0);
st.at = Date.now();
await waitFor(st, rev, rrev, 25000);
reply(req, res, {
d: st.chunks.slice(rev).join(''),
next: st.chunks.length, // 不透明游标,客户端原样回传
rd: st.rchunks.slice(rrev).join(''),
rnext: st.rchunks.length,
done: st.done,
err: st.err,
});
});
function waitFor(st, rev, rrev, ms) {
if (st.chunks.length > rev || st.rchunks.length > rrev || st.done) return Promise.resolve();
return new Promise((resolve) => {
const fire = () => {
clearTimeout(timer);
const i = st.waiters.indexOf(fire);
if (i >= 0) st.waiters.splice(i, 1);
resolve();
};
const timer = setTimeout(fire, ms);
st.waiters.push(fire);
});
}
async function runUpstream(q, st) {
try {
const up = await fetch('https://api.example.com/v1/chat', {
method: 'POST',
headers: {
'content-type': 'application/json',
authorization: 'Bearer ' + process.env.UP_KEY,
},
body: JSON.stringify({ q, stream: true }),
signal: AbortSignal.timeout(120000),
});
if (!up.ok) throw new Error('HTTP ' + up.status);
const reader = up.body.getReader();
const dec = new TextDecoder();
let buf = '';
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
let cut;
while ((cut = buf.indexOf('\n')) >= 0) {
const line = buf.slice(0, cut).trim();
buf = buf.slice(cut + 1);
if (!line.startsWith('data:')) continue;
const payload = line.slice(5).trim();
if (payload === '[DONE]') { st.done = 1; wake(st); return; }
let j;
try { j = JSON.parse(payload); } catch { continue; }
const d = j.choices && j.choices[0] && j.choices[0].delta;
if (!d) continue;
// 按块存,游标是块序号:不会把 emoji 的代理对切成两半
if (d.reasoning_content) st.rchunks.push(d.reasoning_content);
if (d.content) st.chunks.push(d.content);
wake(st);
}
}
st.done = 1;
wake(st);
} catch (e) {
st.err = String((e && e.message) || e);
st.done = 1;
wake(st);
} finally {
st.at = Date.now();
}
}
function wake(st) {
for (const f of st.waiters.splice(0)) f();
}
// 十分钟没人取就回收,否则文本常驻内存
setInterval(() => {
const now = Date.now();
for (const [k, v] of SESS) if (now - v.at > 600000) SESS.delete(k);
for (const [k, v] of TK) if (v < now) TK.delete(k);
for (const [k, v] of BUCKET) if (now - v.at > 3600000) BUCKET.delete(k);
}, 60000);
app.listen(8787);
上生产要改三处:
Map→ Redis(令牌核销用GETDEL);runUpstream拆成独立进程或队列,Node 单进程跑长任务会拖慢所有请求;多实例时waitFor的挂起要在 Redis 上做 pub/sub,不能靠内存 promise。
3.5 服务端六个必做项
| 项 | 原因 |
|---|---|
| callback 名白名单校验 | 不校验就是反射型 XSS:cb=alert(1)// 直接在你页面执行 |
Cache-Control: no-store |
否则旧 WebView 会缓存 GET,第二次拿到旧答案 |
X-Content-Type-Options: nosniff + 正确 content-type |
少了会被当文本拒绝执行 |
| JSON.parse 之后再 stringify | 上游返回 HTML 错误页时,直接拼进 cb(...) 会从语法错误变成可控注入面 |
| 游标由服务端给 | 客户端算 pos += d.length 会被 UTF-16 / 字节 / 码点三种口径打架撕碎 |
| HTTP 永远 200 | 浏览器读不到状态码,错误只能进 payload |
还有三条不在代码里,但漏了会出事:
- 不要用
waitUntil跑生成:响应后只有约 30 秒,长回答会被腰斩,而且客户端表现为"永远没有 done" - 不要用 KV 做一次性令牌:
get和delete之间是竞态窗口,KV 删除还是全球最终一致 - 不要用模块级
Map做限流:每个 isolate 一份,实际配额是 20 × 实例数
4. JSONP 客户端:动态插 script
4.1 完整封装
let seq = 0;
function jsonp(url, timeout = 30000) {
return new Promise((resolve, reject) => {
const name = '__jp' + Date.now().toString(36) + (seq++);
const script = document.createElement('script');
let settled = false;
const cleanup = () => {
try { delete window[name]; } catch (e) { window[name] = undefined; }
if (script.parentNode) script.parentNode.removeChild(script);
clearTimeout(timer);
};
window[name] = (data) => {
if (settled) return;
settled = true;
resolve(data);
cleanup();
};
const timer = setTimeout(() => {
if (settled) return;
settled = true;
reject(new Error('jsonp-timeout'));
cleanup();
}, timeout);
script.onerror = () => {
if (settled) return;
settled = true;
reject(new Error('jsonp-network'));
cleanup();
};
script.src = url + (url.indexOf('?') >= 0 ? '&' : '?') + 'cb=' + name;
script.async = true;
document.head.appendChild(script);
});
}
四个细节都是踩过坑才有的:
| 细节 | 不做会怎样 |
|---|---|
settled 标志 |
超时后又执行回调 → 重复 resolve / 内存泄漏 |
cleanup() 删全局函数 |
每问一次泄一个 window.__jp*,长会话必崩 |
script.onerror |
域名解析失败、被 CSP 拦,只会静默卡到超时 |
服务端 no-store |
GET 被缓存,第二次拿到旧答案 |
4.2 调用
function randId() {
const b = new Uint8Array(16);
crypto.getRandomValues(b);
return [...b].map(x => x.toString(16).padStart(2, '0')).join('');
}
// ① 设备标识:首次生成后持久化,用来分桶限流(换设备即换桶)
function cid() {
let v = localStorage.getItem('cid');
if (!v) { v = randId(); localStorage.setItem('cid', v); }
return v;
}
// ② 取一次性令牌(可以预取,5 分钟内有效)
const t = await jsonp('/tk?cid=' + cid(), 15000);
if (!t.ok) throw new Error(t.err || 'tk-failed');
// ③ 发起:reqId 由客户端生成,重试沿用同一个
const reqId = randId();
const r = await jsonp('/q?tk=' + t.tk + '&reqId=' + reqId + '&q=' + encodeURIComponent('你好'), 20000);
if (!r.ok) throw new Error(r.err);
const { sid, key } = r; // key 只在 poll 的 URL 里出现,不落任何存储
reqId 为什么必须由客户端生成:20 秒超时重试时,如果服务端每次新建会话,就会重复触发上游生成、重复扣费,而旧会话还在后台跑、没人来取。用同一个 reqId,服务端第二次直接返回上次的 sid。
4.3 URL 长度是硬约束
| 浏览器/WebView | 上限 |
|---|---|
| IE / 老 Android WebView | 2083 字符 |
| Chrome / Safari 现代版 | 8 KB 以上,但不保证 |
| 经过 CDN / 反代 | 常被压到 4–8 KB |
统一按字节算,不要按字符算:中文经 encodeURIComponent 后每个字 9 字节(%E4%BD%A0),200 字 = 1.8 KB。所以 2000 字符的中文是 18 KB,早被掐断了。
// ✓ 正确:编码后的字节数
const okByByte = encodeURIComponent(q).length <= 2000;
// ✗ 错误:字符数,中文会超 9 倍
const wrongByChar = q.length <= 2000;
2000 字节 ≈ 222 个汉字。但 URL 上还有 cb、tk(64 hex 字符)、reqId(32)、sid、key 这些参数,加起来 150–200 字节。所以:
- 现代 WebView:
q给 2000 字节没问题 - 要兼容 2083 字符上限的老 Android WebView:
q得压到 1500 左右
更长的内容(文章、代码)需要"先上传换 token"——但上传本身要 POST,JSONP 做不了,只能由宿主提供上传接口,或把长文本切段分多次发。
5. 长轮询:把"流式"拆成"多次取增量"
5.1 状态机
① GET /tk → {ok:true, tk} 5 分钟有效
② GET /q?tk&reqId&q&cb → {ok:true, sid, key} 立即返回
③ GET /poll?sid&key&rev&rrev → {d, next, rd, rnext, done} 挂起 25s
④ rev = next, rrev = rnext,重复 ③
⑤ done:1 或 err 非空 → 结束
两个关键点:
rev/rrev是服务端给的块序号,客户端只负责原样回传。不要自己算pos += d.length:String.length是 UTF-16 code unit,上游按字节或码点切片时你会切在半个 emoji 上,本地测中文短句永远正常,上线遇到 emoji 才炸。/poll是挂起的。有增量立刻返回,没有就等 25 秒。这样一次问答的请求数从 600 降到 6–10,而且客户端不需要自己写退避。
断线续传是顺带的好处:某次 poll 丢了,下次带同一个
rev就能把中间那段完整补回来,因为服务端按块切片,不会错位。
5.2 客户端实现
async function ask(question, onDelta, onReason, signal) {
// ① 令牌(cid() 见 4.2)
const t = await jsonp('/tk?cid=' + cid(), 15000);
if (!t.ok) throw new Error(t.err || 'tk-failed');
// ② 发起:reqId 固定,重试不会重复扣费
const reqId = randId();
const start = await jsonp(
'/q?tk=' + t.tk + '&reqId=' + reqId + '&q=' + encodeURIComponent(question), 20000);
if (!start.ok) throw new Error(start.err || 'start-failed');
const { sid, key } = start;
let rev = 0, rrev = 0;
const startedAt = Date.now();
const LIMIT = 180000; // 只算"页面在前台"的时间
let hiddenAt = 0, hiddenMs = 0;
const onVis = () => {
if (document.hidden) hiddenAt = Date.now();
else if (hiddenAt) { hiddenMs += Date.now() - hiddenAt; hiddenAt = 0; }
};
document.addEventListener('visibilitychange', onVis);
try {
while (Date.now() - startedAt - hiddenMs < LIMIT) {
if (signal && signal.aborted) throw new Error('aborted');
const r = await jsonp(
`/poll?sid=${sid}&key=${key}&rev=${rev}&rrev=${rrev}`, 30000);
if (r.err && r.err !== 'expired') throw new Error(r.err);
if (r.d) { onDelta(r.d); rev = r.next; }
if (r.rd) { onReason(r.rd); rrev = r.rnext; }
if (r.done) return;
// 服务端已经挂了 25 秒,正常情况下这里立刻就能进下一轮,
// 不需要再 sleep(300) 空转;只有服务端提前返回空才补 200ms。
if (!r.d && !r.rd) await sleep(200);
}
throw new Error('timeout');
} finally {
document.removeEventListener('visibilitychange', onVis);
}
}
5.3 四个必须处理的细节
| 细节 | 不处理会怎样 |
|---|---|
| 游标用服务端返回值 | 客户端算长度,遇到 emoji / 生僻字会切在代理对中间,出现乱码、重复、丢字 |
visibilitychange |
用户切后台,挂起请求会一直续,白烧流量;且切后台五分钟回来直接触发总超时 |
| 总时长只算前台时间 | 用 Date.now() < deadline 做判断,后台待机的时长也计入,回来就 timeout |
| 总时长上限 | 服务端挂了不返回 done,客户端永远轮询 |
关于暂停,有个细节值得单独说:上一版写的是 while (paused) await sleep(500),这没有用——它只是在客户端空转,服务端的 180 秒 deadline 照样走,切后台五分钟回来照样超时。正确做法是:
- 页面进入后台 → 立刻
script移除、请求中止,别再续 - 恢复时用同一个
sid/key/rev继续,服务端还留着数据 - 计算总时长时扣除隐藏时长(上面代码里的
hiddenMs)
5.4 体感优化
长轮询天生比流式"顿"。三个办法:
- 首包优先:收到上游第一个 token 就把增量返回,别攒够一句
- 前端补间:拿到一段增量后按 30 ms/字 逐字渲染,视觉上和流式几乎无差别
- 挂起时长压到 20–25 秒:太短会退化成高频轮询,太长会让"最后一句话"迟到一个挂起周期
代价是首字仍然比直连慢 200–600 ms(两次 RTT + 挂起唤醒),这是宿主内嵌场景绕不开的成本。
6. 出口 IP 被限:再加一跳
6.1 现象
本机 curl 上游:200
Worker 调上游:429 / 403,稳定复现
换一把 Key:还是 429
6.2 原因
Worker 的出站请求来自 Cloudflare 共享 IP 池。同一个 IP 上有成千上万个项目在打同一个目标,对方按 IP 限流,你被连坐。
换 Key 没用——限的是 IP,不是账号。
6.3 做法:不同 IP 的二级出口
宿主页面 ──script──▶ Worker ──▶ VPS / 云函数(独立 IP)──▶ 上游
VPS 上用 Caddy 做一层,必须带 token,否则就是公开代理:
relay2.mydomain.com {
@bad not header X-Relay-Token "你的随机长串"
respond @bad 403
reverse_proxy https://api.example.com {
header_up Host api.example.com
flush_interval -1
}
}
Worker 只改上游地址:
const up = await fetch('https://relay2.mydomain.com/v1/chat', {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-relay-token': this.env.RELAY_TOKEN,
},
body,
});
6.4 出口选择
| 出口 | IP 干净度 | 成本 | 适合 |
|---|---|---|---|
| Cloudflare Worker | 差(共享) | 免费 | 一般接口 |
| 轻量云 VPS | 好(独享) | ¥24/月起 | 被限流时的第一选择 |
| 云函数(Lambda / SCF) | 中(池化) | 按量 | 已有云账号 |
| 代理池 | 差且不稳 | — | 不建议生产 |
换出口只解决"IP 被限",解决不了"账号被限"。换 IP 后仍 429,看
Retry-After和配额说明。
7. 鉴权:JSONP 接口是公开的 GET
JSONP 有个天然劣势:它就是一个 GET URL。任何人拿到这个 URL,浏览器地址栏一敲就能用,不需要你的页面。
所以中转必须自己建门槛,否则等于把你的 Key 挂在公网。
先记住一条:所有"状态类"的鉴权(令牌、限流、计数)都必须放 Durable Object。放在 Worker 模块作用域里的 Map,每个 isolate 一份,实例随时销毁,等于没有。
7.1 三层
| 层 | 做法 | 挡住什么 | 放哪 |
|---|---|---|---|
| 来源校验 | 检查 Referer / Origin 是否属于宿主域名 |
别的网站盗用(可伪造,但抬高成本) | Worker |
| 一次性令牌 | URL 带 tk,服务端原子核销 |
扫接口的脚本、被贴群的链接 | Guard DO |
| 限流 | 令牌桶,按 uid 计 | 拿到令牌后的滥用 | Guard DO |
Referer 校验示例:
// 完整实现在 3.2 的 refOk(),核心只有一件事:把 Referer 的 host 拿去比对
const REF_OK = ['app.builda.game', 'game.builda.game'];
function refOk(req) {
const ref = req.headers.get('referer') || '';
let host = '';
try { host = ref ? new URL(ref).host : ''; } catch (e) {}
return REF_OK.includes(host);
}
Referer可以被伪造,也能被隐私设置抹掉。它只能当"第一道筛子",不能当唯一鉴权。
7.2 一次性令牌:必须在 DO 里核销
错误写法(很常见):
// ✗ 这三行有两个问题
async function redeem(env, tk) {
const uid = await env.KV.get('tk:' + tk);
if (!uid) return null;
await env.KV.delete('tk:' + tk);
return uid;
}
| 问题 | 后果 |
|---|---|
get 和 delete 之间是竞态窗口 |
同一个 tk 并发打两次,两次都能通过 |
| KV 删除是全球最终一致的 | 传播期内(几十秒到几分钟)令牌仍然有效 |
| 每发一个令牌一次 KV 写 | 免费版 1000 写/天,几百个用户就打满 |
正确写法:核销放进 Guard DO,单线程串行,没有并发窗口。
class Guard {
redeem(tk, now) {
const v = this.tk.get(tk);
// 先删再看:两次并发核销一定一前一后,第二次拿到 undefined。
this.tk.delete(tk);
if (!v || v.exp < now) return null;
return v.uid;
}
}
签发时给 5 分钟 TTL,并且一次性令牌只绑定一次问答:用完即废,用户下一句要重新取。这样即使 URL 被贴到群里,五分钟后就失效,而且只能用一次。
别把令牌写进前端构建产物。令牌必须运行时从
/tk取,写死在 JS 里的那不叫令牌,叫公开密码。
7.3 限流:令牌桶也在 DO 里
// Guard DO 里
const RATE = 20 / 60000; // 20 次/分钟
const CAP = 20;
function allow(bk, k) {
const now = Date.now();
const b = bk.get(k) || { tokens: CAP, at: now };
b.tokens = Math.min(CAP, b.tokens + (now - b.at) * RATE);
b.at = now;
const ok = b.tokens >= 1;
if (ok) b.tokens -= 1;
bk.set(k, b);
return ok;
}
对比一下三种写法:
| 写法 | 实际效果 |
|---|---|
模块级 new Map() |
配额 = 20 × isolate 数,且实例重启就清零 |
| KV 计数 | 每次限流一次读写,最终一致,计数会丢 |
| 单个 Guard DO | 全局一份,串行,准确;上限约每秒几百次 |
| Guard DO 分片(8 片) | 吞吐 ×8,但配额也变成 20 × 8 |
小项目用单实例就够;真上量再分片,并且把 CAP 按片数调回去。
Cloudflare 也有平台自带的 Rate Limiting binding,不想自己写可以用它。但它的计数窗口是粗粒度的,做"每人每分钟 20 次"这种精确配额,还是 DO 更稳。
7.4 参数白名单
JSONP 的参数全在 URL 上,更要防:
const ALLOW_MODEL = new Set(['model-a', 'model-b']);
function checkParams(u) {
const model = u.searchParams.get('model');
if (model && !ALLOW_MODEL.has(model)) return 'model';
// 只认这几个参数,其余一律忽略
const q = u.searchParams.get('q') || '';
if (encodeURIComponent(q).length > 2000) return 'too-long';
return ''; // 空字符串 = 通过
}
别把 q 原样拼进 system prompt——用户可以通过 URL 注入指令。要放在 user 角色里,并对长度、字符做限制。
模型的 max_tokens、temperature 这类参数不要让客户端传,或者传了也要钳位:客户端能自由指定 max_tokens 就等于能自由刷你的额度。
7.5 sid 不是秘密
sid 会出现在这些地方:
- 宿主 App 的网络日志、崩溃上报
- CDN / 反代的访问日志
- 用户截图、客服工单
- 浏览器历史记录
上一版设计里 /poll?sid=xxx 就能 from=0 拉走整段对话,等于把聊天记录挂在公网。修法很简单:/q 返回一把只存在内存里的 key,/poll 必须带它。
class Session {
poll(u) {
const key = u.searchParams.get('key') || '';
if (key !== this.key) return Response.json({ ok: false, err: 'forbidden' });
// …继续取增量
}
}
这样泄露 sid 只能看到"这个会话存在",看不到内容。key 虽然也在 poll 的 URL 里,但它:
- 不落任何持久存储,只在客户端内存
- 与会话同生命周期,会话回收即失效
- 和
sid是两个独立的随机值,猜不到
严格说,key 一样会进宿主日志——JSONP 做不到把凭据放在 header 里。这是这条路线的固有上限,只能靠短 TTL + 单会话绑定把损失压到最小。
7.6 幂等:reqId 解决重复扣费
客户端 20 秒超时重试,如果服务端每次新建会话:
第一次请求 → 建会话 A,上游开始生成(计费)
客户端超时 → 重试
第二次请求 → 建会话 B,上游再生成一次(又计费)
结果:用户看到一份答案,你付了两份钱,A 还没人取
修法是客户端生成 reqId,服务端用 reqId 直接当 sid:
const sid = reqId;
const stub = env.SESSION.get(env.SESSION.idFromName('s:' + sid));
重试落回同一个 DO,/start 里判断 if (this.key) return { key: this.key },直接返回上次的结果。不需要额外的映射表,也不需要全局锁。
8. 稳定性:三层超时 + 看门狗
| 层 | 值 | 覆盖 |
|---|---|---|
| 连接超时 | 12 s | 建连、等响应头 |
| 首字节超时 | 15–20 s | 上游"已接收没开始生成" |
| 空闲超时 | 20 s | 流式中途卡住 |
| 总时长 | 120–180 s | 兜底 |
8.1 AbortSignal.timeout 不够用
它只管到"拿到响应头"。上游返回 200 之后挂住不吐字,reader.read() 会一直悬着:
// ✗ 只防了第一段
const r = await fetch(url, { signal: AbortSignal.timeout(12000) });
// ✓ 读流再加一道
const it = await Promise.race([
reader.read(),
new Promise((_, rej) => setTimeout(() => rej(new Error('idle')), 20000)),
]);
没有这一道,那次请求既不记成功也不记失败——监控全绿,用户永远转圈。
8.2 重试
| 状态 | 重试 | 原因 |
|---|---|---|
| 401 / 402 / 403 / 429 | ✓ 换 Key / 换出口 | 通道不可用 |
| 500 / 502 / 503 / 超时 | ✓ 退避重试 | 上游抖动 |
| 400 / 404 / 413 / 422 | ✗ | 参数错,重试一百次也一样 |
退避:min(8s, 500ms × 2^n) + 随机 0–300ms,最多 2 次。
8.3 幂等
重试前问一句:重复执行有副作用吗?
- 查询:随便重试
- 扣费 / 下单:必须带幂等键
- 生成:重试 = 多花一次钱,用第 7.6 节的
reqId,让服务端按reqId去重
8.4 三层超时要和挂起时长对齐
如果 /poll 在服务端挂 25 秒,客户端的超时必须大于它,否则每次都在服务端返回前先断:
| 客户端设置 | 服务端挂起 | 结果 |
|---|---|---|
| 15 s | 25 s | ✗ 每次都超时,永远拿不到增量 |
| 30 s | 25 s | ✓ 留 5 s 给网络抖动 |
| 30 s | 30 s | ! 边缘情况下两边同时到点,偶发空返回 |
同理,服务端的上游空闲看门狗(20 s)要小于 DO 的单次执行上限,否则会被平台直接杀掉,错误信息变成没有上下文的 "internal error"。
9. 如果你有页面域名:同源反代(更优)
宿主场景用不上,但值得知道差多少。
9.1 判定
location.origin // "https://mygame.com"
location.origin 就是唯一可用的"同源域名",不用另买。前提是这个域名的服务端归你。
curl -i https://mygame.com/api/__probe
# 返回你自己的东西 → 能挂;返回平台 404 页 → 不能
9.2 为什么同源就没问题
| 检查 | 请求 https://api.example.com |
请求 /api/... |
|---|---|---|
| 同源策略 | 跨源,需对方给 Access-Control-Allow-Origin |
同源,不触发检查 |
connect-src 'self' |
不在 'self',请求不发出 |
命中 'self',放行 |
而真正访问外部的是服务器——服务器之间没有"源"的概念,也不执行 CSP。
9.3 Nginx
server {
listen 443 ssl http2;
server_name mygame.com;
root /var/www/game;
index index.html;
location /api/ {
proxy_pass https://api.example.com/;
proxy_ssl_server_name on;
proxy_set_header Host api.example.com;
proxy_set_header Authorization "Bearer sk-你的密钥";
# 流式三件套
proxy_buffering off;
proxy_cache off;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
}
两个高频错:proxy_pass 结尾少了 /(路径不剥前缀,404)、没设 Host(上游按域名路由,回 404)。
9.4 Caddy
mygame.com {
root * /var/www/game
file_server
handle_path /api/* {
reverse_proxy https://api.example.com {
header_up Host api.example.com
header_up Authorization "Bearer sk-你的密钥"
flush_interval -1
}
}
}
9.5 两条路的真实差距
| JSONP + 轮询 | 同源反代 | |
|---|---|---|
| 首字延迟 | +200–600 ms | 几乎无 |
| 请求次数 | 1 次发起 + 6–10 次挂起轮询 | 1 次 |
| 能否流式 | ✗(只能增量,拼起来像流式) | ✓ |
| 能否 POST | ✗ | ✓ |
| URL 长度限制 | 2 KB | 无 |
| 服务端复杂度 | 会话状态 + 轮询 | 一段配置 |
| 调试 | 只能看服务端日志 | 正常看 Network |
能拿到域名就选反代,这不是风格问题,是上面七项全面占优。
10. 连 script-src 都锁了:iframe 桥
只剩 frame-src 允许你的域名时可用。
宿主页面(connect-src / script-src 都锁)
│ postMessage
▼
你的桥页面 https://bridge.mydomain.com(CSP 你自己定,宽松)
│ fetch(带 Key)
▼
外部 API
父页面:
const waits = new Map();
let seq = 0;
addEventListener('message', (e) => {
if (e.origin !== 'https://bridge.mydomain.com') return; // 必须校验
const { id, ok, data, err } = e.data || {};
const w = waits.get(id);
if (!w) return;
waits.delete(id);
ok ? w.resolve(data) : w.reject(new Error(err));
});
function viaBridge(payload) {
return new Promise((resolve, reject) => {
const id = ++seq;
waits.set(id, { resolve, reject });
frame.contentWindow.postMessage({ id, payload }, 'https://bridge.mydomain.com');
setTimeout(() => { if (waits.delete(id)) reject(new Error('timeout')); }, 30000);
});
}
桥页面:
<script>
const ALLOW = ['https://app.builda.game'];
let tk = null, tkExp = 0;
async function token() {
if (tk && Date.now() < tkExp) return tk;
const r = await fetch('/tk', { credentials: 'omit' }); // 桥自己的同源接口
const j = await r.json();
tk = j.tk; tkExp = Date.now() + 240000;
return tk;
}
addEventListener('message', async (e) => {
if (!ALLOW.includes(e.origin)) return;
const { id, payload } = e.data || {};
try {
// 关键:桥必须自己带令牌,否则它就是一个公开代理
const t = await token();
const r = await fetch('https://api.example.com/v1/chat', {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-bridge-token': t, // 由中转校验,和 JSONP 那套复用同一个 Guard
},
body: JSON.stringify(payload),
});
parent.postMessage({ id, ok: r.ok, data: await r.text() }, e.origin);
} catch (err) {
parent.postMessage({ id, ok: false, err: String(err) }, e.origin);
}
});
</script>
三个前提,少一个就是洞:
| 前提 | 不做的后果 |
|---|---|
frame-ancestors https://app.builda.game |
任何人都能嵌你的桥 → 白嫖你的 Key 的中转 |
postMessage 双向校验 origin |
任意站点给桥发消息,桥会把结果回给攻击者 |
桥自己带令牌(x-bridge-token) |
桥页面变成公开 CORS 代理,被扫到就被当免费 API 用 |
响应头要这样配(Nginx):
add_header Content-Security-Policy "frame-ancestors https://app.builda.game" always;
# 不要写 X-Frame-Options: DENY,那会把宿主也挡在外面
如果用 Cloudflare Worker 托管桥页面,直接在返回 HTML 的响应里加:
const headers = {
'content-security-policy': "frame-ancestors https://app.builda.game",
'permissions-policy': 'interest-cohort=()',
};
frame-ancestors只认完整的源(协议 + 域名 + 端口),不支持通配子域之外的模糊匹配。宿主有多个入口(比如app.和game.)就都写上,空格分隔。
缺点:多一层、延迟 +30–100 ms、调试更难、postMessage 的序列化会丢掉 ReadableStream(所以拿不到流式,只能整段返回或自己再做一层轮询)。
能 JSONP 就别用这个。
11. 成本模型:这套东西到底花多少钱
在这之前,整篇没有一处算过钱,这是最大的缺口。下面按"1 万次问答/月"估算,假设:单次生成 60 秒、输出 1200 tokens、输入 500 tokens。
11.1 请求数差了两个数量级
| 项目 | 300 ms 空轮询 | 服务端挂起 25 s |
|---|---|---|
单次问答 /poll 次数 |
~600 | 6–10 |
| 单次问答总请求 | ~602 | ~10 |
| 1 万次问答总请求 | 602 万 | 10 万 |
| 10 万次问答总请求 | 6020 万 | 100 万 |
请求数是先乘后加的关系,用户量一上来,差距会变成账单上的数字。
11.2 基础设施费用
Cloudflare Workers Paid 是 $5/月,含 1000 万请求、100 万 GB-s。超出部分(2026-11 参考价,以官网为准):
| 项 | 单价 | 1 万次问答 | 10 万次问答 |
|---|---|---|---|
| Worker 请求 | $0.30 / 百万 | 含在套餐内 | 含在套餐内 |
| DO 请求 | $0.15 / 百万 | $0.02 | $0.15 |
| DO duration(128 MB × 60 s × 次数) | $12.50 / 百万 GB-s | $0.96 | $9.60 |
| KV 写(若仍用它发令牌) | 免费版 1000/天 | 超限,$5 起 | 严重超限 |
| 小计 | ≈ $6 | ≈ $15 |
对比旧方案的 600 次轮询:
| 项 | 1 万次问答 |
|---|---|
| Worker 请求 602 万 | 超出 1000 万免费额度前不额外收,但已吃掉 60% |
| DO 请求 600 万 | $0.90 |
| DO duration(每次空轮询 20 ms) | $0.15 |
看起来旧方案的基础设施更便宜,因为空轮询的 duration 短。但别忘了两件事:
- 600 万次请求会把套餐额度吃光,第 2 万次问答开始就要另外买
- 客户端 600 次轮询意味着 600 次唤醒、600 次日志、600 次潜在超时——用户侧的电量和流量也是成本
11.3 真正的大头是上游 token
| 项 | 单价(示例) | 1 万次问答 |
|---|---|---|
| 输入 500 tokens | ¥1 / 百万 | ¥5 |
| 输出 1200 tokens | ¥2 / 百万 | ¥24 |
| 思考模式翻倍 | — | ¥48–58 |
也就是说:基础设施 ≈ ¥45/月,上游 ≈ ¥30–60/月,后者随时可能反超。如果开了思考模式,或者上下文没裁剪,token 费用会先爆。
11.4 四个成本开关
| 开关 | 效果 |
|---|---|
| 上下文裁剪 | 只带最近 6–10 轮,超过就摘要。这是最大的一项 |
max_tokens 钳位 |
客户端传多大都不认,服务端封顶 |
| 回答缓存 | 相同问题(归一化后 hash)1 小时内直接命中,省一次上游调用 |
| 限流 | 每人 20 次/分钟,挡住刷子;没有它,前面三条都白设 |
11.5 什么时候该放弃技术方案
如果算下来满足下面任意两条,别自建:
- 月活 < 500,上游费用 < ¥50
- 你没有维护 DO / 证书 / 域名 / 日志的精力
- 平台有官方 API,只是要申请
- 你的场景不需要"实时逐字"
自建中转的价值在于控制权和体验(自己的限流、自己的模型、自己的计费),不在于省钱。为了省 ¥50 去维护一套多活中转,是负收益。
12. 把域名加进 script-src 意味着什么
这一节比 callback XSS 严重得多,但原文一句没提。
12.1 你交出去的不是一个接口,是一段可执行代码
宿主加上 script-src https://relay.mydomain.com 之后,语义是:
relay.mydomain.com上的任意 JS,都可以在宿主 App 里以宿主的权限执行。
不是"这个路径的 JS",不是"这次请求的 JS",是整个域名。宿主如果是带登录态的游戏平台,那段 JS 能读到的东西比你想象的多。
12.2 攻击面从哪来
| 路径 | 怎么发生 |
|---|---|
| 子域接管 | relay.mydomain.com 的 CNAME 指向已释放的第三方服务,别人注册走 |
| CDN / 存储配置错 | 桶权限写成 public-write,任何人能传 JS 上去 |
| 构建链投毒 | npm 依赖被投毒,产物里带后门,你完全不知情 |
| 过期文件 | 忘了删的 test.html、debug.js 留在同域 |
| 证书 / DNS 劫持 | 域名管理账号被盗 |
任何一条成立,攻击者的代码就以你的名义进了宿主。
12.3 降低影响的做法
- 专用子域,只放一个文件。
relay.专门做接口,js.专门放那一个 bundle,别混用 - 那个子域不放任何后台、上传、管理页。它只输出静态 JS,其他路径一律 404
- 不要用免费的公共 CDN 托管这个文件,别人能往同一个域写东西
- CSP 能精确到路径就精确到路径:
script-src https://js.mydomain.com/app.v3.js - DNS 加 CAA 记录、开注册锁、开二次验证
- 监控:证书透明度日志、DNS 变更告警、
/路径的异常请求
12.4 数据合规,别等出事才想
请求经过你的 Worker,意味着:
用户的提问 ──▶ 宿主页面 ──▶ 你的 Worker ──▶ 上游模型
│
└── 落在 Cloudflare 边缘日志 / 你的日志
| 要做的 | 说明 |
|---|---|
| 告知 | 隐私政策里写清楚"会经过第三方中转",别藏在第 30 条 |
| 最小化 | 日志只留 sid、耗时、状态码,不要留正文 |
| 留存期限 | 会话 10 分钟回收,日志 7 天滚动删除,写进文档 |
| 跨境 | 用户在中国、Worker 在新加坡/美国,属于数据出境,需要单独评估 |
| 未成年人 | 游戏用户里比例不低,涉及未成年人信息要更严 |
如果宿主本身有合规要求(应用商店、版号、未成年人保护),先问宿主能不能加白名单——这也回到了第 0 节的第一步:能用平台官方通道,就别自己当中转。
13. 排错对照表
| 报错 | 根因 | 处理 |
|---|---|---|
blocked by CORS policy |
无 Access-Control-Allow-Origin |
同源反代(有域名时)或 Worker 加头 |
violates "connect-src 'self'" |
CSP 禁外连 | JSONP(第 3 节) |
Refused to load the script ... "script-src" |
script 也锁 | 同源反代 / iframe 桥 / 找宿主 |
Refused to display ... in a frame |
X-Frame-Options |
桥页面去掉该头 |
net::ERR_NAME_NOT_RESOLVED |
DNS | 换域名、确认解析 |
net::ERR_CERT_COMMON_NAME_INVALID |
证书不匹配 | 用正规证书 |
| Mixed Content | HTTPS 页面调 HTTP | 全站 HTTPS |
HTTP 429,换 Key 无效 |
出口 IP 被限 | 二级出口(第 6 节) |
| 流式"憋一大段" | 反代开了缓冲 | proxy_buffering off / flush_interval -1 |
| 流式不结束 | 无空闲超时 | 20 s 看门狗 |
JSONP Unexpected token |
没包 callback | 响应必须是 cb({...}) |
| JSONP 拿到旧数据 | GET 被缓存 | Cache-Control: no-store |
| JSONP 回调不执行,也无报错 | content-type 不对 |
application/javascript + nosniff |
| 轮询一直空 | 会话存 KV,一致性延迟 | 换 Durable Object |
长回答被腰斩、永远没有 done |
生成跑在 waitUntil,约 30 s 被掐 |
挪进 DO 的 alarm(第 3.3 节) |
| 限流像没生效 | Map 在每个 isolate 各一份 |
挪进 Guard DO(第 7.3 节) |
| 同一个令牌能用两次 | KV get → delete 有竞态 |
DO 内先删后判(第 7.2 节) |
| 增量乱码、重复、丢字 | 客户端用 d.length 当游标 |
用服务端返回的 next(第 5.1 节) |
| 重试后扣了两次费 | 每次重试都新建会话 | 客户端 reqId 即 sid(第 7.6 节) |
拿到 sid 就能拉全部对话 |
/poll 没有第二因子 |
sid + key 双因子(第 7.5 节) |
| 一次问答几百个请求 | 客户端 300 ms 轮询 | 服务端挂起 25 s(第 5.1 节) |
| 切后台五分钟回来就超时 | deadline 用 wall-clock | 扣除 hiddenMs(第 5.3 节) |
JSONP 报 Unexpected token '<' |
上游错误页被拼进 cb(...) |
先 JSON.parse 再 stringify |
| 请求体 413 | 超过限制 | 调 client_max_body_size 或分片 |
14. 上线清单
通道
- [ ]
location.origin确认页面源归属,选对了路 - [ ]
connect-src/script-src都抄下来核对过 - [ ] JSONP 接口用 curl 能返回
cb({...}) - [ ] 宿主真机上跑通一次完整问答(不是只在本机浏览器)
- [ ] 出口 IP 压测过,没触发 429
安全
- [ ] callback 名做了白名单校验
- [ ] 中转有 token / 一次性令牌,不是裸 GET
- [ ]
Referer白名单 + 一次性令牌双保险 - [ ] 参数白名单:
q长度、模型名、max_tokens都有上限 - [ ] Key 只在服务端,
grep -r "sk-"无命中 - [ ] 日志不打印完整 Key(只留前 6 位)
稳定
- [ ] 三层超时都配了,尤其是空闲看门狗
- [ ] 客户端
poll超时 > 服务端挂起时长(第 8.4 节) - [ ] 生成跑在 DO 的
alarm,不是waitUntil - [ ] 401/429/5xx 会换通道,400/422 不重试
- [ ] 有
/health和一次真实调用的冒烟测试
状态与幂等
- [ ] 令牌、限流、会话全在 DO,没有模块级
Map - [ ]
/poll校验sid + key,单靠sid拉不走内容 - [ ] 客户端带
reqId,重试不重复扣费 - [ ] 游标用服务端
next,客户端不自己算长度 - [ ] DO 有
deleteAll回收,会话不会常驻 - [ ] 上游错误页不会拼进
cb(...)
成本与合规
- [ ] 算过每月请求数、DO duration、上游 token(第 11 节)
- [ ] 上下文有裁剪,
max_tokens服务端钳位 - [ ] 日志不留正文,留存期限写进文档
- [ ] 隐私政策写明"经过第三方中转"
- [ ] 如果用了
script-src白名单:专用子域、只放一个文件、无上传无后台
15. 一页速查
# 我在哪个源
location.origin
# 宿主到底锁了什么
curl -sI https://app.builda.game | grep -i content-security-policy
# 接应点存活
curl -s "https://relay.mydomain.com/tk?cb=t" # t({"ok":true,"tk":"..."})
curl -s "https://relay.mydomain.com/q?tk=<tk>&reqId=<32hex>&q=hi&cb=t"
# t({"ok":true,"sid":"...","key":"..."})
curl -s "https://relay.mydomain.com/poll?sid=<sid>&key=<key>&rev=0&rrev=0&cb=t"
# t({"d":"...","next":7,"done":0})
能不能加白名单 / 用官方 API? → 能就到此为止
不能
├ 页面源归你 → 同源反代(最优)
└ 页面源不归你
├ script-src 放行 → JSONP + 挂起式长轮询
├ 只放 frame-src → iframe 桥(要 frame-ancestors + 令牌)
└ 都锁 → 没有技术手段
出口 IP 被限 → 加一跳 VPS / 云函数
关键数字:
| 项 | 值 |
|---|---|
| 连接超时 | 12 s |
| 空闲超时 | 20 s |
| 客户端 poll 超时 | 30 s(> 服务端挂起 25 s) |
| 服务端挂起 | 25 s |
| 单次问答请求数 | 6–10(不再是 600) |
| 轮询总时长 | 180 s,只算前台时间 |
| URL 预算 | 2000 字节(不是字符) |
| 请求体上限 | 64 KB |
| 一次性令牌 TTL | 300 s |
| 会话回收 | 10 分钟 |