refactor cli
This commit is contained in:
parent
be5961dde9
commit
0fe8b0fdd2
|
|
@ -9,9 +9,17 @@ const args = process.argv.slice(3);
|
|||
if (!command) {
|
||||
console.log('Usage: qu <command> [options]');
|
||||
console.log('\nAvailable commands:');
|
||||
console.log(' build Build the application');
|
||||
console.log(' build [options] Build the application');
|
||||
console.log(' serve [options] Watch and rebuild on file changes');
|
||||
console.log(' --port, -p Specify port (default: 4300)');
|
||||
console.log('\nGlobal options:');
|
||||
console.log(' -c, --configuration <env> Specify environment (development/production)');
|
||||
console.log(' -e, --environment <env> Alias for --configuration');
|
||||
console.log('\nBuild options:');
|
||||
console.log(' -v, --verbose Show detailed build logs');
|
||||
console.log('\nServe options:');
|
||||
console.log(' -p, --port <port> Specify port (default: 4200)');
|
||||
console.log(' -v, --verbose Show detailed server logs');
|
||||
console.log('\nOther commands:');
|
||||
console.log(' help Show this help message');
|
||||
process.exit(0);
|
||||
}
|
||||
|
|
@ -19,14 +27,27 @@ if (!command) {
|
|||
if (command === 'help' || command === '--help' || command === '-h') {
|
||||
console.log('Usage: qu <command> [options]');
|
||||
console.log('\nAvailable commands:');
|
||||
console.log(' build Build the application');
|
||||
console.log(' build [options] Build the application');
|
||||
console.log(' serve [options] Watch and rebuild on file changes');
|
||||
console.log(' --port, -p Specify port (default: 4300)');
|
||||
console.log('\nGlobal options:');
|
||||
console.log(' -c, --configuration <env> Specify environment (development/production)');
|
||||
console.log(' -e, --environment <env> Alias for --configuration');
|
||||
console.log('\nBuild options:');
|
||||
console.log(' -v, --verbose Show detailed build logs');
|
||||
console.log('\nServe options:');
|
||||
console.log(' -p, --port <port> Specify port (default: 4200)');
|
||||
console.log(' -v, --verbose Show detailed server logs');
|
||||
console.log('\nOther commands:');
|
||||
console.log(' help Show this help message');
|
||||
console.log('\nExamples:');
|
||||
console.log(' qu build');
|
||||
console.log(' qu build -c production');
|
||||
console.log(' qu build -v');
|
||||
console.log(' qu build -c production --verbose');
|
||||
console.log(' qu serve');
|
||||
console.log(' qu serve --port 3000');
|
||||
console.log(' qu serve -p 8080');
|
||||
console.log(' qu serve -v');
|
||||
console.log(' qu serve -c development --port 3000');
|
||||
console.log(' qu serve -p 8080 --verbose');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
|
|
@ -49,7 +70,7 @@ if (command === 'build') {
|
|||
try {
|
||||
const cwd = process.cwd();
|
||||
const cliPath = findQuarcCliPath(cwd);
|
||||
const buildScript = path.join(cliPath, 'build.ts');
|
||||
const buildScript = path.join(cliPath, 'scripts', 'build.ts');
|
||||
const tsNodePath = path.join(cwd, 'node_modules', '.bin', 'ts-node');
|
||||
const buildArgs = args.join(' ');
|
||||
execSync(`${tsNodePath} ${buildScript} ${buildArgs}`, { stdio: 'inherit', cwd });
|
||||
|
|
@ -60,9 +81,10 @@ if (command === 'build') {
|
|||
try {
|
||||
const cwd = process.cwd();
|
||||
const cliPath = findQuarcCliPath(cwd);
|
||||
const serveScript = path.join(cliPath, 'serve.ts');
|
||||
const serveScript = path.join(cliPath, 'scripts', 'serve.ts');
|
||||
const tsNodePath = path.join(cwd, 'node_modules', '.bin', 'ts-node');
|
||||
execSync(`${tsNodePath} ${serveScript}`, { stdio: 'inherit', cwd });
|
||||
const serveArgs = args.join(' ');
|
||||
execSync(`${tsNodePath} ${serveScript} ${serveArgs}`, { stdio: 'inherit', cwd });
|
||||
} catch (error) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
|
|
|||
710
cli/build.ts
710
cli/build.ts
|
|
@ -1,710 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as zlib from 'zlib';
|
||||
import { execSync } from 'child_process';
|
||||
import * as esbuild from 'esbuild';
|
||||
import { minify } from 'terser';
|
||||
import Table from 'cli-table3';
|
||||
import * as sass from 'sass';
|
||||
import { liteTransformer } from './lite-transformer';
|
||||
import { consoleTransformer } from './build/transformers/console-transformer';
|
||||
|
||||
const projectRoot = process.cwd();
|
||||
const srcDir = path.join(projectRoot, 'src');
|
||||
const publicDir = path.join(srcDir, 'public');
|
||||
const distDir = path.join(projectRoot, 'dist');
|
||||
const configPath = path.join(projectRoot, 'quarc.json');
|
||||
|
||||
interface SizeThreshold {
|
||||
warning: string;
|
||||
error: string;
|
||||
}
|
||||
|
||||
interface EnvironmentConfig {
|
||||
treatWarningsAsErrors: boolean;
|
||||
minifyNames: boolean;
|
||||
generateSourceMaps: boolean;
|
||||
compressed?: boolean;
|
||||
}
|
||||
|
||||
interface ActionsConfig {
|
||||
prebuild?: string[];
|
||||
postbuild?: string[];
|
||||
}
|
||||
|
||||
interface LiteConfig {
|
||||
environment: string;
|
||||
build: {
|
||||
actions?: ActionsConfig;
|
||||
minifyNames: boolean;
|
||||
scripts?: string[];
|
||||
externalEntryPoints?: string[];
|
||||
styles?: string[];
|
||||
externalStyles?: string[];
|
||||
limits: {
|
||||
total: SizeThreshold;
|
||||
main: SizeThreshold;
|
||||
sourceMaps: SizeThreshold;
|
||||
components?: SizeThreshold;
|
||||
};
|
||||
};
|
||||
environments: {
|
||||
[key: string]: EnvironmentConfig;
|
||||
};
|
||||
}
|
||||
|
||||
interface ValidationResult {
|
||||
status: 'success' | 'warning' | 'error';
|
||||
message: string;
|
||||
actual: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
function parseSizeString(sizeStr: string): number {
|
||||
const match = sizeStr.match(/^([\d.]+)\s*(B|KB|MB|GB)$/i);
|
||||
if (!match) throw new Error(`Invalid size format: ${sizeStr}`);
|
||||
|
||||
const value = parseFloat(match[1]);
|
||||
const unit = match[2].toUpperCase();
|
||||
|
||||
const multipliers: { [key: string]: number } = {
|
||||
B: 1,
|
||||
KB: 1024,
|
||||
MB: 1024 * 1024,
|
||||
GB: 1024 * 1024 * 1024,
|
||||
};
|
||||
|
||||
return value * (multipliers[unit] || 1);
|
||||
}
|
||||
|
||||
function getCliEnvironment(): string | undefined {
|
||||
const args = process.argv.slice(2);
|
||||
const envIndex = args.findIndex(arg => arg === '--environment' || arg === '-e');
|
||||
if (envIndex !== -1 && args[envIndex + 1]) {
|
||||
return args[envIndex + 1];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function loadConfig(): LiteConfig {
|
||||
const cliEnv = getCliEnvironment();
|
||||
|
||||
if (!fs.existsSync(configPath)) {
|
||||
return {
|
||||
environment: cliEnv ?? 'development',
|
||||
build: {
|
||||
minifyNames: false,
|
||||
limits: {
|
||||
total: { warning: '50 KB', error: '60 KB' },
|
||||
main: { warning: '15 KB', error: '20 KB' },
|
||||
sourceMaps: { warning: '10 KB', error: '20 KB' },
|
||||
},
|
||||
},
|
||||
environments: {
|
||||
development: {
|
||||
treatWarningsAsErrors: false,
|
||||
minifyNames: false,
|
||||
generateSourceMaps: true,
|
||||
},
|
||||
production: {
|
||||
treatWarningsAsErrors: true,
|
||||
minifyNames: true,
|
||||
generateSourceMaps: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
const config = JSON.parse(content) as LiteConfig;
|
||||
|
||||
if (cliEnv) {
|
||||
config.environment = cliEnv;
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
function getEnvironmentConfig(config: LiteConfig): EnvironmentConfig {
|
||||
const envConfig = config.environments[config.environment];
|
||||
if (!envConfig) {
|
||||
console.warn(`Environment '${config.environment}' not found in config, using defaults`);
|
||||
return {
|
||||
treatWarningsAsErrors: false,
|
||||
minifyNames: false,
|
||||
generateSourceMaps: true,
|
||||
};
|
||||
}
|
||||
return envConfig;
|
||||
}
|
||||
|
||||
function ensureDirectoryExists(dir: string): void {
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function copyDirectory(src: string, dest: string): void {
|
||||
if (!fs.existsSync(src)) {
|
||||
console.warn(`Source directory not found: ${src}`);
|
||||
return;
|
||||
}
|
||||
|
||||
ensureDirectoryExists(dest);
|
||||
|
||||
const files = fs.readdirSync(src);
|
||||
files.forEach(file => {
|
||||
const srcPath = path.join(src, file);
|
||||
const destPath = path.join(dest, file);
|
||||
const stat = fs.statSync(srcPath);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
copyDirectory(srcPath, destPath);
|
||||
} else {
|
||||
fs.copyFileSync(srcPath, destPath);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function bundleTypeScript(): Promise<void> {
|
||||
try {
|
||||
console.log('Bundling TypeScript with esbuild...');
|
||||
const config = loadConfig();
|
||||
const envConfig = getEnvironmentConfig(config);
|
||||
const mainTsPath = path.join(srcDir, 'main.ts');
|
||||
|
||||
await esbuild.build({
|
||||
entryPoints: [mainTsPath],
|
||||
bundle: true,
|
||||
minify: false,
|
||||
sourcemap: envConfig.generateSourceMaps,
|
||||
outdir: distDir,
|
||||
format: 'esm',
|
||||
target: 'ES2020',
|
||||
splitting: true,
|
||||
chunkNames: 'chunks/[name]-[hash]',
|
||||
external: [],
|
||||
plugins: [liteTransformer(), consoleTransformer()],
|
||||
tsconfig: path.join(projectRoot, 'tsconfig.json'),
|
||||
treeShaking: true,
|
||||
logLevel: 'info',
|
||||
define: {
|
||||
'process.env.NODE_ENV': config.environment === 'production' ? '"production"' : '"development"',
|
||||
},
|
||||
drop: config.environment === 'production' ? ['console', 'debugger'] : ['debugger'],
|
||||
pure: config.environment === 'production' ? ['console.log', 'console.error', 'console.warn', 'console.info', 'console.debug'] : [],
|
||||
globalName: undefined,
|
||||
});
|
||||
|
||||
console.log('TypeScript bundling completed.');
|
||||
await bundleExternalEntryPoints();
|
||||
await obfuscateAndMinifyBundles();
|
||||
} catch (error) {
|
||||
console.error('TypeScript bundling failed:', error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function bundleExternalEntryPoints(): Promise<void> {
|
||||
const config = loadConfig();
|
||||
const envConfig = getEnvironmentConfig(config);
|
||||
const externalEntryPoints = config.build.externalEntryPoints || [];
|
||||
|
||||
if (externalEntryPoints.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Bundling external entry points...');
|
||||
const externalDistDir = path.join(distDir, 'external');
|
||||
ensureDirectoryExists(externalDistDir);
|
||||
|
||||
for (const entryPoint of externalEntryPoints) {
|
||||
const entryPath = path.join(projectRoot, entryPoint);
|
||||
|
||||
if (!fs.existsSync(entryPath)) {
|
||||
console.warn(`External entry point not found: ${entryPath}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const basename = path.basename(entryPoint, '.ts');
|
||||
|
||||
await esbuild.build({
|
||||
entryPoints: [entryPath],
|
||||
bundle: true,
|
||||
minify: false,
|
||||
sourcemap: envConfig.generateSourceMaps,
|
||||
outfile: path.join(externalDistDir, `${basename}.js`),
|
||||
format: 'esm',
|
||||
target: 'ES2020',
|
||||
splitting: false,
|
||||
external: [],
|
||||
plugins: [liteTransformer(), consoleTransformer()],
|
||||
tsconfig: path.join(projectRoot, 'tsconfig.json'),
|
||||
treeShaking: true,
|
||||
logLevel: 'info',
|
||||
define: {
|
||||
'process.env.NODE_ENV': config.environment === 'production' ? '"production"' : '"development"',
|
||||
},
|
||||
drop: config.environment === 'production' ? ['console', 'debugger'] : ['debugger'],
|
||||
pure: config.environment === 'production' ? ['console.log', 'console.error', 'console.warn', 'console.info', 'console.debug'] : [],
|
||||
});
|
||||
|
||||
console.log(`✓ Bundled external: ${basename}.js`);
|
||||
}
|
||||
}
|
||||
|
||||
async function obfuscateAndMinifyBundles(): Promise<void> {
|
||||
try {
|
||||
console.log('Applying advanced obfuscation and minification...');
|
||||
const config = loadConfig();
|
||||
const envConfig = getEnvironmentConfig(config);
|
||||
|
||||
const collectJsFiles = (dir: string, prefix = ''): { file: string; filePath: string }[] => {
|
||||
const results: { file: string; filePath: string }[] = [];
|
||||
if (!fs.existsSync(dir)) return results;
|
||||
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name;
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
results.push(...collectJsFiles(fullPath, relativePath));
|
||||
} else if (entry.name.endsWith('.js') && !entry.name.endsWith('.map')) {
|
||||
results.push({ file: relativePath, filePath: fullPath });
|
||||
}
|
||||
}
|
||||
return results;
|
||||
};
|
||||
|
||||
const jsFiles = collectJsFiles(distDir);
|
||||
|
||||
for (const { file, filePath } of jsFiles) {
|
||||
const code = fs.readFileSync(filePath, 'utf-8');
|
||||
|
||||
const result = await minify(code, {
|
||||
compress: {
|
||||
passes: 3,
|
||||
unsafe: true,
|
||||
unsafe_methods: true,
|
||||
unsafe_proto: true,
|
||||
drop_console: config.environment === 'production',
|
||||
drop_debugger: true,
|
||||
inline: 3,
|
||||
reduce_vars: true,
|
||||
reduce_funcs: true,
|
||||
collapse_vars: true,
|
||||
dead_code: true,
|
||||
evaluate: true,
|
||||
hoist_funs: true,
|
||||
hoist_vars: true,
|
||||
if_return: true,
|
||||
join_vars: true,
|
||||
loops: true,
|
||||
properties: false,
|
||||
sequences: true,
|
||||
side_effects: true,
|
||||
switches: true,
|
||||
typeofs: true,
|
||||
unused: true,
|
||||
},
|
||||
mangle: envConfig.minifyNames ? {
|
||||
toplevel: true,
|
||||
keep_classnames: false,
|
||||
keep_fnames: false,
|
||||
properties: false,
|
||||
} : false,
|
||||
output: {
|
||||
comments: false,
|
||||
beautify: false,
|
||||
max_line_len: 1000,
|
||||
},
|
||||
});
|
||||
|
||||
if (result.code) {
|
||||
fs.writeFileSync(filePath, result.code, 'utf-8');
|
||||
const originalSize = code.length;
|
||||
const newSize = result.code.length;
|
||||
const reduction = ((1 - newSize / originalSize) * 100).toFixed(2);
|
||||
console.log(`✓ ${file}: ${originalSize} → ${newSize} bytes (${reduction}% reduction)`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Obfuscation and minification completed.');
|
||||
} catch (error) {
|
||||
console.error('Obfuscation failed:', error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function compileStyleFile(stylePath: string, outputDir: string): Promise<void> {
|
||||
const fullPath = path.join(projectRoot, stylePath);
|
||||
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
console.warn(`Style file not found: ${fullPath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const ext = path.extname(fullPath);
|
||||
const basename = path.basename(fullPath, ext);
|
||||
const outputPath = path.join(outputDir, `${basename}.css`);
|
||||
|
||||
ensureDirectoryExists(outputDir);
|
||||
|
||||
if (ext === '.scss' || ext === '.sass') {
|
||||
try {
|
||||
const result = sass.compile(fullPath, {
|
||||
style: 'compressed',
|
||||
sourceMap: false,
|
||||
});
|
||||
fs.writeFileSync(outputPath, result.css, 'utf-8');
|
||||
console.log(`✓ Compiled ${stylePath} → ${basename}.css`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to compile ${stylePath}:`, error instanceof Error ? error.message : String(error));
|
||||
throw error;
|
||||
}
|
||||
} else if (ext === '.css') {
|
||||
fs.copyFileSync(fullPath, outputPath);
|
||||
console.log(`✓ Copied ${stylePath} → ${basename}.css`);
|
||||
}
|
||||
}
|
||||
|
||||
async function compileSCSS(): Promise<void> {
|
||||
const config = loadConfig();
|
||||
const styles = config.build.styles || [];
|
||||
const externalStyles = config.build.externalStyles || [];
|
||||
|
||||
if (styles.length === 0 && externalStyles.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Compiling SCSS files...');
|
||||
|
||||
for (const stylePath of styles) {
|
||||
await compileStyleFile(stylePath, distDir);
|
||||
}
|
||||
|
||||
const externalDistDir = path.join(distDir, 'external');
|
||||
for (const stylePath of externalStyles) {
|
||||
await compileStyleFile(stylePath, externalDistDir);
|
||||
}
|
||||
}
|
||||
|
||||
function injectScriptsAndStyles(indexPath: string): void {
|
||||
if (!fs.existsSync(indexPath)) {
|
||||
console.warn(`Index file not found: ${indexPath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const config = loadConfig();
|
||||
let html = fs.readFileSync(indexPath, 'utf-8');
|
||||
|
||||
const styles = config.build.styles || [];
|
||||
const scripts = config.build.scripts || [];
|
||||
|
||||
let styleInjections = '';
|
||||
for (const stylePath of styles) {
|
||||
const basename = path.basename(stylePath, path.extname(stylePath));
|
||||
const cssFile = `${basename}.css`;
|
||||
if (!html.includes(cssFile)) {
|
||||
styleInjections += ` <link rel="stylesheet" href="./${cssFile}">\n`;
|
||||
}
|
||||
}
|
||||
|
||||
if (styleInjections) {
|
||||
html = html.replace('</head>', `${styleInjections}</head>`);
|
||||
}
|
||||
|
||||
let scriptInjections = '';
|
||||
for (const scriptPath of scripts) {
|
||||
const basename = path.basename(scriptPath);
|
||||
if (!html.includes(basename)) {
|
||||
scriptInjections += ` <script type="module" src="./${basename}"></script>\n`;
|
||||
}
|
||||
}
|
||||
|
||||
const mainScript = ` <script type="module" src="./main.js"></script>\n`;
|
||||
if (!html.includes('main.js')) {
|
||||
scriptInjections += mainScript;
|
||||
}
|
||||
|
||||
if (scriptInjections) {
|
||||
html = html.replace('</body>', `${scriptInjections}</body>`);
|
||||
}
|
||||
|
||||
fs.writeFileSync(indexPath, html, 'utf-8');
|
||||
console.log('Injected scripts and styles into index.html');
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
function getGzipSize(content: Buffer | string): number {
|
||||
const buffer = typeof content === 'string' ? Buffer.from(content) : content;
|
||||
return zlib.gzipSync(buffer, { level: 9 }).length;
|
||||
}
|
||||
|
||||
function displayBuildStats(): void {
|
||||
const files: { name: string; size: number; gzipSize: number; path: string }[] = [];
|
||||
let totalSize = 0;
|
||||
let totalGzipSize = 0;
|
||||
let mainSize = 0;
|
||||
let mapSize = 0;
|
||||
let externalSize = 0;
|
||||
|
||||
const config = loadConfig();
|
||||
const envConfig = getEnvironmentConfig(config);
|
||||
const showGzip = envConfig.compressed ?? false;
|
||||
|
||||
const isExternalFile = (relativePath: string): boolean => relativePath.startsWith('external/');
|
||||
const isMapFile = (name: string): boolean => name.endsWith('.map');
|
||||
|
||||
const walkDir = (dir: string, prefix = ''): void => {
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
|
||||
entries.forEach(entry => {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name;
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
walkDir(fullPath, relativePath);
|
||||
} else if (!entry.name.endsWith('.gz')) {
|
||||
const content = fs.readFileSync(fullPath);
|
||||
const size = content.length;
|
||||
const gzipSize = getGzipSize(content);
|
||||
|
||||
files.push({ name: entry.name, size, gzipSize, path: relativePath });
|
||||
|
||||
if (isMapFile(entry.name)) {
|
||||
mapSize += size;
|
||||
} else if (isExternalFile(relativePath)) {
|
||||
externalSize += size;
|
||||
} else {
|
||||
totalSize += size;
|
||||
totalGzipSize += gzipSize;
|
||||
}
|
||||
|
||||
if (entry.name === 'main.js') mainSize = size;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
walkDir(distDir);
|
||||
files.sort((a, b) => b.size - a.size);
|
||||
|
||||
const validationResults: { [key: string]: ValidationResult } = {};
|
||||
|
||||
// Validate limits
|
||||
const totalWarning = parseSizeString(config.build.limits.total.warning);
|
||||
const totalError = parseSizeString(config.build.limits.total.error);
|
||||
const mainWarning = parseSizeString(config.build.limits.main.warning);
|
||||
const mainError = parseSizeString(config.build.limits.main.error);
|
||||
const mapWarning = parseSizeString(config.build.limits.sourceMaps.warning);
|
||||
const mapError = parseSizeString(config.build.limits.sourceMaps.error);
|
||||
|
||||
validationResults.total = validateSizeWithThresholds('Total Size', totalSize, totalWarning, totalError);
|
||||
validationResults.main = validateSizeWithThresholds('Main Bundle', mainSize, mainWarning, mainError);
|
||||
validationResults.sourceMaps = validateSizeWithThresholds('Source Maps', mapSize, mapWarning, mapError);
|
||||
|
||||
console.log(`\n📊 Size breakdown:`);
|
||||
console.log(` App total: ${formatBytes(totalSize)}`);
|
||||
console.log(` External: ${formatBytes(externalSize)}`);
|
||||
console.log(` Maps: ${formatBytes(mapSize)}`);
|
||||
|
||||
// Display table
|
||||
const tableHead = showGzip
|
||||
? ['📄 File', '💾 Size', '📦 Gzip', '✓ Status']
|
||||
: ['📄 File', '💾 Size', '✓ Status'];
|
||||
const colWidths = showGzip ? [32, 12, 12, 10] : [40, 15, 12];
|
||||
|
||||
const table = new Table({
|
||||
head: tableHead,
|
||||
style: { head: [], border: ['cyan'] },
|
||||
wordWrap: true,
|
||||
colWidths,
|
||||
});
|
||||
|
||||
files.forEach(file => {
|
||||
const sizeStr = formatBytes(file.size);
|
||||
const gzipStr = formatBytes(file.gzipSize);
|
||||
const fileName = file.path.length > 28 ? file.path.substring(0, 25) + '...' : file.path;
|
||||
|
||||
if (showGzip) {
|
||||
table.push([fileName, sizeStr, gzipStr, '✓']);
|
||||
} else {
|
||||
table.push([fileName, sizeStr, '✓']);
|
||||
}
|
||||
});
|
||||
|
||||
if (showGzip) {
|
||||
table.push([
|
||||
'\x1b[1mTOTAL\x1b[0m',
|
||||
'\x1b[1m' + formatBytes(totalSize) + '\x1b[0m',
|
||||
'\x1b[1m' + formatBytes(totalGzipSize) + '\x1b[0m',
|
||||
'\x1b[1m✓\x1b[0m',
|
||||
]);
|
||||
} else {
|
||||
table.push(['\x1b[1mTOTAL\x1b[0m', '\x1b[1m' + formatBytes(totalSize) + '\x1b[0m', '\x1b[1m✓\x1b[0m']);
|
||||
}
|
||||
|
||||
console.log('\n' + table.toString());
|
||||
|
||||
// Display validation results
|
||||
|
||||
|
||||
// Check if build should fail
|
||||
const hasErrors = Object.values(validationResults).some(r => r.status === 'error');
|
||||
const hasWarnings = Object.values(validationResults).some(r => r.status === 'warning');
|
||||
const treatWarningsAsErrors = envConfig.treatWarningsAsErrors;
|
||||
|
||||
if (hasErrors || (hasWarnings && treatWarningsAsErrors)) {
|
||||
console.error('\n❌ Build validation failed!');
|
||||
Object.entries(validationResults).forEach(([key, result]) => {
|
||||
if (result.status === 'error' || (result.status === 'warning' && treatWarningsAsErrors)) {
|
||||
console.error(` ${result.message}`);
|
||||
}
|
||||
});
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (hasWarnings) {
|
||||
console.warn('\n⚠️ Build completed with warnings:');
|
||||
Object.entries(validationResults).forEach(([key, result]) => {
|
||||
if (result.status === 'warning') {
|
||||
console.warn(` ${result.message}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function validateSizeWithThresholds(name: string, actual: number, warningLimit: number, errorLimit: number): ValidationResult {
|
||||
if (actual > errorLimit) {
|
||||
return {
|
||||
status: 'error',
|
||||
message: `${name}: ${formatBytes(actual)} exceeds error limit of ${formatBytes(errorLimit)}`,
|
||||
actual,
|
||||
limit: warningLimit,
|
||||
};
|
||||
}
|
||||
|
||||
if (actual > warningLimit) {
|
||||
return {
|
||||
status: 'warning',
|
||||
message: `${name}: ${formatBytes(actual)} exceeds warning limit of ${formatBytes(warningLimit)}`,
|
||||
actual,
|
||||
limit: warningLimit,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
message: `${name}: ${formatBytes(actual)} is within limits`,
|
||||
actual,
|
||||
limit: warningLimit,
|
||||
};
|
||||
}
|
||||
|
||||
function generateCompressedFiles(): void {
|
||||
const config = loadConfig();
|
||||
const envConfig = getEnvironmentConfig(config);
|
||||
|
||||
if (!envConfig.compressed) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Generating compressed files...');
|
||||
|
||||
const compressibleExtensions = ['.js', '.css', '.html', '.json', '.svg', '.xml'];
|
||||
|
||||
const walkAndCompress = (dir: string): void => {
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
|
||||
entries.forEach(entry => {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
walkAndCompress(fullPath);
|
||||
} else {
|
||||
const ext = path.extname(entry.name).toLowerCase();
|
||||
if (compressibleExtensions.includes(ext)) {
|
||||
const content = fs.readFileSync(fullPath);
|
||||
const compressed = zlib.gzipSync(content, { level: 9 });
|
||||
const gzPath = fullPath + '.gz';
|
||||
fs.writeFileSync(gzPath, compressed);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
walkAndCompress(distDir);
|
||||
console.log('✓ Compressed files generated (.gz)');
|
||||
}
|
||||
|
||||
function runBuildActions(phase: 'prebuild' | 'postbuild'): void {
|
||||
const config = loadConfig();
|
||||
const actions = config.build.actions?.[phase] || [];
|
||||
|
||||
if (actions.length === 0) return;
|
||||
|
||||
console.log(`🔧 Running ${phase} actions...`);
|
||||
|
||||
for (const action of actions) {
|
||||
console.log(` ▶ ${action}`);
|
||||
try {
|
||||
execSync(action, {
|
||||
cwd: projectRoot,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(` ❌ Action failed: ${action}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function build(): Promise<void> {
|
||||
try {
|
||||
console.log('Starting build process...');
|
||||
|
||||
runBuildActions('prebuild');
|
||||
|
||||
if (fs.existsSync(distDir)) {
|
||||
fs.rmSync(distDir, { recursive: true, force: true });
|
||||
}
|
||||
ensureDirectoryExists(distDir);
|
||||
|
||||
console.log('Copying public files...');
|
||||
copyDirectory(publicDir, distDir);
|
||||
|
||||
await compileSCSS();
|
||||
|
||||
await bundleTypeScript();
|
||||
|
||||
const indexPath = path.join(distDir, 'index.html');
|
||||
injectScriptsAndStyles(indexPath);
|
||||
|
||||
generateCompressedFiles();
|
||||
|
||||
displayBuildStats();
|
||||
|
||||
runBuildActions('postbuild');
|
||||
|
||||
console.log('Build completed successfully!');
|
||||
console.log(`Output directory: ${distDir}`);
|
||||
} catch (error) {
|
||||
console.error('Build failed:', error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
build().catch(error => {
|
||||
console.error('Build process failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
import * as esbuild from 'esbuild';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { BaseProcessor } from './processors/base-processor';
|
||||
import { TemplateProcessor } from './processors/template-processor';
|
||||
import { StyleProcessor } from './processors/style-processor';
|
||||
import { DIProcessor } from './processors/di-processor';
|
||||
import { ClassDecoratorProcessor } from './processors/class-decorator-processor';
|
||||
import { SignalTransformerProcessor, SignalTransformerError } from './processors/signal-transformer-processor';
|
||||
import { DirectiveCollectorProcessor } from './processors/directive-collector-processor';
|
||||
|
||||
export class BuildError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public filePath: string,
|
||||
public processorName: string,
|
||||
public originalError?: Error,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'BuildError';
|
||||
}
|
||||
}
|
||||
|
||||
export class QuarcTransformer {
|
||||
private processors: BaseProcessor[];
|
||||
|
||||
constructor(processors?: BaseProcessor[]) {
|
||||
this.processors = processors || [
|
||||
new ClassDecoratorProcessor(),
|
||||
new SignalTransformerProcessor(),
|
||||
new TemplateProcessor(),
|
||||
new StyleProcessor(),
|
||||
new DIProcessor(),
|
||||
new DirectiveCollectorProcessor(),
|
||||
];
|
||||
}
|
||||
|
||||
createPlugin(): esbuild.Plugin {
|
||||
return {
|
||||
name: 'quarc-transformer',
|
||||
setup: (build) => {
|
||||
build.onLoad({ filter: /\.(ts)$/ }, async (args) => {
|
||||
if (args.path.includes('node_modules')) {
|
||||
return {
|
||||
contents: await fs.promises.readFile(args.path, 'utf8'),
|
||||
loader: 'ts',
|
||||
};
|
||||
}
|
||||
|
||||
const source = await fs.promises.readFile(args.path, 'utf8');
|
||||
const fileDir = path.dirname(args.path);
|
||||
|
||||
const skipPaths = [
|
||||
'/quarc/core/module/',
|
||||
'/quarc/core/angular/',
|
||||
'/quarc/router/angular/',
|
||||
];
|
||||
if (skipPaths.some(p => args.path.includes(p))) {
|
||||
return {
|
||||
contents: source,
|
||||
loader: 'ts',
|
||||
};
|
||||
}
|
||||
|
||||
let currentSource = source;
|
||||
|
||||
for (const processor of this.processors) {
|
||||
try {
|
||||
const result = await processor.process({
|
||||
filePath: args.path,
|
||||
fileDir,
|
||||
source: currentSource,
|
||||
});
|
||||
|
||||
if (result.modified) {
|
||||
currentSource = result.source;
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof SignalTransformerError) {
|
||||
return {
|
||||
errors: [{
|
||||
text: error.message,
|
||||
location: {
|
||||
file: args.path,
|
||||
namespace: 'file',
|
||||
},
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
const buildError = new BuildError(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
args.path,
|
||||
processor.name,
|
||||
error instanceof Error ? error : undefined,
|
||||
);
|
||||
|
||||
return {
|
||||
errors: [{
|
||||
text: `[${processor.name}] ${buildError.message}`,
|
||||
location: {
|
||||
file: args.path,
|
||||
namespace: 'file',
|
||||
},
|
||||
}],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
contents: currentSource,
|
||||
loader: 'ts',
|
||||
};
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function quarcTransformer(processors?: BaseProcessor[]): esbuild.Plugin {
|
||||
const transformer = new QuarcTransformer(processors);
|
||||
return transformer.createPlugin();
|
||||
}
|
||||
|
|
@ -0,0 +1,635 @@
|
|||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as zlib from 'zlib';
|
||||
import { execSync } from 'child_process';
|
||||
import * as esbuild from 'esbuild';
|
||||
import { minify } from 'terser';
|
||||
import Table from 'cli-table3';
|
||||
import * as sass from 'sass';
|
||||
import { quarcTransformer } from '../quarc-transformer';
|
||||
import { consoleTransformer } from '../build/transformers/console-transformer';
|
||||
import {
|
||||
QuarcConfig,
|
||||
EnvironmentConfig,
|
||||
ValidationResult,
|
||||
BuildConfig,
|
||||
} from '../types';
|
||||
|
||||
export abstract class BaseBuilder {
|
||||
protected projectRoot: string;
|
||||
protected srcDir: string;
|
||||
protected publicDir: string;
|
||||
protected distDir: string;
|
||||
protected configPath: string;
|
||||
protected config: QuarcConfig;
|
||||
protected envConfig: EnvironmentConfig;
|
||||
|
||||
constructor() {
|
||||
this.projectRoot = process.cwd();
|
||||
this.srcDir = path.join(this.projectRoot, 'src');
|
||||
this.publicDir = path.join(this.srcDir, 'public');
|
||||
this.distDir = path.join(this.projectRoot, 'dist');
|
||||
this.configPath = path.join(this.projectRoot, 'quarc.json');
|
||||
this.config = this.loadConfig();
|
||||
this.envConfig = this.getEnvironmentConfig();
|
||||
}
|
||||
|
||||
protected isVerbose(): boolean {
|
||||
const args = process.argv.slice(2);
|
||||
return args.includes('-v') || args.includes('--verbose');
|
||||
}
|
||||
|
||||
protected getCliConfiguration(): string | undefined {
|
||||
const args = process.argv.slice(2);
|
||||
const configIndex = args.findIndex(arg => arg === '--configuration' || arg === '-c');
|
||||
if (configIndex !== -1 && args[configIndex + 1]) {
|
||||
return args[configIndex + 1];
|
||||
}
|
||||
const envIndex = args.findIndex(arg => arg === '--environment' || arg === '-e');
|
||||
if (envIndex !== -1 && args[envIndex + 1]) {
|
||||
return args[envIndex + 1];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
protected loadConfig(): QuarcConfig {
|
||||
const cliConfig = this.getCliConfiguration();
|
||||
|
||||
if (!fs.existsSync(this.configPath)) {
|
||||
return {
|
||||
environment: cliConfig ?? 'development',
|
||||
build: {
|
||||
minifyNames: false,
|
||||
limits: {
|
||||
total: { warning: '50 KB', error: '60 KB' },
|
||||
main: { warning: '15 KB', error: '20 KB' },
|
||||
sourceMaps: { warning: '10 KB', error: '20 KB' },
|
||||
},
|
||||
},
|
||||
environments: {
|
||||
development: {
|
||||
treatWarningsAsErrors: false,
|
||||
minifyNames: false,
|
||||
generateSourceMaps: true,
|
||||
},
|
||||
production: {
|
||||
treatWarningsAsErrors: true,
|
||||
minifyNames: true,
|
||||
generateSourceMaps: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(this.configPath, 'utf-8');
|
||||
const config = JSON.parse(content) as QuarcConfig;
|
||||
|
||||
if (cliConfig) {
|
||||
config.environment = cliConfig;
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
protected getEnvironmentConfig(): EnvironmentConfig {
|
||||
const envConfig = this.config.environments[this.config.environment];
|
||||
if (!envConfig) {
|
||||
console.warn(`Environment '${this.config.environment}' not found in config, using defaults`);
|
||||
return {
|
||||
treatWarningsAsErrors: false,
|
||||
minifyNames: false,
|
||||
generateSourceMaps: true,
|
||||
};
|
||||
}
|
||||
return envConfig;
|
||||
}
|
||||
|
||||
protected ensureDirectoryExists(dir: string): void {
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
protected copyDirectory(src: string, dest: string): void {
|
||||
if (!fs.existsSync(src)) {
|
||||
console.warn(`Source directory not found: ${src}`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.ensureDirectoryExists(dest);
|
||||
|
||||
const files = fs.readdirSync(src);
|
||||
files.forEach(file => {
|
||||
const srcPath = path.join(src, file);
|
||||
const destPath = path.join(dest, file);
|
||||
const stat = fs.statSync(srcPath);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
this.copyDirectory(srcPath, destPath);
|
||||
} else {
|
||||
fs.copyFileSync(srcPath, destPath);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected async bundleTypeScript(): Promise<void> {
|
||||
try {
|
||||
if (this.isVerbose()) console.log('Bundling TypeScript with esbuild...');
|
||||
const mainTsPath = path.join(this.srcDir, 'main.ts');
|
||||
|
||||
await esbuild.build({
|
||||
entryPoints: [mainTsPath],
|
||||
bundle: true,
|
||||
minify: false,
|
||||
sourcemap: this.envConfig.generateSourceMaps,
|
||||
outdir: this.distDir,
|
||||
format: 'esm',
|
||||
target: 'ES2020',
|
||||
splitting: true,
|
||||
chunkNames: 'chunks/[name]-[hash]',
|
||||
external: [],
|
||||
plugins: [quarcTransformer(), consoleTransformer()],
|
||||
tsconfig: path.join(this.projectRoot, 'tsconfig.json'),
|
||||
treeShaking: true,
|
||||
logLevel: this.isVerbose() ? 'info' : 'silent',
|
||||
define: {
|
||||
'process.env.NODE_ENV': this.config.environment === 'production' ? '"production"' : '"development"',
|
||||
},
|
||||
drop: this.config.environment === 'production' ? ['console', 'debugger'] : ['debugger'],
|
||||
pure: this.config.environment === 'production' ? ['console.log', 'console.error', 'console.warn', 'console.info', 'console.debug'] : [],
|
||||
globalName: undefined,
|
||||
});
|
||||
|
||||
if (this.isVerbose()) console.log('TypeScript bundling completed.');
|
||||
await this.bundleExternalEntryPoints();
|
||||
await this.obfuscateAndMinifyBundles();
|
||||
} catch (error) {
|
||||
console.error('TypeScript bundling failed:', error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
protected async bundleExternalEntryPoints(): Promise<void> {
|
||||
const externalEntryPoints = this.config.build?.externalEntryPoints || [];
|
||||
|
||||
if (externalEntryPoints.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isVerbose()) console.log('Bundling external entry points...');
|
||||
const externalDistDir = path.join(this.distDir, 'external');
|
||||
this.ensureDirectoryExists(externalDistDir);
|
||||
|
||||
for (const entryPoint of externalEntryPoints) {
|
||||
const entryPath = path.join(this.projectRoot, entryPoint);
|
||||
|
||||
if (!fs.existsSync(entryPath)) {
|
||||
console.warn(`External entry point not found: ${entryPath}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const basename = path.basename(entryPoint, '.ts');
|
||||
|
||||
await esbuild.build({
|
||||
entryPoints: [entryPath],
|
||||
bundle: true,
|
||||
minify: false,
|
||||
sourcemap: this.envConfig.generateSourceMaps,
|
||||
outfile: path.join(externalDistDir, `${basename}.js`),
|
||||
format: 'esm',
|
||||
target: 'ES2020',
|
||||
splitting: false,
|
||||
external: [],
|
||||
plugins: [quarcTransformer(), consoleTransformer()],
|
||||
tsconfig: path.join(this.projectRoot, 'tsconfig.json'),
|
||||
treeShaking: true,
|
||||
logLevel: this.isVerbose() ? 'info' : 'silent',
|
||||
define: {
|
||||
'process.env.NODE_ENV': this.config.environment === 'production' ? '"production"' : '"development"',
|
||||
},
|
||||
drop: this.config.environment === 'production' ? ['console', 'debugger'] : ['debugger'],
|
||||
pure: this.config.environment === 'production' ? ['console.log', 'console.error', 'console.warn', 'console.info', 'console.debug'] : [],
|
||||
});
|
||||
|
||||
if (this.isVerbose()) console.log(`✓ Bundled external: ${basename}.js`);
|
||||
}
|
||||
}
|
||||
|
||||
protected async obfuscateAndMinifyBundles(): Promise<void> {
|
||||
try {
|
||||
if (this.isVerbose()) console.log('Applying advanced obfuscation and minification...');
|
||||
|
||||
const collectJsFiles = (dir: string, prefix = ''): { file: string; filePath: string }[] => {
|
||||
const results: { file: string; filePath: string }[] = [];
|
||||
if (!fs.existsSync(dir)) return results;
|
||||
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name;
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
results.push(...collectJsFiles(fullPath, relativePath));
|
||||
} else if (entry.name.endsWith('.js') && !entry.name.endsWith('.map')) {
|
||||
results.push({ file: relativePath, filePath: fullPath });
|
||||
}
|
||||
}
|
||||
return results;
|
||||
};
|
||||
|
||||
const jsFiles = collectJsFiles(this.distDir);
|
||||
|
||||
for (const { file, filePath } of jsFiles) {
|
||||
const code = fs.readFileSync(filePath, 'utf-8');
|
||||
|
||||
const result = await minify(code, {
|
||||
compress: {
|
||||
passes: 3,
|
||||
unsafe: true,
|
||||
unsafe_methods: true,
|
||||
unsafe_proto: true,
|
||||
drop_console: this.config.environment === 'production',
|
||||
drop_debugger: true,
|
||||
inline: 3,
|
||||
reduce_vars: true,
|
||||
reduce_funcs: true,
|
||||
collapse_vars: true,
|
||||
dead_code: true,
|
||||
evaluate: true,
|
||||
hoist_funs: true,
|
||||
hoist_vars: true,
|
||||
if_return: true,
|
||||
join_vars: true,
|
||||
loops: true,
|
||||
properties: false,
|
||||
sequences: true,
|
||||
side_effects: true,
|
||||
switches: true,
|
||||
typeofs: true,
|
||||
unused: true,
|
||||
},
|
||||
mangle: this.envConfig.minifyNames ? {
|
||||
toplevel: true,
|
||||
keep_classnames: false,
|
||||
keep_fnames: false,
|
||||
properties: false,
|
||||
} : false,
|
||||
output: {
|
||||
comments: false,
|
||||
beautify: false,
|
||||
max_line_len: 1000,
|
||||
},
|
||||
});
|
||||
|
||||
if (result.code) {
|
||||
fs.writeFileSync(filePath, result.code, 'utf-8');
|
||||
const originalSize = code.length;
|
||||
const newSize = result.code.length;
|
||||
const reduction = ((1 - newSize / originalSize) * 100).toFixed(2);
|
||||
if (this.isVerbose()) console.log(`✓ ${file}: ${originalSize} → ${newSize} bytes (${reduction}% reduction)`);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.isVerbose()) console.log('Obfuscation and minification completed.');
|
||||
} catch (error) {
|
||||
console.error('Obfuscation failed:', error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
protected async compileStyleFile(stylePath: string, outputDir: string): Promise<void> {
|
||||
const fullPath = path.join(this.projectRoot, stylePath);
|
||||
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
console.warn(`Style file not found: ${fullPath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const ext = path.extname(fullPath);
|
||||
const basename = path.basename(fullPath, ext);
|
||||
const outputPath = path.join(outputDir, `${basename}.css`);
|
||||
|
||||
this.ensureDirectoryExists(outputDir);
|
||||
|
||||
if (ext === '.scss' || ext === '.sass') {
|
||||
try {
|
||||
const result = sass.compile(fullPath, {
|
||||
style: 'compressed',
|
||||
sourceMap: false,
|
||||
});
|
||||
fs.writeFileSync(outputPath, result.css, 'utf-8');
|
||||
if (this.isVerbose()) console.log(`✓ Compiled ${stylePath} → ${basename}.css`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to compile ${stylePath}:`, error instanceof Error ? error.message : String(error));
|
||||
throw error;
|
||||
}
|
||||
} else if (ext === '.css') {
|
||||
fs.copyFileSync(fullPath, outputPath);
|
||||
if (this.isVerbose()) console.log(`✓ Copied ${stylePath} → ${basename}.css`);
|
||||
}
|
||||
}
|
||||
|
||||
protected async compileSCSS(): Promise<void> {
|
||||
const styles = this.config.build?.styles || [];
|
||||
const externalStyles = this.config.build?.externalStyles || [];
|
||||
|
||||
if (styles.length === 0 && externalStyles.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isVerbose()) console.log('Compiling SCSS files...');
|
||||
|
||||
for (const stylePath of styles) {
|
||||
await this.compileStyleFile(stylePath, this.distDir);
|
||||
}
|
||||
|
||||
const externalDistDir = path.join(this.distDir, 'external');
|
||||
for (const stylePath of externalStyles) {
|
||||
await this.compileStyleFile(stylePath, externalDistDir);
|
||||
}
|
||||
}
|
||||
|
||||
protected injectScriptsAndStyles(indexPath: string): void {
|
||||
if (!fs.existsSync(indexPath)) {
|
||||
console.warn(`Index file not found: ${indexPath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
let html = fs.readFileSync(indexPath, 'utf-8');
|
||||
|
||||
const styles = this.config.build?.styles || [];
|
||||
const scripts = this.config.build?.scripts || [];
|
||||
|
||||
let styleInjections = '';
|
||||
for (const stylePath of styles) {
|
||||
const basename = path.basename(stylePath, path.extname(stylePath));
|
||||
const cssFile = `${basename}.css`;
|
||||
if (!html.includes(cssFile)) {
|
||||
styleInjections += ` <link rel="stylesheet" href="./${cssFile}">\n`;
|
||||
}
|
||||
}
|
||||
|
||||
if (styleInjections) {
|
||||
html = html.replace('</head>', `${styleInjections}</head>`);
|
||||
}
|
||||
|
||||
let scriptInjections = '';
|
||||
for (const scriptPath of scripts) {
|
||||
const basename = path.basename(scriptPath);
|
||||
if (!html.includes(basename)) {
|
||||
scriptInjections += ` <script type="module" src="./${basename}"></script>\n`;
|
||||
}
|
||||
}
|
||||
|
||||
const mainScript = ` <script type="module" src="./main.js"></script>\n`;
|
||||
if (!html.includes('main.js')) {
|
||||
scriptInjections += mainScript;
|
||||
}
|
||||
|
||||
if (scriptInjections) {
|
||||
html = html.replace('</body>', `${scriptInjections}</body>`);
|
||||
}
|
||||
|
||||
fs.writeFileSync(indexPath, html, 'utf-8');
|
||||
if (this.isVerbose()) console.log('Injected scripts and styles into index.html');
|
||||
}
|
||||
|
||||
protected formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
protected getGzipSize(content: Buffer | string): number {
|
||||
const buffer = typeof content === 'string' ? Buffer.from(content) : content;
|
||||
return zlib.gzipSync(buffer, { level: 9 }).length;
|
||||
}
|
||||
|
||||
protected parseSizeString(sizeStr: string): number {
|
||||
const match = sizeStr.match(/^([\d.]+)\s*(B|KB|MB|GB)$/i);
|
||||
if (!match) throw new Error(`Invalid size format: ${sizeStr}`);
|
||||
|
||||
const value = parseFloat(match[1]);
|
||||
const unit = match[2].toUpperCase();
|
||||
|
||||
const multipliers: { [key: string]: number } = {
|
||||
B: 1,
|
||||
KB: 1024,
|
||||
MB: 1024 * 1024,
|
||||
GB: 1024 * 1024 * 1024,
|
||||
};
|
||||
|
||||
return value * (multipliers[unit] || 1);
|
||||
}
|
||||
|
||||
protected validateSizeWithThresholds(name: string, actual: number, warningLimit: number, errorLimit: number): ValidationResult {
|
||||
if (actual > errorLimit) {
|
||||
return {
|
||||
status: 'error',
|
||||
message: `${name}: ${this.formatBytes(actual)} exceeds error limit of ${this.formatBytes(errorLimit)}`,
|
||||
actual,
|
||||
limit: warningLimit,
|
||||
};
|
||||
}
|
||||
|
||||
if (actual > warningLimit) {
|
||||
return {
|
||||
status: 'warning',
|
||||
message: `${name}: ${this.formatBytes(actual)} exceeds warning limit of ${this.formatBytes(warningLimit)}`,
|
||||
actual,
|
||||
limit: warningLimit,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
message: `${name}: ${this.formatBytes(actual)} is within limits`,
|
||||
actual,
|
||||
limit: warningLimit,
|
||||
};
|
||||
}
|
||||
|
||||
protected displayBuildStats(): void {
|
||||
const files: { name: string; size: number; gzipSize: number; path: string }[] = [];
|
||||
let totalSize = 0;
|
||||
let totalGzipSize = 0;
|
||||
let mainSize = 0;
|
||||
let mapSize = 0;
|
||||
let externalSize = 0;
|
||||
|
||||
const showGzip = this.envConfig.compressed ?? false;
|
||||
|
||||
const isExternalFile = (relativePath: string): boolean => relativePath.startsWith('external/');
|
||||
const isMapFile = (name: string): boolean => name.endsWith('.map');
|
||||
|
||||
const walkDir = (dir: string, prefix = ''): void => {
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
|
||||
entries.forEach(entry => {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name;
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
walkDir(fullPath, relativePath);
|
||||
} else if (!entry.name.endsWith('.gz')) {
|
||||
const content = fs.readFileSync(fullPath);
|
||||
const size = content.length;
|
||||
const gzipSize = this.getGzipSize(content);
|
||||
|
||||
files.push({ name: entry.name, size, gzipSize, path: relativePath });
|
||||
|
||||
if (isMapFile(entry.name)) {
|
||||
mapSize += size;
|
||||
} else if (isExternalFile(relativePath)) {
|
||||
externalSize += size;
|
||||
} else {
|
||||
totalSize += size;
|
||||
totalGzipSize += gzipSize;
|
||||
}
|
||||
|
||||
if (entry.name === 'main.js') mainSize = size;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
walkDir(this.distDir);
|
||||
files.sort((a, b) => b.size - a.size);
|
||||
|
||||
const validationResults: { [key: string]: ValidationResult } = {};
|
||||
|
||||
const buildConfig = this.config.build as BuildConfig;
|
||||
const totalWarning = this.parseSizeString(buildConfig.limits.total.warning);
|
||||
const totalError = this.parseSizeString(buildConfig.limits.total.error);
|
||||
const mainWarning = this.parseSizeString(buildConfig.limits.main.warning);
|
||||
const mainError = this.parseSizeString(buildConfig.limits.main.error);
|
||||
const mapWarning = this.parseSizeString(buildConfig.limits.sourceMaps.warning);
|
||||
const mapError = this.parseSizeString(buildConfig.limits.sourceMaps.error);
|
||||
|
||||
validationResults.total = this.validateSizeWithThresholds('Total Size', totalSize, totalWarning, totalError);
|
||||
validationResults.main = this.validateSizeWithThresholds('Main Bundle', mainSize, mainWarning, mainError);
|
||||
validationResults.sourceMaps = this.validateSizeWithThresholds('Source Maps', mapSize, mapWarning, mapError);
|
||||
|
||||
console.log(`\n📊 Size breakdown:`);
|
||||
console.log(` App total: ${this.formatBytes(totalSize)}`);
|
||||
console.log(` External: ${this.formatBytes(externalSize)}`);
|
||||
console.log(` Maps: ${this.formatBytes(mapSize)}`);
|
||||
|
||||
const tableHead = showGzip
|
||||
? ['📄 File', '💾 Size', '📦 Gzip', '✓ Status']
|
||||
: ['📄 File', '💾 Size', '✓ Status'];
|
||||
const colWidths = showGzip ? [32, 12, 12, 10] : [40, 15, 12];
|
||||
|
||||
const table = new Table({
|
||||
head: tableHead,
|
||||
style: { head: [], border: ['cyan'] },
|
||||
wordWrap: true,
|
||||
colWidths,
|
||||
});
|
||||
|
||||
files.forEach(file => {
|
||||
const sizeStr = this.formatBytes(file.size);
|
||||
const gzipStr = this.formatBytes(file.gzipSize);
|
||||
const fileName = file.path.length > 28 ? file.path.substring(0, 25) + '...' : file.path;
|
||||
|
||||
if (showGzip) {
|
||||
table.push([fileName, sizeStr, gzipStr, '✓']);
|
||||
} else {
|
||||
table.push([fileName, sizeStr, '✓']);
|
||||
}
|
||||
});
|
||||
|
||||
if (showGzip) {
|
||||
table.push([
|
||||
'\x1b[1mTOTAL\x1b[0m',
|
||||
'\x1b[1m' + this.formatBytes(totalSize) + '\x1b[0m',
|
||||
'\x1b[1m' + this.formatBytes(totalGzipSize) + '\x1b[0m',
|
||||
'\x1b[1m✓\x1b[0m',
|
||||
]);
|
||||
} else {
|
||||
table.push(['\x1b[1mTOTAL\x1b[0m', '\x1b[1m' + this.formatBytes(totalSize) + '\x1b[0m', '\x1b[1m✓\x1b[0m']);
|
||||
}
|
||||
|
||||
console.log('\n' + table.toString());
|
||||
|
||||
const hasErrors = Object.values(validationResults).some(r => r.status === 'error');
|
||||
const hasWarnings = Object.values(validationResults).some(r => r.status === 'warning');
|
||||
const treatWarningsAsErrors = this.envConfig.treatWarningsAsErrors;
|
||||
|
||||
if (hasErrors || (hasWarnings && treatWarningsAsErrors)) {
|
||||
console.error('\n❌ Build validation failed!');
|
||||
Object.entries(validationResults).forEach(([key, result]) => {
|
||||
if (result.status === 'error' || (result.status === 'warning' && treatWarningsAsErrors)) {
|
||||
console.error(` ${result.message}`);
|
||||
}
|
||||
});
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (hasWarnings) {
|
||||
console.warn('\n⚠️ Build completed with warnings:');
|
||||
Object.entries(validationResults).forEach(([key, result]) => {
|
||||
if (result.status === 'warning') {
|
||||
console.warn(` ${result.message}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
protected generateCompressedFiles(): void {
|
||||
if (!this.envConfig.compressed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isVerbose()) console.log('Generating compressed files...');
|
||||
|
||||
const compressibleExtensions = ['.js', '.css', '.html', '.json', '.svg', '.xml'];
|
||||
|
||||
const walkAndCompress = (dir: string): void => {
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
|
||||
entries.forEach(entry => {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
walkAndCompress(fullPath);
|
||||
} else {
|
||||
const ext = path.extname(entry.name).toLowerCase();
|
||||
if (compressibleExtensions.includes(ext)) {
|
||||
const content = fs.readFileSync(fullPath);
|
||||
const compressed = zlib.gzipSync(content, { level: 9 });
|
||||
const gzPath = fullPath + '.gz';
|
||||
fs.writeFileSync(gzPath, compressed);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
walkAndCompress(this.distDir);
|
||||
if (this.isVerbose()) console.log('✓ Compressed files generated (.gz)');
|
||||
}
|
||||
|
||||
protected runBuildActions(phase: 'prebuild' | 'postbuild'): void {
|
||||
const actions = this.config.build?.actions?.[phase] || [];
|
||||
|
||||
if (actions.length === 0) return;
|
||||
|
||||
if (this.isVerbose()) console.log(`🔧 Running ${phase} actions...`);
|
||||
|
||||
for (const action of actions) {
|
||||
if (this.isVerbose()) console.log(` ▶ ${action}`);
|
||||
try {
|
||||
execSync(action, {
|
||||
cwd: this.projectRoot,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(` ❌ Action failed: ${action}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
abstract run(): Promise<void>;
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { BaseBuilder } from './base-builder';
|
||||
|
||||
class Builder extends BaseBuilder {
|
||||
async run(): Promise<void> {
|
||||
try {
|
||||
if (this.isVerbose()) console.log(`Starting build process (environment: ${this.config.environment})...`);
|
||||
|
||||
this.runBuildActions('prebuild');
|
||||
|
||||
if (this.distDir && require('fs').existsSync(this.distDir)) {
|
||||
require('fs').rmSync(this.distDir, { recursive: true, force: true });
|
||||
}
|
||||
this.ensureDirectoryExists(this.distDir);
|
||||
|
||||
if (this.isVerbose()) console.log('Copying public files...');
|
||||
this.copyDirectory(this.publicDir, this.distDir);
|
||||
|
||||
await this.compileSCSS();
|
||||
await this.bundleTypeScript();
|
||||
|
||||
const indexPath = require('path').join(this.distDir, 'index.html');
|
||||
this.injectScriptsAndStyles(indexPath);
|
||||
|
||||
this.generateCompressedFiles();
|
||||
this.displayBuildStats();
|
||||
this.runBuildActions('postbuild');
|
||||
|
||||
if (!this.isVerbose()) {
|
||||
console.log(`\n✅ Build completed | Environment: ${this.config.environment} | Output: ${this.distDir}`);
|
||||
} else {
|
||||
console.log('Build completed successfully!');
|
||||
console.log(`Output directory: ${this.distDir}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Build failed:', error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const builder = new Builder();
|
||||
builder.run().catch(error => {
|
||||
console.error('Build process failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -0,0 +1,557 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { spawn, execSync, ChildProcess } from 'child_process';
|
||||
import * as http from 'http';
|
||||
import * as https from 'https';
|
||||
import { WebSocketServer, WebSocket } from 'ws';
|
||||
import { BaseBuilder } from './base-builder';
|
||||
import {
|
||||
StaticPath,
|
||||
StaticRemotePath,
|
||||
} from '../types';
|
||||
|
||||
class Server extends BaseBuilder {
|
||||
private isBuilding = false;
|
||||
private buildQueued = false;
|
||||
private wsClients: Set<WebSocket> = new Set();
|
||||
private httpServer: http.Server | null = null;
|
||||
private wsServer: WebSocketServer | null = null;
|
||||
private actionProcesses: ChildProcess[] = [];
|
||||
private mergedWsConnections: WebSocket[] = [];
|
||||
|
||||
private getDevServerPort(): number {
|
||||
const args = process.argv.slice(2);
|
||||
const portIndex = args.findIndex(arg => arg === '--port' || arg === '-p');
|
||||
|
||||
if (portIndex !== -1 && args[portIndex + 1]) {
|
||||
const port = parseInt(args[portIndex + 1], 10);
|
||||
if (!isNaN(port) && port > 0 && port < 65536) {
|
||||
return port;
|
||||
}
|
||||
}
|
||||
|
||||
const envConfig = this.config.environments[this.config.environment];
|
||||
return envConfig?.devServer?.port || 4200;
|
||||
}
|
||||
|
||||
private getMimeType(filePath: string): string {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
const mimeTypes: { [key: string]: string } = {
|
||||
'.html': 'text/html',
|
||||
'.js': 'text/javascript',
|
||||
'.css': 'text/css',
|
||||
'.json': 'application/json',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.ico': 'image/x-icon',
|
||||
'.woff': 'font/woff',
|
||||
'.woff2': 'font/woff2',
|
||||
'.ttf': 'font/ttf',
|
||||
'.eot': 'application/vnd.ms-fontobject',
|
||||
};
|
||||
return mimeTypes[ext] || 'application/octet-stream';
|
||||
}
|
||||
|
||||
private getWebSocketConfig() {
|
||||
const envConfig = this.config.environments[this.config.environment];
|
||||
return envConfig?.devServer?.websocket;
|
||||
}
|
||||
|
||||
private attachWebSocketServer(server: http.Server): void {
|
||||
this.wsServer = new WebSocketServer({ server, path: '/qu-ws/' });
|
||||
|
||||
this.wsServer.on('connection', (ws: WebSocket) => {
|
||||
this.wsClients.add(ws);
|
||||
if (this.isVerbose()) console.log('Client connected to live reload WebSocket');
|
||||
|
||||
ws.on('message', (data: Buffer) => {
|
||||
try {
|
||||
const message = JSON.parse(data.toString());
|
||||
this.handleIncomingMessage(message, ws);
|
||||
} catch {
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
this.wsClients.delete(ws);
|
||||
if (this.isVerbose()) console.log('Client disconnected from live reload WebSocket');
|
||||
});
|
||||
|
||||
ws.send(JSON.stringify({ type: 'connected' }));
|
||||
});
|
||||
|
||||
if (this.isVerbose()) console.log('WebSocket server attached to HTTP server');
|
||||
|
||||
this.connectToMergedSources();
|
||||
}
|
||||
|
||||
private connectToMergedSources(): void {
|
||||
const wsConfig = this.getWebSocketConfig();
|
||||
const mergeFrom = wsConfig?.mergeFrom || [];
|
||||
|
||||
for (const url of mergeFrom) {
|
||||
this.connectToMergedSource(url);
|
||||
}
|
||||
}
|
||||
|
||||
private connectToMergedSource(url: string): void {
|
||||
if (this.isVerbose()) console.log(`Connecting to merged WebSocket source: ${url}`);
|
||||
|
||||
const ws = new WebSocket(url);
|
||||
|
||||
ws.on('open', () => {
|
||||
if (this.isVerbose()) console.log(`Connected to merged source: ${url}`);
|
||||
this.mergedWsConnections.push(ws);
|
||||
});
|
||||
|
||||
ws.on('message', (data: Buffer) => {
|
||||
try {
|
||||
const message = JSON.parse(data.toString());
|
||||
if (message.type === 'reload') {
|
||||
this.broadcastToClients(message);
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
if (this.isVerbose()) console.log(`Disconnected from merged source: ${url}, reconnecting...`);
|
||||
this.mergedWsConnections = this.mergedWsConnections.filter(c => c !== ws);
|
||||
setTimeout(() => this.connectToMergedSource(url), 2000);
|
||||
});
|
||||
|
||||
ws.on('error', (err: Error) => {
|
||||
console.warn(`WebSocket error for ${url}:`, err.message);
|
||||
});
|
||||
}
|
||||
|
||||
private handleIncomingMessage(message: { type: string; [key: string]: unknown }, sender: WebSocket): void {
|
||||
if (message.type === 'reload') {
|
||||
this.broadcastToClients(message, sender);
|
||||
this.broadcastToMergedSources(message);
|
||||
}
|
||||
}
|
||||
|
||||
private broadcastToClients(message: { type: string; [key: string]: unknown }, excludeSender?: WebSocket): void {
|
||||
const data = JSON.stringify(message);
|
||||
for (const client of this.wsClients) {
|
||||
if (client !== excludeSender && client.readyState === WebSocket.OPEN) {
|
||||
client.send(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private broadcastToMergedSources(message: { type: string; [key: string]: unknown }): void {
|
||||
const data = JSON.stringify(message);
|
||||
for (const ws of this.mergedWsConnections) {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getStaticPaths(): StaticPath[] {
|
||||
return this.config.serve?.staticPaths || [];
|
||||
}
|
||||
|
||||
private proxyRequest(targetUrl: string, req: http.IncomingMessage, res: http.ServerResponse): void {
|
||||
const parsedUrl = new URL(targetUrl);
|
||||
const protocol = parsedUrl.protocol === 'https:' ? https : http;
|
||||
|
||||
const proxyReq = protocol.request(
|
||||
targetUrl,
|
||||
{
|
||||
method: req.method,
|
||||
headers: {
|
||||
...req.headers,
|
||||
host: parsedUrl.host,
|
||||
},
|
||||
},
|
||||
(proxyRes) => {
|
||||
res.writeHead(proxyRes.statusCode || 500, proxyRes.headers);
|
||||
proxyRes.pipe(res);
|
||||
},
|
||||
);
|
||||
|
||||
proxyReq.on('error', (err) => {
|
||||
console.error('Proxy error:', err.message);
|
||||
res.writeHead(502);
|
||||
res.end('Bad Gateway');
|
||||
});
|
||||
|
||||
req.pipe(proxyReq);
|
||||
}
|
||||
|
||||
private isRemotePath(staticPath: StaticPath): staticPath is StaticRemotePath {
|
||||
return 'url' in staticPath;
|
||||
}
|
||||
|
||||
private tryServeStaticPath(reqUrl: string, req: http.IncomingMessage, res: http.ServerResponse): boolean {
|
||||
const staticPaths = this.getStaticPaths();
|
||||
|
||||
for (const staticPath of staticPaths) {
|
||||
if (reqUrl.startsWith(staticPath.location)) {
|
||||
const relativePath = reqUrl.slice(staticPath.location.length);
|
||||
|
||||
if (this.isRemotePath(staticPath)) {
|
||||
const targetUrl = staticPath.url + relativePath;
|
||||
this.proxyRequest(targetUrl, req, res);
|
||||
return true;
|
||||
}
|
||||
|
||||
const basePath = path.resolve(this.projectRoot, staticPath.path);
|
||||
let filePath = path.join(basePath, relativePath || 'index.html');
|
||||
|
||||
const normalizedFilePath = path.normalize(filePath);
|
||||
if (!normalizedFilePath.startsWith(basePath)) {
|
||||
res.writeHead(403);
|
||||
res.end('Forbidden');
|
||||
return true;
|
||||
}
|
||||
|
||||
if (fs.existsSync(filePath) && fs.statSync(filePath).isDirectory()) {
|
||||
filePath = path.join(filePath, 'index.html');
|
||||
}
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
res.writeHead(404);
|
||||
res.end('Not Found');
|
||||
return true;
|
||||
}
|
||||
|
||||
const mimeType = this.getMimeType(filePath);
|
||||
const content = fs.readFileSync(filePath);
|
||||
|
||||
res.writeHead(200, {
|
||||
'Content-Type': mimeType,
|
||||
'Cache-Control': 'no-cache',
|
||||
});
|
||||
res.end(content);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private startHttpServer(port: number): void {
|
||||
this.httpServer = http.createServer((req, res) => {
|
||||
const reqUrl = req.url || '/';
|
||||
|
||||
if (this.tryServeStaticPath(reqUrl, req, res)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let filePath = path.join(this.distDir, reqUrl === '/' ? 'index.html' : reqUrl);
|
||||
|
||||
if (filePath.includes('..')) {
|
||||
res.writeHead(403);
|
||||
res.end('Forbidden');
|
||||
return;
|
||||
}
|
||||
|
||||
if (fs.existsSync(filePath) && fs.statSync(filePath).isDirectory()) {
|
||||
filePath = path.join(filePath, 'index.html');
|
||||
}
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
const indexPath = path.join(this.distDir, 'index.html');
|
||||
if (fs.existsSync(indexPath)) {
|
||||
filePath = indexPath;
|
||||
} else {
|
||||
res.writeHead(404);
|
||||
res.end('Not Found');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const mimeType = this.getMimeType(filePath);
|
||||
const content = fs.readFileSync(filePath);
|
||||
|
||||
res.writeHead(200, {
|
||||
'Content-Type': mimeType,
|
||||
'Cache-Control': 'no-cache',
|
||||
});
|
||||
res.end(content);
|
||||
});
|
||||
|
||||
this.httpServer.listen(port, () => {
|
||||
if (!this.isVerbose()) {
|
||||
console.log(`\n🌐 Server: http://localhost:${port}`);
|
||||
} else {
|
||||
console.log(`\n** Quarc Live Development Server is listening on localhost:${port} **`);
|
||||
console.log(`** Open your browser on http://localhost:${port}/ **\n`);
|
||||
}
|
||||
});
|
||||
|
||||
this.attachWebSocketServer(this.httpServer);
|
||||
}
|
||||
|
||||
private notifyClients(): void {
|
||||
const message = { type: 'reload' };
|
||||
|
||||
this.broadcastToClients(message);
|
||||
this.broadcastToMergedSources(message);
|
||||
|
||||
if (this.wsClients.size > 0 && this.isVerbose()) {
|
||||
console.log('📢 Notified clients to reload');
|
||||
}
|
||||
}
|
||||
|
||||
private async runBuild(): Promise<void> {
|
||||
if (this.isBuilding) {
|
||||
this.buildQueued = true;
|
||||
return;
|
||||
}
|
||||
|
||||
this.isBuilding = true;
|
||||
this.buildQueued = false;
|
||||
|
||||
if (this.isVerbose()) console.log('\n🔨 Building application...');
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const buildScript = path.join(__dirname, 'build.ts');
|
||||
const tsNodePath = path.join(this.projectRoot, 'node_modules', '.bin', 'ts-node');
|
||||
|
||||
const configArg = ` -c ${this.config.environment}`;
|
||||
const verboseArg = this.isVerbose() ? ' -v' : '';
|
||||
|
||||
execSync(`${tsNodePath} ${buildScript}${configArg}${verboseArg}`, {
|
||||
stdio: 'inherit',
|
||||
cwd: this.projectRoot,
|
||||
});
|
||||
|
||||
const duration = ((Date.now() - startTime) / 1000).toFixed(2);
|
||||
if (this.isVerbose()) console.log(`✅ Build completed in ${duration}s`);
|
||||
|
||||
this.notifyClients();
|
||||
} catch (error) {
|
||||
console.error('❌ Build failed');
|
||||
} finally {
|
||||
this.isBuilding = false;
|
||||
|
||||
if (this.buildQueued) {
|
||||
console.log('⏳ Running queued build...');
|
||||
setTimeout(() => this.runBuild(), 100);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private watchFiles(): void {
|
||||
if (this.isVerbose()) console.log(`👀 Watching for changes in ${this.srcDir}...`);
|
||||
|
||||
const debounceDelay = 300;
|
||||
let debounceTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
const watcher = fs.watch(this.srcDir, { recursive: true }, (eventType, filename) => {
|
||||
if (!filename) return;
|
||||
|
||||
const ext = path.extname(filename);
|
||||
if (!['.ts', '.scss', '.sass', '.css', '.html'].includes(ext)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isVerbose()) console.log(`📝 File changed: ${filename}`);
|
||||
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer);
|
||||
}
|
||||
|
||||
debounceTimer = setTimeout(() => {
|
||||
this.runBuild();
|
||||
}, debounceDelay);
|
||||
});
|
||||
|
||||
const cleanup = () => {
|
||||
console.log('\n👋 Stopping watch mode...');
|
||||
watcher.close();
|
||||
for (const client of this.wsClients) {
|
||||
client.close();
|
||||
}
|
||||
this.wsClients.clear();
|
||||
for (const ws of this.mergedWsConnections) {
|
||||
ws.close();
|
||||
}
|
||||
this.mergedWsConnections = [];
|
||||
if (this.wsServer) {
|
||||
this.wsServer.close();
|
||||
}
|
||||
if (this.httpServer) {
|
||||
this.httpServer.close();
|
||||
}
|
||||
this.terminateActionProcesses();
|
||||
this.runPostServeActions();
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on('SIGINT', cleanup);
|
||||
process.on('SIGTERM', cleanup);
|
||||
}
|
||||
|
||||
private injectLiveReloadScript(): void {
|
||||
const indexPath = path.join(this.distDir, 'index.html');
|
||||
const wsPort = this.getDevServerPort();
|
||||
|
||||
if (!fs.existsSync(indexPath)) {
|
||||
console.warn('index.html not found in dist directory');
|
||||
return;
|
||||
}
|
||||
|
||||
let html = fs.readFileSync(indexPath, 'utf-8');
|
||||
|
||||
const liveReloadScript = `
|
||||
<script>
|
||||
(function() {
|
||||
let ws;
|
||||
let reconnectAttempts = 0;
|
||||
const maxReconnectDelay = 5000;
|
||||
|
||||
function connect() {
|
||||
ws = new WebSocket('ws://localhost:${wsPort}/qu-ws/');
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('[Live Reload] Connected');
|
||||
reconnectAttempts = 0;
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
if (message.type === 'reload') {
|
||||
console.log('[Live Reload] Reloading page...');
|
||||
window.location.reload();
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
console.warn('[Live Reload] Connection lost, attempting to reconnect...');
|
||||
reconnectAttempts++;
|
||||
const delay = Math.min(1000 * reconnectAttempts, maxReconnectDelay);
|
||||
setTimeout(connect, delay);
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
ws.close();
|
||||
};
|
||||
}
|
||||
|
||||
connect();
|
||||
})();
|
||||
</script>
|
||||
`;
|
||||
|
||||
if (!html.includes('Live Reload')) {
|
||||
html = html.replace('</body>', `${liveReloadScript}</body>`);
|
||||
fs.writeFileSync(indexPath, html, 'utf-8');
|
||||
if (this.isVerbose()) console.log('✅ Injected live reload script into index.html');
|
||||
}
|
||||
}
|
||||
|
||||
private runPreServeActions(): void {
|
||||
const actions = this.config.serve?.actions?.preserve || [];
|
||||
|
||||
if (actions.length === 0) return;
|
||||
|
||||
if (this.isVerbose()) console.log('🔧 Running preserve actions...');
|
||||
|
||||
for (const action of actions) {
|
||||
if (this.isVerbose()) console.log(` ▶ ${action}`);
|
||||
const child = spawn(action, [], {
|
||||
shell: true,
|
||||
cwd: this.projectRoot,
|
||||
stdio: 'inherit',
|
||||
detached: true,
|
||||
});
|
||||
|
||||
this.actionProcesses.push(child);
|
||||
|
||||
child.on('error', (err) => {
|
||||
console.error(` ❌ Action failed: ${action}`, err.message);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private runPostServeActions(): void {
|
||||
const actions = this.config.serve?.actions?.postserve || [];
|
||||
|
||||
if (actions.length === 0) return;
|
||||
|
||||
if (this.isVerbose()) console.log('🔧 Running postserve actions...');
|
||||
|
||||
for (const action of actions) {
|
||||
if (this.isVerbose()) console.log(` ▶ ${action}`);
|
||||
try {
|
||||
execSync(action, {
|
||||
cwd: this.projectRoot,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(` ❌ Action failed: ${action}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private terminateActionProcesses(): void {
|
||||
if (this.actionProcesses.length === 0) return;
|
||||
|
||||
if (this.isVerbose()) console.log('🛑 Terminating action processes...');
|
||||
|
||||
for (const child of this.actionProcesses) {
|
||||
if (child.pid && !child.killed) {
|
||||
try {
|
||||
process.kill(-child.pid, 'SIGTERM');
|
||||
if (this.isVerbose()) console.log(` ✓ Terminated process group ${child.pid}`);
|
||||
} catch (err) {
|
||||
try {
|
||||
child.kill('SIGTERM');
|
||||
if (this.isVerbose()) console.log(` ✓ Terminated process ${child.pid}`);
|
||||
} catch {
|
||||
console.warn(` ⚠ Could not terminate process ${child.pid}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.actionProcesses = [];
|
||||
}
|
||||
|
||||
async run(): Promise<void> {
|
||||
const port = this.getDevServerPort();
|
||||
|
||||
if (this.isVerbose()) {
|
||||
console.log('🚀 Starting development server...\n');
|
||||
console.log(`Environment: ${this.config.environment}`);
|
||||
}
|
||||
|
||||
this.runPreServeActions();
|
||||
|
||||
if (this.isVerbose()) console.log('📦 Running initial build...');
|
||||
await this.runBuild();
|
||||
|
||||
this.injectLiveReloadScript();
|
||||
|
||||
this.startHttpServer(port);
|
||||
|
||||
if (this.isVerbose()) {
|
||||
console.log('✨ Development server is ready!');
|
||||
console.log('📂 Serving files from:', this.distDir);
|
||||
console.log('🔄 Live reload WebSocket enabled on port', port);
|
||||
console.log('\nPress Ctrl+C to stop\n');
|
||||
}
|
||||
|
||||
this.watchFiles();
|
||||
}
|
||||
}
|
||||
|
||||
const server = new Server();
|
||||
server.run().catch(error => {
|
||||
console.error('Serve process failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
618
cli/serve.ts
618
cli/serve.ts
|
|
@ -1,618 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { spawn, execSync, ChildProcess } from 'child_process';
|
||||
import * as http from 'http';
|
||||
import * as https from 'https';
|
||||
import { WebSocketServer, WebSocket } from 'ws';
|
||||
|
||||
const projectRoot = process.cwd();
|
||||
const srcDir = path.join(projectRoot, 'src');
|
||||
const distDir = path.join(projectRoot, 'dist');
|
||||
const configPath = path.join(projectRoot, 'quarc.json');
|
||||
|
||||
let isBuilding = false;
|
||||
let buildQueued = false;
|
||||
let wsClients: Set<WebSocket> = new Set();
|
||||
let httpServer: http.Server | null = null;
|
||||
let wsServer: WebSocketServer | null = null;
|
||||
let actionProcesses: ChildProcess[] = [];
|
||||
let mergedWsConnections: WebSocket[] = [];
|
||||
|
||||
interface DevServerConfig {
|
||||
port: number;
|
||||
websocket?: WebSocketConfig;
|
||||
}
|
||||
|
||||
interface WebSocketConfig {
|
||||
mergeFrom?: string[];
|
||||
}
|
||||
|
||||
|
||||
interface StaticLocalPath {
|
||||
location: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface StaticRemotePath {
|
||||
location: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
type StaticPath = StaticLocalPath | StaticRemotePath;
|
||||
|
||||
interface ActionsConfig {
|
||||
preserve?: string[];
|
||||
postserve?: string[];
|
||||
}
|
||||
|
||||
interface ServeConfig {
|
||||
actions?: ActionsConfig;
|
||||
staticPaths?: StaticPath[];
|
||||
}
|
||||
|
||||
interface EnvironmentConfig {
|
||||
treatWarningsAsErrors: boolean;
|
||||
minifyNames: boolean;
|
||||
generateSourceMaps: boolean;
|
||||
devServer?: DevServerConfig;
|
||||
}
|
||||
|
||||
interface QuarcConfig {
|
||||
environment: string;
|
||||
serve?: ServeConfig;
|
||||
environments: {
|
||||
[key: string]: EnvironmentConfig;
|
||||
};
|
||||
}
|
||||
|
||||
function loadConfig(): QuarcConfig {
|
||||
if (!fs.existsSync(configPath)) {
|
||||
return {
|
||||
environment: 'development',
|
||||
environments: {
|
||||
development: {
|
||||
treatWarningsAsErrors: false,
|
||||
minifyNames: false,
|
||||
generateSourceMaps: true,
|
||||
devServer: {
|
||||
port: 4300,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
return JSON.parse(content) as QuarcConfig;
|
||||
}
|
||||
|
||||
function getDevServerPort(): number {
|
||||
const args = process.argv.slice(2);
|
||||
const portIndex = args.findIndex(arg => arg === '--port' || arg === '-p');
|
||||
|
||||
if (portIndex !== -1 && args[portIndex + 1]) {
|
||||
const port = parseInt(args[portIndex + 1], 10);
|
||||
if (!isNaN(port) && port > 0 && port < 65536) {
|
||||
return port;
|
||||
}
|
||||
}
|
||||
|
||||
const config = loadConfig();
|
||||
const envConfig = config.environments[config.environment];
|
||||
|
||||
return envConfig?.devServer?.port || 4300;
|
||||
}
|
||||
|
||||
function getMimeType(filePath: string): string {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
const mimeTypes: { [key: string]: string } = {
|
||||
'.html': 'text/html',
|
||||
'.js': 'text/javascript',
|
||||
'.css': 'text/css',
|
||||
'.json': 'application/json',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.ico': 'image/x-icon',
|
||||
'.woff': 'font/woff',
|
||||
'.woff2': 'font/woff2',
|
||||
'.ttf': 'font/ttf',
|
||||
'.eot': 'application/vnd.ms-fontobject',
|
||||
};
|
||||
return mimeTypes[ext] || 'application/octet-stream';
|
||||
}
|
||||
|
||||
function getWebSocketConfig(): WebSocketConfig | undefined {
|
||||
const config = loadConfig();
|
||||
const envConfig = config.environments[config.environment];
|
||||
return envConfig?.devServer?.websocket;
|
||||
}
|
||||
|
||||
function attachWebSocketServer(server: http.Server): void {
|
||||
wsServer = new WebSocketServer({ server, path: '/qu-ws/' });
|
||||
|
||||
wsServer.on('connection', (ws: WebSocket) => {
|
||||
wsClients.add(ws);
|
||||
console.log('Client connected to live reload WebSocket');
|
||||
|
||||
ws.on('message', (data: Buffer) => {
|
||||
try {
|
||||
const message = JSON.parse(data.toString());
|
||||
handleIncomingMessage(message, ws);
|
||||
} catch {
|
||||
// ignore invalid messages
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
wsClients.delete(ws);
|
||||
console.log('Client disconnected from live reload WebSocket');
|
||||
});
|
||||
|
||||
ws.send(JSON.stringify({ type: 'connected' }));
|
||||
});
|
||||
|
||||
console.log('WebSocket server attached to HTTP server');
|
||||
|
||||
connectToMergedSources();
|
||||
}
|
||||
|
||||
function connectToMergedSources(): void {
|
||||
const wsConfig = getWebSocketConfig();
|
||||
const mergeFrom = wsConfig?.mergeFrom || [];
|
||||
|
||||
for (const url of mergeFrom) {
|
||||
connectToMergedSource(url);
|
||||
}
|
||||
}
|
||||
|
||||
function connectToMergedSource(url: string): void {
|
||||
console.log(`Connecting to merged WebSocket source: ${url}`);
|
||||
|
||||
const ws = new WebSocket(url);
|
||||
|
||||
ws.on('open', () => {
|
||||
console.log(`Connected to merged source: ${url}`);
|
||||
mergedWsConnections.push(ws);
|
||||
});
|
||||
|
||||
ws.on('message', (data: Buffer) => {
|
||||
try {
|
||||
const message = JSON.parse(data.toString());
|
||||
if (message.type === 'reload') {
|
||||
broadcastToClients(message);
|
||||
}
|
||||
} catch {
|
||||
// ignore invalid messages
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
console.log(`Disconnected from merged source: ${url}, reconnecting...`);
|
||||
mergedWsConnections = mergedWsConnections.filter(c => c !== ws);
|
||||
setTimeout(() => connectToMergedSource(url), 2000);
|
||||
});
|
||||
|
||||
ws.on('error', (err: Error) => {
|
||||
console.warn(`WebSocket error for ${url}:`, err.message);
|
||||
});
|
||||
}
|
||||
|
||||
function handleIncomingMessage(message: { type: string; [key: string]: unknown }, sender: WebSocket): void {
|
||||
if (message.type === 'reload') {
|
||||
broadcastToClients(message, sender);
|
||||
broadcastToMergedSources(message);
|
||||
}
|
||||
}
|
||||
|
||||
function broadcastToClients(message: { type: string; [key: string]: unknown }, excludeSender?: WebSocket): void {
|
||||
const data = JSON.stringify(message);
|
||||
for (const client of wsClients) {
|
||||
if (client !== excludeSender && client.readyState === WebSocket.OPEN) {
|
||||
client.send(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function broadcastToMergedSources(message: { type: string; [key: string]: unknown }): void {
|
||||
const data = JSON.stringify(message);
|
||||
for (const ws of mergedWsConnections) {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getStaticPaths(): StaticPath[] {
|
||||
const config = loadConfig();
|
||||
return config.serve?.staticPaths || [];
|
||||
}
|
||||
|
||||
function proxyRequest(targetUrl: string, req: http.IncomingMessage, res: http.ServerResponse): void {
|
||||
const parsedUrl = new URL(targetUrl);
|
||||
const protocol = parsedUrl.protocol === 'https:' ? https : http;
|
||||
|
||||
const proxyReq = protocol.request(
|
||||
targetUrl,
|
||||
{
|
||||
method: req.method,
|
||||
headers: {
|
||||
...req.headers,
|
||||
host: parsedUrl.host,
|
||||
},
|
||||
},
|
||||
(proxyRes) => {
|
||||
res.writeHead(proxyRes.statusCode || 500, proxyRes.headers);
|
||||
proxyRes.pipe(res);
|
||||
},
|
||||
);
|
||||
|
||||
proxyReq.on('error', (err) => {
|
||||
console.error('Proxy error:', err.message);
|
||||
res.writeHead(502);
|
||||
res.end('Bad Gateway');
|
||||
});
|
||||
|
||||
req.pipe(proxyReq);
|
||||
}
|
||||
|
||||
function isRemotePath(staticPath: StaticPath): staticPath is StaticRemotePath {
|
||||
return 'url' in staticPath;
|
||||
}
|
||||
|
||||
function tryServeStaticPath(reqUrl: string, req: http.IncomingMessage, res: http.ServerResponse): boolean {
|
||||
const staticPaths = getStaticPaths();
|
||||
|
||||
for (const staticPath of staticPaths) {
|
||||
if (reqUrl.startsWith(staticPath.location)) {
|
||||
const relativePath = reqUrl.slice(staticPath.location.length);
|
||||
|
||||
if (isRemotePath(staticPath)) {
|
||||
const targetUrl = staticPath.url + relativePath;
|
||||
proxyRequest(targetUrl, req, res);
|
||||
return true;
|
||||
}
|
||||
|
||||
const basePath = path.resolve(projectRoot, staticPath.path);
|
||||
let filePath = path.join(basePath, relativePath || 'index.html');
|
||||
|
||||
const normalizedFilePath = path.normalize(filePath);
|
||||
if (!normalizedFilePath.startsWith(basePath)) {
|
||||
res.writeHead(403);
|
||||
res.end('Forbidden');
|
||||
return true;
|
||||
}
|
||||
|
||||
if (fs.existsSync(filePath) && fs.statSync(filePath).isDirectory()) {
|
||||
filePath = path.join(filePath, 'index.html');
|
||||
}
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
res.writeHead(404);
|
||||
res.end('Not Found');
|
||||
return true;
|
||||
}
|
||||
|
||||
const mimeType = getMimeType(filePath);
|
||||
const content = fs.readFileSync(filePath);
|
||||
|
||||
res.writeHead(200, {
|
||||
'Content-Type': mimeType,
|
||||
'Cache-Control': 'no-cache',
|
||||
});
|
||||
res.end(content);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function startHttpServer(port: number): void {
|
||||
httpServer = http.createServer((req, res) => {
|
||||
const reqUrl = req.url || '/';
|
||||
|
||||
if (tryServeStaticPath(reqUrl, req, res)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let filePath = path.join(distDir, reqUrl === '/' ? 'index.html' : reqUrl);
|
||||
|
||||
if (filePath.includes('..')) {
|
||||
res.writeHead(403);
|
||||
res.end('Forbidden');
|
||||
return;
|
||||
}
|
||||
|
||||
if (fs.existsSync(filePath) && fs.statSync(filePath).isDirectory()) {
|
||||
filePath = path.join(filePath, 'index.html');
|
||||
}
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
const indexPath = path.join(distDir, 'index.html');
|
||||
if (fs.existsSync(indexPath)) {
|
||||
filePath = indexPath;
|
||||
} else {
|
||||
res.writeHead(404);
|
||||
res.end('Not Found');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const mimeType = getMimeType(filePath);
|
||||
const content = fs.readFileSync(filePath);
|
||||
|
||||
res.writeHead(200, {
|
||||
'Content-Type': mimeType,
|
||||
'Cache-Control': 'no-cache',
|
||||
});
|
||||
res.end(content);
|
||||
});
|
||||
|
||||
httpServer.listen(port, () => {
|
||||
console.log(`\n** Quarc Live Development Server is listening on localhost:${port} **`);
|
||||
console.log(`** Open your browser on http://localhost:${port}/ **\n`);
|
||||
});
|
||||
|
||||
attachWebSocketServer(httpServer);
|
||||
}
|
||||
|
||||
function notifyClients(): void {
|
||||
const message = { type: 'reload' };
|
||||
|
||||
broadcastToClients(message);
|
||||
broadcastToMergedSources(message);
|
||||
|
||||
if (wsClients.size > 0) {
|
||||
console.log('📢 Notified clients to reload');
|
||||
}
|
||||
}
|
||||
|
||||
async function runBuild(): Promise<void> {
|
||||
if (isBuilding) {
|
||||
buildQueued = true;
|
||||
return;
|
||||
}
|
||||
|
||||
isBuilding = true;
|
||||
buildQueued = false;
|
||||
|
||||
console.log('\n🔨 Building application...');
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const buildScript = path.join(__dirname, 'build.ts');
|
||||
const tsNodePath = path.join(projectRoot, 'node_modules', '.bin', 'ts-node');
|
||||
|
||||
execSync(`${tsNodePath} ${buildScript}`, {
|
||||
stdio: 'inherit',
|
||||
cwd: projectRoot,
|
||||
});
|
||||
|
||||
const duration = ((Date.now() - startTime) / 1000).toFixed(2);
|
||||
console.log(`✅ Build completed in ${duration}s`);
|
||||
|
||||
notifyClients();
|
||||
} catch (error) {
|
||||
console.error('❌ Build failed');
|
||||
} finally {
|
||||
isBuilding = false;
|
||||
|
||||
if (buildQueued) {
|
||||
console.log('⏳ Running queued build...');
|
||||
setTimeout(() => runBuild(), 100);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function watchFiles(): void {
|
||||
console.log(`👀 Watching for changes in ${srcDir}...`);
|
||||
|
||||
const debounceDelay = 300;
|
||||
let debounceTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
const watcher = fs.watch(srcDir, { recursive: true }, (eventType, filename) => {
|
||||
if (!filename) return;
|
||||
|
||||
const ext = path.extname(filename);
|
||||
if (!['.ts', '.scss', '.sass', '.css', '.html'].includes(ext)) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`📝 File changed: ${filename}`);
|
||||
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer);
|
||||
}
|
||||
|
||||
debounceTimer = setTimeout(() => {
|
||||
runBuild();
|
||||
}, debounceDelay);
|
||||
});
|
||||
|
||||
const cleanup = () => {
|
||||
console.log('\n👋 Stopping watch mode...');
|
||||
watcher.close();
|
||||
for (const client of wsClients) {
|
||||
client.close();
|
||||
}
|
||||
wsClients.clear();
|
||||
for (const ws of mergedWsConnections) {
|
||||
ws.close();
|
||||
}
|
||||
mergedWsConnections = [];
|
||||
if (wsServer) {
|
||||
wsServer.close();
|
||||
}
|
||||
if (httpServer) {
|
||||
httpServer.close();
|
||||
}
|
||||
terminateActionProcesses();
|
||||
runPostServeActions();
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on('SIGINT', cleanup);
|
||||
process.on('SIGTERM', cleanup);
|
||||
}
|
||||
|
||||
function injectLiveReloadScript(): void {
|
||||
const indexPath = path.join(distDir, 'index.html');
|
||||
const wsPort = getDevServerPort();
|
||||
|
||||
if (!fs.existsSync(indexPath)) {
|
||||
console.warn('index.html not found in dist directory');
|
||||
return;
|
||||
}
|
||||
|
||||
let html = fs.readFileSync(indexPath, 'utf-8');
|
||||
|
||||
const liveReloadScript = `
|
||||
<script>
|
||||
(function() {
|
||||
let ws;
|
||||
let reconnectAttempts = 0;
|
||||
const maxReconnectDelay = 5000;
|
||||
|
||||
function connect() {
|
||||
ws = new WebSocket('ws://localhost:${wsPort}/qu-ws/');
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('[Live Reload] Connected');
|
||||
reconnectAttempts = 0;
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
if (message.type === 'reload') {
|
||||
console.log('[Live Reload] Reloading page...');
|
||||
window.location.reload();
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
console.warn('[Live Reload] Connection lost, attempting to reconnect...');
|
||||
reconnectAttempts++;
|
||||
const delay = Math.min(1000 * reconnectAttempts, maxReconnectDelay);
|
||||
setTimeout(connect, delay);
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
ws.close();
|
||||
};
|
||||
}
|
||||
|
||||
connect();
|
||||
})();
|
||||
</script>
|
||||
`;
|
||||
|
||||
if (!html.includes('Live Reload')) {
|
||||
html = html.replace('</body>', `${liveReloadScript}</body>`);
|
||||
fs.writeFileSync(indexPath, html, 'utf-8');
|
||||
console.log('✅ Injected live reload script into index.html');
|
||||
}
|
||||
}
|
||||
|
||||
function runPreServeActions(): void {
|
||||
const config = loadConfig();
|
||||
const actions = config.serve?.actions?.preserve || [];
|
||||
|
||||
if (actions.length === 0) return;
|
||||
|
||||
console.log('🔧 Running preserve actions...');
|
||||
|
||||
for (const action of actions) {
|
||||
console.log(` ▶ ${action}`);
|
||||
const child = spawn(action, [], {
|
||||
shell: true,
|
||||
cwd: projectRoot,
|
||||
stdio: 'inherit',
|
||||
detached: true,
|
||||
});
|
||||
|
||||
actionProcesses.push(child);
|
||||
|
||||
child.on('error', (err) => {
|
||||
console.error(` ❌ Action failed: ${action}`, err.message);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function runPostServeActions(): void {
|
||||
const config = loadConfig();
|
||||
const actions = config.serve?.actions?.postserve || [];
|
||||
|
||||
if (actions.length === 0) return;
|
||||
|
||||
console.log('🔧 Running postserve actions...');
|
||||
|
||||
for (const action of actions) {
|
||||
console.log(` ▶ ${action}`);
|
||||
try {
|
||||
execSync(action, {
|
||||
cwd: projectRoot,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(` ❌ Action failed: ${action}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function terminateActionProcesses(): void {
|
||||
if (actionProcesses.length === 0) return;
|
||||
|
||||
console.log('🛑 Terminating action processes...');
|
||||
|
||||
for (const child of actionProcesses) {
|
||||
if (child.pid && !child.killed) {
|
||||
try {
|
||||
process.kill(-child.pid, 'SIGTERM');
|
||||
console.log(` ✓ Terminated process group ${child.pid}`);
|
||||
} catch (err) {
|
||||
try {
|
||||
child.kill('SIGTERM');
|
||||
console.log(` ✓ Terminated process ${child.pid}`);
|
||||
} catch {
|
||||
console.warn(` ⚠ Could not terminate process ${child.pid}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
actionProcesses = [];
|
||||
}
|
||||
|
||||
async function serve(): Promise<void> {
|
||||
const port = getDevServerPort();
|
||||
|
||||
console.log('🚀 Starting development server...\n');
|
||||
|
||||
runPreServeActions();
|
||||
|
||||
console.log('📦 Running initial build...');
|
||||
await runBuild();
|
||||
|
||||
injectLiveReloadScript();
|
||||
|
||||
startHttpServer(port);
|
||||
|
||||
console.log('✨ Development server is ready!');
|
||||
console.log('📂 Serving files from:', distDir);
|
||||
console.log('🔄 Live reload WebSocket enabled on port', port);
|
||||
console.log('\nPress Ctrl+C to stop\n');
|
||||
|
||||
watchFiles();
|
||||
}
|
||||
|
||||
serve().catch(error => {
|
||||
console.error('Serve process failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
export interface SizeThreshold {
|
||||
warning: string;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export interface EnvironmentConfig {
|
||||
treatWarningsAsErrors: boolean;
|
||||
minifyNames: boolean;
|
||||
generateSourceMaps: boolean;
|
||||
compressed?: boolean;
|
||||
devServer?: DevServerConfig;
|
||||
}
|
||||
|
||||
export interface DevServerConfig {
|
||||
port: number;
|
||||
websocket?: WebSocketConfig;
|
||||
}
|
||||
|
||||
export interface WebSocketConfig {
|
||||
mergeFrom?: string[];
|
||||
}
|
||||
|
||||
export interface StaticLocalPath {
|
||||
location: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface StaticRemotePath {
|
||||
location: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export type StaticPath = StaticLocalPath | StaticRemotePath;
|
||||
|
||||
export interface ActionsConfig {
|
||||
prebuild?: string[];
|
||||
postbuild?: string[];
|
||||
preserve?: string[];
|
||||
postserve?: string[];
|
||||
}
|
||||
|
||||
export interface BuildConfig {
|
||||
actions?: ActionsConfig;
|
||||
minifyNames: boolean;
|
||||
scripts?: string[];
|
||||
externalEntryPoints?: string[];
|
||||
styles?: string[];
|
||||
externalStyles?: string[];
|
||||
limits: {
|
||||
total: SizeThreshold;
|
||||
main: SizeThreshold;
|
||||
sourceMaps: SizeThreshold;
|
||||
components?: SizeThreshold;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ServeConfig {
|
||||
actions?: ActionsConfig;
|
||||
staticPaths?: StaticPath[];
|
||||
}
|
||||
|
||||
export interface QuarcConfig {
|
||||
environment: string;
|
||||
build?: BuildConfig;
|
||||
serve?: ServeConfig;
|
||||
environments: {
|
||||
[key: string]: EnvironmentConfig;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ValidationResult {
|
||||
status: 'success' | 'warning' | 'error';
|
||||
message: string;
|
||||
actual: number;
|
||||
limit: number;
|
||||
}
|
||||
|
|
@ -61,6 +61,18 @@ export class Injector {
|
|||
}
|
||||
|
||||
public createInstanceWithProviders<T>(classType: Type<T>, providers: Provider[]): T {
|
||||
console.log('[DI] createInstanceWithProviders START', {
|
||||
classType,
|
||||
typeofClassType: typeof classType,
|
||||
isFunction: typeof classType === 'function',
|
||||
hasName: classType?.name,
|
||||
originalName: (classType as any)?.__quarc_original_name__,
|
||||
providers: providers.map(p => ({
|
||||
provide: typeof p.provide === 'string' ? p.provide : p.provide?.name,
|
||||
type: 'useValue' in p ? 'useValue' : 'useClass' in p ? 'useClass' : 'useFactory' in p ? 'useFactory' : 'useExisting'
|
||||
}))
|
||||
});
|
||||
|
||||
if (!classType) {
|
||||
throw new Error(`[DI] createInstanceWithProviders called with undefined classType`);
|
||||
}
|
||||
|
|
@ -175,21 +187,32 @@ export class Injector {
|
|||
}
|
||||
|
||||
private resolveDependency(token: any, providers: Provider[]): any {
|
||||
console.log('[DI] resolveDependency', {
|
||||
token,
|
||||
typeofToken: typeof token,
|
||||
tokenName: typeof token === 'string' ? token : (token as any).__quarc_original_name__ || token?.name,
|
||||
isFunction: typeof token === 'function',
|
||||
});
|
||||
|
||||
const tokenName = typeof token === 'string' ? token : (token as any).__quarc_original_name__ || token.name;
|
||||
|
||||
const provider = this.findProvider(token, providers);
|
||||
if (provider) {
|
||||
console.log('[DI] Found provider for token', tokenName);
|
||||
return this.resolveProviderValue(provider, providers);
|
||||
}
|
||||
|
||||
if (this.sharedInstances[tokenName]) {
|
||||
console.log('[DI] Found in sharedInstances', tokenName);
|
||||
return this.sharedInstances[tokenName];
|
||||
}
|
||||
|
||||
if (this.instanceCache[tokenName]) {
|
||||
console.log('[DI] Found in instanceCache', tokenName);
|
||||
return this.instanceCache[tokenName];
|
||||
}
|
||||
|
||||
console.log('[DI] Creating new instance for token', tokenName);
|
||||
return this.createInstanceWithProviders(token, providers);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue