tova 0.10.3 → 0.11.12

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.
@@ -0,0 +1,1044 @@
1
+ // src/cli/build.js — Build pipeline commands
2
+ import { resolve, basename, dirname, join, relative, extname } from 'path';
3
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync, rmSync, chmodSync } from 'fs';
4
+ import { createHash as _cryptoHash } from 'crypto';
5
+ import { watch as fsWatch } from 'fs';
6
+ import { mergeDirectory, compileTova, fixImportPaths, compileWithImports, groupFilesByDirectory, compilationCache, moduleTypeCache, collectExports, injectRouterImport, generateFileBasedRoutes, invalidateFile } from './compile.js';
7
+ import { color, findFiles, getRunStdlib, hasNpmImports, bundleClientCode, _formatBytes, _hasBun } from './utils.js';
8
+ import { resolveConfig } from '../config/resolve.js';
9
+ import { REACTIVITY_SOURCE, RPC_SOURCE, ROUTER_SOURCE, DEVTOOLS_SOURCE, SSR_SOURCE, TESTING_SOURCE } from '../runtime/embedded.js';
10
+ import { generateSecurityScorecard } from '../diagnostics/security-scorecard.js';
11
+ import { buildSelectiveStdlib, BUILTIN_NAMES, PROPAGATE, NATIVE_INIT } from '../stdlib/inline.js';
12
+
13
+ async function buildProject(args) {
14
+ const config = resolveConfig(process.cwd());
15
+ const isProduction = args.includes('--production');
16
+ const isStatic = args.includes('--static');
17
+ const buildStrict = args.includes('--strict');
18
+ const buildStrictSecurity = args.includes('--strict-security');
19
+ const isVerbose = args.includes('--verbose');
20
+ const isQuiet = args.includes('--quiet');
21
+ const isWatch = args.includes('--watch');
22
+ const binaryIdx = args.indexOf('--binary');
23
+ const binaryName = binaryIdx >= 0 ? args[binaryIdx + 1] : null;
24
+ const explicitSrc = args.filter(a => !a.startsWith('--') && a !== binaryName)[0];
25
+ const srcDir = resolve(explicitSrc || config.project.entry || '.');
26
+ const outIdx = args.indexOf('--output');
27
+ const outDir = resolve(outIdx >= 0 ? args[outIdx + 1] : (config.build.output || '.tova-out'));
28
+
29
+ // Binary compilation: compile to single standalone executable
30
+ if (binaryName) {
31
+ return await binaryBuild(srcDir, binaryName, outDir);
32
+ }
33
+
34
+ // Production build uses a separate optimized pipeline
35
+ if (isProduction) {
36
+ return await productionBuild(srcDir, outDir, isStatic);
37
+ }
38
+
39
+ const tovaFiles = findFiles(srcDir, '.tova');
40
+ if (tovaFiles.length === 0) {
41
+ console.error('No .tova files found');
42
+ process.exit(1);
43
+ }
44
+
45
+ mkdirSync(outDir, { recursive: true });
46
+
47
+ // Write embedded runtime files to output directory
48
+ const runtimeDest = join(outDir, 'runtime');
49
+ mkdirSync(runtimeDest, { recursive: true });
50
+ writeFileSync(join(runtimeDest, 'reactivity.js'), REACTIVITY_SOURCE);
51
+ writeFileSync(join(runtimeDest, 'rpc.js'), RPC_SOURCE);
52
+ writeFileSync(join(runtimeDest, 'router.js'), ROUTER_SOURCE);
53
+ writeFileSync(join(runtimeDest, 'devtools.js'), DEVTOOLS_SOURCE);
54
+ writeFileSync(join(runtimeDest, 'ssr.js'), SSR_SOURCE);
55
+ writeFileSync(join(runtimeDest, 'testing.js'), TESTING_SOURCE);
56
+
57
+ if (!isQuiet) console.log(`\n Building ${tovaFiles.length} file(s)...\n`);
58
+
59
+ let errorCount = 0;
60
+ const buildStart = Date.now();
61
+ compilationCache.clear(); moduleTypeCache.clear();
62
+
63
+ // Load incremental build cache
64
+ const noCache = args.includes('--no-cache');
65
+ const buildCache = new BuildCache(join(outDir, '.cache'));
66
+ if (!noCache) buildCache.load();
67
+ let skippedCount = 0;
68
+
69
+ // Group files by directory for multi-file merging
70
+ const dirGroups = groupFilesByDirectory(tovaFiles);
71
+ let _scorecardData = null; // Collect security info for scorecard
72
+
73
+ for (const [dir, files] of dirGroups) {
74
+ const dirName = basename(dir) === '.' ? 'app' : basename(dir);
75
+ const relDir = relative(srcDir, dir) || '.';
76
+ const groupStart = Date.now();
77
+ try {
78
+ // Check incremental cache: skip if all files in this group are unchanged
79
+ if (!noCache) {
80
+ if (files.length === 1) {
81
+ const absFile = files[0];
82
+ const sourceContent = readFileSync(absFile, 'utf-8');
83
+ if (buildCache.isUpToDate(absFile, sourceContent)) {
84
+ const cached = buildCache.getCached(absFile);
85
+ if (cached) {
86
+ skippedCount++;
87
+ if (isVerbose && !isQuiet) {
88
+ console.log(` ○ ${relative(srcDir, absFile)} (cached)`);
89
+ }
90
+ continue;
91
+ }
92
+ }
93
+ } else {
94
+ const dirKey = `dir:${dir}`;
95
+ if (buildCache.isGroupUpToDate(dirKey, files)) {
96
+ const cached = buildCache.getCached(dirKey);
97
+ if (cached) {
98
+ skippedCount++;
99
+ if (isVerbose && !isQuiet) {
100
+ console.log(` ○ ${relative(srcDir, dir)}/ (${files.length} files, cached)`);
101
+ }
102
+ continue;
103
+ }
104
+ }
105
+ }
106
+ }
107
+
108
+ const result = mergeDirectory(dir, srcDir, { strict: buildStrict, strictSecurity: buildStrictSecurity });
109
+ if (!result) continue;
110
+
111
+ const { output, single, warnings: buildWarnings, securityConfig, hasServer, hasEdge } = result;
112
+ if ((hasServer || hasEdge) && !_scorecardData) {
113
+ _scorecardData = { securityConfig, warnings: buildWarnings || [], hasServer, hasEdge };
114
+ }
115
+ // Preserve relative directory structure in output (e.g., src/lib/math.tova → lib/math.js)
116
+ const outBaseName = single
117
+ ? relative(srcDir, files[0]).replace(/\.tova$/, '').replace(/\\/g, '/')
118
+ : (relDir === '.' ? dirName : relDir + '/' + dirName);
119
+ const relLabel = single ? relative(srcDir, files[0]) : `${relDir}/ (${files.length} files merged)`;
120
+ const elapsed = Date.now() - groupStart;
121
+ const timing = isVerbose ? ` (${elapsed}ms)` : '';
122
+
123
+ // Helper to generate source maps
124
+ const generateSourceMap = (code, jsFile) => {
125
+ if (output.sourceMappings && output.sourceMappings.length > 0) {
126
+ const sourceFile = single ? relative(srcDir, files[0]) : relDir;
127
+ const smb = new SourceMapBuilder(sourceFile, output._sourceFiles);
128
+ for (const m of output.sourceMappings) {
129
+ smb.addMapping(m.sourceLine, m.sourceCol, m.outputLine, m.outputCol, m.sourceFile);
130
+ }
131
+ const sourceContent = single ? readFileSync(files[0], 'utf-8') : null;
132
+ const mapFile = jsFile + '.map';
133
+ writeFileSync(mapFile, smb.generate(sourceContent, output._sourceContents));
134
+ return code + `\n//# sourceMappingURL=${basename(mapFile)}`;
135
+ }
136
+ return code;
137
+ };
138
+
139
+ // Ensure output subdirectory exists for nested paths (e.g., lib/math.js)
140
+ const outSubDir = dirname(join(outDir, outBaseName));
141
+ if (outSubDir !== outDir) mkdirSync(outSubDir, { recursive: true });
142
+
143
+ // CLI files: write single executable <name>.js with shebang
144
+ if (output.isCli) {
145
+ if (output.cli && output.cli.trim()) {
146
+ const cliPath = join(outDir, `${outBaseName}.js`);
147
+ const shebang = '#!/usr/bin/env node\n';
148
+ writeFileSync(cliPath, shebang + output.cli);
149
+ try { chmodSync(cliPath, 0o755); } catch (e) { /* ignore on Windows */ }
150
+ if (!isQuiet) console.log(` ✓ ${relLabel} → ${relative('.', cliPath)} [cli]${timing}`);
151
+ }
152
+ if (!noCache) {
153
+ const outputPaths = {};
154
+ if (output.cli && output.cli.trim()) outputPaths.cli = join(outDir, `${outBaseName}.js`);
155
+ if (single) {
156
+ const absFile = files[0];
157
+ const sourceContent = readFileSync(absFile, 'utf-8');
158
+ buildCache.set(absFile, sourceContent, outputPaths);
159
+ } else {
160
+ buildCache.setGroup(`dir:${dir}`, files, outputPaths);
161
+ }
162
+ }
163
+ }
164
+ // Module files: write single <name>.js (not .shared.js)
165
+ else if (output.isModule) {
166
+ if (output.shared && output.shared.trim()) {
167
+ const modulePath = join(outDir, `${outBaseName}.js`);
168
+ writeFileSync(modulePath, fixImportPaths(generateSourceMap(output.shared, modulePath), modulePath, outDir));
169
+ if (!isQuiet) console.log(` ✓ ${relLabel} → ${relative('.', modulePath)}${timing}`);
170
+ }
171
+ // Update incremental build cache
172
+ if (!noCache) {
173
+ const outputPaths = {};
174
+ if (output.shared && output.shared.trim()) outputPaths.shared = join(outDir, `${outBaseName}.js`);
175
+ if (single) {
176
+ const absFile = files[0];
177
+ const sourceContent = readFileSync(absFile, 'utf-8');
178
+ buildCache.set(absFile, sourceContent, outputPaths);
179
+ } else {
180
+ buildCache.setGroup(`dir:${dir}`, files, outputPaths);
181
+ }
182
+ }
183
+ } else {
184
+ // Write shared
185
+ if (output.shared && output.shared.trim()) {
186
+ const sharedPath = join(outDir, `${outBaseName}.shared.js`);
187
+ writeFileSync(sharedPath, fixImportPaths(generateSourceMap(output.shared, sharedPath), sharedPath, outDir));
188
+ if (!isQuiet) console.log(` ✓ ${relLabel} → ${relative('.', sharedPath)}${timing}`);
189
+ }
190
+
191
+ // Write default server
192
+ if (output.server) {
193
+ const serverPath = join(outDir, `${outBaseName}.server.js`);
194
+ writeFileSync(serverPath, fixImportPaths(generateSourceMap(output.server, serverPath), serverPath, outDir));
195
+ if (!isQuiet) console.log(` ✓ ${relLabel} → ${relative('.', serverPath)}${timing}`);
196
+ }
197
+
198
+ // Write default browser
199
+ if (output.browser) {
200
+ const browserPath = join(outDir, `${outBaseName}.browser.js`);
201
+ // Pass srcDir for file-based routing injection (only for root-level browser output)
202
+ const browserSrcDir = (relDir === '.' || relDir === '') ? srcDir : undefined;
203
+ writeFileSync(browserPath, fixImportPaths(generateSourceMap(output.browser, browserPath), browserPath, outDir, browserSrcDir));
204
+ if (!isQuiet) console.log(` ✓ ${relLabel} → ${relative('.', browserPath)}${timing}`);
205
+ }
206
+
207
+ // Write default edge
208
+ if (output.edge) {
209
+ const edgePath = join(outDir, `${outBaseName}.edge.js`);
210
+ writeFileSync(edgePath, fixImportPaths(generateSourceMap(output.edge, edgePath), edgePath, outDir));
211
+ if (!isQuiet) console.log(` ✓ ${relLabel} → ${relative('.', edgePath)} [edge]${timing}`);
212
+ }
213
+
214
+ // Write named server blocks (multi-block)
215
+ if (output.multiBlock && output.servers) {
216
+ for (const [name, code] of Object.entries(output.servers)) {
217
+ if (name === 'default') continue;
218
+ const path = join(outDir, `${outBaseName}.server.${name}.js`);
219
+ writeFileSync(path, fixImportPaths(code, path, outDir));
220
+ if (!isQuiet) console.log(` ✓ ${relLabel} → ${relative('.', path)} [server:${name}]${timing}`);
221
+ }
222
+ }
223
+
224
+ // Write named edge blocks (multi-block)
225
+ if (output.multiBlock && output.edges) {
226
+ for (const [name, code] of Object.entries(output.edges)) {
227
+ if (name === 'default') continue;
228
+ const path = join(outDir, `${outBaseName}.edge.${name}.js`);
229
+ writeFileSync(path, fixImportPaths(code, path, outDir));
230
+ if (!isQuiet) console.log(` ✓ ${relLabel} → ${relative('.', path)} [edge:${name}]${timing}`);
231
+ }
232
+ }
233
+
234
+ // Write named browser blocks (multi-block)
235
+ if (output.multiBlock && output.browsers) {
236
+ for (const [name, code] of Object.entries(output.browsers)) {
237
+ if (name === 'default') continue;
238
+ const path = join(outDir, `${outBaseName}.browser.${name}.js`);
239
+ writeFileSync(path, fixImportPaths(code, path, outDir));
240
+ if (!isQuiet) console.log(` ✓ ${relLabel} → ${relative('.', path)} [browser:${name}]${timing}`);
241
+ }
242
+ }
243
+
244
+ // Update incremental build cache
245
+ if (!noCache) {
246
+ const outputPaths = {};
247
+ if (output.shared && output.shared.trim()) outputPaths.shared = join(outDir, `${outBaseName}.shared.js`);
248
+ if (output.server) outputPaths.server = join(outDir, `${outBaseName}.server.js`);
249
+ if (output.browser) outputPaths.browser = join(outDir, `${outBaseName}.browser.js`);
250
+ if (single) {
251
+ const absFile = files[0];
252
+ const sourceContent = readFileSync(absFile, 'utf-8');
253
+ buildCache.set(absFile, sourceContent, outputPaths);
254
+ } else {
255
+ buildCache.setGroup(`dir:${dir}`, files, outputPaths);
256
+ }
257
+ }
258
+ }
259
+ } catch (err) {
260
+ console.error(` ✗ ${relDir}: ${err.message}`);
261
+ errorCount++;
262
+ }
263
+ }
264
+
265
+ // Save incremental build cache and prune stale entries
266
+ if (!noCache) {
267
+ const dirKeys = [...dirGroups.keys()].map(d => `dir:${d}`);
268
+ buildCache.prune(tovaFiles, dirKeys);
269
+ buildCache.save();
270
+ }
271
+
272
+ const dirCount = dirGroups.size;
273
+ const totalElapsed = Date.now() - buildStart;
274
+ if (!isQuiet) {
275
+ const timingStr = isVerbose ? ` in ${totalElapsed}ms` : '';
276
+ const cachedStr = skippedCount > 0 ? ` (${skippedCount} cached)` : '';
277
+ console.log(`\n Build complete. ${dirCount - errorCount}/${dirCount} directory group(s) succeeded${cachedStr}${timingStr}.\n`);
278
+ }
279
+
280
+ // Security scorecard (shown with --verbose or --strict-security, suppressed with --quiet)
281
+ if ((isVerbose || buildStrictSecurity) && !isQuiet && _scorecardData) {
282
+ const scorecard = generateSecurityScorecard(
283
+ _scorecardData.securityConfig,
284
+ _scorecardData.warnings,
285
+ _scorecardData.hasServer,
286
+ _scorecardData.hasEdge
287
+ );
288
+ if (scorecard) console.log(scorecard.format());
289
+ }
290
+
291
+ if (errorCount > 0 && !isWatch) process.exit(1);
292
+
293
+ // Watch mode for build command
294
+ if (isWatch) {
295
+ console.log(' Watching for changes...\n');
296
+ let debounceTimer = null;
297
+ const watcher = fsWatch(srcDir, { recursive: true }, (event, filename) => {
298
+ if (!filename || !filename.endsWith('.tova')) return;
299
+ if (debounceTimer) clearTimeout(debounceTimer);
300
+ debounceTimer = setTimeout(async () => {
301
+ const changedPath = resolve(srcDir, filename);
302
+ invalidateFile(changedPath);
303
+ if (!isQuiet) console.log(` Rebuilding (${filename} changed)...`);
304
+ try {
305
+ await buildProject(args.filter(a => a !== '--watch'));
306
+ } catch (err) {
307
+ // Continue watching even on error
308
+ }
309
+ if (!isQuiet) console.log(' Watching for changes...\n');
310
+ }, 100);
311
+ });
312
+ // Keep process alive
313
+ await new Promise(() => {});
314
+ }
315
+ }
316
+
317
+ function cleanBuild(args) {
318
+ const config = resolveConfig(process.cwd());
319
+ const outDir = resolve(config.build?.output || '.tova-out');
320
+
321
+ if (existsSync(outDir)) {
322
+ rmSync(outDir, { recursive: true, force: true });
323
+ console.log(` Cleaned ${relative('.', outDir)}/`);
324
+ } else {
325
+ console.log(` Nothing to clean (${relative('.', outDir)}/ does not exist)`);
326
+ }
327
+ }
328
+
329
+ class SourceMapBuilder {
330
+ constructor(sourceFile, sourceFiles = null) {
331
+ this.sourceFile = sourceFile;
332
+ // Multi-source support: array of all source files for merged output
333
+ this.sourceFiles = sourceFiles || [sourceFile];
334
+ this._sourceIndex = new Map();
335
+ for (let i = 0; i < this.sourceFiles.length; i++) {
336
+ this._sourceIndex.set(this.sourceFiles[i], i);
337
+ }
338
+ this.mappings = [];
339
+ this.outputLine = 0;
340
+ this.outputCol = 0;
341
+ }
342
+
343
+ addMapping(sourceLine, sourceCol, outputLine, outputCol, sourceFile = null) {
344
+ const srcIdx = sourceFile ? (this._sourceIndex.get(sourceFile) || 0) : 0;
345
+ this.mappings.push({ sourceLine, sourceCol, outputLine, outputCol, sourceIdx: srcIdx });
346
+ }
347
+
348
+ // Generate a VLQ-encoded source map
349
+ generate(sourceContent, sourceContentsMap = null) {
350
+ const sources = this.sourceFiles;
351
+ const names = [];
352
+
353
+ // Build sourcesContent array for multi-source
354
+ let sourcesContent;
355
+ if (sourceContentsMap && sourceContentsMap instanceof Map) {
356
+ sourcesContent = sources.map(s => sourceContentsMap.get(s) || null);
357
+ } else if (sourceContent) {
358
+ sourcesContent = [sourceContent];
359
+ }
360
+
361
+ // Sort mappings by output position
362
+ this.mappings.sort((a, b) => a.outputLine - b.outputLine || a.outputCol - b.outputCol);
363
+
364
+ // Encode mappings using VLQ
365
+ let prevOutputCol = 0;
366
+ let prevSourceIdx = 0;
367
+ let prevSourceLine = 0;
368
+ let prevSourceCol = 0;
369
+ let currentOutputLine = 0;
370
+ const lines = [];
371
+ let currentLine = [];
372
+
373
+ for (const m of this.mappings) {
374
+ // Fill empty lines
375
+ while (currentOutputLine < m.outputLine) {
376
+ lines.push(currentLine.join(','));
377
+ currentLine = [];
378
+ currentOutputLine++;
379
+ prevOutputCol = 0;
380
+ }
381
+
382
+ const segment = [];
383
+ segment.push(this._vlqEncode(m.outputCol - prevOutputCol));
384
+ segment.push(this._vlqEncode(m.sourceIdx - prevSourceIdx));
385
+ segment.push(this._vlqEncode(m.sourceLine - prevSourceLine));
386
+ segment.push(this._vlqEncode(m.sourceCol - prevSourceCol));
387
+
388
+ currentLine.push(segment.join(''));
389
+ prevOutputCol = m.outputCol;
390
+ prevSourceIdx = m.sourceIdx;
391
+ prevSourceLine = m.sourceLine;
392
+ prevSourceCol = m.sourceCol;
393
+ }
394
+ lines.push(currentLine.join(','));
395
+
396
+ const outFile = typeof this.sourceFile === 'string' ? this.sourceFile.replace('.tova', '.js') : 'merged.js';
397
+ return JSON.stringify({
398
+ version: 3,
399
+ file: outFile,
400
+ sources,
401
+ sourcesContent: sourcesContent || undefined,
402
+ names,
403
+ mappings: lines.join(';'),
404
+ });
405
+ }
406
+
407
+ _vlqEncode(value) {
408
+ let vlq = value < 0 ? ((-value) << 1) + 1 : value << 1;
409
+ const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
410
+ let encoded = '';
411
+ do {
412
+ let digit = vlq & 0x1f;
413
+ vlq >>= 5;
414
+ if (vlq > 0) digit |= 0x20;
415
+ encoded += chars[digit];
416
+ } while (vlq > 0);
417
+ return encoded;
418
+ }
419
+
420
+ toDataURL(sourceContent, sourceContentsMap = null) {
421
+ const mapJson = this.generate(sourceContent, sourceContentsMap);
422
+ const base64 = Buffer.from(mapJson).toString('base64');
423
+ return `//# sourceMappingURL=data:application/json;base64,${base64}`;
424
+ }
425
+ }
426
+
427
+ async function binaryBuild(srcDir, outputName, outDir) {
428
+ const tovaFiles = findFiles(srcDir, '.tova');
429
+ if (tovaFiles.length === 0) {
430
+ console.error('No .tova files found');
431
+ process.exit(1);
432
+ }
433
+
434
+ const tmpDir = join(outDir, '.tova-binary-tmp');
435
+ mkdirSync(tmpDir, { recursive: true });
436
+
437
+ console.log(`\n Compiling to binary: ${outputName}\n`);
438
+
439
+ // Step 1: Compile all .tova files to JS
440
+ const sharedParts = [];
441
+ const serverParts = [];
442
+ const browserParts = [];
443
+
444
+ for (const file of tovaFiles) {
445
+ try {
446
+ const source = readFileSync(file, 'utf-8');
447
+ const output = compileTova(source, file);
448
+ if (output.shared) sharedParts.push(output.shared);
449
+ if (output.server) serverParts.push(output.server);
450
+ if (output.browser) browserParts.push(output.browser);
451
+ } catch (err) {
452
+ console.error(` Error in ${relative(srcDir, file)}: ${err.message}`);
453
+ process.exit(1);
454
+ }
455
+ }
456
+
457
+ // Step 2: Bundle into a single JS file
458
+ const stdlib = getRunStdlib();
459
+ const allShared = sharedParts.join('\n');
460
+ const allServer = serverParts.join('\n');
461
+
462
+ let bundledCode;
463
+ if (allServer.trim()) {
464
+ // Server app
465
+ bundledCode = stdlib + '\n' + allShared + '\n' + allServer;
466
+ } else {
467
+ // Script/shared-only app
468
+ bundledCode = stdlib + '\n' + allShared;
469
+ // Auto-call main() if it exists
470
+ if (/\bfunction\s+main\s*\(/.test(bundledCode)) {
471
+ bundledCode += '\nconst __tova_exit = await main(process.argv.slice(2)); if (typeof __tova_exit === "number") process.exitCode = __tova_exit;\n';
472
+ }
473
+ }
474
+
475
+ // Strip import/export statements (everything is inlined)
476
+ bundledCode = bundledCode.replace(/^export /gm, '');
477
+ bundledCode = bundledCode.replace(/^\s*import\s+(?:\{[^}]*\}|[\w$]+|\*\s+as\s+[\w$]+)\s+from\s+['"][^'"]+['"];?\s*$/gm, '');
478
+
479
+ const entryPath = join(tmpDir, 'entry.js');
480
+ writeFileSync(entryPath, bundledCode);
481
+ console.log(` Compiled ${tovaFiles.length} file(s) to JS`);
482
+
483
+ // Step 3: Use Bun to compile to standalone binary
484
+ const outputPath = resolve(outputName);
485
+ try {
486
+ const { execFileSync } = await import('child_process');
487
+ execFileSync('bun', ['build', '--compile', entryPath, '--outfile', outputPath], {
488
+ stdio: ['pipe', 'pipe', 'pipe'],
489
+ env: process.env,
490
+ });
491
+
492
+ // Get file size
493
+ const stat = statSync(outputPath);
494
+ const sizeMB = (stat.size / (1024 * 1024)).toFixed(1);
495
+ console.log(` Created binary: ${outputPath} (${sizeMB}MB)`);
496
+ } finally {
497
+ // Clean up temp files
498
+ try {
499
+ rmSync(tmpDir, { recursive: true });
500
+ } catch {}
501
+ }
502
+
503
+ const displayPath = outputPath.startsWith(process.cwd()) ? './' + relative(process.cwd(), outputPath) : outputPath;
504
+ console.log(`\n Done! Run with: ${displayPath}\n`);
505
+ }
506
+
507
+ async function productionBuild(srcDir, outDir, isStatic = false) {
508
+ const config = resolveConfig(process.cwd());
509
+ const basePath = config.deploy?.base || '/';
510
+ const base = basePath.endsWith('/') ? basePath : basePath + '/';
511
+
512
+ const tovaFiles = findFiles(srcDir, '.tova');
513
+ if (tovaFiles.length === 0) {
514
+ console.error('No .tova files found');
515
+ process.exit(1);
516
+ }
517
+
518
+ mkdirSync(outDir, { recursive: true });
519
+
520
+ console.log(`\n Production build...\n`);
521
+
522
+ const browserParts = [];
523
+ const serverParts = [];
524
+ const sharedParts = [];
525
+ let cssContent = '';
526
+
527
+ for (const file of tovaFiles) {
528
+ try {
529
+ const source = readFileSync(file, 'utf-8');
530
+ const output = compileWithImports(source, file, srcDir);
531
+
532
+ if (output.shared) sharedParts.push(output.shared);
533
+ if (output.server) serverParts.push(output.server);
534
+ if (output.browser) browserParts.push(output.browser);
535
+ } catch (err) {
536
+ console.error(` Error in ${relative(srcDir, file)}: ${err.message}`);
537
+ process.exit(1);
538
+ }
539
+ }
540
+
541
+ const allClientCode = browserParts.join('\n');
542
+ const allServerCode = serverParts.join('\n');
543
+ const allSharedCode = sharedParts.join('\n');
544
+
545
+ // Generate content hash for cache busting
546
+ const hashCode = (s) => {
547
+ if (_hasBun) return Bun.hash(s).toString(16).slice(0, 12);
548
+ return _cryptoHash('md5').update(s).digest('hex').slice(0, 12);
549
+ };
550
+
551
+ // Write server bundle
552
+ if (allServerCode.trim()) {
553
+ const stdlib = getRunStdlib();
554
+ const serverBundle = stdlib + '\n' + allSharedCode + '\n' + allServerCode;
555
+ const hash = hashCode(serverBundle);
556
+ const serverPath = join(outDir, `server.${hash}.js`);
557
+ writeFileSync(serverPath, serverBundle);
558
+ console.log(` server.${hash}.js`);
559
+
560
+ // Write stable server.js entrypoint for Docker/deployment
561
+ writeFileSync(join(outDir, 'server.js'), `import "./server.${hash}.js";\n`);
562
+ }
563
+
564
+ // Write script bundle for plain scripts (no server/client blocks)
565
+ if (!allServerCode.trim() && !allClientCode.trim() && allSharedCode.trim()) {
566
+ const stdlib = getRunStdlib();
567
+ const scriptBundle = stdlib + '\n' + allSharedCode;
568
+ const hash = hashCode(scriptBundle);
569
+ const scriptPath = join(outDir, `script.${hash}.js`);
570
+ writeFileSync(scriptPath, scriptBundle);
571
+ console.log(` script.${hash}.js`);
572
+ }
573
+
574
+ // Write client bundle
575
+ if (allClientCode.trim()) {
576
+ const fullClientModule = allSharedCode + '\n' + allClientCode;
577
+
578
+ let clientBundle;
579
+ let useModule = false;
580
+
581
+ if (hasNpmImports(fullClientModule)) {
582
+ // npm imports detected — bundle with Bun.build to resolve bare specifiers
583
+ clientBundle = await bundleClientCode(fullClientModule, srcDir);
584
+ useModule = true;
585
+ } else {
586
+ // No npm imports — inline runtime, strip all imports
587
+ const reactivityCode = REACTIVITY_SOURCE.replace(/^export /gm, '');
588
+ const rpcCode = RPC_SOURCE.replace(/^export /gm, '');
589
+ const usesRouter = /\b(defineRoutes|Router|getPath|getQuery|getParams|getCurrentRoute|navigate|onRouteChange|beforeNavigate|afterNavigate|Outlet|Link|Redirect)\b/.test(allClientCode);
590
+ const routerCode = usesRouter ? ROUTER_SOURCE.replace(/^export /gm, '').replace(/^\s*import\s+(?:\{[^}]*\}|[\w$]+|\*\s+as\s+[\w$]+)\s+from\s+['"][^'"]+['"];?\s*$/gm, '') : '';
591
+ clientBundle = reactivityCode + '\n' + rpcCode + '\n' + (routerCode ? routerCode + '\n' : '') + allSharedCode + '\n' +
592
+ allClientCode.replace(/^\s*import\s+(?:\{[^}]*\}|[\w$]+|\*\s+as\s+[\w$]+)\s+from\s+['"][^'"]+['"];?\s*$/gm, '').trim();
593
+ }
594
+
595
+ const hash = hashCode(clientBundle);
596
+ const clientPath = join(outDir, `client.${hash}.js`);
597
+ writeFileSync(clientPath, clientBundle);
598
+ console.log(` client.${hash}.js`);
599
+
600
+ // Generate production HTML
601
+ const scriptTag = useModule
602
+ ? `<script type="module" src="${base}.tova-out/client.${hash}.js"></script>`
603
+ : `<script src="${base}.tova-out/client.${hash}.js"></script>`;
604
+ const html = `<!DOCTYPE html>
605
+ <html lang="en">
606
+ <head>
607
+ <meta charset="UTF-8">
608
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
609
+ <title>Tova App</title>
610
+ <script src="https://cdn.tailwindcss.com"></script>
611
+ <style>
612
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
613
+ body { font-family: system-ui, -apple-system, sans-serif; }
614
+ </style>
615
+ </head>
616
+ <body>
617
+ <div id="app"></div>
618
+ ${scriptTag}
619
+ </body>
620
+ </html>`;
621
+ writeFileSync(join(outDir, 'index.html'), html);
622
+ console.log(` index.html`);
623
+
624
+ // SPA fallback files for various static hosts
625
+ writeFileSync(join(outDir, '404.html'), html);
626
+ console.log(` 404.html (GitHub Pages SPA fallback)`);
627
+ writeFileSync(join(outDir, '200.html'), html);
628
+ console.log(` 200.html (Surge SPA fallback)`);
629
+ }
630
+
631
+ // Minify all JS bundles using Bun's built-in transpiler
632
+ const jsFiles = readdirSync(outDir).filter(f => f.endsWith('.js') && !f.endsWith('.min.js'));
633
+ let minified = 0;
634
+ for (const f of jsFiles) {
635
+ const filePath = join(outDir, f);
636
+ const minPath = join(outDir, f.replace('.js', '.min.js'));
637
+ try {
638
+ // Use Bun.Transpiler for minification without bundling (preserves imports)
639
+ const source = readFileSync(filePath, 'utf-8');
640
+ const transpiler = new Bun.Transpiler({ minifyWhitespace: true, trimUnusedImports: true });
641
+ const minCode = transpiler.transformSync(source);
642
+ writeFileSync(minPath, minCode);
643
+ const originalSize = Buffer.byteLength(source);
644
+ const minSize = Buffer.byteLength(minCode);
645
+ const ratio = ((1 - minSize / originalSize) * 100).toFixed(0);
646
+ console.log(` ${f.replace('.js', '.min.js')} (${_formatBytes(minSize)}, ${ratio}% smaller)`);
647
+ minified++;
648
+ } catch {
649
+ // Bun.build not available — fall back to simple whitespace stripping
650
+ try {
651
+ const source = readFileSync(filePath, 'utf-8');
652
+ const stripped = _simpleMinify(source);
653
+ writeFileSync(minPath, stripped);
654
+ const originalSize = Buffer.byteLength(source);
655
+ const minSize = Buffer.byteLength(stripped);
656
+ const ratio = ((1 - minSize / originalSize) * 100).toFixed(0);
657
+ console.log(` ${f.replace('.js', '.min.js')} (${_formatBytes(minSize)}, ${ratio}% smaller)`);
658
+ minified++;
659
+ } catch {
660
+ // Skip files that can't be minified
661
+ }
662
+ }
663
+ }
664
+
665
+ // Rewrite min entrypoints to import minified hashed files
666
+ for (const f of ['server.min.js', 'script.min.js']) {
667
+ const minEntry = join(outDir, f);
668
+ try {
669
+ const content = readFileSync(minEntry, 'utf-8');
670
+ const rewritten = content.replace(/\.js(["'])/g, '.min.js$1');
671
+ writeFileSync(minEntry, rewritten);
672
+ } catch {}
673
+ }
674
+
675
+ if (minified === 0 && jsFiles.length > 0) {
676
+ console.log(' (minification skipped — Bun.build unavailable)');
677
+ }
678
+
679
+ // Static generation: pre-render each route to its own HTML file
680
+ if (isStatic && allClientCode.trim()) {
681
+ console.log(`\n Static generation...\n`);
682
+
683
+ const routePaths = extractRoutePaths(allClientCode);
684
+ if (routePaths.length > 0) {
685
+ // Read the generated index.html to use as the shell for all routes
686
+ const shellHtml = readFileSync(join(outDir, 'index.html'), 'utf-8');
687
+ for (const routePath of routePaths) {
688
+ const htmlPath = routePath === '/'
689
+ ? join(outDir, 'index.html')
690
+ : join(outDir, routePath.replace(/^\//, ''), 'index.html');
691
+
692
+ mkdirSync(dirname(htmlPath), { recursive: true });
693
+ writeFileSync(htmlPath, shellHtml);
694
+ const relPath = relative(outDir, htmlPath);
695
+ console.log(` ${relPath}`);
696
+ }
697
+ console.log(`\n Pre-rendered ${routePaths.length} route(s)`);
698
+ }
699
+ }
700
+
701
+ console.log(`\n Production build complete.\n`);
702
+ }
703
+
704
+ function extractRoutePaths(code) {
705
+ // Support both defineRoutes({...}) and createRouter({ routes: {...} })
706
+ let match = code.match(/defineRoutes\s*\(\s*\{([^}]+)\}\s*\)/);
707
+ if (!match) {
708
+ match = code.match(/routes\s*:\s*\{([^}]+)\}/);
709
+ }
710
+ if (!match) return [];
711
+
712
+ const paths = [];
713
+ const entries = match[1].matchAll(/"([^"]+)"\s*:/g);
714
+ for (const entry of entries) {
715
+ const path = entry[1];
716
+ if (path === '404' || path === '*') continue;
717
+ if (path.includes(':')) continue;
718
+ paths.push(path);
719
+ }
720
+ return paths;
721
+ }
722
+
723
+ // Fallback JS minifier — string/regex-aware, no AST required
724
+ function _simpleMinify(code) {
725
+ // Phase 1: Strip comments while respecting strings and regexes
726
+ let stripped = '';
727
+ let i = 0;
728
+ const len = code.length;
729
+ while (i < len) {
730
+ const ch = code[i];
731
+ // String literals — pass through unchanged
732
+ if (ch === '"' || ch === "'" || ch === '`') {
733
+ const quote = ch;
734
+ stripped += ch;
735
+ i++;
736
+ while (i < len) {
737
+ const c = code[i];
738
+ stripped += c;
739
+ if (c === '\\') { i++; if (i < len) { stripped += code[i]; i++; } continue; }
740
+ if (quote === '`' && c === '$' && code[i + 1] === '{') {
741
+ // Template literal expression — track brace depth
742
+ stripped += code[++i]; // '{'
743
+ i++;
744
+ let depth = 1;
745
+ while (i < len && depth > 0) {
746
+ const tc = code[i];
747
+ if (tc === '{') depth++;
748
+ else if (tc === '}') depth--;
749
+ if (depth > 0) { stripped += tc; i++; }
750
+ }
751
+ if (i < len) { stripped += code[i]; i++; } // closing '}'
752
+ continue;
753
+ }
754
+ if (c === quote) { i++; break; }
755
+ i++;
756
+ }
757
+ continue;
758
+ }
759
+ // Single-line comment
760
+ if (ch === '/' && code[i + 1] === '/') {
761
+ while (i < len && code[i] !== '\n') i++;
762
+ continue;
763
+ }
764
+ // Multi-line comment
765
+ if (ch === '/' && code[i + 1] === '*') {
766
+ i += 2;
767
+ while (i < len - 1 && !(code[i] === '*' && code[i + 1] === '/')) i++;
768
+ i += 2;
769
+ continue;
770
+ }
771
+ stripped += ch;
772
+ i++;
773
+ }
774
+
775
+ // Phase 2: Process line by line
776
+ const lines = stripped.split('\n');
777
+ const out = [];
778
+ for (let j = 0; j < lines.length; j++) {
779
+ let line = lines[j].trim();
780
+ if (!line) continue; // remove blank lines
781
+ // Strip console.log/debug/warn/info statements (production only)
782
+ if (/^\s*console\.(log|debug|warn|info)\s*\(/.test(line)) {
783
+ // Simple balanced-paren check for single-line console calls
784
+ let parens = 0, inStr = false, sq = '';
785
+ for (let k = 0; k < line.length; k++) {
786
+ const c = line[k];
787
+ if (inStr) { if (c === '\\') k++; else if (c === sq) inStr = false; continue; }
788
+ if (c === '"' || c === "'" || c === '`') { inStr = true; sq = c; continue; }
789
+ if (c === '(') parens++;
790
+ if (c === ')') { parens--; if (parens === 0) break; }
791
+ }
792
+ if (parens === 0) continue; // balanced — safe to strip
793
+ }
794
+ out.push(line);
795
+ }
796
+
797
+ // Phase 3: Collapse whitespace — protect string literals with placeholders
798
+ let joined = out.join('\n');
799
+ const strings = [];
800
+ // Extract all string literals into placeholders so regexes don't mangle them
801
+ let prot = '';
802
+ let pi = 0;
803
+ const plen = joined.length;
804
+ while (pi < plen) {
805
+ const pc = joined[pi];
806
+ if (pc === '"' || pc === "'" || pc === '`') {
807
+ const q = pc;
808
+ let s = pc;
809
+ pi++;
810
+ while (pi < plen) {
811
+ const c = joined[pi];
812
+ s += c;
813
+ if (c === '\\') { pi++; if (pi < plen) { s += joined[pi]; pi++; } continue; }
814
+ if (q === '`' && c === '$' && joined[pi + 1] === '{') {
815
+ s += joined[++pi]; pi++;
816
+ let d = 1;
817
+ while (pi < plen && d > 0) {
818
+ const tc = joined[pi];
819
+ if (tc === '{') d++; else if (tc === '}') d--;
820
+ if (d > 0) { s += tc; pi++; }
821
+ }
822
+ if (pi < plen) { s += joined[pi]; pi++; }
823
+ continue;
824
+ }
825
+ if (c === q) { pi++; break; }
826
+ pi++;
827
+ }
828
+ strings.push(s);
829
+ prot += `\x01${strings.length - 1}\x01`;
830
+ } else {
831
+ prot += pc;
832
+ pi++;
833
+ }
834
+ }
835
+
836
+ // Collapse runs of spaces/tabs to single space
837
+ prot = prot.replace(/[ \t]+/g, ' ');
838
+ // Remove spaces around braces, brackets, parens, semicolons, commas
839
+ prot = prot.replace(/ ?([{}[\]();,]) ?/g, '$1');
840
+ // Restore space after keywords that need it
841
+ prot = prot.replace(/\b(return|const|let|var|if|else|for|while|do|switch|case|throw|new|typeof|instanceof|in|of|yield|await|export|import|from|function|class|extends|async|delete|void)\b(?=[^\s;,})\]])/g, '$1 ');
842
+ // Add space after colon in object literals (key: value)
843
+ prot = prot.replace(/([a-zA-Z0-9_$]):([^\s}])/g, '$1: $2');
844
+
845
+ // Restore string literals
846
+ let result = prot.replace(/\x01(\d+)\x01/g, (_, idx) => strings[idx]);
847
+
848
+ // Phase 4: Dead function elimination — remove unused top-level functions
849
+ result = _eliminateDeadFunctions(result);
850
+
851
+ return result;
852
+ }
853
+
854
+ // Remove top-level function declarations that are never reachable from non-function code.
855
+ // Uses reachability analysis to handle mutual recursion (foo<->bar both dead).
856
+ function _eliminateDeadFunctions(code) {
857
+ const funcDeclRe = /^function\s+([\w$]+)\s*\(/gm;
858
+ const allDecls = []; // [{ name, start, end }]
859
+ let m;
860
+
861
+ // First pass: find all top-level function declarations and their full extent
862
+ while ((m = funcDeclRe.exec(code)) !== null) {
863
+ const name = m[1];
864
+ const start = m.index;
865
+ let depth = 0, i = start, inStr = false, strCh = '', foundOpen = false;
866
+ while (i < code.length) {
867
+ const ch = code[i];
868
+ if (inStr) { if (ch === '\\') { i += 2; continue; } if (ch === strCh) inStr = false; i++; continue; }
869
+ if (ch === '"' || ch === "'" || ch === '`') { inStr = true; strCh = ch; i++; continue; }
870
+ if (ch === '{') { depth++; foundOpen = true; }
871
+ else if (ch === '}') { depth--; if (foundOpen && depth === 0) { i++; break; } }
872
+ i++;
873
+ }
874
+ allDecls.push({ name, start, end: i });
875
+ }
876
+
877
+ if (allDecls.length === 0) return code;
878
+
879
+ // Build a set of all declared function names
880
+ const declaredNames = new Set(allDecls.map(d => d.name));
881
+
882
+ // Helper: find which declared names are referenced in a text region
883
+ function findRefs(text) {
884
+ const refs = new Set();
885
+ for (const name of declaredNames) {
886
+ const escaped = name.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
887
+ if (new RegExp('\\b' + escaped + '\\b').test(text)) refs.add(name);
888
+ }
889
+ return refs;
890
+ }
891
+
892
+ // Build "root" code — everything outside function declarations
893
+ const sortedDecls = [...allDecls].sort((a, b) => a.start - b.start);
894
+ let rootCode = '';
895
+ let pos = 0;
896
+ for (const decl of sortedDecls) {
897
+ rootCode += code.slice(pos, decl.start);
898
+ pos = decl.end;
899
+ }
900
+ rootCode += code.slice(pos);
901
+
902
+ // Find which functions are directly reachable from root code
903
+ const rootRefs = findRefs(rootCode);
904
+
905
+ // Build dependency graph: for each function, which other declared functions does it reference?
906
+ const deps = new Map();
907
+ for (const decl of allDecls) {
908
+ const body = code.slice(decl.start, decl.end);
909
+ const bodyRefs = findRefs(body);
910
+ bodyRefs.delete(decl.name); // ignore self-references
911
+ deps.set(decl.name, bodyRefs);
912
+ }
913
+
914
+ // BFS from root refs to find all transitively reachable functions
915
+ const reachable = new Set();
916
+ const queue = [...rootRefs];
917
+ while (queue.length > 0) {
918
+ const name = queue.pop();
919
+ if (reachable.has(name)) continue;
920
+ reachable.add(name);
921
+ const fnDeps = deps.get(name);
922
+ if (fnDeps) for (const dep of fnDeps) queue.push(dep);
923
+ }
924
+
925
+ // Remove unreachable functions
926
+ const toRemove = allDecls.filter(d => !reachable.has(d.name));
927
+ if (toRemove.length === 0) return code;
928
+
929
+ toRemove.sort((a, b) => b.start - a.start);
930
+ let result = code;
931
+ for (const { start, end } of toRemove) {
932
+ let removeEnd = end;
933
+ while (removeEnd < result.length && (result[removeEnd] === '\n' || result[removeEnd] === '\r')) removeEnd++;
934
+ result = result.slice(0, start) + result.slice(removeEnd);
935
+ }
936
+
937
+ return result;
938
+ }
939
+
940
+ class BuildCache {
941
+ constructor(cacheDir) {
942
+ this._cacheDir = cacheDir;
943
+ this._manifest = null; // { files: { [absPath]: { hash, outputs: {...} } } }
944
+ }
945
+
946
+ _manifestPath() {
947
+ return join(this._cacheDir, 'manifest.json');
948
+ }
949
+
950
+ _hashContent(content) {
951
+ if (typeof Bun !== 'undefined' && Bun.hash) return Bun.hash(content).toString(16);
952
+ return _cryptoHash('md5').update(content).digest('hex');
953
+ }
954
+
955
+ load() {
956
+ try {
957
+ if (existsSync(this._manifestPath())) {
958
+ this._manifest = JSON.parse(readFileSync(this._manifestPath(), 'utf-8'));
959
+ }
960
+ } catch {
961
+ this._manifest = null;
962
+ }
963
+ if (!this._manifest) this._manifest = { files: {} };
964
+ }
965
+
966
+ save() {
967
+ mkdirSync(this._cacheDir, { recursive: true });
968
+ writeFileSync(this._manifestPath(), JSON.stringify(this._manifest, null, 2));
969
+ }
970
+
971
+ // Check if a source file is unchanged since last build
972
+ isUpToDate(absPath, sourceContent) {
973
+ if (!this._manifest) return false;
974
+ const entry = this._manifest.files[absPath];
975
+ if (!entry) return false;
976
+ return entry.hash === this._hashContent(sourceContent);
977
+ }
978
+
979
+ // Check if a multi-file group (directory) is unchanged since last build
980
+ isGroupUpToDate(dirKey, files) {
981
+ if (!this._manifest) return false;
982
+ const entry = this._manifest.files[dirKey];
983
+ if (!entry) return false;
984
+ return entry.hash === this._hashGroup(files);
985
+ }
986
+
987
+ // Hash multiple files together for group caching
988
+ _hashGroup(files) {
989
+ let combined = '';
990
+ for (const f of files.slice().sort()) {
991
+ combined += f + readFileSync(f, 'utf-8');
992
+ }
993
+ if (typeof Bun !== 'undefined' && Bun.hash) return Bun.hash(combined).toString(16);
994
+ return _cryptoHash('md5').update(combined).digest('hex');
995
+ }
996
+
997
+ // Store compiled output for a multi-file group
998
+ setGroup(dirKey, files, outputs) {
999
+ if (!this._manifest) this._manifest = { files: {} };
1000
+ this._manifest.files[dirKey] = {
1001
+ hash: this._hashGroup(files),
1002
+ outputs,
1003
+ timestamp: Date.now(),
1004
+ };
1005
+ }
1006
+
1007
+ // Get cached compiled output for a source file
1008
+ getCached(absPath) {
1009
+ if (!this._manifest) return null;
1010
+ const entry = this._manifest.files[absPath];
1011
+ if (!entry || !entry.outputs) return null;
1012
+ // Verify cached output files still exist on disk
1013
+ for (const outFile of Object.values(entry.outputs)) {
1014
+ if (outFile && !existsSync(outFile)) return null;
1015
+ }
1016
+ return entry.outputs;
1017
+ }
1018
+
1019
+ // Store compiled output for a source file
1020
+ set(absPath, sourceContent, outputs) {
1021
+ if (!this._manifest) this._manifest = { files: {} };
1022
+ this._manifest.files[absPath] = {
1023
+ hash: this._hashContent(sourceContent),
1024
+ outputs,
1025
+ timestamp: Date.now(),
1026
+ };
1027
+ }
1028
+
1029
+ // Remove stale entries for files/dirs that no longer exist
1030
+ prune(existingFiles, existingDirs) {
1031
+ if (!this._manifest) return;
1032
+ const existingSet = new Set(existingFiles);
1033
+ const dirSet = existingDirs ? new Set(existingDirs) : null;
1034
+ for (const key of Object.keys(this._manifest.files)) {
1035
+ if (key.startsWith('dir:')) {
1036
+ if (dirSet && !dirSet.has(key)) delete this._manifest.files[key];
1037
+ } else if (!existingSet.has(key)) {
1038
+ delete this._manifest.files[key];
1039
+ }
1040
+ }
1041
+ }
1042
+ }
1043
+
1044
+ export { buildProject, cleanBuild };