Skip to content

Commit

Permalink
feat(@angular-devkit/build-angular): add initial support for server b…
Browse files Browse the repository at this point in the history
…undle generation using esbuild

This commit adds initial support to generate the server bundle using esbuild as the underlying bundler.
  • Loading branch information
alan-agius4 authored and dgp1130 committed Jul 5, 2023
1 parent 333da08 commit 095f5ab
Show file tree
Hide file tree
Showing 15 changed files with 538 additions and 186 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,15 @@

import { BuilderContext } from '@angular-devkit/architect';
import { SourceFileCache } from '../../tools/esbuild/angular/compiler-plugin';
import { createCodeBundleOptions } from '../../tools/esbuild/application-code-bundle';
import { createBrowserCodeBundleOptions } from '../../tools/esbuild/browser-code-bundle';
import { BundlerContext } from '../../tools/esbuild/bundler-context';
import { ExecutionResult, RebuildState } from '../../tools/esbuild/bundler-execution-result';
import { checkCommonJSModules } from '../../tools/esbuild/commonjs-checker';
import { createGlobalScriptsBundleOptions } from '../../tools/esbuild/global-scripts';
import { createGlobalStylesBundleOptions } from '../../tools/esbuild/global-styles';
import { generateIndexHtml } from '../../tools/esbuild/index-html-generator';
import { extractLicenses } from '../../tools/esbuild/license-extractor';
import { createServerCodeBundleOptions } from '../../tools/esbuild/server-code-bundle';
import {
calculateEstimatedTransferSizes,
logBuildStats,
Expand All @@ -39,6 +40,7 @@ export async function executeBuild(
workspaceRoot,
serviceWorker,
optimizationOptions,
serverEntryPoint,
assets,
indexHtmlOptions,
cacheOptions,
Expand All @@ -55,12 +57,12 @@ export async function executeBuild(
if (bundlerContexts === undefined) {
bundlerContexts = [];

// Application code
// Browser application code
bundlerContexts.push(
new BundlerContext(
workspaceRoot,
!!options.watch,
createCodeBundleOptions(options, target, browsers, codeBundleCache),
createBrowserCodeBundleOptions(options, target, browsers, codeBundleCache),
),
);

Expand Down Expand Up @@ -93,6 +95,25 @@ export async function executeBuild(
}
}
}

// Server application code
if (serverEntryPoint) {
bundlerContexts.push(
new BundlerContext(
workspaceRoot,
!!options.watch,
createServerCodeBundleOptions(
options,
// NOTE: earlier versions of Node.js are not supported due to unsafe promise patching.
// See: https://github.com/angular/angular/pull/50552#issue-1737967592
[...target, 'node18.13'],
browsers,
codeBundleCache,
),
() => false,
),
);
}
}

const bundlingResult = await BundlerContext.bundleAll(bundlerContexts);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export type NormalizedApplicationBuildOptions = Awaited<ReturnType<typeof normal
/** Internal options hidden from builder schema but available when invoked programmatically. */
interface InternalOptions {
/**
* Entry points to use for the compilation. Incompatible with `main`, which must not be provided. May be relative or absolute paths.
* Entry points to use for the compilation. Incompatible with `browser`, which must not be provided. May be relative or absolute paths.
* If given a relative path, it is resolved relative to the current workspace and will generate an output at the same relative location
* 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
* name.
Expand All @@ -42,7 +42,7 @@ interface InternalOptions {
externalPackages?: boolean;
}

/** Full set of options for `browser-esbuild` builder. */
/** Full set of options for `application` builder. */
export type ApplicationBuilderInternalOptions = Omit<
ApplicationBuilderOptions & InternalOptions,
'browser'
Expand Down Expand Up @@ -164,6 +164,13 @@ export async function normalizeOptions(
};
}

let serverEntryPoint: string | undefined;
if (options.server) {
serverEntryPoint = path.join(workspaceRoot, options.server);
} else if (options.server === '') {
throw new Error('`server` option cannot be an empty string.');
}

// Initial options to keep
const {
allowedCommonJsDependencies,
Expand All @@ -182,7 +189,6 @@ export async function normalizeOptions(
stylePreprocessorOptions,
subresourceIntegrity,
verbose,
server,
watch,
progress = true,
externalPackages,
Expand Down Expand Up @@ -210,7 +216,7 @@ export async function normalizeOptions(
preserveSymlinks: preserveSymlinks ?? process.execArgv.includes('--preserve-symlinks'),
stylePreprocessorOptions,
subresourceIntegrity,
server: !!server && path.join(workspaceRoot, server),
serverEntryPoint,
verbose,
watch,
workspaceRoot,
Expand All @@ -233,39 +239,39 @@ export async function normalizeOptions(
}

/**
* Normalize entry point options. To maintain compatibility with the legacy browser builder, we need a single `main` option which defines a
* single entry point. However, we also want to support multiple entry points as an internal option. The two options are mutually exclusive
* 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
* points.
* Normalize entry point options. To maintain compatibility with the legacy browser builder, we need a single `browser`
* option which defines a single entry point. However, we also want to support multiple entry points as an internal option.
* The two options are mutually exclusive and if `browser` is provided it will be used as the sole entry point.
* If `entryPoints` are provided, they will be used as the set of entry points.
*
* @param workspaceRoot Path to the root of the Angular workspace.
* @param main The `main` option pointing at the application entry point. While required per the schema file, it may be omitted by
* @param browser The `browser` option pointing at the application entry point. While required per the schema file, it may be omitted by
* programmatic usages of `browser-esbuild`.
* @param entryPoints Set of entry points to use if provided.
* @returns An object mapping entry point names to their file paths.
*/
function normalizeEntryPoints(
workspaceRoot: string,
main: string | undefined,
browser: string | undefined,
entryPoints: Set<string> = new Set(),
): Record<string, string> {
if (main === '') {
throw new Error('`main` option cannot be an empty string.');
if (browser === '') {
throw new Error('`browser` option cannot be an empty string.');
}

// `main` and `entryPoints` are mutually exclusive.
if (main && entryPoints.size > 0) {
throw new Error('Only one of `main` or `entryPoints` may be provided.');
// `browser` and `entryPoints` are mutually exclusive.
if (browser && entryPoints.size > 0) {
throw new Error('Only one of `browser` or `entryPoints` may be provided.');
}
if (!main && entryPoints.size === 0) {
if (!browser && entryPoints.size === 0) {
// Schema should normally reject this case, but programmatic usages of the builder might make this mistake.
throw new Error('Either `main` or at least one `entryPoints` value must be provided.');
throw new Error('Either `browser` or at least one `entryPoints` value must be provided.');
}

// Schema types force `main` to always be provided, but it may be omitted when the builder is invoked programmatically.
if (main) {
// Use `main` alone.
return { 'main': path.join(workspaceRoot, main) };
// Schema types force `browser` to always be provided, but it may be omitted when the builder is invoked programmatically.
if (browser) {
// Use `browser` alone.
return { 'main': path.join(workspaceRoot, browser) };
} else {
// Use `entryPoints` alone.
const entryPointPaths: Record<string, string> = {};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => {
browser: '',
});

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

expect(error?.message).toContain('cannot be an empty string');
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/

import { buildApplication } from '../../index';
import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup';

describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => {
beforeEach(async () => {
await harness.modifyFile('src/tsconfig.app.json', (content) => {
const tsConfig = JSON.parse(content);
tsConfig.files ??= [];
tsConfig.files.push('main.server.ts');

return JSON.stringify(tsConfig);
});
});

describe('Option: "server"', () => {
it('uses a provided TypeScript file', async () => {
harness.useTarget('build', {
...BASE_OPTIONS,
server: 'src/main.server.ts',
});

const { result } = await harness.executeOnce();
expect(result?.success).toBeTrue();

harness.expectFile('dist/server.mjs').toExist();
harness.expectFile('dist/main.js').toExist();
});

it('uses a provided JavaScript file', async () => {
await harness.writeFile('src/server.js', `console.log('server');`);

harness.useTarget('build', {
...BASE_OPTIONS,
server: 'src/server.js',
});

const { result } = await harness.executeOnce();
expect(result?.success).toBeTrue();

harness.expectFile('dist/server.mjs').content.toContain('console.log("server")');
});

it('fails and shows an error when file does not exist', async () => {
harness.useTarget('build', {
...BASE_OPTIONS,
server: 'src/missing.ts',
});

const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false });

expect(result?.success).toBeFalse();
expect(logs).toContain(
jasmine.objectContaining({ message: jasmine.stringMatching('Could not resolve "') }),
);

harness.expectFile('dist/main.js').toNotExist();
harness.expectFile('dist/server.mjs').toNotExist();
});

it('throws an error when given an empty string', async () => {
harness.useTarget('build', {
...BASE_OPTIONS,
server: '',
});

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

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

it('resolves an absolute path as relative inside the workspace root', async () => {
await harness.writeFile('file.mjs', `console.log('Hello!');`);

harness.useTarget('build', {
...BASE_OPTIONS,
server: '/file.mjs',
});

const { result } = await harness.executeOnce();
expect(result?.success).toBeTrue();

// Always uses the name `server.mjs` for the `server` option.
harness.expectFile('dist/server.mjs').toExist();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ describeBuilder(buildEsbuildBrowser, BROWSER_BUILDER_INFO, (harness) => {
main: '',
});

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

expect(error?.message).toContain('cannot be an empty string');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@

import type ng from '@angular/compiler-cli';
import type ts from 'typescript';
import { loadEsmModule } from '../../../utils/load-esm';
import { profileSync } from '../profiling';
import type { AngularHostOptions } from './angular-host';
import { loadEsmModule } from '../../../../utils/load-esm';
import { profileSync } from '../../profiling';
import type { AngularHostOptions } from '../angular-host';

export interface EmitFileResult {
filename: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,13 @@
import type ng from '@angular/compiler-cli';
import assert from 'node:assert';
import ts from 'typescript';
import { profileAsync, profileSync } from '../profiling';
import { AngularCompilation, EmitFileResult } from './angular-compilation';
import { profileAsync, profileSync } from '../../profiling';
import {
AngularHostOptions,
createAngularCompilerHost,
ensureSourceFileVersions,
} from './angular-host';
} from '../angular-host';
import { AngularCompilation, EmitFileResult } from './angular-compilation';

// Temporary deep import for transformer support
// TODO: Move these to a private exports location or move the implementation into this package.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@
import type ng from '@angular/compiler-cli';
import assert from 'node:assert';
import ts from 'typescript';
import { profileSync } from '../profiling';
import { profileSync } from '../../profiling';
import { AngularHostOptions, createAngularCompilerHost } from '../angular-host';
import { createJitResourceTransformer } from '../jit-resource-transformer';
import { AngularCompilation, EmitFileResult } from './angular-compilation';
import { AngularHostOptions, createAngularCompilerHost } from './angular-host';
import { createJitResourceTransformer } from './jit-resource-transformer';

class JitCompilationState {
constructor(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/

import type ng from '@angular/compiler-cli';
import ts from 'typescript';
import { AngularHostOptions } from '../angular-host';
import { AngularCompilation } from './angular-compilation';

export class NoopCompilation extends AngularCompilation {
async initialize(
tsconfig: string,
hostOptions: AngularHostOptions,
compilerOptionsTransformer?: (compilerOptions: ng.CompilerOptions) => ng.CompilerOptions,
): Promise<{
affectedFiles: ReadonlySet<ts.SourceFile>;
compilerOptions: ng.CompilerOptions;
referencedFiles: readonly string[];
}> {
// Load the compiler configuration and transform as needed
const { options: originalCompilerOptions } = await this.loadConfiguration(tsconfig);
const compilerOptions =
compilerOptionsTransformer?.(originalCompilerOptions) ?? originalCompilerOptions;

return { affectedFiles: new Set(), compilerOptions, referencedFiles: [] };
}

collectDiagnostics(): never {
throw new Error('Not available when using noop compilation.');
}

emitAffectedFiles(): never {
throw new Error('Not available when using noop compilation.');
}
}
Loading

0 comments on commit 095f5ab

Please sign in to comment.