🏑 index : quine-global-labs/mirror-cockpit-project-cockpit.git

#!/usr/bin/env node

import child_process from 'node:child_process';
import fs from 'node:fs';
import { createRequire } from 'node:module';
import os from 'node:os';
import path from 'node:path';
import process from 'node:process';

import { getFiles, getTestFiles, all_subdirs } from './files.js';

const srcdir = import.meta.dirname;
const production = process.env.NODE_ENV === 'production';
const useWasm = os.arch() !== 'x64';

// ensure node_modules is present and up to date
child_process.spawnSync('tools/node-modules', ['make_package_lock_json'], { stdio: 'inherit', cwd: srcdir });

// List of directories to use when resolving import statements
const nodePaths = [`${srcdir}/pkg/lib`];

const parser = (await import('argparse')).default.ArgumentParser();
parser.add_argument('-o', '--outdir', { help: "base output directory", default: ".", metavar: "DIR" });
parser.add_argument('-r', '--rsync', { help: "rsync bundles to ssh target after build", metavar: "HOST" });
parser.add_argument('-w', '--watch', { action: 'store_true', help: "Enable watch mode" });
parser.add_argument('onlydir', { nargs: '?', help: "The pkg/<DIRECTORY> to build (eg. base1, shell, ...)", metavar: "DIRECTORY" });
const args = parser.parse_args();

// context options for distributed pages in dist/
const pkgOptions = {
    ...!production ? { sourcemap: "linked" } : {},
    bundle: true,
    external: ['*.woff', '*.woff2', '*.jpg', '*.svg', '../../assets*'], // Allow external font files which live in ../../static/fonts
    legalComments: 'external', // Move all legal comments to a .LEGAL.txt file
    loader: {
        ".js": "jsx",
        ".py": "text",
        ".sh": "text",
    },
    metafile: true,
    minify: production,
    nodePaths,
    outbase: `${srcdir}/pkg`,
    outdir: `${args.outdir}/dist`,
    target: ['es2021'],
};

// context options for qunit tests in qunit/
const qunitOptions = {
    sourcemap: "linked",
    bundle: true,
    minify: false,
    nodePaths,
    outbase: `${srcdir}/pkg`,
    outdir: `${args.outdir}/qunit`,
    loader: {
        ".sh": "text",
    },
};

if (args.onlydir?.includes('/'))
    parser.error("Directory must not contain '/'");

if (useWasm && args.watch)
    parser.error("watch mode is not supported with esbuild-wasm");

if (args.onlydir)
    process.env.ONLYDIR = args.onlydir;
if (args.rsync)
    process.env.RSYNC = args.rsync;

// keep cockpit.js as global external, except on base1 (as that's what exports it), and kdump (for testing that bundling works)
const cockpitJSResolvePlugin = {
    name: 'cockpit-js-resolve',
    setup(build) {
        build.onResolve({ filter: /^cockpit$/ }, args => {
            if (args.resolveDir.endsWith('/base1') || args.resolveDir.endsWith('/kdump'))
                return null;
            return { path: args.path, namespace: 'external-global' };
        });

        build.onLoad({ filter: /.*/, namespace: 'external-global' },
                     args => ({ contents: `module.exports = ${args.path}` }));
    },
};

// similar to fs.watch(), but recursively watches all subdirectories
function watch_dirs(dir, on_change) {
    const callback = (ev, dir, fname) => {
        // only listen for "change" events, as renames are noisy
        if (ev !== "change")
            return;
        on_change(path.join(dir, fname));
    };

    fs.watch(dir, {}, (ev, path) => callback(ev, dir, path));

    // watch all subdirectories in dir
    const d = fs.opendirSync(dir);
    let dirent;
    while ((dirent = d.readSync()) !== null) {
        if (dirent.isDirectory())
            watch_dirs(path.join(dir, dirent.name), on_change);
    }
    d.closeSync();
}

async function build() {
    // dynamic imports which need node_modules
    const esbuild = await (async () => {
        try {
            // Try node_modules first for installs with devDependencies
            return (await import(useWasm ? 'esbuild-wasm' : 'esbuild')).default;
        } catch (e) {
            if (e.code !== 'ERR_MODULE_NOT_FOUND')
                throw e;

            // Fall back to distro package (e.g. Debian's /usr/lib/*/nodejs/esbuild)
            // Use createRequire to leverage Node's module resolution which searches system paths
            // Use require.resolve to find esbuild in system paths, then import it
            const require = createRequire(import.meta.url);
            return (await import(require.resolve('esbuild'))).default;
        }
    })();

    // Check if qunit is available
    const qunitAvailable = await (async () => {
        try {
            await import('qunit');
            return true;
        } catch (e) {
            if (e.code === 'ERR_MODULE_NOT_FOUND') {
                console.log('qunit not found, skipping test builds');
                return false;
            }
            throw e;
        }
    })();

    const sassPlugin = (await import('esbuild-sass-plugin')).sassPlugin;

    const cleanPlugin = (await import('./pkg/lib/esbuild-cleanup-plugin.js')).cleanPlugin;
    const cockpitCompressPlugin = (await import('./pkg/lib/esbuild-compress-plugin.js')).cockpitCompressPlugin;
    const cockpitPoEsbuildPlugin = (await import('./pkg/lib/cockpit-po-plugin.js')).cockpitPoEsbuildPlugin;
    const cockpitRsyncEsbuildPlugin = (await import('./pkg/lib/cockpit-rsync-plugin.js')).cockpitRsyncEsbuildPlugin;
    const cockpitTestHtmlPlugin = (await import('./pkg/lib/esbuild-test-html-plugin.js')).cockpitTestHtmlPlugin;
    const cockpitRemoveRHIconsPlugin = (await import('./pkg/lib/cockpit-remove-rhicons-plugin.js')).cockpitRemoveRHIconsPlugin;

    const { entryPoints, assetFiles, redhat_fonts } = getFiles(args.onlydir);
    const tests = getTestFiles();
    const testEntryPoints = tests.map(test => `${srcdir}/pkg/${test}`);

    const pkgFirstPlugins = [
        cleanPlugin({ subdir: args.onlydir }),
    ];

    const pkgPlugins = [
        cockpitJSResolvePlugin,
        sassPlugin({
            loadPaths: [...nodePaths, `${srcdir}/node_modules`],
            filter: /\.scss/,
            quietDeps: true,
            silenceDeprecations: [
                "if-function" // https://github.com/patternfly/patternfly/issues/8077
            ]
        })
    ];

    const getTime = () => new Date().toTimeString().split(' ')[0];

    const pkgLastPlugins = [
        cockpitPoEsbuildPlugin({
            podir: path.join(srcdir, 'po'),
            subdirs: args.onlydir ? [args.onlydir] : all_subdirs,
            // login page does not have cockpit.js, but reads window.cockpit_po
            wrapper: subdir => subdir === "static" ? "window.cockpit_po = PO_DATA;" : undefined,
        }),

        // copy the static asset files determined in ./files.js
        {
            name: 'copy-assets',
            setup(build) {
                build.onEnd(() => {
                    for (const file of [...assetFiles, ...redhat_fonts]) {
                        const destDir = path.join(pkgOptions.outdir, file.to);
                        fs.mkdirSync(destDir, { recursive: true });
                        fs.copyFileSync(file.from, path.join(destDir, path.basename(file.from)));
                    }
                });
            }
        },

        // HACK: filter out RH icons until they become default
        // Reduces PF icon byte size by >50% per icon
        cockpitRemoveRHIconsPlugin(),

        // cockpit-ws cannot currently serve compressed login page
        ...production ? [cockpitCompressPlugin({ subdir: args.onlydir, exclude: /\/static/ })] : [],

        {
            name: 'notify-end',
            setup(build) {
                build.onEnd(() => console.log(`${getTime()}: Build finished`));
            }
        },

        ...(args.rsync || process.env.RSYNC)
            ? [cockpitRsyncEsbuildPlugin({ source: `${pkgOptions.outdir}/${args.onlydir || ''}` })]
            : [],
    ];

    if (useWasm) {
        // build each entry point individually, as otherwise it runs out of memory
        // See https://github.com/evanw/esbuild/issues/3006
        const numEntries = entryPoints.length;
        for (const [index, entryPoint] of entryPoints.entries()) {
            console.log("building", entryPoint);
            const context = await esbuild.context({
                ...pkgOptions,
                entryPoints: [entryPoint],
                plugins: [
                    ...(index === 0 ? pkgFirstPlugins : []),
                    ...pkgPlugins,
                    ...(index === numEntries - 1 ? pkgLastPlugins : []),
                ],
            });

            await context.rebuild();
            context.dispose();
        }

        // build all tests in one go, they are small enough
        if (qunitAvailable) {
            console.log("building qunit tests");
            const context = await esbuild.context({
                ...qunitOptions,
                entryPoints: testEntryPoints,
                plugins: [
                    cockpitTestHtmlPlugin({ testFiles: tests }),
                ],
            });

            await context.rebuild();
            context.dispose();
        }
    } else {
        // with native esbuild, build everything in one go, that's fastest
        const pkgContext = await esbuild.context({
            ...pkgOptions,
            entryPoints,
            plugins: [...pkgFirstPlugins, ...pkgPlugins, ...pkgLastPlugins],
        });

        const qunitContext = qunitAvailable
            ? await esbuild.context({
                ...qunitOptions,
                entryPoints: testEntryPoints,
                plugins: [
                    cockpitTestHtmlPlugin({ testFiles: tests }),
                ],
            })
            : null;

        try {
            const results = await Promise.all([pkgContext.rebuild(), qunitContext?.rebuild()]);
            // skip metafile and runtime module calculation in watch and onlydir modes
            if (!args.watch && !args.onlydir) {
                fs.writeFileSync(`${args.outdir}/metafile.json`, JSON.stringify(results[0].metafile));

                // Extract bundled npm packages for dependency tracking
                const bundledPackages = new Set();
                for (const inputPath of Object.keys(results[0].metafile.inputs)) {
                    const rel = path.relative(srcdir, inputPath);

                    // Match paths like node_modules/package-name/ or node_modules/@scope/package-name/
                    const match = rel.match(/^node_modules\/(@[^/]+\/[^/]+|[^/]+)\//);
                    if (match)
                        bundledPackages.add(match[1]);
                }

                // Look up versions from package-lock.json and output simple format
                const packageLock = JSON.parse(fs.readFileSync(`${srcdir}/package-lock.json`, 'utf8'));
                const deps = [];
                for (const pkgName of Array.from(bundledPackages).sort()) {
                    const lockKey = `node_modules/${pkgName}`;
                    const pkgInfo = packageLock.packages?.[lockKey];
                    if (pkgInfo?.version)
                        deps.push(`${pkgName} ${pkgInfo.version}`);
                    else
                        console.error(`Warning: Could not find version for ${pkgName}`);
                }
                fs.writeFileSync(`${args.outdir}/runtime-npm-modules.txt`, deps.join('\n') + '\n');
            }
        } catch (e) {
            const msg = String(e);
            if (!msg.includes("Build failed"))
                console.error("build.js:", msg);
            if (!args.watch)
                process.exit(1);
            // ignore errors in watch mode
        }

        if (args.watch) {
            const on_change = async path => {
                console.log("change detected:", path);
                await Promise.all([pkgContext.cancel(), qunitContext?.cancel()]);
                try {
                    await Promise.all([pkgContext.rebuild(), qunitContext?.rebuild()]);
                } catch (e) {} // ignore in watch mode
            };

            watch_dirs(`${srcdir}/pkg`, on_change);
            // wait forever until Control-C
            await new Promise(() => {});
        }

        pkgContext.dispose();
        qunitContext?.dispose();
    }
}

build();