/** * @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"; import { copyAssets } from "./build-assets.mjs"; /** * @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"); 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 = []; await copyAssets(); return { errors }; }); }, }, { name: "log", setup(build) { let start = new Date(0); build.onStart(() => { start = new Date(); logger.info("Build started"); }); build.onEnd((r) => { const end = new Date(); const dur = end.getTime() - start.getTime(); logger.info( `Build finished (took ${dur} ms, ${r.errors.length} error(s), ${r.warnings.length} warning(s))`, ); }); }, }, mdxPlugin({ root: MonoRepoRoot, }), ]; /** * @type {BuildOptions} */ const BASE_ESBUILD_OPTIONS = { entryNames: `[dir]/[name]-${BuildIdentifier}`, chunkNames: "[dir]/chunks/[hash]", assetNames: "assets/[dir]/[name]-[hash]", /** * Anchor `[dir]` at the monorepo root rather than letting ESBuild infer it * from the entry points. * * Assets pulled from a workspace package outside `web/` (the RedHat faces in * `@goauthentik/fonts`) would otherwise resolve to a `..` segment, which * ESBuild sanitizes to `_.._`. Go's `//go:embed dist/*` in `static_outpost.go` * silently skips any path segment starting with `_`, so those fonts would be * missing from the embedded outpost build. */ outbase: MonoRepoRoot, 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. * * `bundler` lets workspace packages that ship TypeScript sources (e.g. * `@goauthentik/theme`) expose `src/*.ts` to this build while keeping a * compiled `default` entry for plain Node consumers. esbuild does not * imply it, so without this the resolver falls through to the built * output and every edit needs a rebuild of the dependency first. * * @see https://esbuild.github.io/api/#conditions * @see https://nodejs.org/api/packages.html#packages_conditional_exports */ conditions: [ "bundler", ...(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)), );