外部 API 接入:页面跑在别人 App 里,怎么把请求送出去外部 API 接入指南
下载 .md PDF 打印 / PDF

外部 API 接入:页面跑在别人 App 里,怎么把请求送出去

外部 API 接入 · 2026-11-08

默认场景: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 节
什么都不放行 放弃联网功能,或换平台

把这一步放在最前面,原因有三个:

  1. 数据责任:自建中转意味着用户对话经过你的服务器,你就多了一份数据合规义务(第 12.4 节)
  2. 条款风险:平台明确禁止外连时,绕过去违反的是平台条款,封号成本由你承担
  3. 维护成本:白名单只是平台改一个字符串,而你要维护 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 体感优化

长轮询天生比流式"顿"。三个办法:

  1. 首包优先:收到上游第一个 token 就把增量返回,别攒够一句
  2. 前端补间:拿到一段增量后按 30 ms/字 逐字渲染,视觉上和流式几乎无差别
  3. 挂起时长压到 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 短。但别忘了两件事:

  1. 600 万次请求会把套餐额度吃光,第 2 万次问答开始就要另外买
  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 降低影响的做法

  1. 专用子域,只放一个文件。relay. 专门做接口,js. 专门放那一个 bundle,别混用
  2. 那个子域不放任何后台、上传、管理页。它只输出静态 JS,其他路径一律 404
  3. 不要用免费的公共 CDN 托管这个文件,别人能往同一个域写东西
  4. CSP 能精确到路径就精确到路径:script-src https://js.mydomain.com/app.v3.js
  5. DNS 加 CAA 记录、开注册锁、开二次验证
  6. 监控:证书透明度日志、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 分钟