Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions apps/studio/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
193 changes: 122 additions & 71 deletions apps/studio/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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';

Expand Down Expand Up @@ -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);
Comment on lines +264 to +266
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

extractBody() falls back to String(incoming.body) for non-JSON content types. If incoming.body is an object (common for urlencoded/form parsing), this becomes "[object Object]" and forwards an invalid payload. Prefer forwarding rawBody whenever possible; otherwise, add explicit handling for application/x-www-form-urlencoded (e.g., URLSearchParams) and avoid lossy string coercion for structured bodies.

Suggested change
if (typeof incoming.body === 'string') return incoming.body;
if (contentType?.includes('application/json')) return JSON.stringify(incoming.body);
return String(incoming.body);
const body: unknown = incoming.body;
// Already-encoded string bodies can be forwarded as-is.
if (typeof body === 'string') {
return body;
}
// Preserve binary bodies.
if (typeof Buffer !== 'undefined' && Buffer.isBuffer(body)) {
return body;
}
// Respect URLSearchParams bodies.
if (body instanceof URLSearchParams) {
return body;
}
// For form-urlencoded content, encode plain objects into a query string.
if (contentType?.includes('application/x-www-form-urlencoded') && body && typeof body === 'object') {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(body as Record<string, unknown>)) {
if (Array.isArray(value)) {
for (const v of value) {
if (v != null) params.append(key, String(v));
}
} else if (value != null) {
params.append(key, String(value));
}
}
return params.toString();
}
// For JSON and other structured bodies, prefer JSON encoding over lossy String().
if (contentType?.includes('application/json')) {
return JSON.stringify(body);
}
if (typeof body === 'object') {
return JSON.stringify(body);
}
// For primitive scalar values, String() is a faithful representation.
if (typeof body === 'number' || typeof body === 'boolean' || typeof body === 'bigint') {
return String(body);
}

Copilot uses AI. Check for mistakes.
}

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
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The handler calls ensureApp() (kernel bootstrap) before checking the HTTP method. This means CORS preflight OPTIONS requests will also trigger a cold start, and there is no fast-path response for preflight. Consider short-circuiting OPTIONS before bootstrapping (and returning the appropriate CORS headers / 204) so browsers can complete preflight without waiting for kernel init.

Copilot uses AI. Check for mistakes.
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
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When constructing the forwarded Request, the code reuses request.headers unchanged. If the body is rebuilt from incoming.body (e.g., via JSON.stringify), headers like content-length may no longer match the forwarded payload, which can cause downstream body parsing issues. Safer approach: clone headers and remove hop-by-hop headers (at least content-length, and potentially transfer-encoding/connection) before creating the new Request.

Copilot uses AI. Check for mistakes.
}
}

// 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.
Expand Down
Loading
Loading