diff --git a/apps/studio/CHANGELOG.md b/apps/studio/CHANGELOG.md index 76be5de47..2524d082e 100644 --- a/apps/studio/CHANGELOG.md +++ b/apps/studio/CHANGELOG.md @@ -4,6 +4,25 @@ ### Patch Changes +- **Vercel deployment: Fix POST/PUT/PATCH API requests timing out** + + Replaced 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 to the inner ObjectStack + app via `inner.fetch(c.req.raw)`. On Vercel, the `IncomingMessage` + stream is already drained by the time the inner app's route handler + calls `.json()`, 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 for + the inner Hono app. This also adds `x-forwarded-proto` URL correction + for proper HTTPS detection behind Vercel's reverse proxy. + - Remove `functions` block from `vercel.json` to fix deployment error: "The pattern 'api/index.js' defined in `functions` doesn't match any Serverless Functions inside the `api` directory." diff --git a/apps/studio/server/index.ts b/apps/studio/server/index.ts index 9e764ed8c..966d1724e 100644 --- a/apps/studio/server/index.ts +++ b/apps/studio/server/index.ts @@ -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 { } // --------------------------------------------------------------------------- -// 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; +} + +/** 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` 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); + 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 }), + ); + } + } + + // 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. diff --git a/packages/adapters/hono/src/hono.test.ts b/packages/adapters/hono/src/hono.test.ts index 57161f9ad..057f8a077 100644 --- a/packages/adapters/hono/src/hono.test.ts +++ b/packages/adapters/hono/src/hono.test.ts @@ -657,6 +657,83 @@ describe('createHonoApp', () => { ); }); + it('POST /api/v1/data/account parses JSON body through outer→inner delegation', async () => { + const outerApp = createVercelApp(); + const body = { name: 'Acme' }; + + const res = await outerApp.request('/api/v1/data/account', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + expect(res.status).toBe(200); + expect(mockDispatcher.dispatch).toHaveBeenCalledWith( + 'POST', + '/data/account', + body, + expect.any(Object), + expect.objectContaining({ request: expect.anything() }), + '/api/v1', + ); + }); + + it('PUT /api/v1/data/account parses JSON body through outer→inner delegation', async () => { + const outerApp = createVercelApp(); + const body = { name: 'Updated' }; + + const res = await outerApp.request('/api/v1/data/account', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + expect(res.status).toBe(200); + expect(mockDispatcher.dispatch).toHaveBeenCalledWith( + 'PUT', + '/data/account', + body, + expect.any(Object), + expect.objectContaining({ request: expect.anything() }), + '/api/v1', + ); + }); + + it('PATCH /api/v1/data/account parses JSON body through outer→inner delegation', async () => { + const outerApp = createVercelApp(); + const body = { name: 'Patched' }; + + const res = await outerApp.request('/api/v1/data/account', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + expect(res.status).toBe(200); + expect(mockDispatcher.dispatch).toHaveBeenCalledWith( + 'PATCH', + '/data/account', + body, + expect.any(Object), + expect.objectContaining({ request: expect.anything() }), + '/api/v1', + ); + }); + + it('DELETE /api/v1/data/account routes through outer→inner delegation', async () => { + const outerApp = createVercelApp(); + + const res = await outerApp.request('/api/v1/data/account', { + method: 'DELETE', + }); + expect(res.status).toBe(200); + expect(mockDispatcher.dispatch).toHaveBeenCalledWith( + 'DELETE', + '/data/account', + undefined, + expect.any(Object), + expect.objectContaining({ request: expect.anything() }), + '/api/v1', + ); + }); + it('returns 500 with error details when inner app throws', async () => { const outerApp = new Hono(); @@ -680,6 +757,153 @@ describe('createHonoApp', () => { }); }); + describe('Body-safe Vercel delegation (buffered body forwarding)', () => { + /** + * Validates the body-safe delegation pattern used in + * `apps/studio/server/index.ts` where the outer handler buffers + * POST/PUT/PATCH bodies and creates a fresh `Request` for the inner app. + * This avoids @hono/node-server's lazy body materialisation which can + * hang on Vercel when the IncomingMessage stream state has changed. + */ + function createBodySafeVercelApp() { + const innerApp = createHonoApp({ kernel: mockKernel, prefix: '/api/v1' }); + const outerApp = new Hono(); + + outerApp.all('*', async (c) => { + const method = c.req.method; + + // GET/HEAD have no body — pass through directly + if (method === 'GET' || method === 'HEAD') { + return innerApp.fetch(c.req.raw); + } + + // Buffer body and create a fresh Request + const rawReq = c.req.raw; + const body = await rawReq.arrayBuffer(); + const forwarded = new Request(rawReq.url, { + method, + headers: rawReq.headers, + body, + }); + return innerApp.fetch(forwarded); + }); + + return outerApp; + } + + it('GET requests work without body buffering', async () => { + const outerApp = createBodySafeVercelApp(); + + const res = await outerApp.request('/api/v1/data/account'); + expect(res.status).toBe(200); + expect(mockDispatcher.dispatch).toHaveBeenCalledWith( + 'GET', + '/data/account', + undefined, + expect.any(Object), + expect.objectContaining({ request: expect.anything() }), + '/api/v1', + ); + }); + + it('POST body is forwarded correctly via buffered delegation', async () => { + const outerApp = createBodySafeVercelApp(); + const body = { name: 'Acme Corp' }; + + const res = await outerApp.request('/api/v1/data/account', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + expect(res.status).toBe(200); + expect(mockDispatcher.dispatch).toHaveBeenCalledWith( + 'POST', + '/data/account', + body, + expect.any(Object), + expect.objectContaining({ request: expect.anything() }), + '/api/v1', + ); + }); + + it('PUT body is forwarded correctly via buffered delegation', async () => { + const outerApp = createBodySafeVercelApp(); + const body = { name: 'Updated Corp' }; + + const res = await outerApp.request('/api/v1/data/account', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + expect(res.status).toBe(200); + expect(mockDispatcher.dispatch).toHaveBeenCalledWith( + 'PUT', + '/data/account', + body, + expect.any(Object), + expect.objectContaining({ request: expect.anything() }), + '/api/v1', + ); + }); + + it('PATCH body is forwarded correctly via buffered delegation', async () => { + const outerApp = createBodySafeVercelApp(); + const body = { status: 'active' }; + + const res = await outerApp.request('/api/v1/data/account', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + expect(res.status).toBe(200); + expect(mockDispatcher.dispatch).toHaveBeenCalledWith( + 'PATCH', + '/data/account', + body, + expect.any(Object), + expect.objectContaining({ request: expect.anything() }), + '/api/v1', + ); + }); + + it('DELETE without body works via buffered delegation', async () => { + const outerApp = createBodySafeVercelApp(); + + const res = await outerApp.request('/api/v1/data/account', { + method: 'DELETE', + }); + expect(res.status).toBe(200); + expect(mockDispatcher.dispatch).toHaveBeenCalledWith( + 'DELETE', + '/data/account', + undefined, + expect.any(Object), + expect.objectContaining({ request: expect.anything() }), + '/api/v1', + ); + }); + + it('POST with empty body defaults to {} via buffered delegation', async () => { + const outerApp = createBodySafeVercelApp(); + + const res = await outerApp.request('/api/v1/data/account', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '', + }); + expect(res.status).toBe(200); + // Empty body falls back to {} via .catch(() => ({})) in the adapter + expect(mockDispatcher.dispatch).toHaveBeenCalledWith( + 'POST', + '/data/account', + {}, + expect.any(Object), + expect.objectContaining({ request: expect.anything() }), + '/api/v1', + ); + }); + }); + describe('Vercel deployment endpoint smoke tests', () => { /** * These tests validate that the two key deployment-health endpoints