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,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 ( / ^ h t t p : / , '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 ( / ^ 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-
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