Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/amsg-server-worker-error-cors.md
Original file line number Diff line number Diff line change
@@ -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-*`。
Original file line number Diff line number Diff line change
Expand Up @@ -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` /
Expand Down Expand Up @@ -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);
Expand All @@ -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
Expand Down Expand Up @@ -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);
}
}

Expand Down
64 changes: 64 additions & 0 deletions packages/rei-standard-amsg/server/test/single-user-worker.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading