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
14 changes: 10 additions & 4 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,11 +7,17 @@ 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
`/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
Expand Down
11 changes: 11 additions & 0 deletions apps/studio/CHANGELOG.md
Original file line numberDiff line numberDiff line change
@@ -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
Expand Down
49 changes: 0 additions & 49 deletions apps/studio/api/[...path].ts

This file was deleted.

6 changes: 3 additions & 3 deletions apps/studio/api/_kernel.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -33,7 +33,7 @@ let _bootPromise: Promise<ObjectKernel> | 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<ObjectKernel> {
export async function ensureKernel(): Promise<ObjectKernel> {
if (_kernel) return _kernel;

// Return the in-flight boot if one is already running
Expand DownExpand Up@@ -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<Hono> {
export async function ensureApp(): Promise<Hono> {
if (_app) return _app;

const kernel = await bootKernel();
const kernel = await ensureKernel();
_app = createHonoApp({ kernel, prefix: '/api/v1' });
return _app;
}
40 changes: 40 additions & 0 deletions apps/studio/api/index.ts
Original file line numberDiff line numberDiff line change
@@ -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,
);
Comment on lines +31 to +36
}
});

export default handle(app);
1 change: 1 addition & 0 deletions apps/studio/vercel.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,6 +11,7 @@
}
},
"rewrites": [
{ "source": "/api/(.*)", "destination": "/api" },
{ "source": "/((?!api/).*)", "destination": "/index.html" }
]
}
23 changes: 12 additions & 11 deletions content/docs/guides/deployment-vercel.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -282,7 +282,7 @@ import appConfig from '../objectstack.config';
let _kernel: ObjectKernel | null = null;
let _app: Hono | null = null;

async function bootKernel(): Promise<ObjectKernel> {
async function ensureKernel(): Promise<ObjectKernel> {
if (_kernel) return _kernel;
_kernel = new ObjectKernel();
await _kernel.use(new ObjectQLPlugin());
Expand All@@ -292,26 +292,26 @@ async function bootKernel(): Promise<ObjectKernel> {
return _kernel;
}

export async function getApp(): Promise<Hono> {
export async function ensureApp(): Promise<Hono> {
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);
});

Expand All@@ -333,13 +333,14 @@ export default handle(app);
}
},
"rewrites": [
{ "source": "/api/(.*)", "destination": "/api" },
{ "source": "/((?!api/).*)", "destination": "/index.html" }
]
}
```

<Callout type="info">
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.
</Callout>

---
Expand DownExpand Up@@ -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

Expand Down
100 changes: 64 additions & 36 deletions packages/adapters/hono/src/hono.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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(
Expand All@@ -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);
Expand All@@ -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);
Expand All@@ -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',
Expand All@@ -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');
Expand All@@ -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));
Comment on lines +648 to +650
});

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);
});
});
});
Loading