[ Web Proxy ]
URL:
Viewing: https://raw.githubusercontent.com/devforth/adminforth/v3.32.0/adminforth/modules/codeInjector.ts [Back]  [Original]

import { exec, spawn } from 'child_process';
import chokidar from 'chokidar';
import fs from 'fs';
import os from 'os';
import path from 'path';
import pLimit from 'p-limit';
import { promisify } from 'util';
import yaml from 'yaml';
import AdminForth, { AdminForthConfigMenuItem } from '../index.js';
import { ADMIN_FORTH_ABSOLUTE_PATH, getComponentNameFromPath, transformObject, deepMerge, md5hash, slugifyString } from './utils.js';
import { ICodeInjector } from '../types/Back.js';
import { StylesGenerator } from './styleGenerator.js';
import { afLogger } from '../modules/logger.js';
import { pathToFileURL } from 'url';


let TMP_DIR;

try {
  TMP_DIR = os.tmpdir();
} catch (e) {
  if (process.platform === 'win32') {
    TMP_DIR = process.env.TEMP || process.env.TMP || 'C:\\Windows\\Temp';
  } else {
    TMP_DIR = '/tmp';
  }//maybe we can consider to use node_modules/.cache/adminforth here instead of tmp
}

function stripAnsiCodes(str) {
  // Regular expression to match ANSI escape codes
  return str.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, '');
}

// how many files we copy into spa_tmp at the same time, to not exhaust file descriptors on big trees.
// One limiter for the whole module, so several copyTreeAtomic calls in a row share the same budget
// instead of each opening its own 32.
const copyLimit = pLimit(32);

// marks serveDir as created by AdminForth, so it is safe to remove it before a rebuild
const SERVE_DIR_MARKER = '.adminforth_serve_dir';

// the only spa_tmp entries which survive prepareSources: node_modules is the install cache and hashes.json
// explains the next build cache miss. Everything else is copied from the sources again.
const SPA_TMP_KEPT_ENTRIES = ['node_modules', 'hashes.json'];

// what prepareSources found out about SPA dependencies, installSpaDependencies installs them when they are needed
type SpaDependencies = {
  hash: string,
  icons: string[],
  packages: string[],
};

let atomicCopySeq = 0;

const ATOMIC_COPY_TMP_RE = /\.af-(\d+)-\d+\.tmp$/;
const LOGIN_PAGE_ROUTE_PART_RE = /\/\* IMPORTANT:ADMINFORTH LOGIN PAGE START \*\/[\s\S]*?\/\* IMPORTANT:ADMINFORTH LOGIN PAGE END \*\//;

function isAtomicCopyTempName(name: string): boolean {
  return ATOMIC_COPY_TMP_RE.test(name);
}

// the pid baked into a copyFileAtomic temp name, so we can tell our own leftovers from someone else's
function atomicCopyTempPid(name: string): number | null {
  const match = name.match(ATOMIC_COPY_TMP_RE);
  return match ? parseInt(match[1], 10) : null;
}

function isProcessAlive(pid: number): boolean {
  try {
    process.kill(pid, 0);
    return true;
  } catch (e: any) {
    return e?.code === 'EPERM'; // exists but is not ours to signal
  }
}

/**
 * Copies a single file to dest atomically: the content goes to a unique temp file next to dest
 * and is then renamed over it. rename(2) replaces the destination in one step (and MoveFileEx with
 * MOVEFILE_REPLACE_EXISTING does the same on Windows), so dest is never missing or half-written.
 *
 * fsExtra.copy() cannot be used for this: for an existing destination it does
 * unlink(dest) -> copyFile(src, dest) -> chmod(dest, mode) as three separate syscalls. Anything that
 * writes the same destination in between - a second hot reload event, or another process bundling into
 * the same spa_tmp - makes it fail with `ENOENT: ... unlink` or `ENOENT: ... chmod`, and readers
 * (vite, computeSourcesHash) can observe a missing file.
 */
async function copyFileAtomic(src: string, dest: string, mode?: number): Promise {
  await fs.promises.mkdir(path.dirname(dest), { recursive: true });
  const tmp = `${dest}.af-${process.pid}-${atomicCopySeq++}.tmp`;
  try {
    await fs.promises.copyFile(src, tmp);
    if (mode !== undefined) {
      // safe to chmod before the rename: tmp is private to this call, nobody else can see it
      await fs.promises.chmod(tmp, mode);
    }
    await renameWithRetry(tmp, dest);
  } catch (e) {
    await fs.promises.rm(tmp, { force: true }).catch(() => {});
    throw e;
  }
}

/**
 * On Windows a rename over an open destination fails with EPERM/EACCES/EBUSY until the other holder
 * (vite, an editor, an antivirus scanner) lets go, so give it a few short tries before giving up.
 * On POSIX the first attempt always succeeds.
 */
async function renameWithRetry(from: string, to: string, attempts = 5): Promise {
  for (let attempt = 1; ; attempt++) {
    try {
      await fs.promises.rename(from, to);
      return;
    } catch (e: any) {
      const retriable = e?.code === 'EPERM' || e?.code === 'EACCES' || e?.code === 'EBUSY';
      if (!retriable || attempt >= attempts) {
        throw e;
      }
      await new Promise((resolve) => setTimeout(resolve, 10 * attempt));
    }
  }
}

/**
 * Recursive copy with the semantics prepareSources relies on: symlinks are dereferenced,
 * `filter` prunes a source path together with its subtree. Every file lands atomically
 * (see copyFileAtomic) and file copies go through the shared copyLimit.
 */
async function copyTreeAtomic(
  src: string,
  dest: string,
  { filter }: { filter?: (src: string) => boolean } = {}
): Promise {
  const walk = async (from: string, to: string) => {
    if (filter && !filter(from)) {
      process.env.HEAVY_DEBUG && console.log(` copyTreeAtomic filtered out, ${from}`);
      return;
    }
    // stat (not lstat) follows symlinks, same as fsExtra's dereference: true
    const stat = await fs.promises.stat(from);
    if (stat.isDirectory()) {
      await fs.promises.mkdir(to, { recursive: true });
      const entries = await fs.promises.readdir(from);
      await Promise.all(entries.map((name) => walk(path.join(from, name), path.join(to, name))));
    } else {
      await copyLimit(() => copyFileAtomic(from, to, stat.mode));
    }
  };

  await walk(src, dest);
}

function findHomePage(menuItem: AdminForthConfigMenuItem[]): AdminForthConfigMenuItem | undefined {
  for (const item of menuItem) {
    if (item.homepage) {
      return item;
    }
    if (item.children) {
      const found = findHomePage(item.children);
      if (found) {
        return found;
      }
    }
  }
  return undefined;
}
async function findFirstMenuItemWithResource(menuItem: AdminForthConfigMenuItem[]): Promise {
  for (const item of menuItem) {
    if (item.path || item.resourceId) {
      return item;
    }
    if (item.children) {
      const found = await findFirstMenuItemWithResource(item.children);
      if (found) {
        return found;
      }
    }
  }
  return undefined;
}

const execAsync = promisify(exec);

function hashify(obj) {
  return md5hash(JSON.stringify(obj));
}

function isFulfilled(result: PromiseSettledResult): result is PromiseFulfilledResult {
  return result.status === 'fulfilled';
}

/**
 * chokidar options shared by both watchers.
 *
 * awaitWriteFinish is what makes a save safe to act on: chokidar waits for the size to stop changing
 * before emitting, so we never copy a half-written .vue file. atomic (default true) collapses the
 * unlink+add pair that editors produce when they save via a temp file and rename.
 *
 * Polling is deliberately not configured here: chokidar honours CHOKIDAR_USEPOLLING and
 * CHOKIDAR_INTERVAL on its own, and that is the same switch vite's watcher reads.
 */
function watcherOptions() {
  return {
    ignoreInitial: true,
    awaitWriteFinish: {
      stabilityThreshold: 60,
      pollInterval: 20,
    },
    ignored: (checkedPath: string) => {
      const base = path.basename(checkedPath);
      return base === 'node_modules' || base === 'dist' || isAtomicCopyTempName(base);
    },
  };
}

class CodeInjector implements ICodeInjector {

  allWatchers = [];
  adminforth: AdminForth;
  allComponentNames: { [key: string]: string } = {};
  srcFoldersToSync: { [key: string]: string } = {};
  publicFoldersToSync: { [key: string]: string } = {};
  devServerPort: number = null;

  spaTmpPath(): string {
    const { spaBuildDir, brandNameSlug } = this.adminforth.config.customization;
    if (spaBuildDir) {
      return path.join(spaBuildDir, 'spa_tmp');
    }
    if (!brandNameSlug) {
      throw new Error('brandSlug is empty, but it should be populated at least by config Validator ');
    }
    return path.join(TMP_DIR, 'adminforth', brandNameSlug, 'spa_tmp');
  }

  /**
   * spa_tmp is named after the brand slug only, so every run of the same project shares one folder.
   * We record who owns it next to it (not inside, so it stays out of the sources hash) purely to make
   * a second process visible: two of them copy over each other's files, which surfaces later as a
   * random fs error in an unrelated place. Investigation notes are in AdminForth/1519.
   */
  private async claimSpaTmp(): Promise {
    const ownerFile = path.join(path.dirname(this.spaTmpPath()), '.owner');
    const previous = await this.tryReadFile(ownerFile);
    if (previous) {
      const [pidText, ...command] = previous.trim().split(' ');
      const pid = parseInt(pidText, 10);
      if (pid && pid !== process.pid && isProcessAlive(pid)) {
        afLogger.warn(
          `Another AdminForth process is already using ${this.spaTmpPath()}: pid ${pid}` +
          `${command.length ? ` (${command.join(' ')})` : ''}. They share this folder because it is named ` +
          `after the brand only, so their file copies overwrite each other. If you did not start a second ` +
          `dev server or "npx adminforth bundle" on purpose, please report this on AdminForth/1519.`
        );
      }
    }
    await fs.promises.writeFile(ownerFile, `${process.pid} ${process.argv.slice(1).join(' ')}`).catch(() => {});
  }

  async checkIconNames(icons: string[]) {
    process.env.HEAVY_DEBUG && console.log(`Checking icon names: ${icons.join(', ')}`);
    const uniqueIcons = Array.from(new Set(icons));
    process.env.HEAVY_DEBUG && console.log(`Unique icons: ${uniqueIcons.join(', ')}`);
    const collections = new Set(icons.map((icon) => icon.split(':')[0]));
    process.env.HEAVY_DEBUG && console.log(`Icon collections: ${Array.from(collections).join(', ')}`);
    const iconPackageNames = Array.from(collections).map((collection) => `@iconify-prerendered/vue-${collection}`);
    process.env.HEAVY_DEBUG && console.log(`Icon package names: ${iconPackageNames.join(', ')}`);
    const iconPackages = (
      await Promise.allSettled(
        iconPackageNames.map(
          async (pkg) => (
            { 
              pkg: await import(pathToFileURL(path.join(this.spaTmpPath(), 'node_modules', pkg, 'index.js')).href), 
              name: pkg
            }
          )
        )
      )
    );
    process.env.HEAVY_DEBUG && console.log(`Icon packages load results: ${iconPackages.map(res => res.status === 'fulfilled' ? res.value.name : 'error:' + res.reason).join(', ')}`);
    const loadedIconPackages = iconPackages.filter(isFulfilled).map((res) => res.value).reduce((acc, { pkg, name }) => {
      acc[name.slice(`@iconify-prerendered/vue-`.length)] = pkg;
      return acc;
    }, {});
    process.env.HEAVY_DEBUG && console.log(`Loaded icon packages: ${Object.keys(loadedIconPackages).join(', ')}`);
    uniqueIcons.forEach((icon) => {
      const [ collection, iconName ] = icon.split(':');
      const PascalIconName = 'Icon' + iconName.split('-').map((part, index) => {
        return part[0].toUpperCase() + part.slice(1);
      }).join('');
      process.env.HEAVY_DEBUG && console.log(`Checking icon: ${icon}, collection: ${collection}, iconName: ${iconName}, PascalIconName: ${PascalIconName}`);
      if (!loadedIconPackages[collection]) {
        throw new Error(`Collection ${collection} not found`);
      }
      if (!loadedIconPackages[collection][PascalIconName]) {
        throw new Error(`Icon ${iconName} not found in collection ${collection}`);
      }
    });
  }


  registerCustomComponent(filePath: string): void {
    const componentName = getComponentNameFromPath(filePath);
    this.allComponentNames[filePath] = componentName;
  }

  collectTailwindSafelist(): string[] {
    const classes = new Set();

    for (const resource of this.adminforth.config.resources) {
      for (const column of resource.columns || []) {
        if (!column.listCssClass) {
          continue;
        }

        column.listCssClass
          .split(/\s+/)
          .filter(Boolean)
          .forEach((className) => classes.add(className));
      }
    }

    return Array.from(classes);
  }

  cleanup() {
    console.log('Cleaning up...');
    this.allWatchers.forEach((watcher) => {
      // close() is async, but the signal handler exits right after us - firing it is all we can do,
      // and an already-closed watcher must not throw out of a signal handler
      try {
        watcher.close();
      } catch (e) {
        process.env.HEAVY_DEBUG && console.log(` Failed to close a watcher: ${e}`);
      }
    });
  }
  constructor(adminforth) {
    this.adminforth = adminforth;

    ['SIGINT', 'SIGTERM', 'SIGQUIT']
      .forEach(signal => process.on(signal, () => {
        this.cleanup();
        process.exit();
      }));

  }


  public async doesUserHasPnpmLockFile(dir: string): Promise {
    if (!dir) {
      return false;
    }

    const usersPackagePath = path.join(dir, 'package.json');
    let packageContent: { dependencies: any, devDependencies: any } = null;
    try {
      packageContent = JSON.parse(await fs.promises.readFile(usersPackagePath, 'utf-8'));
    } catch (e) {
      // user package.json does not exist, user does not have custom components
    }
    if (packageContent) {
      const lockPath = path.join(dir, 'pnpm-lock.yaml');
      let lock: any = null;
      try {
        lock = yaml.parse(await fs.promises.readFile(lockPath, 'utf-8'));
        return true;
      } catch (e) {
        return false;
      }
    }
    return false;
  }

  async runPackageManagerShell({command, cwd, envOverrides = {}}: {
    command: string,
    cwd: string,
    envOverrides?: { [key: string]: string }
  }) {
    
    const nodeBinary = process.execPath; // Path to the Node.js binary running this script
    const doesUserHavePnpmLock = await this.doesUserHasPnpmLockFile(this.adminforth.config.customization.customComponentsDir);

    // On Windows, npm/pnpm is npm/pnpm.cmd, on Unix systems it's npm/pnpm
    let packageExecutable 
    if (doesUserHavePnpmLock) {
      process.env.HEAVY_DEBUG && console.log(`User has pnpm-lock.yaml, using pnpm for installing custom components`);
      packageExecutable = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm';
    } else {
      process.env.HEAVY_DEBUG && console.log(`User does not have pnpm-lock.yaml, falling back to npm for installing custom components`);
      packageExecutable = process.platform === 'win32' ? 'npm.cmd' : 'npm';
    }
    const packagePath = path.join(path.dirname(nodeBinary), packageExecutable); // Path to the package executable

    const env = {
      VITE_ADMINFORTH_PUBLIC_PATH: this.adminforth.config.baseUrl,
      FORCE_COLOR: '1',
      ...process.env,
      ...envOverrides,
    };

    process.env.HEAVY_DEBUG && console.log(` exec: ${packageExecutable} ${command}`);
    process.env.HEAVY_DEBUG && console.log(` ${packageExecutable} ${command} cwd: ${cwd}`);

    let execCommand: string;
    if (process.platform === 'win32') {
      // Quote path if it contains spaces
      const quotedPackagePath = packagePath.includes(' ') ? `"${packagePath}"` : packagePath;
      execCommand = `${quotedPackagePath} ${command}`;
    } else {
      // Quote paths that contain spaces (for Unix systems)
      const quotedNodeBinary = nodeBinary.includes(' ') ? `"${nodeBinary}"` : nodeBinary;
      const quotedPackagePath = packagePath.includes(' ') ? `"${packagePath}"` : packagePath;
      execCommand = `${quotedNodeBinary} ${quotedPackagePath} ${command}`;
    }
    
    const execOptions: any = {
      cwd,
      env,
    };

    if (process.platform === 'win32') {
      execOptions.shell = true;
    }

    const { stderr: err } = await execAsync(execCommand, execOptions);
    process.env.HEAVY_DEBUG && console.log(`${packageExecutable} ${command} done in`);

    if (err) {
      process.env.HEAVY_DEBUG && console.log(`${packageExecutable} ${command} errors/warnings: ${err}`);
    }
  }

  async rmTmpDir() {
    // remove spa_tmp folder if it is exists
    try {
      await fs.promises.rm(
        this.spaTmpPath(), { recursive: true });
    } catch (e) {
      // ignore
    }
  }

  getServeDir() {
    return this.adminforth.config.customization.spaServeDir || path.join(this.getSpaDir(), 'dist');
  }

  /**
   * serveDir is removed before every rebuild, so a user-configured spaServeDir which points to a folder with
   * other files must not be wiped: only an empty folder or one created by AdminForth (it has the marker) is cleared.
   * The default serveDir lives inside the AdminForth package, so it is always ours.
   */
  private async assertServeDirCanBeCleared(serveDir: string): Promise {
    if (!this.adminforth.config.customization.spaServeDir) {
      return;
    }
    let entries: string[];
    try {
      entries = await fs.promises.readdir(serveDir);
    } catch (e) {
      if (e.code === 'ENOENT') {
        return;
      }
      throw e;
    }
    if (entries.length && !entries.includes(SERVE_DIR_MARKER)) {
      throw new Error(
        `customization.spaServeDir "${serveDir}" is not empty and was not created by AdminForth. ` +
        `Its content is removed on every SPA rebuild, so please point spaServeDir to a dedicated directory.`
      );
    }
  }

  async parsePackageLockPackages(dir: string, packageContent: { dependencies: any, devDependencies: any }): Promise {
    const npmLockPath = path.join(dir, 'package-lock.json');
    let npmLock: any = null;
    try {
      npmLock = JSON.parse(await fs.promises.readFile(npmLockPath, 'utf-8'));
    } catch (npmLockError) {
      throw new Error(`Custom pnpm-lock.yaml or package-lock.json does not exist in ${dir}, but package.json does.
      We can't determine version of packages without pnpm-lock.yaml or package-lock.json. Please run pnpm install or npm install in ${dir}`);
    }

    const lockHash = hashify(npmLock);

    const packages = [
      ...Object.keys(packageContent.dependencies || {}),
      ...Object.keys(packageContent.devDependencies || {})
    ].reduce(
      (acc, packageName) => {
        const pack = npmLock?.packages?.[`node_modules/${packageName}`];
        if (!pack?.version) {
          throw new Error(`Package ${packageName} is not in package-lock.json but is in package.json. Please run 'npm install' in ${dir}`);
        }

        acc.push(`${packageName}@${pack.version}`);
        return acc;
      }, []
    );

    return [lockHash, packages];
  }

  async packagesFromPnpm(dir: string): Promise {
    const usersPackagePath = path.join(dir, 'package.json');
    let packageContent: { dependencies: any, devDependencies: any } = null;
    let lockHash: string = '';
    let packages: string[] = [];
    try {
      packageContent = JSON.parse(await fs.promises.readFile(usersPackagePath, 'utf-8'));
    } catch (e) {
      // user package.json does not exist, user does not have custom components
    }
    if (packageContent) {
      const lockPath = path.join(dir, 'pnpm-lock.yaml');
      let lock: any = null;
      try {
        lock = yaml.parse(await fs.promises.readFile(lockPath, 'utf-8'));
      } catch (e) {
        return await this.parsePackageLockPackages(dir, packageContent);
      }
      lockHash = hashify(lock);
      const importer = lock?.importers?.['.'];
      if (!importer) {
        throw new Error(`pnpm-lock.yaml in ${dir} does not contain importer ".". Please run pnpm install in ${dir}`);
      }

      const importerDeps = {
        ...(importer.dependencies || {}),
        ...(importer.devDependencies || {}),
        ...(importer.optionalDependencies || {}),
      };
      try {
        packages = [
          ...Object.keys(packageContent.dependencies || {}),
          ...Object.keys(packageContent.devDependencies || {})
        ].reduce(
            (acc, packageName) => {
              const depInfo = importerDeps[packageName];
              const raw = typeof depInfo === 'string'
                ? depInfo
                : (depInfo?.version || depInfo?.specifier);

              if (!raw) {
                throw new Error(`Package ${packageName} is not in pnpm-lock.yaml but is in package.json. Please run 'pnpm install' in ${dir}`);
              }

              const cleaned = raw.includes('(') ? raw.split('(')[0] : raw;

              acc.push(`${packageName}@${cleaned}`);
              return acc;
            }, []
        );
      } catch (e) {
        console.log(`Error while parsing pnpm-lock.yaml: ${e.message}. Falling back to package-lock.json parsing.`);
        // if there there is no sync between package.json and pnpm-lock.yaml - fallback to the package-lock.json parsing
        return await this.parsePackageLockPackages(dir, packageContent);
      }
    }
    return [lockHash, packages];
  }

  async allowBuildsFromWorkspaceFile(dir: string, sourceName: string): Promise {
    const content = await this.tryReadFile(path.join(dir, 'pnpm-workspace.yaml'));
    if (!content) {
      return {};
    }

    let parsed: any = null;
    try {
      parsed = yaml.parse(content);
    } catch (e) {
      afLogger.warn(`Could not parse pnpm-workspace.yaml in ${dir}, ignoring its allowBuilds: ${e.message}`);
      return {};
    }

    const allowBuilds: { [packageName: string]: boolean } = {};
    for (const [packageName, allowed] of Object.entries(parsed?.allowBuilds || {})) {
      if (typeof allowed !== 'boolean') {
        afLogger.warn(
          `Ignoring allowBuilds."${packageName}" declared by ${sourceName}: expected true or false, got ${JSON.stringify(allowed)}`
        );
        continue;
      }
      allowBuilds[packageName] = allowed;
    }
    return allowBuilds;
  }

  async syncAllowBuildsToSpaTmp(): Promise {
    const sources: { name: string, dir: string }[] = [];
    const customComponentsDir = this.adminforth.config.customization?.customComponentsDir;
    if (customComponentsDir) {
      sources.push({ name: 'customComponentsDir', dir: path.resolve(customComponentsDir) });
    }
    for (const plugin of this.adminforth.activatedPlugins) {
      sources.push({ name: plugin.constructor.name, dir: plugin.customFolderPath });
    }

    const merged: { [packageName: string]: boolean } = {};
    for (const { name, dir } of sources) {
      const allowBuilds = await this.allowBuildsFromWorkspaceFile(dir, name);
      for (const [packageName, allowed] of Object.entries(allowBuilds)) {
        if (merged[packageName] !== undefined && merged[packageName] !== allowed) {
          afLogger.warn(`Conflicting allowBuilds for "${packageName}", denying the build. Last source: ${name}`);
          merged[packageName] = false;
          continue;
        }
        merged[packageName] = allowed;
      }
    }
    process.env.HEAVY_DEBUG && console.log(` allowBuilds collected from plugins/custom dir: ${JSON.stringify(merged)}`);

    if (!Object.keys(merged).length) {
      return merged;
    }

    const workspacePath = path.join(this.spaTmpPath(), 'pnpm-workspace.yaml');
    const workspaceContent = await this.tryReadFile(workspacePath);
    const workspace = workspaceContent ? (yaml.parse(workspaceContent) || {}) : {};
    workspace.allowBuilds = { ...merged, ...(workspace.allowBuilds || {}) };
    await fs.promises.writeFile(workspacePath, yaml.stringify(workspace));

    return merged;
  }

  getSpaDir() {
    let spaDir = path.join(ADMIN_FORTH_ABSOLUTE_PATH, 'spa');
    if (!fs.existsSync(spaDir)) {
      spaDir = path.join(ADMIN_FORTH_ABSOLUTE_PATH, 'dist', 'spa');
    }
    return spaDir;
  }

  registerPluginPublicFoldersToSync() {
    this.publicFoldersToSync = {};

    for (const plugin of this.adminforth.activatedPlugins) {
      const pluginPublicFolderPath = path.join(plugin.customFolderPath, 'public');
      if (fs.existsSync(pluginPublicFolderPath)) {
        this.publicFoldersToSync[pluginPublicFolderPath] = `./plugins/${plugin.className}/`;
      }
    }
  }

  async updatePartials({ filesUpdated }: { filesUpdated: string[] }) {
    const spaDir = this.getSpaDir();

    // copy only updated files
    await Promise.all(filesUpdated.map(async (file) => {
      const src = path.join(spaDir, file);
      const dest = path.join(this.spaTmpPath(), file);

      // atomic: a hot reload copy must never leave dest missing for a reader (vite / computeSourcesHash)
      await copyFileAtomic(src, dest);
      process.env.HEAVY_DEBUG && console.log(` copyFileAtomic copy single file, ${src}, ${dest}`);
    }));
  }
  async migrateLegacyCustomLayout(oldMeta) {
    if (oldMeta.customLayout === true) {
      oldMeta.sidebarAndHeader = "none";
    }
    return oldMeta;
  }
  async prepareSources(): Promise {
    // collects all files and folders into SPA_TMP_DIR

    // check spa tmp folder exists and create if not
    try {
      await fs.promises.access(this.spaTmpPath(), fs.constants.F_OK);
    } catch (e) {
      await fs.promises.mkdir(this.spaTmpPath(), { recursive: true });
    }

    await this.claimSpaTmp();

    // leftovers of deleted sources would stay in the sources hash, so a build made in a reused spa_tmp
    // (e.g. docker build with a cache mount) would never match a clean spa_tmp of the same sources at runtime
    const spaTmpEntries = await fs.promises.readdir(this.spaTmpPath());
    await Promise.all(spaTmpEntries
      .filter((entry) => !SPA_TMP_KEPT_ENTRIES.includes(entry))
      .map((entry) => fs.promises.rm(path.join(this.spaTmpPath(), entry), { recursive: true, force: true }))
    );

    const icons = [];
    let routes = '';
    let routerComponents = '';  

    const collectAssetsFromMenu = (menu) => {
      menu.forEach((item) => {
        if (item.icon) {
          icons.push(item.icon);
        }
        
        if (item.component) {
          if(Object.keys(item).includes('isStaticRoute')) {
            if(!item.isStaticRoute) {
              routes += `{
                path: '${item.path}',
                name: '${item.path}',
                component: () => import('${item.component}'),
                meta: { title: '${item?.meta?.title || item?.label ||  item.path.replace('/', '')}'}
              },\n`
            } else {
              routes += `{
                path: '${item.path}',
                name: '${item.path}',
                component: ${getComponentNameFromPath(item.component)},
                meta: { title: '${item?.meta?.title || item?.label ||item.path.replace('/', '')}'}
              },\n`
              const componentName = `${getComponentNameFromPath(item.component)}`;
              routerComponents += `import ${componentName} from '${item.component}';\n`;
            }
          } else {
              if (item.homepage) {
                routes += `{
                  path: '${item.path}',
                  name: '${item.path}',
                  component: ${getComponentNameFromPath(item.component)},
                  meta: { title: '${item?.meta?.title || item?.label || item.path.replace('/', '')}'}
                },\n`
                const componentName = `${getComponentNameFromPath(item.component)}`;
                routerComponents += `import ${componentName} from '${item.component}';\n`;
              } else {
                routes += `{
                  path: '${item.path}',
                  name: '${item.path}',
                  component: () => import('${item.component}'),
                  meta: { title: '${item?.meta?.title || item.path.replace('/', '')}'}
                },\n` 
                }
          }
        }
        if (item.children) {
          collectAssetsFromMenu(item.children);
        }
      });
    };
    const registerCustomPages = (config) => {
      if (config.customization.customPages) {
        config.customization.customPages.forEach(async (page) => {
          const newMeta = await this.migrateLegacyCustomLayout(page?.component?.meta || {});
          routes += `{
            path: '${page.path}',
            name: '${page.path}',
            component: () => import('${page?.component?.file || page.component}'),
            meta: ${
                JSON.stringify({
                  ...newMeta,
                  title: page.meta?.title || page.path.replace('/', '')
                })
            }
          },`})
    }}
    const registerSettingPages = ( settingPage ) => {
      if (!settingPage) {
        return;
      }
      for (const page of settingPage) {
        if (page.icon) {
          icons.push(page.icon);
        }
      }
    }

    registerCustomPages(this.adminforth.config);
    const menuWithContributions = await this.adminforth.getMenuWithContributions();
    collectAssetsFromMenu(menuWithContributions);
    registerSettingPages(this.adminforth.config.auth.userMenuSettingsPages);
    const spaDir = this.getSpaDir();

    process.env.HEAVY_DEBUG && console.log(` copyTreeAtomic from ${spaDir} -> ${this.spaTmpPath()}`);

    // overwrite can't be used to not destroy cache
  
    await copyTreeAtomic(spaDir, this.spaTmpPath(), {
      filter: (src) => {
        // /adminforth/* used for local development and /dist/* used for production
        const filterPasses = !src.includes(`${path.sep}adminforth${path.sep}spa${path.sep}node_modules`) && !src.includes(`${path.sep}adminforth${path.sep}spa${path.sep}dist`) 
                          && !src.includes(`${path.sep}dist${path.sep}spa${path.sep}node_modules`) && !src.includes(`${path.sep}dist${path.sep}spa${path.sep}dist`);
        return filterPasses
      },
    });

    // copy whole custom directory
    if (this.adminforth.config.customization?.customComponentsDir) {
      // resolve customComponentsDir to absolute path, so ./aa will be resolved to /path/to/current/dir/aa
      const customCompAbsPath = path.resolve(this.adminforth.config.customization.customComponentsDir);
      this.srcFoldersToSync[customCompAbsPath] = './'
    }

    this.registerPluginPublicFoldersToSync();

    // if this.adminforth.config.customization.favicon is set, copy it to assets
    const customFav = this.adminforth.config.customization?.favicon;
    if (customFav) {

      const faviconPath = path.join(this.adminforth.config.customization?.customComponentsDir, customFav.replace('@@/', ''));
      const dest = path.join(this.spaTmpPath(), 'public', 'assets', customFav.replace('@@/', ''));
      // copyFileAtomic creates all folders in dest itself
      await copyFileAtomic(faviconPath, dest);
    }

    for (const [src, dest] of Object.entries(this.srcFoldersToSync)) {
      const to = path.join(this.spaTmpPath(), 'src', 'custom', dest);
      process.env.HEAVY_DEBUG && console.log(` srcFoldersToSync: copyTreeAtomic from ${src}, ${to}`);

      await copyTreeAtomic(src, to, {
        // exclude if node_modules comes after /custom/ in path
        filter: (src) => !src.includes(path.join('custom', 'node_modules')),
      });
    }

    for (const [src, dest] of Object.entries(this.publicFoldersToSync)) {
      const to = path.join(this.spaTmpPath(), 'public', dest);
      process.env.HEAVY_DEBUG && console.log(` publicFoldersToSync: copyTreeAtomic from ${src}, ${to}`);

      await copyTreeAtomic(src, to);
    }

    //collect all 'icon' fields from resources bulkActions
    this.adminforth.config.resources.forEach((resource) => {
      if (resource.options?.bulkActions) {
        resource.options.bulkActions.forEach((action) => {
          if (action.icon) {
            icons.push(action.icon);
          }
        });
      }

      if (resource.options?.actions) {
        resource.options.actions.forEach((action) => {
          if (action.icon) {
            icons.push(action.icon);
          }
        });
      }
    });

    const uniqueIcons = Array.from(new Set(icons));

    // icons are collectionName:iconName. Get list of all unique collection names:
    const collections = new Set(icons.map((icon) => icon.split(':')[0]));

    // package names @iconify-prerendered/vue-
    const iconPackageNames = Array.from(collections).map((collection) => `@iconify-prerendered/vue-${collection}`);

    // for each icon generate import statement
    const iconImports = uniqueIcons.map((icon) => {
      const [ collection, iconName ] = icon.split(':');
      const PascalIconName = 'Icon' + iconName.split('-').map((part, index) => {
        return part[0].toUpperCase() + part.slice(1);
      }).join('');
      return `import { ${PascalIconName} } from '@iconify-prerendered/vue-${collection}';`;
    }).join('\n');

    // for each custom component generate import statement
    const customResourceComponents = [];

    function checkInjections(filePathes) {
      filePathes.forEach(({ file }) => {
        if (!customResourceComponents.includes(file)) {
          if (file === undefined) {
            throw new Error('file is undefined');
          }
          customResourceComponents.push(file);
        }
      });
    }

    this.adminforth.config.resources.forEach((resource) => {
      resource.columns.forEach((column) => {
        if (column.components) {
          Object.values(column.components).forEach(({ file }: {file: string}) => {
            if (!customResourceComponents.includes(file)) {
              if (file === undefined) {
                throw new Error('file is undefined from field.components, field:' + JSON.stringify(column));
              }
              customResourceComponents.push(file);
            }
          });
        }
      });
      resource.options.actions.forEach((action) => {
        const cc = action.customComponent;
        if (!cc) return;
      
        const file = (typeof cc === 'string') ? cc : cc.file;
        if (!file) {
          throw new Error('customComponent.file is missing for action: ' + JSON.stringify({ id: action.id, name: action.name }));
        }
        if (!customResourceComponents.includes(file)) {
          customResourceComponents.push(file);
        }
      });
      
      (Object.values(resource.options?.pageInjections || {})).forEach((injection) => {
        Object.values(injection).forEach((filePathes: {file: string}[]) => {
          checkInjections(filePathes);
        });
      });
    });

    if (this.adminforth.config.customization?.globalInjections) {
      Object.values(this.adminforth.config.customization.globalInjections).forEach((injection) => {
        checkInjections(injection);
      });
    }

    if (this.adminforth.config.customization?.loginPageInjections) {
      Object.values(this.adminforth.config.customization.loginPageInjections).forEach((injection) => {
        checkInjections(injection);
      });
    }

    if (this.adminforth.config.auth.userMenuSettingsPages) {
      for (const settingPage of this.adminforth.config.auth.userMenuSettingsPages) {
        checkInjections([{ file: settingPage.component }]);
      }
    }

    if (this.adminforth.config.componentsToExplicitRegister) {
      this.adminforth.config.componentsToExplicitRegister.forEach((component) =>  {
        if (!customResourceComponents.includes(component)) {
          customResourceComponents.push(component.file);
        }
      }); 
    }

    customResourceComponents.forEach((filePath) => {
      const componentName = getComponentNameFromPath(filePath);
      this.allComponentNames[filePath] = componentName;
    });

    
    let customComponentsImports = '';
    for (const [targetPath, component] of Object.entries(this.allComponentNames)) {
      customComponentsImports += `import ${component} from '${targetPath}';\n`;
    }


    // Generate Vue.component statements for each icon
    const iconComponents = uniqueIcons.map((icon) => {
      const [ collection, iconName ] = icon.split(':');
      const PascalIconName = 'Icon' + iconName.split('-').map((part, index) => {
        return part[0].toUpperCase() + part.slice(1);
      }).join('');
      return `app.component('${PascalIconName}', ${PascalIconName});`;
    }).join('\n');

    // Generate Vue.component statements for each custom component
    let customComponentsComponents = '';
    for (const name of Object.values(this.allComponentNames)) {
      customComponentsComponents += `app.component('${name}', ${name});\n`;
    }

    let imports = iconImports + '\n';
    imports += customComponentsImports + '\n';

    if (this.adminforth.config.customization?.vueUsesFile) {
      imports += `import addCustomUses from '${this.adminforth.config.customization.vueUsesFile}';\n`;
    }

    // inject that code into spa_tmp/src/App.vue
    const appVuePath = path.join(this.spaTmpPath(), 'src', 'main.ts');
    let appVueContent = await fs.promises.readFile(appVuePath, 'utf-8');
    appVueContent = appVueContent.replace('/* IMPORTANT:ADMINFORTH IMPORTS */', imports);
    appVueContent = appVueContent.replace('/* IMPORTANT:ADMINFORTH COMPONENT REGISTRATIONS */', iconComponents + '\n' + customComponentsComponents + '\n');
    if (this.adminforth.config.customization?.vueUsesFile) {
      appVueContent = appVueContent.replace('/* IMPORTANT:ADMINFORTH CUSTOM USES */', 'addCustomUses(app);');
    }
    await fs.promises.writeFile(appVuePath, appVueContent);

    // generate tailwind extend styles
    const stylesGenerator = new StylesGenerator(this.adminforth.config.customization?.styles); 
    const  stylesText = JSON.stringify(stylesGenerator.mergeStyles(), null, 2).slice(1, -1);
    const safelistText = JSON.stringify(this.collectTailwindSafelist(), null, 2).slice(1, -1);
    let tailwindConfigPath = path.join(this.spaTmpPath(), 'tailwind.config.js');
    let tailwindConfigContent = await fs.promises.readFile(tailwindConfigPath, 'utf-8');
    tailwindConfigContent = tailwindConfigContent.replace('/* IMPORTANT:ADMINFORTH TAILWIND STYLES */', stylesText);
    tailwindConfigContent = tailwindConfigContent.replace('/* IMPORTANT:ADMINFORTH TAILWIND SAFELIST */', safelistText);
    await fs.promises.writeFile(tailwindConfigPath, tailwindConfigContent);
    

    const routerVuePath = path.join(this.spaTmpPath(), 'src', 'router', 'index.ts');

    let routerVueContent = await fs.promises.readFile(routerVuePath, 'utf-8');
    routerVueContent = routerVueContent.replace('/* IMPORTANT:ADMINFORTH ROUTES IMPORTS */', routerComponents);

    const loginPage = this.adminforth.config.customization.loginPage;
    if (loginPage) {
      // path, name and beforeEnter of the built-in route are kept, so all redirects to { name: 'login' } still work
      routerVueContent = routerVueContent.replace(LOGIN_PAGE_ROUTE_PART_RE, `component: () => import('${loginPage.file}'),
      meta: ${JSON.stringify({ title: 'Login', ...loginPage.meta })},`);
    }

    // inject title to index.html
    const indexHtmlPath = path.join(this.spaTmpPath(), 'index.html');
    let indexHtmlContent = await fs.promises.readFile(indexHtmlPath, 'utf-8');
    indexHtmlContent = indexHtmlContent.replace('/* IMPORTANT:ADMINFORTH TITLE */', `${this.adminforth.config.customization?.title || this.adminforth.config.customization?.brandName || 'AdminForth'}`);
    
    // we dont't need to add baseUrl in front of assets here, because it is already added by Vite/Vue
    indexHtmlContent = indexHtmlContent.replace(
      '/* IMPORTANT:ADMINFORTH FAVICON */',
      this.adminforth.config.customization.favicon?.replace('@@/', `/assets/`)
          ||
       `/assets/favicon.png`
    );

    // inject heads to index.html
    const headItems = this.adminforth.config.customization?.customHeadItems;
    if(headItems){
      const renderedHead = headItems.map(({ tagName, attributes, innerCode }) => {
      const attrs = Object.entries(attributes)
        .map(([key, value]) => `${key}="${value}"`)
        .join(' ');
      const isVoid = ['base', 'link', 'meta'].includes(tagName);
      return isVoid
        ? ``
        : ` ${innerCode} `;
      }).join('\n    ');

      indexHtmlContent = indexHtmlContent.replace("    ", `${renderedHead}` );
    }
    await fs.promises.writeFile(indexHtmlPath, indexHtmlContent);

    /* generate custom routes */
    let homepageMenuItem: AdminForthConfigMenuItem = findHomePage(menuWithContributions);
    if (!homepageMenuItem) {
      // find first item with path or resourceId. If we face a menu item with children earlier then path/resourceId, we should search in children
      homepageMenuItem = await findFirstMenuItemWithResource(menuWithContributions);
    }
    if (!homepageMenuItem) {
      throw new Error('No homepage found in menu and no menu item with path/resourceId found. AdminForth can not generate routes');
    }

    let homePagePath = homepageMenuItem.path || `/resource/${homepageMenuItem.resourceId}`;
    if (!homePagePath) {
      homePagePath=menuWithContributions.filter((mi)=>mi.path)[0]?.path || `/resource/${menuWithContributions.filter((mi)=>mi.children)[0]?.resourceId}` ;
    }

    routes += `{
      path: '/',
      name: 'home',
      //redirect to login 
      redirect: '${homePagePath}'
    },\n`;
    routerVueContent = routerVueContent.replace('/* IMPORTANT:ADMINFORTH ROUTES */', routes);
    await fs.promises.writeFile(routerVuePath, routerVueContent);
    

    /* hash checking */
    let spaLockHash = '';
    const isTherePnpmLockInCustomFolder = await this.doesUserHasPnpmLockFile(this.adminforth.config.customization.customComponentsDir);
    if (isTherePnpmLockInCustomFolder) {
      const spaPnpmLockPath = path.join(this.spaTmpPath(), 'pnpm-lock.yaml');
      const spaPnpmLock = yaml.parse(await fs.promises.readFile(spaPnpmLockPath, 'utf-8'));
      spaLockHash = hashify(spaPnpmLock);
    } else {
      const spaNpmLockPath = path.join(this.spaTmpPath(), 'package-lock.json');
      const spaNpmLock = JSON.parse(await fs.promises.readFile(spaNpmLockPath, 'utf-8'));
      spaLockHash = hashify(spaNpmLock);
    }
    /* customPackageLock */
    let usersLockHash: string = '';
    let usersPackages: string[] = [];


    if (this.adminforth.config.customization?.customComponentsDir) {
      [usersLockHash, usersPackages] = await this.packagesFromPnpm(this.adminforth.config.customization.customComponentsDir);
    }

    const pluginPackages: {
        pluginName: string,
        lockHash: string,
        packages: string[],
    }[] = [];

    // for every installed plugin generate packages
    for (const plugin of this.adminforth.activatedPlugins) {
      process.env.HEAVY_DEBUG && console.log(` Checking packages for plugin, ${plugin.constructor.name}, ${plugin.customFolderPath}`);
      const [lockHash, packages] = await this.packagesFromPnpm(plugin.customFolderPath);
      if (packages.length) {
        pluginPackages.push({
          pluginName: plugin.constructor.name,
          lockHash,
          packages,
        });
      }
    }
    // form string "pluginName:lockHash::pLugin2Name:lockHash"
    const pluginsLockHash = pluginPackages.map(({ pluginName, lockHash }) => `${pluginName}>${lockHash}`).join('::');

    const iconPackagesNamesHash = hashify(iconPackageNames);

    await this.syncAllowBuildsToSpaTmp();

    const fullHash = `spa>${spaLockHash}::icons>${iconPackagesNamesHash}::user/custom>${usersLockHash}::${pluginsLockHash}`;

    const allPacks = [
      ...iconPackageNames,
      ...usersPackages,
      ...pluginPackages.reduce((acc, { packages }) => {
        acc.push(...packages);
        return acc;
      }, []),
    ];
    const EXCLUDE_PACKS = ['@iconify-prerendered/vue-flowbite'];

    const allPacksFiltered = allPacks.filter((pack) => {
      return !EXCLUDE_PACKS.some((exclude) => pack.startsWith(exclude));
    })
    const allPacksUnique = Array.from(new Set(allPacksFiltered));

    return { hash: fullHash, icons, packages: allPacksUnique };
  }

  /**
   * node_modules of spa_tmp are needed only to build SPA, extract i18n messages or run dev server, so bundleNow
   * calls it only then: a runtime whose spa_tmp did not survive (e.g. it was a docker cache mount at build time)
   * still starts without installing anything when serveDir already has the build of the same sources.
   */
  async installSpaDependencies({ hash, icons, packages }: SpaDependencies) {
    const hashPath = path.join(this.spaTmpPath(), 'node_modules', '.adminforth_hash');

    try {
      const existingHash = await fs.promises.readFile(hashPath, 'utf-8');
      await this.checkIconNames(icons);
      if (existingHash === hash) {
        process.env.HEAVY_DEBUG && console.log(`Hashes match, skipping pnpm install, from file: ${existingHash}, actual: ${hash}`);
        return;
      } else {
        process.env.HEAVY_DEBUG && console.log(` Hashes do not match: from file: ${existingHash} actual: ${hash}, proceeding with pnpm install`);
      }
    } catch (e) {
      // ignore
      process.env.HEAVY_DEBUG && console.log(`Hash file does not exist, proceeding with pnpm install, ${e}`);
    }

    // install --frozen-lockfile works for npm and pnpm
    await this.runPackageManagerShell({command: 'install --frozen-lockfile', cwd: this.spaTmpPath(), envOverrides: {
      NODE_ENV: 'development' // otherwise it will not install devDependencies which we still need, e.g for extract
    }});

    if (packages.length) {
      const packageManagerInstallCommand = `install ${packages.join(' ')}`;
      await this.runPackageManagerShell({
        command: packageManagerInstallCommand, cwd: this.spaTmpPath(),
        envOverrides: {
          NODE_ENV: 'development' // otherwise it will not install devDependencies which we still need, e.g for extract
        }
      });
    }
    await this.checkIconNames(icons);
    await fs.promises.writeFile(hashPath, hash);
  }

  async watchForReprepare({}) {
    const spaPath = this.getSpaDir();
    // get list of all subdirectories in spa recursively (for SPA development)
    process.env.HEAVY_DEBUG && console.log(` Watch for: ${spaPath}`);

    // one recursive watcher for the whole tree, instead of one fs.watch handle per file:
    // files created after startup are picked up too, which the per-file registration never did
    const watcher = chokidar.watch(spaPath, watcherOptions());

    const onAddOrChange = (file: string) => {
      process.env.HEAVY_DEBUG && console.log(` File ${file} changed (SPA), preparing sources...`);
      this.onWatchedFileChange(file, async () => {
        await this.updatePartials({ filesUpdated: [path.relative(spaPath, file)] });
      });
    };

    watcher.on('add', onAddOrChange);
    watcher.on('change', onAddOrChange);
    watcher.on('unlink', (file: string) => {
      // keep spa_tmp in sync with removals, it used to only ever grow
      this.onWatchedFileRemove(path.join(this.spaTmpPath(), path.relative(spaPath, file)));
    });
    watcher.on('error', (e: any) => afLogger.warn(`Hot reload watcher error for ${spaPath}: ${e?.message}`));
    this.allWatchers.push(watcher);
  }

  // one promise chain per watched path, so two events for the same file can never have their
  // copies interleaved (which is what makes fs copies race each other), and a burst of saves
  // queues up instead of piling on top of each other
  private fileChangeChains: Map = new Map();

  /**
   * Runs `task` for a changed watched file, serialized per path and fully guarded.
   *
   * Both guards matter. A watcher event only says that something happened, not that the file is still
   * there - a rename, a git checkout or an editor writing through a temp file is enough for it to be
   * gone by the time we act on it. And any throw here would land in a listener whose promise nobody
   * awaits, i.e. an unhandled rejection, which node kills the process for.
   */
  private onWatchedFileChange(file: string, task: () => Promise): void {
    this.queuePerPath(file, async () => {
      const stat = await fs.promises.stat(file).catch(() => null);
      if (!stat?.isFile()) {
        process.env.HEAVY_DEBUG && console.log(` ${file} is gone or is not a file, skipping hot reload copy`);
        return;
      }
      await task();
    });
  }

  // removes a file from spa_tmp after it was deleted from the sources, on the same queue as copies
  // so a delete can never overtake a copy of the same path
  private onWatchedFileRemove(destPath: string): void {
    this.queuePerPath(destPath, async () => {
      process.env.HEAVY_DEBUG && console.log(` Removing ${destPath} from spa_tmp, source is gone`);
      await fs.promises.rm(destPath, { force: true });
    });
  }

  private queuePerPath(key: string, task: () => Promise): void {
    const previous = this.fileChangeChains.get(key) ?? Promise.resolve();

    const current = previous.then(task).catch((e) => {
      afLogger.warn(`Hot reload failed to sync ${key}: ${e.message}`);
    });

    this.fileChangeChains.set(key, current);
    current.then(() => {
      // keep the map from growing for the lifetime of the dev server
      if (this.fileChangeChains.get(key) === current) {
        this.fileChangeChains.delete(key);
      }
    });
  }

  async watchCustomComponentsForCopy({ customComponentsDir, destination, targetRoot = path.join('src', 'custom') }: {
    customComponentsDir: string,
    destination: string,
    targetRoot?: string,
  }) {
    if (!customComponentsDir) {
      return;
    }
    // check if folder exists
    try {
      await fs.promises.access(customComponentsDir, fs.constants.F_OK);
    } catch (e) {
      process.env.HEAVY_DEBUG && console.log(`Custom components dir ${customComponentsDir} does not exist, skipping watching`);
      return;
    }

    process.env.HEAVY_DEBUG && console.log(` Watch for: ${customComponentsDir}`);

    const watcher = chokidar.watch(customComponentsDir, watcherOptions());

    const destinationOf = (fileOrDir: string) =>
      path.join(this.spaTmpPath(), targetRoot, destination, path.relative(customComponentsDir, fileOrDir));

    const onAddOrChange = (fileOrDir: string) => {
      process.env.HEAVY_DEBUG && console.log(` fileOrDir ${fileOrDir} changed`);
      process.env.HEAVY_DEBUG && console.log(` customComponentsDir ${customComponentsDir}`);
      process.env.HEAVY_DEBUG && console.log(` destination ${destination}`);
      this.onWatchedFileChange(fileOrDir, async () => {
        const destPath = destinationOf(fileOrDir);
        process.env.HEAVY_DEBUG && console.log(` Copying file ${fileOrDir} to ${destPath}`);
        await copyFileAtomic(fileOrDir, destPath);
      });
    };

    watcher.on('add', onAddOrChange);
    watcher.on('change', onAddOrChange);
    watcher.on('unlink', (file: string) => this.onWatchedFileRemove(destinationOf(file)));
    watcher.on('error', (e: any) => afLogger.warn(`Hot reload watcher error for ${customComponentsDir}: ${e?.message}`));

    this.allWatchers.push(watcher);
  }

  async tryReadFile(filePath: string) {
    try {
      const content = await fs.promises.readFile(filePath, 'utf-8');
      return content;
    } catch (e) {
      // file does not exist
      process.env.HEAVY_DEBUG && console.log(`File ${filePath} does not exist, returning null`);
      return null;
    }
  }

  async computeSourcesHash(folderPath: string = this.spaTmpPath(), allFiles: string[] = []) {
    const files = await fs.promises.readdir(folderPath, { withFileTypes: true });
    const hashes = await Promise.all(
      files.map(async (file) => {
        const filePath = path.join(folderPath, file.name);

        //  Skip big files or files which might be dynamic
        if (file.name === 'node_modules' || file.name === 'dist' ||
            file.name === 'i18n-messages.json' || file.name === 'i18n-empty.json' ||
            file.name === 'hashes.json' || file.name === 'package.json' ||
            file.name === 'pnpm-lock.yaml' || file.name === 'package-lock.json') {
          return '';
        }

        // a copyFileAtomic temp file survives only if the process was killed mid-copy (SIGKILL);
        // drop it so it neither poisons the build cache hash nor accumulates in spa_tmp.
        // If it belongs to a process that is still running, it is not a leftover at all - somebody
        // else is copying into our spa_tmp right now, which is the situation behind AdminForth/1519.
        if (isAtomicCopyTempName(file.name)) {
          const ownerPid = atomicCopyTempPid(file.name);
          if (ownerPid && ownerPid !== process.pid && isProcessAlive(ownerPid)) {
            afLogger.warn(
              `Caught another live process (pid ${ownerPid}) copying into ${this.spaTmpPath()} right now ` +
              `(${filePath}). Two processes sharing one spa_tmp overwrite each other's files - ` +
              `please report this on AdminForth/1519.`
            );
          } else {
            await fs.promises.rm(filePath, { force: true }).catch(() => {});
          }
          return '';
        }

        allFiles.push(filePath);
        
        if (file.isDirectory()) {
          return this.computeSourcesHash(filePath, allFiles);
        } else {
          const content = await fs.promises.readFile(filePath, 'utf-8');
          return md5hash(content);
        }
      })
    );
    return md5hash(hashes.join(''));
  }

  // Compute a map of file relative paths -> md5 hash of file contents and return it.
  // Skips directories/files that are ignored by computeSourcesHash (node_modules, dist, i18n files).
  async computeSourcesHashMap(folderPath: string = this.spaTmpPath(), rootFolder: string = this.spaTmpPath(), map: { [key: string]: string } = {}): Promise {
    const files = await fs.promises.readdir(folderPath, { withFileTypes: true });

    await Promise.all(
      files.map(async (file) => {
        const filePath = path.join(folderPath, file.name);

        //  Skip big/dynamic folders or files
        if (file.name === 'node_modules' || file.name === 'dist' ||
            file.name === 'i18n-messages.json' || file.name === 'i18n-empty.json' ||
            file.name === 'hashes.json' || file.name === 'package.json' ||
            file.name === 'pnpm-lock.yaml' || file.name === 'package-lock.json' ||
            isAtomicCopyTempName(file.name)) {
          return;
        }

        if (file.isDirectory()) {
          return await this.computeSourcesHashMap(filePath, rootFolder, map);
        } else {
          try {
            const content = await fs.promises.readFile(filePath, 'utf-8');
            const hash = md5hash(content);
            // store relative path using forward slashes for portability
            const rel = path.relative(rootFolder, filePath).split(path.sep).join('/');
            map[rel] = hash;
          } catch (e) {
            // If a file can't be read (binary or permission), log and continue
            process.env.HEAVY_DEBUG && console.log(`File ${filePath} read error: ${e}`);
            return;
          }
        }
      })
    );

    return map;
  }

  // Convenience helper: compute per-file hashes and save them into hashes.json in the spa tmp dir
  async saveSourcesHashesToFile(outputFileName: string = 'hashes.json', hashMap: { [key: string]: string } = {}): Promise {
    const root = this.spaTmpPath();
    const outPath = path.join(root, outputFileName);
    await fs.promises.writeFile(outPath, JSON.stringify(hashMap, null, 2), 'utf-8');
    process.env.HEAVY_DEBUG && console.log(` Saved sources hashes to ${outPath}`);
    return outPath;
  }

  async bundleNow({ hotReload = false, buildTime = true }: { hotReload: boolean, buildTime?: boolean }) {
    console.log(`${this.adminforth.formatAdminForth()} Bundling ${hotReload ? 'and listening for changes ( Hotreload)' : ' (no hot reload)'}`);
    this.adminforth.runningHotReload = hotReload;

    const spaDependencies = await this.prepareSources();

    if (hotReload) {
      await Promise.all([
        this.watchForReprepare({}),
        ...Object.entries(this.srcFoldersToSync).map(async ([src, dest]) => {

          await this.watchCustomComponentsForCopy({ 
            customComponentsDir: src,
            destination: dest,
          });
        }),
        ...Object.entries(this.publicFoldersToSync).map(async ([src, dest]) => {
          await this.watchCustomComponentsForCopy({
            customComponentsDir: src,
            destination: dest,
            targetRoot: 'public',
          });
        }),
      ]);
    }

    const cwd = this.spaTmpPath();
    const serveDir = this.getServeDir();

    const allFiles = [];
    // computeSourcesHash skips lock files, so dependencies are added to it: a changed lock must rebuild SPA too
    const sourcesHash = `${await this.computeSourcesHash(this.spaTmpPath(), allFiles)}::${spaDependencies.hash}`;
    process.env.HEAVY_DEBUG && console.log(` allFiles:, ${JSON.stringify(
      allFiles.sort((a,b) => a.localeCompare(b)), null, 1)}`);
    
    const buildHash = await this.tryReadFile(path.join(serveDir, '.adminforth_build_hash'));
    const messagesHash = await this.tryReadFile(path.join(serveDir, '.adminforth_messages_hash'));

    const skipBuild = buildHash === sourcesHash;
    const skipExtract = messagesHash === sourcesHash;

    process.env.HEAVY_DEBUG && console.log(` SPA messages hash: ${messagesHash}`);

    if (hotReload || !skipBuild || !skipExtract) {
      await this.installSpaDependencies(spaDependencies);
    }

    if (!skipBuild) {
      await this.assertServeDirCanBeCleared(serveDir);
      // remove serveDir if exists
      try {
        await fs.promises.rm(serveDir, { recursive: true });
      } catch (e) {
        // ignore
      }
      await fs.promises.mkdir(serveDir, { recursive: true });
      await fs.promises.writeFile(path.join(serveDir, SERVE_DIR_MARKER), '');
    }

    if (!skipExtract) {
      await this.runPackageManagerShell({command: 'run i18n:extract', cwd});
      
      // create serveDir if not exists
      await fs.promises.mkdir(serveDir, { recursive: true });

      // copy i18n messages to serve dir
      await copyFileAtomic(path.join(cwd, 'i18n-messages.json'), path.join(serveDir, 'i18n-messages.json'));

      // save hash
      await fs.promises.writeFile(path.join(serveDir, '.adminforth_messages_hash'), sourcesHash);
    } else {
      console.log(`AdminForth i18n message extraction skipped  build already performed for the current sources.`);
    }

    if (!hotReload) {
      if (!skipBuild) {     
        console.log(` Build cache miss or outdated, building SPA...`);
        let oldHashForFiles = null;
        try {
          oldHashForFiles = await fs.promises.readFile(path.join(this.spaTmpPath(), 'hashes.json'), 'utf-8');
        } catch (e) {
          // ignore if file doesn't exist, it is only for debugging
          if (!buildTime) {
            console.log(`Build cache not found, building now (downtime) please consider running npx adminforth bundle at build time to avoid downtimes at runtime`);
          }
        }
        const root = this.spaTmpPath();
        const hashMap = await this.computeSourcesHashMap(root, root, {});
        if (oldHashForFiles) {
          const parsedOldHashForFiles = JSON.parse(oldHashForFiles);
          const logsToDisplay = [];
          logsToDisplay.push(`Build cache exists but is outdated:`);
          for(const [file, hash] of Object.entries(hashMap)) {
            if (!parsedOldHashForFiles[file]) {
              logsToDisplay.push(`   - file ${file} - does not exist in cache but exists in runtime`);
            } else if (parsedOldHashForFiles[file] !== hash) {
              logsToDisplay.push(`   - file ${file} - content in cache is different then in runtime`);
            }
          }
          /**
           * Currently we can't detect, if file was removed, 
           * because we can only add files to the tpm folder but not remove them, 
           * so if file existed before and now doesn't exist, we will not detect it
           */

          // for(const [file, hash] of Object.entries(parsedOldHashForFiles)) {
          //   console.log(`checking file ${file} in old hash: ${hash}`);
          //   console.log(`checking file ${file} in new hash: ${hashMap[file]}`);
          //   if (!hashMap[file]) {
          //     logsToDisplay.push(`   - file ${file} - exists in cache but does not exist in runtime`);
          //   }
          // }

          logsToDisplay.push(`If you are running in production now, then the cache loss is a downtime issue.`);
          logsToDisplay.push(`If you have npx adminforth bundle in build time, then this issue might be caused by conditional instantiation of plugins:`)
          logsToDisplay.push(`Please avoid constructions like (process.env.SOME_KEY ? new Plugin(...) ) because if you will miss SOME_KEY in build time build cache and functionality fails.`);
          if (logsToDisplay.length > 4) {
            for(const log of logsToDisplay) {
              console.log(log);
            }
          }
        }

        // TODO probably add option to build with tsh check (plain 'build')
        await this.runPackageManagerShell({command: 'run build-only', cwd});
        
        // coy dist to serveDir
        await copyTreeAtomic(path.join(cwd, 'dist'), serveDir);

        // save hash
        await fs.promises.writeFile(path.join(serveDir, '.adminforth_build_hash'), sourcesHash);
        // save sources hashes to file for later debugging if needed
        await this.saveSourcesHashesToFile('hashes.json', hashMap);
      } else {
        console.log(`Skipping AdminForth SPA bundling - already completed for the current sources.`);
      }
    } else {

      const command = 'run dev';
      const usersPackageManager = await this.doesUserHasPnpmLockFile(this.adminforth.config.customization.customComponentsDir) ? 'pnpm' : 'npm';
      console.log(` spawn: ${usersPackageManager} ${command}...`);
      if (process.env.VITE_ADMINFORTH_PUBLIC_PATH) {
        console.log(` Your VITE_ADMINFORTH_PUBLIC_PATH: ${process.env.VITE_ADMINFORTH_PUBLIC_PATH} has no effect`);
      }
      const env = {
        VITE_ADMINFORTH_PUBLIC_PATH: this.adminforth.config.baseUrl,
        FORCE_COLOR: '1',
        ...process.env,
      }; 
      
      const nodeBinary = process.execPath;
      const packageManagerPath = path.join(path.dirname(nodeBinary), usersPackageManager);
      
      let devServer;
      if (process.platform === 'win32') {
        devServer = spawn(usersPackageManager, command.split(' '), { cwd, env, shell: true });
      } else {
        devServer = spawn(`${nodeBinary}`, [`${packageManagerPath}`, ...command.split(' ')], { cwd, env });
      }
      devServer.stdout.on('data', (data) => {
        if (data.includes('')) {
          // TODO: maybe better use our string "App port: 5174. HMR port: 5274", it is more reliable because vue might change their output

          // parse port from message "    Local:   http://localhost:xyz/"
          const s = stripAnsiCodes(data.toString());
          
          const portMatch = s.match(/.+?http:\/\/.+?:(\d+).+?/m);
          if (portMatch) {
            this.devServerPort = parseInt(portMatch[1]);
          }
        } else {
          process.env.HEAVY_DEBUG && console.log(`[AdminForth SPA]:`);
          process.env.HEAVY_DEBUG && console.log(data.toString());
        }
      });
      devServer.stderr.on('data', (data) => {
        const text = data.toString();
        // pnpm/npm echo the script command they are about to run (e.g. "$ vite") to stderr,
        // and emit empty lines. These are not errors, so don't log them as such.
        const meaningful = stripAnsiCodes(text)
          .split('\n')
          .filter((line: string) => line.trim() && !line.trim().startsWith('$ '));
        if (meaningful.length === 0) {
          process.env.HEAVY_DEBUG && console.log(`[AdminForth SPA]:`, text);
          return;
        }
        afLogger.error(`[AdminForth SPA ERROR]:`);
        afLogger.error(text);
      });

    }
  }
}

export default CodeInjector;

Web Proxy Viewer  |  New URL  |  Original Page