Skip to content

Commit 2c196fc

Browse files
Copilothotlong
andcommitted
refactor: migrate API layer from [..path].ts catch-all to Hono + Vercel adapter
- Create api/index.ts with top-level Hono app and handle(app) export - Remove api/[...path].ts (vestigial Next.js-style catch-all) - Rename getApp→ensureApp, export ensureKernel from _kernel.ts - Add /api/* → /api rewrite in vercel.json for native Hono routing - Remove path-normalisation workaround (no longer needed) - Add deployment smoke tests for /api/v1/meta and /api/v1/packages - Update CHANGELOG.md, studio CHANGELOG.md, deployment docs Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent acd0de6 commit 2c196fc

8 files changed

Lines changed: 138 additions & 99 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Changed
11+
- **Migrate API layer to Hono + Vercel Node adapter** — Replaced the vestigial Next.js-style
12+
`api/[...path].ts` catch-all with a proper `api/index.ts` Hono entrypoint using `handle(app)`
13+
from `hono/vercel`. Vercel routes now use a rewrite rule (`/api/*``/api`) for native Hono
14+
routing, eliminating path-normalisation hacks and catch-all bundling pitfalls. Kernel boot
15+
remains lazy (cold-start only) via `ensureApp()` / `ensureKernel()` in `_kernel.ts`.
16+
1017
### Fixed
1118
- **Vercel serverless 404 fix**`api/[...path].ts` now normalises request paths and includes
1219
robust error handling, preventing silent 404s when the Vercel runtime strips or alters the

apps/studio/CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
11
# @objectstack/studio
22

3+
## 3.2.9
4+
5+
### Minor Changes
6+
7+
- Migrate Vercel API entrypoint from `api/[...path].ts` to `api/index.ts` (Hono + Vercel Node adapter)
8+
- Replace Next.js-style catch-all with a proper Hono app exported via `handle(app)` from `hono/vercel`
9+
- Add `/api/*``/api` rewrite in `vercel.json` for native Hono routing
10+
- Rename `getApp()``ensureApp()` and export `ensureKernel()` from `_kernel.ts`
11+
- Remove path-normalisation workaround (no longer needed with Vercel rewrites)
12+
- Add deployment smoke tests for `/api/v1/meta` and `/api/v1/packages`
13+
314
## 3.2.8
415

516
### Minor Changes

apps/studio/api/[...path].ts

Lines changed: 0 additions & 49 deletions
This file was deleted.

apps/studio/api/_kernel.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ let _bootPromise: Promise<ObjectKernel> | null = null;
3333
* Uses a shared promise so that concurrent requests during a cold start
3434
* wait for the same boot sequence rather than starting duplicates.
3535
*/
36-
async function bootKernel(): Promise<ObjectKernel> {
36+
export async function ensureKernel(): Promise<ObjectKernel> {
3737
if (_kernel) return _kernel;
3838

3939
// Return the in-flight boot if one is already running
@@ -130,10 +130,10 @@ async function seedData(kernel: ObjectKernel, configs: any[]) {
130130
* Get (or create) the Hono application backed by the ObjectStack kernel.
131131
* The prefix `/api/v1` matches the client SDK's default API path.
132132
*/
133-
export async function getApp(): Promise<Hono> {
133+
export async function ensureApp(): Promise<Hono> {
134134
if (_app) return _app;
135135

136-
const kernel = await bootKernel();
136+
const kernel = await ensureKernel();
137137
_app = createHonoApp({ kernel, prefix: '/api/v1' });
138138
return _app;
139139
}

apps/studio/api/index.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Vercel Serverless API Entrypoint (Hono + Vercel Node Adapter)
5+
*
6+
* Top-level Hono app that delegates all /api/* requests to the
7+
* ObjectStack Hono application. The kernel boots lazily on the first
8+
* request and persists across warm invocations.
9+
*
10+
* Vercel's `vercel.json` rewrites route all `/api/*` traffic to this
11+
* single function — no catch-all `[...path].ts` is needed.
12+
*
13+
* @see https://hono.dev/docs/getting-started/vercel
14+
*/
15+
16+
import { Hono } from 'hono';
17+
import { handle } from 'hono/vercel';
18+
import { ensureApp } from './_kernel';
19+
20+
const app = new Hono();
21+
22+
/**
23+
* Delegate every request to the lazily-initialized ObjectStack Hono app.
24+
* `ensureApp()` boots the kernel on the first invocation (cold start)
25+
* and returns the cached instance on subsequent warm invocations.
26+
*/
27+
app.all('*', async (c) => {
28+
try {
29+
const inner = await ensureApp();
30+
return await inner.fetch(c.req.raw);
31+
} catch (err: any) {
32+
console.error('[Vercel] Handler error:', err?.message || err);
33+
return c.json(
34+
{ success: false, error: { message: err?.message || 'Internal Server Error', code: 500 } },
35+
500,
36+
);
37+
}
38+
});
39+
40+
export default handle(app);

apps/studio/vercel.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
}
1212
},
1313
"rewrites": [
14+
{ "source": "/api/(.*)", "destination": "/api" },
1415
{ "source": "/((?!api/).*)", "destination": "/index.html" }
1516
]
1617
}

content/docs/guides/deployment-vercel.mdx

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -282,7 +282,7 @@ import appConfig from '../objectstack.config';
282282
let _kernel: ObjectKernel | null = null;
283283
let _app: Hono | null = null;
284284

285-
async function bootKernel(): Promise<ObjectKernel> {
285+
async function ensureKernel(): Promise<ObjectKernel> {
286286
if (_kernel) return _kernel;
287287
_kernel = new ObjectKernel();
288288
await _kernel.use(new ObjectQLPlugin());
@@ -292,26 +292,26 @@ async function bootKernel(): Promise<ObjectKernel> {
292292
return _kernel;
293293
}
294294

295-
export async function getApp(): Promise<Hono> {
295+
export async function ensureApp(): Promise<Hono> {
296296
if (_app) return _app;
297-
const kernel = await bootKernel();
297+
const kernel = await ensureKernel();
298298
_app = createHonoApp({ kernel, prefix: '/api/v1' });
299299
return _app;
300300
}
301301
```
302302

303-
**2. Create the catch-all serverless function** (`api/[...path].ts`):
303+
**2. Create the API entrypoint** (`api/index.ts`):
304304

305305
```typescript
306-
// api/[...path].ts
306+
// api/index.ts
307307
import { Hono } from 'hono';
308308
import { handle } from 'hono/vercel';
309-
import { getApp } from './_kernel';
309+
import { ensureApp } from './_kernel';
310310

311311
const app = new Hono();
312312

313-
app.all('/*', async (c) => {
314-
const inner = await getApp();
313+
app.all('*', async (c) => {
314+
const inner = await ensureApp();
315315
return inner.fetch(c.req.raw);
316316
});
317317

@@ -333,13 +333,14 @@ export default handle(app);
333333
}
334334
},
335335
"rewrites": [
336+
{ "source": "/api/(.*)", "destination": "/api" },
336337
{ "source": "/((?!api/).*)", "destination": "/index.html" }
337338
]
338339
}
339340
```
340341

341342
<Callout type="info">
342-
Setting `VITE_SERVER_URL` to empty string tells the client SDK to use same-origin API calls. The `rewrites` rule excludes `/api/` paths so they reach the serverless function, while all other paths serve the SPA.
343+
Setting `VITE_SERVER_URL` to empty string tells the client SDK to use same-origin API calls. The first rewrite routes all `/api/*` sub-paths to the `api/index.ts` serverless function. The second rewrite excludes `/api/` paths so all other paths serve the SPA.
343344
</Callout>
344345

345346
---
@@ -381,10 +382,10 @@ This is controlled by the config module which checks (in priority order):
381382

382383
### Server Mode (Recommended)
383384

384-
- [ ] `api/[...path].ts` catch-all serverless function exists
385+
- [ ] `api/index.ts` Hono entrypoint exists with `handle(app)` export
385386
- [ ] `api/_kernel.ts` boots the kernel with the correct driver and broker shim
386387
- [ ] `vercel.json` sets `VITE_RUNTIME_MODE=server` and `VITE_SERVER_URL=` (empty)
387-
- [ ] Rewrite rule excludes `/api/` paths: `/((?!api/).*)`
388+
- [ ] Rewrite rule routes `/api/*` to `/api` and excludes `/api/` from SPA rewrite
388389
- [ ] `DATABASE_URL` is configured in Vercel environment variables (for production drivers)
389390
- [ ] CORS is configured if frontend and API are on different origins
390391

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

Lines changed: 64 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -545,17 +545,24 @@ describe('createHonoApp', () => {
545545
});
546546
});
547547

548-
describe('Vercel Delegation Pattern (inner.fetch)', () => {
549-
it('works when an outer Hono app delegates via inner.fetch(c.req.raw)', async () => {
548+
describe('Vercel Delegation Pattern (api/index.ts → inner.fetch)', () => {
549+
/**
550+
* Helper: creates the same outer→inner delegation pattern used by
551+
* `apps/studio/api/index.ts`. The outer Hono app delegates all
552+
* requests to the inner ObjectStack Hono app via `inner.fetch()`.
553+
*/
554+
function createVercelApp() {
550555
const innerApp = createHonoApp({ kernel: mockKernel, prefix: '/api/v1' });
551-
552-
// Simulate the Vercel catch-all pattern: outer app wraps inner app
553556
const outerApp = new Hono();
554-
outerApp.all('/*', async (c) => {
557+
outerApp.all('*', async (c) => {
555558
return innerApp.fetch(c.req.raw);
556559
});
560+
return outerApp;
561+
}
562+
563+
it('works when an outer Hono app delegates via inner.fetch(c.req.raw)', async () => {
564+
const outerApp = createVercelApp();
557565

558-
// Request with the full /api/v1 prefix — should route correctly
559566
const res = await outerApp.request('/api/v1/meta');
560567
expect(res.status).toBe(200);
561568
expect(mockDispatcher.dispatch).toHaveBeenCalledWith(
@@ -568,12 +575,7 @@ describe('createHonoApp', () => {
568575
});
569576

570577
it('routes /api/v1/packages through outer→inner delegation', async () => {
571-
const innerApp = createHonoApp({ kernel: mockKernel, prefix: '/api/v1' });
572-
573-
const outerApp = new Hono();
574-
outerApp.all('/*', async (c) => {
575-
return innerApp.fetch(c.req.raw);
576-
});
578+
const outerApp = createVercelApp();
577579

578580
const res = await outerApp.request('/api/v1/packages');
579581
expect(res.status).toBe(200);
@@ -587,12 +589,7 @@ describe('createHonoApp', () => {
587589
});
588590

589591
it('routes /api/v1 discovery through outer→inner delegation', async () => {
590-
const innerApp = createHonoApp({ kernel: mockKernel, prefix: '/api/v1' });
591-
592-
const outerApp = new Hono();
593-
outerApp.all('/*', async (c) => {
594-
return innerApp.fetch(c.req.raw);
595-
});
592+
const outerApp = createVercelApp();
596593

597594
const res = await outerApp.request('/api/v1');
598595
expect(res.status).toBe(200);
@@ -601,24 +598,11 @@ describe('createHonoApp', () => {
601598
expect(mockDispatcher.getDiscoveryInfo).toHaveBeenCalledWith('/api/v1');
602599
});
603600

604-
it('handles path normalisation (strips prefix correctly) through delegation', async () => {
605-
const innerApp = createHonoApp({ kernel: mockKernel, prefix: '/api/v1' });
601+
it('routes /api/v1/data/account through outer→inner delegation', async () => {
602+
const outerApp = createVercelApp();
606603

607-
const outerApp = new Hono();
608-
outerApp.all('/*', async (c) => {
609-
// Simulate the normalisation logic from [...path].ts
610-
const url = new URL(c.req.url);
611-
if (!url.pathname.startsWith('/api')) {
612-
url.pathname = '/api' + url.pathname;
613-
const request = new Request(url.toString(), c.req.raw);
614-
return innerApp.fetch(request);
615-
}
616-
return innerApp.fetch(c.req.raw);
617-
});
618-
619-
// Request with the full path — should work directly
620-
const res1 = await outerApp.request('/api/v1/data/account');
621-
expect(res1.status).toBe(200);
604+
const res = await outerApp.request('/api/v1/data/account');
605+
expect(res.status).toBe(200);
622606
expect(mockDispatcher.dispatch).toHaveBeenCalledWith(
623607
'GET',
624608
'/data/account',
@@ -631,7 +615,7 @@ describe('createHonoApp', () => {
631615
it('returns 500 with error details when inner app throws', async () => {
632616
const outerApp = new Hono();
633617

634-
outerApp.all('/*', async (c) => {
618+
outerApp.all('*', async (c) => {
635619
try {
636620
// Simulate a kernel boot failure
637621
throw new Error('Kernel boot failed');
@@ -650,4 +634,48 @@ describe('createHonoApp', () => {
650634
expect(json.error.message).toBe('Kernel boot failed');
651635
});
652636
});
637+
638+
describe('Vercel deployment endpoint smoke tests', () => {
639+
/**
640+
* These tests validate that the two key deployment-health endpoints
641+
* `/api/v1/meta` and `/api/v1/packages` return 200 OK when routed
642+
* through the Vercel adapter pattern (outer Hono → inner ObjectStack Hono).
643+
*/
644+
let outerApp: Hono;
645+
646+
beforeEach(() => {
647+
vi.clearAllMocks();
648+
const innerApp = createHonoApp({ kernel: mockKernel, prefix: '/api/v1' });
649+
outerApp = new Hono();
650+
outerApp.all('*', async (c) => innerApp.fetch(c.req.raw));
651+
});
652+
653+
it('GET /api/v1/meta returns 200 OK', async () => {
654+
const res = await outerApp.request('/api/v1/meta');
655+
expect(res.status).toBe(200);
656+
const json = await res.json();
657+
expect(json.success).toBe(true);
658+
});
659+
660+
it('GET /api/v1/meta/object returns 200 OK', async () => {
661+
const res = await outerApp.request('/api/v1/meta/object');
662+
expect(res.status).toBe(200);
663+
const json = await res.json();
664+
expect(json.success).toBe(true);
665+
});
666+
667+
it('GET /api/v1/packages returns 200 OK', async () => {
668+
const res = await outerApp.request('/api/v1/packages');
669+
expect(res.status).toBe(200);
670+
const json = await res.json();
671+
expect(json.success).toBe(true);
672+
});
673+
674+
it('GET /api/v1/packages/:id returns 200 OK', async () => {
675+
const res = await outerApp.request('/api/v1/packages/com.acme.crm');
676+
expect(res.status).toBe(200);
677+
const json = await res.json();
678+
expect(json.success).toBe(true);
679+
});
680+
});
653681
});

0 commit comments

Comments
 (0)