-
Notifications
You must be signed in to change notification settings - Fork 1
fix(studio): use getRequestListener() with extractBody() for Vercel POST support #1066
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
eb5adae
51b13dd
2e4de28
a3f9662
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,18 +6,16 @@ | |
| * Boots the ObjectStack kernel lazily on the first request and delegates | ||
| * all /api/* traffic to the ObjectStack Hono adapter. | ||
| * | ||
| * IMPORTANT: Vercel's Node.js runtime calls serverless functions with the | ||
| * legacy `(IncomingMessage, ServerResponse)` signature — NOT the Web standard | ||
| * `(Request) → Response` format. | ||
| * Uses `getRequestListener()` from `@hono/node-server` together with an | ||
| * `extractBody()` helper to handle Vercel's pre-buffered request body. | ||
| * Vercel's Node.js runtime attaches the full body to `req.rawBody` / | ||
| * `req.body` before the handler is called, so the original stream is | ||
| * already drained when the handler receives the request. Reading from | ||
| * `rawBody` / `body` directly and constructing a fresh `Request` object | ||
| * prevents POST/PUT/PATCH requests from hanging indefinitely. | ||
| * | ||
| * We use `handle()` from `@hono/node-server/vercel` which is the standard | ||
| * Vercel adapter for Hono. It internally uses `getRequestListener()` to | ||
| * convert `IncomingMessage → Request` (including Vercel's pre-buffered | ||
| * `rawBody`) and writes the `Response` back to `ServerResponse`. | ||
| * | ||
| * The outer Hono app delegates all requests to the inner ObjectStack Hono | ||
| * app via `inner.fetch(c.req.raw)`, matching the pattern documented in | ||
| * the ObjectStack deployment guide and validated by the hono adapter tests. | ||
| * This follows the proven pattern from the hotcrm reference deployment: | ||
| * @see https://github.com/objectstack-ai/hotcrm/blob/main/api/%5B%5B...route%5D%5D.ts | ||
| * | ||
| * All kernel/service initialisation is co-located here so there are no | ||
| * extensionless relative module imports — which would break Node's ESM | ||
|
|
@@ -37,9 +35,8 @@ import { MetadataPlugin } from '@objectstack/metadata'; | |
| import { AIServicePlugin } from '@objectstack/service-ai'; | ||
| import { AutomationServicePlugin } from '@objectstack/service-automation'; | ||
| import { AnalyticsServicePlugin } from '@objectstack/service-analytics'; | ||
| import { handle } from '@hono/node-server/vercel'; | ||
| import { Hono } from 'hono'; | ||
| import { cors } from 'hono/cors'; | ||
| import { getRequestListener } from '@hono/node-server'; | ||
| import type { Hono } from 'hono'; | ||
| import { createBrokerShim } from '../src/lib/create-broker-shim.js'; | ||
| import studioConfig from '../objectstack.config.js'; | ||
|
|
||
|
|
@@ -225,78 +222,132 @@ async function ensureApp(): Promise<Hono> { | |
| } | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // Vercel handler | ||
| // Body extraction — reads Vercel's pre-buffered request body. | ||
| // | ||
| // Vercel's Node.js runtime buffers the entire request body before invoking | ||
| // the serverless handler and attaches it to `IncomingMessage` as: | ||
| // - `rawBody` (Buffer | string) — the raw bytes | ||
| // - `body` (object | string) — parsed body (for JSON/form content types) | ||
| // | ||
| // The underlying readable stream is therefore already drained by the time | ||
| // our handler runs. Building a new `Request` from these pre-buffered | ||
| // properties avoids the indefinite hang that occurs when `req.json()` tries | ||
| // to read a consumed stream. | ||
| // | ||
| // @see https://github.com/objectstack-ai/hotcrm/blob/main/api/%5B%5B...route%5D%5D.ts | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| /** Shape of the Vercel-augmented IncomingMessage passed via `env.incoming`. */ | ||
| interface VercelIncomingMessage { | ||
| rawBody?: Buffer | string; | ||
| body?: unknown; | ||
| headers?: Record<string, string | string[] | undefined>; | ||
| } | ||
|
|
||
| /** Shape of the env object provided by `getRequestListener` on Vercel. */ | ||
| interface VercelEnv { | ||
| incoming?: VercelIncomingMessage; | ||
| } | ||
|
|
||
| function extractBody( | ||
| incoming: VercelIncomingMessage, | ||
| method: string, | ||
| contentType: string | undefined, | ||
| ): BodyInit | null { | ||
| if (method === 'GET' || method === 'HEAD' || method === 'OPTIONS') return null; | ||
|
|
||
| if (incoming.rawBody != null) { | ||
| return incoming.rawBody; | ||
| } | ||
|
|
||
| if (incoming.body != null) { | ||
| if (typeof incoming.body === 'string') return incoming.body; | ||
| if (contentType?.includes('application/json')) return JSON.stringify(incoming.body); | ||
| return String(incoming.body); | ||
| } | ||
|
|
||
| return null; | ||
| } | ||
|
|
||
| /** | ||
| * Outer Hono app — delegates all requests to the inner ObjectStack app. | ||
| * Derive the correct public URL for the request, fixing the protocol when | ||
| * running behind a reverse proxy such as Vercel's edge network. | ||
| * | ||
| * `handle()` from `@hono/node-server/vercel` wraps any Hono app and returns | ||
| * the `(IncomingMessage, ServerResponse) => Promise<void>` signature that | ||
| * Vercel's Node.js runtime expects for serverless functions. Internally it | ||
| * uses `getRequestListener()`, which already handles Vercel's pre-buffered | ||
| * `rawBody` (Buffer) on the IncomingMessage for POST/PUT/PATCH requests. | ||
| * | ||
| * The outer→inner delegation pattern (`inner.fetch(c.req.raw)`) is the | ||
| * standard ObjectStack Vercel deployment pattern documented in the deployment | ||
| * guide and covered by the @objectstack/hono adapter test suite. | ||
| * `@hono/node-server`'s `getRequestListener` constructs the URL from | ||
| * `incoming.socket.encrypted`, which is `false` on Vercel's internal network | ||
| * even though the external request is HTTPS. Using `x-forwarded-proto: https` | ||
| * (set by Vercel's edge) ensures that better-auth sees an `https://` URL, | ||
| * so cookie `Secure` attributes, callback URL validation, and any protocol | ||
| * comparisons work correctly. | ||
| */ | ||
| const app = new Hono(); | ||
| function resolvePublicUrl( | ||
| requestUrl: string, | ||
| incoming: VercelIncomingMessage | undefined, | ||
| ): string { | ||
| if (!incoming) return requestUrl; | ||
| const fwdProto = incoming.headers?.['x-forwarded-proto']; | ||
| const rawProto = Array.isArray(fwdProto) ? fwdProto[0] : fwdProto; | ||
| // Accept only well-known protocol values to prevent header-injection attacks. | ||
| const proto = rawProto === 'https' || rawProto === 'http' ? rawProto : undefined; | ||
| if (proto === 'https' && requestUrl.startsWith('http:')) { | ||
| return requestUrl.replace(/^http:/, 'https:'); | ||
| } | ||
| return requestUrl; | ||
| } | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // CORS middleware | ||
| // --------------------------------------------------------------------------- | ||
| // Placed on the outer app so preflight (OPTIONS) requests are answered | ||
| // immediately, without waiting for the kernel cold-start. This is essential | ||
| // when the SPA is loaded from a Vercel temporary/preview domain but the | ||
| // API base URL points to a different deployment (cross-origin). | ||
| // Vercel Node.js serverless handler via @hono/node-server getRequestListener. | ||
| // | ||
| // Allowed origins: | ||
| // 1. All Vercel deployment URLs exposed via env vars (current deployment) | ||
| // 2. Any *.vercel.app subdomain (covers all preview/branch deployments) | ||
| // 3. localhost (local development) | ||
| // Using getRequestListener() instead of handle() from @hono/node-server/vercel | ||
| // gives us access to the raw IncomingMessage via `env.incoming`, which lets us | ||
| // read Vercel's pre-buffered rawBody/body for POST/PUT/PATCH requests. | ||
| // | ||
| // This follows the proven pattern from the hotcrm reference deployment. | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| const vercelOrigins = getVercelOrigins(); | ||
|
|
||
| app.use('*', cors({ | ||
| origin: (origin) => { | ||
| // Same-origin or non-browser requests (no Origin header) | ||
| if (!origin) return origin; | ||
| // Explicitly listed Vercel deployment origins | ||
| if (vercelOrigins.includes(origin)) return origin; | ||
| // Any *.vercel.app subdomain (preview / temp deployments) | ||
| if (origin.endsWith('.vercel.app') && origin.startsWith('https://')) return origin; | ||
| // Localhost for development | ||
| if (/^https?:\/\/localhost(:\d+)?$/.test(origin)) return origin; | ||
| // Deny — return empty string so no Access-Control-Allow-Origin is set | ||
| return ''; | ||
| }, | ||
| credentials: true, | ||
| allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], | ||
| allowHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'], | ||
| maxAge: 86400, | ||
| })); | ||
|
|
||
| app.all('*', async (c) => { | ||
| console.log(`[Vercel] ${c.req.method} ${c.req.url}`); | ||
|
|
||
| export default getRequestListener(async (request, env) => { | ||
| let app: Hono; | ||
| try { | ||
| const inner = await ensureApp(); | ||
| return await inner.fetch(c.req.raw); | ||
| } catch (err: any) { | ||
| console.error('[Vercel] Handler error:', err?.message || err); | ||
| return c.json( | ||
| { | ||
| app = await ensureApp(); | ||
| } catch (err: unknown) { | ||
| const message = err instanceof Error ? err.message : String(err); | ||
| console.error('[Vercel] Handler error — bootstrap did not complete:', message); | ||
|
Comment on lines
+308
to
+314
|
||
| return new Response( | ||
| JSON.stringify({ | ||
| success: false, | ||
| error: { message: err?.message || 'Internal Server Error', code: 500 }, | ||
| }, | ||
| 500, | ||
| error: { | ||
| message: 'Service Unavailable — kernel bootstrap failed.', | ||
| code: 503, | ||
| }, | ||
| }), | ||
| { status: 503, headers: { 'content-type': 'application/json' } }, | ||
| ); | ||
| } | ||
| }); | ||
|
|
||
| export default handle(app); | ||
| const method = request.method.toUpperCase(); | ||
| const incoming = (env as VercelEnv)?.incoming; | ||
|
|
||
| // Fix URL protocol using x-forwarded-proto (Vercel sets this to 'https'). | ||
| const url = resolvePublicUrl(request.url, incoming); | ||
|
|
||
| console.log(`[Vercel] ${method} ${url}`); | ||
|
|
||
| if (method !== 'GET' && method !== 'HEAD' && method !== 'OPTIONS' && incoming) { | ||
| const contentType = incoming.headers?.['content-type']; | ||
| const contentTypeStr = Array.isArray(contentType) ? contentType[0] : contentType; | ||
| const body = extractBody(incoming, method, contentTypeStr); | ||
| if (body != null) { | ||
| return await app.fetch( | ||
| new Request(url, { method, headers: request.headers, body }), | ||
| ); | ||
|
Comment on lines
+335
to
+342
|
||
| } | ||
| } | ||
|
|
||
| // For GET/HEAD/OPTIONS (or body-less requests): pass through with corrected URL. | ||
| return await app.fetch( | ||
| new Request(url, { method, headers: request.headers }), | ||
| ); | ||
| }); | ||
|
|
||
| /** | ||
| * Vercel per-function configuration. | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
extractBody()falls back toString(incoming.body)for non-JSON content types. Ifincoming.bodyis an object (common for urlencoded/form parsing), this becomes "[object Object]" and forwards an invalid payload. Prefer forwardingrawBodywhenever possible; otherwise, add explicit handling forapplication/x-www-form-urlencoded(e.g., URLSearchParams) and avoid lossy string coercion for structured bodies.