/* * Copyright (C) 2026 Fluxer Contributors * * This file is part of Fluxer. * * Fluxer is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Fluxer is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with Fluxer. If not, see . */ import {type ChildProcess, spawn} from 'node:child_process'; import type {Dirent} from 'node:fs'; import {mkdir, readdir, readFile, rm, stat, writeFile} from 'node:fs/promises'; import path from 'node:path'; import {fileURLToPath} from 'node:url'; const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const projectRoot = path.resolve(scriptDir, '..'); const metadataFile = path.join(projectRoot, '.devserver-cache.json'); const binDir = path.join(projectRoot, 'node_modules', '.bin'); const rspackBin = path.join(binDir, 'rspack'); const tcmBin = path.join(binDir, 'tcm'); const DEFAULT_SKIP_DIRS = new Set(['.git', 'node_modules', '.turbo', 'dist', 'target', 'pkg', 'pkgs']); let metadataCache: Metadata | null = null; interface StepMetadata { lastRun: number; inputs: Record; } interface Metadata { [key: string]: StepMetadata; } type StepKey = 'wasm' | 'colors' | 'masks' | 'cssTypes' | 'lingui'; async function loadMetadata(): Promise { if (metadataCache !== null) { return; } try { const raw = await readFile(metadataFile, 'utf8'); const parsed = JSON.parse(raw); metadataCache = (typeof parsed === 'object' && parsed !== null ? parsed : {}) as Metadata; } catch (error) { if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') { metadataCache = {}; return; } console.warn('Failed to read dev server metadata cache, falling back to full rebuild:', error); metadataCache = {}; } } async function saveMetadata(): Promise { if (!metadataCache) { return; } await mkdir(path.dirname(metadataFile), {recursive: true}); await writeFile(metadataFile, JSON.stringify(metadataCache, null, 2), 'utf8'); } function haveInputsChanged(prev: Record, next: Record): boolean { const prevKeys = Object.keys(prev); const nextKeys = Object.keys(next); if (prevKeys.length !== nextKeys.length) { return true; } for (const key of nextKeys) { if (!Object.hasOwn(prev, key) || prev[key] !== next[key]) { return true; } } return false; } function shouldRunStep(stepName: StepKey, inputs: Record): boolean { if (!metadataCache) { return true; } const entry = metadataCache[stepName]; if (!entry) { return true; } return haveInputsChanged(entry.inputs, inputs); } async function collectFileStats(paths: ReadonlyArray): Promise> { const result: Record = {}; for (const relPath of paths) { const absolutePath = path.join(projectRoot, relPath); const fileStat = await stat(absolutePath); if (!fileStat.isFile()) { throw new Error(`Expected ${relPath} to be a file when collecting dev server cache inputs.`); } result[relPath] = fileStat.mtimeMs; } return result; } async function collectDirectoryStats( rootRel: string, predicate: (relPath: string) => boolean, ): Promise> { const accumulator: Record = {}; async function walk(relPath: string): Promise { const absoluteDir = path.join(projectRoot, relPath); let entries: Array; try { entries = await readdir(absoluteDir, {withFileTypes: true}); } catch (error) { if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') { return; } throw error; } for (const entry of entries) { if (entry.isDirectory()) { if (DEFAULT_SKIP_DIRS.has(entry.name)) { continue; } await walk(path.join(relPath, entry.name)); continue; } if (!entry.isFile()) { continue; } const fileRel = path.join(relPath, entry.name); if (!predicate(fileRel)) { continue; } const fileStat = await stat(path.join(projectRoot, fileRel)); accumulator[fileRel] = fileStat.mtimeMs; } } await walk(rootRel); return accumulator; } async function runCachedStep( stepName: StepKey, gatherInputs: () => Promise>, command: string, args: ReadonlyArray, ): Promise { const inputs = await gatherInputs(); if (!shouldRunStep(stepName, inputs)) { console.log(`Skipping ${command} ${args.join(' ')} (no changes detected)`); return; } await runCommand(command, args); metadataCache ??= {}; metadataCache[stepName] = {lastRun: Date.now(), inputs}; await saveMetadata(); } async function gatherWasmInputs(): Promise> { return collectDirectoryStats(path.join('crates', 'libfluxcore'), () => true); } async function gatherColorInputs(): Promise> { return collectFileStats(['scripts/GenerateColorSystem.tsx']); } async function gatherMaskInputs(): Promise> { return collectFileStats(['scripts/GenerateAvatarMasks.tsx', 'src/components/uikit/TypingConstants.tsx']); } async function gatherCssModuleInputs(): Promise> { return collectDirectoryStats('src', (relPath) => relPath.endsWith('.module.css')); } async function gatherLinguiInputs(): Promise> { return collectDirectoryStats(path.join('src', 'locales'), (relPath) => relPath.endsWith('.po')); } let currentChild: ChildProcess | null = null; let cssTypeWatcher: ChildProcess | null = null; let shuttingDown = false; const shutdownSignals: ReadonlyArray = ['SIGINT', 'SIGTERM']; function handleShutdown(signal: NodeJS.Signals): void { if (shuttingDown) { return; } shuttingDown = true; console.log(`\nReceived ${signal}, shutting down fluxer app dev server...`); currentChild?.kill('SIGTERM'); cssTypeWatcher?.kill('SIGTERM'); } shutdownSignals.forEach((signal) => { process.on(signal, () => handleShutdown(signal)); }); function runCommand(command: string, args: ReadonlyArray): Promise { return new Promise((resolve, reject) => { if (shuttingDown) { resolve(); return; } const child = spawn(command, args, { cwd: projectRoot, stdio: 'inherit', }); currentChild = child; child.once('error', (error) => { currentChild = null; reject(error); }); child.once('exit', (code, signal) => { currentChild = null; if (shuttingDown) { resolve(); return; } if (signal) { reject(new Error(`${command} ${args.join(' ')} terminated by signal ${signal}`)); return; } if (code && code !== 0) { reject(new Error(`${command} ${args.join(' ')} exited with status ${code}`)); return; } resolve(); }); }); } async function cleanDist(): Promise { if (shuttingDown) { return; } const distPath = path.join(projectRoot, 'dist'); await rm(distPath, {recursive: true, force: true}); } function startCssTypeWatcher(): void { if (shuttingDown) { return; } const child = spawn(tcmBin, ['src', '--pattern', '**/*.module.css', '--watch', '--silent'], { cwd: projectRoot, stdio: 'inherit', }); cssTypeWatcher = child; child.once('error', (error) => { if (!shuttingDown) { console.error('CSS type watcher error:', error); } cssTypeWatcher = null; }); child.once('exit', (code, signal) => { cssTypeWatcher = null; if (!shuttingDown && code !== 0) { console.error(`CSS type watcher exited unexpectedly (code: ${code}, signal: ${signal})`); } }); } function runRspack(): Promise { return new Promise((resolve, reject) => { if (shuttingDown) { resolve(0); return; } const child = spawn(rspackBin, ['serve', '--mode', 'development'], { cwd: projectRoot, stdio: 'inherit', }); currentChild = child; child.once('error', (error) => { currentChild = null; reject(error); }); child.once('exit', (code, signal) => { currentChild = null; if (shuttingDown) { resolve(0); return; } if (signal) { reject(new Error(`rspack serve terminated by signal ${signal}`)); return; } resolve(code ?? 0); }); }); } async function main(): Promise { await loadMetadata(); try { await runCachedStep('wasm', gatherWasmInputs, 'pnpm', ['wasm:codegen']); await runCachedStep('colors', gatherColorInputs, 'pnpm', ['generate:colors']); await runCachedStep('masks', gatherMaskInputs, 'pnpm', ['generate:masks']); await runCachedStep('cssTypes', gatherCssModuleInputs, 'pnpm', ['generate:css-types']); await runCachedStep('lingui', gatherLinguiInputs, 'pnpm', ['lingui:compile']); await cleanDist(); startCssTypeWatcher(); const rspackExitCode = await runRspack(); if (!shuttingDown && rspackExitCode !== 0) { process.exit(rspackExitCode); } } catch (error) { if (shuttingDown) { process.exit(0); } console.error(error); process.exit(1); } } void main();