Skip to content

Commit 2e4de28

Browse files
Copilothotlong
andauthored
fix(studio): use getRequestListener() with extractBody() for Vercel POST support
Replace the `handle()` + outer Hono app delegation pattern with `getRequestListener()` from `@hono/node-server`, matching the proven pattern from the hotcrm reference deployment. The previous approach used `handle()` from `@hono/node-server/vercel` wrapped in an outer Hono app that delegated via `inner.fetch(c.req.raw)`. On Vercel the IncomingMessage stream is already drained by the time the inner app reads it, causing POST/PUT/PATCH requests to hang indefinitely. The new approach uses `getRequestListener()` directly which exposes the raw IncomingMessage via `env.incoming`. For POST/PUT/PATCH requests the body is extracted from Vercel's pre-buffered `rawBody`/`body` properties and a fresh standard `Request` is constructed. Also adds x-forwarded-proto URL correction for proper HTTPS detection behind Vercel's reverse proxy. Agent-Logs-Url: https://github.com/objectstack-ai/framework/sessions/8923ba52-78fa-4a0c-8f11-edd6b20e611c Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent 51b13dd commit 2e4de28

2 files changed

Lines changed: 141 additions & 112 deletions

File tree

apps/studio/CHANGELOG.md

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,22 @@
66

77
- **Vercel deployment: Fix POST/PUT/PATCH API requests timing out**
88

9-
The outer→inner Hono app delegation pattern (`inner.fetch(c.req.raw)`)
10-
passed the `@hono/node-server` pseudo-Request directly to the inner
11-
ObjectStack Hono app. The pseudo-Request lazily materialises its body
12-
from the Node.js `IncomingMessage` via `Readable.toWeb()` the first
13-
time `.json()` / `.text()` is called. On Vercel's serverless runtime
14-
the `IncomingMessage` stream can be in a half-consumed state by the
15-
time the inner app reads it, causing body reads to hang and the
16-
function to time out — while GET requests (no body) worked fine.
17-
18-
Fix: for POST/PUT/PATCH/DELETE the outer handler now eagerly buffers
19-
the request body with `arrayBuffer()` while the `IncomingMessage` is
20-
still in a known-good state, then creates a plain `Request` for the
21-
inner app. GET/HEAD requests continue to use the direct pass-through.
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.
2225

2326
- Remove `functions` block from `vercel.json` to fix deployment error:
2427
"The pattern 'api/index.js' defined in `functions` doesn't match any

apps/studio/server/index.ts

Lines changed: 125 additions & 99 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,106 +222,135 @@ 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+
if (typeof incoming.rawBody === 'string') return incoming.rawBody;
261+
return incoming.rawBody;
262+
}
263+
264+
if (incoming.body != null) {
265+
if (typeof incoming.body === 'string') return incoming.body;
266+
if (contentType?.includes('application/json')) return JSON.stringify(incoming.body);
267+
return String(incoming.body);
268+
}
269+
270+
return null;
271+
}
272+
231273
/**
232-
* Outer Hono app — delegates all requests to the inner ObjectStack app.
233-
*
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.
274+
* Derive the correct public URL for the request, fixing the protocol when
275+
* running behind a reverse proxy such as Vercel's edge network.
239276
*
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.
277+
* `@hono/node-server`'s `getRequestListener` constructs the URL from
278+
* `incoming.socket.encrypted`, which is `false` on Vercel's internal network
279+
* even though the external request is HTTPS. Using `x-forwarded-proto: https`
280+
* (set by Vercel's edge) ensures that better-auth sees an `https://` URL,
281+
* so cookie `Secure` attributes, callback URL validation, and any protocol
282+
* comparisons work correctly.
243283
*/
244-
const app = new Hono();
284+
function resolvePublicUrl(
285+
requestUrl: string,
286+
incoming: VercelIncomingMessage | undefined,
287+
): string {
288+
if (!incoming) return requestUrl;
289+
const fwdProto = incoming.headers?.['x-forwarded-proto'];
290+
const rawProto = Array.isArray(fwdProto) ? fwdProto[0] : fwdProto;
291+
// Accept only well-known protocol values to prevent header-injection attacks.
292+
const proto = rawProto === 'https' || rawProto === 'http' ? rawProto : undefined;
293+
if (proto === 'https' && requestUrl.startsWith('http:')) {
294+
return requestUrl.replace(/^http:/, 'https:');
295+
}
296+
return requestUrl;
297+
}
245298

246299
// ---------------------------------------------------------------------------
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).
300+
// Vercel Node.js serverless handler via @hono/node-server getRequestListener.
301+
//
302+
// Using getRequestListener() instead of handle() from @hono/node-server/vercel
303+
// gives us access to the raw IncomingMessage via `env.incoming`, which lets us
304+
// read Vercel's pre-buffered rawBody/body for POST/PUT/PATCH requests.
253305
//
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)
306+
// This follows the proven pattern from the hotcrm reference deployment.
258307
// ---------------------------------------------------------------------------
259308

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-
309+
export default getRequestListener(async (request, env) => {
310+
let app: Hono;
284311
try {
285-
const inner = await ensureApp();
286-
287-
// ── Body-safe delegation ────────────────────────────────────────
288-
// `c.req.raw` is the *pseudo-Request* created by @hono/node-server.
289-
// Its body is lazily materialised from the Node.js IncomingMessage
290-
// via `Readable.toWeb()` the first time `.json()`, `.text()`, etc.
291-
// are called. On Vercel's serverless runtime the IncomingMessage
292-
// stream can already be in a half-consumed state by the time the
293-
// inner Hono app reads it, causing `.json()` to hang indefinitely
294-
// and the function to time out.
295-
//
296-
// For GET/HEAD (no body) we can forward the pseudo-Request as-is.
297-
// For every other method we eagerly buffer the body while the
298-
// IncomingMessage is still in a known-good state, then construct a
299-
// plain `Request` that the inner app can consume without issues.
300-
// ────────────────────────────────────────────────────────────────
301-
const method = c.req.method;
302-
303-
if (method === 'GET' || method === 'HEAD') {
304-
return await inner.fetch(c.req.raw);
305-
}
306-
307-
const rawReq = c.req.raw;
308-
const body = await rawReq.arrayBuffer();
309-
const forwarded = new Request(rawReq.url, {
310-
method,
311-
headers: rawReq.headers,
312-
body,
313-
});
314-
return await inner.fetch(forwarded);
315-
} catch (err: any) {
316-
console.error('[Vercel] Handler error:', err?.message || err);
317-
return c.json(
318-
{
312+
app = await ensureApp();
313+
} catch (err: unknown) {
314+
const message = err instanceof Error ? err.message : String(err);
315+
console.error('[Vercel] Handler error — bootstrap did not complete:', message);
316+
return new Response(
317+
JSON.stringify({
319318
success: false,
320-
error: { message: err?.message || 'Internal Server Error', code: 500 },
321-
},
322-
500,
319+
error: {
320+
message: 'Service Unavailable — kernel bootstrap failed.',
321+
code: 503,
322+
},
323+
}),
324+
{ status: 503, headers: { 'content-type': 'application/json' } },
323325
);
324326
}
325-
});
326327

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

329355
/**
330356
* Vercel per-function configuration.

0 commit comments

Comments
 (0)