Skip to content

Commit 51b13dd

Browse files
Copilothotlong
andauthored
fix(studio): buffer POST/PUT/PATCH body in Vercel handler to prevent timeout
The outer→inner Hono app delegation pattern passed the @hono/node-server pseudo-Request directly to the inner app. The pseudo-Request lazily materialises its body from the Node.js IncomingMessage via Readable.toWeb() which can hang on Vercel's serverless runtime, causing POST/PUT/PATCH requests to time out while GET requests work fine. Fix: for methods with a body, eagerly buffer via arrayBuffer() while the IncomingMessage is in a known-good state, then create a plain Request for the inner app. GET/HEAD continue to use the direct pass-through. Also adds POST/PUT/PATCH/DELETE body forwarding tests for the Vercel delegation pattern (previously only GET was tested). Agent-Logs-Url: https://github.com/objectstack-ai/framework/sessions/15d6a2bf-9777-42de-92d4-56a61b26dc77 Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent eb5adae commit 51b13dd

3 files changed

Lines changed: 269 additions & 1 deletion

File tree

apps/studio/CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,22 @@
44

55
### Patch Changes
66

7+
- **Vercel deployment: Fix POST/PUT/PATCH API requests timing out**
8+
9+
The outer→inner Hono app delegation pattern (`inner.fetch(c.req.raw)`)
10+
passed the `@hono/node-server` pseudo-Request directly to the inner
11+
ObjectStack Hono app. The pseudo-Request lazily materialises its body
12+
from the Node.js `IncomingMessage` via `Readable.toWeb()` the first
13+
time `.json()` / `.text()` is called. On Vercel's serverless runtime
14+
the `IncomingMessage` stream can be in a half-consumed state by the
15+
time the inner app reads it, causing body reads to hang and the
16+
function to time out — while GET requests (no body) worked fine.
17+
18+
Fix: for POST/PUT/PATCH/DELETE the outer handler now eagerly buffers
19+
the request body with `arrayBuffer()` while the `IncomingMessage` is
20+
still in a known-good state, then creates a plain `Request` for the
21+
inner app. GET/HEAD requests continue to use the direct pass-through.
22+
723
- Remove `functions` block from `vercel.json` to fix deployment error:
824
"The pattern 'api/index.js' defined in `functions` doesn't match any
925
Serverless Functions inside the `api` directory."

apps/studio/server/index.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -283,7 +283,35 @@ app.all('*', async (c) => {
283283

284284
try {
285285
const inner = await ensureApp();
286-
return await inner.fetch(c.req.raw);
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);
287315
} catch (err: any) {
288316
console.error('[Vercel] Handler error:', err?.message || err);
289317
return c.json(

packages/adapters/hono/src/hono.test.ts

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -657,6 +657,83 @@ describe('createHonoApp', () => {
657657
);
658658
});
659659

660+
it('POST /api/v1/data/account parses JSON body through outer→inner delegation', async () => {
661+
const outerApp = createVercelApp();
662+
const body = { name: 'Acme' };
663+
664+
const res = await outerApp.request('/api/v1/data/account', {
665+
method: 'POST',
666+
headers: { 'Content-Type': 'application/json' },
667+
body: JSON.stringify(body),
668+
});
669+
expect(res.status).toBe(200);
670+
expect(mockDispatcher.dispatch).toHaveBeenCalledWith(
671+
'POST',
672+
'/data/account',
673+
body,
674+
expect.any(Object),
675+
expect.objectContaining({ request: expect.anything() }),
676+
'/api/v1',
677+
);
678+
});
679+
680+
it('PUT /api/v1/data/account parses JSON body through outer→inner delegation', async () => {
681+
const outerApp = createVercelApp();
682+
const body = { name: 'Updated' };
683+
684+
const res = await outerApp.request('/api/v1/data/account', {
685+
method: 'PUT',
686+
headers: { 'Content-Type': 'application/json' },
687+
body: JSON.stringify(body),
688+
});
689+
expect(res.status).toBe(200);
690+
expect(mockDispatcher.dispatch).toHaveBeenCalledWith(
691+
'PUT',
692+
'/data/account',
693+
body,
694+
expect.any(Object),
695+
expect.objectContaining({ request: expect.anything() }),
696+
'/api/v1',
697+
);
698+
});
699+
700+
it('PATCH /api/v1/data/account parses JSON body through outer→inner delegation', async () => {
701+
const outerApp = createVercelApp();
702+
const body = { name: 'Patched' };
703+
704+
const res = await outerApp.request('/api/v1/data/account', {
705+
method: 'PATCH',
706+
headers: { 'Content-Type': 'application/json' },
707+
body: JSON.stringify(body),
708+
});
709+
expect(res.status).toBe(200);
710+
expect(mockDispatcher.dispatch).toHaveBeenCalledWith(
711+
'PATCH',
712+
'/data/account',
713+
body,
714+
expect.any(Object),
715+
expect.objectContaining({ request: expect.anything() }),
716+
'/api/v1',
717+
);
718+
});
719+
720+
it('DELETE /api/v1/data/account routes through outer→inner delegation', async () => {
721+
const outerApp = createVercelApp();
722+
723+
const res = await outerApp.request('/api/v1/data/account', {
724+
method: 'DELETE',
725+
});
726+
expect(res.status).toBe(200);
727+
expect(mockDispatcher.dispatch).toHaveBeenCalledWith(
728+
'DELETE',
729+
'/data/account',
730+
undefined,
731+
expect.any(Object),
732+
expect.objectContaining({ request: expect.anything() }),
733+
'/api/v1',
734+
);
735+
});
736+
660737
it('returns 500 with error details when inner app throws', async () => {
661738
const outerApp = new Hono();
662739

@@ -680,6 +757,153 @@ describe('createHonoApp', () => {
680757
});
681758
});
682759

760+
describe('Body-safe Vercel delegation (buffered body forwarding)', () => {
761+
/**
762+
* Validates the body-safe delegation pattern used in
763+
* `apps/studio/server/index.ts` where the outer handler buffers
764+
* POST/PUT/PATCH bodies and creates a fresh `Request` for the inner app.
765+
* This avoids @hono/node-server's lazy body materialisation which can
766+
* hang on Vercel when the IncomingMessage stream state has changed.
767+
*/
768+
function createBodySafeVercelApp() {
769+
const innerApp = createHonoApp({ kernel: mockKernel, prefix: '/api/v1' });
770+
const outerApp = new Hono();
771+
772+
outerApp.all('*', async (c) => {
773+
const method = c.req.method;
774+
775+
// GET/HEAD have no body — pass through directly
776+
if (method === 'GET' || method === 'HEAD') {
777+
return innerApp.fetch(c.req.raw);
778+
}
779+
780+
// Buffer body and create a fresh Request
781+
const rawReq = c.req.raw;
782+
const body = await rawReq.arrayBuffer();
783+
const forwarded = new Request(rawReq.url, {
784+
method,
785+
headers: rawReq.headers,
786+
body,
787+
});
788+
return innerApp.fetch(forwarded);
789+
});
790+
791+
return outerApp;
792+
}
793+
794+
it('GET requests work without body buffering', async () => {
795+
const outerApp = createBodySafeVercelApp();
796+
797+
const res = await outerApp.request('/api/v1/data/account');
798+
expect(res.status).toBe(200);
799+
expect(mockDispatcher.dispatch).toHaveBeenCalledWith(
800+
'GET',
801+
'/data/account',
802+
undefined,
803+
expect.any(Object),
804+
expect.objectContaining({ request: expect.anything() }),
805+
'/api/v1',
806+
);
807+
});
808+
809+
it('POST body is forwarded correctly via buffered delegation', async () => {
810+
const outerApp = createBodySafeVercelApp();
811+
const body = { name: 'Acme Corp' };
812+
813+
const res = await outerApp.request('/api/v1/data/account', {
814+
method: 'POST',
815+
headers: { 'Content-Type': 'application/json' },
816+
body: JSON.stringify(body),
817+
});
818+
expect(res.status).toBe(200);
819+
expect(mockDispatcher.dispatch).toHaveBeenCalledWith(
820+
'POST',
821+
'/data/account',
822+
body,
823+
expect.any(Object),
824+
expect.objectContaining({ request: expect.anything() }),
825+
'/api/v1',
826+
);
827+
});
828+
829+
it('PUT body is forwarded correctly via buffered delegation', async () => {
830+
const outerApp = createBodySafeVercelApp();
831+
const body = { name: 'Updated Corp' };
832+
833+
const res = await outerApp.request('/api/v1/data/account', {
834+
method: 'PUT',
835+
headers: { 'Content-Type': 'application/json' },
836+
body: JSON.stringify(body),
837+
});
838+
expect(res.status).toBe(200);
839+
expect(mockDispatcher.dispatch).toHaveBeenCalledWith(
840+
'PUT',
841+
'/data/account',
842+
body,
843+
expect.any(Object),
844+
expect.objectContaining({ request: expect.anything() }),
845+
'/api/v1',
846+
);
847+
});
848+
849+
it('PATCH body is forwarded correctly via buffered delegation', async () => {
850+
const outerApp = createBodySafeVercelApp();
851+
const body = { status: 'active' };
852+
853+
const res = await outerApp.request('/api/v1/data/account', {
854+
method: 'PATCH',
855+
headers: { 'Content-Type': 'application/json' },
856+
body: JSON.stringify(body),
857+
});
858+
expect(res.status).toBe(200);
859+
expect(mockDispatcher.dispatch).toHaveBeenCalledWith(
860+
'PATCH',
861+
'/data/account',
862+
body,
863+
expect.any(Object),
864+
expect.objectContaining({ request: expect.anything() }),
865+
'/api/v1',
866+
);
867+
});
868+
869+
it('DELETE without body works via buffered delegation', async () => {
870+
const outerApp = createBodySafeVercelApp();
871+
872+
const res = await outerApp.request('/api/v1/data/account', {
873+
method: 'DELETE',
874+
});
875+
expect(res.status).toBe(200);
876+
expect(mockDispatcher.dispatch).toHaveBeenCalledWith(
877+
'DELETE',
878+
'/data/account',
879+
undefined,
880+
expect.any(Object),
881+
expect.objectContaining({ request: expect.anything() }),
882+
'/api/v1',
883+
);
884+
});
885+
886+
it('POST with empty body defaults to {} via buffered delegation', async () => {
887+
const outerApp = createBodySafeVercelApp();
888+
889+
const res = await outerApp.request('/api/v1/data/account', {
890+
method: 'POST',
891+
headers: { 'Content-Type': 'application/json' },
892+
body: '',
893+
});
894+
expect(res.status).toBe(200);
895+
// Empty body falls back to {} via .catch(() => ({})) in the adapter
896+
expect(mockDispatcher.dispatch).toHaveBeenCalledWith(
897+
'POST',
898+
'/data/account',
899+
{},
900+
expect.any(Object),
901+
expect.objectContaining({ request: expect.anything() }),
902+
'/api/v1',
903+
);
904+
});
905+
});
906+
683907
describe('Vercel deployment endpoint smoke tests', () => {
684908
/**
685909
* These tests validate that the two key deployment-health endpoints

0 commit comments

Comments
 (0)