web/packages: NPM workspace: Mini Cleanup (#14767)

* web: Move non-workspace package.

* web: Update ESBuild package version.

* web: Use NPM link to alias local package.

* web: Update lock.

* web: Fix regression where bundler is expected.
This commit is contained in:
Teffen Ellis
2025-06-26 23:29:35 +02:00
committed by GitHub
parent a95776891e
commit d86b5e7c8a
17 changed files with 37 additions and 182 deletions

View File

@ -0,0 +1,59 @@
_An ESBuild development plugin that watches for file changes and triggers automatic browser refreshes._
## Quick start
```sh
npm install -D @goauthentik/esbuild-plugin-live-reload
# Or with Yarn:
yarn add -D @goauthentik/esbuild-plugin-live-reload
```
### 1. Configure ESBuild
```js
import { liveReloadPlugin } from "@goauthentik/esbuild-plugin-live-reload";
import esbuild from "esbuild";
const NodeEnvironment = process.env.NODE_ENV || "development";
/**
* @type {esbuild.BuildOptions}
*/
const buildOptions = {
// ... Your build options.
define: {
"process.env.NODE_ENV": JSON.stringify(NodeEnvironment),
},
plugins: [
/** @see {@link LiveReloadPluginOptions} */
liveReloadPlugin(),
],
};
const buildContext = await esbuild.context(buildOptions);
await buildContext.rebuild();
await buildContext.watch();
```
### 2. Connect your browser
Add the following import near the beginning of your application's entry point.
```js
if (process.env.NODE_ENV === "development") {
await import("@goauthentik/esbuild-plugin-live-reload/client");
}
```
That's it! Your browser will now automatically refresh whenever ESBuild finishes rebuilding your code.
## About authentik
[authentik](https://goauthentik.io) is an open source Identity Provider that unifies your identity needs into a single platform, replacing Okta, Active Directory, and Auth0.
We built this plugin to streamline our development workflow, and we're sharing it with the community. If you have any questions, feature requests, or bug reports, please [open an issue](https://github.com/goauthentik/authentik/issues/new/choose).
## License
This code is licensed under the [MIT License](https://www.tldrlegal.com/license/mit-license)

View File

@ -0,0 +1,4 @@
README.md
node_modules
_media
!.github/README.md

View File

@ -0,0 +1,3 @@
node_modules
./README.md
out

View File

@ -0,0 +1,18 @@
The MIT License (MIT)
Copyright (c) 2025 Authentik Security, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction,
including without limitation the rights to use, copy, modify, merge, publish, distribute,
sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial
portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES
OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@ -0,0 +1,221 @@
/// <reference types="./types.js" />
/**
* @file Client-side observer for ESBuild events.
*
* @import { Message as ESBuildMessage } from "esbuild";
*/
const logPrefix = "authentik/dev/web: ";
const log = console.debug.bind(console, logPrefix);
/**
* @template {unknown} [Data=unknown]
* @typedef {(event: MessageEvent) => void} BuildEventListener
*/
/**
* 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") {
* await import("@goauthentik/esbuild-plugin-live-reload/client")
* .catch(() => console.warn("Failed to import watcher"))
* }
* ```
*
* @implements {Disposable}
* @category Plugin
* runtime browser
*/
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.
* @type {ReturnType<typeof setInterval> | undefined}
*/
#keepAliveInterval;
#trackActivity = () => {
this.lastUpdatedAt = Date.now();
this.alive = true;
};
/**
* @type {BuildEventListener}
*/
#startListener = () => {
this.#trackActivity();
log("⏰ Build started...");
};
#internalErrorListener = () => {
this.errorCount += 1;
if (this.errorCount > 100) {
clearTimeout(this.#keepAliveInterval);
this.close();
log("⛔️ Closing connection");
}
};
/**
* @type {BuildEventListener<string>}
*/
#errorListener = (event) => {
this.#trackActivity();
console.group(logPrefix, "⛔️⛔️⛔️ Build error...");
/**
* @type {ESBuildMessage[]}
*/
const esbuildErrorMessages = 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);
}
}
console.groupEnd();
};
/**
* @type {BuildEventListener}
*/
#endListener = () => {
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();
});
};
/**
* @type {BuildEventListener}
*/
#keepAliveListener = () => {
this.#trackActivity();
log("🏓 Keep-alive");
};
/**
* Initialize the ESBuild observer.
* This should be called once in your application.
*
* @param {string | URL} [url]
* @returns {ESBuildObserver}
*/
static initialize = (url) => {
const esbuildObserver = new ESBuildObserver(url);
return esbuildObserver;
};
/**
*
* @param {string | URL} [url]
*/
constructor(url) {
if (!url) {
throw new TypeError("ESBuildObserver: Cannot construct without a 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);
}
[Symbol.dispose]() {
return this.close();
}
dispose() {
return this[Symbol.dispose]();
}
}
export default ESBuildObserver;

View File

@ -0,0 +1,13 @@
/// <reference types="./types.js" />
/**
* @file Entry point for the ESBuild client-side observer.
*/
import { ESBuildObserver } from "./ESBuildObserver.js";
if (import.meta.env?.ESBUILD_WATCHER_URL) {
const buildObserver = new ESBuildObserver(import.meta.env.ESBUILD_WATCHER_URL);
window.addEventListener("beforeunload", () => {
buildObserver.dispose();
});
}

View File

@ -0,0 +1,23 @@
/**
* @file Import meta environment variables available via ESBuild.
*/
export {};
declare global {
/**
* Environment variables injected by ESBuild.
*/
interface ImportMetaEnv {
/**
* The injected watcher URL for ESBuild.
* This is used for live reloading in development mode.
*
* @format url
*/
readonly ESBUILD_WATCHER_URL?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
}

View File

@ -0,0 +1,6 @@
/**
* @remarks Live reload plugin for ESBuild.
*/
export * from "./client/index.js";
export * from "./plugin/index.js";

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,75 @@
{
"name": "@goauthentik/esbuild-plugin-live-reload",
"version": "1.0.6",
"description": "ESBuild + browser refresh. Build completes, page reloads.",
"license": "MIT",
"scripts": {
"build": "npm run build:types && npm run build:docs",
"build:docs": "typedoc",
"build:types": "tsc -p .",
"prettier": "prettier --cache --write -u .",
"prettier-check": "prettier --cache --check -u ."
},
"main": "index.js",
"type": "module",
"exports": {
"./package.json": "./package.json",
".": {
"types": "./out/index.d.ts",
"import": "./index.js"
},
"./client": {
"types": "./out/client/index.d.ts",
"import": "./client/index.js"
},
"./plugin": {
"types": "./out/plugin/index.d.ts",
"import": "./plugin/index.js"
}
},
"dependencies": {
"find-free-ports": "^3.1.1"
},
"devDependencies": {
"@goauthentik/prettier-config": "^1.0.5",
"@goauthentik/tsconfig": "^1.0.4",
"@trivago/prettier-plugin-sort-imports": "^5.2.2",
"@types/node": "^22.15.21",
"esbuild": "^0.25.5",
"prettier": "^3.5.3",
"prettier-plugin-packagejson": "^2.5.14",
"typedoc": "^0.28.5",
"typedoc-plugin-markdown": "^4.6.3",
"typescript": "^5.8.3"
},
"peerDependencies": {
"esbuild": "^0.25.5"
},
"engines": {
"node": ">=22"
},
"keywords": [
"esbuild",
"live-reload",
"browser",
"refresh",
"reload",
"authentik"
],
"repository": {
"type": "git",
"url": "git+https://github.com/goauthentik/authentik.git",
"directory": "web/packages/esbuild-plugin-live-reload"
},
"types": "./out/index.d.ts",
"files": [
"./index.js",
"client/**/*",
"plugin/**/*",
"out/**/*"
],
"prettier": "@goauthentik/prettier-config",
"publishConfig": {
"access": "public"
}
}

View File

@ -0,0 +1,265 @@
/**
* @file Live reload plugin for ESBuild.
*
* @import { ListenOptions } from "node:net";
* @import {Server as HTTPServer} from "node:http";
* @import {Server as HTTPSServer} from "node:https";
*/
import { findFreePorts } from "find-free-ports";
import * as http from "node:http";
import { resolve as resolvePath } from "node:path";
/**
* Serializes a custom event to a text stream.
*
* @param {Event} event
* @returns {string}
*
* @category Server API
* @ignore
* @internal
* @runtime node
*/
export function serializeCustomEventToStream(event) {
// @ts-expect-error - TS doesn't know about the detail property
const data = event.detail ?? {};
const eventContent = [`event: ${event.type}`, `data: ${JSON.stringify(data)}`];
return eventContent.join("\n") + "\n\n";
}
const MIN_PORT = 1025;
const MAX_PORT = 65535;
/**
* Find a random port that is not in use, sufficiently far from the default port.
* @returns {Promise<number>}
*/
async function findDisparatePort() {
const startPort = Math.floor(Math.random() * (MAX_PORT - MIN_PORT + 1)) + MIN_PORT;
const wathcherPorts = await findFreePorts(1, {
startPort,
});
const [port] = wathcherPorts;
if (!port) {
throw new Error("No free ports available");
}
return port;
}
/**
* Event server initialization options.
*
* @typedef {Object} EventServerInit
*
* @property {string} pathname
* @property {EventTarget} dispatcher
* @property {string} [logPrefix]
*
* @category Server API
* @runtime node
*/
/**
* @typedef {(req: http.IncomingMessage, res: http.ServerResponse) => void} RequestHandler
*
* @category Server API
* @runtime node
*/
/**
* Create an event request handler.
*
* @param {EventServerInit} options
* @returns {RequestHandler}
*
* @category Server API
* @runtime node
*/
export function createRequestHandler({ pathname, dispatcher, logPrefix = "Build Observer" }) {
const log = console.log.bind(console, `[${logPrefix}]`);
/**
* @type {RequestHandler}
*/
const requestHandler = (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 !== pathname) {
log(`🚫 Invalid request to ${req.url}`);
res.writeHead(404);
res.end();
return;
}
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", () => {
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 requestHandler;
}
/**
* Options for the build observer plugin.
*
* @category Plugin API
* @runtime node
*
* @typedef {object} LiveReloadPluginOptions
*
* @property {HTTPServer | HTTPSServer} [server] A server to listen on. If not provided, a new server will be created.
* @property {ListenOptions} [listenOptions] Options for the server's listen method.
* @property {string | URL} [publicURL] A URL to listen on. If not provided, a random port will be used.
* @property {string} [logPrefix] A prefix to use for log messages.
* @property {string} [relativeRoot] A relative path to the root of the project. This is used to resolve build errors, line numbers, and file paths.
*/
/**
* Creates a plugin that listens for build events and sends them to a server-sent event stream.
*
* @param {LiveReloadPluginOptions} [options]
* @returns {import('esbuild').Plugin}
*/
export function liveReloadPlugin(options = {}) {
return {
name: "build-watcher",
setup: async (build) => {
const logPrefix = options.logPrefix || "Build Observer";
const timerLabel = `[${logPrefix}] 🏁`;
const relativeRoot = options.relativeRoot || process.cwd();
const dispatcher = new EventTarget();
/**
* @type {URL}
*/
let publicURL;
if (!options.publicURL) {
const port = await findDisparatePort();
publicURL = new URL(`http://localhost:${port}/events`);
} else {
publicURL =
typeof options.publicURL === "string"
? new URL(options.publicURL)
: options.publicURL;
}
build.initialOptions.define = {
...build.initialOptions.define,
"import.meta.env.ESBUILD_WATCHER_URL": JSON.stringify(publicURL.href),
};
build.initialOptions.define["process.env.NODE_ENV"] ??= JSON.stringify(
process.env.NODE_ENV || "development",
);
const requestHandler = createRequestHandler({
pathname: publicURL.pathname,
dispatcher,
logPrefix,
});
const server = options.server || http.createServer(requestHandler);
const listenOptions = options.listenOptions || {
port: parseInt(publicURL.port, 10),
host: publicURL.hostname,
};
server.listen(listenOptions, () => {
console.log(`[${logPrefix}] Listening`);
});
build.onDispose(() => {
server?.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: resolvePath(relativeRoot, error.location.file),
}
: null,
})),
}),
);
});
},
};
}
export default liveReloadPlugin;

View File

@ -0,0 +1,14 @@
{
"extends": "@goauthentik/tsconfig",
"compilerOptions": {
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"resolveJsonModule": true,
"baseUrl": ".",
"checkJs": true,
"emitDeclarationOnly": true
},
"exclude": [
// ---
"**/out/**/*"
]
}

View File

@ -0,0 +1,66 @@
{
"$schema": "https://typedoc-plugin-markdown.org/schema.json",
"entryPoints": ["./plugin/index.js"],
"plugin": ["typedoc-plugin-markdown"],
"name": "ESBuild Plugin Live Reload",
"formatWithPrettier": true,
"prettierConfigFile": "@goauthentik/prettier-config",
"flattenOutputFiles": true,
"readme": ".github/README.md",
"mergeReadme": true,
"enumMembersFormat": "table",
"parametersFormat": "table",
"interfacePropertiesFormat": "table",
"typeDeclarationFormat": "table",
"indexFormat": "table",
"router": "module",
"jsDocCompatibility": true,
"defaultCategory": "Plugin API",
"disableSources": true,
"out": ".",
"cleanOutputDir": false,
"blockTags": [
"@runtime",
"@file",
"@defaultValue",
"@deprecated",
"@example",
"@param",
"@privateRemarks",
"@remarks",
"@returns",
"@see",
"@throws",
"@typeParam",
"@author",
"@callback",
"@category",
"@categoryDescription",
"@default",
"@document",
"@extends",
"@augments",
"@yields",
"@group",
"@groupDescription",
"@import",
"@inheritDoc",
"@jsx",
"@license",
"@module",
"@mergeModuleWith",
"@prop",
"@property",
"@return",
"@satisfies",
"@since",
"@template",
"@type",
"@typedef",
"@summary",
"@preventInline",
"@inlineType",
"@preventExpand",
"@expandType"
]
}