@magneticjs/cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +99 -0
- package/dist/cli.js +498 -0
- package/package.json +30 -0
- package/scripts/install-server.js +92 -0
- package/src/bundler.ts +113 -0
- package/src/cli.ts +180 -0
- package/src/dev.ts +188 -0
- package/src/generator.ts +211 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* install-server.js — Downloads the prebuilt magnetic-v8-server binary
|
|
4
|
+
* for the current platform during `npm install @magnetic/cli`.
|
|
5
|
+
*
|
|
6
|
+
* Update strategy:
|
|
7
|
+
* - Version is read from ../package.json (matches @magnetic/cli version)
|
|
8
|
+
* - When user runs `npm update @magnetic/cli`, this script re-runs
|
|
9
|
+
* - Binary version is tracked in bin/.version
|
|
10
|
+
* - If version mismatch → re-download; if match → skip
|
|
11
|
+
*
|
|
12
|
+
* Binary distribution:
|
|
13
|
+
* GitHub Releases: magnetic-v8-server-{target}.tar.gz
|
|
14
|
+
* Targets: x86_64-apple-darwin, aarch64-apple-darwin, x86_64-unknown-linux-gnu
|
|
15
|
+
*
|
|
16
|
+
* Falls back gracefully if download fails — user can build from source.
|
|
17
|
+
*/
|
|
18
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from 'fs';
|
|
19
|
+
import { join, dirname } from 'path';
|
|
20
|
+
import { execSync } from 'child_process';
|
|
21
|
+
import { fileURLToPath } from 'url';
|
|
22
|
+
|
|
23
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
24
|
+
const pkgDir = join(__dirname, '..');
|
|
25
|
+
const binDir = join(pkgDir, 'bin');
|
|
26
|
+
const binPath = join(binDir, 'magnetic-v8-server');
|
|
27
|
+
const versionFile = join(binDir, '.version');
|
|
28
|
+
|
|
29
|
+
// Read version from package.json (stays in sync with npm version)
|
|
30
|
+
const pkg = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf-8'));
|
|
31
|
+
const version = pkg.version;
|
|
32
|
+
|
|
33
|
+
// Check if installed binary matches current CLI version
|
|
34
|
+
if (existsSync(binPath) && existsSync(versionFile)) {
|
|
35
|
+
const installed = readFileSync(versionFile, 'utf-8').trim();
|
|
36
|
+
if (installed === version) {
|
|
37
|
+
console.log(`[magnetic] Server binary v${version} already installed, skipping`);
|
|
38
|
+
process.exit(0);
|
|
39
|
+
}
|
|
40
|
+
console.log(`[magnetic] Server binary outdated (${installed} → ${version}), updating...`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Determine platform target
|
|
44
|
+
function getTarget() {
|
|
45
|
+
const platform = process.platform;
|
|
46
|
+
const arch = process.arch;
|
|
47
|
+
|
|
48
|
+
if (platform === 'darwin' && arch === 'arm64') return 'aarch64-apple-darwin';
|
|
49
|
+
if (platform === 'darwin' && arch === 'x64') return 'x86_64-apple-darwin';
|
|
50
|
+
if (platform === 'linux' && arch === 'x64') return 'x86_64-unknown-linux-gnu';
|
|
51
|
+
if (platform === 'linux' && arch === 'arm64') return 'aarch64-unknown-linux-gnu';
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const target = getTarget();
|
|
56
|
+
if (!target) {
|
|
57
|
+
console.warn(`[magnetic] No prebuilt binary for ${process.platform}-${process.arch}`);
|
|
58
|
+
console.warn('[magnetic] Build from source: cargo build --release -p magnetic-v8-server');
|
|
59
|
+
process.exit(0);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const baseUrl = process.env.MAGNETIC_BINARY_URL ||
|
|
63
|
+
`https://github.com/inventhq/magnetic/releases/download/v${version}`;
|
|
64
|
+
const filename = `magnetic-v8-server-${target}.tar.gz`;
|
|
65
|
+
const url = `${baseUrl}/${filename}`;
|
|
66
|
+
|
|
67
|
+
console.log(`[magnetic] Downloading server binary for ${target}...`);
|
|
68
|
+
console.log(`[magnetic] ${url}`);
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
mkdirSync(binDir, { recursive: true });
|
|
72
|
+
// Use curl or wget — available on all platforms
|
|
73
|
+
try {
|
|
74
|
+
execSync(`curl -fsSL "${url}" | tar xz -C "${binDir}"`, { stdio: 'pipe' });
|
|
75
|
+
} catch {
|
|
76
|
+
execSync(`wget -qO- "${url}" | tar xz -C "${binDir}"`, { stdio: 'pipe' });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (existsSync(binPath)) {
|
|
80
|
+
chmodSync(binPath, 0o755);
|
|
81
|
+
writeFileSync(versionFile, version);
|
|
82
|
+
console.log(`[magnetic] ✓ Server binary v${version} installed: ${binPath}`);
|
|
83
|
+
} else {
|
|
84
|
+
throw new Error('Binary not found after extraction');
|
|
85
|
+
}
|
|
86
|
+
} catch (err) {
|
|
87
|
+
console.warn(`[magnetic] Could not download prebuilt binary: ${err.message}`);
|
|
88
|
+
console.warn('[magnetic] The CLI will still work, but you need the server binary for `magnetic dev`.');
|
|
89
|
+
console.warn('[magnetic] Build from source: cargo build --release -p magnetic-v8-server');
|
|
90
|
+
// Don't fail the install — just warn
|
|
91
|
+
process.exit(0);
|
|
92
|
+
}
|
package/src/bundler.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// bundler.ts — esbuild wrapper for Magnetic apps
|
|
2
|
+
// Takes generated bridge code and bundles it into an IIFE for V8
|
|
3
|
+
|
|
4
|
+
import { build } from 'esbuild';
|
|
5
|
+
import { join, resolve } from 'node:path';
|
|
6
|
+
import { mkdirSync, existsSync, writeFileSync, statSync, readdirSync, readFileSync } from 'node:fs';
|
|
7
|
+
|
|
8
|
+
export interface BundleOptions {
|
|
9
|
+
/** Absolute path to the app directory */
|
|
10
|
+
appDir: string;
|
|
11
|
+
/** Generated bridge source code (from generator) */
|
|
12
|
+
bridgeCode: string;
|
|
13
|
+
/** Output directory (default: dist/) */
|
|
14
|
+
outDir?: string;
|
|
15
|
+
/** Output filename (default: app.js) */
|
|
16
|
+
outFile?: string;
|
|
17
|
+
/** Minify output */
|
|
18
|
+
minify?: boolean;
|
|
19
|
+
/** Monorepo root (for resolving @magneticjs/server) */
|
|
20
|
+
monorepoRoot?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface BundleResult {
|
|
24
|
+
outPath: string;
|
|
25
|
+
sizeBytes: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Bundle the generated bridge code into an IIFE for V8 consumption.
|
|
30
|
+
* Uses esbuild with stdin so no temp file is needed.
|
|
31
|
+
*/
|
|
32
|
+
export async function bundleApp(opts: BundleOptions): Promise<BundleResult> {
|
|
33
|
+
const outDir = opts.outDir || join(opts.appDir, 'dist');
|
|
34
|
+
const outFile = opts.outFile || 'app.js';
|
|
35
|
+
const outPath = join(outDir, outFile);
|
|
36
|
+
|
|
37
|
+
if (!existsSync(outDir)) {
|
|
38
|
+
mkdirSync(outDir, { recursive: true });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Resolve @magneticjs/server — in monorepo use actual path, otherwise npm package
|
|
42
|
+
const alias: Record<string, string> = {};
|
|
43
|
+
if (opts.monorepoRoot) {
|
|
44
|
+
const serverPkg = join(opts.monorepoRoot, 'js/packages/magnetic-server/src');
|
|
45
|
+
alias['@magneticjs/server'] = serverPkg;
|
|
46
|
+
alias['@magneticjs/server/jsx-runtime'] = join(serverPkg, 'jsx-runtime.ts');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const result = await build({
|
|
50
|
+
stdin: {
|
|
51
|
+
contents: opts.bridgeCode,
|
|
52
|
+
resolveDir: opts.appDir,
|
|
53
|
+
loader: 'tsx',
|
|
54
|
+
},
|
|
55
|
+
bundle: true,
|
|
56
|
+
format: 'iife',
|
|
57
|
+
globalName: 'MagneticApp',
|
|
58
|
+
outfile: outPath,
|
|
59
|
+
minify: opts.minify || false,
|
|
60
|
+
sourcemap: false,
|
|
61
|
+
target: 'es2020',
|
|
62
|
+
jsx: 'automatic',
|
|
63
|
+
jsxImportSource: '@magneticjs/server',
|
|
64
|
+
alias,
|
|
65
|
+
logLevel: 'warning',
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const stat = statSync(outPath);
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
outPath,
|
|
72
|
+
sizeBytes: stat.size,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Bundle and also prepare the assets manifest for deployment.
|
|
78
|
+
* Returns the bundle path + a map of public/ files.
|
|
79
|
+
*/
|
|
80
|
+
export async function buildForDeploy(opts: BundleOptions): Promise<{
|
|
81
|
+
bundlePath: string;
|
|
82
|
+
bundleSize: number;
|
|
83
|
+
assets: Record<string, string>;
|
|
84
|
+
}> {
|
|
85
|
+
const bundle = await bundleApp({ ...opts, minify: true });
|
|
86
|
+
|
|
87
|
+
// Collect public/ files as a map for upload
|
|
88
|
+
const publicDir = join(opts.appDir, 'public');
|
|
89
|
+
const assets: Record<string, string> = {};
|
|
90
|
+
|
|
91
|
+
if (existsSync(publicDir)) {
|
|
92
|
+
const entries = readdirSync(publicDir);
|
|
93
|
+
for (const entry of entries) {
|
|
94
|
+
const fullPath = join(publicDir, entry);
|
|
95
|
+
if (statSync(fullPath).isFile()) {
|
|
96
|
+
// For text files, include as string; for binary, base64 encode
|
|
97
|
+
const ext = entry.split('.').pop() || '';
|
|
98
|
+
const textExts = ['css', 'js', 'json', 'html', 'svg', 'txt', 'xml'];
|
|
99
|
+
if (textExts.includes(ext)) {
|
|
100
|
+
assets[entry] = readFileSync(fullPath, 'utf-8');
|
|
101
|
+
} else {
|
|
102
|
+
assets[entry] = readFileSync(fullPath).toString('base64');
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
bundlePath: bundle.outPath,
|
|
110
|
+
bundleSize: bundle.sizeBytes,
|
|
111
|
+
assets,
|
|
112
|
+
};
|
|
113
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// cli.ts — Magnetic CLI entry point
|
|
3
|
+
// Commands: dev, build, push
|
|
4
|
+
|
|
5
|
+
import { resolve, join } from 'node:path';
|
|
6
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
7
|
+
import { scanApp, generateBridge } from './generator.ts';
|
|
8
|
+
import { bundleApp, buildForDeploy } from './bundler.ts';
|
|
9
|
+
import { startDev } from './dev.ts';
|
|
10
|
+
|
|
11
|
+
const args = process.argv.slice(2);
|
|
12
|
+
const command = args[0];
|
|
13
|
+
|
|
14
|
+
// Structured logging
|
|
15
|
+
function log(level: 'info' | 'warn' | 'error' | 'debug', msg: string) {
|
|
16
|
+
const ts = new Date().toISOString().slice(11, 23);
|
|
17
|
+
const prefix = level === 'error' ? '✗' : level === 'warn' ? '⚠' : level === 'debug' ? '·' : '→';
|
|
18
|
+
const stream = level === 'error' ? process.stderr : process.stdout;
|
|
19
|
+
stream.write(`[${ts}] ${prefix} ${msg}\n`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function findMonorepoRoot(from: string): string | null {
|
|
23
|
+
let dir = from;
|
|
24
|
+
for (let i = 0; i < 10; i++) {
|
|
25
|
+
if (existsSync(join(dir, 'js/packages/magnetic-server'))) return dir;
|
|
26
|
+
const parent = resolve(dir, '..');
|
|
27
|
+
if (parent === dir) break;
|
|
28
|
+
dir = parent;
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function getArg(flag: string): string | undefined {
|
|
34
|
+
const idx = args.indexOf(flag);
|
|
35
|
+
return idx >= 0 ? args[idx + 1] : undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function usage() {
|
|
39
|
+
console.log(`
|
|
40
|
+
@magnetic/cli — Build and deploy server-driven UI apps
|
|
41
|
+
|
|
42
|
+
Usage:
|
|
43
|
+
magnetic dev Start dev mode (watch + rebuild + serve)
|
|
44
|
+
magnetic build Build the app bundle for deployment
|
|
45
|
+
magnetic push Build and deploy to a Magnetic platform server
|
|
46
|
+
|
|
47
|
+
Options:
|
|
48
|
+
--port <n> Dev server port (default: 3003)
|
|
49
|
+
--dir <path> App directory (default: current directory)
|
|
50
|
+
--server <url> Platform server URL for push
|
|
51
|
+
--name <name> App name for push (default: from magnetic.json)
|
|
52
|
+
--minify Minify the output bundle
|
|
53
|
+
|
|
54
|
+
Developer workflow:
|
|
55
|
+
1. Write pages in pages/*.tsx
|
|
56
|
+
2. Write business logic in state.ts (optional)
|
|
57
|
+
3. Run \`magnetic dev\` to develop locally
|
|
58
|
+
4. Run \`magnetic push\` to deploy
|
|
59
|
+
`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function main() {
|
|
63
|
+
if (!command || command === '--help' || command === '-h') {
|
|
64
|
+
usage();
|
|
65
|
+
process.exit(0);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const appDir = resolve(getArg('--dir') || '.');
|
|
69
|
+
const monorepoRoot = findMonorepoRoot(appDir);
|
|
70
|
+
const port = parseInt(getArg('--port') || '3003', 10);
|
|
71
|
+
|
|
72
|
+
// Load magnetic.json if it exists
|
|
73
|
+
let config: any = {};
|
|
74
|
+
const configPath = join(appDir, 'magnetic.json');
|
|
75
|
+
if (existsSync(configPath)) {
|
|
76
|
+
config = JSON.parse(readFileSync(configPath, 'utf-8'));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
switch (command) {
|
|
80
|
+
case 'dev': {
|
|
81
|
+
await startDev({
|
|
82
|
+
appDir,
|
|
83
|
+
port,
|
|
84
|
+
monorepoRoot: monorepoRoot || undefined,
|
|
85
|
+
});
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
case 'build': {
|
|
90
|
+
log('info', `Building ${appDir}`);
|
|
91
|
+
const buildStart = Date.now();
|
|
92
|
+
const scan = scanApp(appDir, monorepoRoot || undefined);
|
|
93
|
+
log('info', `Scanned: ${scan.pages.length} pages, state: ${scan.statePath || 'none (using defaults)'}`);
|
|
94
|
+
|
|
95
|
+
for (const page of scan.pages) {
|
|
96
|
+
log('debug', ` route ${page.routePath.padEnd(15)} ← ${page.filePath}`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const bridgeCode = generateBridge(scan);
|
|
100
|
+
log('debug', `Bridge generated: ${bridgeCode.split('\n').length} lines`);
|
|
101
|
+
|
|
102
|
+
if (args.includes('--verbose')) {
|
|
103
|
+
console.log('\n--- Generated bridge ---');
|
|
104
|
+
console.log(bridgeCode);
|
|
105
|
+
console.log('--- End bridge ---\n');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const result = await bundleApp({
|
|
109
|
+
appDir,
|
|
110
|
+
bridgeCode,
|
|
111
|
+
minify: args.includes('--minify'),
|
|
112
|
+
monorepoRoot: monorepoRoot || undefined,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const kb = (result.sizeBytes / 1024).toFixed(1);
|
|
116
|
+
const elapsed = Date.now() - buildStart;
|
|
117
|
+
log('info', `✓ Built ${result.outPath} (${kb}KB) in ${elapsed}ms`);
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
case 'push': {
|
|
122
|
+
const serverUrl = getArg('--server') || config.server;
|
|
123
|
+
const appName = getArg('--name') || config.name;
|
|
124
|
+
|
|
125
|
+
if (!serverUrl) {
|
|
126
|
+
console.error('[magnetic] No server URL. Use --server <url> or set "server" in magnetic.json');
|
|
127
|
+
process.exit(1);
|
|
128
|
+
}
|
|
129
|
+
if (!appName) {
|
|
130
|
+
console.error('[magnetic] No app name. Use --name <name> or set "name" in magnetic.json');
|
|
131
|
+
process.exit(1);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
log('info', `Building for deploy...`);
|
|
135
|
+
const scan = scanApp(appDir, monorepoRoot || undefined);
|
|
136
|
+
log('info', `Scanned: ${scan.pages.length} pages, state: ${scan.statePath || 'none'}`);
|
|
137
|
+
const bridgeCode = generateBridge(scan);
|
|
138
|
+
const deploy = await buildForDeploy({ appDir, bridgeCode, monorepoRoot: monorepoRoot || undefined });
|
|
139
|
+
|
|
140
|
+
log('info', `Bundle: ${(deploy.bundleSize / 1024).toFixed(1)}KB (minified)`);
|
|
141
|
+
log('info', `Assets: ${Object.keys(deploy.assets).length} files`);
|
|
142
|
+
for (const [name, content] of Object.entries(deploy.assets)) {
|
|
143
|
+
log('debug', ` asset: ${name} (${(content.length / 1024).toFixed(1)}KB)`);
|
|
144
|
+
}
|
|
145
|
+
log('info', `Pushing to ${serverUrl}/api/apps/${appName}/deploy...`);
|
|
146
|
+
|
|
147
|
+
const bundleContent = readFileSync(deploy.bundlePath, 'utf-8');
|
|
148
|
+
|
|
149
|
+
const resp = await fetch(`${serverUrl}/api/apps/${appName}/deploy`, {
|
|
150
|
+
method: 'POST',
|
|
151
|
+
headers: { 'Content-Type': 'application/json' },
|
|
152
|
+
body: JSON.stringify({
|
|
153
|
+
bundle: bundleContent,
|
|
154
|
+
assets: deploy.assets,
|
|
155
|
+
}),
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
if (resp.ok) {
|
|
159
|
+
const data = await resp.json() as any;
|
|
160
|
+
log('info', `✓ Deployed! ${data.url || serverUrl + '/apps/' + appName + '/'}`);
|
|
161
|
+
log('info', ` Live at: ${serverUrl}/apps/${appName}/`);
|
|
162
|
+
} else {
|
|
163
|
+
const text = await resp.text();
|
|
164
|
+
log('error', `Deploy failed (${resp.status}): ${text}`);
|
|
165
|
+
process.exit(1);
|
|
166
|
+
}
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
default:
|
|
171
|
+
console.error(`[magnetic] Unknown command: ${command}`);
|
|
172
|
+
usage();
|
|
173
|
+
process.exit(1);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
main().catch((err) => {
|
|
178
|
+
console.error(`[magnetic] Fatal: ${err.message}`);
|
|
179
|
+
process.exit(1);
|
|
180
|
+
});
|
package/src/dev.ts
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// dev.ts — Dev mode: watch pages/, auto-rebuild, start V8 server
|
|
2
|
+
// Gives developers instant feedback as they edit TSX pages
|
|
3
|
+
|
|
4
|
+
import { watch } from 'node:fs';
|
|
5
|
+
import { join, resolve } from 'node:path';
|
|
6
|
+
import { existsSync } from 'node:fs';
|
|
7
|
+
import { spawn, ChildProcess } from 'node:child_process';
|
|
8
|
+
import { scanApp, generateBridge } from './generator.ts';
|
|
9
|
+
import { bundleApp } from './bundler.ts';
|
|
10
|
+
|
|
11
|
+
export interface DevOptions {
|
|
12
|
+
/** Absolute path to the app directory */
|
|
13
|
+
appDir: string;
|
|
14
|
+
/** Port for the V8 server (default: 3003) */
|
|
15
|
+
port?: number;
|
|
16
|
+
/** Path to the magnetic-v8-server binary */
|
|
17
|
+
serverBin?: string;
|
|
18
|
+
/** Monorepo root (for resolving @magneticjs/server) */
|
|
19
|
+
monorepoRoot?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Start dev mode:
|
|
24
|
+
* 1. Scan pages/ and generate bridge
|
|
25
|
+
* 2. Bundle with esbuild
|
|
26
|
+
* 3. Start the Rust V8 server
|
|
27
|
+
* 4. Watch for file changes → rebuild → restart server
|
|
28
|
+
*/
|
|
29
|
+
export async function startDev(opts: DevOptions): Promise<void> {
|
|
30
|
+
const {
|
|
31
|
+
appDir,
|
|
32
|
+
port = 3003,
|
|
33
|
+
monorepoRoot,
|
|
34
|
+
} = opts;
|
|
35
|
+
|
|
36
|
+
const staticDir = join(appDir, 'public');
|
|
37
|
+
const outDir = join(appDir, 'dist');
|
|
38
|
+
|
|
39
|
+
// Find the V8 server binary
|
|
40
|
+
const serverBin = opts.serverBin || findServerBinary(monorepoRoot || appDir);
|
|
41
|
+
if (!serverBin) {
|
|
42
|
+
console.error('[magnetic] Cannot find magnetic-v8-server binary.');
|
|
43
|
+
console.error(' Build it with: cargo build --release -p magnetic-v8-server');
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let serverProcess: ChildProcess | null = null;
|
|
48
|
+
|
|
49
|
+
async function rebuild(): Promise<string | null> {
|
|
50
|
+
const start = Date.now();
|
|
51
|
+
try {
|
|
52
|
+
const scan = scanApp(appDir, monorepoRoot);
|
|
53
|
+
console.log(`[magnetic] Scanned ${scan.pages.length} pages, state: ${scan.statePath || 'none'}`);
|
|
54
|
+
|
|
55
|
+
for (const page of scan.pages) {
|
|
56
|
+
console.log(` ${page.routePath} → ${page.filePath} (${page.importName})`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const bridgeCode = generateBridge(scan);
|
|
60
|
+
const result = await bundleApp({
|
|
61
|
+
appDir,
|
|
62
|
+
bridgeCode,
|
|
63
|
+
outDir,
|
|
64
|
+
monorepoRoot,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const ms = Date.now() - start;
|
|
68
|
+
const kb = (result.sizeBytes / 1024).toFixed(1);
|
|
69
|
+
console.log(`[magnetic] Built ${result.outPath} (${kb}KB) in ${ms}ms`);
|
|
70
|
+
return result.outPath;
|
|
71
|
+
} catch (err: any) {
|
|
72
|
+
console.error(`[magnetic] Build failed: ${err.message}`);
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function startServer(bundlePath: string): ChildProcess {
|
|
78
|
+
const args = [
|
|
79
|
+
'--bundle', bundlePath,
|
|
80
|
+
'--port', String(port),
|
|
81
|
+
'--static', staticDir,
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
console.log(`[magnetic] Starting V8 server on :${port}`);
|
|
85
|
+
const proc = spawn(serverBin!, args, {
|
|
86
|
+
stdio: ['ignore', 'inherit', 'inherit'],
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
proc.on('exit', (code) => {
|
|
90
|
+
if (code !== null && code !== 0) {
|
|
91
|
+
console.error(`[magnetic] Server exited with code ${code}`);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
return proc;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function stopServer() {
|
|
99
|
+
if (serverProcess) {
|
|
100
|
+
serverProcess.kill('SIGTERM');
|
|
101
|
+
serverProcess = null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Initial build + start
|
|
106
|
+
const bundlePath = await rebuild();
|
|
107
|
+
if (!bundlePath) {
|
|
108
|
+
console.error('[magnetic] Initial build failed. Fix errors and save to retry.');
|
|
109
|
+
} else {
|
|
110
|
+
serverProcess = startServer(bundlePath);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Watch for changes
|
|
114
|
+
const watchDirs = [join(appDir, 'pages'), join(appDir, 'components')];
|
|
115
|
+
const watchFiles = ['state.ts', 'state.tsx', 'server/state.ts', 'server/state.tsx']
|
|
116
|
+
.map(f => join(appDir, f));
|
|
117
|
+
|
|
118
|
+
let rebuildTimer: ReturnType<typeof setTimeout> | null = null;
|
|
119
|
+
|
|
120
|
+
function scheduleRebuild() {
|
|
121
|
+
if (rebuildTimer) clearTimeout(rebuildTimer);
|
|
122
|
+
rebuildTimer = setTimeout(async () => {
|
|
123
|
+
console.log('\n[magnetic] Change detected, rebuilding...');
|
|
124
|
+
stopServer();
|
|
125
|
+
const path = await rebuild();
|
|
126
|
+
if (path) {
|
|
127
|
+
serverProcess = startServer(path);
|
|
128
|
+
}
|
|
129
|
+
}, 200); // Debounce 200ms
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
for (const dir of watchDirs) {
|
|
133
|
+
if (existsSync(dir)) {
|
|
134
|
+
watch(dir, { recursive: true }, (event, filename) => {
|
|
135
|
+
if (filename && /\.(tsx?|jsx?|css)$/.test(filename)) {
|
|
136
|
+
scheduleRebuild();
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
console.log(`[magnetic] Watching ${dir}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
for (const file of watchFiles) {
|
|
144
|
+
if (existsSync(file)) {
|
|
145
|
+
watch(file, () => scheduleRebuild());
|
|
146
|
+
console.log(`[magnetic] Watching ${file}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Handle cleanup
|
|
151
|
+
process.on('SIGINT', () => {
|
|
152
|
+
console.log('\n[magnetic] Shutting down...');
|
|
153
|
+
stopServer();
|
|
154
|
+
process.exit(0);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
process.on('SIGTERM', () => {
|
|
158
|
+
stopServer();
|
|
159
|
+
process.exit(0);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
console.log(`[magnetic] Dev mode ready. Edit pages/ and save to rebuild.`);
|
|
163
|
+
console.log(`[magnetic] http://localhost:${port}\n`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Look for the magnetic-v8-server binary in common locations.
|
|
168
|
+
*/
|
|
169
|
+
function findServerBinary(searchRoot: string): string | null {
|
|
170
|
+
// __dirname equivalent for the CLI package
|
|
171
|
+
const cliPkgBin = join(import.meta.dirname || __dirname, '..', 'bin', 'magnetic-v8-server');
|
|
172
|
+
|
|
173
|
+
const candidates = [
|
|
174
|
+
// npm-installed binary (from postinstall)
|
|
175
|
+
cliPkgBin,
|
|
176
|
+
// Monorepo development paths
|
|
177
|
+
join(searchRoot, 'rs/crates/magnetic-v8-server/target/debug/magnetic-v8-server'),
|
|
178
|
+
join(searchRoot, 'rs/crates/magnetic-v8-server/target/release/magnetic-v8-server'),
|
|
179
|
+
join(searchRoot, 'target/debug/magnetic-v8-server'),
|
|
180
|
+
join(searchRoot, 'target/release/magnetic-v8-server'),
|
|
181
|
+
];
|
|
182
|
+
|
|
183
|
+
for (const path of candidates) {
|
|
184
|
+
if (existsSync(path)) return path;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return null;
|
|
188
|
+
}
|