Skip to content

Commit 0a62704

Browse files
committed
feat: integrate account portal with CLI and enhance build scripts for deployment
1 parent 3c08faa commit 0a62704

6 files changed

Lines changed: 230 additions & 6 deletions

File tree

apps/account/src/routes/__root.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,11 @@ function RequireAuth({ children }: { children: React.ReactNode }) {
3333
if (!user && !pub) {
3434
navigate({
3535
to: '/login',
36-
search: { redirect: location.pathname + location.search },
36+
search: { redirect: location.pathname + location.searchStr },
3737
replace: true,
3838
});
3939
}
40-
}, [user, loading, pub, navigate, location.pathname, location.search]);
40+
}, [user, loading, pub, navigate, location.pathname, location.searchStr]);
4141

4242
if (loading && !user) {
4343
return (

apps/server/scripts/build-vercel.sh

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,9 @@ set -euo pipefail
1919
echo "[build-vercel] Starting server build..."
2020

2121
# 1. Build the project with turbo (from monorepo root)
22-
# This builds both server and studio
22+
# This builds server, studio, and the account portal.
2323
cd ../..
24-
pnpm turbo run build --filter=@objectstack/server --filter=@objectstack/studio
24+
pnpm turbo run build --filter=@objectstack/server --filter=@objectstack/studio --filter=@objectstack/account
2525
cd apps/server
2626

2727
# 1b. Compile objectstack.config.ts → dist/objectstack.json (the metadata artifact).
@@ -57,6 +57,17 @@ else
5757
echo "[build-vercel] ⚠ Studio dist not found (skipped)"
5858
fi
5959

60+
# 3b. Copy the account portal dist to public/_account/ — same pattern as
61+
# studio. The account SPA is always built with base: '/_account/'.
62+
echo "[build-vercel] Copying account dist to public/_account/..."
63+
mkdir -p public/_account
64+
if [ -d "../account/dist" ]; then
65+
cp -r ../account/dist/. public/_account/
66+
echo "[build-vercel] ✓ Copied account dist to public/_account/"
67+
else
68+
echo "[build-vercel] ⚠ Account dist not found (skipped)"
69+
fi
70+
6071
# 4. Install external dependencies in api/node_modules/ (no symlinks)
6172
# pnpm uses symlinks in node_modules/, which Vercel's serverless function
6273
# packaging cannot handle ("invalid deployment package" error).

apps/server/vercel.json

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,22 @@
2222
"headers": [
2323
{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }
2424
]
25+
},
26+
{
27+
"source": "/_account/assets/(.*)",
28+
"headers": [
29+
{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }
30+
]
2531
}
2632
],
2733
"redirects": [
2834
{ "source": "/", "destination": "/_studio/", "permanent": false },
29-
{ "source": "/_studio", "destination": "/_studio/", "permanent": false }
35+
{ "source": "/_studio", "destination": "/_studio/", "permanent": false },
36+
{ "source": "/_account", "destination": "/_account/", "permanent": false }
3037
],
3138
"rewrites": [
3239
{ "source": "/api/:path*", "destination": "/api/[[...route]]" },
33-
{ "source": "/_studio/:path*", "destination": "/_studio/index.html" }
40+
{ "source": "/_studio/:path*", "destination": "/_studio/index.html" },
41+
{ "source": "/_account/:path*", "destination": "/_account/index.html" }
3442
]
3543
}

packages/cli/src/commands/serve.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@ import {
2323
hasStudioDist,
2424
createStudioStaticPlugin,
2525
} from '../utils/studio.js';
26+
import {
27+
ACCOUNT_PATH,
28+
resolveAccountPath,
29+
hasAccountDist,
30+
createAccountStaticPlugin,
31+
} from '../utils/account.js';
2632
import dotenvFlow from 'dotenv-flow';
2733

2834
// Helper to find available port
@@ -435,6 +441,21 @@ export default class Serve extends Command {
435441
} else {
436442
console.warn(chalk.yellow(` ⚠ Studio dist not found — run "pnpm --filter @objectstack/studio build" first`));
437443
}
444+
445+
// ── Account portal ─────────────────────────────────────────
446+
// The account portal sits next to Studio under `/_account/` and
447+
// follows the same enable rules — it's a self-service surface
448+
// for end-users (login, organizations, profile, sessions).
449+
const accountPath = resolveAccountPath();
450+
if (!accountPath) {
451+
console.warn(chalk.yellow(` ⚠ @objectstack/account not found — skipping Account UI`));
452+
} else if (hasAccountDist(accountPath)) {
453+
const accountDistPath = path.join(accountPath, 'dist');
454+
await kernel.use(createAccountStaticPlugin(accountDistPath, { isDev }));
455+
trackPlugin('AccountUI');
456+
} else {
457+
console.warn(chalk.yellow(` ⚠ Account dist not found — run "pnpm --filter @objectstack/account build" first`));
458+
}
438459
}
439460

440461
// Boot the runtime
@@ -453,6 +474,7 @@ export default class Serve extends Command {
453474
pluginNames: loadedPlugins,
454475
uiEnabled: enableUI,
455476
studioPath: STUDIO_PATH,
477+
accountPath: ACCOUNT_PATH,
456478
});
457479

458480
// Kernel already registers SIGINT/SIGTERM handlers during bootstrap.

packages/cli/src/utils/account.ts

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

packages/cli/src/utils/format.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,7 @@ export interface ServerReadyOptions {
185185
pluginNames?: string[];
186186
uiEnabled?: boolean;
187187
studioPath?: string;
188+
accountPath?: string;
188189
}
189190

190191
export function printServerReady(opts: ServerReadyOptions) {
@@ -196,6 +197,9 @@ export function printServerReady(opts: ServerReadyOptions) {
196197
if (opts.uiEnabled && opts.studioPath) {
197198
console.log(chalk.cyan(' ➜') + chalk.bold(' Studio: ') + chalk.cyan(base + opts.studioPath + '/'));
198199
}
200+
if (opts.uiEnabled && opts.accountPath) {
201+
console.log(chalk.cyan(' ➜') + chalk.bold(' Account: ') + chalk.cyan(base + opts.accountPath + '/'));
202+
}
199203
console.log('');
200204
console.log(chalk.dim(` Config: ${opts.configFile}`));
201205
console.log(chalk.dim(` Mode: ${opts.isDev ? 'development' : 'production'}`));

0 commit comments

Comments
 (0)