From 0fe8b0fdd2a7aa06fa398d9cdfa79798ce88cb39 Mon Sep 17 00:00:00 2001 From: Michal Date: Sat, 17 Jan 2026 00:25:26 +0100 Subject: [PATCH] refactor cli --- cli/bin/qu.js | 44 ++- cli/build.ts | 710 ------------------------------------ cli/quarc-transformer.ts | 123 +++++++ cli/scripts/base-builder.ts | 635 ++++++++++++++++++++++++++++++++ cli/scripts/build.ts | 47 +++ cli/scripts/serve.ts | 557 ++++++++++++++++++++++++++++ cli/serve.ts | 618 ------------------------------- cli/types.ts | 76 ++++ core/module/injector.ts | 23 ++ 9 files changed, 1494 insertions(+), 1339 deletions(-) delete mode 100644 cli/build.ts create mode 100644 cli/quarc-transformer.ts create mode 100644 cli/scripts/base-builder.ts create mode 100644 cli/scripts/build.ts create mode 100644 cli/scripts/serve.ts delete mode 100644 cli/serve.ts create mode 100644 cli/types.ts diff --git a/cli/bin/qu.js b/cli/bin/qu.js index 7e6ccd3..c1412b6 100755 --- a/cli/bin/qu.js +++ b/cli/bin/qu.js @@ -9,24 +9,45 @@ const args = process.argv.slice(3); if (!command) { console.log('Usage: qu [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(' help Show this help message'); + console.log('\nGlobal options:'); + console.log(' -c, --configuration Specify environment (development/production)'); + console.log(' -e, --environment Alias for --configuration'); + console.log('\nBuild options:'); + console.log(' -v, --verbose Show detailed build logs'); + console.log('\nServe options:'); + console.log(' -p, --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); } if (command === 'help' || command === '--help' || command === '-h') { console.log('Usage: qu [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(' help Show this help message'); + console.log('\nGlobal options:'); + console.log(' -c, --configuration Specify environment (development/production)'); + console.log(' -e, --environment Alias for --configuration'); + console.log('\nBuild options:'); + console.log(' -v, --verbose Show detailed build logs'); + console.log('\nServe options:'); + console.log(' -p, --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); } diff --git a/cli/build.ts b/cli/build.ts deleted file mode 100644 index 8598fdf..0000000 --- a/cli/build.ts +++ /dev/null @@ -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 { - 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 { - 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 { - 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 { - 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 { - 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 += ` \n`; - } - } - - if (styleInjections) { - html = html.replace('', `${styleInjections}`); - } - - let scriptInjections = ''; - for (const scriptPath of scripts) { - const basename = path.basename(scriptPath); - if (!html.includes(basename)) { - scriptInjections += ` \n`; - } - } - - const mainScript = ` \n`; - if (!html.includes('main.js')) { - scriptInjections += mainScript; - } - - if (scriptInjections) { - html = html.replace('', `${scriptInjections}`); - } - - 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 { - 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); -}); diff --git a/cli/quarc-transformer.ts b/cli/quarc-transformer.ts new file mode 100644 index 0000000..f1fcd01 --- /dev/null +++ b/cli/quarc-transformer.ts @@ -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(); +} diff --git a/cli/scripts/base-builder.ts b/cli/scripts/base-builder.ts new file mode 100644 index 0000000..99464dd --- /dev/null +++ b/cli/scripts/base-builder.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 += ` \n`; + } + } + + if (styleInjections) { + html = html.replace('', `${styleInjections}`); + } + + let scriptInjections = ''; + for (const scriptPath of scripts) { + const basename = path.basename(scriptPath); + if (!html.includes(basename)) { + scriptInjections += ` \n`; + } + } + + const mainScript = ` \n`; + if (!html.includes('main.js')) { + scriptInjections += mainScript; + } + + if (scriptInjections) { + html = html.replace('', `${scriptInjections}`); + } + + 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; +} diff --git a/cli/scripts/build.ts b/cli/scripts/build.ts new file mode 100644 index 0000000..5e112e6 --- /dev/null +++ b/cli/scripts/build.ts @@ -0,0 +1,47 @@ +#!/usr/bin/env node + +import { BaseBuilder } from './base-builder'; + +class Builder extends BaseBuilder { + async run(): Promise { + 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); +}); diff --git a/cli/scripts/serve.ts b/cli/scripts/serve.ts new file mode 100644 index 0000000..609074f --- /dev/null +++ b/cli/scripts/serve.ts @@ -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 = 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 { + 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 = ` + +`; + + if (!html.includes('Live Reload')) { + html = html.replace('', `${liveReloadScript}`); + 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 { + 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); +}); diff --git a/cli/serve.ts b/cli/serve.ts deleted file mode 100644 index 18c5d5e..0000000 --- a/cli/serve.ts +++ /dev/null @@ -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 = 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 { - 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 = ` - -`; - - if (!html.includes('Live Reload')) { - html = html.replace('', `${liveReloadScript}`); - 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 { - 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); -}); diff --git a/cli/types.ts b/cli/types.ts new file mode 100644 index 0000000..cd355bb --- /dev/null +++ b/cli/types.ts @@ -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; +} diff --git a/core/module/injector.ts b/core/module/injector.ts index 8765642..b04010e 100644 --- a/core/module/injector.ts +++ b/core/module/injector.ts @@ -61,6 +61,18 @@ export class Injector { } public createInstanceWithProviders(classType: Type, 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); }