Skip to content

Commit 9eaa398

Browse files
committed
perf: move stylesheet processing into a worker pool
Stylesheets will now be processed using a worker pool This allows up to four stylesheets to be processed in parallel and keeps the main thread available for other build tasks. `NG_BUILD_MAX_WORKERS` environment variable can be used to comfigure the limit of workers used.
1 parent caf0fee commit 9eaa398

File tree

7 files changed

+325
-214
lines changed

7 files changed

+325
-214
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
"jsonc-parser": "^3.2.0",
5151
"less": "^4.1.3",
5252
"ora": "^5.1.0",
53+
"piscina": "^3.2.0",
5354
"postcss": "^8.4.16",
5455
"postcss-url": "^10.1.3",
5556
"rollup": "^3.0.0",

src/lib/ng-package/entry-point/compile-ngc.transform.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,12 @@ export const compileNgcTransformFactory = (
2020
discardStdin: false,
2121
});
2222

23-
try {
24-
const entryPoints: EntryPointNode[] = graph.filter(isEntryPoint);
25-
const entryPoint: EntryPointNode = entryPoints.find(isEntryPointInProgress());
26-
const ngPackageNode: PackageNode = graph.find(isPackage);
27-
const projectBasePath = ngPackageNode.data.primary.basePath;
23+
const entryPoints: EntryPointNode[] = graph.filter(isEntryPoint);
24+
const entryPoint: EntryPointNode = entryPoints.find(isEntryPointInProgress());
25+
const ngPackageNode: PackageNode = graph.find(isPackage);
26+
const projectBasePath = ngPackageNode.data.primary.basePath;
2827

28+
try {
2929
// Add paths mappings for dependencies
3030
const tsConfig = setDependenciesTsConfigPaths(entryPoint.data.tsConfig, entryPoints);
3131

@@ -74,6 +74,10 @@ export const compileNgcTransformFactory = (
7474
} catch (error) {
7575
spinner.fail();
7676
throw error;
77+
} finally {
78+
if (!options.watch) {
79+
entryPoint.cache.stylesheetProcessor?.destroy();
80+
}
7781
}
7882

7983
spinner.succeed();

src/lib/ng-package/package.transform.ts

Lines changed: 24 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
11
import { DepGraph } from 'dependency-graph';
2-
import { NEVER, Observable, from, of as observableOf, pipe } from 'rxjs';
2+
import { NEVER, Observable, finalize, from, of as observableOf, pipe } from 'rxjs';
33
import {
44
catchError,
55
concatMap,
66
debounceTime,
77
defaultIfEmpty,
88
filter,
99
map,
10-
mapTo,
1110
startWith,
1211
switchMap,
1312
takeLast,
@@ -61,48 +60,30 @@ export const packageTransformFactory =
6160
entryPointTransform: Transform,
6261
) =>
6362
(source$: Observable<BuildGraph>): Observable<BuildGraph> => {
64-
const pkgUri = ngUrl(project);
63+
log.info(`Building Angular Package`);
6564

6665
const buildTransform = options.watch
6766
? watchTransformFactory(project, options, analyseSourcesTransform, entryPointTransform)
6867
: buildTransformFactory(project, analyseSourcesTransform, entryPointTransform);
6968

69+
const pkgUri = ngUrl(project);
70+
const ngPkg = new PackageNode(pkgUri);
71+
7072
return source$.pipe(
71-
tap(() => log.info(`Building Angular Package`)),
7273
// Discover packages and entry points
73-
switchMap(graph => {
74-
const pkg = discoverPackages({ project });
75-
76-
return from(pkg).pipe(
77-
map(value => {
78-
const ngPkg = new PackageNode(pkgUri);
79-
ngPkg.data = value;
80-
81-
return graph.put(ngPkg);
82-
}),
83-
);
84-
}),
8574
// Clean the primary dest folder (should clean all secondary sub-directory, as well)
86-
switchMap(
87-
async graph => {
88-
const { dest, deleteDestPath } = graph.get(pkgUri).data;
75+
switchMap(async graph => {
76+
ngPkg.data = await discoverPackages({ project });
8977

90-
if (deleteDestPath) {
91-
try {
92-
await rmdir(dest, { recursive: true });
93-
} catch {}
94-
}
95-
},
96-
(graph, _) => graph,
97-
),
98-
// Add entry points to graph
99-
map(graph => {
100-
const foundNode = graph.get(pkgUri);
101-
if (!isPackage(foundNode)) {
102-
return graph;
78+
graph.put(ngPkg);
79+
const { dest, deleteDestPath } = ngPkg.data;
80+
81+
if (deleteDestPath) {
82+
try {
83+
await rmdir(dest, { recursive: true });
84+
} catch {}
10385
}
10486

105-
const ngPkg: PackageNode = foundNode;
10687
const entryPoints = [ngPkg.data.primary, ...ngPkg.data.secondaries].map(entryPoint => {
10788
const { destinationFiles, moduleId } = entryPoint;
10889
const node = new EntryPointNode(
@@ -118,12 +99,20 @@ export const packageTransformFactory =
11899
return node;
119100
});
120101

102+
// Add entry points to graph
121103
return graph.put(entryPoints);
122104
}),
123105
// Initialize the tsconfig for each entry point
124106
initTsConfigTransform,
125107
// perform build
126108
buildTransform,
109+
finalize(() => {
110+
for (const node of ngPkg.dependents) {
111+
if (node instanceof EntryPointNode) {
112+
node.cache.stylesheetProcessor?.destroy();
113+
}
114+
}
115+
}),
127116
);
128117
};
129118

@@ -190,7 +179,7 @@ const watchTransformFactory =
190179
debounceTime(200),
191180
tap(() => log.msg(FileChangeDetected)),
192181
startWith(undefined),
193-
mapTo(graph),
182+
map(() => graph),
194183
);
195184
}),
196185
switchMap(graph => {
@@ -264,7 +253,7 @@ const scheduleEntryPoints = (epTransform: Transform): Transform =>
264253
observableOf(ep).pipe(
265254
// Mark the entry point as 'in-progress'
266255
tap(entryPoint => (entryPoint.state = STATE_IN_PROGRESS)),
267-
mapTo(graph),
256+
map(() => graph),
268257
epTransform,
269258
),
270259
),
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
import autoprefixer from 'autoprefixer';
2+
import { extname, relative } from 'node:path';
3+
import { pathToFileURL } from 'node:url';
4+
import { workerData } from 'node:worker_threads';
5+
import postcss from 'postcss';
6+
import postcssUrl from 'postcss-url';
7+
import { EsbuildExecutor } from '../esbuild/esbuild-executor';
8+
import { generateKey, readCacheEntry, saveCacheEntry } from '../utils/cache';
9+
import * as log from '../utils/log';
10+
import { CssUrl } from './stylesheet-processor';
11+
12+
const { tailwindConfigPath, projectBasePath, browserslistData, targets, cssUrl, styleIncludePaths } = workerData as {
13+
tailwindConfigPath: string | undefined;
14+
browserslistData: string;
15+
targets: string[];
16+
projectBasePath: string;
17+
cssUrl: CssUrl;
18+
styleIncludePaths: string[];
19+
cacheDirectory: string | undefined;
20+
};
21+
22+
let cacheDirectory = workerData.cacheDirectory;
23+
let postCssProcessor: ReturnType<typeof postcss>;
24+
let esbuild: EsbuildExecutor;
25+
26+
interface RenderRequest {
27+
content: string;
28+
filePath: string;
29+
}
30+
31+
async function render({ content, filePath }: RenderRequest): Promise<string> {
32+
let key: string | undefined;
33+
if (cacheDirectory && !content.includes('@import') && !content.includes('@use')) {
34+
// No transitive deps, we can cache more aggressively.
35+
key = await generateKey(content, ...browserslistData);
36+
const result = await readCacheEntry(cacheDirectory, key);
37+
if (result) {
38+
result.warnings.forEach(msg => log.warn(msg));
39+
40+
return result.css;
41+
}
42+
}
43+
44+
// Render pre-processor language (sass, styl, less)
45+
const renderedCss = await renderCss(filePath, content);
46+
47+
// We cannot cache CSS re-rendering phase, because a transitive dependency via (@import) can case different CSS output.
48+
// Example a change in a mixin or SCSS variable.
49+
if (!key) {
50+
key = await generateKey(renderedCss, ...browserslistData);
51+
}
52+
53+
if (cacheDirectory) {
54+
const cachedResult = await readCacheEntry(cacheDirectory, key);
55+
if (cachedResult) {
56+
cachedResult.warnings.forEach(msg => log.warn(msg));
57+
58+
return cachedResult.css;
59+
}
60+
}
61+
62+
// Render postcss (autoprefixing and friends)
63+
const result = await postCssProcessor.process(renderedCss, {
64+
from: filePath,
65+
to: filePath.replace(extname(filePath), '.css'),
66+
});
67+
68+
const warnings = result.warnings().map(w => w.toString());
69+
const { code, warnings: esBuildWarnings } = await esbuild.transform(result.css, {
70+
loader: 'css',
71+
minify: true,
72+
target: targets,
73+
sourcefile: filePath,
74+
});
75+
76+
if (esBuildWarnings.length > 0) {
77+
warnings.push(...(await esbuild.formatMessages(esBuildWarnings, { kind: 'warning' })));
78+
}
79+
80+
if (cacheDirectory) {
81+
await saveCacheEntry(
82+
cacheDirectory,
83+
key,
84+
JSON.stringify({
85+
css: code,
86+
warnings,
87+
}),
88+
);
89+
}
90+
91+
warnings.forEach(msg => log.warn(msg));
92+
93+
return code;
94+
}
95+
96+
async function renderCss(filePath: string, css: string): Promise<string> {
97+
const ext = extname(filePath);
98+
99+
switch (ext) {
100+
case '.sass':
101+
case '.scss': {
102+
return (await import('sass')).compileString(css, {
103+
url: pathToFileURL(filePath),
104+
syntax: '.sass' === ext ? 'indented' : 'scss',
105+
loadPaths: styleIncludePaths,
106+
}).css;
107+
}
108+
case '.less': {
109+
const { css: content } = await (
110+
await import('less')
111+
).default.render(css, {
112+
filename: filePath,
113+
math: 'always',
114+
javascriptEnabled: true,
115+
paths: styleIncludePaths,
116+
});
117+
118+
return content;
119+
}
120+
121+
case '.css':
122+
default:
123+
return css;
124+
}
125+
}
126+
127+
function getTailwindPlugin() {
128+
// Attempt to setup Tailwind CSS
129+
// Only load Tailwind CSS plugin if configuration file was found.
130+
// This acts as a guard to ensure the project actually wants to use Tailwind CSS.
131+
// The package may be unknowningly present due to a third-party transitive package dependency.
132+
if (tailwindConfigPath) {
133+
let tailwindPackagePath;
134+
try {
135+
tailwindPackagePath = require.resolve('tailwindcss', { paths: [projectBasePath] });
136+
} catch {
137+
const relativeTailwindConfigPath = relative(projectBasePath, tailwindConfigPath);
138+
log.warn(
139+
`Tailwind CSS configuration file found (${relativeTailwindConfigPath})` +
140+
` but the 'tailwindcss' package is not installed.` +
141+
` To enable Tailwind CSS, please install the 'tailwindcss' package.`,
142+
);
143+
}
144+
145+
if (tailwindPackagePath) {
146+
return require(tailwindPackagePath)({ config: tailwindConfigPath });
147+
}
148+
}
149+
}
150+
151+
async function initialize() {
152+
const postCssPlugins = [];
153+
const tailwinds = getTailwindPlugin();
154+
if (tailwinds) {
155+
postCssPlugins.push(tailwinds);
156+
cacheDirectory = undefined;
157+
}
158+
159+
if (cssUrl !== CssUrl.none) {
160+
postCssPlugins.push(postcssUrl({ url: cssUrl }));
161+
}
162+
163+
postCssPlugins.push(
164+
autoprefixer({
165+
ignoreUnknownVersions: true,
166+
overrideBrowserslist: browserslistData,
167+
}),
168+
);
169+
170+
postCssProcessor = postcss(postCssPlugins);
171+
172+
esbuild = new EsbuildExecutor();
173+
174+
// Return the render function for use
175+
return render;
176+
}
177+
178+
/**
179+
* The default export will be the promise returned by the initialize function.
180+
* This is awaited by piscina prior to using the Worker.
181+
*/
182+
export default initialize();

0 commit comments

Comments
 (0)