Skip to content

Commit 2296e19

Browse files
authored
Merge pull request #1066 from objectstack-ai/copilot/fix-post-api-timeout
2 parents 12bade7 + a3f9662 commit 2296e19

File tree

3 files changed

+365
-71
lines changed

3 files changed

+365
-71
lines changed

apps/studio/CHANGELOG.md

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

55
### Patch Changes
66

7+
- **Vercel deployment: Fix POST/PUT/PATCH API requests timing out**
8+
9+
Replaced the `handle()` + outer Hono app delegation pattern with
10+
`getRequestListener()` from `@hono/node-server`, matching the proven
11+
pattern from the hotcrm reference deployment.
12+
13+
The previous approach used `handle()` from `@hono/node-server/vercel`
14+
wrapped in an outer Hono app that delegated to the inner ObjectStack
15+
app via `inner.fetch(c.req.raw)`. On Vercel, the `IncomingMessage`
16+
stream is already drained by the time the inner app's route handler
17+
calls `.json()`, causing POST/PUT/PATCH requests to hang indefinitely.
18+
19+
The new approach uses `getRequestListener()` directly, which exposes
20+
the raw `IncomingMessage` via `env.incoming`. For POST/PUT/PATCH
21+
requests, the body is extracted from Vercel's pre-buffered `rawBody` /
22+
`body` properties and a fresh standard `Request` is constructed for
23+
the inner Hono app. This also adds `x-forwarded-proto` URL correction
24+
for proper HTTPS detection behind Vercel's reverse proxy.
25+
726
- Remove `functions` block from `vercel.json` to fix deployment error:
827
"The pattern 'api/index.js' defined in `functions` doesn't match any
928
Serverless Functions inside the `api` directory."

apps/studio/server/index.ts

Lines changed: 122 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,16 @@
66
* Boots the ObjectStack kernel lazily on the first request and delegates
77
* all /api/* traffic to the ObjectStack Hono adapter.
88
*
9-
* IMPORTANT: Vercel's Node.js runtime calls serverless functions with the
10-
* legacy `(IncomingMessage, ServerResponse)` signature — NOT the Web standard
11-
* `(Request) → Response` format.
9+
* Uses `getRequestListener()` from `@hono/node-server` together with an
10+
* `extractBody()` helper to handle Vercel's pre-buffered request body.
11+
* Vercel's Node.js runtime attaches the full body to `req.rawBody` /
12+
* `req.body` before the handler is called, so the original stream is
13+
* already drained when the handler receives the request. Reading from
14+
* `rawBody` / `body` directly and constructing a fresh `Request` object
15+
* prevents POST/PUT/PATCH requests from hanging indefinitely.
1216
*
13-
* We use `handle()` from `@hono/node-server/vercel` which is the standard
14-
* Vercel adapter for Hono. It internally uses `getRequestListener()` to
15-
* convert `IncomingMessage → Request` (including Vercel's pre-buffered
16-
* `rawBody`) and writes the `Response` back to `ServerResponse`.
17-
*
18-
* The outer Hono app delegates all requests to the inner ObjectStack Hono
19-
* app via `inner.fetch(c.req.raw)`, matching the pattern documented in
20-
* the ObjectStack deployment guide and validated by the hono adapter tests.
17+
* This follows the proven pattern from the hotcrm reference deployment:
18+
* @see https://github.com/objectstack-ai/hotcrm/blob/main/api/%5B%5B...route%5D%5D.ts
2119
*
2220
* All kernel/service initialisation is co-located here so there are no
2321
* extensionless relative module imports — which would break Node's ESM
@@ -37,9 +35,8 @@ import { MetadataPlugin } from '@objectstack/metadata';
3735
import { AIServicePlugin } from '@objectstack/service-ai';
3836
import { AutomationServicePlugin } from '@objectstack/service-automation';
3937
import { AnalyticsServicePlugin } from '@objectstack/service-analytics';
40-
import { handle } from '@hono/node-server/vercel';
41-
import { Hono } from 'hono';
42-
import { cors } from 'hono/cors';
38+
import { getRequestListener } from '@hono/node-server';
39+
import type { Hono } from 'hono';
4340
import { createBrokerShim } from '../src/lib/create-broker-shim.js';
4441
import studioConfig from '../objectstack.config.js';
4542

@@ -225,78 +222,132 @@ async function ensureApp(): Promise<Hono> {
225222
}
226223

227224
// ---------------------------------------------------------------------------
228-
// Vercel handler
225+
// Body extraction — reads Vercel's pre-buffered request body.
226+
//
227+
// Vercel's Node.js runtime buffers the entire request body before invoking
228+
// the serverless handler and attaches it to `IncomingMessage` as:
229+
// - `rawBody` (Buffer | string) — the raw bytes
230+
// - `body` (object | string) — parsed body (for JSON/form content types)
231+
//
232+
// The underlying readable stream is therefore already drained by the time
233+
// our handler runs. Building a new `Request` from these pre-buffered
234+
// properties avoids the indefinite hang that occurs when `req.json()` tries
235+
// to read a consumed stream.
236+
//
237+
// @see https://github.com/objectstack-ai/hotcrm/blob/main/api/%5B%5B...route%5D%5D.ts
229238
// ---------------------------------------------------------------------------
230239

240+
/** Shape of the Vercel-augmented IncomingMessage passed via `env.incoming`. */
241+
interface VercelIncomingMessage {
242+
rawBody?: Buffer | string;
243+
body?: unknown;
244+
headers?: Record<string, string | string[] | undefined>;
245+
}
246+
247+
/** Shape of the env object provided by `getRequestListener` on Vercel. */
248+
interface VercelEnv {
249+
incoming?: VercelIncomingMessage;
250+
}
251+
252+
function extractBody(
253+
incoming: VercelIncomingMessage,
254+
method: string,
255+
contentType: string | undefined,
256+
): BodyInit | null {
257+
if (method === 'GET' || method === 'HEAD' || method === 'OPTIONS') return null;
258+
259+
if (incoming.rawBody != null) {
260+
return incoming.rawBody;
261+
}
262+
263+
if (incoming.body != null) {
264+
if (typeof incoming.body === 'string') return incoming.body;
265+
if (contentType?.includes('application/json')) return JSON.stringify(incoming.body);
266+
return String(incoming.body);
267+
}
268+
269+
return null;
270+
}
271+
231272
/**
232-
* Outer Hono app — delegates all requests to the inner ObjectStack app.
273+
* Derive the correct public URL for the request, fixing the protocol when
274+
* running behind a reverse proxy such as Vercel's edge network.
233275
*
234-
* `handle()` from `@hono/node-server/vercel` wraps any Hono app and returns
235-
* the `(IncomingMessage, ServerResponse) => Promise<void>` signature that
236-
* Vercel's Node.js runtime expects for serverless functions. Internally it
237-
* uses `getRequestListener()`, which already handles Vercel's pre-buffered
238-
* `rawBody` (Buffer) on the IncomingMessage for POST/PUT/PATCH requests.
239-
*
240-
* The outer→inner delegation pattern (`inner.fetch(c.req.raw)`) is the
241-
* standard ObjectStack Vercel deployment pattern documented in the deployment
242-
* guide and covered by the @objectstack/hono adapter test suite.
276+
* `@hono/node-server`'s `getRequestListener` constructs the URL from
277+
* `incoming.socket.encrypted`, which is `false` on Vercel's internal network
278+
* even though the external request is HTTPS. Using `x-forwarded-proto: https`
279+
* (set by Vercel's edge) ensures that better-auth sees an `https://` URL,
280+
* so cookie `Secure` attributes, callback URL validation, and any protocol
281+
* comparisons work correctly.
243282
*/
244-
const app = new Hono();
283+
function resolvePublicUrl(
284+
requestUrl: string,
285+
incoming: VercelIncomingMessage | undefined,
286+
): string {
287+
if (!incoming) return requestUrl;
288+
const fwdProto = incoming.headers?.['x-forwarded-proto'];
289+
const rawProto = Array.isArray(fwdProto) ? fwdProto[0] : fwdProto;
290+
// Accept only well-known protocol values to prevent header-injection attacks.
291+
const proto = rawProto === 'https' || rawProto === 'http' ? rawProto : undefined;
292+
if (proto === 'https' && requestUrl.startsWith('http:')) {
293+
return requestUrl.replace(/^http:/, 'https:');
294+
}
295+
return requestUrl;
296+
}
245297

246298
// ---------------------------------------------------------------------------
247-
// CORS middleware
248-
// ---------------------------------------------------------------------------
249-
// Placed on the outer app so preflight (OPTIONS) requests are answered
250-
// immediately, without waiting for the kernel cold-start. This is essential
251-
// when the SPA is loaded from a Vercel temporary/preview domain but the
252-
// API base URL points to a different deployment (cross-origin).
299+
// Vercel Node.js serverless handler via @hono/node-server getRequestListener.
253300
//
254-
// Allowed origins:
255-
// 1. All Vercel deployment URLs exposed via env vars (current deployment)
256-
// 2. Any *.vercel.app subdomain (covers all preview/branch deployments)
257-
// 3. localhost (local development)
301+
// Using getRequestListener() instead of handle() from @hono/node-server/vercel
302+
// gives us access to the raw IncomingMessage via `env.incoming`, which lets us
303+
// read Vercel's pre-buffered rawBody/body for POST/PUT/PATCH requests.
304+
//
305+
// This follows the proven pattern from the hotcrm reference deployment.
258306
// ---------------------------------------------------------------------------
259307

260-
const vercelOrigins = getVercelOrigins();
261-
262-
app.use('*', cors({
263-
origin: (origin) => {
264-
// Same-origin or non-browser requests (no Origin header)
265-
if (!origin) return origin;
266-
// Explicitly listed Vercel deployment origins
267-
if (vercelOrigins.includes(origin)) return origin;
268-
// Any *.vercel.app subdomain (preview / temp deployments)
269-
if (origin.endsWith('.vercel.app') && origin.startsWith('https://')) return origin;
270-
// Localhost for development
271-
if (/^https?:\/\/localhost(:\d+)?$/.test(origin)) return origin;
272-
// Deny — return empty string so no Access-Control-Allow-Origin is set
273-
return '';
274-
},
275-
credentials: true,
276-
allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
277-
allowHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
278-
maxAge: 86400,
279-
}));
280-
281-
app.all('*', async (c) => {
282-
console.log(`[Vercel] ${c.req.method} ${c.req.url}`);
283-
308+
export default getRequestListener(async (request, env) => {
309+
let app: Hono;
284310
try {
285-
const inner = await ensureApp();
286-
return await inner.fetch(c.req.raw);
287-
} catch (err: any) {
288-
console.error('[Vercel] Handler error:', err?.message || err);
289-
return c.json(
290-
{
311+
app = await ensureApp();
312+
} catch (err: unknown) {
313+
const message = err instanceof Error ? err.message : String(err);
314+
console.error('[Vercel] Handler error — bootstrap did not complete:', message);
315+
return new Response(
316+
JSON.stringify({
291317
success: false,
292-
error: { message: err?.message || 'Internal Server Error', code: 500 },
293-
},
294-
500,
318+
error: {
319+
message: 'Service Unavailable — kernel bootstrap failed.',
320+
code: 503,
321+
},
322+
}),
323+
{ status: 503, headers: { 'content-type': 'application/json' } },
295324
);
296325
}
297-
});
298326

299-
export default handle(app);
327+
const method = request.method.toUpperCase();
328+
const incoming = (env as VercelEnv)?.incoming;
329+
330+
// Fix URL protocol using x-forwarded-proto (Vercel sets this to 'https').
331+
const url = resolvePublicUrl(request.url, incoming);
332+
333+
console.log(`[Vercel] ${method} ${url}`);
334+
335+
if (method !== 'GET' && method !== 'HEAD' && method !== 'OPTIONS' && incoming) {
336+
const contentType = incoming.headers?.['content-type'];
337+
const contentTypeStr = Array.isArray(contentType) ? contentType[0] : contentType;
338+
const body = extractBody(incoming, method, contentTypeStr);
339+
if (body != null) {
340+
return await app.fetch(
341+
new Request(url, { method, headers: request.headers, body }),
342+
);
343+
}
344+
}
345+
346+
// For GET/HEAD/OPTIONS (or body-less requests): pass through with corrected URL.
347+
return await app.fetch(
348+
new Request(url, { method, headers: request.headers }),
349+
);
350+
});
300351

301352
/**
302353
* Vercel per-function configuration.

0 commit comments

Comments
 (0)