From acd0de658d06fe09dae1f90b71fc5a73158c5377 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Mar 2026 02:34:44 +0000 Subject: [PATCH 1/3] Initial plan From 2c196fc86b5e6df6a3d0212bc4f331c3ac8003f7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Mar 2026 02:41:57 +0000 Subject: [PATCH 2/3] refactor: migrate API layer from [..path].ts catch-all to Hono + Vercel adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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> --- CHANGELOG.md | 7 ++ apps/studio/CHANGELOG.md | 11 +++ apps/studio/api/[...path].ts | 49 ----------- apps/studio/api/_kernel.ts | 6 +- apps/studio/api/index.ts | 40 +++++++++ apps/studio/vercel.json | 1 + content/docs/guides/deployment-vercel.mdx | 23 ++--- packages/adapters/hono/src/hono.test.ts | 100 ++++++++++++++-------- 8 files changed, 138 insertions(+), 99 deletions(-) delete mode 100644 apps/studio/api/[...path].ts create mode 100644 apps/studio/api/index.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 314aa3396f..87c71b8df0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed +- **Migrate API layer to Hono + Vercel Node adapter** — Replaced the vestigial Next.js-style + `api/[...path].ts` catch-all with a proper `api/index.ts` Hono entrypoint using `handle(app)` + from `hono/vercel`. Vercel routes now use a rewrite rule (`/api/*` → `/api`) for native Hono + routing, eliminating path-normalisation hacks and catch-all bundling pitfalls. Kernel boot + remains lazy (cold-start only) via `ensureApp()` / `ensureKernel()` in `_kernel.ts`. + ### Fixed - **Vercel serverless 404 fix** — `api/[...path].ts` now normalises request paths and includes robust error handling, preventing silent 404s when the Vercel runtime strips or alters the diff --git a/apps/studio/CHANGELOG.md b/apps/studio/CHANGELOG.md index 825b89c1a2..1cedef0348 100644 --- a/apps/studio/CHANGELOG.md +++ b/apps/studio/CHANGELOG.md @@ -1,5 +1,16 @@ # @objectstack/studio +## 3.2.9 + +### Minor Changes + +- Migrate Vercel API entrypoint from `api/[...path].ts` to `api/index.ts` (Hono + Vercel Node adapter) + - Replace Next.js-style catch-all with a proper Hono app exported via `handle(app)` from `hono/vercel` + - Add `/api/*` → `/api` rewrite in `vercel.json` for native Hono routing + - Rename `getApp()` → `ensureApp()` and export `ensureKernel()` from `_kernel.ts` + - Remove path-normalisation workaround (no longer needed with Vercel rewrites) + - Add deployment smoke tests for `/api/v1/meta` and `/api/v1/packages` + ## 3.2.8 ### Minor Changes diff --git a/apps/studio/api/[...path].ts b/apps/studio/api/[...path].ts deleted file mode 100644 index 12befec1fc..0000000000 --- a/apps/studio/api/[...path].ts +++ /dev/null @@ -1,49 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * Vercel Serverless Catch-All Route - * - * Handles all /api/* requests using the ObjectStack Hono application. - * The Hono app provides discovery, data CRUD, metadata, auth, and more - * endpoints under the /api/v1 prefix. - * - * File name convention: Vercel maps `[...path].ts` to a catch-all route - * that matches /api and any sub-paths (/api/v1/data/task, etc.). - */ - -import { Hono } from 'hono'; -import { handle } from 'hono/vercel'; -import { getApp } from './_kernel'; - -/** - * Outer Hono app — synchronously created so it can be wrapped by handle(). - * Delegates all requests to the lazy-initialized inner app (which boots - * the ObjectStack kernel on first invocation). - */ -const app = new Hono(); - -app.all('/*', async (c) => { - try { - const inner = await getApp(); - - // Normalise the request URL so the inner Hono app always sees the - // full /api/… prefix. Vercel's Node.js runtime preserves it, but - // some runtimes or proxies may strip the function directory prefix. - const url = new URL(c.req.url); - if (!url.pathname.startsWith('/api')) { - url.pathname = '/api' + url.pathname; - const request = new Request(url.toString(), c.req.raw); - return await inner.fetch(request); - } - - return await inner.fetch(c.req.raw); - } catch (err: any) { - console.error('[Vercel] Handler error:', err?.message || err); - return c.json( - { success: false, error: { message: err?.message || 'Internal Server Error', code: 500 } }, - 500, - ); - } -}); - -export default handle(app); diff --git a/apps/studio/api/_kernel.ts b/apps/studio/api/_kernel.ts index fa00f120e0..1b6f07f63f 100644 --- a/apps/studio/api/_kernel.ts +++ b/apps/studio/api/_kernel.ts @@ -33,7 +33,7 @@ let _bootPromise: Promise | null = null; * Uses a shared promise so that concurrent requests during a cold start * wait for the same boot sequence rather than starting duplicates. */ -async function bootKernel(): Promise { +export async function ensureKernel(): Promise { if (_kernel) return _kernel; // Return the in-flight boot if one is already running @@ -130,10 +130,10 @@ async function seedData(kernel: ObjectKernel, configs: any[]) { * Get (or create) the Hono application backed by the ObjectStack kernel. * The prefix `/api/v1` matches the client SDK's default API path. */ -export async function getApp(): Promise { +export async function ensureApp(): Promise { if (_app) return _app; - const kernel = await bootKernel(); + const kernel = await ensureKernel(); _app = createHonoApp({ kernel, prefix: '/api/v1' }); return _app; } diff --git a/apps/studio/api/index.ts b/apps/studio/api/index.ts new file mode 100644 index 0000000000..2117555420 --- /dev/null +++ b/apps/studio/api/index.ts @@ -0,0 +1,40 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Vercel Serverless API Entrypoint (Hono + Vercel Node Adapter) + * + * Top-level Hono app that delegates all /api/* requests to the + * ObjectStack Hono application. The kernel boots lazily on the first + * request and persists across warm invocations. + * + * Vercel's `vercel.json` rewrites route all `/api/*` traffic to this + * single function — no catch-all `[...path].ts` is needed. + * + * @see https://hono.dev/docs/getting-started/vercel + */ + +import { Hono } from 'hono'; +import { handle } from 'hono/vercel'; +import { ensureApp } from './_kernel'; + +const app = new Hono(); + +/** + * Delegate every request to the lazily-initialized ObjectStack Hono app. + * `ensureApp()` boots the kernel on the first invocation (cold start) + * and returns the cached instance on subsequent warm invocations. + */ +app.all('*', async (c) => { + 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( + { success: false, error: { message: err?.message || 'Internal Server Error', code: 500 } }, + 500, + ); + } +}); + +export default handle(app); diff --git a/apps/studio/vercel.json b/apps/studio/vercel.json index b650627e1c..9888173290 100644 --- a/apps/studio/vercel.json +++ b/apps/studio/vercel.json @@ -11,6 +11,7 @@ } }, "rewrites": [ + { "source": "/api/(.*)", "destination": "/api" }, { "source": "/((?!api/).*)", "destination": "/index.html" } ] } diff --git a/content/docs/guides/deployment-vercel.mdx b/content/docs/guides/deployment-vercel.mdx index f574775913..c1461a74fa 100644 --- a/content/docs/guides/deployment-vercel.mdx +++ b/content/docs/guides/deployment-vercel.mdx @@ -282,7 +282,7 @@ import appConfig from '../objectstack.config'; let _kernel: ObjectKernel | null = null; let _app: Hono | null = null; -async function bootKernel(): Promise { +async function ensureKernel(): Promise { if (_kernel) return _kernel; _kernel = new ObjectKernel(); await _kernel.use(new ObjectQLPlugin()); @@ -292,26 +292,26 @@ async function bootKernel(): Promise { return _kernel; } -export async function getApp(): Promise { +export async function ensureApp(): Promise { if (_app) return _app; - const kernel = await bootKernel(); + const kernel = await ensureKernel(); _app = createHonoApp({ kernel, prefix: '/api/v1' }); return _app; } ``` -**2. Create the catch-all serverless function** (`api/[...path].ts`): +**2. Create the API entrypoint** (`api/index.ts`): ```typescript -// api/[...path].ts +// api/index.ts import { Hono } from 'hono'; import { handle } from 'hono/vercel'; -import { getApp } from './_kernel'; +import { ensureApp } from './_kernel'; const app = new Hono(); -app.all('/*', async (c) => { - const inner = await getApp(); +app.all('*', async (c) => { + const inner = await ensureApp(); return inner.fetch(c.req.raw); }); @@ -333,13 +333,14 @@ export default handle(app); } }, "rewrites": [ + { "source": "/api/(.*)", "destination": "/api" }, { "source": "/((?!api/).*)", "destination": "/index.html" } ] } ``` -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. +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. --- @@ -381,10 +382,10 @@ This is controlled by the config module which checks (in priority order): ### Server Mode (Recommended) -- [ ] `api/[...path].ts` catch-all serverless function exists +- [ ] `api/index.ts` Hono entrypoint exists with `handle(app)` export - [ ] `api/_kernel.ts` boots the kernel with the correct driver and broker shim - [ ] `vercel.json` sets `VITE_RUNTIME_MODE=server` and `VITE_SERVER_URL=` (empty) -- [ ] Rewrite rule excludes `/api/` paths: `/((?!api/).*)` +- [ ] Rewrite rule routes `/api/*` to `/api` and excludes `/api/` from SPA rewrite - [ ] `DATABASE_URL` is configured in Vercel environment variables (for production drivers) - [ ] CORS is configured if frontend and API are on different origins diff --git a/packages/adapters/hono/src/hono.test.ts b/packages/adapters/hono/src/hono.test.ts index 2fede5a7b3..5310df0967 100644 --- a/packages/adapters/hono/src/hono.test.ts +++ b/packages/adapters/hono/src/hono.test.ts @@ -545,17 +545,24 @@ describe('createHonoApp', () => { }); }); - describe('Vercel Delegation Pattern (inner.fetch)', () => { - it('works when an outer Hono app delegates via inner.fetch(c.req.raw)', async () => { + describe('Vercel Delegation Pattern (api/index.ts → inner.fetch)', () => { + /** + * Helper: creates the same outer→inner delegation pattern used by + * `apps/studio/api/index.ts`. The outer Hono app delegates all + * requests to the inner ObjectStack Hono app via `inner.fetch()`. + */ + function createVercelApp() { const innerApp = createHonoApp({ kernel: mockKernel, prefix: '/api/v1' }); - - // Simulate the Vercel catch-all pattern: outer app wraps inner app const outerApp = new Hono(); - outerApp.all('/*', async (c) => { + outerApp.all('*', async (c) => { return innerApp.fetch(c.req.raw); }); + return outerApp; + } + + it('works when an outer Hono app delegates via inner.fetch(c.req.raw)', async () => { + const outerApp = createVercelApp(); - // Request with the full /api/v1 prefix — should route correctly const res = await outerApp.request('/api/v1/meta'); expect(res.status).toBe(200); expect(mockDispatcher.dispatch).toHaveBeenCalledWith( @@ -568,12 +575,7 @@ describe('createHonoApp', () => { }); it('routes /api/v1/packages through outer→inner delegation', async () => { - const innerApp = createHonoApp({ kernel: mockKernel, prefix: '/api/v1' }); - - const outerApp = new Hono(); - outerApp.all('/*', async (c) => { - return innerApp.fetch(c.req.raw); - }); + const outerApp = createVercelApp(); const res = await outerApp.request('/api/v1/packages'); expect(res.status).toBe(200); @@ -587,12 +589,7 @@ describe('createHonoApp', () => { }); it('routes /api/v1 discovery through outer→inner delegation', async () => { - const innerApp = createHonoApp({ kernel: mockKernel, prefix: '/api/v1' }); - - const outerApp = new Hono(); - outerApp.all('/*', async (c) => { - return innerApp.fetch(c.req.raw); - }); + const outerApp = createVercelApp(); const res = await outerApp.request('/api/v1'); expect(res.status).toBe(200); @@ -601,24 +598,11 @@ describe('createHonoApp', () => { expect(mockDispatcher.getDiscoveryInfo).toHaveBeenCalledWith('/api/v1'); }); - it('handles path normalisation (strips prefix correctly) through delegation', async () => { - const innerApp = createHonoApp({ kernel: mockKernel, prefix: '/api/v1' }); + it('routes /api/v1/data/account through outer→inner delegation', async () => { + const outerApp = createVercelApp(); - const outerApp = new Hono(); - outerApp.all('/*', async (c) => { - // Simulate the normalisation logic from [...path].ts - const url = new URL(c.req.url); - if (!url.pathname.startsWith('/api')) { - url.pathname = '/api' + url.pathname; - const request = new Request(url.toString(), c.req.raw); - return innerApp.fetch(request); - } - return innerApp.fetch(c.req.raw); - }); - - // Request with the full path — should work directly - const res1 = await outerApp.request('/api/v1/data/account'); - expect(res1.status).toBe(200); + const res = await outerApp.request('/api/v1/data/account'); + expect(res.status).toBe(200); expect(mockDispatcher.dispatch).toHaveBeenCalledWith( 'GET', '/data/account', @@ -631,7 +615,7 @@ describe('createHonoApp', () => { it('returns 500 with error details when inner app throws', async () => { const outerApp = new Hono(); - outerApp.all('/*', async (c) => { + outerApp.all('*', async (c) => { try { // Simulate a kernel boot failure throw new Error('Kernel boot failed'); @@ -650,4 +634,48 @@ describe('createHonoApp', () => { expect(json.error.message).toBe('Kernel boot failed'); }); }); + + describe('Vercel deployment endpoint smoke tests', () => { + /** + * These tests validate that the two key deployment-health endpoints + * `/api/v1/meta` and `/api/v1/packages` return 200 OK when routed + * through the Vercel adapter pattern (outer Hono → inner ObjectStack Hono). + */ + let outerApp: Hono; + + beforeEach(() => { + vi.clearAllMocks(); + const innerApp = createHonoApp({ kernel: mockKernel, prefix: '/api/v1' }); + outerApp = new Hono(); + outerApp.all('*', async (c) => innerApp.fetch(c.req.raw)); + }); + + it('GET /api/v1/meta returns 200 OK', async () => { + const res = await outerApp.request('/api/v1/meta'); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.success).toBe(true); + }); + + it('GET /api/v1/meta/object returns 200 OK', async () => { + const res = await outerApp.request('/api/v1/meta/object'); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.success).toBe(true); + }); + + it('GET /api/v1/packages returns 200 OK', async () => { + const res = await outerApp.request('/api/v1/packages'); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.success).toBe(true); + }); + + it('GET /api/v1/packages/:id returns 200 OK', async () => { + const res = await outerApp.request('/api/v1/packages/com.acme.crm'); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.success).toBe(true); + }); + }); }); From 5cd70fb182d5ba3a74d15a7a1cdf741339797279 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Mar 2026 02:44:37 +0000 Subject: [PATCH 3/3] chore: update changelog entry for superseded 404 fix Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- CHANGELOG.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 87c71b8df0..3172c003ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,10 +15,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 remains lazy (cold-start only) via `ensureApp()` / `ensureKernel()` in `_kernel.ts`. ### Fixed -- **Vercel serverless 404 fix** — `api/[...path].ts` now normalises request paths and includes - robust error handling, preventing silent 404s when the Vercel runtime strips or alters the - `/api/` prefix. Cold-start errors are now caught and returned as structured 500 responses - instead of being swallowed. +- **Vercel serverless 404 fix** — The previous `api/[...path].ts` path-normalisation fix is now + superseded by the Hono adapter migration above. The new `api/index.ts` entrypoint combined with + Vercel rewrites (`/api/*` → `/api`) eliminates the routing ambiguity that caused 404s. - **Kernel cold-start race condition** — `api/_kernel.ts` uses a shared boot promise so that concurrent cold-start requests wait for the same initialisation rather than launching duplicate boot sequences. Seed-data failures are treated as non-fatal, and the broker shim