Skip to content

Commit d0c58d6

Browse files
authored
Merge pull request #933 from objectstack-ai/copilot/fix-vercel-deployment-404-errors
2 parents be64bc0 + ee369af commit d0c58d6

6 files changed

Lines changed: 372 additions & 57 deletions

File tree

CHANGELOG.md

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

88
## [Unreleased]
99

10+
### Fixed
11+
- **Vercel serverless 404 fix**`api/[...path].ts` now normalises request paths and includes
12+
robust error handling, preventing silent 404s when the Vercel runtime strips or alters the
13+
`/api/` prefix. Cold-start errors are now caught and returned as structured 500 responses
14+
instead of being swallowed.
15+
- **Kernel cold-start race condition**`api/_kernel.ts` uses a shared boot promise so that
16+
concurrent cold-start requests wait for the same initialisation rather than launching
17+
duplicate boot sequences. Seed-data failures are treated as non-fatal, and the broker shim
18+
is validated after bootstrap with automatic reattachment if lost.
19+
- **Broker-resilient metadata handler**`HttpDispatcher.handleMetadata()` no longer requires
20+
a broker upfront. It tries the protocol service and ObjectQL registry first, falling back to
21+
the broker only when available. Serverless/lightweight setups without a full message broker
22+
now return proper metadata responses instead of throwing 500 errors.
23+
1024
### Added
1125
- **Studio system objects visibility** — Studio now auto-registers all system objects (sys_user,
1226
sys_role, sys_audit_log, etc.) from plugin-auth, plugin-security, and plugin-audit at kernel

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

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,27 @@ import { getApp } from './_kernel';
2323
const app = new Hono();
2424

2525
app.all('/*', async (c) => {
26-
const inner = await getApp();
27-
return inner.fetch(c.req.raw);
26+
try {
27+
const inner = await getApp();
28+
29+
// Normalise the request URL so the inner Hono app always sees the
30+
// full /api/… prefix. Vercel's Node.js runtime preserves it, but
31+
// some runtimes or proxies may strip the function directory prefix.
32+
const url = new URL(c.req.url);
33+
if (!url.pathname.startsWith('/api')) {
34+
url.pathname = '/api' + url.pathname;
35+
const request = new Request(url.toString(), c.req.raw);
36+
return await inner.fetch(request);
37+
}
38+
39+
return await inner.fetch(c.req.raw);
40+
} catch (err: any) {
41+
console.error('[Vercel] Handler error:', err?.message || err);
42+
return c.json(
43+
{ success: false, error: { message: err?.message || 'Internal Server Error', code: 500 } },
44+
500,
45+
);
46+
}
2847
});
2948

3049
export default handle(app);

apps/studio/api/_kernel.ts

Lines changed: 43 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -24,31 +24,61 @@ import studioConfig from '../objectstack.config';
2424
let _kernel: ObjectKernel | null = null;
2525
let _app: Hono | null = null;
2626

27+
// Initialisation lock — prevents concurrent cold-start boots from racing.
28+
let _bootPromise: Promise<ObjectKernel> | null = null;
29+
2730
/**
2831
* Boot the ObjectStack kernel (one-time cold-start cost).
32+
*
33+
* Uses a shared promise so that concurrent requests during a cold start
34+
* wait for the same boot sequence rather than starting duplicates.
2935
*/
3036
async function bootKernel(): Promise<ObjectKernel> {
3137
if (_kernel) return _kernel;
3238

33-
console.log('[Vercel] Booting ObjectStack Kernel (server mode)...');
39+
// Return the in-flight boot if one is already running
40+
if (_bootPromise) return _bootPromise;
41+
42+
_bootPromise = (async () => {
43+
console.log('[Vercel] Booting ObjectStack Kernel (server mode)...');
44+
45+
try {
46+
const kernel = new ObjectKernel();
3447

35-
const kernel = new ObjectKernel();
48+
await kernel.use(new ObjectQLPlugin());
49+
await kernel.use(new DriverPlugin(new InMemoryDriver(), 'memory'));
50+
await kernel.use(new AppPlugin(studioConfig));
3651

37-
await kernel.use(new ObjectQLPlugin());
38-
await kernel.use(new DriverPlugin(new InMemoryDriver(), 'memory'));
39-
await kernel.use(new AppPlugin(studioConfig));
52+
// Broker shim — bridges HttpDispatcher → ObjectQL engine
53+
(kernel as any).broker = createBrokerShim(kernel);
4054

41-
// Broker shim — bridges HttpDispatcher → ObjectQL engine
42-
(kernel as any).broker = createBrokerShim(kernel);
55+
await kernel.bootstrap();
4356

44-
await kernel.bootstrap();
57+
// Validate broker attachment
58+
if (!(kernel as any).broker) {
59+
console.warn('[Vercel] Broker shim lost during bootstrap — reattaching.');
60+
(kernel as any).broker = createBrokerShim(kernel);
61+
}
62+
63+
// Seed data from config (non-fatal — the kernel is usable without seed data)
64+
try {
65+
await seedData(kernel, [studioConfig]);
66+
} catch (seedErr: any) {
67+
console.warn('[Vercel] Seed data failed (non-fatal):', seedErr?.message || seedErr);
68+
}
4569

46-
// Seed data from config
47-
await seedData(kernel, [studioConfig]);
70+
_kernel = kernel;
71+
console.log('[Vercel] Kernel ready.');
72+
return kernel;
73+
} catch (err) {
74+
// Clear the lock so the next request can retry
75+
_bootPromise = null;
76+
console.error('[Vercel] Kernel boot failed:', (err as any)?.message || err);
77+
throw err;
78+
}
79+
})();
4880

49-
_kernel = kernel;
50-
console.log('[Vercel] Kernel ready.');
51-
return kernel;
81+
return _bootPromise;
5282
}
5383

5484
/**

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

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -544,4 +544,110 @@ describe('createHonoApp', () => {
544544
expect(json.id).toBe(1);
545545
});
546546
});
547+
548+
describe('Vercel Delegation Pattern (inner.fetch)', () => {
549+
it('works when an outer Hono app delegates via inner.fetch(c.req.raw)', async () => {
550+
const innerApp = createHonoApp({ kernel: mockKernel, prefix: '/api/v1' });
551+
552+
// Simulate the Vercel catch-all pattern: outer app wraps inner app
553+
const outerApp = new Hono();
554+
outerApp.all('/*', async (c) => {
555+
return innerApp.fetch(c.req.raw);
556+
});
557+
558+
// Request with the full /api/v1 prefix — should route correctly
559+
const res = await outerApp.request('/api/v1/meta');
560+
expect(res.status).toBe(200);
561+
expect(mockDispatcher.dispatch).toHaveBeenCalledWith(
562+
'GET',
563+
'/meta',
564+
undefined,
565+
expect.any(Object),
566+
expect.objectContaining({ request: expect.anything() }),
567+
);
568+
});
569+
570+
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+
});
577+
578+
const res = await outerApp.request('/api/v1/packages');
579+
expect(res.status).toBe(200);
580+
expect(mockDispatcher.dispatch).toHaveBeenCalledWith(
581+
'GET',
582+
'/packages',
583+
undefined,
584+
expect.any(Object),
585+
expect.objectContaining({ request: expect.anything() }),
586+
);
587+
});
588+
589+
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+
});
596+
597+
const res = await outerApp.request('/api/v1');
598+
expect(res.status).toBe(200);
599+
const json = await res.json();
600+
expect(json.data).toBeDefined();
601+
expect(mockDispatcher.getDiscoveryInfo).toHaveBeenCalledWith('/api/v1');
602+
});
603+
604+
it('handles path normalisation (strips prefix correctly) through delegation', async () => {
605+
const innerApp = createHonoApp({ kernel: mockKernel, prefix: '/api/v1' });
606+
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);
622+
expect(mockDispatcher.dispatch).toHaveBeenCalledWith(
623+
'GET',
624+
'/data/account',
625+
undefined,
626+
expect.any(Object),
627+
expect.objectContaining({ request: expect.anything() }),
628+
);
629+
});
630+
631+
it('returns 500 with error details when inner app throws', async () => {
632+
const outerApp = new Hono();
633+
634+
outerApp.all('/*', async (c) => {
635+
try {
636+
// Simulate a kernel boot failure
637+
throw new Error('Kernel boot failed');
638+
} catch (err: any) {
639+
return c.json(
640+
{ success: false, error: { message: err.message, code: 500 } },
641+
500,
642+
);
643+
}
644+
});
645+
646+
const res = await outerApp.request('/api/v1/meta');
647+
expect(res.status).toBe(500);
648+
const json = await res.json();
649+
expect(json.success).toBe(false);
650+
expect(json.error.message).toBe('Kernel boot failed');
651+
});
652+
});
547653
});

packages/runtime/src/http-dispatcher.test.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1208,4 +1208,104 @@ describe('HttpDispatcher', () => {
12081208
expect(result.response?.status).toBe(200);
12091209
});
12101210
});
1211+
1212+
describe('handleMetadata without broker (serverless degradation)', () => {
1213+
let brokerlessKernel: any;
1214+
let brokerlessDispatcher: HttpDispatcher;
1215+
1216+
beforeEach(() => {
1217+
// Kernel with NO broker — simulates a lightweight/serverless setup
1218+
// where only the protocol service and/or ObjectQL registry are available.
1219+
brokerlessKernel = {
1220+
broker: null,
1221+
context: {
1222+
getService: vi.fn().mockReturnValue(null),
1223+
},
1224+
};
1225+
brokerlessDispatcher = new HttpDispatcher(brokerlessKernel);
1226+
});
1227+
1228+
it('GET /meta should return default types when broker is missing', async () => {
1229+
const context = { request: {} };
1230+
const result = await brokerlessDispatcher.handleMetadata('', context, 'GET');
1231+
expect(result.handled).toBe(true);
1232+
expect(result.response?.status).toBe(200);
1233+
expect(result.response?.body?.data?.types).toContain('object');
1234+
});
1235+
1236+
it('GET /meta/types should return default types when broker is missing', async () => {
1237+
const context = { request: {} };
1238+
const result = await brokerlessDispatcher.handleMetadata('/types', context, 'GET');
1239+
expect(result.handled).toBe(true);
1240+
expect(result.response?.status).toBe(200);
1241+
expect(result.response?.body?.data?.types).toContain('object');
1242+
});
1243+
1244+
it('GET /meta/objects should use ObjectQL registry when broker is missing', async () => {
1245+
const mockRegistry = {
1246+
getAllObjects: vi.fn().mockReturnValue([{ name: 'account' }]),
1247+
getObject: vi.fn(),
1248+
};
1249+
brokerlessKernel.context.getService = vi.fn().mockImplementation((name: string) => {
1250+
if (name === 'objectql') return { registry: mockRegistry };
1251+
return null;
1252+
});
1253+
1254+
const context = { request: {} };
1255+
const result = await brokerlessDispatcher.handleMetadata('/objects', context, 'GET');
1256+
expect(result.handled).toBe(true);
1257+
expect(result.response?.status).toBe(200);
1258+
expect(mockRegistry.getAllObjects).toHaveBeenCalled();
1259+
});
1260+
1261+
it('GET /meta/objects/:name should use ObjectQL registry when broker is missing', async () => {
1262+
const mockRegistry = {
1263+
registry: {
1264+
getObject: vi.fn().mockReturnValue({ name: 'account', fields: {} }),
1265+
},
1266+
};
1267+
brokerlessKernel.context.getService = vi.fn().mockImplementation((name: string) => {
1268+
if (name === 'objectql') return mockRegistry;
1269+
return null;
1270+
});
1271+
1272+
const context = { request: {} };
1273+
const result = await brokerlessDispatcher.handleMetadata('/objects/account', context, 'GET');
1274+
expect(result.handled).toBe(true);
1275+
expect(result.response?.status).toBe(200);
1276+
expect(mockRegistry.registry.getObject).toHaveBeenCalledWith('account');
1277+
});
1278+
1279+
it('GET /meta/:type/:name/published should return 404 when broker is missing and metadata service is unavailable', async () => {
1280+
const context = { request: {} };
1281+
const result = await brokerlessDispatcher.handleMetadata('/object/my_obj/published', context, 'GET');
1282+
expect(result.handled).toBe(true);
1283+
expect(result.response?.status).toBe(404);
1284+
});
1285+
1286+
it('PUT /meta/:type/:name should return 501 when broker is missing and protocol is unavailable', async () => {
1287+
const context = { request: {} };
1288+
const body = { label: 'Test' };
1289+
const result = await brokerlessDispatcher.handleMetadata('/objects/my_obj', context, 'PUT', body);
1290+
expect(result.handled).toBe(true);
1291+
expect(result.response?.status).toBe(501);
1292+
});
1293+
1294+
it('should use protocol service even when broker is missing', async () => {
1295+
const mockProtocolLocal = {
1296+
getMetaTypes: vi.fn().mockResolvedValue({ types: ['custom_type'] }),
1297+
};
1298+
brokerlessKernel.context.getService = vi.fn().mockImplementation((name: string) => {
1299+
if (name === 'protocol') return mockProtocolLocal;
1300+
return null;
1301+
});
1302+
1303+
const context = { request: {} };
1304+
const result = await brokerlessDispatcher.handleMetadata('/types', context, 'GET');
1305+
expect(result.handled).toBe(true);
1306+
expect(result.response?.status).toBe(200);
1307+
expect(mockProtocolLocal.getMetaTypes).toHaveBeenCalled();
1308+
expect(result.response?.body?.data?.types).toContain('custom_type');
1309+
});
1310+
});
12111311
});

0 commit comments

Comments
 (0)