Skip to content

Commit c03e0dd

Browse files
committed
feat: add dashboard portal integration to CLI serve command
1 parent f4be7ea commit c03e0dd

4 files changed

Lines changed: 363 additions & 4 deletions

File tree

apps/dashboard/vite.config.ts

Lines changed: 149 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,15 +61,163 @@ const workspaceAliases: Record<string, string> = useObjectUiSource
6161
export default defineConfig({
6262
base: process.env.VITE_BASE || '/_dashboard/',
6363
resolve: {
64-
dedupe: ['react', 'react-dom'],
64+
dedupe: ['react', 'react-dom', 'lucide-react', 'react-router-dom', 'react-router'],
6565
alias: {
6666
...workspaceAliases,
6767
react: path.resolve(__dirname, './node_modules/react'),
6868
'react-dom': path.resolve(__dirname, './node_modules/react-dom'),
69+
// Force a single react-router copy. When @object-ui/* are aliased to
70+
// sibling source, Node's resolver finds react-router-dom in
71+
// ../objectui/node_modules — a different physical install than the one
72+
// dashboard's App.tsx imports from. Two copies = two Router contexts =
73+
// "<Navigate> may be used only in the context of a <Router>".
74+
'react-router-dom': path.resolve(__dirname, './node_modules/react-router-dom'),
75+
// Don't alias 'react-router' as a string (it would intercept the
76+
// 'react-router/dom' subpath import inside react-router-dom). The
77+
// dedupe entry above is enough to ensure a single copy.
78+
// Force a single lucide-react copy. @object-ui/app-shell pulls 0.544.0
79+
// while @object-ui/components and the plugins pull 1.14.0 — letting both
80+
// through produces duplicate icon chunks where one references a stale
81+
// `createLucideIcon` symbol from the main bundle and crashes at runtime.
82+
'lucide-react': path.resolve(
83+
__dirname,
84+
'../../node_modules/.pnpm/lucide-react@1.14.0_react@19.2.5/node_modules/lucide-react',
85+
),
6986
'@': path.resolve(__dirname, './src'),
7087
},
7188
},
7289
plugins: [react()],
90+
build: {
91+
target: 'esnext',
92+
sourcemap: false,
93+
cssCodeSplit: true,
94+
// Don't auto-emit `<link rel="modulepreload">` for every chunk; with
95+
// Vite-Rolldown's per-icon code splitting that would inject 1700+
96+
// preload tags into the HTML, defeating lazy loading. Mirrors
97+
// objectui/apps/console.
98+
modulePreload: false,
99+
commonjsOptions: {
100+
include: [/node_modules/, /packages/],
101+
transformMixedEsModules: true,
102+
},
103+
rollupOptions: {
104+
output: {
105+
// Manual chunking ported verbatim from objectui/apps/console — the
106+
// proven shape that avoids the per-leaf TDZ ("X is not a function")
107+
// crashes triggered by Vite-Rolldown's default dynamic-import
108+
// splitting against @object-ui's static+dynamic widget imports.
109+
manualChunks(id: string) {
110+
// Vendor: React ecosystem
111+
if (
112+
id.includes('node_modules/react/') ||
113+
id.includes('node_modules/react-dom/') ||
114+
id.includes('node_modules/react-router') ||
115+
id.includes('node_modules/scheduler/')
116+
) {
117+
return 'vendor-react';
118+
}
119+
// Vendor: Radix UI primitives
120+
if (id.includes('node_modules/@radix-ui/')) {
121+
return 'vendor-radix';
122+
}
123+
// Vendor: @objectstack/* SDK & spec
124+
if (
125+
id.includes('node_modules/@objectstack/') ||
126+
id.includes('/@objectstack+') ||
127+
id.includes('\\@objectstack+')
128+
) {
129+
return 'vendor-objectstack';
130+
}
131+
// Vendor: Lucide icons — only bundle the runtime helpers; per-icon
132+
// chunks then import a fully-initialized factory.
133+
if (
134+
id.includes('node_modules/lucide-react/dist/lucide-react') ||
135+
id.includes('node_modules/lucide-react/dist/esm/Icon') ||
136+
id.includes('node_modules/lucide-react/dist/esm/createLucideIcon') ||
137+
id.includes('node_modules/lucide-react/dist/esm/defaultAttributes') ||
138+
id.includes('node_modules/lucide-react/dist/esm/shared')
139+
) {
140+
return 'vendor-icons-core';
141+
}
142+
// Vendor: UI utilities
143+
if (
144+
id.includes('node_modules/class-variance-authority/') ||
145+
id.includes('node_modules/clsx/') ||
146+
id.includes('node_modules/tailwind-merge/') ||
147+
id.includes('node_modules/sonner/')
148+
) {
149+
return 'vendor-ui-utils';
150+
}
151+
if (id.includes('node_modules/zod/')) {
152+
return 'vendor-zod';
153+
}
154+
if (
155+
id.includes('node_modules/recharts/') ||
156+
id.includes('node_modules/d3-') ||
157+
id.includes('node_modules/victory-')
158+
) {
159+
return 'vendor-charts';
160+
}
161+
if (id.includes('node_modules/@dnd-kit/')) {
162+
return 'vendor-dndkit';
163+
}
164+
if (
165+
id.includes('node_modules/i18next') ||
166+
id.includes('node_modules/react-i18next/')
167+
) {
168+
return 'vendor-i18n';
169+
}
170+
// @object-ui/core + @object-ui/react (framework)
171+
if (
172+
id.includes('/packages/core/') ||
173+
id.includes('/packages/react/') ||
174+
id.includes('/packages/types/')
175+
) {
176+
return 'framework';
177+
}
178+
// @object-ui/components + @object-ui/fields
179+
if (
180+
id.includes('/packages/components/') ||
181+
id.includes('/packages/fields/') ||
182+
id.includes('/@object-ui/components/') ||
183+
id.includes('/@object-ui/fields/')
184+
) {
185+
return 'ui-components';
186+
}
187+
if (id.includes('/packages/layout/')) {
188+
return 'ui-layout';
189+
}
190+
if (id.includes('/packages/data-objectstack/')) {
191+
return 'data-adapter';
192+
}
193+
if (
194+
id.includes('/packages/auth/') ||
195+
id.includes('/packages/permissions/') ||
196+
id.includes('/packages/tenant/') ||
197+
id.includes('/packages/i18n/')
198+
) {
199+
return 'infrastructure';
200+
}
201+
if (id.includes('/packages/plugin-grid/')) return 'plugin-grid';
202+
if (id.includes('/packages/plugin-form/')) return 'plugin-form';
203+
if (id.includes('/packages/plugin-view/')) return 'plugin-view';
204+
if (
205+
id.includes('/packages/plugin-detail/') ||
206+
id.includes('/packages/plugin-list/') ||
207+
id.includes('/packages/plugin-dashboard/') ||
208+
id.includes('/packages/plugin-report/')
209+
) {
210+
return 'plugins-views';
211+
}
212+
if (id.includes('/packages/plugin-charts/')) return 'plugin-charts';
213+
if (id.includes('/packages/plugin-calendar/')) return 'plugin-calendar';
214+
if (id.includes('/packages/plugin-kanban/')) return 'plugin-kanban';
215+
if (id.includes('/packages/plugin-chatbot/')) return 'plugin-chatbot';
216+
if (id.includes('/packages/app-shell/')) return 'app-shell';
217+
},
218+
},
219+
},
220+
},
73221
server: {
74222
port: parseInt(process.env.VITE_PORT || '5175'),
75223
hmr: hmrConfig,

packages/cli/src/commands/serve.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,12 @@ import {
2929
hasAccountDist,
3030
createAccountStaticPlugin,
3131
} from '../utils/account.js';
32+
import {
33+
DASHBOARD_PATH,
34+
resolveDashboardPath,
35+
hasDashboardDist,
36+
createDashboardStaticPlugin,
37+
} from '../utils/dashboard.js';
3238
import dotenvFlow from 'dotenv-flow';
3339

3440
// Helper to find available port
@@ -590,6 +596,23 @@ export default class Serve extends Command {
590596
} else {
591597
console.warn(chalk.yellow(` ⚠ Account dist not found — run "pnpm --filter @objectstack/account build" first`));
592598
}
599+
600+
// ── Dashboard portal ────────────────────────────────────────
601+
// The opinionated, fork-ready console (`@objectstack/dashboard`)
602+
// mounts under `/_dashboard/` exactly like Studio/Account. It is
603+
// optional — we only mount it when the package resolves and a
604+
// pre-built `dist/` is present, so consumers without dashboard
605+
// installed don't pay any cost.
606+
const dashboardPath = resolveDashboardPath();
607+
if (dashboardPath) {
608+
if (hasDashboardDist(dashboardPath)) {
609+
const dashboardDistPath = path.join(dashboardPath, 'dist');
610+
await kernel.use(createDashboardStaticPlugin(dashboardDistPath, { isDev }));
611+
trackPlugin('DashboardUI');
612+
} else {
613+
console.warn(chalk.yellow(` ⚠ Dashboard dist not found — run "pnpm --filter @objectstack/dashboard build" first`));
614+
}
615+
}
593616
}
594617

595618
// Boot the runtime
@@ -609,6 +632,7 @@ export default class Serve extends Command {
609632
uiEnabled: enableUI,
610633
studioPath: STUDIO_PATH,
611634
accountPath: ACCOUNT_PATH,
635+
dashboardPath: loadedPlugins.includes('DashboardUI') ? DASHBOARD_PATH : undefined,
612636
});
613637

614638
// Kernel already registers SIGINT/SIGTERM handlers during bootstrap.
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Dashboard UI Integration Utilities
5+
*
6+
* Mirrors `studio.ts` / `account.ts` but for the opinionated, fork-ready
7+
* console (`@objectstack/dashboard`). The dashboard SPA is mounted at
8+
* `/_dashboard/` by every deployment that opts in (CLI dev server,
9+
* self-host, Vercel) — exactly the same convention as `_studio` and
10+
* `_account`. The dashboard is built with `base: '/_dashboard/'`, so its
11+
* pre-built `dist/` is served verbatim.
12+
*/
13+
import path from 'path';
14+
import fs from 'fs';
15+
import { createRequire } from 'module';
16+
import { pathToFileURL } from 'url';
17+
18+
// ─── Constants ──────────────────────────────────────────────────────
19+
20+
/** URL mount path for the Dashboard portal inside the ObjectStack server */
21+
export const DASHBOARD_PATH = '/_dashboard';
22+
23+
// ─── Path Resolution ────────────────────────────────────────────────
24+
25+
/**
26+
* Resolve the filesystem path to the @objectstack/dashboard package.
27+
* Searches workspace locations first, then falls back to node_modules.
28+
*/
29+
export function resolveDashboardPath(): string | null {
30+
const cwd = process.cwd();
31+
32+
// Workspace candidates (monorepo layouts)
33+
const candidates = [
34+
path.resolve(cwd, 'apps/dashboard'),
35+
path.resolve(cwd, '../../apps/dashboard'),
36+
path.resolve(cwd, '../apps/dashboard'),
37+
];
38+
39+
for (const candidate of candidates) {
40+
const pkgPath = path.join(candidate, 'package.json');
41+
if (fs.existsSync(pkgPath)) {
42+
try {
43+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
44+
if (pkg.name === '@objectstack/dashboard') return candidate;
45+
} catch {
46+
// Skip invalid package.json
47+
}
48+
}
49+
}
50+
51+
// Fallback: resolve from node_modules via createRequire.
52+
const resolutionBases = [
53+
pathToFileURL(path.join(cwd, 'package.json')).href, // consumer workspace
54+
import.meta.url, // CLI package itself
55+
];
56+
57+
for (const base of resolutionBases) {
58+
try {
59+
const req = createRequire(base);
60+
const resolved = req.resolve('@objectstack/dashboard/package.json');
61+
return path.dirname(resolved);
62+
} catch {
63+
// Not resolvable from this base — try next
64+
}
65+
}
66+
67+
// Last resort: direct filesystem check in cwd/node_modules
68+
const directPath = path.join(cwd, 'node_modules', '@objectstack', 'dashboard');
69+
if (fs.existsSync(path.join(directPath, 'package.json'))) {
70+
return directPath;
71+
}
72+
73+
return null;
74+
}
75+
76+
/**
77+
* Check whether the Dashboard portal has a pre-built `dist/` directory.
78+
*/
79+
export function hasDashboardDist(dashboardPath: string): boolean {
80+
return fs.existsSync(path.join(dashboardPath, 'dist', 'index.html'));
81+
}
82+
83+
// ─── Plugin Factory ─────────────────────────────────────────────────
84+
85+
/**
86+
* Create a lightweight kernel plugin that serves the pre-built Dashboard
87+
* portal static files at `/_dashboard/*`.
88+
*
89+
* Identical SPA-fallback semantics to `createStudioStaticPlugin` and
90+
* `createAccountStaticPlugin`:
91+
* - `index.html` is read fresh on every fallback hit (so a rebuild
92+
* producing new hashed asset names doesn't leave the browser
93+
* pointing at stale URLs).
94+
* - Hashed asset paths under `/_dashboard/assets/*` never SPA-fallback —
95+
* a real 404 surfaces a rebuild/deploy mismatch instead of the
96+
* dreaded "asset returns text/html" silent failure.
97+
*/
98+
export function createDashboardStaticPlugin(distPath: string, options?: { isDev?: boolean }) {
99+
return {
100+
name: 'com.objectstack.dashboard-static',
101+
102+
init: async () => {},
103+
104+
start: async (ctx: any) => {
105+
const httpServer = ctx.getService?.('http.server');
106+
if (!httpServer?.getRawApp) {
107+
ctx.logger?.warn?.('Dashboard static: http.server service not found — skipping');
108+
return;
109+
}
110+
111+
const app = httpServer.getRawApp();
112+
const absoluteDist = path.resolve(distPath);
113+
114+
const indexPath = path.join(absoluteDist, 'index.html');
115+
if (!fs.existsSync(indexPath)) {
116+
ctx.logger?.warn?.(`Dashboard static: dist not found at ${absoluteDist}`);
117+
return;
118+
}
119+
120+
const readIndexHtml = () => fs.readFileSync(indexPath, 'utf-8');
121+
122+
// Redirect bare path to trailing-slash (SPA convention)
123+
app.get(DASHBOARD_PATH, (c: any) => c.redirect(`${DASHBOARD_PATH}/`));
124+
125+
// Serve static files with SPA fallback
126+
app.get(`${DASHBOARD_PATH}/*`, async (c: any) => {
127+
const reqPath = c.req.path.substring(DASHBOARD_PATH.length) || '/';
128+
const filePath = path.join(absoluteDist, reqPath);
129+
130+
// Security: prevent path traversal
131+
if (!filePath.startsWith(absoluteDist)) {
132+
return c.text('Forbidden', 403);
133+
}
134+
135+
// Try serving the exact file
136+
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
137+
const content = fs.readFileSync(filePath);
138+
return new Response(content, {
139+
headers: { 'content-type': mimeType(filePath) },
140+
});
141+
}
142+
143+
// Hashed-asset paths must never SPA-fallback.
144+
if (reqPath.startsWith('/assets/')) {
145+
return c.text('Not Found', 404);
146+
}
147+
148+
// SPA fallback
149+
return new Response(readIndexHtml(), {
150+
headers: { 'content-type': 'text/html; charset=utf-8' },
151+
});
152+
});
153+
154+
// Suppress unused-parameter lint when isDev isn't needed.
155+
void options;
156+
},
157+
};
158+
}
159+
160+
// ─── Helpers ────────────────────────────────────────────────────────
161+
162+
const MIME_TYPES: Record<string, string> = {
163+
'.html': 'text/html; charset=utf-8',
164+
'.js': 'application/javascript; charset=utf-8',
165+
'.mjs': 'application/javascript; charset=utf-8',
166+
'.css': 'text/css; charset=utf-8',
167+
'.json': 'application/json; charset=utf-8',
168+
'.svg': 'image/svg+xml',
169+
'.png': 'image/png',
170+
'.jpg': 'image/jpeg',
171+
'.jpeg': 'image/jpeg',
172+
'.gif': 'image/gif',
173+
'.ico': 'image/x-icon',
174+
'.woff': 'font/woff',
175+
'.woff2': 'font/woff2',
176+
'.ttf': 'font/ttf',
177+
'.map': 'application/json',
178+
};
179+
180+
function mimeType(filePath: string): string {
181+
const ext = path.extname(filePath).toLowerCase();
182+
return MIME_TYPES[ext] || 'application/octet-stream';
183+
}

0 commit comments

Comments
 (0)