diff --git a/packages/angular_devkit/build_angular/src/builders/application/build-action.ts b/packages/angular_devkit/build_angular/src/builders/application/build-action.ts index a0e677cc4e8d..11e05deff19a 100644 --- a/packages/angular_devkit/build_angular/src/builders/application/build-action.ts +++ b/packages/angular_devkit/build_angular/src/builders/application/build-action.ts @@ -10,6 +10,7 @@ import { BuilderOutput } from '@angular-devkit/architect'; import type { logging } from '@angular-devkit/core'; import fs from 'node:fs/promises'; import path from 'node:path'; +import { BuildOutputFile } from '../../tools/esbuild/bundler-context'; import { ExecutionResult, RebuildState } from '../../tools/esbuild/bundler-execution-result'; import { shutdownSassWorkerPool } from '../../tools/esbuild/stylesheets/sass-language'; import { withNoProgress, withSpinner, writeResultFiles } from '../../tools/esbuild/utils'; @@ -25,6 +26,7 @@ export async function* runEsBuildBuildAction( logger: logging.LoggerApi; cacheOptions: NormalizedCachedOptions; writeToFileSystem?: boolean; + writeToFileSystemFilter?: (file: BuildOutputFile) => boolean; watch?: boolean; verbose?: boolean; progress?: boolean; @@ -34,6 +36,7 @@ export async function* runEsBuildBuildAction( }, ): AsyncIterable<(ExecutionResult['outputWithFiles'] | ExecutionResult['output']) & BuilderOutput> { const { + writeToFileSystemFilter, writeToFileSystem = true, watch, poll, @@ -177,7 +180,10 @@ export async function* runEsBuildBuildAction( if (writeToFileSystem) { // Write output files - await writeResultFiles(result.outputFiles, result.assetFiles, outputPath); + const filesToWrite = writeToFileSystemFilter + ? result.outputFiles.filter(writeToFileSystemFilter) + : result.outputFiles; + await writeResultFiles(filesToWrite, result.assetFiles, outputPath); yield result.output; } else { diff --git a/packages/angular_devkit/build_angular/src/builders/application/execute-build.ts b/packages/angular_devkit/build_angular/src/builders/application/execute-build.ts index d38076cc7241..112467f89740 100644 --- a/packages/angular_devkit/build_angular/src/builders/application/execute-build.ts +++ b/packages/angular_devkit/build_angular/src/builders/application/execute-build.ts @@ -13,7 +13,7 @@ import { createBrowserCodeBundleOptions, createServerCodeBundleOptions, } from '../../tools/esbuild/application-code-bundle'; -import { BundlerContext } from '../../tools/esbuild/bundler-context'; +import { BuildOutputFileType, 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'; @@ -174,10 +174,14 @@ export async function executeBuild( indexContentOutputNoCssInlining = contentWithoutCriticalCssInlined; printWarningsAndErrorsToConsole(context, warnings, errors); - executionResult.addOutputFile(indexHtmlOptions.output, content); + executionResult.addOutputFile(indexHtmlOptions.output, content, BuildOutputFileType.Browser); if (ssrOptions) { - executionResult.addOutputFile('index.server.html', contentWithoutCriticalCssInlined); + executionResult.addOutputFile( + 'index.server.html', + contentWithoutCriticalCssInlined, + BuildOutputFileType.Server, + ); } } @@ -203,7 +207,7 @@ export async function executeBuild( printWarningsAndErrorsToConsole(context, warnings, errors); for (const [path, content] of Object.entries(output)) { - executionResult.addOutputFile(path, content); + executionResult.addOutputFile(path, content, BuildOutputFileType.Browser); } } @@ -211,7 +215,7 @@ export async function executeBuild( if (assets) { // The webpack copy assets helper is used with no base paths defined. This prevents the helper // from directly writing to disk. This should eventually be replaced with a more optimized helper. - executionResult.assetFiles.push(...(await copyAssets(assets, [], workspaceRoot))); + executionResult.addAssets(await copyAssets(assets, [], workspaceRoot)); } // Extract and write licenses for used packages @@ -219,6 +223,7 @@ export async function executeBuild( executionResult.addOutputFile( '3rdpartylicenses.txt', await extractLicenses(metafile, workspaceRoot), + BuildOutputFileType.Root, ); } @@ -233,8 +238,12 @@ export async function executeBuild( executionResult.outputFiles, executionResult.assetFiles, ); - executionResult.addOutputFile('ngsw.json', serviceWorkerResult.manifest); - executionResult.assetFiles.push(...serviceWorkerResult.assetFiles); + executionResult.addOutputFile( + 'ngsw.json', + serviceWorkerResult.manifest, + BuildOutputFileType.Browser, + ); + executionResult.addAssets(serviceWorkerResult.assetFiles); } catch (error) { context.logger.error(error instanceof Error ? error.message : `${error}`); @@ -261,7 +270,11 @@ export async function executeBuild( // Write metafile if stats option is enabled if (options.stats) { - executionResult.addOutputFile('stats.json', JSON.stringify(metafile, null, 2)); + executionResult.addOutputFile( + 'stats.json', + JSON.stringify(metafile, null, 2), + BuildOutputFileType.Root, + ); } return executionResult; diff --git a/packages/angular_devkit/build_angular/src/builders/application/i18n.ts b/packages/angular_devkit/build_angular/src/builders/application/i18n.ts index c245fdcecd81..509ff8b40098 100644 --- a/packages/angular_devkit/build_angular/src/builders/application/i18n.ts +++ b/packages/angular_devkit/build_angular/src/builders/application/i18n.ts @@ -8,7 +8,7 @@ import { BuilderContext } from '@angular-devkit/architect'; import { join } from 'node:path'; -import { InitialFileRecord } from '../../tools/esbuild/bundler-context'; +import { BuildOutputFileType, InitialFileRecord } from '../../tools/esbuild/bundler-context'; import { ExecutionResult } from '../../tools/esbuild/bundler-execution-result'; import { I18nInliner } from '../../tools/esbuild/i18n-inliner'; import { generateIndexHtml } from '../../tools/esbuild/index-html-generator'; @@ -75,7 +75,13 @@ export async function inlineI18n( locale, ); - localeOutputFiles.push(createOutputFileFromText(options.indexHtmlOptions.output, content)); + localeOutputFiles.push( + createOutputFileFromText( + options.indexHtmlOptions.output, + content, + BuildOutputFileType.Browser, + ), + ); inlineResult.errors.push(...errors); inlineResult.warnings.push(...warnings); @@ -96,7 +102,9 @@ export async function inlineI18n( inlineResult.warnings.push(...warnings); for (const [path, content] of Object.entries(output)) { - localeOutputFiles.push(createOutputFileFromText(path, content)); + localeOutputFiles.push( + createOutputFileFromText(path, content, BuildOutputFileType.Browser), + ); } } } @@ -111,7 +119,11 @@ export async function inlineI18n( executionResult.assetFiles, ); localeOutputFiles.push( - createOutputFileFromText('ngsw.json', serviceWorkerResult.manifest), + createOutputFileFromText( + 'ngsw.json', + serviceWorkerResult.manifest, + BuildOutputFileType.Browser, + ), ); executionResult.assetFiles.push(...serviceWorkerResult.assetFiles); } catch (error) { diff --git a/packages/angular_devkit/build_angular/src/builders/application/index.ts b/packages/angular_devkit/build_angular/src/builders/application/index.ts index 982cf22e1ae5..bcfa310b7c5f 100644 --- a/packages/angular_devkit/build_angular/src/builders/application/index.ts +++ b/packages/angular_devkit/build_angular/src/builders/application/index.ts @@ -7,7 +7,7 @@ */ import { BuilderContext, BuilderOutput, createBuilder } from '@angular-devkit/architect'; -import type { OutputFile } from 'esbuild'; +import { BuildOutputFile, BuildOutputFileType } from '../../tools/esbuild/bundler-context'; import { purgeStaleBuildCache } from '../../utils/purge-cache'; import { assertCompatibleAngularVersion } from '../../utils/version'; import { runEsBuildBuildAction } from './build-action'; @@ -24,7 +24,7 @@ export async function* buildApplicationInternal( }, ): AsyncIterable< BuilderOutput & { - outputFiles?: OutputFile[]; + outputFiles?: BuildOutputFile[]; assetFiles?: { source: string; destination: string }[]; } > { @@ -65,6 +65,12 @@ export async function* buildApplicationInternal( workspaceRoot: normalizedOptions.workspaceRoot, progress: normalizedOptions.progress, writeToFileSystem: infrastructureSettings?.write, + // For app-shell and SSG server files are not required by users. + // Omit these when SSR is not enabled. + writeToFileSystemFilter: + normalizedOptions.ssrOptions && normalizedOptions.serverEntryPoint + ? undefined + : (file) => file.type !== BuildOutputFileType.Server, logger: context.logger, signal: context.signal, }, @@ -76,7 +82,7 @@ export function buildApplication( context: BuilderContext, ): AsyncIterable< BuilderOutput & { - outputFiles?: OutputFile[]; + outputFiles?: BuildOutputFile[]; assetFiles?: { source: string; destination: string }[]; } > { diff --git a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/index.ts b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/index.ts index 833fb546edd5..57c6fdfa2898 100644 --- a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/index.ts +++ b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/index.ts @@ -7,7 +7,10 @@ */ import { BuilderContext, BuilderOutput, createBuilder } from '@angular-devkit/architect'; -import type { OutputFile } from 'esbuild'; +import { constants as fsConstants } from 'node:fs'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { BuildOutputFile } from '../../tools/esbuild/bundler-context'; import { buildApplicationInternal } from '../application'; import { Schema as ApplicationBuilderOptions } from '../application/schema'; import { logBuilderStatusWarnings } from './builder-status-warnings'; @@ -20,7 +23,7 @@ import { Schema as BrowserBuilderOptions } from './schema'; * @param context The Architect builder context object * @returns An async iterable with the builder result output */ -export function buildEsbuildBrowser( +export async function* buildEsbuildBrowser( userOptions: BrowserBuilderOptions, context: BuilderContext, infrastructureSettings?: { @@ -28,16 +31,25 @@ export function buildEsbuildBrowser( }, ): AsyncIterable< BuilderOutput & { - outputFiles?: OutputFile[]; + outputFiles?: BuildOutputFile[]; assetFiles?: { source: string; destination: string }[]; } > { // Inform user of status of builder and options logBuilderStatusWarnings(userOptions, context); - const normalizedOptions = normalizeOptions(userOptions); + const fullOutputPath = path.join(context.workspaceRoot, normalizedOptions.outputPath); + + for await (const result of buildApplicationInternal(normalizedOptions, context, { + write: false, + })) { + if (infrastructureSettings?.write !== false && result.outputFiles) { + // Write output files + await writeResultFiles(result.outputFiles, result.assetFiles, fullOutputPath); + } - return buildApplicationInternal(normalizedOptions, context, infrastructureSettings); + yield result; + } } function normalizeOptions(options: BrowserBuilderOptions): ApplicationBuilderOptions { @@ -51,4 +63,41 @@ function normalizeOptions(options: BrowserBuilderOptions): ApplicationBuilderOpt }; } +// We write the file directly from this builder to maintain webpack output compatibility +// and not output browser files into '/browser'. +async function writeResultFiles( + outputFiles: BuildOutputFile[], + assetFiles: { source: string; destination: string }[] | undefined, + outputPath: string, +) { + const directoryExists = new Set(); + await Promise.all( + outputFiles.map(async (file) => { + // Ensure output subdirectories exist + const basePath = path.dirname(file.path); + if (basePath && !directoryExists.has(basePath)) { + await fs.mkdir(path.join(outputPath, basePath), { recursive: true }); + directoryExists.add(basePath); + } + // Write file contents + await fs.writeFile(path.join(outputPath, file.path), file.contents); + }), + ); + + if (assetFiles?.length) { + await Promise.all( + assetFiles.map(async ({ source, destination }) => { + // Ensure output subdirectories exist + const basePath = path.dirname(destination); + if (basePath && !directoryExists.has(basePath)) { + await fs.mkdir(path.join(outputPath, basePath), { recursive: true }); + directoryExists.add(basePath); + } + // Copy file contents + await fs.copyFile(source, path.join(outputPath), fsConstants.COPYFILE_FICLONE); + }), + ); + } +} + export default createBuilder(buildEsbuildBrowser); diff --git a/packages/angular_devkit/build_angular/src/builders/dev-server/vite-server.ts b/packages/angular_devkit/build_angular/src/builders/dev-server/vite-server.ts index 558509548fbe..6f8ebe68b7f7 100644 --- a/packages/angular_devkit/build_angular/src/builders/dev-server/vite-server.ts +++ b/packages/angular_devkit/build_angular/src/builders/dev-server/vite-server.ts @@ -8,7 +8,6 @@ import type { BuilderContext } from '@angular-devkit/architect'; import type { json, logging } from '@angular-devkit/core'; -import type { OutputFile } from 'esbuild'; import { lookup as lookupMimeType } from 'mrmime'; import assert from 'node:assert'; import { BinaryLike, createHash } from 'node:crypto'; @@ -17,6 +16,7 @@ import { ServerResponse } from 'node:http'; import type { AddressInfo } from 'node:net'; import path, { posix } from 'node:path'; import type { Connect, InlineConfig, ViteDevServer } from 'vite'; +import { BuildOutputFile, BuildOutputFileType } from '../../tools/esbuild/bundler-context'; import { JavaScriptTransformer } from '../../tools/esbuild/javascript-transformer'; import { createAngularLocaleDataPlugin } from '../../tools/vite/i18n-locale-plugin'; import { RenderOptions, renderPage } from '../../utils/server-rendering/render-page'; @@ -32,6 +32,7 @@ interface OutputFileRecord { size: number; hash?: Buffer; updated: boolean; + servable: boolean; } const SSG_MARKER_REGEXP = /ng-server-context=["']\w*\|?ssg\|?\w*["']/; @@ -222,7 +223,7 @@ function handleUpdate( function analyzeResultFiles( normalizePath: (id: string) => string, htmlIndexPath: string, - resultFiles: OutputFile[], + resultFiles: BuildOutputFile[], generatedFiles: Map, ) { const seen = new Set(['/index.html']); @@ -241,6 +242,8 @@ function analyzeResultFiles( if (filePath.endsWith('.map')) { generatedFiles.set(filePath, { contents: file.contents, + servable: + file.type === BuildOutputFileType.Browser || file.type === BuildOutputFileType.Media, size: file.contents.byteLength, updated: false, }); @@ -270,6 +273,8 @@ function analyzeResultFiles( size: file.contents.byteLength, hash: fileHash, updated: true, + servable: + file.type === BuildOutputFileType.Browser || file.type === BuildOutputFileType.Media, }); } @@ -397,7 +402,7 @@ export async function setupServer( // dev server sourcemap issues with stylesheets. if (extension !== '.js' && extension !== '.html') { const outputFile = outputFiles.get(pathname); - if (outputFile) { + if (outputFile?.servable) { const mimeType = lookupMimeType(extension); if (mimeType) { res.setHeader('Content-Type', mimeType); diff --git a/packages/angular_devkit/build_angular/src/builders/jest/index.ts b/packages/angular_devkit/build_angular/src/builders/jest/index.ts index fbd640446b7b..e2b0bdd4a34f 100644 --- a/packages/angular_devkit/build_angular/src/builders/jest/index.ts +++ b/packages/angular_devkit/build_angular/src/builders/jest/index.ts @@ -84,7 +84,7 @@ export default createBuilder( '--experimental-vm-modules', jest, - `--rootDir="${testOut}"`, + `--rootDir="${path.join(testOut, 'browser')}"`, '--testEnvironment=jsdom', // TODO(dgp1130): Enable cache once we have a mechanism for properly clearing / disabling it. diff --git a/packages/angular_devkit/build_angular/src/tools/esbuild/bundler-context.ts b/packages/angular_devkit/build_angular/src/tools/esbuild/bundler-context.ts index c920aec39ca5..bc0d7e03016f 100644 --- a/packages/angular_devkit/build_angular/src/tools/esbuild/bundler-context.ts +++ b/packages/angular_devkit/build_angular/src/tools/esbuild/bundler-context.ts @@ -16,7 +16,8 @@ import { build, context, } from 'esbuild'; -import { basename, extname, join, relative } from 'node:path'; +import { basename, dirname, extname, join, relative } from 'node:path'; +import { createOutputFileFromData, createOutputFileFromText } from './utils'; export type BundleContextResult = | { errors: Message[]; warnings: Message[] } @@ -24,7 +25,7 @@ export type BundleContextResult = errors: undefined; warnings: Message[]; metafile: Metafile; - outputFiles: OutputFile[]; + outputFiles: BuildOutputFile[]; initialFiles: Map; }; @@ -35,6 +36,19 @@ export interface InitialFileRecord { external?: boolean; } +export enum BuildOutputFileType { + Browser = 1, + Media = 2, + Server = 3, + Root = 4, +} + +export interface BuildOutputFile extends OutputFile { + type: BuildOutputFileType; + fullOutputPath: string; + clone: () => BuildOutputFile; +} + /** * Determines if an unknown value is an esbuild BuildFailure error object thrown by esbuild. * @param value A potential esbuild BuildFailure error object. @@ -217,8 +231,27 @@ export class BundlerContext { } } + const outputFiles = result.outputFiles.map(({ contents, path }) => { + let fileType: BuildOutputFileType; + if (dirname(path) === 'media') { + fileType = BuildOutputFileType.Media; + } else { + fileType = + this.#esbuildOptions?.platform === 'node' + ? BuildOutputFileType.Server + : BuildOutputFileType.Browser; + } + + return createOutputFileFromData(path, contents, fileType); + }); + // Return the successful build results - return { ...result, initialFiles, errors: undefined }; + return { + ...result, + outputFiles, + initialFiles, + errors: undefined, + }; } /** diff --git a/packages/angular_devkit/build_angular/src/tools/esbuild/bundler-execution-result.ts b/packages/angular_devkit/build_angular/src/tools/esbuild/bundler-execution-result.ts index b64e66c37980..2d2d53330aed 100644 --- a/packages/angular_devkit/build_angular/src/tools/esbuild/bundler-execution-result.ts +++ b/packages/angular_devkit/build_angular/src/tools/esbuild/bundler-execution-result.ts @@ -6,10 +6,9 @@ * found in the LICENSE file at https://angular.io/license */ -import { OutputFile } from 'esbuild'; import type { ChangedFiles } from '../../tools/esbuild/watcher'; import type { SourceFileCache } from './angular/compiler-plugin'; -import type { BundlerContext } from './bundler-context'; +import type { BuildOutputFile, BuildOutputFileType, BundlerContext } from './bundler-context'; import { createOutputFileFromText } from './utils'; export interface RebuildState { @@ -22,7 +21,7 @@ export interface RebuildState { * Represents the result of a single builder execute call. */ export class ExecutionResult { - outputFiles: OutputFile[] = []; + outputFiles: BuildOutputFile[] = []; assetFiles: { source: string; destination: string }[] = []; constructor( @@ -30,8 +29,12 @@ export class ExecutionResult { private codeBundleCache?: SourceFileCache, ) {} - addOutputFile(path: string, content: string): void { - this.outputFiles.push(createOutputFileFromText(path, content)); + addOutputFile(path: string, content: string, type: BuildOutputFileType): void { + this.outputFiles.push(createOutputFileFromText(path, content, type)); + } + + addAssets(assets: { source: string; destination: string }[]): void { + this.assetFiles.push(...assets); } get output() { diff --git a/packages/angular_devkit/build_angular/src/tools/esbuild/i18n-inliner.ts b/packages/angular_devkit/build_angular/src/tools/esbuild/i18n-inliner.ts index 668c16533563..9efc37c338bf 100644 --- a/packages/angular_devkit/build_angular/src/tools/esbuild/i18n-inliner.ts +++ b/packages/angular_devkit/build_angular/src/tools/esbuild/i18n-inliner.ts @@ -6,9 +6,9 @@ * found in the LICENSE file at https://angular.io/license */ -import type { OutputFile } from 'esbuild'; import Piscina from 'piscina'; -import { cloneOutputFile, createOutputFileFromText } from './utils'; +import { BuildOutputFile, BuildOutputFileType } from './bundler-context'; +import { createOutputFileFromText } from './utils'; /** * A keyword used to indicate if a JavaScript file may require inlining of translations. @@ -21,7 +21,7 @@ const LOCALIZE_KEYWORD = '$localize'; */ export interface I18nInlinerOptions { missingTranslation: 'error' | 'warning' | 'ignore'; - outputFiles: OutputFile[]; + outputFiles: BuildOutputFile[]; shouldOptimize?: boolean; } @@ -34,7 +34,8 @@ export interface I18nInlinerOptions { export class I18nInliner { #workerPool: Piscina; readonly #localizeFiles: ReadonlyMap; - readonly #unmodifiedFiles: Array; + readonly #unmodifiedFiles: Array; + readonly #fileToType = new Map(); constructor(options: I18nInlinerOptions, maxThreads?: number) { this.#unmodifiedFiles = []; @@ -42,6 +43,13 @@ export class I18nInliner { const files = new Map(); const pendingMaps = []; for (const file of options.outputFiles) { + if (file.type === BuildOutputFileType.Root) { + // Skip stats and similar files. + continue; + } + + this.#fileToType.set(file.path, file.type); + if (file.path.endsWith('.js') || file.path.endsWith('.mjs')) { // Check if localizations are present const contentBuffer = Buffer.isBuffer(file.contents) @@ -102,7 +110,7 @@ export class I18nInliner { async inlineForLocale( locale: string, translation: Record | undefined, - ): Promise { + ): Promise { // Request inlining for each file that contains localize calls const requests = []; for (const filename of this.#localizeFiles.keys()) { @@ -123,8 +131,11 @@ export class I18nInliner { // Convert raw results to output file objects and include all unmodified files return [ - ...rawResults.flat().map(({ file, contents }) => createOutputFileFromText(file, contents)), - ...this.#unmodifiedFiles.map((file) => cloneOutputFile(file)), + ...rawResults.flat().map(({ file, contents }) => + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + createOutputFileFromText(file, contents, this.#fileToType.get(file)!), + ), + ...this.#unmodifiedFiles.map((file) => file.clone()), ]; } diff --git a/packages/angular_devkit/build_angular/src/tools/esbuild/index-html-generator.ts b/packages/angular_devkit/build_angular/src/tools/esbuild/index-html-generator.ts index d51ee821b9c8..6d3540dd39b8 100644 --- a/packages/angular_devkit/build_angular/src/tools/esbuild/index-html-generator.ts +++ b/packages/angular_devkit/build_angular/src/tools/esbuild/index-html-generator.ts @@ -6,17 +6,16 @@ * found in the LICENSE file at https://angular.io/license */ -import type { OutputFile } from 'esbuild'; import assert from 'node:assert'; import path from 'node:path'; import { NormalizedApplicationBuildOptions } from '../../builders/application/options'; import { IndexHtmlGenerator } from '../../utils/index-file/index-html-generator'; import { InlineCriticalCssProcessor } from '../../utils/index-file/inline-critical-css'; -import { InitialFileRecord } from './bundler-context'; +import { BuildOutputFile, BuildOutputFileType, InitialFileRecord } from './bundler-context'; export async function generateIndexHtml( initialFiles: Map, - outputFiles: OutputFile[], + outputFiles: BuildOutputFile[], buildOptions: NormalizedApplicationBuildOptions, lang?: string, ): Promise<{ @@ -57,11 +56,12 @@ export async function generateIndexHtml( } /** Virtual output path to support reading in-memory files. */ + const browserOutputFiles = outputFiles.filter(({ type }) => type === BuildOutputFileType.Browser); const virtualOutputPath = '/'; const readAsset = async function (filePath: string): Promise { // Remove leading directory separator const relativefilePath = path.relative(virtualOutputPath, filePath); - const file = outputFiles.find((file) => file.path === relativefilePath); + const file = browserOutputFiles.find((file) => file.path === relativefilePath); if (file) { return file.text; } diff --git a/packages/angular_devkit/build_angular/src/tools/esbuild/stylesheets/bundle-options.ts b/packages/angular_devkit/build_angular/src/tools/esbuild/stylesheets/bundle-options.ts index 2afcaddd8411..44c93277f56e 100644 --- a/packages/angular_devkit/build_angular/src/tools/esbuild/stylesheets/bundle-options.ts +++ b/packages/angular_devkit/build_angular/src/tools/esbuild/stylesheets/bundle-options.ts @@ -9,7 +9,7 @@ import type { BuildOptions, OutputFile } from 'esbuild'; import { createHash } from 'node:crypto'; import path from 'node:path'; -import { BundlerContext } from '../bundler-context'; +import { BuildOutputFileType, BundlerContext } from '../bundler-context'; import { LoadResultCache } from '../load-result-cache'; import { CssStylesheetLanguage } from './css-language'; import { createCssResourcePlugin } from './css-resource-plugin'; @@ -152,14 +152,18 @@ export async function bundleComponentStylesheet( if (!result.errors) { for (const outputFile of result.outputFiles) { const filename = path.basename(outputFile.path); - if (filename.endsWith('.css')) { + if (outputFile.type === BuildOutputFileType.Media) { + // The output files could also contain resources (images/fonts/etc.) that were referenced + resourceFiles.push(outputFile); + } else if (filename.endsWith('.css')) { outputPath = outputFile.path; contents = outputFile.text; } else if (filename.endsWith('.css.map')) { map = outputFile.text; } else { - // The output files could also contain resources (images/fonts/etc.) that were referenced - resourceFiles.push(outputFile); + throw new Error( + `Unexpected non CSS/Media file "${filename}" outputted during component stylesheet processing.`, + ); } } } diff --git a/packages/angular_devkit/build_angular/src/tools/esbuild/utils.ts b/packages/angular_devkit/build_angular/src/tools/esbuild/utils.ts index 98f2ac74bce3..5bd0f7ccac37 100644 --- a/packages/angular_devkit/build_angular/src/tools/esbuild/utils.ts +++ b/packages/angular_devkit/build_angular/src/tools/esbuild/utils.ts @@ -11,13 +11,13 @@ import { BuildOptions, Metafile, OutputFile, PartialMessage, formatMessages } fr import { createHash } from 'node:crypto'; import { constants as fsConstants } from 'node:fs'; import fs from 'node:fs/promises'; -import path from 'node:path'; +import path, { join } from 'node:path'; import { promisify } from 'node:util'; import { brotliCompress } from 'node:zlib'; import { coerce } from 'semver'; import { Spinner } from '../../utils/spinner'; import { BundleStats, generateBuildStatsTable } from '../webpack/utils/stats'; -import { InitialFileRecord } from './bundler-context'; +import { BuildOutputFile, BuildOutputFileType, InitialFileRecord } from './bundler-context'; const compressAsync = promisify(brotliCompress); @@ -165,21 +165,22 @@ export function getFeatureSupport(target: string[]): BuildOptions['supported'] { } export async function writeResultFiles( - outputFiles: OutputFile[], + outputFiles: BuildOutputFile[], assetFiles: { source: string; destination: string }[] | undefined, outputPath: string, ) { const directoryExists = new Set(); await Promise.all( outputFiles.map(async (file) => { + const fullOutputPath = file.fullOutputPath; // Ensure output subdirectories exist - const basePath = path.dirname(file.path); + const basePath = path.dirname(fullOutputPath); if (basePath && !directoryExists.has(basePath)) { await fs.mkdir(path.join(outputPath, basePath), { recursive: true }); directoryExists.add(basePath); } // Write file contents - await fs.writeFile(path.join(outputPath, file.path), file.contents); + await fs.writeFile(path.join(outputPath, fullOutputPath), file.contents); }), ); @@ -187,61 +188,81 @@ export async function writeResultFiles( await Promise.all( assetFiles.map(async ({ source, destination }) => { // Ensure output subdirectories exist - const basePath = path.dirname(destination); + const destPath = join('browser', destination); + const basePath = path.dirname(destPath); if (basePath && !directoryExists.has(basePath)) { await fs.mkdir(path.join(outputPath, basePath), { recursive: true }); directoryExists.add(basePath); } // Copy file contents - await fs.copyFile(source, path.join(outputPath, destination), fsConstants.COPYFILE_FICLONE); + await fs.copyFile(source, path.join(outputPath, destPath), fsConstants.COPYFILE_FICLONE); }), ); } } -export function createOutputFileFromText(path: string, text: string): OutputFile { +export function createOutputFileFromText( + path: string, + text: string, + type: BuildOutputFileType, +): BuildOutputFile { return { path, text, + type, get hash() { return createHash('sha256').update(this.text).digest('hex'); }, get contents() { return Buffer.from(this.text, 'utf-8'); }, + get fullOutputPath(): string { + return getFullOutputPath(this); + }, + clone(): BuildOutputFile { + return createOutputFileFromText(this.path, this.text, this.type); + }, }; } -export function createOutputFileFromData(path: string, data: Uint8Array): OutputFile { +export function createOutputFileFromData( + path: string, + data: Uint8Array, + type: BuildOutputFileType, +): BuildOutputFile { return { path, + type, get text() { return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString('utf-8'); }, get hash() { - return createHash('sha256').update(data).digest('hex'); + return createHash('sha256').update(this.text).digest('hex'); }, get contents() { return data; }, - }; -} - -export function cloneOutputFile(file: OutputFile): OutputFile { - return { - path: file.path, - get text() { - return file.text; - }, - get hash() { - return file.hash; + get fullOutputPath(): string { + return getFullOutputPath(this); }, - get contents() { - return file.contents; + clone(): BuildOutputFile { + return createOutputFileFromData(this.path, this.contents, this.type); }, }; } +export function getFullOutputPath(file: BuildOutputFile): string { + switch (file.type) { + case BuildOutputFileType.Browser: + case BuildOutputFileType.Media: + return join('browser', file.path); + case BuildOutputFileType.Server: + return join('server', file.path); + default: + return file.path; + } +} + /** * Transform browserlists result to esbuild target. * @see https://esbuild.github.io/api/#target diff --git a/packages/angular_devkit/build_angular/src/utils/server-rendering/prerender.ts b/packages/angular_devkit/build_angular/src/utils/server-rendering/prerender.ts index 09128765e799..d351475172e6 100644 --- a/packages/angular_devkit/build_angular/src/utils/server-rendering/prerender.ts +++ b/packages/angular_devkit/build_angular/src/utils/server-rendering/prerender.ts @@ -6,11 +6,11 @@ * found in the LICENSE file at https://angular.io/license */ -import { OutputFile } from 'esbuild'; import { readFile } from 'node:fs/promises'; import { extname, join, posix } from 'node:path'; import { pathToFileURL } from 'node:url'; import Piscina from 'piscina'; +import { BuildOutputFile, BuildOutputFileType } from '../../tools/esbuild/bundler-context'; import type { RenderResult, ServerContext } from './render-page'; import type { RenderWorkerData } from './render-worker'; import type { @@ -31,7 +31,7 @@ export async function prerenderPages( workspaceRoot: string, appShellOptions: AppShellOptions = {}, prerenderOptions: PrerenderOptions = {}, - outputFiles: Readonly, + outputFiles: Readonly, document: string, inlineCriticalCss?: boolean, maxThreads = 1, @@ -46,12 +46,12 @@ export async function prerenderPages( const errors: string[] = []; const outputFilesForWorker: Record = {}; - for (const { text, path } of outputFiles) { - switch (extname(path)) { - case '.mjs': // Contains the server runnable application code. - case '.css': // Global styles for critical CSS inlining. - outputFilesForWorker[path] = text; - break; + for (const { text, path, type } of outputFiles) { + if ( + type === BuildOutputFileType.Server || // Contains the server runnable application code + (type === BuildOutputFileType.Browser && extname(path) === '.css') // Global styles for critical CSS inlining. + ) { + outputFilesForWorker[path] = text; } } diff --git a/packages/angular_devkit/build_angular/src/utils/service-worker.ts b/packages/angular_devkit/build_angular/src/utils/service-worker.ts index e19e6ef3a84e..99c8740fec92 100644 --- a/packages/angular_devkit/build_angular/src/utils/service-worker.ts +++ b/packages/angular_devkit/build_angular/src/utils/service-worker.ts @@ -8,14 +8,17 @@ import type { Config, Filesystem } from '@angular/service-worker/config'; import * as crypto from 'crypto'; -import type { OutputFile } from 'esbuild'; import { existsSync, constants as fsConstants, promises as fsPromises } from 'node:fs'; import * as path from 'path'; +import { BuildOutputFile, BuildOutputFileType } from '../tools/esbuild/bundler-context'; import { assertIsError } from './error'; import { loadEsmModule } from './load-esm'; class CliFilesystem implements Filesystem { - constructor(private fs: typeof fsPromises, private base: string) {} + constructor( + private fs: typeof fsPromises, + private base: string, + ) {} list(dir: string): Promise { return this._recursiveList(this._resolve(dir), []); @@ -65,9 +68,14 @@ class CliFilesystem implements Filesystem { class ResultFilesystem implements Filesystem { private readonly fileReaders = new Map Promise>(); - constructor(outputFiles: OutputFile[], assetFiles: { source: string; destination: string }[]) { + constructor( + outputFiles: BuildOutputFile[], + assetFiles: { source: string; destination: string }[], + ) { for (const file of outputFiles) { - this.fileReaders.set('/' + file.path.replace(/\\/g, '/'), async () => file.contents); + if (file.type === BuildOutputFileType.Media || file.type === BuildOutputFileType.Browser) { + this.fileReaders.set('/' + file.path.replace(/\\/g, '/'), async () => file.contents); + } } for (const file of assetFiles) { this.fileReaders.set('/' + file.destination.replace(/\\/g, '/'), () => @@ -171,7 +179,7 @@ export async function augmentAppWithServiceWorkerEsbuild( workspaceRoot: string, configPath: string, baseHref: string, - outputFiles: OutputFile[], + outputFiles: BuildOutputFile[], assetFiles: { source: string; destination: string }[], ): Promise<{ manifest: string; assetFiles: { source: string; destination: string }[] }> { // Read the configuration file diff --git a/packages/schematics/angular/ssr/files/application-builder/server.ts.template b/packages/schematics/angular/ssr/files/application-builder/server.ts.template index 79d3b09f00d0..9d7a8dbf911c 100644 --- a/packages/schematics/angular/ssr/files/application-builder/server.ts.template +++ b/packages/schematics/angular/ssr/files/application-builder/server.ts.template @@ -2,14 +2,15 @@ import { APP_BASE_HREF } from '@angular/common'; import { CommonEngine } from '@angular/ssr'; import express from 'express'; import { fileURLToPath } from 'node:url'; -import { dirname, join } from 'node:path'; +import { dirname, join, resolve } from 'node:path'; import <% if (isStandalone) { %>bootstrap<% } else { %>AppServerModule<% } %> from './src/main.server'; // The Express app is exported so that it can be used by serverless Functions. export function app(): express.Express { const server = express(); - const browserDistFolder = dirname(fileURLToPath(import.meta.url)); - const indexHtml = join(browserDistFolder, 'index.server.html'); + const serverDistFolder = dirname(fileURLToPath(import.meta.url)); + const browserDistFolder = resolve(serverDistFolder, '../browser'); + const indexHtml = join(serverDistFolder, 'index.server.html'); const commonEngine = new CommonEngine();