GitHub Viewer
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;