From 2529fced87bb6041edc04b83d29c74e67ba7174f Mon Sep 17 00:00:00 2001 From: Teffen Ellis Date: Fri, 14 Feb 2025 02:32:23 +0100 Subject: [PATCH 1/2] web: Silence ESBuild warning. --- web/build.mjs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/web/build.mjs b/web/build.mjs index cd5a9ed564cd..65d36a7d803f 100644 --- a/web/build.mjs +++ b/web/build.mjs @@ -91,6 +91,15 @@ const baseArgs = { loader: { ".css": "text", ".md": "text" }, define: definitions, format: "esm", + logOverride: { + /** + * HACK: Silences issue originating in ESBuild. + * + * @see {@link https://github.com/evanw/esbuild/blob/b914dd30294346aa15fcc04278f4b4b51b8b43b5/internal/logger/msg_ids.go#L211 ESBuild source} + * @expires 2025-08-11 + */ + "invalid-source-url": "silent", + }, }; function getVersion() { From ce57e1adcc9816d2ef30e0931a61da868bdaafb9 Mon Sep 17 00:00:00 2001 From: Teffen Ellis Date: Fri, 14 Feb 2025 02:45:22 +0100 Subject: [PATCH 2/2] web: Flesh out live reload. Tidy ESBuild. --- web/build-observer-plugin.mjs | 141 +++++++++ web/build.mjs | 291 +++++++++++------- web/package-lock.json | 8 + web/package.json | 1 + .../admin/AdminInterface/AdminInterface.ts | 13 + web/src/common/client.ts | 170 ++++++++++ web/src/flow/FlowInterface.ts | 6 + web/src/user/UserInterface.ts | 8 +- 8 files changed, 534 insertions(+), 104 deletions(-) create mode 100644 web/build-observer-plugin.mjs create mode 100644 web/src/common/client.ts diff --git a/web/build-observer-plugin.mjs b/web/build-observer-plugin.mjs new file mode 100644 index 000000000000..b16d784f9c4f --- /dev/null +++ b/web/build-observer-plugin.mjs @@ -0,0 +1,141 @@ +import * as http from "http"; +import path from "path"; + +/** + * Serializes a custom event to a text stream. + * a + * @param {Event} event + * @returns {string} + */ +export function serializeCustomEventToStream(event) { + // @ts-ignore + const data = event.detail ?? {}; + + const eventContent = [`event: ${event.type}`, `data: ${JSON.stringify(data)}`]; + + return eventContent.join("\n") + "\n\n"; +} + +/** + * Options for the build observer plugin. + * + * @typedef {Object} BuildObserverOptions + * + * @property {URL} serverURL + * @property {string} logPrefix + * @property {string} relativeRoot + */ + +/** + * Creates a plugin that listens for build events and sends them to a server-sent event stream. + * + * @param {BuildObserverOptions} options + * @returns {import('esbuild').Plugin} + */ +export function buildObserverPlugin({ serverURL, logPrefix, relativeRoot }) { + const timerLabel = `[${logPrefix}] Build`; + const endpoint = serverURL.pathname; + const dispatcher = new EventTarget(); + + const eventServer = http.createServer((req, res) => { + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Allow-Methods", "GET"); + res.setHeader("Access-Control-Allow-Headers", "Content-Type"); + + if (req.url !== endpoint) { + console.log(`🚫 Invalid request to ${req.url}`); + res.writeHead(404); + res.end(); + return; + } + + console.log("🔌 Client connected"); + + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + }); + + /** + * @param {Event} event + */ + const listener = (event) => { + const body = serializeCustomEventToStream(event); + + res.write(body); + }; + + dispatcher.addEventListener("esbuild:start", listener); + dispatcher.addEventListener("esbuild:error", listener); + dispatcher.addEventListener("esbuild:end", listener); + + req.on("close", () => { + console.log("🔌 Client disconnected"); + + clearInterval(keepAliveInterval); + + dispatcher.removeEventListener("esbuild:start", listener); + dispatcher.removeEventListener("esbuild:error", listener); + dispatcher.removeEventListener("esbuild:end", listener); + }); + + const keepAliveInterval = setInterval(() => { + console.timeStamp("🏓 Keep-alive"); + + res.write("event: keep-alive\n\n"); + res.write(serializeCustomEventToStream(new CustomEvent("esbuild:keep-alive"))); + }, 15_000); + }); + + return { + name: "build-watcher", + setup: (build) => { + eventServer.listen(parseInt(serverURL.port, 10), serverURL.hostname); + + build.onDispose(() => { + eventServer.close(); + }); + + build.onStart(() => { + console.time(timerLabel); + + dispatcher.dispatchEvent( + new CustomEvent("esbuild:start", { + detail: new Date().toISOString(), + }), + ); + }); + + build.onEnd((buildResult) => { + console.timeEnd(timerLabel); + + if (!buildResult.errors.length) { + dispatcher.dispatchEvent( + new CustomEvent("esbuild:end", { + detail: new Date().toISOString(), + }), + ); + + return; + } + + console.warn(`Build ended with ${buildResult.errors.length} errors`); + + dispatcher.dispatchEvent( + new CustomEvent("esbuild:error", { + detail: buildResult.errors.map((error) => ({ + ...error, + location: error.location + ? { + ...error.location, + file: path.resolve(relativeRoot, error.location.file), + } + : null, + })), + }), + ); + }); + }, + }; +} diff --git a/web/build.mjs b/web/build.mjs index 65d36a7d803f..453d5d009897 100644 --- a/web/build.mjs +++ b/web/build.mjs @@ -1,45 +1,54 @@ import { execFileSync } from "child_process"; -import * as chokidar from "chokidar"; import esbuild from "esbuild"; -import fs from "fs"; +import findFreePorts from "find-free-ports"; +import { copyFileSync, mkdirSync, readFileSync, statSync } from "fs"; import { globSync } from "glob"; import path from "path"; import { cwd } from "process"; import process from "process"; import { fileURLToPath } from "url"; -const __dirname = fileURLToPath(new URL(".", import.meta.url)); +import { buildObserverPlugin } from "./build-observer-plugin.mjs"; +const __dirname = fileURLToPath(new URL(".", import.meta.url)); let authentikProjectRoot = __dirname + "../"; + try { // Use the package.json file in the root folder, as it has the current version information. authentikProjectRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8", }).replace("\n", ""); -} catch (_exc) { - // We probably don't have a .git folder, which could happen in container builds +} catch (_error) { + // We probably don't have a .git folder, which could happen in container builds. } -const rootPackage = JSON.parse(fs.readFileSync(path.join(authentikProjectRoot, "./package.json"))); -const isProdBuild = process.env.NODE_ENV === "production"; +const packageJSONPath = path.join(authentikProjectRoot, "./package.json"); +const rootPackage = JSON.parse(readFileSync(packageJSONPath, "utf8")); -const apiBasePath = process.env.AK_API_BASE_PATH || ""; +const NODE_ENV = process.env.NODE_ENV || "development"; +const AK_API_BASE_PATH = process.env.AK_API_BASE_PATH || ""; -const envGitHashKey = "GIT_BUILD_HASH"; - -const definitions = { - "process.env.NODE_ENV": JSON.stringify(isProdBuild ? "production" : "development"), - "process.env.CWD": JSON.stringify(cwd()), - "process.env.AK_API_BASE_PATH": JSON.stringify(apiBasePath), -}; +const environmentVars = new Map([ + ["NODE_ENV", NODE_ENV], + ["CWD", cwd()], + ["AK_API_BASE_PATH", AK_API_BASE_PATH], +]); -// All is magic is just to make sure the assets are copied into the right places. This is a very -// stripped down version of what the rollup-copy-plugin does, without any of the features we don't -// use, and using globSync instead of globby since we already had globSync lying around thanks to -// Typescript. If there's a third argument in an array entry, it's used to replace the internal path -// before concatenating it all together as the destination target. +const definitions = Object.fromEntries( + Array.from(environmentVars).map(([key, value]) => { + return [`process.env.${key}`, JSON.stringify(value)]; + }), +); -const otherFiles = [ +/** + * All is magic is just to make sure the assets are copied into the right places. This is a very + * stripped down version of what the rollup-copy-plugin does, without any of the features we don't + * use, and using globSync instead of globby since we already had globSync lying around thanks to + * Typescript. If there's a third argument in an array entry, it's used to replace the internal path + * before concatenating it all together as the destination target. + * @type {Array<[string, string, string?]>} + */ +const assetsFileMappings = [ ["node_modules/@patternfly/patternfly/patternfly.min.css", "."], ["node_modules/@patternfly/patternfly/assets/**", ".", "node_modules/@patternfly/patternfly/"], ["src/custom.css", "."], @@ -48,28 +57,47 @@ const otherFiles = [ ["./icons/*", "./assets/icons"], ]; -const isFile = (filePath) => fs.statSync(filePath).isFile(); +/** + * @param {string} filePath + */ +const isFile = (filePath) => statSync(filePath).isFile(); + +/** + * @param {string} src Source file + * @param {string} dest Destination folder + * @param {string} [strip] Path to strip from the source file + */ function nameCopyTarget(src, dest, strip) { const target = path.join(dest, strip ? src.replace(strip, "") : path.parse(src).base); return [src, target]; } -for (const [source, rawdest, strip] of otherFiles) { +for (const [source, rawdest, strip] of assetsFileMappings) { const matchedPaths = globSync(source); const dest = path.join("dist", rawdest); + const copyTargets = matchedPaths.map((path) => nameCopyTarget(path, dest, strip)); + for (const [src, dest] of copyTargets) { if (isFile(src)) { - fs.mkdirSync(path.dirname(dest), { recursive: true }); - fs.copyFileSync(src, dest); + mkdirSync(path.dirname(dest), { recursive: true }); + copyFileSync(src, dest); } } } -// This starts the definitions used for esbuild: Our targets, our arguments, the function for -// running a build, and three options for building: watching, building, and building the proxy. -// Ordered by largest to smallest interface to build even faster -const interfaces = [ +/** + * @typedef {[source: string, destination: string]} EntryPoint + */ + +/** + * This starts the definitions used for esbuild: Our targets, our arguments, the function for + * running a build, and three options for building: watching, building, and building the proxy. + * Ordered by largest to smallest interface to build even faster + * + * @type {EntryPoint[]} + */ +const entryPoints = [ ["admin/AdminInterface/AdminInterface.ts", "admin"], ["user/UserInterface.ts", "user"], ["flow/FlowInterface.ts", "flow"], @@ -79,11 +107,14 @@ const interfaces = [ ["polyfill/poly.ts", "."], ]; -const baseArgs = { +/** + * @satisfies {import("esbuild").BuildOptions} + */ +const BASE_ESBUILD_OPTIONS = { bundle: true, write: true, sourcemap: true, - minify: isProdBuild, + minify: NODE_ENV === "production", splitting: true, treeShaking: true, external: ["*.woff", "*.woff2"], @@ -91,6 +122,7 @@ const baseArgs = { loader: { ".css": "text", ".md": "text" }, define: definitions, format: "esm", + plugins: [], logOverride: { /** * HACK: Silences issue originating in ESBuild. @@ -102,91 +134,144 @@ const baseArgs = { }, }; -function getVersion() { - let version = rootPackage.version; - if (process.env[envGitHashKey]) { - version = `${version}+${process.env[envGitHashKey]}`; +/** + * Creates a version ID for the build. + * @returns {string} + */ +function composeVersionID() { + const { version } = rootPackage; + const buildHash = process.env.GIT_BUILD_HASH; + + if (buildHash) { + return `${version}+${buildHash}`; } + return version; } -async function buildOneSource(source, dest) { - const DIST = path.join(__dirname, "./dist", dest); - console.log(`[${new Date(Date.now()).toISOString()}] Starting build for target ${source}`); - - try { - const start = Date.now(); - await esbuild.build({ - ...baseArgs, - entryPoints: [`./src/${source}`], - entryNames: `[dir]/[name]-${getVersion()}`, - outdir: DIST, - }); - const end = Date.now(); - console.log( - `[${new Date(end).toISOString()}] Finished build for target ${source} in ${ - Date.now() - start - }ms`, - ); - return 0; - } catch (exc) { - console.error(`[${new Date(Date.now()).toISOString()}] Failed to build ${source}: ${exc}`); - return 1; - } -} +/** + * Build a single entry point. + * + * @param {EntryPoint} buildTarget + * @param {Partial} [overrides] + * @throws {Error} on build failure + */ +function createEntryPointOptions([source, dest], overrides = {}) { + const outdir = path.join(__dirname, "./dist", dest); -async function buildAuthentik(interfaces) { - const code = await Promise.allSettled( - interfaces.map(([source, dest]) => buildOneSource(source, dest)), - ); - const finalCode = code.reduce((a, res) => a + res.value, 0); - if (finalCode > 0) { - return 1; - } - return 0; + return { + ...BASE_ESBUILD_OPTIONS, + entryPoints: [`./src/${source}`], + entryNames: `[dir]/[name]-${composeVersionID()}`, + outdir, + ...overrides, + }; } -let timeoutId = null; -function debouncedBuild() { - if (timeoutId !== null) { - clearTimeout(timeoutId); - } - timeoutId = setTimeout(() => { - console.clear(); - buildAuthentik(interfaces); - }, 250); +/** + * Build all entry points in parallel. + * + * @param {EntryPoint[]} entryPoints + */ +async function buildParallel(entryPoints) { + await Promise.allSettled( + entryPoints.map((entryPoint) => { + return esbuild.build(createEntryPointOptions(entryPoint)); + }), + ); } -if (process.argv.length > 2 && (process.argv[2] === "-h" || process.argv[2] === "--help")) { - console.log(`Build the authentikUI +function doHelp() { + console.log(`Build the authentik UI -options: - -w, --watch: Build all ${interfaces.length} interfaces - -p, --proxy: Build only the polyfills and the loading application - -h, --help: This help message + options: + -w, --watch: Build all ${entryPoints.length} interfaces + -p, --proxy: Build only the polyfills and the loading application + -h, --help: This help message `); + process.exit(0); } -if (process.argv.length > 2 && (process.argv[2] === "-w" || process.argv[2] === "--watch")) { - console.log("Watching ./src for changes"); - chokidar.watch("./src").on("all", (event, path) => { - if (!["add", "change", "unlink"].includes(event)) { - return; - } - if (!/(\.css|\.ts|\.js)$/.test(path)) { - return; - } - debouncedBuild(); - }); -} else if (process.argv.length > 2 && (process.argv[2] === "-p" || process.argv[2] === "--proxy")) { - // There's no watch-for-proxy, sorry. - process.exit( - await buildAuthentik( - interfaces.filter(([_, dest]) => ["standalone/loading", "."].includes(dest)), - ), +async function doWatch() { + console.log("Watching all entry points..."); + + const wathcherPorts = await findFreePorts(entryPoints.length); + + const buildContexts = await Promise.all( + entryPoints.map((entryPoint, i) => { + const port = wathcherPorts[i]; + const serverURL = new URL(`http://localhost:${port}/events`); + + return esbuild.context( + createEntryPointOptions(entryPoint, { + plugins: [ + ...BASE_ESBUILD_OPTIONS.plugins, + buildObserverPlugin({ + serverURL, + logPrefix: entryPoint[1], + relativeRoot: __dirname, + }), + ], + define: { + ...definitions, + "process.env.WATCHER_URL": JSON.stringify(serverURL.toString()), + }, + }), + ); + }), + ); + + await Promise.all(buildContexts.map((context) => context.rebuild())); + + await Promise.allSettled(buildContexts.map((context) => context.watch())); + + return /** @type {Promise} */ ( + new Promise((resolve) => { + process.on("SIGINT", () => { + resolve(); + }); + }) + ); +} + +async function doBuild() { + console.log("Building all entry points"); + + return buildParallel(entryPoints); +} + +async function doProxy() { + return buildParallel( + entryPoints.filter(([_, dest]) => ["standalone/loading", "."].includes(dest)), ); -} else { - // And the fallback: just build it. - process.exit(await buildAuthentik(interfaces)); } + +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 "-p": + case "--proxy": + return doProxy(); + default: + return doBuild(); + } +} + +await delegateCommand() + .then(() => { + console.log("Build complete"); + process.exit(0); + }) + .catch((error) => { + console.error(error); + process.exit(1); + }); diff --git a/web/package-lock.json b/web/package-lock.json index 90db69812f91..091a24926679 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -89,6 +89,7 @@ "eslint": "^9.11.1", "eslint-plugin-lit": "^1.15.0", "eslint-plugin-wc": "^2.1.1", + "find-free-ports": "^3.1.1", "github-slugger": "^2.0.0", "glob": "^11.0.0", "globals": "^15.10.0", @@ -12595,6 +12596,13 @@ "url": "https://github.com/avajs/find-cache-dir?sponsor=1" } }, + "node_modules/find-free-ports": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/find-free-ports/-/find-free-ports-3.1.1.tgz", + "integrity": "sha512-hQebewth9i5qkf0a0u06iFaxQssk5ZnPBBggsa1vk8zCYaZoz9IZXpoRLTbEOrYdqfrjvcxU00gYoCPgmXugKA==", + "dev": true, + "license": "MIT" + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", diff --git a/web/package.json b/web/package.json index 9a333cf3c323..55010b91d79d 100644 --- a/web/package.json +++ b/web/package.json @@ -77,6 +77,7 @@ "eslint": "^9.11.1", "eslint-plugin-lit": "^1.15.0", "eslint-plugin-wc": "^2.1.1", + "find-free-ports": "^3.1.1", "github-slugger": "^2.0.0", "glob": "^11.0.0", "globals": "^15.10.0", diff --git a/web/src/admin/AdminInterface/AdminInterface.ts b/web/src/admin/AdminInterface/AdminInterface.ts index 4093efc20180..04c633c81aaf 100644 --- a/web/src/admin/AdminInterface/AdminInterface.ts +++ b/web/src/admin/AdminInterface/AdminInterface.ts @@ -90,12 +90,14 @@ export class AdminInterface extends AuthenticatedInterface { constructor() { super(); this.ws = new WebsocketClient(); + window.addEventListener(EVENT_NOTIFICATION_DRAWER_TOGGLE, () => { this.notificationDrawerOpen = !this.notificationDrawerOpen; updateURLParams({ notificationDrawerOpen: this.notificationDrawerOpen, }); }); + window.addEventListener(EVENT_API_DRAWER_TOGGLE, () => { this.apiDrawerOpen = !this.apiDrawerOpen; updateURLParams({ @@ -107,6 +109,7 @@ export class AdminInterface extends AuthenticatedInterface { async firstUpdated(): Promise { configureSentry(true); this.user = await me(); + const canAccessAdmin = this.user.user.isSuperuser || // TODO: somehow add `access_admin_interface` to the API schema @@ -116,6 +119,16 @@ export class AdminInterface extends AuthenticatedInterface { } } + async connectedCallback(): Promise { + super.connectedCallback(); + + if (process.env.NODE_ENV === "development" && process.env.WATCHER_URL) { + const { ESBuildObserver } = await import("@goauthentik/common/client"); + + new ESBuildObserver(process.env.WATCHER_URL); + } + } + render(): TemplateResult { const sidebarClasses = { "pf-m-light": this.activeTheme === UiThemeEnum.Light, diff --git a/web/src/common/client.ts b/web/src/common/client.ts new file mode 100644 index 000000000000..1869392740f2 --- /dev/null +++ b/web/src/common/client.ts @@ -0,0 +1,170 @@ +/** + * @file + * Client-side observer for ESBuild events. + */ +import type { Message as ESBuildMessage } from "esbuild"; + +const logPrefix = "👷 [ESBuild]"; +const log = console.debug.bind(console, logPrefix); + +type BuildEventListener = (event: MessageEvent) => void; + +/** + * A client-side watcher for ESBuild. + * + * Note that this should be conditionally imported in your code, so that + * ESBuild may tree-shake it out of production builds. + * + * ```ts + * if (process.env.NODE_ENV === "development" && process.env.WATCHER_URL) { + * const { ESBuildObserver } = await import("@goauthentik/common/client"); + * + * new ESBuildObserver(process.env.WATCHER_URL); + * } + * ``` +} + + */ +export class ESBuildObserver extends EventSource { + /** + * Whether the watcher has a recent connection to the server. + */ + alive = true; + + /** + * The number of errors that have occurred since the watcher started. + */ + errorCount = 0; + + /** + * Whether a reload has been requested while offline. + */ + deferredReload = false; + + /** + * The last time a message was received from the server. + */ + lastUpdatedAt = Date.now(); + + /** + * Whether the browser considers itself online. + */ + online = true; + + /** + * The ID of the animation frame for the reload. + */ + #reloadFrameID = -1; + + /** + * The interval for the keep-alive check. + */ + #keepAliveInterval: ReturnType | undefined; + + #trackActivity = () => { + this.lastUpdatedAt = Date.now(); + this.alive = true; + }; + + #startListener: BuildEventListener = () => { + this.#trackActivity(); + log("⏰ Build started..."); + }; + + #internalErrorListener = () => { + this.errorCount += 1; + + if (this.errorCount > 100) { + clearTimeout(this.#keepAliveInterval); + + this.close(); + log("⛔️ Closing connection"); + } + }; + + #errorListener: BuildEventListener = (event) => { + this.#trackActivity(); + + // eslint-disable-next-line no-console + console.group(logPrefix, "⛔️⛔️⛔️ Build error..."); + + const esbuildErrorMessages: ESBuildMessage[] = JSON.parse(event.data); + + for (const error of esbuildErrorMessages) { + console.warn(error.text); + + if (error.location) { + console.debug( + `file://${error.location.file}:${error.location.line}:${error.location.column}`, + ); + console.debug(error.location.lineText); + } + } + + // eslint-disable-next-line no-console + console.groupEnd(); + }; + + #endListener: BuildEventListener = () => { + cancelAnimationFrame(this.#reloadFrameID); + + this.#trackActivity(); + + if (!this.online) { + log("🚫 Build finished while offline."); + this.deferredReload = true; + + return; + } + + log("🛎️ Build completed! Reloading..."); + + // We use an animation frame to keep the reload from happening before the + // event loop has a chance to process the message. + this.#reloadFrameID = requestAnimationFrame(() => { + window.location.reload(); + }); + }; + + #keepAliveListener: BuildEventListener = () => { + this.#trackActivity(); + log("🏓 Keep-alive"); + }; + + constructor(url: string | URL) { + super(url); + + this.addEventListener("esbuild:start", this.#startListener); + this.addEventListener("esbuild:end", this.#endListener); + this.addEventListener("esbuild:error", this.#errorListener); + this.addEventListener("esbuild:keep-alive", this.#keepAliveListener); + + this.addEventListener("error", this.#internalErrorListener); + + window.addEventListener("offline", () => { + this.online = false; + }); + + window.addEventListener("online", () => { + this.online = true; + + if (!this.deferredReload) return; + + log("🛎️ Reloading after offline build..."); + this.deferredReload = false; + + window.location.reload(); + }); + + log("🛎️ Listening for build changes..."); + + this.#keepAliveInterval = setInterval(() => { + const now = Date.now(); + + if (now - this.lastUpdatedAt < 10_000) return; + + this.alive = false; + log("👋 Waiting for build to start..."); + }, 15_000); + } +} diff --git a/web/src/flow/FlowInterface.ts b/web/src/flow/FlowInterface.ts index f645023c04a6..994032501e2f 100644 --- a/web/src/flow/FlowInterface.ts +++ b/web/src/flow/FlowInterface.ts @@ -13,3 +13,9 @@ import "@goauthentik/flow/stages/identification/IdentificationStage"; import "@goauthentik/flow/stages/password/PasswordStage"; // end of stage import + +if (process.env.NODE_ENV === "development" && process.env.WATCHER_URL) { + const { ESBuildObserver } = await import("@goauthentik/common/client"); + + new ESBuildObserver(process.env.WATCHER_URL); +} diff --git a/web/src/user/UserInterface.ts b/web/src/user/UserInterface.ts index 451848a98538..58fc1937fee3 100644 --- a/web/src/user/UserInterface.ts +++ b/web/src/user/UserInterface.ts @@ -278,11 +278,17 @@ export class UserInterface extends AuthenticatedInterface { this.fetchConfigurationDetails = this.fetchConfigurationDetails.bind(this); } - connectedCallback() { + async connectedCallback() { super.connectedCallback(); window.addEventListener(EVENT_NOTIFICATION_DRAWER_TOGGLE, this.toggleNotificationDrawer); window.addEventListener(EVENT_API_DRAWER_TOGGLE, this.toggleApiDrawer); window.addEventListener(EVENT_WS_MESSAGE, this.fetchConfigurationDetails); + + if (process.env.NODE_ENV === "development" && process.env.WATCHER_URL) { + const { ESBuildObserver } = await import("@goauthentik/common/client"); + + new ESBuildObserver(process.env.WATCHER_URL); + } } disconnectedCallback() {