Skip to content

Commit

Permalink
feat(@angular-devkit/build-angular): add assets option to server bu…
Browse files Browse the repository at this point in the history
…ilder

This commits adds the `assets` option to the server builder. This can be useful to copy server specific assets such as config files.

Closes #24203
  • Loading branch information
alan-agius4 authored and dgp1130 committed Nov 18, 2022
1 parent 78ce78c commit c29df69
Show file tree
Hide file tree
Showing 9 changed files with 528 additions and 66 deletions.
1 change: 1 addition & 0 deletions goldens/public-api/angular_devkit/build_angular/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,7 @@ export interface ProtractorBuilderOptions {

// @public (undocumented)
export interface ServerBuilderOptions {
assets?: AssetPattern_3[];
deleteOutputPath?: boolean;
// @deprecated
deployUrl?: string;
Expand Down
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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 @@ -212,6 +220,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

0 comments on commit c29df69

Please sign in to comment.