Skip to content

Commit 89ee842

Browse files
committed
feat(@angular-devkit/build-angular): add assets option to server builder
This commits adds the `assets` option to the server builder. This can be useful to copy server specific assets such as config files. Closes angular#24203
1 parent 6cc45f6 commit 89ee842

File tree

8 files changed

+524
-62
lines changed

8 files changed

+524
-62
lines changed

goldens/public-api/angular_devkit/build_angular/index.md

+1
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,7 @@ export interface ProtractorBuilderOptions {
246246

247247
// @public (undocumented)
248248
export interface ServerBuilderOptions {
249+
assets?: AssetPattern_3[];
249250
deleteOutputPath?: boolean;
250251
// @deprecated
251252
deployUrl?: string;

packages/angular_devkit/build_angular/src/builders/browser/index.ts

-14
Original file line numberDiff line numberDiff line change
@@ -111,20 +111,6 @@ async function initialize(
111111
getStylesConfig(wco),
112112
]);
113113

114-
// Validate asset option values if processed directly
115-
if (options.assets?.length && !adjustedOptions.assets?.length) {
116-
normalizeAssetPatterns(
117-
options.assets,
118-
context.workspaceRoot,
119-
projectRoot,
120-
projectSourceRoot,
121-
).forEach(({ output }) => {
122-
if (output.startsWith('..')) {
123-
throw new Error('An asset cannot be written to a location outside of the output path.');
124-
}
125-
});
126-
}
127-
128114
let transformedConfig;
129115
if (webpackConfigurationTransform) {
130116
transformedConfig = await webpackConfigurationTransform(config);

packages/angular_devkit/build_angular/src/builders/browser/tests/options/assets_spec.ts

+7-17
Original file line numberDiff line numberDiff line change
@@ -107,22 +107,17 @@ describeBuilder(buildWebpackBrowser, BROWSER_BUILDER_INFO, (harness) => {
107107
harness.expectFile('dist/test.svg').toNotExist();
108108
});
109109

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

113113
harness.useTarget('build', {
114114
...BASE_OPTIONS,
115115
assets: ['test.svg'],
116116
});
117117

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

120-
expect(result).toBeUndefined();
121-
expect(error).toEqual(
122-
jasmine.objectContaining({
123-
message: jasmine.stringMatching('path must start with the project source root'),
124-
}),
125-
);
120+
expect(result?.error).toMatch('path must start with the project source root');
126121

127122
harness.expectFile('dist/test.svg').toNotExist();
128123
});
@@ -364,23 +359,18 @@ describeBuilder(buildWebpackBrowser, BROWSER_BUILDER_INFO, (harness) => {
364359
harness.expectFile('dist/subdirectory/test.svg').content.toBe('<svg></svg>');
365360
});
366361

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

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

375-
const { result, error } = await harness.executeOnce({ outputLogsOnException: false });
370+
const { result } = await harness.executeOnce({ outputLogsOnException: false });
376371

377-
expect(result).toBeUndefined();
378-
expect(error).toEqual(
379-
jasmine.objectContaining({
380-
message: jasmine.stringMatching(
381-
'An asset cannot be written to a location outside of the output path',
382-
),
383-
}),
372+
expect(result?.error).toMatch(
373+
'An asset cannot be written to a location outside of the output path',
384374
);
385375

386376
harness.expectFile('dist/test.svg').toNotExist();

packages/angular_devkit/build_angular/src/builders/server/index.ts

+78-26
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,22 @@ import { BuilderContext, BuilderOutput, createBuilder } from '@angular-devkit/ar
1010
import { runWebpack } from '@angular-devkit/build-webpack';
1111
import * as path from 'path';
1212
import { Observable, from } from 'rxjs';
13-
import { concatMap, map } from 'rxjs/operators';
13+
import { concatMap } from 'rxjs/operators';
1414
import webpack, { Configuration } from 'webpack';
1515
import { ExecutionTransformer } from '../../transforms';
16-
import { NormalizedBrowserBuilderSchema, deleteOutputDir } from '../../utils';
16+
import {
17+
NormalizedBrowserBuilderSchema,
18+
deleteOutputDir,
19+
normalizeAssetPatterns,
20+
} from '../../utils';
21+
import { colors } from '../../utils/color';
22+
import { copyAssets } from '../../utils/copy-assets';
23+
import { assertIsError } from '../../utils/error';
1724
import { i18nInlineEmittedFiles } from '../../utils/i18n-inlining';
1825
import { I18nOptions } from '../../utils/i18n-options';
1926
import { ensureOutputPaths } from '../../utils/output-paths';
2027
import { purgeStaleBuildCache } from '../../utils/purge-cache';
28+
import { Spinner } from '../../utils/spinner';
2129
import { assertCompatibleAngularVersion } from '../../utils/version';
2230
import {
2331
BrowserWebpackConfigOptions,
@@ -69,7 +77,7 @@ export function execute(
6977
let outputPaths: undefined | Map<string, string>;
7078

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

87-
let success = output.success;
88-
if (success && i18n.shouldInline) {
89-
outputPaths = ensureOutputPaths(baseOutputPath, i18n);
95+
if (!output.success) {
96+
return output;
97+
}
9098

91-
success = await i18nInlineEmittedFiles(
99+
const spinner = new Spinner();
100+
spinner.enabled = options.progress !== false;
101+
outputPaths = ensureOutputPaths(baseOutputPath, i18n);
102+
103+
// Copy assets
104+
if (!options.watch && options.assets?.length) {
105+
spinner.start('Copying assets...');
106+
try {
107+
await copyAssets(
108+
normalizeAssetPatterns(
109+
options.assets,
110+
context.workspaceRoot,
111+
projectRoot,
112+
projectSourceRoot,
113+
),
114+
Array.from(outputPaths.values()),
115+
context.workspaceRoot,
116+
);
117+
spinner.succeed('Copying assets complete.');
118+
} catch (err) {
119+
spinner.fail(colors.redBright('Copying of assets failed.'));
120+
assertIsError(err);
121+
122+
return {
123+
...output,
124+
success: false,
125+
error: 'Unable to copy assets: ' + err.message,
126+
};
127+
}
128+
}
129+
130+
if (i18n.shouldInline) {
131+
const success = await i18nInlineEmittedFiles(
92132
context,
93133
emittedFiles,
94134
i18n,
@@ -98,15 +138,21 @@ export function execute(
98138
outputPath,
99139
options.i18nMissingTranslation,
100140
);
141+
if (!success) {
142+
return {
143+
...output,
144+
success: false,
145+
};
146+
}
101147
}
102148

103149
webpackStatsLogger(context.logger, webpackStats, config);
104150

105-
return { ...output, success };
151+
return output;
106152
}),
107153
);
108154
}),
109-
map((output) => {
155+
concatMap(async (output) => {
110156
if (!output.success) {
111157
return output as ServerBuilderOutput;
112158
}
@@ -137,36 +183,42 @@ async function initialize(
137183
): Promise<{
138184
config: webpack.Configuration;
139185
i18n: I18nOptions;
186+
projectRoot: string;
187+
projectSourceRoot?: string;
140188
}> {
141189
// Purge old build disk cache.
142190
await purgeStaleBuildCache(context);
143191

144192
const browserslist = (await import('browserslist')).default;
145193
const originalOutputPath = options.outputPath;
146-
const { config, i18n } = await generateI18nBrowserWebpackConfigFromContext(
147-
{
148-
...options,
149-
buildOptimizer: false,
150-
aot: true,
151-
platform: 'server',
152-
} as NormalizedBrowserBuilderSchema,
153-
context,
154-
(wco) => {
155-
// We use the platform to determine the JavaScript syntax output.
156-
wco.buildOptions.supportedBrowsers ??= [];
157-
wco.buildOptions.supportedBrowsers.push(...browserslist('maintained node versions'));
158-
159-
return [getPlatformServerExportsConfig(wco), getCommonConfig(wco), getStylesConfig(wco)];
160-
},
161-
);
194+
// Assets are processed directly by the builder except when watching
195+
const adjustedOptions = options.watch ? options : { ...options, assets: [] };
196+
197+
const { config, projectRoot, projectSourceRoot, i18n } =
198+
await generateI18nBrowserWebpackConfigFromContext(
199+
{
200+
...adjustedOptions,
201+
buildOptimizer: false,
202+
aot: true,
203+
platform: 'server',
204+
} as NormalizedBrowserBuilderSchema,
205+
context,
206+
(wco) => {
207+
// We use the platform to determine the JavaScript syntax output.
208+
wco.buildOptions.supportedBrowsers ??= [];
209+
wco.buildOptions.supportedBrowsers.push(...browserslist('maintained node versions'));
210+
211+
return [getPlatformServerExportsConfig(wco), getCommonConfig(wco), getStylesConfig(wco)];
212+
},
213+
);
162214

163215
if (options.deleteOutputPath) {
164216
deleteOutputDir(context.workspaceRoot, originalOutputPath);
165217
}
166218

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

169-
return { config: transformedConfig, i18n };
221+
return { config: transformedConfig, i18n, projectRoot, projectSourceRoot };
170222
}
171223

172224
/**

packages/angular_devkit/build_angular/src/builders/server/schema.json

+46
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,14 @@
44
"title": "Universal Target",
55
"type": "object",
66
"properties": {
7+
"assets": {
8+
"type": "array",
9+
"description": "List of static application assets.",
10+
"default": [],
11+
"items": {
12+
"$ref": "#/definitions/assetPattern"
13+
}
14+
},
715
"main": {
816
"type": "string",
917
"description": "The name of the main entry-point file."
@@ -207,6 +215,44 @@
207215
"additionalProperties": false,
208216
"required": ["outputPath", "main", "tsConfig"],
209217
"definitions": {
218+
"assetPattern": {
219+
"oneOf": [
220+
{
221+
"type": "object",
222+
"properties": {
223+
"followSymlinks": {
224+
"type": "boolean",
225+
"default": false,
226+
"description": "Allow glob patterns to follow symlink directories. This allows subdirectories of the symlink to be searched."
227+
},
228+
"glob": {
229+
"type": "string",
230+
"description": "The pattern to match."
231+
},
232+
"input": {
233+
"type": "string",
234+
"description": "The input directory path in which to apply 'glob'. Defaults to the project root."
235+
},
236+
"ignore": {
237+
"description": "An array of globs to ignore.",
238+
"type": "array",
239+
"items": {
240+
"type": "string"
241+
}
242+
},
243+
"output": {
244+
"type": "string",
245+
"description": "Absolute path within the output."
246+
}
247+
},
248+
"additionalProperties": false,
249+
"required": ["glob", "input", "output"]
250+
},
251+
{
252+
"type": "string"
253+
}
254+
]
255+
},
210256
"fileReplacement": {
211257
"oneOf": [
212258
{

0 commit comments

Comments
 (0)