Skip to content

Commit ee6c694

Browse files
V VV V
authored andcommitted
feat: static (SSG) deployment support for magnetic platform
Platform server (Rust): - AppHandle.is_static field to differentiate SSR vs static apps - load_static_app(): loads static apps without V8 thread - handle_static_get(): serves files from static/ dir with proper content-type, cache headers, and index.html resolution - handle_deploy(): accepts {static:true, assets:{...}} payload, writes files with subdirectory support, creates static.marker - Startup loader detects static.marker before bundle.js - Supports switching between SSR ↔ static on redeploy - GET + HEAD methods supported, other methods return 405 CLI (magnetic push --static): - Builds SSG (bundle + prerender), collects dist/ + public/ files - Sends {static:true, assets:{...}} instead of {bundle:...} - Includes CSS, images, and other public assets alongside HTML - Works with both authenticated (control plane) and direct push
1 parent 3d150d2 commit ee6c694

3 files changed

Lines changed: 367 additions & 108 deletions

File tree

apps/docs/netlify.toml

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,3 @@
11
[build]
22
publish = "dist"
33
command = "echo 'Pre-built static site'"
4-
5-
[[redirects]]
6-
from = "/*"
7-
to = "/index.html"
8-
status = 200

js/packages/magnetic-cli/src/cli.ts

Lines changed: 107 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
// Commands: dev, build, push
44

55
import { resolve, join, dirname } from 'node:path';
6-
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
6+
import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from 'node:fs';
77
import { homedir } from 'node:os';
88
import { createInterface } from 'node:readline';
99
import { scanApp, generateBridge } from './generator.ts';
@@ -341,6 +341,7 @@ async function main() {
341341
const serverUrl = getArg('--server') || config.server || globalConfig.server;
342342
const appName = getArg('--name') || config.name;
343343
const apiKey = resolveApiKey();
344+
const isStaticPush = args.includes('--static');
344345

345346
if (!serverUrl) {
346347
console.error('[magnetic] No server URL. Use --server <url> or set "server" in magnetic.json');
@@ -351,38 +352,108 @@ async function main() {
351352
process.exit(1);
352353
}
353354

354-
log('info', `Building for deploy...`);
355-
const scan = scanApp(appDir, monorepoRoot || undefined);
356-
const appConfig = parseAppConfig(appDir);
357-
log('info', `Scanned: ${scan.pages.length} pages, ${scan.layouts.length} layouts, state: ${scan.statePath || 'none'}`);
358-
if (appConfig.data.length > 0) log('info', `Data sources: ${appConfig.data.length}`);
359-
if (appConfig.actions.length > 0) log('info', `Action mappings: ${appConfig.actions.length}`);
360-
const pushDesignJson = readDesignJson(appDir);
361-
if (pushDesignJson) log('info', 'Design tokens: design.json loaded');
362-
const pushContentMap = buildContentMap(appDir);
363-
const pushContentInjection = pushContentMap ? generateContentInjection(pushContentMap) : undefined;
364-
if (pushContentMap) log('info', `Content: ${Object.keys(pushContentMap).length} markdown files`);
365-
const bridgeCode = generateBridge(scan, appConfig, pushDesignJson ?? undefined, pushContentInjection);
366-
const deploy = await buildForDeploy({ appDir, bridgeCode, monorepoRoot: monorepoRoot || undefined });
367-
const serverConfig = serializeConfigForServer(appConfig);
368-
369-
log('info', `Bundle: ${(deploy.bundleSize / 1024).toFixed(1)}KB (minified)`);
370-
log('info', `Assets: ${Object.keys(deploy.assets).length} files`);
371-
for (const [name, content] of Object.entries(deploy.assets)) {
372-
log('debug', ` asset: ${name} (${(content.length / 1024).toFixed(1)}KB)`);
373-
}
355+
let deployPayload: any;
356+
357+
if (isStaticPush) {
358+
// ── Static (SSG) deployment ──────────────────────────────
359+
log('info', `Building static site for deploy...`);
360+
const scan = scanApp(appDir, monorepoRoot || undefined);
361+
const appConfig = parseAppConfig(appDir);
362+
log('info', `Scanned: ${scan.pages.length} pages, ${scan.layouts.length} layouts, state: ${scan.statePath || 'none'}`);
363+
const pushDesignJson = readDesignJson(appDir);
364+
if (pushDesignJson) log('info', 'Design tokens: design.json loaded');
365+
const pushContentMap = buildContentMap(appDir);
366+
const pushContentInjection = pushContentMap ? generateContentInjection(pushContentMap) : undefined;
367+
const contentSlugs = pushContentMap ? Object.keys(pushContentMap) : [];
368+
if (pushContentMap) log('info', `Content: ${contentSlugs.length} markdown files`);
369+
const bridgeCode = generateBridge(scan, appConfig, pushDesignJson ?? undefined, pushContentInjection);
370+
371+
const result = await bundleApp({
372+
appDir,
373+
bridgeCode,
374+
minify: true,
375+
monorepoRoot: monorepoRoot || undefined,
376+
});
377+
378+
// Build prerender route list
379+
const prerenderList: string[] = ['/'];
380+
for (const slug of contentSlugs) prerenderList.push('/' + slug);
381+
for (const page of scan.pages) {
382+
if (page.routePath !== '/' && !page.routePath.includes(':') && !page.isCatchAll) {
383+
prerenderList.push(page.routePath);
384+
}
385+
}
386+
const routes = [...new Set(prerenderList)];
387+
388+
log('info', `Pre-rendering ${routes.length} routes...`);
389+
const { prerenderRoutes } = await import('./prerender.ts');
390+
const distDir = join(appDir, 'dist');
391+
await prerenderRoutes({
392+
bundlePath: result.outPath,
393+
outDir: distDir,
394+
routes,
395+
title: appConfig.name || 'Magnetic App',
396+
inlineCSS: undefined,
397+
publicDir: join(appDir, 'public'),
398+
contentDir: undefined,
399+
log,
400+
});
401+
402+
// Collect all files from dist/ (excluding app.js bundle) + public/
403+
const staticFiles: Record<string, string> = {};
404+
const collectFiles = (dir: string, prefix: string) => {
405+
if (!existsSync(dir)) return;
406+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
407+
if (entry.isDirectory()) {
408+
collectFiles(join(dir, entry.name), prefix ? `${prefix}/${entry.name}` : entry.name);
409+
} else if (entry.isFile() && entry.name !== 'app.js') {
410+
const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
411+
staticFiles[relPath] = readFileSync(join(dir, entry.name), 'utf-8');
412+
}
413+
}
414+
};
415+
// Public assets (CSS, images, etc.)
416+
collectFiles(join(appDir, 'public'), '');
417+
// Prerendered HTML pages (overwrites any public/ conflicts)
418+
collectFiles(distDir, '');
419+
420+
log('info', `Static files: ${Object.keys(staticFiles).length} files`);
421+
for (const [name, content] of Object.entries(staticFiles)) {
422+
log('debug', ` ${name} (${(content.length / 1024).toFixed(1)}KB)`);
423+
}
374424

375-
const bundleContent = readFileSync(deploy.bundlePath, 'utf-8');
425+
deployPayload = { name: appName, static: true, assets: staticFiles };
426+
} else {
427+
// ── SSR deployment ───────────────────────────────────────
428+
log('info', `Building for deploy...`);
429+
const scan = scanApp(appDir, monorepoRoot || undefined);
430+
const appConfig = parseAppConfig(appDir);
431+
log('info', `Scanned: ${scan.pages.length} pages, ${scan.layouts.length} layouts, state: ${scan.statePath || 'none'}`);
432+
if (appConfig.data.length > 0) log('info', `Data sources: ${appConfig.data.length}`);
433+
if (appConfig.actions.length > 0) log('info', `Action mappings: ${appConfig.actions.length}`);
434+
const pushDesignJson = readDesignJson(appDir);
435+
if (pushDesignJson) log('info', 'Design tokens: design.json loaded');
436+
const pushContentMap = buildContentMap(appDir);
437+
const pushContentInjection = pushContentMap ? generateContentInjection(pushContentMap) : undefined;
438+
if (pushContentMap) log('info', `Content: ${Object.keys(pushContentMap).length} markdown files`);
439+
const bridgeCode = generateBridge(scan, appConfig, pushDesignJson ?? undefined, pushContentInjection);
440+
const deploy = await buildForDeploy({ appDir, bridgeCode, monorepoRoot: monorepoRoot || undefined });
441+
const serverConfig = serializeConfigForServer(appConfig);
442+
443+
log('info', `Bundle: ${(deploy.bundleSize / 1024).toFixed(1)}KB (minified)`);
444+
log('info', `Assets: ${Object.keys(deploy.assets).length} files`);
445+
for (const [name, content] of Object.entries(deploy.assets)) {
446+
log('debug', ` asset: ${name} (${(content.length / 1024).toFixed(1)}KB)`);
447+
}
448+
449+
const bundleContent = readFileSync(deploy.bundlePath, 'utf-8');
450+
deployPayload = { name: appName, bundle: bundleContent, assets: deploy.assets, config: serverConfig };
451+
}
376452

377453
if (apiKey) {
378454
// Authenticated: deploy via control plane
379-
log('info', `Deploying to ${serverUrl} (authenticated)...`);
380-
const deployBody = Buffer.from(JSON.stringify({
381-
name: appName,
382-
bundle: bundleContent,
383-
assets: deploy.assets,
384-
config: serverConfig,
385-
}));
455+
log('info', `Deploying ${isStaticPush ? 'static site' : 'app'} to ${serverUrl} (authenticated)...`);
456+
const deployBody = Buffer.from(JSON.stringify(deployPayload));
386457
const resp = await fetch(`${serverUrl}/api/deploy`, {
387458
method: 'POST',
388459
headers: {
@@ -397,6 +468,7 @@ async function main() {
397468
log('info', `✓ Deployed!`);
398469
log('info', ` Live at: ${data.url}`);
399470
log('info', ` App ID: ${data.id}`);
471+
if (data.static) log('info', ` Mode: static (${data.files} files)`);
400472
} else {
401473
const text = await resp.text();
402474
log('error', `Deploy failed (${resp.status}): ${text}`);
@@ -405,11 +477,11 @@ async function main() {
405477
} else {
406478
// No auth: direct push to node (backward-compatible with --platform servers)
407479
log('info', `Pushing to ${serverUrl}/api/apps/${appName}/deploy...`);
408-
const directBody = Buffer.from(JSON.stringify({
409-
bundle: bundleContent,
410-
assets: deploy.assets,
411-
config: serverConfig,
412-
}));
480+
const directBody = Buffer.from(JSON.stringify(
481+
isStaticPush
482+
? { static: true, assets: deployPayload.assets }
483+
: { bundle: deployPayload.bundle, assets: deployPayload.assets, config: deployPayload.config }
484+
));
413485
const resp = await fetch(`${serverUrl}/api/apps/${appName}/deploy`, {
414486
method: 'POST',
415487
headers: {
@@ -422,6 +494,7 @@ async function main() {
422494
const data = await resp.json() as any;
423495
log('info', `✓ Deployed! ${data.url || serverUrl + '/apps/' + appName + '/'}`);
424496
log('info', ` Live at: ${serverUrl}/apps/${appName}/`);
497+
if (data.static) log('info', ` Mode: static (${data.files} files)`);
425498
} else {
426499
const text = await resp.text();
427500
log('error', `Deploy failed (${resp.status}): ${text}`);

0 commit comments

Comments
 (0)