Skip to content

Commit 095f5ab

Browse files
alan-agius4dgp1130
authored andcommitted
feat(@angular-devkit/build-angular): add initial support for server bundle generation using esbuild
This commit adds initial support to generate the server bundle using esbuild as the underlying bundler.
1 parent 333da08 commit 095f5ab

File tree

15 files changed

+538
-186
lines changed

15 files changed

+538
-186
lines changed

Diff for: packages/angular_devkit/build_angular/src/builders/application/execute-build.ts

+24-3
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,15 @@
88

99
import { BuilderContext } from '@angular-devkit/architect';
1010
import { SourceFileCache } from '../../tools/esbuild/angular/compiler-plugin';
11-
import { createCodeBundleOptions } from '../../tools/esbuild/application-code-bundle';
11+
import { createBrowserCodeBundleOptions } from '../../tools/esbuild/browser-code-bundle';
1212
import { BundlerContext } from '../../tools/esbuild/bundler-context';
1313
import { ExecutionResult, RebuildState } from '../../tools/esbuild/bundler-execution-result';
1414
import { checkCommonJSModules } from '../../tools/esbuild/commonjs-checker';
1515
import { createGlobalScriptsBundleOptions } from '../../tools/esbuild/global-scripts';
1616
import { createGlobalStylesBundleOptions } from '../../tools/esbuild/global-styles';
1717
import { generateIndexHtml } from '../../tools/esbuild/index-html-generator';
1818
import { extractLicenses } from '../../tools/esbuild/license-extractor';
19+
import { createServerCodeBundleOptions } from '../../tools/esbuild/server-code-bundle';
1920
import {
2021
calculateEstimatedTransferSizes,
2122
logBuildStats,
@@ -39,6 +40,7 @@ export async function executeBuild(
3940
workspaceRoot,
4041
serviceWorker,
4142
optimizationOptions,
43+
serverEntryPoint,
4244
assets,
4345
indexHtmlOptions,
4446
cacheOptions,
@@ -55,12 +57,12 @@ export async function executeBuild(
5557
if (bundlerContexts === undefined) {
5658
bundlerContexts = [];
5759

58-
// Application code
60+
// Browser application code
5961
bundlerContexts.push(
6062
new BundlerContext(
6163
workspaceRoot,
6264
!!options.watch,
63-
createCodeBundleOptions(options, target, browsers, codeBundleCache),
65+
createBrowserCodeBundleOptions(options, target, browsers, codeBundleCache),
6466
),
6567
);
6668

@@ -93,6 +95,25 @@ export async function executeBuild(
9395
}
9496
}
9597
}
98+
99+
// Server application code
100+
if (serverEntryPoint) {
101+
bundlerContexts.push(
102+
new BundlerContext(
103+
workspaceRoot,
104+
!!options.watch,
105+
createServerCodeBundleOptions(
106+
options,
107+
// NOTE: earlier versions of Node.js are not supported due to unsafe promise patching.
108+
// See: https://github.com/angular/angular/pull/50552#issue-1737967592
109+
[...target, 'node18.13'],
110+
browsers,
111+
codeBundleCache,
112+
),
113+
() => false,
114+
),
115+
);
116+
}
96117
}
97118

98119
const bundlingResult = await BundlerContext.bundleAll(bundlerContexts);

Diff for: packages/angular_devkit/build_angular/src/builders/application/options.ts

+27-21
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export type NormalizedApplicationBuildOptions = Awaited<ReturnType<typeof normal
2525
/** Internal options hidden from builder schema but available when invoked programmatically. */
2626
interface InternalOptions {
2727
/**
28-
* Entry points to use for the compilation. Incompatible with `main`, which must not be provided. May be relative or absolute paths.
28+
* Entry points to use for the compilation. Incompatible with `browser`, which must not be provided. May be relative or absolute paths.
2929
* If given a relative path, it is resolved relative to the current workspace and will generate an output at the same relative location
3030
* in the output directory. If given an absolute path, the output will be generated in the root of the output directory with the same base
3131
* name.
@@ -42,7 +42,7 @@ interface InternalOptions {
4242
externalPackages?: boolean;
4343
}
4444

45-
/** Full set of options for `browser-esbuild` builder. */
45+
/** Full set of options for `application` builder. */
4646
export type ApplicationBuilderInternalOptions = Omit<
4747
ApplicationBuilderOptions & InternalOptions,
4848
'browser'
@@ -164,6 +164,13 @@ export async function normalizeOptions(
164164
};
165165
}
166166

167+
let serverEntryPoint: string | undefined;
168+
if (options.server) {
169+
serverEntryPoint = path.join(workspaceRoot, options.server);
170+
} else if (options.server === '') {
171+
throw new Error('`server` option cannot be an empty string.');
172+
}
173+
167174
// Initial options to keep
168175
const {
169176
allowedCommonJsDependencies,
@@ -182,7 +189,6 @@ export async function normalizeOptions(
182189
stylePreprocessorOptions,
183190
subresourceIntegrity,
184191
verbose,
185-
server,
186192
watch,
187193
progress = true,
188194
externalPackages,
@@ -210,7 +216,7 @@ export async function normalizeOptions(
210216
preserveSymlinks: preserveSymlinks ?? process.execArgv.includes('--preserve-symlinks'),
211217
stylePreprocessorOptions,
212218
subresourceIntegrity,
213-
server: !!server && path.join(workspaceRoot, server),
219+
serverEntryPoint,
214220
verbose,
215221
watch,
216222
workspaceRoot,
@@ -233,39 +239,39 @@ export async function normalizeOptions(
233239
}
234240

235241
/**
236-
* Normalize entry point options. To maintain compatibility with the legacy browser builder, we need a single `main` option which defines a
237-
* single entry point. However, we also want to support multiple entry points as an internal option. The two options are mutually exclusive
238-
* and if `main` is provided it will be used as the sole entry point. If `entryPoints` are provided, they will be used as the set of entry
239-
* points.
242+
* Normalize entry point options. To maintain compatibility with the legacy browser builder, we need a single `browser`
243+
* option which defines a single entry point. However, we also want to support multiple entry points as an internal option.
244+
* The two options are mutually exclusive and if `browser` is provided it will be used as the sole entry point.
245+
* If `entryPoints` are provided, they will be used as the set of entry points.
240246
*
241247
* @param workspaceRoot Path to the root of the Angular workspace.
242-
* @param main The `main` option pointing at the application entry point. While required per the schema file, it may be omitted by
248+
* @param browser The `browser` option pointing at the application entry point. While required per the schema file, it may be omitted by
243249
* programmatic usages of `browser-esbuild`.
244250
* @param entryPoints Set of entry points to use if provided.
245251
* @returns An object mapping entry point names to their file paths.
246252
*/
247253
function normalizeEntryPoints(
248254
workspaceRoot: string,
249-
main: string | undefined,
255+
browser: string | undefined,
250256
entryPoints: Set<string> = new Set(),
251257
): Record<string, string> {
252-
if (main === '') {
253-
throw new Error('`main` option cannot be an empty string.');
258+
if (browser === '') {
259+
throw new Error('`browser` option cannot be an empty string.');
254260
}
255261

256-
// `main` and `entryPoints` are mutually exclusive.
257-
if (main && entryPoints.size > 0) {
258-
throw new Error('Only one of `main` or `entryPoints` may be provided.');
262+
// `browser` and `entryPoints` are mutually exclusive.
263+
if (browser && entryPoints.size > 0) {
264+
throw new Error('Only one of `browser` or `entryPoints` may be provided.');
259265
}
260-
if (!main && entryPoints.size === 0) {
266+
if (!browser && entryPoints.size === 0) {
261267
// Schema should normally reject this case, but programmatic usages of the builder might make this mistake.
262-
throw new Error('Either `main` or at least one `entryPoints` value must be provided.');
268+
throw new Error('Either `browser` or at least one `entryPoints` value must be provided.');
263269
}
264270

265-
// Schema types force `main` to always be provided, but it may be omitted when the builder is invoked programmatically.
266-
if (main) {
267-
// Use `main` alone.
268-
return { 'main': path.join(workspaceRoot, main) };
271+
// Schema types force `browser` to always be provided, but it may be omitted when the builder is invoked programmatically.
272+
if (browser) {
273+
// Use `browser` alone.
274+
return { 'main': path.join(workspaceRoot, browser) };
269275
} else {
270276
// Use `entryPoints` alone.
271277
const entryPointPaths: Record<string, string> = {};

Diff for: packages/angular_devkit/build_angular/src/builders/application/tests/options/browser_spec.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => {
6666
browser: '',
6767
});
6868

69-
const { result, error } = await harness.executeOnce();
69+
const { result, error } = await harness.executeOnce({ outputLogsOnException: false });
7070
expect(result).toBeUndefined();
7171

7272
expect(error?.message).toContain('cannot be an empty string');
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import { buildApplication } from '../../index';
10+
import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup';
11+
12+
describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => {
13+
beforeEach(async () => {
14+
await harness.modifyFile('src/tsconfig.app.json', (content) => {
15+
const tsConfig = JSON.parse(content);
16+
tsConfig.files ??= [];
17+
tsConfig.files.push('main.server.ts');
18+
19+
return JSON.stringify(tsConfig);
20+
});
21+
});
22+
23+
describe('Option: "server"', () => {
24+
it('uses a provided TypeScript file', async () => {
25+
harness.useTarget('build', {
26+
...BASE_OPTIONS,
27+
server: 'src/main.server.ts',
28+
});
29+
30+
const { result } = await harness.executeOnce();
31+
expect(result?.success).toBeTrue();
32+
33+
harness.expectFile('dist/server.mjs').toExist();
34+
harness.expectFile('dist/main.js').toExist();
35+
});
36+
37+
it('uses a provided JavaScript file', async () => {
38+
await harness.writeFile('src/server.js', `console.log('server');`);
39+
40+
harness.useTarget('build', {
41+
...BASE_OPTIONS,
42+
server: 'src/server.js',
43+
});
44+
45+
const { result } = await harness.executeOnce();
46+
expect(result?.success).toBeTrue();
47+
48+
harness.expectFile('dist/server.mjs').content.toContain('console.log("server")');
49+
});
50+
51+
it('fails and shows an error when file does not exist', async () => {
52+
harness.useTarget('build', {
53+
...BASE_OPTIONS,
54+
server: 'src/missing.ts',
55+
});
56+
57+
const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false });
58+
59+
expect(result?.success).toBeFalse();
60+
expect(logs).toContain(
61+
jasmine.objectContaining({ message: jasmine.stringMatching('Could not resolve "') }),
62+
);
63+
64+
harness.expectFile('dist/main.js').toNotExist();
65+
harness.expectFile('dist/server.mjs').toNotExist();
66+
});
67+
68+
it('throws an error when given an empty string', async () => {
69+
harness.useTarget('build', {
70+
...BASE_OPTIONS,
71+
server: '',
72+
});
73+
74+
const { result, error } = await harness.executeOnce({ outputLogsOnException: false });
75+
expect(result).toBeUndefined();
76+
77+
expect(error?.message).toContain('cannot be an empty string');
78+
});
79+
80+
it('resolves an absolute path as relative inside the workspace root', async () => {
81+
await harness.writeFile('file.mjs', `console.log('Hello!');`);
82+
83+
harness.useTarget('build', {
84+
...BASE_OPTIONS,
85+
server: '/file.mjs',
86+
});
87+
88+
const { result } = await harness.executeOnce();
89+
expect(result?.success).toBeTrue();
90+
91+
// Always uses the name `server.mjs` for the `server` option.
92+
harness.expectFile('dist/server.mjs').toExist();
93+
});
94+
});
95+
});

Diff for: packages/angular_devkit/build_angular/src/builders/browser-esbuild/tests/options/main_spec.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ describeBuilder(buildEsbuildBrowser, BROWSER_BUILDER_INFO, (harness) => {
6666
main: '',
6767
});
6868

69-
const { result, error } = await harness.executeOnce();
69+
const { result, error } = await harness.executeOnce({ outputLogsOnException: false });
7070
expect(result).toBeUndefined();
7171

7272
expect(error?.message).toContain('cannot be an empty string');

Diff for: packages/angular_devkit/build_angular/src/tools/esbuild/angular/angular-compilation.ts renamed to packages/angular_devkit/build_angular/src/tools/esbuild/angular/compilation/angular-compilation.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,9 @@
88

99
import type ng from '@angular/compiler-cli';
1010
import type ts from 'typescript';
11-
import { loadEsmModule } from '../../../utils/load-esm';
12-
import { profileSync } from '../profiling';
13-
import type { AngularHostOptions } from './angular-host';
11+
import { loadEsmModule } from '../../../../utils/load-esm';
12+
import { profileSync } from '../../profiling';
13+
import type { AngularHostOptions } from '../angular-host';
1414

1515
export interface EmitFileResult {
1616
filename: string;

Diff for: packages/angular_devkit/build_angular/src/tools/esbuild/angular/aot-compilation.ts renamed to packages/angular_devkit/build_angular/src/tools/esbuild/angular/compilation/aot-compilation.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,13 @@
99
import type ng from '@angular/compiler-cli';
1010
import assert from 'node:assert';
1111
import ts from 'typescript';
12-
import { profileAsync, profileSync } from '../profiling';
13-
import { AngularCompilation, EmitFileResult } from './angular-compilation';
12+
import { profileAsync, profileSync } from '../../profiling';
1413
import {
1514
AngularHostOptions,
1615
createAngularCompilerHost,
1716
ensureSourceFileVersions,
18-
} from './angular-host';
17+
} from '../angular-host';
18+
import { AngularCompilation, EmitFileResult } from './angular-compilation';
1919

2020
// Temporary deep import for transformer support
2121
// TODO: Move these to a private exports location or move the implementation into this package.

Diff for: packages/angular_devkit/build_angular/src/tools/esbuild/angular/jit-compilation.ts renamed to packages/angular_devkit/build_angular/src/tools/esbuild/angular/compilation/jit-compilation.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,10 @@
99
import type ng from '@angular/compiler-cli';
1010
import assert from 'node:assert';
1111
import ts from 'typescript';
12-
import { profileSync } from '../profiling';
12+
import { profileSync } from '../../profiling';
13+
import { AngularHostOptions, createAngularCompilerHost } from '../angular-host';
14+
import { createJitResourceTransformer } from '../jit-resource-transformer';
1315
import { AngularCompilation, EmitFileResult } from './angular-compilation';
14-
import { AngularHostOptions, createAngularCompilerHost } from './angular-host';
15-
import { createJitResourceTransformer } from './jit-resource-transformer';
1616

1717
class JitCompilationState {
1818
constructor(
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import type ng from '@angular/compiler-cli';
10+
import ts from 'typescript';
11+
import { AngularHostOptions } from '../angular-host';
12+
import { AngularCompilation } from './angular-compilation';
13+
14+
export class NoopCompilation extends AngularCompilation {
15+
async initialize(
16+
tsconfig: string,
17+
hostOptions: AngularHostOptions,
18+
compilerOptionsTransformer?: (compilerOptions: ng.CompilerOptions) => ng.CompilerOptions,
19+
): Promise<{
20+
affectedFiles: ReadonlySet<ts.SourceFile>;
21+
compilerOptions: ng.CompilerOptions;
22+
referencedFiles: readonly string[];
23+
}> {
24+
// Load the compiler configuration and transform as needed
25+
const { options: originalCompilerOptions } = await this.loadConfiguration(tsconfig);
26+
const compilerOptions =
27+
compilerOptionsTransformer?.(originalCompilerOptions) ?? originalCompilerOptions;
28+
29+
return { affectedFiles: new Set(), compilerOptions, referencedFiles: [] };
30+
}
31+
32+
collectDiagnostics(): never {
33+
throw new Error('Not available when using noop compilation.');
34+
}
35+
36+
emitAffectedFiles(): never {
37+
throw new Error('Not available when using noop compilation.');
38+
}
39+
}

0 commit comments

Comments
 (0)