Skip to content

Commit 82173f4

Browse files
committed
feat: enhance multi-project support with new route plugins for templates and runtime config
1 parent 7e1953b commit 82173f4

5 files changed

Lines changed: 137 additions & 42 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
## [Unreleased]
99

1010
### Fixed
11+
- `apps/server`: `GET /api/v1/cloud/templates` now returns the full template registry (`blank`, `crm`, `todo`) on Vercel / play.objectstack.ai. Root cause: the dispatcher resolved templates through a `template-seeder` service registered by `MultiProjectPlugin`, and on Vercel cold starts that service registration could be missed by the request handler — the dispatcher then silently returned `{ templates: [], total: 0 }`. Added a `createTemplatesRoutePlugin` that registers `/cloud/templates` directly on `http.server` from a static, module-scope `listTemplates()` snapshot, registered before `DispatcherPlugin`. Local single-project mode is unchanged.
12+
- `packages/runtime/http-dispatcher.ts`: `/cloud/templates` fallback now logs the resolution error via `console.error` instead of swallowing it silently, so the underlying cause is visible in production logs.
13+
- `apps/server/objectstack.config.ts`: `multiProjectPluginProxy.init` now wraps the inner init in try/catch and logs failures, preventing one-off init errors from silently leaving the kernel without `template-seeder` / `kernel-manager`.
1114
- `apps/server`: Multi-project / cloud mode now also serves `GET /api/v1/studio/runtime-config` (returns `{ singleProject: false }`) via a new `createStudioRuntimeConfigPlugin`. Eliminates the 404 the Studio SPA logged on first load when `OBJECTSTACK_MULTI_PROJECT=true` (the default for root `pnpm dev`). Single-project mode is unchanged.
1215

1316
### Added

apps/server/objectstack.config.ts

Lines changed: 32 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,9 @@ import {
4646
AppPlugin,
4747
} from '@objectstack/runtime';
4848
import { createControlPlanePlugins } from './server/control-plane-preset.js';
49-
import { createSingleProjectPlugin, createStudioRuntimeConfigPlugin } from './server/single-project-plugin.js';
49+
import { createSingleProjectPlugin } from './server/single-project-plugin.js';
50+
import { createStudioRuntimeConfigPlugin, createTemplatesRoutePlugin } from './server/multi-project-plugins.js';
51+
import { listTemplates } from './server/templates/registry.js';
5052
import { templateRegistry } from './server/templates/registry.js';
5153

5254
type IDataDriver = Contracts.IDataDriver;
@@ -208,18 +210,30 @@ const multiProjectPluginProxy: any = {
208210
version: '0.0.0',
209211
_impl: null as any,
210212
async init(ctx: any) {
211-
const { driver: controlDriver } = await controlDriverPromise;
212-
const { MultiProjectPlugin: MPlugin } = await import('@objectstack/runtime');
213-
this._impl = new MPlugin({
214-
controlDriver,
215-
basePlugins,
216-
appBundles,
217-
templates: templateRegistry,
218-
maxSize: Number(process.env.OBJECTSTACK_KERNEL_CACHE_SIZE ?? 32),
219-
ttlMs: Number(process.env.OBJECTSTACK_KERNEL_TTL_MS ?? 15 * 60 * 1000),
220-
cacheTTLMs: Number(process.env.OBJECTSTACK_ENV_CACHE_TTL_MS ?? 5 * 60 * 1000),
221-
});
222-
if (this._impl.init) await this._impl.init(ctx);
213+
try {
214+
const { driver: controlDriver } = await controlDriverPromise;
215+
const { MultiProjectPlugin: MPlugin } = await import('@objectstack/runtime');
216+
this._impl = new MPlugin({
217+
controlDriver,
218+
basePlugins,
219+
appBundles,
220+
templates: templateRegistry,
221+
maxSize: Number(process.env.OBJECTSTACK_KERNEL_CACHE_SIZE ?? 32),
222+
ttlMs: Number(process.env.OBJECTSTACK_KERNEL_TTL_MS ?? 15 * 60 * 1000),
223+
cacheTTLMs: Number(process.env.OBJECTSTACK_ENV_CACHE_TTL_MS ?? 5 * 60 * 1000),
224+
});
225+
if (this._impl.init) await this._impl.init(ctx);
226+
} catch (err: any) {
227+
// Surface init failures explicitly. Without this, a Turso connection
228+
// error or service-registration crash silently leaves the kernel
229+
// running with no `template-seeder`, surfacing as `/cloud/templates`
230+
// returning `{ templates: [], total: 0 }` on Vercel/play.objectstack.ai.
231+
// eslint-disable-next-line no-console
232+
console.error('[multiProjectPluginProxy] init failed:', err?.stack ?? err?.message ?? err);
233+
// Do NOT rethrow: a partial init (e.g. driver registered but
234+
// service registration failed) is still better than crashing the
235+
// entire control plane. The logged error is the only diagnostic.
236+
}
223237
},
224238
async start(ctx: any) {
225239
if (this._impl?.start) await this._impl.start(ctx);
@@ -248,6 +262,11 @@ const config = isLocalMode
248262
}),
249263
multiProjectPluginProxy,
250264
createStudioRuntimeConfigPlugin(),
265+
// Static /cloud/templates handler — registered on http.server
266+
// before DispatcherPlugin so it wins. The dispatcher's seeder-
267+
// based path is kept as a fallback for environments that bypass
268+
// this layer (e.g. tests using the dispatcher directly).
269+
createTemplatesRoutePlugin(listTemplates()),
251270
],
252271
api: {
253272
enableProjectScoping: true,
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Multi-Project / Cloud auxiliary route plugins.
5+
*
6+
* Counterpart to `single-project-plugin.ts`. These plugins are registered
7+
* only when the server runs in multi-project mode (`OBJECTSTACK_MULTI_PROJECT=true`,
8+
* e.g. the Vercel deployment behind play.objectstack.ai). They expose two
9+
* Studio-facing endpoints:
10+
*
11+
* - `GET /api/v1/studio/runtime-config` → `{ singleProject: false }`
12+
* so the SPA initRuntimeConfig() handshake doesn't 404 and falls
13+
* through to the org/project picker.
14+
*
15+
* - `GET /api/v1/cloud/templates` → static list from the template
16+
* registry. Bypasses the dispatcher's `template-seeder` service
17+
* indirection, which silently returned `{templates:[],total:0}` on
18+
* Vercel cold starts when MultiProjectPlugin's service registration
19+
* races the request.
20+
*
21+
* Both plugins register their routes on `http.server` *before*
22+
* DispatcherPlugin, so they win the route match.
23+
*/
24+
25+
import type { IHttpServer } from '@objectstack/spec/contracts';
26+
27+
type AnyContext = any;
28+
29+
/**
30+
* Returns `{ singleProject: false }` so the SPA's `initRuntimeConfig()`
31+
* handshake succeeds in multi-project mode without 404 noise.
32+
*/
33+
export function createStudioRuntimeConfigPlugin(options: { apiPrefix?: string } = {}): any {
34+
const prefix = options.apiPrefix ?? '/api/v1';
35+
return {
36+
name: 'com.objectstack.studio.runtime-config',
37+
version: '1.0.0',
38+
init: async (_ctx: AnyContext) => {},
39+
start: async (ctx: AnyContext) => {
40+
let server: IHttpServer | undefined;
41+
try {
42+
server = ctx.getService('http.server') as IHttpServer | undefined;
43+
} catch {
44+
return;
45+
}
46+
if (!server) return;
47+
server.get(`${prefix}/studio/runtime-config`, async (_req: any, res: any) => {
48+
res.json({ singleProject: false });
49+
});
50+
},
51+
stop: async (_ctx: AnyContext) => {},
52+
};
53+
}
54+
55+
/**
56+
* Direct `/cloud/templates` route. Serves a snapshot of the template
57+
* registry captured at config-load time, so SPA always receives the
58+
* full list even before per-project kernels finish provisioning.
59+
*/
60+
export function createTemplatesRoutePlugin(
61+
templates: Array<{ id: string; label: string; description: string; category?: string }>,
62+
options: { apiPrefix?: string } = {},
63+
): any {
64+
const prefix = options.apiPrefix ?? '/api/v1';
65+
const payload = templates.map(({ id, label, description, category }) => ({
66+
id,
67+
label,
68+
description,
69+
category,
70+
}));
71+
return {
72+
name: 'com.objectstack.studio.templates-route',
73+
version: '1.0.0',
74+
init: async (_ctx: AnyContext) => {},
75+
start: async (ctx: AnyContext) => {
76+
let server: IHttpServer | undefined;
77+
try {
78+
server = ctx.getService('http.server') as IHttpServer | undefined;
79+
} catch {
80+
return;
81+
}
82+
if (!server) return;
83+
server.get(`${prefix}/cloud/templates`, async (_req: any, res: any) => {
84+
res.json({
85+
success: true,
86+
data: { templates: payload, total: payload.length },
87+
});
88+
});
89+
},
90+
stop: async (_ctx: AnyContext) => {},
91+
};
92+
}

apps/server/server/single-project-plugin.ts

Lines changed: 2 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
* and `useProjects.ts`. Routes are registered on the shared
1111
* `http.server` service *before* DispatcherPlugin, so they win the
1212
* match against the control-plane `/cloud/projects*` handlers.
13+
*
14+
* Multi-project / cloud-mode counterparts live in `multi-project-plugins.ts`.
1315
*/
1416

1517
import type { IHttpServer } from '@objectstack/spec/contracts';
@@ -141,34 +143,6 @@ export function createSingleProjectPlugin(options: SingleProjectPluginOptions =
141143
};
142144
}
143145

144-
/**
145-
* Multi-project / cloud variant: only registers `/studio/runtime-config`,
146-
* returning `{ singleProject: false }` so the SPA falls through to the
147-
* org/project picker. Avoids a 404 in the Network panel without affecting
148-
* any actual control-plane behaviour.
149-
*/
150-
export function createStudioRuntimeConfigPlugin(options: { apiPrefix?: string } = {}): any {
151-
const prefix = options.apiPrefix ?? '/api/v1';
152-
return {
153-
name: 'com.objectstack.studio.runtime-config',
154-
version: '1.0.0',
155-
init: async (_ctx: AnyContext) => {},
156-
start: async (ctx: AnyContext) => {
157-
let server: IHttpServer | undefined;
158-
try {
159-
server = ctx.getService('http.server') as IHttpServer | undefined;
160-
} catch {
161-
return;
162-
}
163-
if (!server) return;
164-
server.get(`${prefix}/studio/runtime-config`, async (_req: any, res: any) => {
165-
res.json({ singleProject: false });
166-
});
167-
},
168-
stop: async (_ctx: AnyContext) => {},
169-
};
170-
}
171-
172146
function buildLocalProjectRow(orgId: string, projectId: string, userId: string): Record<string, unknown> {
173147
const now = new Date().toISOString();
174148
return {

packages/runtime/src/http-dispatcher.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1488,7 +1488,14 @@ export class HttpDispatcher {
14881488
const seeder: any = await this.resolveService('template-seeder');
14891489
const templates = seeder?.listTemplates?.() ?? [];
14901490
return { handled: true, response: this.success({ templates, total: templates.length }) };
1491-
} catch {
1491+
} catch (err: any) {
1492+
// Don't silently mask — log the real reason. Empty templates
1493+
// here usually means MultiProjectPlugin failed to register
1494+
// `template-seeder` (e.g. control-driver init error).
1495+
try {
1496+
// eslint-disable-next-line no-console
1497+
console.error('[HttpDispatcher] /cloud/templates: failed to resolve template-seeder:', err?.message ?? err);
1498+
} catch { /* noop */ }
14921499
return { handled: true, response: this.success({ templates: [], total: 0 }) };
14931500
}
14941501
}

0 commit comments

Comments
 (0)