Skip to content

Commit 90d9b2d

Browse files
authored
web: Fix ESBuild hanging process (#18162)
1 parent 4c92eff commit 90d9b2d

File tree

3 files changed

+110
-60
lines changed

3 files changed

+110
-60
lines changed

web/bundler/style-loader-plugin/node.js

Lines changed: 59 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
/**
22
* @file MDX plugin for ESBuild.
33
*
4-
* @import { Plugin, PluginBuild, BuildContext } from "esbuild"
4+
* @import { Plugin, PluginBuild, BuildContext, BuildOptions } from "esbuild"
5+
* @import { BaseLogger } from "pino"
56
*/
67

78
import { readFile } from "node:fs/promises";
89
import { createRequire } from "node:module";
9-
import { dirname, join } from "node:path";
10+
import { dirname, join, relative } from "node:path";
11+
12+
import { ConsoleLogger } from "#logger/node";
1013

1114
import { resolvePackage } from "@goauthentik/core/paths/node";
1215

@@ -16,12 +19,23 @@ const CSSNamespace = /** @type {const} */ ({
1619
Bundled: "css-bundled",
1720
});
1821

22+
/**
23+
* @typedef StyleLoaderPluginOptions
24+
*
25+
* @property {boolean} [watch] Whether to watch for file changes.
26+
* @property {BaseLogger} [logger]
27+
*/
28+
1929
/**
2030
* Selectively apply the ESBuild `css` loader.
2131
*
32+
* @param {StyleLoaderPluginOptions} [options]
2233
* @returns {Plugin}
2334
*/
24-
export function styleLoaderPlugin() {
35+
export function styleLoaderPlugin({
36+
watch = false,
37+
logger = ConsoleLogger.child({ name: "style-loader-plugin" }),
38+
} = {}) {
2539
const patternflyPath = resolvePackage("@patternfly/patternfly", import.meta);
2640
const require = createRequire(import.meta.url);
2741

@@ -56,7 +70,8 @@ export function styleLoaderPlugin() {
5670
const disposables = new Map();
5771

5872
build.onDispose(async () => {
59-
for (const ctx of disposables.values()) {
73+
for (const [filePath, ctx] of disposables) {
74+
logger.debug(`Disposing CSS build context for ${filePath}`);
6075
await ctx.dispose();
6176
}
6277
});
@@ -114,33 +129,50 @@ export function styleLoaderPlugin() {
114129
const cssContent = await readFile(args.path, "utf8");
115130
let context = disposables.get(args.path);
116131

117-
if (!context) {
118-
context = await build.esbuild.context({
119-
stdin: {
120-
contents: cssContent,
121-
resolveDir: dirname(args.path),
122-
loader: "css",
123-
},
124-
metafile: true,
125-
bundle: true,
126-
write: false,
127-
minify: build.initialOptions.minify || false,
128-
logLevel: "silent",
129-
loader: { ".woff": "empty", ".woff2": "empty" },
130-
plugins: [
131-
{
132-
name: "font-resolver",
133-
setup(fontBuild) {
134-
fontBuild.onResolve(...fontResolverArgs);
135-
},
132+
/**
133+
* @type {BuildOptions}
134+
*/
135+
const buildOptions = {
136+
stdin: {
137+
contents: cssContent,
138+
resolveDir: dirname(args.path),
139+
loader: "css",
140+
},
141+
metafile: true,
142+
bundle: true,
143+
write: false,
144+
minify: build.initialOptions.minify || false,
145+
logLevel: "silent",
146+
loader: { ".woff": "empty", ".woff2": "empty" },
147+
plugins: [
148+
{
149+
name: "font-resolver",
150+
setup(fontBuild) {
151+
fontBuild.onResolve(...fontResolverArgs);
136152
},
137-
],
138-
});
153+
},
154+
],
155+
};
139156

140-
disposables.set(args.path, context);
157+
if (!watch) {
158+
const result = await build.esbuild.build(buildOptions);
159+
const bundledCSS = result.outputFiles?.[0].text;
160+
161+
return {
162+
contents: bundledCSS,
163+
loader: "text",
164+
};
141165
}
142166

143-
await context.cancel();
167+
if (!context) {
168+
const relativePath = relative(absWorkingDir, args.path);
169+
logger.debug(`Watching ${relativePath}`);
170+
context = await build.esbuild.context(buildOptions);
171+
172+
disposables.set(args.path, context);
173+
} else {
174+
await context.cancel();
175+
}
144176

145177
// Resolve the CSS content by bundling it with ESBuild.
146178
const result = await context.rebuild();

web/paths/node.js

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,6 @@ import { fileURLToPath } from "node:url";
99

1010
import { DistDirectoryName } from "#paths";
1111

12-
import { resolvePackage } from "@goauthentik/core/paths/node";
13-
1412
const relativeDirname = dirname(fileURLToPath(import.meta.url));
1513

1614
//#region Base paths
@@ -48,8 +46,6 @@ export const DistDirectory = /** @type {`${WebPackageIdentifier}/${DistDirectory
4846
* Matches the type defined in the ESBuild context.
4947
*/
5048

51-
const patternflyPath = resolvePackage("@patternfly/patternfly", import.meta);
52-
5349
/**
5450
* Entry points available for building.
5551
*

web/scripts/build-web.mjs

Lines changed: 51 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import { ConsoleLogger } from "#logger/node";
1919
import { DistDirectory, EntryPoint, PackageRoot } from "#paths/node";
2020

2121
import { NodeEnvironment } from "@goauthentik/core/environment/node";
22-
import { MonoRepoRoot, resolvePackage } from "@goauthentik/core/paths/node";
22+
import { MonoRepoRoot } from "@goauthentik/core/paths/node";
2323
import { BuildIdentifier } from "@goauthentik/core/version/node";
2424

2525
import { deepmerge } from "deepmerge-ts";
@@ -37,8 +37,6 @@ const publicBundledDefinitions = Object.fromEntries(
3737
);
3838
logger.info(publicBundledDefinitions, "Bundle definitions");
3939

40-
const patternflyPath = resolvePackage("@patternfly/patternfly", import.meta);
41-
4240
/**
4341
* @type {Readonly<BuildOptions>}
4442
*/
@@ -53,6 +51,7 @@ const BASE_ESBUILD_OPTIONS = {
5351
minify: NodeEnvironment === "production",
5452
legalComments: "external",
5553
splitting: true,
54+
color: !process.env.NO_COLOR,
5655
treeShaking: true,
5756
tsconfig: path.resolve(PackageRoot, "tsconfig.build.json"),
5857
loader: {
@@ -80,7 +79,6 @@ const BASE_ESBUILD_OPTIONS = {
8079
},
8180
],
8281
}),
83-
styleLoaderPlugin(),
8482
mdxPlugin({
8583
root: MonoRepoRoot,
8684
}),
@@ -140,9 +138,11 @@ function doHelp() {
140138
process.exit(0);
141139
}
142140

141+
/**
142+
*
143+
* @returns {Promise<() => Promise<void>>} dispose
144+
*/
143145
async function doWatch() {
144-
const { promise, resolve, reject } = Promise.withResolvers();
145-
146146
logger.info(`🤖 Watching entry points:\n\t${Object.keys(EntryPoint).join("\n\t")}`);
147147

148148
const entryPoints = Object.values(EntryPoint);
@@ -158,7 +158,7 @@ async function doWatch() {
158158

159159
const buildOptions = createESBuildOptions({
160160
entryPoints,
161-
plugins: developmentPlugins,
161+
plugins: [...developmentPlugins, styleLoaderPlugin({ logger, watch: true })],
162162
});
163163

164164
const buildContext = await esbuild.context(buildOptions);
@@ -176,34 +176,23 @@ async function doWatch() {
176176
logger.info(`🔓 ${httpURL.href}`);
177177
logger.info(`🔒 ${httpsURL.href}`);
178178

179-
let disposing = false;
180-
181-
const delegateShutdown = () => {
179+
return () => {
182180
logger.flush();
183-
console.log("");
181+
console.info("");
182+
console.info("🛑 Stopping file watcher...");
184183

185-
// We prevent multiple attempts to dispose the context
186-
// because ESBuild will repeatedly restart its internal clean-up logic.
187-
// However, sending a second SIGINT will still exit the process immediately.
188-
if (disposing) return;
189-
190-
disposing = true;
191-
192-
return buildContext.dispose().then(resolve).catch(reject);
184+
return buildContext.dispose();
193185
};
194-
195-
process.on("SIGINT", delegateShutdown);
196-
197-
return promise;
198186
}
199187

200188
async function doBuild() {
201-
logger.info(`🤖 Watching entry points:\n\t${Object.keys(EntryPoint).join("\n\t")}`);
189+
logger.info(`🤖 Building entry points:\n\t${Object.keys(EntryPoint).join("\n\t")}`);
202190

203191
const entryPoints = Object.values(EntryPoint);
204192

205193
const buildOptions = createESBuildOptions({
206194
entryPoints,
195+
plugins: [styleLoaderPlugin({ logger })],
207196
});
208197

209198
await esbuild.build(buildOptions);
@@ -245,10 +234,43 @@ await cleanDistDirectory()
245234
// ---
246235
.then(() =>
247236
delegateCommand()
248-
.then(() => {
249-
process.exit(0);
237+
.then((dispose) => {
238+
if (!dispose) {
239+
process.exit(0);
240+
}
241+
242+
/**
243+
* @type {Promise<void>}
244+
*/
245+
const signalListener = new Promise((resolve) => {
246+
// We prevent multiple attempts to dispose the context
247+
// because ESBuild will repeatedly restart its internal clean-up logic.
248+
// However, sending a second SIGINT will still exit the process immediately.
249+
let signalCount = 0;
250+
251+
process.on("SIGINT", () => {
252+
if (signalCount > 3) {
253+
// Something is taking too long and the user wants to exit now.
254+
console.log("🛑 Forcing exit...");
255+
process.exit(0);
256+
}
257+
});
258+
259+
process.once("SIGINT", () => {
260+
signalCount++;
261+
262+
dispose().finally(() => {
263+
console.log("✅ Done!");
264+
265+
resolve();
266+
});
267+
});
268+
269+
logger.info("🚪 Press Ctrl+C to exit.");
270+
});
271+
272+
return signalListener;
250273
})
251-
.catch(() => {
252-
process.exit(1);
253-
}),
274+
.then(() => process.exit(0))
275+
.catch(() => process.exit(1)),
254276
);

0 commit comments

Comments
 (0)