From 532e5a526887896727e3fba438a2367e444871f3 Mon Sep 17 00:00:00 2001 From: Tosd0 <65720409+Tosd0@users.noreply.github.com> Date: Mon, 3 Aug 2026 13:14:52 +0800 Subject: [PATCH] =?UTF-8?q?fix(amsg-server):=20=E5=8D=95=E7=94=A8=E6=88=B7?= =?UTF-8?q?=20Worker=20=E7=9A=84=E9=94=99=E8=AF=AF=E5=93=8D=E5=BA=94?= =?UTF-8?q?=E8=A1=A5=E4=B8=8A=20CORS=20=E5=A4=B4=EF=BC=8C=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E6=9E=84=E5=BB=BA=E5=A4=B1=E8=B4=A5=E4=B9=9F=E8=83=BD?= =?UTF-8?q?=E8=A2=AB=E5=89=8D=E7=AB=AF=E8=AF=BB=E5=88=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 跨域部署里,Worker 抛异常回的 500 之前不带 Access-Control-*,浏览器整条丢掉、 fetch reject 成网络错误,服务端故障和「Worker 连不上」在前端完全无法区分。 - 异常 500 带上与正常响应同一份 CORS 头 - buildConfig 自身抛错时单独兜底:预检回 204、真实请求回能读的 500;该降级路径 的 CORS 头回显来访 Origin 而非 '*',同源调用仍不加头,Max-Age 0 不进缓存 - 没配 cors 的部署行为不变(OPTIONS 仍走 404,响应仍不带 Access-Control-*) 回归测试钉住两条:处理器抛异常的 500 带头;配置构建失败时预检为 2xx 且 500 带头、 同源不加头。 Claude-Session: https://claude.ai/code/session_01GEKR7ivPYPq59A1cJVs14u --- .changeset/amsg-server-worker-error-cors.md | 18 ++++++ .../server/cloudflare/single-user-worker.js | 62 ++++++++++++++++-- .../server/test/single-user-worker.test.mjs | 64 +++++++++++++++++++ 3 files changed, 137 insertions(+), 7 deletions(-) create mode 100644 .changeset/amsg-server-worker-error-cors.md diff --git a/.changeset/amsg-server-worker-error-cors.md b/.changeset/amsg-server-worker-error-cors.md new file mode 100644 index 0000000..dfa37e2 --- /dev/null +++ b/.changeset/amsg-server-worker-error-cors.md @@ -0,0 +1,18 @@ +--- +"@rei-standard/amsg-server": patch +--- + +单用户 Worker 的错误响应补上 CORS 头,服务端异常不再伪装成网络故障 + +配了 `cors` 的部署里,之前只有正常响应带 `Access-Control-*`,Worker 内部抛异常时回的那条 500 是裸的。跨域前端拿到没有 `Access-Control-Allow-Origin` 的响应,浏览器会把整条丢掉,`fetch` 直接 reject 成 TypeError(Safari 显示 `Load failed`、Chrome 显示 `Failed to fetch`)。结果是**服务端故障在前端长得和「Worker 连不上」一模一样**:错误码、错误信息、HTTP 状态一概读不到,只剩一条「网络失败」。真实排查里,从外部探测该 Worker 一切正常(预检 204、401 都带 CORS 头),因为只有过了鉴权的请求才会走到抛异常那段。 + +现在异常 500 带上和正常响应同一份 CORS 头,前端能照常读到 `{ success: false, error: { code: 'INTERNAL_ERROR' } }`,剩下的真因去 `wrangler tail` 里看 `[amsg single-user] fetch() unhandled error:` 那行。 + +**配置构建失败也不再静默**。`buildConfig` 自己抛错时(少绑一个 binding、环境变量被重新部署刷掉),连 CORS 策略都无从得知,之前预检和真实请求会一起拿到裸 500——预检不是 2xx,浏览器根本不会发真正那条请求,整个部署在前端看来就是彻底离线且零报错。现在这条降级路径: + +- 预检回 204,真实请求回一条能读的 500; +- CORS 头**回显来访的 `Origin`,绝不退化成 `*`**。这条路径的响应体是固定的错误信封,没有数据也不带 credentials,且只在配置炸了的时候生效——配置一旦能解析,所有响应重新由 `cfg.cors` 管辖,没配 CORS 的部署不会因为一次故障变成开放的; +- 同源调用(请求没有 `Origin` 头)依然一个头都不加,与其余路径一致; +- `Access-Control-Max-Age: 0`,故障期间答复的预检不进浏览器缓存,配置修好即刻失效。 + +没配 `cors` 的部署(默认同源)行为不变:OPTIONS 仍然走 404,响应仍然不带任何 `Access-Control-*`。 diff --git a/packages/rei-standard-amsg/server/src/server/cloudflare/single-user-worker.js b/packages/rei-standard-amsg/server/src/server/cloudflare/single-user-worker.js index 2e6e38d..465bf94 100644 --- a/packages/rei-standard-amsg/server/src/server/cloudflare/single-user-worker.js +++ b/packages/rei-standard-amsg/server/src/server/cloudflare/single-user-worker.js @@ -26,6 +26,10 @@ * CORS is opt-in: pass `cors: { origin }` in the config (a fixed origin, '*', or * an (origin) => allowedOrigin function) to answer OPTIONS preflights and echo * Access-Control-* on responses. With no `cors` the Worker stays same-origin. + * Error responses carry the same headers as the success path — a header-less + * 500 is invisible to a cross-origin caller (the browser drops it and `fetch` + * rejects as a network error), so a server-side exception would otherwise be + * indistinguishable from the worker being unreachable. * * Fire-time hooks are opt-in too: pass `hooks: { onBeforeFire, onLLMOutput, * executeToolCalls }` (+ optional `maxToolIterations` / `totalTimeoutMs` / @@ -92,6 +96,31 @@ function corsHeadersFor(cors, requestOrigin) { return headers; } +/** + * CORS headers for the degraded path: buildConfig itself threw, so the + * deployment's configured policy is unknowable — yet the error response still + * needs headers, or a cross-origin caller cannot see it at all (the browser + * drops a header-less response and `fetch` rejects as a network error, which + * reads exactly like the worker being down). + * + * The fallback echoes the caller's Origin — never '*' — and is used ONLY on this + * path. What it exposes is a fixed error envelope with no data and no + * credentials, so a stranger's page learns nothing beyond "this worker is + * failing"; the moment the config builds again, every response goes back to + * cfg.cors, so a CORS-less (same-origin) deployment does not become an open one. + * maxAge 0 keeps a preflight answered during the outage out of the browser cache. + * + * @param {string} requestOrigin - the request's Origin header ('' → null, i.e. + * a same-origin caller needs no headers, same as everywhere else) + */ +function degradedCorsHeaders(requestOrigin) { + return corsHeadersFor({ origin: requestOrigin, maxAge: 0 }, requestOrigin); +} + +function internalErrorResponse(cors) { + return jsonResponse(500, { success: false, error: { code: 'INTERNAL_ERROR', message: '服务器内部错误' } }, cors); +} + export function createSingleUserCloudflareWorker(buildConfig) { async function resolveConfig(env) { const cfg = await buildConfig(env); @@ -100,14 +129,33 @@ export function createSingleUserCloudflareWorker(buildConfig) { } async function fetch(request, env /* , ctx */) { - // Error boundary: a handler (or config build) may throw — e.g. - // schedule-message re-throws a non-unique DB error. Keep the client-facing - // contract consistent (a JSON envelope, not the runtime's HTML error page). + const requestOrigin = request.headers.get('origin') || ''; + const method = request.method.toUpperCase(); + + // The config is built on its own so that a build failure (a missing binding, + // a lost env var) still answers with CORS headers — otherwise every request, + // preflights included, comes back header-less and the whole deployment looks + // offline to the frontend instead of broken. + let cfg; try { - const cfg = await resolveConfig(env); - const cors = corsHeadersFor(cfg.cors, request.headers.get('origin') || ''); - const method = request.method.toUpperCase(); + cfg = await resolveConfig(env); + } catch (error) { + console.error('[amsg single-user] fetch() config build failed:', error && error.message); + const degraded = degradedCorsHeaders(requestOrigin); + // A preflight must be 2xx or the browser never sends the real request — + // and then the readable 500 below never reaches the caller. + if (method === 'OPTIONS' && degraded) return new Response(null, { status: 204, headers: degraded }); + return internalErrorResponse(degraded); + } + + const cors = corsHeadersFor(cfg.cors, requestOrigin); + // Error boundary: a handler may throw — e.g. schedule-message re-throws a + // non-unique DB error. Keep the client-facing contract consistent (a JSON + // envelope, not the runtime's HTML error page) and carry the same CORS + // headers as the success path, so the caller reads the error instead of a + // browser-level network failure. + try { // CORS preflight: answer OPTIONS directly when CORS is configured. if (method === 'OPTIONS') { return cors @@ -163,7 +211,7 @@ export function createSingleUserCloudflareWorker(buildConfig) { return jsonResponse(result.status, result.body, cors); } catch (error) { console.error('[amsg single-user] fetch() unhandled error:', error && error.message); - return jsonResponse(500, { success: false, error: { code: 'INTERNAL_ERROR', message: '服务器内部错误' } }); + return internalErrorResponse(cors); } } diff --git a/packages/rei-standard-amsg/server/test/single-user-worker.test.mjs b/packages/rei-standard-amsg/server/test/single-user-worker.test.mjs index 0011b70..cfe3cf9 100644 --- a/packages/rei-standard-amsg/server/test/single-user-worker.test.mjs +++ b/packages/rei-standard-amsg/server/test/single-user-worker.test.mjs @@ -176,6 +176,70 @@ test('CORS opt-in: OPTIONS preflight answered, real response echoes the allowed assert.equal(listed.headers.get('Vary'), 'Origin'); }); +// 回归守卫:抛异常那条 500 也必须带 CORS 头。少了 Access-Control-Allow-Origin, +// 浏览器会把整条响应吞掉、前端的 fetch 直接 reject 成网络错误(Safari 显示 +// "Load failed")—— 服务端异常在前端长得和断网一模一样,真因谁也查不出来。 +test('CORS: a throwing handler still returns a 500 the browser can read', async () => { + const worker = createSingleUserCloudflareWorker(() => ({ + db: { async listTasks() { throw new Error('db boom'); } }, + masterKey: MASTER_KEY, + vapid: { email: 'mailto:x@example.com', publicKey: 'pub', privateKey: 'priv' }, + webpush: { async sendNotification() {} }, + cors: { origin: 'https://app.example.com' } + })); + const origErr = console.error; + console.error = () => {}; + let res; + try { + res = await worker.fetch(new Request('https://w.dev/messages?status=all', { + method: 'GET', headers: { 'X-User-Id': USER, Origin: 'https://app.example.com' } + }), {}); + } finally { + console.error = origErr; + } + assert.equal(res.status, 500); + assert.equal((await res.json()).error.code, 'INTERNAL_ERROR'); + assert.equal(res.headers.get('Access-Control-Allow-Origin'), 'https://app.example.com'); +}); + +// 同一个洞的配置级版本:buildConfig 自己炸了(少个 binding、环境变量丢了)时 +// 连预检都会掉进错误分支。预检不是 2xx 浏览器就不会发真正那条请求,前端拿到的 +// 还是「网络失败」,整个站挂掉且零报错信息。 +test('CORS: a config build failure answers the preflight and returns a readable 500', async () => { + const worker = createSingleUserCloudflareWorker(() => { throw new Error('config boom'); }); + const origErr = console.error; + console.error = () => {}; + let preflight, res, sameOrigin; + try { + preflight = await worker.fetch( + new Request('https://w.dev/messages', { method: 'OPTIONS', headers: { Origin: 'https://app.example.com' } }), + {} + ); + res = await worker.fetch( + new Request('https://w.dev/messages?status=all', { + method: 'GET', headers: { 'X-User-Id': USER, Origin: 'https://app.example.com' } + }), + {} + ); + sameOrigin = await worker.fetch(new Request('https://w.dev/messages?status=all', { method: 'GET' }), {}); + } finally { + console.error = origErr; + } + + assert.ok(preflight.status >= 200 && preflight.status < 300, '预检必须是 2xx,否则真正那条请求根本发不出去'); + assert.equal(preflight.headers.get('Access-Control-Allow-Origin'), 'https://app.example.com'); + assert.match(preflight.headers.get('Access-Control-Allow-Headers'), /X-User-Id/); + assert.match(preflight.headers.get('Access-Control-Allow-Methods'), /GET/); + + assert.equal(res.status, 500); + assert.equal((await res.json()).error.code, 'INTERNAL_ERROR'); + assert.equal(res.headers.get('Access-Control-Allow-Origin'), 'https://app.example.com'); + + // 兜底只回显来访的 Origin,不退化成 '*':没配 CORS 的部署不该因为配置炸了就变成全开。 + assert.equal(sameOrigin.status, 500); + assert.equal(sameOrigin.headers.get('Access-Control-Allow-Origin'), null); +}); + // Regression guard (design spec §7): with serverToken set, EVERY exposed HTTP // endpoint must require X-Client-Token. Today all handlers funnel through the // same resolveTenant, but this pins it down so a future handler that forgets