Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(@angular-devkit/build-angular): add assets option to server builder #24233

Merged
merged 2 commits into from Nov 18, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions goldens/public-api/angular_devkit/build_angular/index.md
Expand Up @@ -246,6 +246,7 @@ export interface ProtractorBuilderOptions {

// @public (undocumented)
export interface ServerBuilderOptions {
assets?: AssetPattern_3[];
deleteOutputPath?: boolean;
// @deprecated
deployUrl?: string;
Expand Down
Expand Up @@ -111,20 +111,6 @@ async function initialize(
getStylesConfig(wco),
]);

// Validate asset option values if processed directly
if (options.assets?.length && !adjustedOptions.assets?.length) {
normalizeAssetPatterns(
options.assets,
context.workspaceRoot,
projectRoot,
projectSourceRoot,
).forEach(({ output }) => {
if (output.startsWith('..')) {
throw new Error('An asset cannot be written to a location outside of the output path.');
}
});
}

let transformedConfig;
if (webpackConfigurationTransform) {
transformedConfig = await webpackConfigurationTransform(config);
Expand Down
Expand Up @@ -107,22 +107,17 @@ describeBuilder(buildWebpackBrowser, BROWSER_BUILDER_INFO, (harness) => {
harness.expectFile('dist/test.svg').toNotExist();
});

it('throws exception if asset path is not within project source root', async () => {
it('fail if asset path is not within project source root', async () => {
await harness.writeFile('test.svg', '<svg></svg>');

harness.useTarget('build', {
...BASE_OPTIONS,
assets: ['test.svg'],
});

const { result, error } = await harness.executeOnce({ outputLogsOnException: false });
const { result } = await harness.executeOnce();

expect(result).toBeUndefined();
expect(error).toEqual(
jasmine.objectContaining({
message: jasmine.stringMatching('path must start with the project source root'),
}),
);
expect(result?.error).toMatch('path must start with the project source root');

harness.expectFile('dist/test.svg').toNotExist();
});
Expand Down Expand Up @@ -364,23 +359,18 @@ describeBuilder(buildWebpackBrowser, BROWSER_BUILDER_INFO, (harness) => {
harness.expectFile('dist/subdirectory/test.svg').content.toBe('<svg></svg>');
});

it('throws exception if output option is not within project output path', async () => {
it('fails if output option is not within project output path', async () => {
await harness.writeFile('test.svg', '<svg></svg>');

harness.useTarget('build', {
...BASE_OPTIONS,
assets: [{ glob: 'test.svg', input: 'src', output: '..' }],
});

const { result, error } = await harness.executeOnce({ outputLogsOnException: false });
const { result } = await harness.executeOnce();

expect(result).toBeUndefined();
expect(error).toEqual(
jasmine.objectContaining({
message: jasmine.stringMatching(
'An asset cannot be written to a location outside of the output path',
),
}),
expect(result?.error).toMatch(
'An asset cannot be written to a location outside of the output path',
);

harness.expectFile('dist/test.svg').toNotExist();
Expand Down
104 changes: 78 additions & 26 deletions packages/angular_devkit/build_angular/src/builders/server/index.ts
Expand Up @@ -10,14 +10,22 @@ import { BuilderContext, BuilderOutput, createBuilder } from '@angular-devkit/ar
import { runWebpack } from '@angular-devkit/build-webpack';
import * as path from 'path';
import { Observable, from } from 'rxjs';
import { concatMap, map } from 'rxjs/operators';
import { concatMap } from 'rxjs/operators';
import webpack, { Configuration } from 'webpack';
import { ExecutionTransformer } from '../../transforms';
import { NormalizedBrowserBuilderSchema, deleteOutputDir } from '../../utils';
import {
NormalizedBrowserBuilderSchema,
deleteOutputDir,
normalizeAssetPatterns,
} from '../../utils';
import { colors } from '../../utils/color';
import { copyAssets } from '../../utils/copy-assets';
import { assertIsError } from '../../utils/error';
import { i18nInlineEmittedFiles } from '../../utils/i18n-inlining';
import { I18nOptions } from '../../utils/i18n-options';
import { ensureOutputPaths } from '../../utils/output-paths';
import { purgeStaleBuildCache } from '../../utils/purge-cache';
import { Spinner } from '../../utils/spinner';
import { assertCompatibleAngularVersion } from '../../utils/version';
import {
BrowserWebpackConfigOptions,
Expand Down Expand Up @@ -69,7 +77,7 @@ export function execute(
let outputPaths: undefined | Map<string, string>;

return from(initialize(options, context, transforms.webpackConfiguration)).pipe(
concatMap(({ config, i18n }) => {
concatMap(({ config, i18n, projectRoot, projectSourceRoot }) => {
return runWebpack(config, context, {
webpackFactory: require('webpack') as typeof webpack,
logging: (stats, config) => {
Expand All @@ -84,11 +92,43 @@ export function execute(
throw new Error('Webpack stats build result is required.');
}

let success = output.success;
if (success && i18n.shouldInline) {
outputPaths = ensureOutputPaths(baseOutputPath, i18n);
if (!output.success) {
return output;
}

success = await i18nInlineEmittedFiles(
const spinner = new Spinner();
spinner.enabled = options.progress !== false;
outputPaths = ensureOutputPaths(baseOutputPath, i18n);

// Copy assets
if (!options.watch && options.assets?.length) {
spinner.start('Copying assets...');
try {
await copyAssets(
normalizeAssetPatterns(
options.assets,
context.workspaceRoot,
projectRoot,
projectSourceRoot,
),
Array.from(outputPaths.values()),
context.workspaceRoot,
);
spinner.succeed('Copying assets complete.');
} catch (err) {
spinner.fail(colors.redBright('Copying of assets failed.'));
assertIsError(err);

return {
...output,
success: false,
error: 'Unable to copy assets: ' + err.message,
};
}
}

if (i18n.shouldInline) {
const success = await i18nInlineEmittedFiles(
context,
emittedFiles,
i18n,
Expand All @@ -98,15 +138,21 @@ export function execute(
outputPath,
options.i18nMissingTranslation,
);
if (!success) {
return {
...output,
success: false,
};
}
}

webpackStatsLogger(context.logger, webpackStats, config);

return { ...output, success };
return output;
}),
);
}),
map((output) => {
concatMap(async (output) => {
if (!output.success) {
return output as ServerBuilderOutput;
}
Expand Down Expand Up @@ -137,36 +183,42 @@ async function initialize(
): Promise<{
config: webpack.Configuration;
i18n: I18nOptions;
projectRoot: string;
projectSourceRoot?: string;
}> {
// Purge old build disk cache.
await purgeStaleBuildCache(context);

const browserslist = (await import('browserslist')).default;
const originalOutputPath = options.outputPath;
const { config, i18n } = await generateI18nBrowserWebpackConfigFromContext(
{
...options,
buildOptimizer: false,
aot: true,
platform: 'server',
} as NormalizedBrowserBuilderSchema,
context,
(wco) => {
// We use the platform to determine the JavaScript syntax output.
wco.buildOptions.supportedBrowsers ??= [];
wco.buildOptions.supportedBrowsers.push(...browserslist('maintained node versions'));

return [getPlatformServerExportsConfig(wco), getCommonConfig(wco), getStylesConfig(wco)];
},
);
// Assets are processed directly by the builder except when watching
const adjustedOptions = options.watch ? options : { ...options, assets: [] };

const { config, projectRoot, projectSourceRoot, i18n } =
await generateI18nBrowserWebpackConfigFromContext(
{
...adjustedOptions,
buildOptimizer: false,
aot: true,
platform: 'server',
} as NormalizedBrowserBuilderSchema,
context,
(wco) => {
// We use the platform to determine the JavaScript syntax output.
wco.buildOptions.supportedBrowsers ??= [];
wco.buildOptions.supportedBrowsers.push(...browserslist('maintained node versions'));

return [getPlatformServerExportsConfig(wco), getCommonConfig(wco), getStylesConfig(wco)];
},
);

if (options.deleteOutputPath) {
deleteOutputDir(context.workspaceRoot, originalOutputPath);
}

const transformedConfig = (await webpackConfigurationTransform?.(config)) ?? config;

return { config: transformedConfig, i18n };
return { config: transformedConfig, i18n, projectRoot, projectSourceRoot };
}

/**
Expand Down
Expand Up @@ -4,6 +4,14 @@
"title": "Universal Target",
"type": "object",
"properties": {
"assets": {
"type": "array",
"description": "List of static application assets.",
"default": [],
"items": {
"$ref": "#/definitions/assetPattern"
}
},
"main": {
"type": "string",
"description": "The name of the main entry-point file."
Expand Down Expand Up @@ -207,6 +215,44 @@
"additionalProperties": false,
"required": ["outputPath", "main", "tsConfig"],
"definitions": {
"assetPattern": {
"oneOf": [
{
"type": "object",
"properties": {
"followSymlinks": {
"type": "boolean",
"default": false,
"description": "Allow glob patterns to follow symlink directories. This allows subdirectories of the symlink to be searched."
},
"glob": {
"type": "string",
"description": "The pattern to match."
},
"input": {
"type": "string",
"description": "The input directory path in which to apply 'glob'. Defaults to the project root."
},
"ignore": {
"description": "An array of globs to ignore.",
"type": "array",
"items": {
"type": "string"
}
},
"output": {
"type": "string",
"description": "Absolute path within the output."
}
},
"additionalProperties": false,
"required": ["glob", "input", "output"]
},
{
"type": "string"
}
]
},
"fileReplacement": {
"oneOf": [
{
Expand Down