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: 14 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### 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.
- **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
is validated after bootstrap with automatic reattachment if lost.
- **Broker-resilient metadata handler** — `HttpDispatcher.handleMetadata()` no longer requires
a broker upfront. It tries the protocol service and ObjectQL registry first, falling back to
the broker only when available. Serverless/lightweight setups without a full message broker
now return proper metadata responses instead of throwing 500 errors.

### Added
- **Studio system objects visibility** — Studio now auto-registers all system objects (sys_user,
sys_role, sys_audit_log, etc.) from plugin-auth, plugin-security, and plugin-audit at kernel
Expand Down
23 changes: 21 additions & 2 deletions apps/studio/api/[...path].ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,8 +23,27 @@ import { getApp } from './_kernel';
const app = new Hono();

app.all('/*', async (c) => {
const inner = await getApp();
return inner.fetch(c.req.raw);
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);
56 changes: 43 additions & 13 deletions apps/studio/api/_kernel.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,31 +24,61 @@ import studioConfig from '../objectstack.config';
let _kernel: ObjectKernel | null = null;
let _app: Hono | null = null;

// Initialisation lock — prevents concurrent cold-start boots from racing.
let _bootPromise: Promise<ObjectKernel> | null = null;

/**
* Boot the ObjectStack kernel (one-time cold-start cost).
*
* 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> {
if (_kernel) return _kernel;

console.log('[Vercel] Booting ObjectStack Kernel (server mode)...');
// Return the in-flight boot if one is already running
if (_bootPromise) return _bootPromise;

_bootPromise = (async () => {
console.log('[Vercel] Booting ObjectStack Kernel (server mode)...');

try {
const kernel = new ObjectKernel();

const kernel = new ObjectKernel();
await kernel.use(new ObjectQLPlugin());
await kernel.use(new DriverPlugin(new InMemoryDriver(), 'memory'));
await kernel.use(new AppPlugin(studioConfig));

await kernel.use(new ObjectQLPlugin());
await kernel.use(new DriverPlugin(new InMemoryDriver(), 'memory'));
await kernel.use(new AppPlugin(studioConfig));
// Broker shim — bridges HttpDispatcher → ObjectQL engine
(kernel as any).broker = createBrokerShim(kernel);

// Broker shim — bridges HttpDispatcher → ObjectQL engine
(kernel as any).broker = createBrokerShim(kernel);
await kernel.bootstrap();

await kernel.bootstrap();
// Validate broker attachment
if (!(kernel as any).broker) {
console.warn('[Vercel] Broker shim lost during bootstrap — reattaching.');
(kernel as any).broker = createBrokerShim(kernel);
}

// Seed data from config (non-fatal — the kernel is usable without seed data)
try {
await seedData(kernel, [studioConfig]);
} catch (seedErr: any) {
console.warn('[Vercel] Seed data failed (non-fatal):', seedErr?.message || seedErr);
}

// Seed data from config
await seedData(kernel, [studioConfig]);
_kernel = kernel;
console.log('[Vercel] Kernel ready.');
return kernel;
} catch (err) {
// Clear the lock so the next request can retry
_bootPromise = null;
console.error('[Vercel] Kernel boot failed:', (err as any)?.message || err);
throw err;
}
})();

_kernel = kernel;
console.log('[Vercel] Kernel ready.');
return kernel;
return _bootPromise;
}

/**
Expand Down
106 changes: 106 additions & 0 deletions packages/adapters/hono/src/hono.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -544,4 +544,110 @@ describe('createHonoApp', () => {
expect(json.id).toBe(1);
});
});

describe('Vercel Delegation Pattern (inner.fetch)', () => {
it('works when an outer Hono app delegates via inner.fetch(c.req.raw)', async () => {
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) => {
return innerApp.fetch(c.req.raw);
});

// 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(
'GET',
'/meta',
undefined,
expect.any(Object),
expect.objectContaining({ request: expect.anything() }),
);
});

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 res = await outerApp.request('/api/v1/packages');
expect(res.status).toBe(200);
expect(mockDispatcher.dispatch).toHaveBeenCalledWith(
'GET',
'/packages',
undefined,
expect.any(Object),
expect.objectContaining({ request: expect.anything() }),
);
});

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 res = await outerApp.request('/api/v1');
expect(res.status).toBe(200);
const json = await res.json();
expect(json.data).toBeDefined();
expect(mockDispatcher.getDiscoveryInfo).toHaveBeenCalledWith('/api/v1');
});

it('handles path normalisation (strips prefix correctly) through delegation', async () => {
const innerApp = createHonoApp({ kernel: mockKernel, prefix: '/api/v1' });

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);
Comment on lines +604 to +613
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);
expect(mockDispatcher.dispatch).toHaveBeenCalledWith(
'GET',
'/data/account',
undefined,
expect.any(Object),
expect.objectContaining({ request: expect.anything() }),
);
});

it('returns 500 with error details when inner app throws', async () => {
const outerApp = new Hono();

outerApp.all('/*', async (c) => {
try {
// Simulate a kernel boot failure
throw new Error('Kernel boot failed');
} catch (err: any) {
return c.json(
{ success: false, error: { message: err.message, code: 500 } },
500,
);
}
});

const res = await outerApp.request('/api/v1/meta');
expect(res.status).toBe(500);
const json = await res.json();
expect(json.success).toBe(false);
expect(json.error.message).toBe('Kernel boot failed');
});
});
});
100 changes: 100 additions & 0 deletions packages/runtime/src/http-dispatcher.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1208,4 +1208,104 @@ describe('HttpDispatcher', () => {
expect(result.response?.status).toBe(200);
});
});

describe('handleMetadata without broker (serverless degradation)', () => {
let brokerlessKernel: any;
let brokerlessDispatcher: HttpDispatcher;

beforeEach(() => {
// Kernel with NO broker — simulates a lightweight/serverless setup
// where only the protocol service and/or ObjectQL registry are available.
brokerlessKernel = {
broker: null,
context: {
getService: vi.fn().mockReturnValue(null),
},
};
brokerlessDispatcher = new HttpDispatcher(brokerlessKernel);
});

it('GET /meta should return default types when broker is missing', async () => {
const context = { request: {} };
const result = await brokerlessDispatcher.handleMetadata('', context, 'GET');
expect(result.handled).toBe(true);
expect(result.response?.status).toBe(200);
expect(result.response?.body?.data?.types).toContain('object');
});

it('GET /meta/types should return default types when broker is missing', async () => {
const context = { request: {} };
const result = await brokerlessDispatcher.handleMetadata('/types', context, 'GET');
expect(result.handled).toBe(true);
expect(result.response?.status).toBe(200);
expect(result.response?.body?.data?.types).toContain('object');
});

it('GET /meta/objects should use ObjectQL registry when broker is missing', async () => {
const mockRegistry = {
getAllObjects: vi.fn().mockReturnValue([{ name: 'account' }]),
getObject: vi.fn(),
};
brokerlessKernel.context.getService = vi.fn().mockImplementation((name: string) => {
if (name === 'objectql') return { registry: mockRegistry };
return null;
});

const context = { request: {} };
const result = await brokerlessDispatcher.handleMetadata('/objects', context, 'GET');
expect(result.handled).toBe(true);
expect(result.response?.status).toBe(200);
expect(mockRegistry.getAllObjects).toHaveBeenCalled();
});

it('GET /meta/objects/:name should use ObjectQL registry when broker is missing', async () => {
const mockRegistry = {
registry: {
getObject: vi.fn().mockReturnValue({ name: 'account', fields: {} }),
},
};
brokerlessKernel.context.getService = vi.fn().mockImplementation((name: string) => {
if (name === 'objectql') return mockRegistry;
return null;
});

const context = { request: {} };
const result = await brokerlessDispatcher.handleMetadata('/objects/account', context, 'GET');
expect(result.handled).toBe(true);
expect(result.response?.status).toBe(200);
expect(mockRegistry.registry.getObject).toHaveBeenCalledWith('account');
});

it('GET /meta/:type/:name/published should return 404 when broker is missing and metadata service is unavailable', async () => {
const context = { request: {} };
const result = await brokerlessDispatcher.handleMetadata('/object/my_obj/published', context, 'GET');
expect(result.handled).toBe(true);
expect(result.response?.status).toBe(404);
});

it('PUT /meta/:type/:name should return 501 when broker is missing and protocol is unavailable', async () => {
const context = { request: {} };
const body = { label: 'Test' };
const result = await brokerlessDispatcher.handleMetadata('/objects/my_obj', context, 'PUT', body);
expect(result.handled).toBe(true);
expect(result.response?.status).toBe(501);
});

it('should use protocol service even when broker is missing', async () => {
const mockProtocolLocal = {
getMetaTypes: vi.fn().mockResolvedValue({ types: ['custom_type'] }),
};
brokerlessKernel.context.getService = vi.fn().mockImplementation((name: string) => {
if (name === 'protocol') return mockProtocolLocal;
return null;
});

const context = { request: {} };
const result = await brokerlessDispatcher.handleMetadata('/types', context, 'GET');
expect(result.handled).toBe(true);
expect(result.response?.status).toBe(200);
expect(mockProtocolLocal.getMetaTypes).toHaveBeenCalled();
expect(result.response?.body?.data?.types).toContain('custom_type');
});
});
});
Loading
Loading