Skip to content

Commit db088d2

Browse files
Merge pull request #1175 from objectstack-ai/copilot/fix-cors-header-issue
fix(server): preserve CORS headers on Vercel preflight and bootstrap-failure paths
2 parents 89f796b + 4f4a659 commit db088d2

File tree

2 files changed

+146
-7
lines changed

2 files changed

+146
-7
lines changed

apps/server/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@
44

55
### Patch Changes
66

7+
- **Fixed missing `Access-Control-Allow-Origin` header on Vercel deployment.** Two code paths in `server/index.ts` previously returned raw `Response` objects that bypassed the Hono app (and therefore the CORS middleware registered inside `createHonoApp`):
8+
- `OPTIONS` preflight requests went through full kernel bootstrap, which is slow and can fail on cold start — causing the browser to see a preflight with no CORS headers and block every subsequent `/api/v1/*` GET.
9+
- Bootstrap-failure `503` responses had no CORS headers at all, surfacing in the browser as a generic "missing Access-Control-Allow-Origin" error instead of the real status code.
10+
- Fix: `OPTIONS` is now short-circuited **before** `ensureApp()` with a proper CORS preflight response; the `503` bootstrap-failure response is now wrapped with the same CORS headers via `withCorsHeaders()`. Both helpers honour the same `CORS_ENABLED` / `CORS_ORIGIN` / `CORS_CREDENTIALS` / `CORS_MAX_AGE` env vars as the Hono adapter, so behaviour is identical whether or not the kernel has finished booting.
711
- **Unified Studio mount path to `/_studio/` for all deployments** (CLI embedded, Vercel, self-host).
812
- `vercel.json`: studio SPA now serves under `/_studio/:path*` with a dedicated rewrite to `/_studio/index.html`. Root `/` and bare `/_studio` redirect to `/_studio/`. Asset caching headers scoped to `/_studio/assets/*`. `VITE_BASE=/_studio/` is set in `build.env`.
913
- `scripts/build-vercel.sh`: studio dist is copied to `public/_studio/` (previously `public/`), so Vercel serves it under the same sub-path the CLI uses. This resolves the deep-link / sidebar-click routing failures that occurred when the Studio was mounted at the public root.

apps/server/server/index.ts

Lines changed: 142 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,128 @@ async function ensureApp(): Promise<Hono> {
7272
return _app;
7373
}
7474

75+
// ---------------------------------------------------------------------------
76+
// CORS headers — applied to responses that bypass the Hono app
77+
// (bootstrap failures, preflight short-circuit). Mirrors the defaults of
78+
// `createHonoApp()` so behaviour is identical whether or not the kernel
79+
// has finished booting. See packages/adapters/hono/src/index.ts.
80+
//
81+
// Controlled by the same environment variables as the Hono adapter:
82+
// CORS_ENABLED, CORS_ORIGIN, CORS_CREDENTIALS, CORS_MAX_AGE.
83+
// ---------------------------------------------------------------------------
84+
85+
const CORS_ALLOW_METHODS = 'GET,POST,PUT,DELETE,PATCH,HEAD,OPTIONS';
86+
const CORS_ALLOW_HEADERS = 'Content-Type,Authorization,X-Requested-With';
87+
88+
function corsEnabled(): boolean {
89+
return process.env.CORS_ENABLED !== 'false';
90+
}
91+
92+
function corsCredentials(): boolean {
93+
return process.env.CORS_CREDENTIALS !== 'false';
94+
}
95+
96+
function corsMaxAge(): number {
97+
return process.env.CORS_MAX_AGE ? parseInt(process.env.CORS_MAX_AGE, 10) : 86400;
98+
}
99+
100+
/**
101+
* Resolve the `Access-Control-Allow-Origin` value for a given request.
102+
*
103+
* - If `CORS_ORIGIN` is unset, reflects the request `Origin` (or `*` when
104+
* credentials are disabled and no `Origin` is sent).
105+
* - If `CORS_ORIGIN` is a comma-separated list, matches against it.
106+
* - Returns `null` if the origin is disallowed.
107+
*/
108+
function resolveAllowOrigin(requestOrigin: string | null): string | null {
109+
const credentials = corsCredentials();
110+
const envOrigin = process.env.CORS_ORIGIN?.trim();
111+
112+
if (!envOrigin) {
113+
// Default: reflect origin (credentials-safe). Fall back to '*' only
114+
// when no Origin header is sent and credentials are disabled.
115+
if (requestOrigin) return requestOrigin;
116+
return credentials ? null : '*';
117+
}
118+
119+
if (envOrigin === '*') {
120+
if (credentials) return requestOrigin || null;
121+
return '*';
122+
}
123+
124+
const allowed = envOrigin.includes(',')
125+
? envOrigin.split(',').map((s: string) => s.trim()).filter(Boolean)
126+
: [envOrigin];
127+
128+
if (requestOrigin && allowed.includes(requestOrigin)) return requestOrigin;
129+
// Exact match with the single configured origin is allowed as a safe default
130+
if (allowed.length === 1 && !requestOrigin) return allowed[0];
131+
return null;
132+
}
133+
134+
/**
135+
* Apply CORS headers to a Response that was produced outside of the Hono app
136+
* (e.g., bootstrap-failure 503). Headers are added to a cloned Response so
137+
* the original is never mutated. Non-browser requests (no `Origin`) are
138+
* passed through untouched.
139+
*/
140+
function withCorsHeaders(response: Response, request: Request): Response {
141+
if (!corsEnabled()) return response;
142+
143+
const requestOrigin = request.headers.get('origin');
144+
const allowOrigin = resolveAllowOrigin(requestOrigin);
145+
if (!allowOrigin) return response;
146+
147+
// Clone so we can mutate headers — Response headers may be locked.
148+
const headers = new Headers(response.headers);
149+
headers.set('Access-Control-Allow-Origin', allowOrigin);
150+
if (corsCredentials()) {
151+
headers.set('Access-Control-Allow-Credentials', 'true');
152+
}
153+
// Vary on Origin whenever we reflect it, per CORS spec recommendation.
154+
const existingVary = headers.get('Vary');
155+
if (!existingVary) {
156+
headers.set('Vary', 'Origin');
157+
} else if (!/(^|,\s*)Origin(\s*,|$)/i.test(existingVary)) {
158+
headers.set('Vary', `${existingVary}, Origin`);
159+
}
160+
161+
return new Response(response.body, {
162+
status: response.status,
163+
statusText: response.statusText,
164+
headers,
165+
});
166+
}
167+
168+
/**
169+
* Build a CORS preflight (OPTIONS) response without requiring the kernel to
170+
* be booted. Browsers block the subsequent simple request if preflight fails
171+
* for any reason, so this path must never depend on bootstrap success.
172+
*/
173+
function buildPreflightResponse(request: Request): Response {
174+
const requestOrigin = request.headers.get('origin');
175+
const allowOrigin = resolveAllowOrigin(requestOrigin);
176+
177+
// No Origin header or disallowed origin → 204 without CORS headers
178+
// (matches Hono's cors() behaviour for non-browser/disallowed requests).
179+
if (!allowOrigin) {
180+
return new Response(null, { status: 204 });
181+
}
182+
183+
const requestedHeaders = request.headers.get('access-control-request-headers');
184+
const headers = new Headers({
185+
'Access-Control-Allow-Origin': allowOrigin,
186+
'Access-Control-Allow-Methods': CORS_ALLOW_METHODS,
187+
'Access-Control-Allow-Headers': requestedHeaders || CORS_ALLOW_HEADERS,
188+
'Access-Control-Max-Age': String(corsMaxAge()),
189+
Vary: 'Origin, Access-Control-Request-Headers',
190+
});
191+
if (corsCredentials()) {
192+
headers.set('Access-Control-Allow-Credentials', 'true');
193+
}
194+
return new Response(null, { status: 204, headers });
195+
}
196+
75197
// ---------------------------------------------------------------------------
76198
// Body extraction — reads Vercel's pre-buffered request body.
77199
// ---------------------------------------------------------------------------
@@ -125,13 +247,27 @@ function resolvePublicUrl(
125247
// ---------------------------------------------------------------------------
126248

127249
export default getRequestListener(async (request, env) => {
250+
const method = request.method.toUpperCase();
251+
const incoming = (env as VercelEnv)?.incoming;
252+
const url = resolvePublicUrl(request.url, incoming);
253+
254+
// ─── CORS Preflight short-circuit ──────────────────────────────────────
255+
// OPTIONS requests must never depend on kernel bootstrap. If we let them
256+
// fall through to ensureApp() a slow/failed cold start would cause the
257+
// browser to see a missing Access-Control-Allow-Origin on the preflight,
258+
// which then blocks every subsequent `/api/v1/*` request.
259+
if (method === 'OPTIONS') {
260+
console.log(`[Vercel] OPTIONS ${url} (preflight short-circuit)`);
261+
return buildPreflightResponse(request);
262+
}
263+
128264
let app: Hono;
129265
try {
130266
app = await ensureApp();
131267
} catch (err: unknown) {
132268
const message = err instanceof Error ? err.message : String(err);
133269
console.error('[Vercel] Handler error — bootstrap did not complete:', message);
134-
return new Response(
270+
const errorResponse = new Response(
135271
JSON.stringify({
136272
success: false,
137273
error: {
@@ -141,16 +277,15 @@ export default getRequestListener(async (request, env) => {
141277
}),
142278
{ status: 503, headers: { 'content-type': 'application/json' } },
143279
);
280+
// Ensure CORS headers are present even on bootstrap failure so that
281+
// browsers surface the real status code instead of a generic CORS
282+
// error. Without this the frontend sees "missing Access-Control-Allow-Origin".
283+
return withCorsHeaders(errorResponse, request);
144284
}
145285

146-
const method = request.method.toUpperCase();
147-
const incoming = (env as VercelEnv)?.incoming;
148-
149-
const url = resolvePublicUrl(request.url, incoming);
150-
151286
console.log(`[Vercel] ${method} ${url}`);
152287

153-
if (method !== 'GET' && method !== 'HEAD' && method !== 'OPTIONS' && incoming) {
288+
if (method !== 'GET' && method !== 'HEAD' && incoming) {
154289
const contentType = incoming.headers?.['content-type'];
155290
const contentTypeStr = Array.isArray(contentType) ? contentType[0] : contentType;
156291
const body = extractBody(incoming, method, contentTypeStr);

0 commit comments

Comments
 (0)