/** * @file ESBuild script for building the authentik web UI. */ import "@goauthentik/core/environment/load/node"; import * as fs from "node:fs/promises"; import * as path from "node:path"; /** * @file ESBuild script for building the authentik web UI. * * @import { BuildOptions, Plugin } from "esbuild"; */ import { mdxPlugin } from "#bundler/mdx-plugin/node"; import { styleLoaderPlugin } from "#bundler/style-loader-plugin/node"; import { createBundleDefinitions } from "#bundler/utils/node"; import { ConsoleLogger } from "#logger/node"; import { DistDirectory, EntryPoint, PackageRoot } from "#paths/node"; import { NodeEnvironment } from "@goauthentik/core/environment/node"; import { MonoRepoRoot } from "@goauthentik/core/paths/node"; import { BuildIdentifier } from "@goauthentik/core/version/node"; import esbuild from "esbuild"; /// const logger = ConsoleLogger.child({ name: "Build" }); const bundleDefinitions = createBundleDefinitions(); const publicBundledDefinitions = Object.fromEntries( Object.entries(bundleDefinitions).map(([name, value]) => [name, JSON.parse(value)]), ); logger.info(publicBundledDefinitions, "Bundle definitions"); /** * @typedef {[from: string, to: string]} SourceDestinationPair */ /** * @type {SourceDestinationPair[]} */ const assets = [ [ path.join(path.dirname(EntryPoint.StandaloneLoading.in), "startup"), path.dirname(EntryPoint.StandaloneLoading.out), ], [path.resolve(PackageRoot, "src", "assets", "images"), "./assets/images"], [path.resolve(PackageRoot, "icons"), "./assets/icons"], ]; const entryPointNames = Object.keys(EntryPoint); const entryPoints = Object.values(EntryPoint); const entryPointsDescription = entryPointNames.join("\n\t"); /** * @type {Plugin[]} */ const BASE_ESBUILD_PLUGINS = [ { name: "copy", setup(build) { build.onEnd(async () => { /** * @type {import('esbuild').PartialMessage[]} */ const errors = []; /** * @param {SourceDestinationPair} pair */ const copy = ([from, to]) => { const resolvedDestination = path.resolve(DistDirectory, to); logger.debug(`📋 Copying assets from ${from} to ${to}`); return fs .cp(from, resolvedDestination, { recursive: true, }) .catch((error) => { errors.push({ text: `Failed to copy assets from ${from} to ${to}: ${error}`, location: { file: from, }, }); }); }; await Promise.all(assets.map(copy)); return { errors }; }); }, }, mdxPlugin({ root: MonoRepoRoot, }), ]; /** * @type {BuildOptions} */ const BASE_ESBUILD_OPTIONS = { entryNames: `[dir]/[name]-${BuildIdentifier}`, chunkNames: "[dir]/chunks/[hash]", assetNames: "assets/[dir]/[name]-[hash]", outdir: DistDirectory, bundle: true, write: true, sourcemap: true, minify: NodeEnvironment === "production", legalComments: "external", splitting: true, color: !process.env.NO_COLOR, treeShaking: true, tsconfig: path.resolve(PackageRoot, "tsconfig.build.json"), loader: { ".css": "text", ".woff": "file", ".woff2": "file", ".jpg": "file", ".png": "file", ".svg": "file", }, /** * Conditions for module resolution. * * @see https://esbuild.github.io/api/#conditions * @see https://nodejs.org/api/packages.html#packages_conditional_exports */ conditions: NodeEnvironment === "production" ? ["production"] : ["development", "production"], plugins: BASE_ESBUILD_PLUGINS, define: bundleDefinitions, format: "esm", }; /** * Creates an ESBuild options, extending the base options with the given overrides. * * @param {BuildOptions["entryPoints"]} entryPoints * @param {Plugin[]} plugIns * @returns {BuildOptions} */ export function createESBuildOptions(entryPoints, plugIns = []) { const plugins = [...BASE_ESBUILD_PLUGINS, ...plugIns]; return { ...BASE_ESBUILD_OPTIONS, entryPoints, plugins, }; } async function cleanDistDirectory() { logger.info(`♻️ Cleaning previous builds...`); await fs.rm(DistDirectory, { recursive: true, force: true, }); await fs.mkdir(DistDirectory, { recursive: true, }); logger.info(`♻️ Done!`); } function doHelp() { logger.info(`Build the authentik UI options: -w, --watch: Build all interfaces -s, --styles-only: Build the static CSS`); process.exit(0); } /** * * @returns {Promise<() => Promise>} dispose */ async function doWatch() { logger.info(`🤖 Watching entry points:\n\t${entryPointsDescription}`); const developmentPlugins = await import("@goauthentik/esbuild-plugin-live-reload/plugin") .then(({ liveReloadPlugin }) => [ liveReloadPlugin({ relativeRoot: PackageRoot, logger: logger.child({ name: "Live Reload", }), }), ]) .catch(() => []); const buildOptions = createESBuildOptions(entryPoints, [ ...developmentPlugins, styleLoaderPlugin({ logger, watch: true }), ]); const buildContext = await esbuild.context(buildOptions); await buildContext.watch(); const httpURL = new URL("http://localhost"); httpURL.port = process.env.COMPOSE_PORT_HTTP ?? "9000"; const httpsURL = new URL("https://localhost"); httpsURL.port = process.env.COMPOSE_PORT_HTTPS ?? "9443"; logger.info(`🚀 Server running`); logger.info(`🔓 ${httpURL.href}`); logger.info(`🔒 ${httpsURL.href}`); return () => { logger.flush(); console.info(""); console.info("🛑 Stopping file watcher..."); return buildContext.dispose(); }; } async function doBuild() { logger.info(`🤖 Building entry points:\n\t${entryPointsDescription}`); const buildOptions = createESBuildOptions(entryPoints, [styleLoaderPlugin({ logger })]); await esbuild.build(buildOptions); logger.info("Build complete"); } async function doProxy() { const entryPoints = [ EntryPoint.InterfaceStyles, EntryPoint.StaticStyles, EntryPoint.FlowStyles, ]; const buildOptions = createESBuildOptions(entryPoints, [styleLoaderPlugin({ logger })]); await esbuild.build(buildOptions); logger.info("Proxy build complete"); } async function delegateCommand() { const command = process.argv[2]; switch (command) { case "-h": case "--help": return doHelp(); case "-w": case "--watch": return doWatch(); // There's no watch-for-proxy, sorry. case "-s": case "--styles-only": return doProxy(); default: return doBuild(); } } await cleanDistDirectory() // --- .then(() => delegateCommand() .then((dispose) => { if (!dispose) { process.exit(0); } /** * @type {Promise} */ const signalListener = new Promise((resolve) => { // We prevent multiple attempts to dispose the context // because ESBuild will repeatedly restart its internal clean-up logic. // However, sending a second SIGINT will still exit the process immediately. let signalCount = 0; process.on("SIGINT", () => { if (signalCount > 3) { // Something is taking too long and the user wants to exit now. console.log("🛑 Forcing exit..."); process.exit(0); } }); process.once("SIGINT", () => { signalCount++; dispose().finally(() => { console.log("✅ Done!"); resolve(); }); }); logger.info("🚪 Press Ctrl+C to exit."); }); return signalListener; }) .then(() => process.exit(0)) .catch(() => process.exit(1)), );