diff --git a/.changeset/fix-vercel-api-html-response.md b/.changeset/fix-vercel-api-html-response.md new file mode 100644 index 0000000000..1f3bdc260c --- /dev/null +++ b/.changeset/fix-vercel-api-html-response.md @@ -0,0 +1,18 @@ +--- +"@objectstack/studio": patch +--- + +Fix Vercel deployment API endpoints returning HTML instead of JSON. + +Replace the custom `getRequestListener` export in `server/index.ts` with the +standard `handle()` adapter from `@hono/node-server/vercel` and the +outer→inner Hono delegation pattern (`inner.fetch(c.req.raw)`). + +- The `handle()` adapter correctly wraps the Hono app with the + `(IncomingMessage, ServerResponse) => Promise` signature that + Vercel's Node.js runtime expects for serverless functions in `api/`. +- `@hono/node-server/vercel`'s `getRequestListener()` already handles + Vercel's pre-buffered `rawBody` natively, removing the need for the + custom body-extraction helper. +- The outer→inner delegation pattern matches the documented ObjectStack + Vercel deployment guide and the `@objectstack/hono` adapter test suite. diff --git a/apps/studio/server/index.ts b/apps/studio/server/index.ts index e73850ada0..f88001248f 100644 --- a/apps/studio/server/index.ts +++ b/apps/studio/server/index.ts @@ -10,15 +10,14 @@ * legacy `(IncomingMessage, ServerResponse)` signature — NOT the Web standard * `(Request) → Response` format. * - * We use `getRequestListener()` from `@hono/node-server` which properly - * converts `IncomingMessage → Request`, calls our fetch callback, then writes - * the `Response` back to `ServerResponse`. + * 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`. * - * For POST/PUT/PATCH requests, Vercel pre-buffers the body on the - * IncomingMessage as `rawBody` (Buffer) or `body` (parsed). We extract it - * directly and build a clean `Request` so the inner Hono app receives a - * body it can `.json()` without depending on Node stream-to-ReadableStream - * conversion (which can hang when the stream has already been consumed). + * 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. * * All kernel/service initialisation is co-located here so there are no * extensionless relative module imports — which would break Node's ESM @@ -34,8 +33,8 @@ import { SecurityPlugin } from '@objectstack/plugin-security'; import { AuditPlugin } from '@objectstack/plugin-audit'; import { FeedServicePlugin } from '@objectstack/service-feed'; import { MetadataPlugin } from '@objectstack/metadata'; -import { getRequestListener } from '@hono/node-server'; -import type { Hono } from 'hono'; +import { handle } from '@hono/node-server/vercel'; +import { Hono } from 'hono'; import { createBrokerShim } from '../src/lib/create-broker-shim.js'; import studioConfig from '../objectstack.config.js'; @@ -198,93 +197,41 @@ async function ensureApp(): Promise { return _app; } -// --------------------------------------------------------------------------- -// Body extraction helpers -// --------------------------------------------------------------------------- - -/** - * Extract the request body from the Vercel IncomingMessage. - * - * Vercel's Node.js runtime pre-buffers the full request body and attaches it - * to the IncomingMessage as `rawBody` (Buffer) and/or `body` (parsed). - * Reading from these properties is synchronous and avoids the fragile - * IncomingMessage → ReadableStream conversion that can hang when the - * underlying Node stream has already been consumed. - * - * Returns `null` for GET/HEAD/OPTIONS or when no body is available. - */ -function extractBody(incoming: any, method: string, contentType: string | undefined): BodyInit | null { - if (method === 'GET' || method === 'HEAD' || method === 'OPTIONS') { - return null; - } - - // 1. rawBody (Buffer or string) — most reliable, set by Vercel runtime - if (incoming?.rawBody != null) { - if (typeof incoming.rawBody === 'string') return incoming.rawBody; - if (typeof incoming.rawBody.toString === 'function') return incoming.rawBody; - return String(incoming.rawBody); - } - - // 2. body (parsed by Vercel) — re-serialize based on content-type - 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; -} - // --------------------------------------------------------------------------- // Vercel handler // --------------------------------------------------------------------------- /** - * `getRequestListener` from `@hono/node-server` converts - * `IncomingMessage → Request`, calls our fetch callback, then writes the - * `Response` back to `ServerResponse` (including `res.end()`). + * Outer Hono app — delegates all requests to the inner ObjectStack app. + * + * `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. * - * For requests with a body, we extract it from the IncomingMessage directly - * (bypassing the Node stream → ReadableStream conversion) and create a new - * Request that the inner Hono app can safely `.json()`. + * 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. */ -export default getRequestListener(async (request, env) => { - const method = request.method; - const url = request.url; +const app = new Hono(); - console.log(`[Vercel] ${method} ${url}`); +app.all('*', async (c) => { + console.log(`[Vercel] ${c.req.method} ${c.req.url}`); try { - const app = await ensureApp(); - const incoming = (env as any)?.incoming; - - // For body methods, extract body from IncomingMessage and build a clean Request - if (method !== 'GET' && method !== 'HEAD' && method !== 'OPTIONS' && incoming) { - const contentType = incoming.headers?.['content-type']; - const body = extractBody(incoming, method, contentType); - - if (body != null) { - console.log(`[Vercel] Body extracted from IncomingMessage (${typeof body === 'string' ? body.length + ' chars' : 'Buffer'})`); - const newReq = new Request(url, { - method, - headers: request.headers, - body, - }); - return await app.fetch(newReq); - } - - console.log('[Vercel] No rawBody/body on IncomingMessage — using proxy request'); - } - - return await app.fetch(request); + const inner = await ensureApp(); + return await inner.fetch(c.req.raw); } catch (err: any) { console.error('[Vercel] Handler error:', err?.message || err); - return new Response( - JSON.stringify({ + return c.json( + { success: false, error: { message: err?.message || 'Internal Server Error', code: 500 }, - }), - { status: 500, headers: { 'Content-Type': 'application/json' } }, + }, + 500, ); } }); + +export default handle(app);