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';
3735import { AIServicePlugin } from '@objectstack/service-ai' ;
3836import { AutomationServicePlugin } from '@objectstack/service-automation' ;
3937import { 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' ;
4340import { createBrokerShim } from '../src/lib/create-broker-shim.js' ;
4441import 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 ( / ^ h t t p : / , '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 ( / ^ h t t p s ? : \/ \/ l o c a l h o s t ( : \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