Skip to content

Commit

Permalink
perf: move stylesheet processing into a worker pool
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
alan-agius4 committed Dec 21, 2022
1 parent caf0fee commit 9eaa398
Show file tree
Hide file tree
Showing 7 changed files with 325 additions and 214 deletions.
1 change: 1 addition & 0 deletions package.json
Expand Up @@ -50,6 +50,7 @@
"jsonc-parser": "^3.2.0",
"less": "^4.1.3",
"ora": "^5.1.0",
"piscina": "^3.2.0",
"postcss": "^8.4.16",
"postcss-url": "^10.1.3",
"rollup": "^3.0.0",
Expand Down
14 changes: 9 additions & 5 deletions src/lib/ng-package/entry-point/compile-ngc.transform.ts
Expand Up @@ -20,12 +20,12 @@ export const compileNgcTransformFactory = (
discardStdin: false,
});

try {
const entryPoints: EntryPointNode[] = graph.filter(isEntryPoint);
const entryPoint: EntryPointNode = entryPoints.find(isEntryPointInProgress());
const ngPackageNode: PackageNode = graph.find(isPackage);
const projectBasePath = ngPackageNode.data.primary.basePath;
const entryPoints: EntryPointNode[] = graph.filter(isEntryPoint);
const entryPoint: EntryPointNode = entryPoints.find(isEntryPointInProgress());
const ngPackageNode: PackageNode = graph.find(isPackage);
const projectBasePath = ngPackageNode.data.primary.basePath;

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

Expand Down Expand Up @@ -74,6 +74,10 @@ export const compileNgcTransformFactory = (
} catch (error) {
spinner.fail();
throw error;
} finally {
if (!options.watch) {
entryPoint.cache.stylesheetProcessor?.destroy();
}
}

spinner.succeed();
Expand Down
59 changes: 24 additions & 35 deletions src/lib/ng-package/package.transform.ts
@@ -1,13 +1,12 @@
import { DepGraph } from 'dependency-graph';
import { NEVER, Observable, from, of as observableOf, pipe } from 'rxjs';
import { NEVER, Observable, finalize, from, of as observableOf, pipe } from 'rxjs';
import {
catchError,
concatMap,
debounceTime,
defaultIfEmpty,
filter,
map,
mapTo,
startWith,
switchMap,
takeLast,
Expand Down Expand Up @@ -61,48 +60,30 @@ export const packageTransformFactory =
entryPointTransform: Transform,
) =>
(source$: Observable<BuildGraph>): Observable<BuildGraph> => {
const pkgUri = ngUrl(project);
log.info(`Building Angular Package`);

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

const pkgUri = ngUrl(project);
const ngPkg = new PackageNode(pkgUri);

return source$.pipe(
tap(() => log.info(`Building Angular Package`)),
// Discover packages and entry points
switchMap(graph => {
const pkg = discoverPackages({ project });

return from(pkg).pipe(
map(value => {
const ngPkg = new PackageNode(pkgUri);
ngPkg.data = value;

return graph.put(ngPkg);
}),
);
}),
// Clean the primary dest folder (should clean all secondary sub-directory, as well)
switchMap(
async graph => {
const { dest, deleteDestPath } = graph.get(pkgUri).data;
switchMap(async graph => {
ngPkg.data = await discoverPackages({ project });

if (deleteDestPath) {
try {
await rmdir(dest, { recursive: true });
} catch {}
}
},
(graph, _) => graph,
),
// Add entry points to graph
map(graph => {
const foundNode = graph.get(pkgUri);
if (!isPackage(foundNode)) {
return graph;
graph.put(ngPkg);
const { dest, deleteDestPath } = ngPkg.data;

if (deleteDestPath) {
try {
await rmdir(dest, { recursive: true });
} catch {}
}

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

// Add entry points to graph
return graph.put(entryPoints);
}),
// Initialize the tsconfig for each entry point
initTsConfigTransform,
// perform build
buildTransform,
finalize(() => {
for (const node of ngPkg.dependents) {
if (node instanceof EntryPointNode) {
node.cache.stylesheetProcessor?.destroy();
}
}
}),
);
};

Expand Down Expand Up @@ -190,7 +179,7 @@ const watchTransformFactory =
debounceTime(200),
tap(() => log.msg(FileChangeDetected)),
startWith(undefined),
mapTo(graph),
map(() => graph),
);
}),
switchMap(graph => {
Expand Down Expand Up @@ -264,7 +253,7 @@ const scheduleEntryPoints = (epTransform: Transform): Transform =>
observableOf(ep).pipe(
// Mark the entry point as 'in-progress'
tap(entryPoint => (entryPoint.state = STATE_IN_PROGRESS)),
mapTo(graph),
map(() => graph),
epTransform,
),
),
Expand Down
182 changes: 182 additions & 0 deletions src/lib/styles/stylesheet-processor-worker.ts
@@ -0,0 +1,182 @@
import autoprefixer from 'autoprefixer';
import { extname, relative } from 'node:path';
import { pathToFileURL } from 'node:url';
import { workerData } from 'node:worker_threads';
import postcss from 'postcss';
import postcssUrl from 'postcss-url';
import { EsbuildExecutor } from '../esbuild/esbuild-executor';
import { generateKey, readCacheEntry, saveCacheEntry } from '../utils/cache';
import * as log from '../utils/log';
import { CssUrl } from './stylesheet-processor';

const { tailwindConfigPath, projectBasePath, browserslistData, targets, cssUrl, styleIncludePaths } = workerData as {
tailwindConfigPath: string | undefined;
browserslistData: string;
targets: string[];
projectBasePath: string;
cssUrl: CssUrl;
styleIncludePaths: string[];
cacheDirectory: string | undefined;
};

let cacheDirectory = workerData.cacheDirectory;
let postCssProcessor: ReturnType<typeof postcss>;
let esbuild: EsbuildExecutor;

interface RenderRequest {
content: string;
filePath: string;
}

async function render({ content, filePath }: RenderRequest): Promise<string> {
let key: string | undefined;
if (cacheDirectory && !content.includes('@import') && !content.includes('@use')) {
// No transitive deps, we can cache more aggressively.
key = await generateKey(content, ...browserslistData);
const result = await readCacheEntry(cacheDirectory, key);
if (result) {
result.warnings.forEach(msg => log.warn(msg));

return result.css;
}
}

// Render pre-processor language (sass, styl, less)
const renderedCss = await renderCss(filePath, content);

// We cannot cache CSS re-rendering phase, because a transitive dependency via (@import) can case different CSS output.
// Example a change in a mixin or SCSS variable.
if (!key) {
key = await generateKey(renderedCss, ...browserslistData);
}

if (cacheDirectory) {
const cachedResult = await readCacheEntry(cacheDirectory, key);
if (cachedResult) {
cachedResult.warnings.forEach(msg => log.warn(msg));

return cachedResult.css;
}
}

// Render postcss (autoprefixing and friends)
const result = await postCssProcessor.process(renderedCss, {
from: filePath,
to: filePath.replace(extname(filePath), '.css'),
});

const warnings = result.warnings().map(w => w.toString());
const { code, warnings: esBuildWarnings } = await esbuild.transform(result.css, {
loader: 'css',
minify: true,
target: targets,
sourcefile: filePath,
});

if (esBuildWarnings.length > 0) {
warnings.push(...(await esbuild.formatMessages(esBuildWarnings, { kind: 'warning' })));
}

if (cacheDirectory) {
await saveCacheEntry(
cacheDirectory,
key,
JSON.stringify({
css: code,
warnings,
}),
);
}

warnings.forEach(msg => log.warn(msg));

return code;
}

async function renderCss(filePath: string, css: string): Promise<string> {
const ext = extname(filePath);

switch (ext) {
case '.sass':
case '.scss': {
return (await import('sass')).compileString(css, {
url: pathToFileURL(filePath),
syntax: '.sass' === ext ? 'indented' : 'scss',
loadPaths: styleIncludePaths,
}).css;
}
case '.less': {
const { css: content } = await (
await import('less')
).default.render(css, {
filename: filePath,
math: 'always',
javascriptEnabled: true,
paths: styleIncludePaths,
});

return content;
}

case '.css':
default:
return css;
}
}

function getTailwindPlugin() {
// Attempt to setup Tailwind CSS
// Only load Tailwind CSS plugin if configuration file was found.
// This acts as a guard to ensure the project actually wants to use Tailwind CSS.
// The package may be unknowningly present due to a third-party transitive package dependency.
if (tailwindConfigPath) {
let tailwindPackagePath;
try {
tailwindPackagePath = require.resolve('tailwindcss', { paths: [projectBasePath] });
} catch {
const relativeTailwindConfigPath = relative(projectBasePath, tailwindConfigPath);
log.warn(
`Tailwind CSS configuration file found (${relativeTailwindConfigPath})` +
` but the 'tailwindcss' package is not installed.` +
` To enable Tailwind CSS, please install the 'tailwindcss' package.`,
);
}

if (tailwindPackagePath) {
return require(tailwindPackagePath)({ config: tailwindConfigPath });
}
}
}

async function initialize() {
const postCssPlugins = [];
const tailwinds = getTailwindPlugin();
if (tailwinds) {
postCssPlugins.push(tailwinds);
cacheDirectory = undefined;
}

if (cssUrl !== CssUrl.none) {
postCssPlugins.push(postcssUrl({ url: cssUrl }));
}

postCssPlugins.push(
autoprefixer({
ignoreUnknownVersions: true,
overrideBrowserslist: browserslistData,
}),
);

postCssProcessor = postcss(postCssPlugins);

esbuild = new EsbuildExecutor();

// Return the render function for use
return render;
}

/**
* The default export will be the promise returned by the initialize function.
* This is awaited by piscina prior to using the Worker.
*/
export default initialize();

0 comments on commit 9eaa398

Please sign in to comment.