diff --git a/CHANGELOG.md b/CHANGELOG.md index 02f07faeee..314aa3396f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/apps/studio/api/[...path].ts b/apps/studio/api/[...path].ts index 1264c558ab..12befec1fc 100644 --- a/apps/studio/api/[...path].ts +++ b/apps/studio/api/[...path].ts @@ -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); diff --git a/apps/studio/api/_kernel.ts b/apps/studio/api/_kernel.ts index 8d1a41bcb0..fa00f120e0 100644 --- a/apps/studio/api/_kernel.ts +++ b/apps/studio/api/_kernel.ts @@ -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 | 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 { 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; } /** diff --git a/packages/adapters/hono/src/hono.test.ts b/packages/adapters/hono/src/hono.test.ts index 29a71223ff..2fede5a7b3 100644 --- a/packages/adapters/hono/src/hono.test.ts +++ b/packages/adapters/hono/src/hono.test.ts @@ -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); + 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'); + }); + }); }); diff --git a/packages/runtime/src/http-dispatcher.test.ts b/packages/runtime/src/http-dispatcher.test.ts index a8c583f0f4..1fb22a8f9f 100644 --- a/packages/runtime/src/http-dispatcher.test.ts +++ b/packages/runtime/src/http-dispatcher.test.ts @@ -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'); + }); + }); }); \ No newline at end of file diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index bde6063b28..b15b092648 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -311,7 +311,10 @@ export class HttpDispatcher { * Fallback for backward compat: /metadata (all objects), /metadata/:objectName (get object) */ async handleMetadata(path: string, context: HttpProtocolContext, method?: string, body?: any, query?: any): Promise { - const broker = this.ensureBroker(); + // Broker is used as a fallback — not required upfront. + // This allows metadata to be served when only the protocol service + // or ObjectQL service is available (e.g. lightweight / serverless setups). + const broker = this.kernel.broker ?? null; const parts = path.replace(/^\/+/, '').split('/').filter(Boolean); // GET /metadata/types @@ -323,13 +326,16 @@ export class HttpDispatcher { return { handled: true, response: this.success(result) }; } // Fallback: ask broker for registered types - try { - const data = await broker.call('metadata.types', {}, { request: context.request }); - return { handled: true, response: this.success(data) }; - } catch { - // Last resort: hardcoded defaults - return { handled: true, response: this.success({ types: ['object', 'app', 'plugin'] }) }; + if (broker) { + try { + const data = await broker.call('metadata.types', {}, { request: context.request }); + return { handled: true, response: this.success(data) }; + } catch { + // fall through to hardcoded defaults + } } + // Last resort: hardcoded defaults + return { handled: true, response: this.success({ types: ['object', 'app', 'plugin'] }) }; } // GET /metadata/:type/:name/published → get published version @@ -342,12 +348,15 @@ export class HttpDispatcher { return { handled: true, response: this.success(data) }; } // Broker fallback - try { - const data = await broker.call('metadata.getPublished', { type, name }, { request: context.request }); - return { handled: true, response: this.success(data) }; - } catch (e: any) { - return { handled: true, response: this.error(e.message, 404) }; + if (broker) { + try { + const data = await broker.call('metadata.getPublished', { type, name }, { request: context.request }); + return { handled: true, response: this.success(data) }; + } catch (e: any) { + return { handled: true, response: this.error(e.message, 404) }; + } } + return { handled: true, response: this.error('Not found', 404) }; } // /metadata/:type/:name @@ -369,20 +378,31 @@ export class HttpDispatcher { } // Fallback to broker if protocol not available (legacy) - try { - const data = await broker.call('metadata.saveItem', { type, name, item: body }, { request: context.request }); - return { handled: true, response: this.success(data) }; - } catch (e: any) { - // If broker doesn't support it either - return { handled: true, response: this.error(e.message || 'Save not supported', 501) }; + if (broker) { + try { + const data = await broker.call('metadata.saveItem', { type, name, item: body }, { request: context.request }); + return { handled: true, response: this.success(data) }; + } catch (e: any) { + return { handled: true, response: this.error(e.message || 'Save not supported', 501) }; + } } + return { handled: true, response: this.error('Save not supported', 501) }; } try { // Try specific calls based on type if (type === 'objects' || type === 'object') { - const data = await broker.call('metadata.getObject', { objectName: name }, { request: context.request }); - return { handled: true, response: this.success(data) }; + if (broker) { + const data = await broker.call('metadata.getObject', { objectName: name }, { request: context.request }); + return { handled: true, response: this.success(data) }; + } + // Try ObjectQL service directly when broker is unavailable + const qlService = await this.getObjectQLService(); + if (qlService?.registry) { + const data = qlService.registry.getObject(name); + if (data) return { handled: true, response: this.success(data) }; + } + return { handled: true, response: this.error('Not found', 404) }; } // If type is singular (e.g. 'app'), use it directly @@ -402,9 +422,12 @@ export class HttpDispatcher { } // Generic call for other types if supported via Broker (Legacy) - const method = `metadata.get${this.capitalize(singularType)}`; - const data = await broker.call(method, { name }, { request: context.request }); - return { handled: true, response: this.success(data) }; + if (broker) { + const method = `metadata.get${this.capitalize(singularType)}`; + const data = await broker.call(method, { name }, { request: context.request }); + return { handled: true, response: this.success(data) }; + } + return { handled: true, response: this.error('Not found', 404) }; } catch (e: any) { // Fallback: treat first part as object name if only 1 part (handled below) // But here we are deep in 2 parts. Must be an error. @@ -433,26 +456,46 @@ export class HttpDispatcher { } // Try broker for the type - try { - if (typeOrName === 'objects') { - const data = await broker.call('metadata.objects', { packageId }, { request: context.request }); - return { handled: true, response: this.success(data) }; + if (broker) { + try { + if (typeOrName === 'objects') { + const data = await broker.call('metadata.objects', { packageId }, { request: context.request }); + return { handled: true, response: this.success(data) }; + } + const data = await broker.call(`metadata.${typeOrName}`, { packageId }, { request: context.request }); + if (data !== null && data !== undefined) { + return { handled: true, response: this.success(data) }; + } + } catch { + // Broker doesn't support this action, fall through } - const data = await broker.call(`metadata.${typeOrName}`, { packageId }, { request: context.request }); - if (data !== null && data !== undefined) { + + // Legacy: /metadata/:objectName (treat as single object lookup) + try { + const data = await broker.call('metadata.getObject', { objectName: typeOrName }, { request: context.request }); return { handled: true, response: this.success(data) }; + } catch (e: any) { + return { handled: true, response: this.error(e.message, 404) }; } - } catch { - // Broker doesn't support this action, fall through } - // Legacy: /metadata/:objectName (treat as single object lookup) - try { - const data = await broker.call('metadata.getObject', { objectName: typeOrName }, { request: context.request }); - return { handled: true, response: this.success(data) }; - } catch (e: any) { - return { handled: true, response: this.error(e.message, 404) }; + // No broker — try ObjectQL registry directly for object lookups + const qlService = await this.getObjectQLService(); + if (qlService?.registry) { + if (typeOrName === 'objects') { + const objs = qlService.registry.getAllObjects(packageId); + return { handled: true, response: this.success({ type: 'object', items: objs }) }; + } + // Try listing items of the given type + const items = qlService.registry.listItems?.(typeOrName, packageId); + if (items && items.length > 0) { + return { handled: true, response: this.success({ type: typeOrName, items }) }; + } + // Legacy: treat as object name + const obj = qlService.registry.getObject(typeOrName); + if (obj) return { handled: true, response: this.success(obj) }; } + return { handled: true, response: this.error('Not found', 404) }; } // GET /metadata — return available metadata types @@ -464,12 +507,15 @@ export class HttpDispatcher { return { handled: true, response: this.success(result) }; } // Fallback: ask broker for registered types - try { - const data = await broker.call('metadata.types', {}, { request: context.request }); - return { handled: true, response: this.success(data) }; - } catch { - return { handled: true, response: this.success({ types: ['object', 'app', 'plugin'] }) }; + if (broker) { + try { + const data = await broker.call('metadata.types', {}, { request: context.request }); + return { handled: true, response: this.success(data) }; + } catch { + // fall through to hardcoded defaults + } } + return { handled: true, response: this.success({ types: ['object', 'app', 'plugin'] }) }; } return { handled: false };