fluxer/fluxer_app/scripts/DevServer.tsx
2026-02-17 12:22:36 +00:00

365 lines
9.1 KiB
TypeScript

/*
* 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 <https://www.gnu.org/licenses/>.
*/
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<string, number>;
}
interface Metadata {
[key: string]: StepMetadata;
}
type StepKey = 'wasm' | 'colors' | 'masks' | 'cssTypes' | 'lingui';
async function loadMetadata(): Promise<void> {
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<void> {
if (!metadataCache) {
return;
}
await mkdir(path.dirname(metadataFile), {recursive: true});
await writeFile(metadataFile, JSON.stringify(metadataCache, null, 2), 'utf8');
}
function haveInputsChanged(prev: Record<string, number>, next: Record<string, number>): 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<string, number>): boolean {
if (!metadataCache) {
return true;
}
const entry = metadataCache[stepName];
if (!entry) {
return true;
}
return haveInputsChanged(entry.inputs, inputs);
}
async function collectFileStats(paths: ReadonlyArray<string>): Promise<Record<string, number>> {
const result: Record<string, number> = {};
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<Record<string, number>> {
const accumulator: Record<string, number> = {};
async function walk(relPath: string): Promise<void> {
const absoluteDir = path.join(projectRoot, relPath);
let entries: Array<Dirent>;
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<Record<string, number>>,
command: string,
args: ReadonlyArray<string>,
): Promise<void> {
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<Record<string, number>> {
return collectDirectoryStats(path.join('crates', 'libfluxcore'), () => true);
}
async function gatherColorInputs(): Promise<Record<string, number>> {
return collectFileStats(['scripts/GenerateColorSystem.tsx']);
}
async function gatherMaskInputs(): Promise<Record<string, number>> {
return collectFileStats(['scripts/GenerateAvatarMasks.tsx', 'src/components/uikit/TypingConstants.tsx']);
}
async function gatherCssModuleInputs(): Promise<Record<string, number>> {
return collectDirectoryStats('src', (relPath) => relPath.endsWith('.module.css'));
}
async function gatherLinguiInputs(): Promise<Record<string, number>> {
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<NodeJS.Signals> = ['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<string>): Promise<void> {
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<void> {
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<number> {
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<void> {
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();