diff --git a/README.md b/README.md index 1e0b0acc..5e033985 100644 --- a/README.md +++ b/README.md @@ -18,4 +18,4 @@ npm install git+https://github.com/angular/angular-build-builds.git The sources for this package are in the [Angular CLI](https://github.com/angular/angular-cli) repository. Please file issues and pull requests against that repository. -Usage information and reference details can be found in repository [README](../../../README.md) file. +Usage information and reference details can be found in repository [README](https://github.com/angular/angular-cli/blob/main/README.md) file. diff --git a/package.json b/package.json index d09b235a..0ebb4d52 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@angular/build", - "version": "19.1.0-next.2+sha-6fc31fd", + "version": "19.1.9+sha-d7747ed", "description": "Official build system for Angular", "keywords": [ "Angular CLI", @@ -23,7 +23,8 @@ "builders": "builders.json", "dependencies": { "@ampproject/remapping": "2.3.0", - "@angular-devkit/architect": "github:angular/angular-devkit-architect-builds#6fc31fd", + "@angular-devkit/core": "github:angular/angular-devkit-core-builds#d7747ed", + "@angular-devkit/architect": "github:angular/angular-devkit-architect-builds#d7747ed", "@babel/core": "7.26.0", "@babel/helper-annotate-as-pure": "7.25.9", "@babel/helper-split-export-declaration": "7.24.7", @@ -45,23 +46,23 @@ "rollup": "4.30.1", "sass": "1.83.1", "semver": "7.6.3", - "vite": "6.0.7", + "vite": "6.0.11", "watchpack": "2.4.2" }, "optionalDependencies": { "lmdb": "3.2.2" }, "peerDependencies": { - "@angular/compiler": "^19.0.0 || ^19.1.0-next.0", - "@angular/compiler-cli": "^19.0.0 || ^19.1.0-next.0", - "@angular/localize": "^19.0.0 || ^19.1.0-next.0", - "@angular/platform-server": "^19.0.0 || ^19.1.0-next.0", - "@angular/service-worker": "^19.0.0 || ^19.1.0-next.0", - "@angular/ssr": "github:angular/angular-ssr-builds#6fc31fd", + "@angular/compiler": "^19.0.0", + "@angular/compiler-cli": "^19.0.0", + "@angular/localize": "^19.0.0", + "@angular/platform-server": "^19.0.0", + "@angular/service-worker": "^19.0.0", + "@angular/ssr": "github:angular/angular-ssr-builds#d7747ed", "less": "^4.2.0", - "ng-packagr": "^19.0.0 || ^19.1.0-next.0", + "ng-packagr": "^19.0.0", "postcss": "^8.4.0", - "tailwindcss": "^2.0.0 || ^3.0.0", + "tailwindcss": "^2.0.0 || ^3.0.0 || ^4.0.0", "typescript": ">=5.5 <5.8" }, "peerDependenciesMeta": { @@ -74,7 +75,7 @@ "@angular/service-worker": { "optional": true }, - "@angular/ssr": "github:angular/angular-ssr-builds#6fc31fd", + "@angular/ssr": "github:angular/angular-ssr-builds#d7747ed", "less": { "optional": true }, @@ -102,16 +103,5 @@ "bugs": { "url": "https://github.com/angular/angular-cli/issues" }, - "homepage": "https://github.com/angular/angular-cli", - "dependenciesMeta": { - "esbuild": { - "built": true - }, - "puppeteer": { - "built": true - } - }, - "pnpm": { - "onlyBuiltDependencies": [] - } + "homepage": "https://github.com/angular/angular-cli" } diff --git a/src/builders/application/build-action.js b/src/builders/application/build-action.js index 0d6cacea..5b0a7e9c 100644 --- a/src/builders/application/build-action.js +++ b/src/builders/application/build-action.js @@ -127,6 +127,9 @@ async function* runEsBuildBuildAction(action, options) { if (!watcher) { return; } + // Used to force a full result on next rebuild if there were initial errors. + // This ensures at least one full result is emitted. + let hasInitialErrors = result.errors.length > 0; // Wait for changes and rebuild as needed const currentWatchFiles = new Set(result.watchFiles); try { @@ -164,9 +167,11 @@ async function* runEsBuildBuildAction(action, options) { if (staleWatchFiles?.size) { watcher.remove([...staleWatchFiles]); } - for (const outputResult of emitOutputResults(result, outputOptions, incrementalResults ? rebuildState.previousOutputInfo : undefined)) { + for (const outputResult of emitOutputResults(result, outputOptions, changes, incrementalResults && !hasInitialErrors ? rebuildState : undefined)) { yield outputResult; } + // Clear initial build errors flag if no errors are now present + hasInitialErrors &&= result.errors.length > 0; } } finally { @@ -175,7 +180,7 @@ async function* runEsBuildBuildAction(action, options) { (0, sass_language_1.shutdownSassWorkerPool)(); } } -function* emitOutputResults({ outputFiles, assetFiles, errors, warnings, externalMetadata, htmlIndexPath, htmlBaseHref, templateUpdates, }, outputOptions, previousOutputInfo) { +function* emitOutputResults({ outputFiles, assetFiles, errors, warnings, externalMetadata, htmlIndexPath, htmlBaseHref, templateUpdates, }, outputOptions, changes, rebuildState) { if (errors.length > 0) { yield { kind: results_1.ResultKind.Failure, @@ -187,27 +192,11 @@ function* emitOutputResults({ outputFiles, assetFiles, errors, warnings, externa }; return; } - // Template updates only exist if no other JS changes have occurred - const hasTemplateUpdates = !!templateUpdates?.size; - if (hasTemplateUpdates) { - const updateResult = { - kind: results_1.ResultKind.ComponentUpdate, - updates: Array.from(templateUpdates, ([id, content]) => ({ - type: 'template', - id, - content, - })), - }; - yield updateResult; - } - // Use an incremental result if previous output information is available - if (previousOutputInfo) { - const incrementalResult = { - kind: results_1.ResultKind.Incremental, + // Use a full result if there is no rebuild state (no prior build result) + if (!rebuildState || !changes) { + const result = { + kind: results_1.ResultKind.Full, warnings: warnings, - added: [], - removed: [], - modified: [], files: {}, detail: { externalMetadata, @@ -216,57 +205,37 @@ function* emitOutputResults({ outputFiles, assetFiles, errors, warnings, externa outputOptions, }, }; - // Initially assume all previous output files have been removed - const removedOutputFiles = new Map(previousOutputInfo); - for (const file of outputFiles) { - removedOutputFiles.delete(file.path); - // Temporarily ignore JS files until Angular compiler plugin refactor to allow - // bypassing application code bundling for template affecting only changes. - // TODO: Remove once refactor is complete. - if (hasTemplateUpdates && /\.js(?:\.map)?$/.test(file.path)) { - continue; - } - const previousHash = previousOutputInfo.get(file.path)?.hash; - let needFile = false; - if (previousHash === undefined) { - needFile = true; - incrementalResult.added.push(file.path); - } - else if (previousHash !== file.hash) { - needFile = true; - incrementalResult.modified.push(file.path); - } - if (needFile) { - incrementalResult.files[file.path] = { - type: file.type, - contents: file.contents, - origin: 'memory', - hash: file.hash, - }; - } - } - // Include the removed output files - incrementalResult.removed.push(...Array.from(removedOutputFiles, ([file, { type }]) => ({ - path: file, - type, - }))); - // Always consider asset files as added to ensure new/modified assets are available. - // TODO: Consider more comprehensive asset analysis. for (const file of assetFiles) { - incrementalResult.added.push(file.destination); - incrementalResult.files[file.destination] = { + result.files[file.destination] = { type: bundler_context_1.BuildOutputFileType.Browser, inputPath: file.source, origin: 'disk', }; } - yield incrementalResult; + for (const file of outputFiles) { + result.files[file.path] = { + type: file.type, + contents: file.contents, + origin: 'memory', + hash: file.hash, + }; + } + yield result; return; } - // Otherwise, use a full result - const result = { - kind: results_1.ResultKind.Full, + // Template updates only exist if no other JS changes have occurred. + // A full page reload may be required based on the following incremental output change analysis. + const hasTemplateUpdates = !!templateUpdates?.size; + // Use an incremental result if previous output information is available + const { previousAssetsInfo, previousOutputInfo } = rebuildState; + const incrementalResult = { + kind: results_1.ResultKind.Incremental, warnings: warnings, + // Initially attempt to use a background update of files to support component updates. + background: hasTemplateUpdates, + added: [], + removed: [], + modified: [], files: {}, detail: { externalMetadata, @@ -275,20 +244,101 @@ function* emitOutputResults({ outputFiles, assetFiles, errors, warnings, externa outputOptions, }, }; - for (const file of assetFiles) { - result.files[file.destination] = { + let hasCssUpdates = false; + // Initially assume all previous output files have been removed + const removedOutputFiles = new Map(previousOutputInfo); + for (const file of outputFiles) { + removedOutputFiles.delete(file.path); + const previousHash = previousOutputInfo.get(file.path)?.hash; + let needFile = false; + if (previousHash === undefined) { + needFile = true; + incrementalResult.added.push(file.path); + } + else if (previousHash !== file.hash) { + needFile = true; + incrementalResult.modified.push(file.path); + } + if (needFile) { + if (file.path.endsWith('.css')) { + hasCssUpdates = true; + } + else if (!/(?:\.m?js|\.map)$/.test(file.path)) { + // Updates to non-JS files must signal an update with the dev server + incrementalResult.background = false; + } + incrementalResult.files[file.path] = { + type: file.type, + contents: file.contents, + origin: 'memory', + hash: file.hash, + }; + } + } + // Initially assume all previous assets files have been removed + const removedAssetFiles = new Map(previousAssetsInfo); + for (const { source, destination } of assetFiles) { + removedAssetFiles.delete(source); + if (!previousAssetsInfo.has(source)) { + incrementalResult.added.push(destination); + incrementalResult.background = false; + } + else if (changes.modified.has(source)) { + incrementalResult.modified.push(destination); + incrementalResult.background = false; + } + else { + continue; + } + hasCssUpdates ||= destination.endsWith('.css'); + incrementalResult.files[destination] = { type: bundler_context_1.BuildOutputFileType.Browser, - inputPath: file.source, + inputPath: source, origin: 'disk', }; } - for (const file of outputFiles) { - result.files[file.path] = { - type: file.type, - contents: file.contents, - origin: 'memory', - hash: file.hash, + // Do not remove stale files yet if there are template updates. + // Component chunk files may still be referenced in running browser code. + // Module evaluation time component updates will update any of these files. + // This typically occurs when a lazy component is changed that has not yet + // been accessed at runtime. + if (hasTemplateUpdates && incrementalResult.background) { + removedOutputFiles.clear(); + } + // Include the removed output and asset files + incrementalResult.removed.push(...Array.from(removedOutputFiles, ([file, { type }]) => ({ + path: file, + type, + })), ...Array.from(removedAssetFiles.values(), (file) => ({ + path: file, + type: bundler_context_1.BuildOutputFileType.Browser, + }))); + yield incrementalResult; + // If there are template updates and the incremental update was background only, a component + // update is possible. + if (hasTemplateUpdates && incrementalResult.background) { + // Template changes may be accompanied by stylesheet changes and these should also be updated hot when possible. + if (hasCssUpdates) { + const styleResult = { + kind: results_1.ResultKind.Incremental, + added: incrementalResult.added.filter(isCssFilePath), + removed: incrementalResult.removed.filter(({ path }) => isCssFilePath(path)), + modified: incrementalResult.modified.filter(isCssFilePath), + files: Object.fromEntries(Object.entries(incrementalResult.files).filter(([path]) => isCssFilePath(path))), + }; + yield styleResult; + } + const updateResult = { + kind: results_1.ResultKind.ComponentUpdate, + updates: Array.from(templateUpdates, ([id, content]) => ({ + type: 'template', + id, + content, + })), }; + yield updateResult; } - yield result; +} +function isCssFilePath(filePath) { + return /\.css(?:\.map)?$/i.test(filePath); } diff --git a/src/builders/application/execute-build.js b/src/builders/application/execute-build.js index 99edaab9..1c1d7526 100644 --- a/src/builders/application/execute-build.js +++ b/src/builders/application/execute-build.js @@ -41,6 +41,7 @@ var __importStar = (this && this.__importStar) || (function () { })(); Object.defineProperty(exports, "__esModule", { value: true }); exports.executeBuild = executeBuild; +const compilation_1 = require("../../tools/angular/compilation"); const source_file_cache_1 = require("../../tools/esbuild/angular/source-file-cache"); const budget_stats_1 = require("../../tools/esbuild/budget-stats"); const bundler_context_1 = require("../../tools/esbuild/bundler-context"); @@ -102,7 +103,9 @@ async function executeBuild(options, context, rebuildState) { if (options.templateUpdates) { templateUpdates = new Map(); } - bundlerContexts = (0, setup_bundling_1.setupBundlerContexts)(options, target, codeBundleCache, componentStyleBundler, templateUpdates); + bundlerContexts = (0, setup_bundling_1.setupBundlerContexts)(options, target, codeBundleCache, componentStyleBundler, + // Create new reusable compilation for the appropriate mode based on the `jit` plugin option + await (0, compilation_1.createAngularCompilation)(!!options.jit, !options.serverEntryPoint), templateUpdates); // Bundle everything on initial build bundlingResult = await bundler_context_1.BundlerContext.bundleAll([ ...bundlerContexts.typescriptContexts, @@ -145,7 +148,6 @@ async function executeBuild(options, context, rebuildState) { } const { metafile, initialFiles, outputFiles } = bundlingResult; executionResult.outputFiles.push(...outputFiles); - const changedFiles = rebuildState && executionResult.findChangedFiles(rebuildState.previousOutputInfo); // Analyze files for bundle budget failures if present let budgetFailures; if (options.budgets) { @@ -188,10 +190,6 @@ async function executeBuild(options, context, rebuildState) { if (serverEntryPoint) { executionResult.addOutputFile(manifest_1.SERVER_APP_ENGINE_MANIFEST_FILENAME, (0, manifest_1.generateAngularServerAppEngineManifest)(i18nOptions, baseHref), bundler_context_1.BuildOutputFileType.ServerRoot); } - // Override auto-CSP settings if we are serving through Vite middleware. - if (context.builder.builderName === 'dev-server' && options.security) { - options.security.autoCsp = false; - } // Perform i18n translation inlining if enabled if (i18nOptions.shouldInline) { const result = await (0, i18n_1.inlineI18n)(metafile, options, executionResult, initialFiles); @@ -215,6 +213,7 @@ async function executeBuild(options, context, rebuildState) { executionResult.addOutputFile('stats.json', JSON.stringify(metafile, null, 2), bundler_context_1.BuildOutputFileType.Root); } if (!jsonLogs) { + const changedFiles = rebuildState && executionResult.findChangedFiles(rebuildState.previousOutputInfo); executionResult.addLog((0, utils_1.logBuildStats)(metafile, outputFiles, initialFiles, budgetFailures, colors, changedFiles, estimatedTransferSizes, !!ssrOptions, verbose)); } return executionResult; diff --git a/src/builders/application/execute-post-bundle.js b/src/builders/application/execute-post-bundle.js index 70291cbd..1d412c64 100644 --- a/src/builders/application/execute-post-bundle.js +++ b/src/builders/application/execute-post-bundle.js @@ -38,7 +38,7 @@ async function executePostBundleSteps(metafile, options, outputFiles, assetFiles const allErrors = []; const allWarnings = []; const prerenderedRoutes = {}; - const { baseHref = '/', serviceWorker, i18nOptions, indexHtmlOptions, optimizationOptions, sourcemapOptions, outputMode, serverEntryPoint, prerenderOptions, appShellOptions, publicPath, workspaceRoot, partialSSRBuild, } = options; + const { baseHref = '/', serviceWorker, ssrOptions, indexHtmlOptions, optimizationOptions, sourcemapOptions, outputMode, serverEntryPoint, prerenderOptions, appShellOptions, publicPath, workspaceRoot, partialSSRBuild, } = options; // Index HTML content without CSS inlining to be used for server rendering (AppShell, SSG and SSR). // NOTE: Critical CSS inlining is deliberately omitted here, as it will be handled during server rendering. // Additionally, when using prerendering or AppShell, the index HTML file may be regenerated. @@ -57,7 +57,7 @@ async function executePostBundleSteps(metafile, options, outputFiles, assetFiles } // Create server manifest const initialFilesPaths = new Set(initialFiles.keys()); - if (serverEntryPoint) { + if (serverEntryPoint && (outputMode || prerenderOptions || appShellOptions || ssrOptions)) { const { manifestContent, serverAssetsChunks } = (0, manifest_1.generateAngularServerAppManifest)(additionalHtmlOutputFiles, outputFiles, optimizationOptions.styles.inlineCritical ?? false, undefined, locale, baseHref, initialFilesPaths, metafile, publicPath); additionalOutputFiles.push(...serverAssetsChunks, (0, utils_1.createOutputFile)(manifest_1.SERVER_APP_MANIFEST_FILENAME, manifestContent, bundler_context_1.BuildOutputFileType.ServerApplication)); } diff --git a/src/builders/application/i18n.js b/src/builders/application/i18n.js index 4119d902..b299c6c2 100644 --- a/src/builders/application/i18n.js +++ b/src/builders/application/i18n.js @@ -26,12 +26,13 @@ const options_1 = require("./options"); * @param initialFiles A map containing initial file information for the executed build. */ async function inlineI18n(metafile, options, executionResult, initialFiles) { - const { i18nOptions, optimizationOptions, baseHref } = options; + const { i18nOptions, optimizationOptions, baseHref, cacheOptions } = options; // Create the multi-threaded inliner with common options and the files generated from the build. const inliner = new i18n_inliner_1.I18nInliner({ missingTranslation: i18nOptions.missingTranslationBehavior ?? 'warning', outputFiles: executionResult.outputFiles, shouldOptimize: optimizationOptions.scripts, + persistentCachePath: cacheOptions.enabled ? cacheOptions.path : undefined, }, environment_options_1.maxWorkers); const inlineResult = { errors: [], @@ -41,6 +42,8 @@ async function inlineI18n(metafile, options, executionResult, initialFiles) { // For each active locale, use the inliner to process the output files of the build. const updatedOutputFiles = []; const updatedAssetFiles = []; + // Root and SSR entry files are not modified. + const unModifiedOutputFiles = executionResult.outputFiles.filter(({ type }) => type === bundler_context_1.BuildOutputFileType.Root || type === bundler_context_1.BuildOutputFileType.ServerRoot); try { for (const locale of i18nOptions.inlineLocales) { // A locale specific set of files is returned from the inliner. @@ -51,7 +54,7 @@ async function inlineI18n(metafile, options, executionResult, initialFiles) { const { errors, warnings, additionalAssets, additionalOutputFiles, prerenderedRoutes: generatedRoutes, } = await (0, execute_post_bundle_1.executePostBundleSteps)(metafile, { ...options, baseHref: (0, options_1.getLocaleBaseHref)(baseHref, i18nOptions, locale) ?? baseHref, - }, localeOutputFiles, executionResult.assetFiles, initialFiles, locale); + }, [...unModifiedOutputFiles, ...localeOutputFiles], executionResult.assetFiles, initialFiles, locale); localeOutputFiles.push(...additionalOutputFiles); inlineResult.errors.push(...errors); inlineResult.warnings.push(...warnings); @@ -81,7 +84,7 @@ async function inlineI18n(metafile, options, executionResult, initialFiles) { // Update the result with all localized files. executionResult.outputFiles = [ // Root and SSR entry files are not modified. - ...executionResult.outputFiles.filter(({ type }) => type === bundler_context_1.BuildOutputFileType.Root || type === bundler_context_1.BuildOutputFileType.ServerRoot), + ...unModifiedOutputFiles, // Updated files for each locale. ...updatedOutputFiles, ]; diff --git a/src/builders/application/index.d.ts b/src/builders/application/index.d.ts index 42549e41..11369ae6 100644 --- a/src/builders/application/index.d.ts +++ b/src/builders/application/index.d.ts @@ -5,7 +5,7 @@ * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.dev/license */ -import { BuilderContext, BuilderOutput } from '@angular-devkit/architect'; +import { Builder, BuilderContext, BuilderOutput } from '@angular-devkit/architect'; import { ApplicationBuilderExtensions, ApplicationBuilderInternalOptions } from './options'; import { Result } from './results'; import { Schema as ApplicationBuilderOptions } from './schema'; @@ -28,5 +28,5 @@ export declare function buildApplicationInternal(options: ApplicationBuilderInte * @returns The build output results of the build. */ export declare function buildApplication(options: ApplicationBuilderOptions, context: BuilderContext, extensions?: ApplicationBuilderExtensions): AsyncIterable; -declare const _default: import("../../../../../angular_devkit/architect/src/internal").Builder; -export default _default; +declare const builder: Builder; +export default builder; diff --git a/src/builders/application/index.js b/src/builders/application/index.js index 116ad942..05e0399a 100644 --- a/src/builders/application/index.js +++ b/src/builders/application/index.js @@ -196,4 +196,5 @@ function generateFullPath(filePath, type, outputOptions) { const fullFilePath = node_path_1.default.join(outputOptions.base, typeDirectory, filePath); return fullFilePath; } -exports.default = (0, architect_1.createBuilder)(buildApplication); +const builder = (0, architect_1.createBuilder)(buildApplication); +exports.default = builder; diff --git a/src/builders/application/options.d.ts b/src/builders/application/options.d.ts index 6ad2ab01..ed7e118f 100644 --- a/src/builders/application/options.d.ts +++ b/src/builders/application/options.d.ts @@ -196,7 +196,11 @@ export declare function normalizeOptions(context: BuilderContext, projectName: s partialSSRBuild: boolean; externalRuntimeStyles: boolean | undefined; instrumentForCoverage: ((filename: string) => boolean) | undefined; - security: import("./schema").Security | undefined; + security: { + autoCsp: { + unsafeEval: boolean; + } | undefined; + }; templateUpdates: boolean; incrementalResults: boolean; }>; diff --git a/src/builders/application/options.js b/src/builders/application/options.js index f40a3273..06d26f11 100644 --- a/src/builders/application/options.js +++ b/src/builders/application/options.js @@ -63,7 +63,7 @@ async function normalizeOptions(context, projectName, options, extensions) { // Gather persistent caching option and provide a project specific cache location const cacheOptions = (0, normalize_cache_1.normalizeCacheOptions)(projectMetadata, workspaceRoot); cacheOptions.path = node_path_1.default.join(cacheOptions.path, projectName); - const i18nOptions = (0, i18n_options_1.createI18nOptions)(projectMetadata, options.localize, context.logger); + const i18nOptions = (0, i18n_options_1.createI18nOptions)(projectMetadata, options.localize, context.logger, !!options.ssr); i18nOptions.duplicateTranslationBehavior = options.i18nDuplicateTranslation; i18nOptions.missingTranslationBehavior = options.i18nMissingTranslation; if (options.forceI18nFlatOutput) { @@ -236,8 +236,16 @@ async function normalizeOptions(context, projectName, options, extensions) { throw new Error('The "index" option cannot be set to false when enabling "ssr", "prerender" or "app-shell".'); } } + const autoCsp = options.security?.autoCsp; + const security = { + autoCsp: autoCsp + ? { + unsafeEval: autoCsp === true ? false : !!autoCsp.unsafeEval, + } + : undefined, + }; // Initial options to keep - const { allowedCommonJsDependencies, aot = true, baseHref, crossOrigin, externalDependencies, extractLicenses, inlineStyleLanguage = 'css', outExtension, serviceWorker, poll, polyfills, statsJson, outputMode, stylePreprocessorOptions, subresourceIntegrity, verbose, watch, progress = true, externalPackages, namedChunks, budgets, deployUrl, clearScreen, define, partialSSRBuild = false, externalRuntimeStyles, instrumentForCoverage, security, } = options; + const { allowedCommonJsDependencies, aot = true, baseHref, crossOrigin, externalDependencies, extractLicenses, inlineStyleLanguage = 'css', outExtension, serviceWorker, poll, polyfills, statsJson, outputMode, stylePreprocessorOptions, subresourceIntegrity, verbose, watch, progress = true, externalPackages, namedChunks, budgets, deployUrl, clearScreen, define, partialSSRBuild = false, externalRuntimeStyles, instrumentForCoverage, } = options; // Return all the normalized options return { advancedOptimizations: !!aot && optimizationOptions.scripts, diff --git a/src/builders/application/results.d.ts b/src/builders/application/results.d.ts index e7bba898..9bc82d50 100644 --- a/src/builders/application/results.d.ts +++ b/src/builders/application/results.d.ts @@ -29,6 +29,7 @@ export interface FullResult extends BaseResult { } export interface IncrementalResult extends BaseResult { kind: ResultKind.Incremental; + background?: boolean; added: string[]; removed: { path: string; diff --git a/src/builders/application/schema.d.ts b/src/builders/application/schema.d.ts index 6965b7f0..ee155991 100644 --- a/src/builders/application/schema.d.ts +++ b/src/builders/application/schema.d.ts @@ -1,7 +1,7 @@ /** * Application builder target options */ -export interface Schema { +export type Schema = { /** * A list of CommonJS or AMD packages that are allowed to be used without a build time * warning. Use `'*'` to allow all. @@ -208,9 +208,9 @@ export interface Schema { * TypeScript configuration for Web Worker modules. */ webWorkerTsConfig?: string; -} +}; export type AssetPattern = AssetPatternClass | string; -export interface AssetPatternClass { +export type AssetPatternClass = { /** * Allow glob patterns to follow symlink directories. This allows subdirectories of the * symlink to be searched. @@ -232,8 +232,8 @@ export interface AssetPatternClass { * Absolute path within the output. */ output?: string; -} -export interface Budget { +}; +export type Budget = { /** * The baseline size for comparison. */ @@ -270,7 +270,7 @@ export interface Budget { * The threshold for warning relative to the baseline (min & max). */ warning?: string; -} +}; /** * The type of budget. */ @@ -291,10 +291,10 @@ export declare enum CrossOrigin { None = "none", UseCredentials = "use-credentials" } -export interface FileReplacement { +export type FileReplacement = { replace: string; with: string; -} +}; /** * How to handle duplicate translations for i18n. * @@ -309,7 +309,7 @@ export declare enum I18NTranslation { * Configures the generation of the application's HTML index. */ export type IndexUnion = boolean | IndexObject | string; -export interface IndexObject { +export type IndexObject = { /** * The path of a file to use for the application's generated HTML index. */ @@ -325,7 +325,7 @@ export interface IndexObject { */ preloadInitial?: boolean; [property: string]: any; -} +}; /** * The stylesheet language to use for the application's inline component styles. */ @@ -346,7 +346,7 @@ export type Localize = string[] | boolean; * https://angular.dev/reference/configs/workspace-config#optimization-configuration. */ export type OptimizationUnion = boolean | OptimizationClass; -export interface OptimizationClass { +export type OptimizationClass = { /** * Enables optimization for fonts. This option requires internet access. `HTTPS_PROXY` * environment variable can be used to specify a proxy server. @@ -360,25 +360,25 @@ export interface OptimizationClass { * Enables optimization of the styles output. */ styles?: StylesUnion; -} +}; /** * Enables optimization for fonts. This option requires internet access. `HTTPS_PROXY` * environment variable can be used to specify a proxy server. */ export type FontsUnion = boolean | FontsClass; -export interface FontsClass { +export type FontsClass = { /** * Reduce render blocking requests by inlining external Google Fonts and Adobe Fonts CSS * definitions in the application's HTML index file. This option requires internet access. * `HTTPS_PROXY` environment variable can be used to specify a proxy server. */ inline?: boolean; -} +}; /** * Enables optimization of the styles output. */ export type StylesUnion = boolean | StylesClass; -export interface StylesClass { +export type StylesClass = { /** * Extract and inline critical CSS definitions to improve first paint time. */ @@ -393,7 +393,7 @@ export interface StylesClass { * '//!' or '/*!'. */ removeSpecialComments?: boolean; -} +}; /** * Define the output filename cache-busting hashing mode. */ @@ -416,7 +416,7 @@ export declare enum OutputMode { * Specify the output path relative to workspace root. */ export type OutputPathUnion = OutputPathClass | string; -export interface OutputPathClass { +export type OutputPathClass = { /** * Specify the output path relative to workspace root. */ @@ -436,12 +436,12 @@ export interface OutputPathClass { * 'server'. */ server?: string; -} +}; /** * Prerender (SSG) pages of your application during build time. */ export type PrerenderUnion = boolean | PrerenderClass; -export interface PrerenderClass { +export type PrerenderClass = { /** * Whether the builder should process the Angular Router configuration to find all * unparameterized routes and prerender them. @@ -452,9 +452,9 @@ export interface PrerenderClass { * newlines. This option is useful if you want to prerender routes with parameterized URLs. */ routesFile?: string; -} +}; export type ScriptElement = ScriptClass | string; -export interface ScriptClass { +export type ScriptClass = { /** * The bundle name for this extra entry point. */ @@ -467,32 +467,32 @@ export interface ScriptClass { * The file to include. */ input: string; -} +}; /** * Security features to protect against XSS and other common attacks */ -export interface Security { +export type Security = { /** * Enables automatic generation of a hash-based Strict Content Security Policy * (https://web.dev/articles/strict-csp#choose-hash) based on scripts in index.html. Will * default to true once we are out of experimental/preview phases. */ autoCsp?: AutoCspUnion; -} +}; /** * Enables automatic generation of a hash-based Strict Content Security Policy * (https://web.dev/articles/strict-csp#choose-hash) based on scripts in index.html. Will * default to true once we are out of experimental/preview phases. */ export type AutoCspUnion = boolean | AutoCspClass; -export interface AutoCspClass { +export type AutoCspClass = { /** * Include the `unsafe-eval` directive (https://web.dev/articles/strict-csp#remove-eval) in * the auto-CSP. Please only enable this if you are absolutely sure that you need to, as * allowing calls to eval will weaken the XSS defenses provided by the auto-CSP. */ unsafeEval?: boolean; -} +}; /** * Generates a service worker configuration. */ @@ -502,7 +502,7 @@ export type ServiceWorker = boolean | string; * https://angular.dev/reference/configs/workspace-config#source-map-configuration. */ export type SourceMapUnion = boolean | SourceMapClass; -export interface SourceMapClass { +export type SourceMapClass = { /** * Output source maps used for error reporting tools. */ @@ -519,12 +519,12 @@ export interface SourceMapClass { * Resolve vendor packages source maps. */ vendor?: boolean; -} +}; /** * Server side render (SSR) pages of your application during runtime. */ export type SsrUnion = boolean | SsrClass; -export interface SsrClass { +export type SsrClass = { /** * The server entry-point that when executed will spawn the web server. */ @@ -543,7 +543,7 @@ export interface SsrClass { * versions. */ experimentalPlatform?: ExperimentalPlatform; -} +}; /** * Specifies the platform for which the server bundle is generated. This affects the APIs * and modules available in the server-side code. @@ -564,7 +564,7 @@ export declare enum ExperimentalPlatform { /** * Options to pass to style preprocessors. */ -export interface StylePreprocessorOptions { +export type StylePreprocessorOptions = { /** * Paths to include. Paths will be resolved to workspace root. */ @@ -573,11 +573,11 @@ export interface StylePreprocessorOptions { * Options to pass to the sass preprocessor. */ sass?: Sass; -} +}; /** * Options to pass to the sass preprocessor. */ -export interface Sass { +export type Sass = { /** * A set of deprecations to treat as fatal. If a deprecation warning of any provided type is * encountered during compilation, the compiler will error instead. If a Version is @@ -595,9 +595,9 @@ export interface Sass { * encountered during compilation, the compiler will ignore it instead. */ silenceDeprecations?: string[]; -} +}; export type StyleElement = StyleClass | string; -export interface StyleClass { +export type StyleClass = { /** * The bundle name for this extra entry point. */ @@ -610,4 +610,4 @@ export interface StyleClass { * The file to include. */ input: string; -} +}; diff --git a/src/builders/application/setup-bundling.d.ts b/src/builders/application/setup-bundling.d.ts index 7561c1cb..ab0253c7 100644 --- a/src/builders/application/setup-bundling.d.ts +++ b/src/builders/application/setup-bundling.d.ts @@ -5,6 +5,7 @@ * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.dev/license */ +import { AngularCompilation } from '../../tools/angular/compilation'; import { ComponentStylesheetBundler } from '../../tools/esbuild/angular/component-stylesheets'; import { SourceFileCache } from '../../tools/esbuild/angular/source-file-cache'; import { BundlerContext } from '../../tools/esbuild/bundler-context'; @@ -17,7 +18,7 @@ import { NormalizedApplicationBuildOptions } from './options'; * @param codeBundleCache An instance of the TypeScript source file cache. * @returns An array of BundlerContext objects. */ -export declare function setupBundlerContexts(options: NormalizedApplicationBuildOptions, target: string[], codeBundleCache: SourceFileCache, stylesheetBundler: ComponentStylesheetBundler, templateUpdates: Map | undefined): { +export declare function setupBundlerContexts(options: NormalizedApplicationBuildOptions, target: string[], codeBundleCache: SourceFileCache, stylesheetBundler: ComponentStylesheetBundler, angularCompilation: AngularCompilation, templateUpdates: Map | undefined): { typescriptContexts: BundlerContext[]; otherContexts: BundlerContext[]; }; diff --git a/src/builders/application/setup-bundling.js b/src/builders/application/setup-bundling.js index 38f3cea9..3d7e1ebf 100644 --- a/src/builders/application/setup-bundling.js +++ b/src/builders/application/setup-bundling.js @@ -23,12 +23,12 @@ const utils_1 = require("../../tools/esbuild/utils"); * @param codeBundleCache An instance of the TypeScript source file cache. * @returns An array of BundlerContext objects. */ -function setupBundlerContexts(options, target, codeBundleCache, stylesheetBundler, templateUpdates) { +function setupBundlerContexts(options, target, codeBundleCache, stylesheetBundler, angularCompilation, templateUpdates) { const { outputMode, serverEntryPoint, appShellOptions, prerenderOptions, ssrOptions, workspaceRoot, watch = false, } = options; const typescriptContexts = []; const otherContexts = []; // Browser application code - typescriptContexts.push(new bundler_context_1.BundlerContext(workspaceRoot, watch, (0, application_code_bundle_1.createBrowserCodeBundleOptions)(options, target, codeBundleCache, stylesheetBundler, templateUpdates))); + typescriptContexts.push(new bundler_context_1.BundlerContext(workspaceRoot, watch, (0, application_code_bundle_1.createBrowserCodeBundleOptions)(options, target, codeBundleCache, stylesheetBundler, angularCompilation, templateUpdates))); // Browser polyfills code const browserPolyfillBundleOptions = (0, application_code_bundle_1.createBrowserPolyfillBundleOptions)(options, target, codeBundleCache, stylesheetBundler); if (browserPolyfillBundleOptions) { diff --git a/src/builders/dev-server/index.d.ts b/src/builders/dev-server/index.d.ts index 2757603f..3e860d1e 100644 --- a/src/builders/dev-server/index.d.ts +++ b/src/builders/dev-server/index.d.ts @@ -5,10 +5,11 @@ * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.dev/license */ +import { Builder } from '@angular-devkit/architect'; import { execute } from './builder'; import type { DevServerBuilderOutput } from './output'; import type { Schema as DevServerBuilderOptions } from './schema'; export { type DevServerBuilderOptions, type DevServerBuilderOutput, execute as executeDevServerBuilder, }; -declare const _default: import("../../../../../angular_devkit/architect/src/internal").Builder; -export default _default; +declare const builder: Builder; +export default builder; export { execute as executeDevServer }; diff --git a/src/builders/dev-server/index.js b/src/builders/dev-server/index.js index 7c7352ce..599dd7c4 100644 --- a/src/builders/dev-server/index.js +++ b/src/builders/dev-server/index.js @@ -12,4 +12,5 @@ const architect_1 = require("@angular-devkit/architect"); const builder_1 = require("./builder"); Object.defineProperty(exports, "executeDevServerBuilder", { enumerable: true, get: function () { return builder_1.execute; } }); Object.defineProperty(exports, "executeDevServer", { enumerable: true, get: function () { return builder_1.execute; } }); -exports.default = (0, architect_1.createBuilder)(builder_1.execute); +const builder = (0, architect_1.createBuilder)(builder_1.execute); +exports.default = builder; diff --git a/src/builders/dev-server/options.d.ts b/src/builders/dev-server/options.d.ts index 27f9091e..ed8e1021 100644 --- a/src/builders/dev-server/options.d.ts +++ b/src/builders/dev-server/options.d.ts @@ -44,4 +44,5 @@ export declare function normalizeOptions(context: BuilderContext, projectName: s host?: string; port?: number; }; + allowedHosts: true | string[]; }>; diff --git a/src/builders/dev-server/options.js b/src/builders/dev-server/options.js index 3c5222f5..58dc59e6 100644 --- a/src/builders/dev-server/options.js +++ b/src/builders/dev-server/options.js @@ -73,7 +73,7 @@ async function normalizeOptions(context, projectName, options) { } } // Initial options to keep - const { host, port, poll, open, verbose, watch, liveReload, hmr, headers, proxyConfig, servePath, ssl, sslCert, sslKey, prebundle, } = options; + const { host, port, poll, open, verbose, watch, liveReload, hmr, headers, proxyConfig, servePath, ssl, sslCert, sslKey, prebundle, allowedHosts, } = options; // Return all the normalized options return { buildTarget, @@ -97,5 +97,6 @@ async function normalizeOptions(context, projectName, options) { // Prebundling defaults to true but requires caching to function prebundle: cacheOptions.enabled && !optimization.scripts && prebundle, inspect, + allowedHosts: allowedHosts ? allowedHosts : [], }; } diff --git a/src/builders/dev-server/schema.d.ts b/src/builders/dev-server/schema.d.ts index 59647810..cb68a19f 100644 --- a/src/builders/dev-server/schema.d.ts +++ b/src/builders/dev-server/schema.d.ts @@ -1,7 +1,13 @@ /** * Dev Server target options for Build Facade. */ -export interface Schema { +export type Schema = { + /** + * The hosts that the development server will respond to. This option sets the Vite option + * of the same name. For further details: + * https://vite.dev/config/server-options.html#server-allowedhosts + */ + allowedHosts?: AllowedHosts; /** * A build builder target to serve in the format of `project:target[:configuration]`. You * can also pass in more than one configuration name as a comma-separated list. Example: @@ -78,7 +84,13 @@ export interface Schema { * Rebuild on change. */ watch?: boolean; -} +}; +/** + * The hosts that the development server will respond to. This option sets the Vite option + * of the same name. For further details: + * https://vite.dev/config/server-options.html#server-allowedhosts + */ +export type AllowedHosts = string[] | boolean; /** * Activate debugging inspector. This option only has an effect when 'SSR' or 'SSG' are * enabled. @@ -89,10 +101,10 @@ export type Inspect = boolean | string; * enable prebundling, the Angular CLI cache must also be enabled. */ export type PrebundleUnion = boolean | PrebundleClass; -export interface PrebundleClass { +export type PrebundleClass = { /** * List of package imports that should not be prebundled by the development server. The * packages will be bundled into the application code itself. */ exclude: string[]; -} +}; diff --git a/src/builders/dev-server/schema.json b/src/builders/dev-server/schema.json index 2eb16987..c36d8614 100644 --- a/src/builders/dev-server/schema.json +++ b/src/builders/dev-server/schema.json @@ -36,6 +36,23 @@ "type": "string", "description": "SSL certificate to use for serving HTTPS." }, + "allowedHosts": { + "description": "The hosts that the development server will respond to. This option sets the Vite option of the same name. For further details: https://vite.dev/config/server-options.html#server-allowedhosts", + "default": [], + "oneOf": [ + { + "type": "array", + "description": "A list of hosts that the development server will respond to.", + "items": { + "type": "string" + } + }, + { + "type": "boolean", + "description": "Indicates that all hosts are allowed. This is not recommended and a security risk." + } + ] + }, "headers": { "type": "object", "description": "Custom HTTP headers to be added to all responses.", diff --git a/src/builders/dev-server/vite-server.d.ts b/src/builders/dev-server/vite-server.d.ts index c09a7338..2452ec43 100644 --- a/src/builders/dev-server/vite-server.d.ts +++ b/src/builders/dev-server/vite-server.d.ts @@ -23,6 +23,10 @@ interface OutputFileRecord { servable: boolean; type: BuildOutputFileType; } +interface OutputAssetRecord { + source: string; + updated: boolean; +} interface DevServerExternalResultMetadata extends Omit { explicitBrowser: string[]; explicitServer: string[]; @@ -34,5 +38,5 @@ export declare function serveWithVite(serverOptions: NormalizedDevServerOptions, middleware?: Connect.NextHandleFunction[]; buildPlugins?: Plugin[]; }): AsyncIterableIterator; -export declare function setupServer(serverOptions: NormalizedDevServerOptions, outputFiles: Map, assets: Map, preserveSymlinks: boolean | undefined, externalMetadata: DevServerExternalResultMetadata, ssrMode: ServerSsrMode, prebundleTransformer: JavaScriptTransformer, target: string[], zoneless: boolean, componentStyles: Map, templateUpdates: Map, prebundleLoaderExtensions: EsbuildLoaderOption | undefined, define: ApplicationBuilderInternalOptions['define'], extensionMiddleware?: Connect.NextHandleFunction[], indexHtmlTransformer?: (content: string) => Promise, thirdPartySourcemaps?: boolean): Promise; +export declare function setupServer(serverOptions: NormalizedDevServerOptions, outputFiles: Map, assets: Map, preserveSymlinks: boolean | undefined, externalMetadata: DevServerExternalResultMetadata, ssrMode: ServerSsrMode, prebundleTransformer: JavaScriptTransformer, target: string[], zoneless: boolean, componentStyles: Map, templateUpdates: Map, prebundleLoaderExtensions: EsbuildLoaderOption | undefined, define: ApplicationBuilderInternalOptions['define'], extensionMiddleware?: Connect.NextHandleFunction[], indexHtmlTransformer?: (content: string) => Promise, thirdPartySourcemaps?: boolean): Promise; export {}; diff --git a/src/builders/dev-server/vite-server.js b/src/builders/dev-server/vite-server.js index f3e14d29..09f6e661 100644 --- a/src/builders/dev-server/vite-server.js +++ b/src/builders/dev-server/vite-server.js @@ -82,6 +82,17 @@ async function* serveWithVite(serverOptions, builderName, builderAction, context browserOptions.prerender = undefined; browserOptions.ssr ||= true; } + // Disable auto CSP. + browserOptions.security = { + autoCsp: false, + }; + // Disable JSON build stats. + // These are not accessible with the dev server and can cause HMR fallbacks. + if (browserOptions.statsJson === true) { + context.logger.warn('Build JSON statistics output (`statsJson` option) has been disabled.' + + ' The development server does not support this option.'); + } + browserOptions.statsJson = false; // Set all packages as external to support Vite's prebundle caching browserOptions.externalPackages = serverOptions.prebundle; // Disable generating a full manifest with routes. @@ -103,13 +114,19 @@ async function* serveWithVite(serverOptions, builderName, builderAction, context // https://nodejs.org/api/process.html#processsetsourcemapsenabledval process.setSourceMapsEnabled(true); } - // Enable to support link-based component style hot reloading (`NG_HMR_CSTYLES=0` can be used to disable selectively) + // Enable to support link-based component style hot reloading (`NG_HMR_CSTYLES=1` can be used to enable) browserOptions.externalRuntimeStyles = serverOptions.liveReload && serverOptions.hmr && environment_options_1.useComponentStyleHmr; // Enable to support component template hot replacement (`NG_HMR_TEMPLATE=0` can be used to disable selectively) // This will also replace file-based/inline styles as code if external runtime styles are not enabled. browserOptions.templateUpdates = serverOptions.liveReload && serverOptions.hmr && environment_options_1.useComponentTemplateHmr; + if (browserOptions.templateUpdates) { + context.logger.warn('Component HMR has been enabled.\n' + + 'If you encounter application reload issues, you can manually reload the page to bypass HMR and/or disable this feature with the' + + ' `--no-hmr` command line option.\n' + + 'Please consider reporting any issues you encounter here: https://github.com/angular/angular-cli/issues\n'); + } browserOptions.incrementalResults = true; // Setup the prebundling transformer that will be shared across Vite prebundling requests const prebundleTransformer = new internal_1.JavaScriptTransformer( @@ -155,6 +172,7 @@ async function* serveWithVite(serverOptions, builderName, builderAction, context }, }); } + yield { baseUrl: '', success: false }; continue; } // Clear existing error overlay on successful result @@ -166,6 +184,7 @@ async function* serveWithVite(serverOptions, builderName, builderAction, context updates: [], }); } + let needClientUpdate = true; switch (result.kind) { case results_1.ResultKind.Full: if (result.detail?.['htmlIndexPath']) { @@ -182,47 +201,33 @@ async function* serveWithVite(serverOptions, builderName, builderAction, context assetFiles.clear(); componentStyles.clear(); generatedFiles.clear(); - for (const entry of Object.entries(result.files)) { - const [outputPath, file] = entry; - if (file.origin === 'disk') { - assetFiles.set('/' + normalizePath(outputPath), normalizePath(file.inputPath)); - continue; - } - updateResultRecord(outputPath, file, normalizePath, htmlIndexPath, generatedFiles, componentStyles, + for (const [outputPath, file] of Object.entries(result.files)) { + updateResultRecord(outputPath, file, normalizePath, htmlIndexPath, generatedFiles, assetFiles, componentStyles, // The initial build will not yet have a server setup !server); } - // Invalidate SSR module graph to ensure that only new rebuild is used and not stale component updates - if (server && browserOptions.ssr && templateUpdates.size > 0) { - server.moduleGraph.invalidateAll(); - } // Clear stale template updates on code rebuilds templateUpdates.clear(); break; case results_1.ResultKind.Incremental: (0, node_assert_1.default)(server, 'Builder must provide an initial full build before incremental results.'); + // Background updates should only update server files/options + needClientUpdate = !result.background; for (const removed of result.removed) { const filePath = '/' + normalizePath(removed.path); generatedFiles.delete(filePath); assetFiles.delete(filePath); } for (const modified of result.modified) { - updateResultRecord(modified, result.files[modified], normalizePath, htmlIndexPath, generatedFiles, componentStyles); + updateResultRecord(modified, result.files[modified], normalizePath, htmlIndexPath, generatedFiles, assetFiles, componentStyles); } for (const added of result.added) { - updateResultRecord(added, result.files[added], normalizePath, htmlIndexPath, generatedFiles, componentStyles); + updateResultRecord(added, result.files[added], normalizePath, htmlIndexPath, generatedFiles, assetFiles, componentStyles); } break; case results_1.ResultKind.ComponentUpdate: (0, node_assert_1.default)(serverOptions.hmr, 'Component updates are only supported with HMR enabled.'); (0, node_assert_1.default)(server, 'Builder must provide an initial full build before component update results.'); - // Invalidate SSR module graph to ensure that new component updates are used - // TODO: Use fine-grained invalidation of only the component update modules - if (browserOptions.ssr) { - server.moduleGraph.invalidateAll(); - const { ɵresetCompiledComponents } = (await server.ssrLoadModule('/main.server.mjs')); - ɵresetCompiledComponents(); - } for (const componentUpdate of result.updates) { if (componentUpdate.type === 'template') { templateUpdates.set(componentUpdate.id, componentUpdate.content); @@ -262,9 +267,15 @@ async function* serveWithVite(serverOptions, builderName, builderAction, context if (server) { // Update fs allow list to include any new assets from the build option. server.config.server.fs.allow = [ - ...new Set([...server.config.server.fs.allow, ...assetFiles.values()]), + ...new Set([ + ...server.config.server.fs.allow, + ...[...assetFiles.values()].map(({ source }) => source), + ]), ]; - await handleUpdate(normalizePath, generatedFiles, server, serverOptions, context.logger, componentStyles); + const updatedFiles = await invalidateUpdatedFiles(normalizePath, generatedFiles, assetFiles, server); + if (needClientUpdate) { + handleUpdate(server, serverOptions, context.logger, componentStyles, updatedFiles); + } } else { const projectName = context.target?.project; @@ -305,6 +316,46 @@ async function* serveWithVite(serverOptions, builderName, builderAction, context const serverConfiguration = await setupServer(serverOptions, generatedFiles, assetFiles, browserOptions.preserveSymlinks, externalMetadata, ssrMode, prebundleTransformer, target, (0, internal_1.isZonelessApp)(polyfills), componentStyles, templateUpdates, browserOptions.loader, browserOptions.define, extensions?.middleware, transformers?.indexHtml, thirdPartySourcemaps); server = await createServer(serverConfiguration); await server.listen(); + // Setup builder context logging for browser clients + server.hot.on('angular:log', (data) => { + if (typeof data?.text !== 'string') { + context.logger.warn('Development server client sent invalid internal log event.'); + } + switch (data.kind) { + case 'error': + context.logger.error(`[CLIENT ERROR]: ${data.text}`); + break; + case 'warning': + context.logger.warn(`[CLIENT WARNING]: ${data.text}`); + break; + default: + context.logger.info(`[CLIENT INFO]: ${data.text}`); + break; + } + }); + // Setup component HMR invalidation + // Invalidation occurs when the runtime cannot update a component + server.hot.on('angular:invalidate', (data) => { + if (typeof data?.id !== 'string') { + context.logger.warn('Development server client sent invalid internal invalidate event.'); + } + // Clear invalid template update + templateUpdates.delete(data.id); + // Some cases are expected unsupported update scenarios but some may be errors. + // If an error occurred, log the error in addition to the invalidation. + if (data.error) { + context.logger.error(`Component update failed${data.message ? `: ${data.message}` : '.'}` + + '\nPlease consider reporting the error at https://github.com/angular/angular-cli/issues'); + } + else { + context.logger.warn(`Component update unsupported${data.message ? `: ${data.message}` : '.'}`); + } + server?.ws.send({ + type: 'full-reload', + path: '*', + }); + context.logger.info('Page reload sent to client(s).'); + }); const urls = server.resolvedUrls; if (urls && (urls.local.length || urls.network.length)) { serverUrl = new URL(urls.local[0] ?? urls.network[0]); @@ -337,33 +388,51 @@ async function* serveWithVite(serverOptions, builderName, builderAction, context } await new Promise((resolve) => (deferred = resolve)); } -async function handleUpdate(normalizePath, generatedFiles, server, serverOptions, logger, componentStyles) { +/** + * Invalidates any updated asset or generated files and resets their `updated` state. + * This function also clears the server application cache when necessary. + * + * @returns A list of files that were updated and invalidated. + */ +async function invalidateUpdatedFiles(normalizePath, generatedFiles, assetFiles, server) { const updatedFiles = []; - let destroyAngularServerAppCalled = false; + // Invalidate any updated asset + for (const [file, record] of assetFiles) { + if (!record.updated) { + continue; + } + record.updated = false; + updatedFiles.push(file); + } // Invalidate any updated files + let serverApplicationChanged = false; for (const [file, record] of generatedFiles) { if (!record.updated) { continue; } record.updated = false; - if (record.type === internal_1.BuildOutputFileType.ServerApplication && !destroyAngularServerAppCalled) { - // Clear the server app cache - // This must be done before module invalidation. - const { ɵdestroyAngularServerApp } = (await server.ssrLoadModule('/main.server.mjs')); - ɵdestroyAngularServerApp(); - destroyAngularServerAppCalled = true; - } updatedFiles.push(file); + serverApplicationChanged ||= record.type === internal_1.BuildOutputFileType.ServerApplication; const updatedModules = server.moduleGraph.getModulesByFile(normalizePath((0, node_path_1.join)(server.config.root, file))); updatedModules?.forEach((m) => server.moduleGraph.invalidateModule(m)); } + if (serverApplicationChanged) { + // Clear the server app cache and + // trigger module evaluation before reload to initiate dependency optimization. + const { ɵdestroyAngularServerApp } = (await server.ssrLoadModule('/main.server.mjs')); + ɵdestroyAngularServerApp(); + } + return updatedFiles; +} +/** + * Handles updates for the client by sending HMR or full page reload commands + * based on the updated files. It also ensures proper tracking of component styles and determines if + * a full reload is needed. + */ +function handleUpdate(server, serverOptions, logger, componentStyles, updatedFiles) { if (!updatedFiles.length) { return; } - if (destroyAngularServerAppCalled) { - // Trigger module evaluation before reload to initiate dependency optimization. - await server.ssrLoadModule('/main.server.mjs'); - } if (serverOptions.hmr) { if (updatedFiles.every((f) => f.endsWith('.css'))) { let requiresReload = false; @@ -403,7 +472,7 @@ async function handleUpdate(normalizePath, generatedFiles, server, serverOptions type: 'update', updates, }); - logger.info('HMR update sent to client(s).'); + logger.info('Stylesheet update sent to client(s).'); return; } } @@ -419,8 +488,12 @@ async function handleUpdate(normalizePath, generatedFiles, server, serverOptions logger.info('Page reload sent to client(s).'); } } -function updateResultRecord(outputPath, file, normalizePath, htmlIndexPath, generatedFiles, componentStyles, initial = false) { +function updateResultRecord(outputPath, file, normalizePath, htmlIndexPath, generatedFiles, assetFiles, componentStyles, initial = false) { if (file.origin === 'disk') { + assetFiles.set('/' + normalizePath(outputPath), { + source: normalizePath(file.inputPath), + updated: !initial, + }); return; } let filePath; @@ -468,6 +541,7 @@ function updateResultRecord(outputPath, file, normalizePath, htmlIndexPath, gene } } } +// eslint-disable-next-line max-lines-per-function async function setupServer(serverOptions, outputFiles, assets, preserveSymlinks, externalMetadata, ssrMode, prebundleTransformer, target, zoneless, componentStyles, templateUpdates, prebundleLoaderExtensions, define, extensionMiddleware, indexHtmlTransformer, thirdPartySourcemaps = false) { const proxy = await (0, utils_2.loadProxyConfiguration)(serverOptions.workspaceRoot, serverOptions.proxyConfig); // dynamically import Vite for ESM compatibility @@ -484,6 +558,14 @@ async function setupServer(serverOptions, outputFiles, assets, preserveSymlinks, ssrFiles = ['./main.server.mjs', './server.mjs']; break; } + /** + * Required when using `externalDependencies` to prevent Vite load errors. + * + * @note Can be removed if Vite introduces native support for externals. + * @note Vite misresolves browser modules in SSR when accessing URLs with multiple segments + * (e.g., 'foo/bar'), as they are not correctly re-based from the base href. + */ + const preTransformRequests = externalMetadata.explicitBrowser.length === 0 && ssrMode === plugins_1.ServerSsrMode.NoSsr; const cacheDir = (0, node_path_1.join)(serverOptions.cacheOptions.path, serverOptions.buildTarget.project, 'vite'); const configuration = { configFile: false, @@ -512,7 +594,11 @@ async function setupServer(serverOptions, outputFiles, assets, preserveSymlinks, mainFields: ['es2020', 'browser', 'module', 'main'], preserveSymlinks, }, + dev: { + preTransformRequests, + }, server: { + preTransformRequests, warmup: { ssrFiles, }, @@ -520,6 +606,7 @@ async function setupServer(serverOptions, outputFiles, assets, preserveSymlinks, strictPort: true, host: serverOptions.host, open: serverOptions.open, + allowedHosts: serverOptions.allowedHosts, headers: serverOptions.headers, // Disable the websocket if live reload is disabled (false/undefined are the only valid values) ws: serverOptions.liveReload === false && serverOptions.hmr === false ? false : undefined, @@ -532,6 +619,9 @@ async function setupServer(serverOptions, outputFiles, assets, preserveSymlinks, ? (proxy ?? {}) : proxy, cors: { + // This will add the header `Access-Control-Allow-Origin: http://example.com`, + // where `http://example.com` is the requesting origin. + origin: true, // Allow preflight requests to be proxied. preflightContinue: true, }, @@ -542,11 +632,12 @@ async function setupServer(serverOptions, outputFiles, assets, preserveSymlinks, // The first two are required for Vite to function in prebundling mode (the default) and to load // the Vite client-side code for browser reloading. These would be available by default but when // the `allow` option is explicitly configured, they must be included manually. - allow: [cacheDir, (0, node_path_1.join)(serverOptions.workspaceRoot, 'node_modules'), ...assets.values()], + allow: [ + cacheDir, + (0, node_path_1.join)(serverOptions.workspaceRoot, 'node_modules'), + ...[...assets.values()].map(({ source }) => source), + ], }, - // This is needed when `externalDependencies` is used to prevent Vite load errors. - // NOTE: If Vite adds direct support for externals, this can be removed. - preTransformRequests: externalMetadata.explicitBrowser.length === 0, }, ssr: { // Note: `true` and `/.*/` have different sematics. When true, the `external` option is ignored. @@ -579,6 +670,7 @@ async function setupServer(serverOptions, outputFiles, assets, preserveSymlinks, componentStyles, templateUpdates, ssrMode, + resetComponentUpdates: () => templateUpdates.clear(), }), (0, plugins_1.createRemoveIdPrefixPlugin)(externalMetadata.explicitBrowser), await (0, plugins_1.createAngularSsrTransformPlugin)(serverOptions.workspaceRoot), @@ -587,7 +679,7 @@ async function setupServer(serverOptions, outputFiles, assets, preserveSymlinks, outputFiles, templateUpdates, external: externalMetadata.explicitBrowser, - skipViteClient: serverOptions.liveReload === false && serverOptions.hmr === false, + disableViteTransport: !serverOptions.liveReload, }), ], // Browser only optimizeDeps. (This does not run for SSR dependencies). diff --git a/src/builders/extract-i18n/index.d.ts b/src/builders/extract-i18n/index.d.ts index 030f922a..b8eb54ba 100644 --- a/src/builders/extract-i18n/index.d.ts +++ b/src/builders/extract-i18n/index.d.ts @@ -5,8 +5,9 @@ * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.dev/license */ +import { Builder } from '@angular-devkit/architect'; import { execute } from './builder'; import type { Schema as ExtractI18nBuilderOptions } from './schema'; export { ExtractI18nBuilderOptions, execute }; -declare const _default: import("../../../../../angular_devkit/architect/src/internal").Builder; -export default _default; +declare const builder: Builder; +export default builder; diff --git a/src/builders/extract-i18n/index.js b/src/builders/extract-i18n/index.js index a33fef33..f204f9f9 100644 --- a/src/builders/extract-i18n/index.js +++ b/src/builders/extract-i18n/index.js @@ -11,4 +11,5 @@ exports.execute = void 0; const architect_1 = require("@angular-devkit/architect"); const builder_1 = require("./builder"); Object.defineProperty(exports, "execute", { enumerable: true, get: function () { return builder_1.execute; } }); -exports.default = (0, architect_1.createBuilder)(builder_1.execute); +const builder = (0, architect_1.createBuilder)(builder_1.execute); +exports.default = builder; diff --git a/src/builders/extract-i18n/schema.d.ts b/src/builders/extract-i18n/schema.d.ts index c77f0cd2..fa72d336 100644 --- a/src/builders/extract-i18n/schema.d.ts +++ b/src/builders/extract-i18n/schema.d.ts @@ -1,7 +1,7 @@ /** * Extract i18n target options for Build Facade. */ -export interface Schema { +export type Schema = { /** * A builder target to extract i18n messages in the format of * `project:target[:configuration]`. You can also pass in more than one configuration name @@ -24,7 +24,7 @@ export interface Schema { * Log progress to the console. */ progress?: boolean; -} +}; /** * Output format for the generated file. */ diff --git a/src/builders/ng-packagr/index.d.ts b/src/builders/ng-packagr/index.d.ts index 08421cbb..d0890676 100644 --- a/src/builders/ng-packagr/index.d.ts +++ b/src/builders/ng-packagr/index.d.ts @@ -5,8 +5,9 @@ * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.dev/license */ +import { Builder } from '@angular-devkit/architect'; import { execute } from './builder'; import type { Schema as NgPackagrBuilderOptions } from './schema'; export { type NgPackagrBuilderOptions, execute }; -declare const _default: import("../../../../../angular_devkit/architect/src/internal").Builder; -export default _default; +declare const builder: Builder; +export default builder; diff --git a/src/builders/ng-packagr/index.js b/src/builders/ng-packagr/index.js index a33fef33..f204f9f9 100644 --- a/src/builders/ng-packagr/index.js +++ b/src/builders/ng-packagr/index.js @@ -11,4 +11,5 @@ exports.execute = void 0; const architect_1 = require("@angular-devkit/architect"); const builder_1 = require("./builder"); Object.defineProperty(exports, "execute", { enumerable: true, get: function () { return builder_1.execute; } }); -exports.default = (0, architect_1.createBuilder)(builder_1.execute); +const builder = (0, architect_1.createBuilder)(builder_1.execute); +exports.default = builder; diff --git a/src/builders/ng-packagr/schema.d.ts b/src/builders/ng-packagr/schema.d.ts index 96d571fa..5607385c 100644 --- a/src/builders/ng-packagr/schema.d.ts +++ b/src/builders/ng-packagr/schema.d.ts @@ -1,7 +1,7 @@ /** * ng-packagr target options for Build Architect. Use to build library projects. */ -export interface Schema { +export type Schema = { /** * Enable and define the file watching poll time period in milliseconds. */ @@ -18,4 +18,4 @@ export interface Schema { * Run build when files change. */ watch?: boolean; -} +}; diff --git a/src/private.d.ts b/src/private.d.ts index 1b9eeb7f..adfb7d03 100644 --- a/src/private.d.ts +++ b/src/private.d.ts @@ -5,12 +5,6 @@ * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.dev/license */ -/** - * @fileoverview - * Private exports intended only for use with the @angular-devkit/build-angular package. - * All exports are not supported for external use, do not provide SemVer guarantees, and - * their existence may change in any future version. - */ import { CompilerPluginOptions } from './tools/esbuild/angular/compiler-plugin'; import { BundleStylesheetOptions } from './tools/esbuild/stylesheets/bundle-options'; export { buildApplicationInternal } from './builders/application'; @@ -25,7 +19,10 @@ export { SassWorkerImplementation } from './tools/sass/sass-service'; export { SourceFileCache } from './tools/esbuild/angular/source-file-cache'; export { createJitResourceTransformer } from './tools/angular/transformers/jit-resource-transformer'; export { JavaScriptTransformer } from './tools/esbuild/javascript-transformer'; -export declare function createCompilerPlugin(pluginOptions: CompilerPluginOptions, styleOptions: BundleStylesheetOptions & { +export declare function createCompilerPlugin(pluginOptions: CompilerPluginOptions & { + browserOnlyBuild?: boolean; + noopTypeScriptCompilation?: boolean; +}, styleOptions: BundleStylesheetOptions & { inlineStyleLanguage: string; }): import('esbuild').Plugin; export * from './utils/bundle-calculator'; diff --git a/src/private.js b/src/private.js index c634cd9f..6367133e 100644 --- a/src/private.js +++ b/src/private.js @@ -29,6 +29,7 @@ exports.createCompilerPlugin = createCompilerPlugin; * All exports are not supported for external use, do not provide SemVer guarantees, and * their existence may change in any future version. */ +const compilation_1 = require("./tools/angular/compilation"); const compiler_plugin_1 = require("./tools/esbuild/angular/compiler-plugin"); const component_stylesheets_1 = require("./tools/esbuild/angular/component-stylesheets"); // Builders @@ -53,7 +54,9 @@ Object.defineProperty(exports, "createJitResourceTransformer", { enumerable: tru var javascript_transformer_1 = require("./tools/esbuild/javascript-transformer"); Object.defineProperty(exports, "JavaScriptTransformer", { enumerable: true, get: function () { return javascript_transformer_1.JavaScriptTransformer; } }); function createCompilerPlugin(pluginOptions, styleOptions) { - return (0, compiler_plugin_1.createCompilerPlugin)(pluginOptions, new component_stylesheets_1.ComponentStylesheetBundler(styleOptions, styleOptions.inlineStyleLanguage, pluginOptions.incremental)); + return (0, compiler_plugin_1.createCompilerPlugin)(pluginOptions, pluginOptions.noopTypeScriptCompilation + ? new compilation_1.NoopCompilation() + : () => (0, compilation_1.createAngularCompilation)(!!pluginOptions.jit, !!pluginOptions.browserOnlyBuild), new component_stylesheets_1.ComponentStylesheetBundler(styleOptions, styleOptions.inlineStyleLanguage, pluginOptions.incremental)); } // Utilities __exportStar(require("./utils/bundle-calculator"), exports); diff --git a/src/tools/angular/compilation/angular-compilation.js b/src/tools/angular/compilation/angular-compilation.js index 3b835313..53168ea8 100644 --- a/src/tools/angular/compilation/angular-compilation.js +++ b/src/tools/angular/compilation/angular-compilation.js @@ -80,6 +80,9 @@ class AngularCompilation { enableResourceInlining: false, supportTestBed: false, supportJitMode: false, + // Disable removing of comments as TS is quite aggressive with these and can + // remove important annotations, such as /* @__PURE__ */ and comments like /* vite-ignore */. + removeComments: false, })); } async diagnoseFiles(modes = DiagnosticModes.All) { diff --git a/src/tools/angular/compilation/aot-compilation.js b/src/tools/angular/compilation/aot-compilation.js index d95688ab..6cd382f4 100644 --- a/src/tools/angular/compilation/aot-compilation.js +++ b/src/tools/angular/compilation/aot-compilation.js @@ -120,7 +120,11 @@ class AotCompilation extends angular_compilation_1.AngularCompilation { relativePath = relativePath.replaceAll('\\', '/'); const updateId = encodeURIComponent(`${host.getCanonicalFileName(relativePath)}@${node.name?.text}`); const updateText = angularCompiler.emitHmrUpdateModule(node); - if (updateText === null) { + // If compiler cannot generate an update for the component, prevent template updates. + // Also prevent template updates if $localize is directly present which also currently + // prevents a template update at runtime. + // TODO: Support localized template update modules and remove this check. + if (updateText === null || updateText.includes('$localize')) { // Build is needed if a template cannot be updated templateUpdates = undefined; break; diff --git a/src/tools/esbuild/angular/compiler-plugin.d.ts b/src/tools/esbuild/angular/compiler-plugin.d.ts index fa0abd9b..7fb0bb9e 100644 --- a/src/tools/esbuild/angular/compiler-plugin.d.ts +++ b/src/tools/esbuild/angular/compiler-plugin.d.ts @@ -6,6 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ import type { Plugin } from 'esbuild'; +import { AngularCompilation } from '../../angular/compilation'; import { LoadResultCache } from '../load-result-cache'; import { ComponentStylesheetBundler } from './component-stylesheets'; import { SourceFileCache } from './source-file-cache'; @@ -13,9 +14,6 @@ export interface CompilerPluginOptions { sourcemap: boolean | 'external'; tsconfig: string; jit?: boolean; - browserOnlyBuild?: boolean; - /** Skip TypeScript compilation setup. This is useful to re-use the TypeScript compilation from another plugin. */ - noopTypeScriptCompilation?: boolean; advancedOptimizations?: boolean; thirdPartySourcemaps?: boolean; fileReplacements?: Record; @@ -26,4 +24,4 @@ export interface CompilerPluginOptions { instrumentForCoverage?: (request: string) => boolean; templateUpdates?: Map; } -export declare function createCompilerPlugin(pluginOptions: CompilerPluginOptions, stylesheetBundler: ComponentStylesheetBundler): Plugin; +export declare function createCompilerPlugin(pluginOptions: CompilerPluginOptions, compilationOrFactory: AngularCompilation | (() => Promise), stylesheetBundler: ComponentStylesheetBundler): Plugin; diff --git a/src/tools/esbuild/angular/compiler-plugin.js b/src/tools/esbuild/angular/compiler-plugin.js index f6eb144d..9e35394f 100644 --- a/src/tools/esbuild/angular/compiler-plugin.js +++ b/src/tools/esbuild/angular/compiler-plugin.js @@ -56,7 +56,7 @@ const compilation_state_1 = require("./compilation-state"); const file_reference_tracker_1 = require("./file-reference-tracker"); const jit_plugin_callbacks_1 = require("./jit-plugin-callbacks"); // eslint-disable-next-line max-lines-per-function -function createCompilerPlugin(pluginOptions, stylesheetBundler) { +function createCompilerPlugin(pluginOptions, compilationOrFactory, stylesheetBundler) { return { name: 'angular-compiler', // eslint-disable-next-line max-lines-per-function @@ -94,16 +94,17 @@ function createCompilerPlugin(pluginOptions, stylesheetBundler) { // Setup defines based on the values used by the Angular compiler-cli build.initialOptions.define ??= {}; build.initialOptions.define['ngI18nClosureMode'] ??= 'false'; + // The factory is only relevant for compatibility purposes with the private API. + // TODO: Update private API in the next major to allow compilation function factory removal here. + const compilation = typeof compilationOrFactory === 'function' + ? await compilationOrFactory() + : compilationOrFactory; // The in-memory cache of TypeScript file outputs will be used during the build in `onLoad` callbacks for TS files. // A string value indicates direct TS/NG output and a Uint8Array indicates fully transformed code. const typeScriptFileCache = pluginOptions.sourceFileCache?.typeScriptFileCache ?? new Map(); // The resources from component stylesheets and web workers that will be added to the build results output files const additionalResults = new Map(); - // Create new reusable compilation for the appropriate mode based on the `jit` plugin option - const compilation = pluginOptions.noopTypeScriptCompilation - ? new compilation_1.NoopCompilation() - : await (0, compilation_1.createAngularCompilation)(!!pluginOptions.jit, !!pluginOptions.browserOnlyBuild); // Compilation is initially assumed to have errors until emitted let hasCompilationErrors = true; // Determines if TypeScript should process JavaScript files based on tsconfig `allowJs` option @@ -128,8 +129,8 @@ function createCompilerPlugin(pluginOptions, stylesheetBundler) { // Angular compiler which does not have direct knowledge of transitive resource // dependencies or web worker processing. let modifiedFiles; - if (pluginOptions.sourceFileCache?.modifiedFiles.size && - !pluginOptions.noopTypeScriptCompilation) { + if (!(compilation instanceof compilation_1.NoopCompilation) && + pluginOptions.sourceFileCache?.modifiedFiles.size) { // TODO: Differentiate between changed input files and stale output files modifiedFiles = referencedFileTracker.update(pluginOptions.sourceFileCache.modifiedFiles); pluginOptions.sourceFileCache.invalidate(modifiedFiles); @@ -141,9 +142,7 @@ function createCompilerPlugin(pluginOptions, stylesheetBundler) { // Remove any stale additional results based on modified files modifiedFiles.forEach((file) => additionalResults.delete(file)); } - if (!pluginOptions.noopTypeScriptCompilation && - compilation.update && - pluginOptions.sourceFileCache?.modifiedFiles.size) { + if (compilation.update && pluginOptions.sourceFileCache?.modifiedFiles.size) { await compilation.update(modifiedFiles ?? pluginOptions.sourceFileCache.modifiedFiles); } // Create Angular compiler host options @@ -403,7 +402,7 @@ function createCompilerPlugin(pluginOptions, stylesheetBundler) { const replacement = pluginOptions.fileReplacements?.[path.normalize(args.path)]; if (replacement) { return { - contents: await Promise.resolve().then(() => __importStar(require('fs/promises'))).then(({ readFile }) => readFile(path.normalize(replacement))), + contents: await Promise.resolve().then(() => __importStar(require('node:fs/promises'))).then(({ readFile }) => readFile(path.normalize(replacement))), loader: 'json', watchFiles: [replacement], }; @@ -523,6 +522,18 @@ function createCompilerOptionsTransformer(setupWarnings, pluginOptions, preserve notes: [{ text: `The 'module' option will be set to 'ES2022' instead.` }], }); } + if (compilerOptions.isolatedModules && compilerOptions.emitDecoratorMetadata) { + setupWarnings?.push({ + text: `TypeScript compiler option 'isolatedModules' may prevent the 'emitDecoratorMetadata' option from emitting all metadata.`, + location: null, + notes: [ + { + text: `The 'emitDecoratorMetadata' option is not required by Angular` + + 'and can be removed if not explictly required by the project.', + }, + ], + }); + } // Synchronize custom resolve conditions. // Set if using the supported bundler resolution mode (bundler is the default in new projects) if (compilerOptions.moduleResolution === 100 /* ModuleResolutionKind.Bundler */) { diff --git a/src/tools/esbuild/application-code-bundle.d.ts b/src/tools/esbuild/application-code-bundle.d.ts index 323a248d..adf9cfea 100644 --- a/src/tools/esbuild/application-code-bundle.d.ts +++ b/src/tools/esbuild/application-code-bundle.d.ts @@ -7,11 +7,12 @@ */ import type { BuildOptions } from 'esbuild'; import type { NormalizedApplicationBuildOptions } from '../../builders/application/options'; +import { AngularCompilation } from '../angular/compilation'; import { ComponentStylesheetBundler } from './angular/component-stylesheets'; import { SourceFileCache } from './angular/source-file-cache'; import { BundlerOptionsFactory } from './bundler-context'; import type { LoadResultCache } from './load-result-cache'; -export declare function createBrowserCodeBundleOptions(options: NormalizedApplicationBuildOptions, target: string[], sourceFileCache: SourceFileCache, stylesheetBundler: ComponentStylesheetBundler, templateUpdates: Map | undefined): BundlerOptionsFactory; +export declare function createBrowserCodeBundleOptions(options: NormalizedApplicationBuildOptions, target: string[], sourceFileCache: SourceFileCache, stylesheetBundler: ComponentStylesheetBundler, angularCompilation: AngularCompilation, templateUpdates: Map | undefined): BundlerOptionsFactory; export declare function createBrowserPolyfillBundleOptions(options: NormalizedApplicationBuildOptions, target: string[], sourceFileCache: SourceFileCache, stylesheetBundler: ComponentStylesheetBundler): BuildOptions | BundlerOptionsFactory | undefined; export declare function createServerPolyfillBundleOptions(options: NormalizedApplicationBuildOptions, target: string[], loadResultCache: LoadResultCache | undefined): BundlerOptionsFactory | undefined; export declare function createServerMainCodeBundleOptions(options: NormalizedApplicationBuildOptions, target: string[], sourceFileCache: SourceFileCache, stylesheetBundler: ComponentStylesheetBundler): BundlerOptionsFactory; diff --git a/src/tools/esbuild/application-code-bundle.js b/src/tools/esbuild/application-code-bundle.js index 55848e09..dea33231 100644 --- a/src/tools/esbuild/application-code-bundle.js +++ b/src/tools/esbuild/application-code-bundle.js @@ -21,6 +21,7 @@ const node_path_1 = require("node:path"); const schema_1 = require("../../builders/application/schema"); const environment_options_1 = require("../../utils/environment-options"); const manifest_1 = require("../../utils/server-rendering/manifest"); +const compilation_1 = require("../angular/compilation"); const compiler_plugin_1 = require("./angular/compiler-plugin"); const angular_localize_init_warning_plugin_1 = require("./angular-localize-init-warning-plugin"); const compiler_plugin_options_1 = require("./compiler-plugin-options"); @@ -33,7 +34,7 @@ const sourcemap_ignorelist_plugin_1 = require("./sourcemap-ignorelist-plugin"); const utils_1 = require("./utils"); const virtual_module_plugin_1 = require("./virtual-module-plugin"); const wasm_plugin_1 = require("./wasm-plugin"); -function createBrowserCodeBundleOptions(options, target, sourceFileCache, stylesheetBundler, templateUpdates) { +function createBrowserCodeBundleOptions(options, target, sourceFileCache, stylesheetBundler, angularCompilation, templateUpdates) { return (loadCache) => { const { entryPoints, outputNames, polyfills } = options; const pluginOptions = (0, compiler_plugin_options_1.createCompilerPluginOptions)(options, sourceFileCache, loadCache, templateUpdates); @@ -57,7 +58,7 @@ function createBrowserCodeBundleOptions(options, target, sourceFileCache, styles (0, angular_localize_init_warning_plugin_1.createAngularLocalizeInitWarningPlugin)(), (0, compiler_plugin_1.createCompilerPlugin)( // JS/TS options - pluginOptions, + pluginOptions, angularCompilation, // Component stylesheet bundler stylesheetBundler), ], @@ -65,19 +66,7 @@ function createBrowserCodeBundleOptions(options, target, sourceFileCache, styles if (options.plugins) { buildOptions.plugins?.push(...options.plugins); } - if (options.externalPackages) { - // Package files affected by a customized loader should not be implicitly marked as external - if (options.loaderExtensions || - options.plugins || - typeof options.externalPackages === 'object') { - // Plugin must be added after custom plugins to ensure any added loader options are considered - buildOptions.plugins?.push((0, external_packages_plugin_1.createExternalPackagesPlugin)(options.externalPackages !== true ? options.externalPackages : undefined)); - } - else { - // Safe to use the packages external option directly - buildOptions.packages = 'external'; - } - } + appendOptionsForExternalPackages(options, buildOptions); return buildOptions; }; } @@ -109,7 +98,9 @@ function createBrowserPolyfillBundleOptions(options, target, sourceFileCache, st const pluginOptions = (0, compiler_plugin_options_1.createCompilerPluginOptions)(options, sourceFileCache); buildOptions.plugins.push((0, compiler_plugin_1.createCompilerPlugin)( // JS/TS options - { ...pluginOptions, noopTypeScriptCompilation: true }, + pluginOptions, + // Browser compilation handles the actual Angular code compilation + new compilation_1.NoopCompilation(), // Component stylesheet options are unused for polyfills but required by the plugin stylesheetBundler)); } @@ -201,16 +192,15 @@ function createServerMainCodeBundleOptions(options, target, sourceFileCache, sty (0, angular_localize_init_warning_plugin_1.createAngularLocalizeInitWarningPlugin)(), (0, compiler_plugin_1.createCompilerPlugin)( // JS/TS options - { ...pluginOptions, noopTypeScriptCompilation: true }, + pluginOptions, + // Browser compilation handles the actual Angular code compilation + new compilation_1.NoopCompilation(), // Component stylesheet bundler stylesheetBundler), ], }; buildOptions.plugins ??= []; - if (externalPackages) { - buildOptions.packages = 'external'; - } - else { + if (!externalPackages) { buildOptions.plugins.push((0, rxjs_esm_resolution_plugin_1.createRxjsEsmResolutionPlugin)()); } // Mark manifest and polyfills file as external as these are generated by a different bundle step. @@ -269,6 +259,7 @@ function createServerMainCodeBundleOptions(options, target, sourceFileCache, sty if (options.plugins) { buildOptions.plugins.push(...options.plugins); } + appendOptionsForExternalPackages(options, buildOptions); return buildOptions; }; } @@ -305,16 +296,15 @@ function createSsrEntryCodeBundleOptions(options, target, sourceFileCache, style (0, angular_localize_init_warning_plugin_1.createAngularLocalizeInitWarningPlugin)(), (0, compiler_plugin_1.createCompilerPlugin)( // JS/TS options - { ...pluginOptions, noopTypeScriptCompilation: true }, + pluginOptions, + // Browser compilation handles the actual Angular code compilation + new compilation_1.NoopCompilation(), // Component stylesheet bundler stylesheetBundler), ], }; buildOptions.plugins ??= []; - if (externalPackages) { - buildOptions.packages = 'external'; - } - else { + if (!externalPackages) { buildOptions.plugins.push((0, rxjs_esm_resolution_plugin_1.createRxjsEsmResolutionPlugin)()); } // Mark manifest file as external. As this will be generated later on. @@ -370,6 +360,7 @@ function createSsrEntryCodeBundleOptions(options, target, sourceFileCache, style if (options.plugins) { buildOptions.plugins.push(...options.plugins); } + appendOptionsForExternalPackages(options, buildOptions); return buildOptions; }; } @@ -528,3 +519,19 @@ function entryFileToWorkspaceRelative(workspaceRoot, entryFile) { .replace(/.[mc]?ts$/, '') .replace(/\\/g, '/')); } +function appendOptionsForExternalPackages(options, buildOptions) { + if (!options.externalPackages) { + return; + } + buildOptions.plugins ??= []; + // Package files affected by a customized loader should not be implicitly marked as external + if (options.loaderExtensions || options.plugins || typeof options.externalPackages === 'object') { + // Plugin must be added after custom plugins to ensure any added loader options are considered + buildOptions.plugins.push((0, external_packages_plugin_1.createExternalPackagesPlugin)(options.externalPackages !== true ? options.externalPackages : undefined)); + buildOptions.packages = undefined; + } + else { + // Safe to use the packages external option directly + buildOptions.packages = 'external'; + } +} diff --git a/src/tools/esbuild/bundler-execution-result.d.ts b/src/tools/esbuild/bundler-execution-result.d.ts index 6a490463..00c5b200 100644 --- a/src/tools/esbuild/bundler-execution-result.d.ts +++ b/src/tools/esbuild/bundler-execution-result.d.ts @@ -22,10 +22,11 @@ export interface RebuildState { componentStyleBundler: ComponentStylesheetBundler; codeBundleCache?: SourceFileCache; fileChanges: ChangedFiles; - previousOutputInfo: Map; + previousAssetsInfo: ReadonlyMap; templateUpdates?: Map; } export interface ExternalResultMetadata { @@ -86,7 +87,7 @@ export declare class ExecutionResult { }; get watchFiles(): Readonly; createRebuildState(fileChanges: ChangedFiles): RebuildState; - findChangedFiles(previousOutputHashes: Map): Set; diff --git a/src/tools/esbuild/bundler-execution-result.js b/src/tools/esbuild/bundler-execution-result.js index ede7b633..09dcd268 100644 --- a/src/tools/esbuild/bundler-execution-result.js +++ b/src/tools/esbuild/bundler-execution-result.js @@ -123,6 +123,7 @@ class ExecutionResult { componentStyleBundler: this.componentStyleBundler, fileChanges, previousOutputInfo: new Map(this.outputFiles.map(({ path, hash, type }) => [path, { hash, type }])), + previousAssetsInfo: new Map(this.assetFiles.map(({ source, destination }) => [source, destination])), templateUpdates: this.templateUpdates, }; } diff --git a/src/tools/esbuild/compiler-plugin-options.js b/src/tools/esbuild/compiler-plugin-options.js index 4d790286..3bc2dbf1 100644 --- a/src/tools/esbuild/compiler-plugin-options.js +++ b/src/tools/esbuild/compiler-plugin-options.js @@ -12,7 +12,6 @@ function createCompilerPluginOptions(options, sourceFileCache, loadResultCache, const { sourcemapOptions, tsconfig, fileReplacements, advancedOptimizations, jit, externalRuntimeStyles, instrumentForCoverage, } = options; const incremental = !!options.watch; return { - browserOnlyBuild: !options.serverEntryPoint, sourcemap: !!sourcemapOptions.scripts && (sourcemapOptions.hidden ? 'external' : true), thirdPartySourcemaps: sourcemapOptions.vendor, tsconfig, diff --git a/src/tools/esbuild/i18n-inliner.d.ts b/src/tools/esbuild/i18n-inliner.d.ts index 26c211df..47c2d11d 100644 --- a/src/tools/esbuild/i18n-inliner.d.ts +++ b/src/tools/esbuild/i18n-inliner.d.ts @@ -13,6 +13,7 @@ export interface I18nInlinerOptions { missingTranslation: 'error' | 'warning' | 'ignore'; outputFiles: BuildOutputFile[]; shouldOptimize?: boolean; + persistentCachePath?: string; } /** * A class that performs i18n translation inlining of JavaScript code. @@ -22,6 +23,7 @@ export interface I18nInlinerOptions { */ export declare class I18nInliner { #private; + private readonly options; constructor(options: I18nInlinerOptions, maxThreads?: number); /** * Performs inlining of translations for the provided locale and translations. The files that @@ -41,4 +43,11 @@ export declare class I18nInliner { * @returns A void promise that resolves when closing is complete. */ close(): Promise; + /** + * Initializes the cache for storing translated bundles. + * If the cache is already initialized, it does nothing. + * + * @returns A promise that resolves once the cache initialization process is complete. + */ + private initCache; } diff --git a/src/tools/esbuild/i18n-inliner.js b/src/tools/esbuild/i18n-inliner.js index d0c2dcdc..b4e7b167 100644 --- a/src/tools/esbuild/i18n-inliner.js +++ b/src/tools/esbuild/i18n-inliner.js @@ -6,12 +6,47 @@ * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.dev/license */ +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.I18nInliner = void 0; const node_assert_1 = __importDefault(require("node:assert")); +const node_crypto_1 = require("node:crypto"); +const node_path_1 = require("node:path"); const worker_pool_1 = require("../../utils/worker-pool"); const bundler_context_1 = require("./bundler-context"); const utils_1 = require("./utils"); @@ -27,37 +62,37 @@ const LOCALIZE_KEYWORD = '$localize'; * localize function (`$localize`). */ class I18nInliner { + options; + #cacheInitFailed = false; #workerPool; + #cache; #localizeFiles; #unmodifiedFiles; - #fileToType = new Map(); constructor(options, maxThreads) { + this.options = options; this.#unmodifiedFiles = []; + const { outputFiles, shouldOptimize, missingTranslation } = options; const files = new Map(); const pendingMaps = []; - for (const file of options.outputFiles) { + for (const file of outputFiles) { if (file.type === bundler_context_1.BuildOutputFileType.Root || file.type === bundler_context_1.BuildOutputFileType.ServerRoot) { // Skip also the server entry-point. // Skip stats and similar files. continue; } - this.#fileToType.set(file.path, file.type); - if (file.path.endsWith('.js') || file.path.endsWith('.mjs')) { + const fileExtension = (0, node_path_1.extname)(file.path); + if (fileExtension === '.js' || fileExtension === '.mjs') { // Check if localizations are present const contentBuffer = Buffer.isBuffer(file.contents) ? file.contents : Buffer.from(file.contents.buffer, file.contents.byteOffset, file.contents.byteLength); const hasLocalize = contentBuffer.includes(LOCALIZE_KEYWORD); if (hasLocalize) { - // A Blob is an immutable data structure that allows sharing the data between workers - // without copying until the data is actually used within a Worker. This is useful here - // since each file may not actually be processed in each Worker and the Blob avoids - // unneeded repeat copying of potentially large JavaScript files. - files.set(file.path, new Blob([file.contents])); + files.set(file.path, file); continue; } } - else if (file.path.endsWith('.js.map')) { + else if (fileExtension === '.map') { // The related JS file may not have been checked yet. To ensure that map files are not // missed, store any pending map files and check them after all output files. pendingMaps.push(file); @@ -68,7 +103,7 @@ class I18nInliner { // Check if any pending map files should be processed by checking if the parent JS file is present for (const file of pendingMaps) { if (files.has(file.path.slice(0, -4))) { - files.set(file.path, new Blob([file.contents])); + files.set(file.path, file); } else { this.#unmodifiedFiles.push(file); @@ -80,9 +115,13 @@ class I18nInliner { maxThreads, // Extract options to ensure only the named options are serialized and sent to the worker workerData: { - missingTranslation: options.missingTranslation, - shouldOptimize: options.shouldOptimize, - files, + missingTranslation, + shouldOptimize, + // A Blob is an immutable data structure that allows sharing the data between workers + // without copying until the data is actually used within a Worker. This is useful here + // since each file may not actually be processed in each Worker and the Blob avoids + // unneeded repeat copying of potentially large JavaScript files. + files: new Map(Array.from(files, ([name, file]) => [name, new Blob([file.contents])])), }, }); } @@ -95,18 +134,41 @@ class I18nInliner { * @returns A promise that resolves to an array of OutputFiles representing a translated result. */ async inlineForLocale(locale, translation) { + await this.initCache(); + const { shouldOptimize, missingTranslation } = this.options; // Request inlining for each file that contains localize calls const requests = []; - for (const filename of this.#localizeFiles.keys()) { + let fileCacheKeyBase; + for (const [filename, file] of this.#localizeFiles) { + let cacheKey; if (filename.endsWith('.map')) { continue; } - const fileRequest = this.#workerPool.run({ - filename, - locale, - translation, + let cacheResultPromise = Promise.resolve(null); + if (this.#cache) { + fileCacheKeyBase ??= Buffer.from(JSON.stringify({ locale, translation, missingTranslation, shouldOptimize }), 'utf-8'); + // NOTE: If additional options are added, this may need to be updated. + // TODO: Consider xxhash or similar instead of SHA256 + cacheKey = (0, node_crypto_1.createHash)('sha256') + .update(file.hash) + .update(filename) + .update(fileCacheKeyBase) + .digest('hex'); + // Failure to get the value should not fail the transform + cacheResultPromise = this.#cache.get(cacheKey).catch(() => null); + } + const fileResult = cacheResultPromise.then(async (cachedResult) => { + if (cachedResult) { + return cachedResult; + } + const result = await this.#workerPool.run({ filename, locale, translation }); + if (this.#cache && cacheKey) { + // Failure to set the value should not fail the transform + await this.#cache.set(cacheKey, result).catch(() => { }); + } + return result; }); - requests.push(fileRequest); + requests.push(fileResult); } // Wait for all file requests to complete const rawResults = await Promise.all(requests); @@ -115,7 +177,7 @@ class I18nInliner { const warnings = []; const outputFiles = [ ...rawResults.flatMap(({ file, code, map, messages }) => { - const type = this.#fileToType.get(file); + const type = this.#localizeFiles.get(file)?.type; (0, node_assert_1.default)(type !== undefined, 'localized file should always have a type' + file); const resultFiles = [(0, utils_1.createOutputFile)(file, code, type)]; if (map) { @@ -146,5 +208,32 @@ class I18nInliner { close() { return this.#workerPool.destroy(); } + /** + * Initializes the cache for storing translated bundles. + * If the cache is already initialized, it does nothing. + * + * @returns A promise that resolves once the cache initialization process is complete. + */ + async initCache() { + if (this.#cache || this.#cacheInitFailed) { + return; + } + const { persistentCachePath } = this.options; + // Webcontainers currently do not support this persistent cache store. + if (!persistentCachePath || process.versions.webcontainer) { + return; + } + // Initialize a persistent cache for i18n transformations. + try { + const { LmbdCacheStore } = await Promise.resolve().then(() => __importStar(require('./lmdb-cache-store'))); + this.#cache = new LmbdCacheStore((0, node_path_1.join)(persistentCachePath, 'angular-i18n.db')); + } + catch { + this.#cacheInitFailed = true; + // eslint-disable-next-line no-console + console.warn('Unable to initialize JavaScript cache storage.\n' + + 'This will not affect the build output content but may result in slower builds.'); + } + } } exports.I18nInliner = I18nInliner; diff --git a/src/tools/esbuild/index-html-generator.js b/src/tools/esbuild/index-html-generator.js index 6a82406c..d37f6ad1 100644 --- a/src/tools/esbuild/index-html-generator.js +++ b/src/tools/esbuild/index-html-generator.js @@ -59,13 +59,6 @@ async function generateIndexHtml(initialFiles, outputFiles, buildOptions, lang) } throw new Error(`Output file does not exist: ${relativefilePath}`); }; - // Read the Auto CSP options. - const autoCsp = buildOptions.security?.autoCsp; - const autoCspOptions = autoCsp === true - ? { unsafeEval: false } - : autoCsp - ? { unsafeEval: !!autoCsp.unsafeEval } - : undefined; // Create an index HTML generator that reads from the in-memory output files const indexHtmlGenerator = new index_html_generator_1.IndexHtmlGenerator({ indexPath: indexHtmlOptions.input, @@ -78,7 +71,7 @@ async function generateIndexHtml(initialFiles, outputFiles, buildOptions, lang) generateDedicatedSSRContent: !!(buildOptions.ssrOptions || buildOptions.prerenderOptions || buildOptions.appShellOptions), - autoCsp: autoCspOptions, + autoCsp: buildOptions.security.autoCsp, }); indexHtmlGenerator.readAsset = readAsset; return indexHtmlGenerator.process({ diff --git a/src/tools/esbuild/utils.js b/src/tools/esbuild/utils.js index 1e4cfa2b..4d2af550 100644 --- a/src/tools/esbuild/utils.js +++ b/src/tools/esbuild/utils.js @@ -35,6 +35,9 @@ const manifest_1 = require("../../utils/server-rendering/manifest"); const stats_table_1 = require("../../utils/stats-table"); const bundler_context_1 = require("./bundler-context"); function logBuildStats(metafile, outputFiles, initial, budgetFailures, colors, changedFiles, estimatedTransferSizes, ssrOutputEnabled, verbose) { + // Remove the i18n subpath in case the build is using i18n. + // en-US/main.js -> main.js + const normalizedChangedFiles = new Set([...(changedFiles ?? [])].map((f) => (0, node_path_1.basename)(f))); const browserStats = []; const serverStats = []; let unchangedCount = 0; @@ -45,7 +48,7 @@ function logBuildStats(metafile, outputFiles, initial, budgetFailures, colors, c continue; } // Show only changed files if a changed list is provided - if (changedFiles && !changedFiles.has(file)) { + if (normalizedChangedFiles.size && !normalizedChangedFiles.has(file)) { ++unchangedCount; continue; } diff --git a/src/tools/vite/middlewares/assets-middleware.d.ts b/src/tools/vite/middlewares/assets-middleware.d.ts index 6b90881c..40d5e46e 100644 --- a/src/tools/vite/middlewares/assets-middleware.d.ts +++ b/src/tools/vite/middlewares/assets-middleware.d.ts @@ -6,10 +6,10 @@ * found in the LICENSE file at https://angular.dev/license */ import type { Connect, ViteDevServer } from 'vite'; -import { AngularMemoryOutputFiles } from '../utils'; +import { AngularMemoryOutputFiles, AngularOutputAssets } from '../utils'; export interface ComponentStyleRecord { rawContent: Uint8Array; used?: Set; reload?: boolean; } -export declare function createAngularAssetsMiddleware(server: ViteDevServer, assets: Map, outputFiles: AngularMemoryOutputFiles, componentStyles: Map, encapsulateStyle: (style: Uint8Array, componentId: string) => string): Connect.NextHandleFunction; +export declare function createAngularAssetsMiddleware(server: ViteDevServer, assets: AngularOutputAssets, outputFiles: AngularMemoryOutputFiles, componentStyles: Map, encapsulateStyle: (style: Uint8Array, componentId: string) => string): Connect.NextHandleFunction; diff --git a/src/tools/vite/middlewares/assets-middleware.js b/src/tools/vite/middlewares/assets-middleware.js index 73c827c4..0ab4daa9 100644 --- a/src/tools/vite/middlewares/assets-middleware.js +++ b/src/tools/vite/middlewares/assets-middleware.js @@ -22,15 +22,15 @@ function createAngularAssetsMiddleware(server, assets, outputFiles, componentSty const extension = (0, node_path_1.extname)(pathname); const pathnameHasTrailingSlash = pathname[pathname.length - 1] === '/'; // Rewrite all build assets to a vite raw fs URL - const assetSourcePath = assets.get(pathname); - if (assetSourcePath !== undefined) { + const asset = assets.get(pathname); + if (asset) { // Workaround to disable Vite transformer middleware. // See: https://github.com/vitejs/vite/blob/746a1daab0395f98f0afbdee8f364cb6cf2f3b3f/packages/vite/src/node/server/middlewares/transform.ts#L201 and // https://github.com/vitejs/vite/blob/746a1daab0395f98f0afbdee8f364cb6cf2f3b3f/packages/vite/src/node/server/transformRequest.ts#L204-L206 req.headers.accept = 'text/html'; // The encoding needs to match what happens in the vite static middleware. // ref: https://github.com/vitejs/vite/blob/d4f13bd81468961c8c926438e815ab6b1c82735e/packages/vite/src/node/server/middlewares/static.ts#L163 - req.url = `${server.config.base}@fs/${encodeURI(assetSourcePath)}`; + req.url = `${server.config.base}@fs/${encodeURI(asset.source)}`; next(); return; } @@ -43,7 +43,7 @@ function createAngularAssetsMiddleware(server, assets, outputFiles, componentSty : // Non-trailing slash check for fallback `.html` assets.get(pathname + '.html'); if (htmlAssetSourcePath) { - req.url = `${server.config.base}@fs/${encodeURI(htmlAssetSourcePath)}`; + req.url = `${server.config.base}@fs/${encodeURI(htmlAssetSourcePath.source)}`; next(); return; } diff --git a/src/tools/vite/middlewares/component-middleware.d.ts b/src/tools/vite/middlewares/component-middleware.d.ts index d799662c..0cafa7e0 100644 --- a/src/tools/vite/middlewares/component-middleware.d.ts +++ b/src/tools/vite/middlewares/component-middleware.d.ts @@ -5,5 +5,5 @@ * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.dev/license */ -import type { Connect } from 'vite'; -export declare function createAngularComponentMiddleware(templateUpdates: ReadonlyMap): Connect.NextHandleFunction; +import type { Connect, ViteDevServer } from 'vite'; +export declare function createAngularComponentMiddleware(server: ViteDevServer, templateUpdates: ReadonlyMap): Connect.NextHandleFunction; diff --git a/src/tools/vite/middlewares/component-middleware.js b/src/tools/vite/middlewares/component-middleware.js index 62371c57..f086fde7 100644 --- a/src/tools/vite/middlewares/component-middleware.js +++ b/src/tools/vite/middlewares/component-middleware.js @@ -8,13 +8,15 @@ */ Object.defineProperty(exports, "__esModule", { value: true }); exports.createAngularComponentMiddleware = createAngularComponentMiddleware; +const utils_1 = require("../utils"); const ANGULAR_COMPONENT_PREFIX = '/@ng/component'; -function createAngularComponentMiddleware(templateUpdates) { +function createAngularComponentMiddleware(server, templateUpdates) { return function angularComponentMiddleware(req, res, next) { if (req.url === undefined || res.writableEnded) { return; } - if (!req.url.startsWith(ANGULAR_COMPONENT_PREFIX)) { + const pathname = (0, utils_1.pathnameWithoutBasePath)(req.url, server.config.base); + if (!pathname.includes(ANGULAR_COMPONENT_PREFIX)) { next(); return; } diff --git a/src/tools/vite/middlewares/html-fallback-middleware.js b/src/tools/vite/middlewares/html-fallback-middleware.js index cf697e5b..a2225bb2 100644 --- a/src/tools/vite/middlewares/html-fallback-middleware.js +++ b/src/tools/vite/middlewares/html-fallback-middleware.js @@ -20,7 +20,8 @@ function angularHtmlFallbackMiddleware(req, _res, next) { } if (req.url) { const mimeType = (0, utils_1.lookupMimeTypeFromRequest)(req.url); - if (mimeType === 'text/html' || mimeType === 'application/xhtml+xml') { + if ((mimeType === 'text/html' || mimeType === 'application/xhtml+xml') && + !/^\/index\.(?:csr\.)?html/.test(req.url)) { // eslint-disable-next-line no-console console.warn(`Request for HTML file "${req.url}" was received but no asset found. Asset may be missing from build.`); } diff --git a/src/tools/vite/middlewares/index-html-middleware.d.ts b/src/tools/vite/middlewares/index-html-middleware.d.ts index 74134000..5b21e651 100644 --- a/src/tools/vite/middlewares/index-html-middleware.d.ts +++ b/src/tools/vite/middlewares/index-html-middleware.d.ts @@ -7,4 +7,4 @@ */ import type { Connect, ViteDevServer } from 'vite'; import { AngularMemoryOutputFiles } from '../utils'; -export declare function createAngularIndexHtmlMiddleware(server: ViteDevServer, outputFiles: AngularMemoryOutputFiles, indexHtmlTransformer: ((content: string) => Promise) | undefined): Connect.NextHandleFunction; +export declare function createAngularIndexHtmlMiddleware(server: ViteDevServer, outputFiles: AngularMemoryOutputFiles, resetComponentUpdates: () => void, indexHtmlTransformer: ((content: string) => Promise) | undefined): Connect.NextHandleFunction; diff --git a/src/tools/vite/middlewares/index-html-middleware.js b/src/tools/vite/middlewares/index-html-middleware.js index 967485f4..7e5914bd 100644 --- a/src/tools/vite/middlewares/index-html-middleware.js +++ b/src/tools/vite/middlewares/index-html-middleware.js @@ -10,7 +10,7 @@ Object.defineProperty(exports, "__esModule", { value: true }); exports.createAngularIndexHtmlMiddleware = createAngularIndexHtmlMiddleware; const node_path_1 = require("node:path"); const utils_1 = require("../utils"); -function createAngularIndexHtmlMiddleware(server, outputFiles, indexHtmlTransformer) { +function createAngularIndexHtmlMiddleware(server, outputFiles, resetComponentUpdates, indexHtmlTransformer) { return function angularIndexHtmlMiddleware(req, res, next) { if (!req.url) { next(); @@ -29,6 +29,8 @@ function createAngularIndexHtmlMiddleware(server, outputFiles, indexHtmlTransfor next(); return; } + // A request for the index indicates a full page reload request. + resetComponentUpdates(); server .transformIndexHtml(req.url, Buffer.from(rawHtml).toString('utf-8')) .then(async (processedHtml) => { diff --git a/src/tools/vite/middlewares/ssr-middleware.js b/src/tools/vite/middlewares/ssr-middleware.js index 4a553789..40ab7573 100644 --- a/src/tools/vite/middlewares/ssr-middleware.js +++ b/src/tools/vite/middlewares/ssr-middleware.js @@ -22,6 +22,10 @@ function createAngularSsrInternalMiddleware(server, indexHtmlTransformer) { // which must be processed by the runtime linker, even if they are not used. await (0, load_esm_1.loadEsmModule)('@angular/compiler'); const { writeResponseToNodeResponse, createWebRequestFromNodeRequest } = await (0, load_esm_1.loadEsmModule)('@angular/ssr/node'); + // The following is necessary because accessing the module after invalidation may result in an empty module, + // which can trigger a `TypeError: ɵgetOrCreateAngularServerApp is not a function` error. + // TODO: look into why. + await server.ssrLoadModule('/main.server.mjs'); const { ɵgetOrCreateAngularServerApp } = (await server.ssrLoadModule('/main.server.mjs')); const angularServerApp = ɵgetOrCreateAngularServerApp({ allowStaticRouteRender: true, diff --git a/src/tools/vite/plugins/angular-memory-plugin.d.ts b/src/tools/vite/plugins/angular-memory-plugin.d.ts index d1b5fbfc..a1c69320 100644 --- a/src/tools/vite/plugins/angular-memory-plugin.d.ts +++ b/src/tools/vite/plugins/angular-memory-plugin.d.ts @@ -12,7 +12,7 @@ interface AngularMemoryPluginOptions { outputFiles: AngularMemoryOutputFiles; templateUpdates?: ReadonlyMap; external?: string[]; - skipViteClient?: boolean; + disableViteTransport?: boolean; } export declare function createAngularMemoryPlugin(options: AngularMemoryPluginOptions): Promise; export {}; diff --git a/src/tools/vite/plugins/angular-memory-plugin.js b/src/tools/vite/plugins/angular-memory-plugin.js index fda168e0..8bfd134d 100644 --- a/src/tools/vite/plugins/angular-memory-plugin.js +++ b/src/tools/vite/plugins/angular-memory-plugin.js @@ -14,9 +14,11 @@ exports.createAngularMemoryPlugin = createAngularMemoryPlugin; const node_assert_1 = __importDefault(require("node:assert")); const promises_1 = require("node:fs/promises"); const node_path_1 = require("node:path"); +const node_url_1 = require("node:url"); const load_esm_1 = require("../../../utils/load-esm"); const ANGULAR_PREFIX = '/@ng/'; const VITE_FS_PREFIX = '/@fs/'; +const FILE_PROTOCOL = 'file:'; async function createAngularMemoryPlugin(options) { const { virtualProjectRoot, outputFiles, external } = options; const { normalizePath } = await (0, load_esm_1.loadEsmModule)('vite'); @@ -29,8 +31,15 @@ async function createAngularMemoryPlugin(options) { return; } // For SSR with component HMR, pass through as a virtual module - if (ssr && source.startsWith(ANGULAR_PREFIX)) { - return '\0' + source; + if (ssr && source.startsWith(FILE_PROTOCOL) && source.includes(ANGULAR_PREFIX)) { + // Vite will resolve these these files example: + // `file:///@ng/component?c=src%2Fapp%2Fapp.component.ts%40AppComponent&t=1737017253850` + const sourcePath = (0, node_url_1.fileURLToPath)(source); + const sourceWithoutRoot = normalizePath('/' + (0, node_path_1.relative)(virtualProjectRoot, sourcePath)); + if (sourceWithoutRoot.startsWith(ANGULAR_PREFIX)) { + const [, query] = source.split('?', 2); + return `\0${sourceWithoutRoot}?${query}`; + } } // Prevent vite from resolving an explicit external dependency (`externalDependencies` option) if (external?.includes(source)) { @@ -38,24 +47,13 @@ async function createAngularMemoryPlugin(options) { // `/@id/${source}` but is currently closer to a raw external than a resolved file path. return source; } - if (importer) { + if (importer && source[0] === '.') { const normalizedImporter = normalizePath(importer); - if (source[0] === '.' && normalizedImporter.startsWith(virtualProjectRoot)) { + if (normalizedImporter.startsWith(virtualProjectRoot)) { // Remove query if present const [importerFile] = normalizedImporter.split('?', 1); source = '/' + (0, node_path_1.join)((0, node_path_1.dirname)((0, node_path_1.relative)(virtualProjectRoot, importerFile)), source); } - else if (!ssr && - source[0] === '/' && - importer.endsWith('index.html') && - normalizedImporter.startsWith(virtualProjectRoot)) { - // This is only needed when using SSR and `angularSsrMiddleware` (old style) to correctly resolve - // .js files when using lazy-loading. - // Remove query if present - const [importerFile] = normalizedImporter.split('?', 1); - source = - '/' + (0, node_path_1.join)((0, node_path_1.dirname)((0, node_path_1.relative)(virtualProjectRoot, importerFile)), (0, node_path_1.basename)(source)); - } } const [file] = source.split('?', 1); if (outputFiles.has(normalizePath(file))) { @@ -75,7 +73,7 @@ async function createAngularMemoryPlugin(options) { const codeContents = outputFiles.get(relativeFile)?.contents; if (codeContents === undefined) { if (relativeFile.endsWith('/node_modules/vite/dist/client/client.mjs')) { - return options.skipViteClient ? '' : loadViteClientCode(file); + return loadViteClientCode(file, options.disableViteTransport); } return undefined; } @@ -98,9 +96,9 @@ async function createAngularMemoryPlugin(options) { * @param file The absolute path to the Vite client code. * @returns */ -async function loadViteClientCode(file) { +async function loadViteClientCode(file, disableViteTransport = false) { const originalContents = await (0, promises_1.readFile)(file, 'utf-8'); - const updatedContents = originalContents.replace(`"You can also disable this overlay by setting ", + let updatedContents = originalContents.replace(`"You can also disable this overlay by setting ", h("code", { part: "config-option-name" }, "server.hmr.overlay"), " to ", h("code", { part: "config-option-value" }, "false"), @@ -108,5 +106,11 @@ async function loadViteClientCode(file) { h("code", { part: "config-file-name" }, hmrConfigName), "."`, ''); (0, node_assert_1.default)(originalContents !== updatedContents, 'Failed to update Vite client error overlay text.'); + if (disableViteTransport) { + const previousUpdatedContents = updatedContents; + updatedContents = updatedContents.replace('transport.connect(handleMessage)', ''); + (0, node_assert_1.default)(previousUpdatedContents !== updatedContents, 'Failed to update Vite client WebSocket disable.'); + updatedContents = updatedContents.replace('console.debug("[vite] connecting...")', ''); + } return updatedContents; } diff --git a/src/tools/vite/plugins/setup-middlewares-plugin.d.ts b/src/tools/vite/plugins/setup-middlewares-plugin.d.ts index 3a467bf0..e8b66d38 100644 --- a/src/tools/vite/plugins/setup-middlewares-plugin.d.ts +++ b/src/tools/vite/plugins/setup-middlewares-plugin.d.ts @@ -7,7 +7,7 @@ */ import type { Connect, Plugin } from 'vite'; import { ComponentStyleRecord } from '../middlewares'; -import { AngularMemoryOutputFiles } from '../utils'; +import { AngularMemoryOutputFiles, AngularOutputAssets } from '../utils'; export declare enum ServerSsrMode { /** * No SSR @@ -32,12 +32,13 @@ export declare enum ServerSsrMode { } interface AngularSetupMiddlewaresPluginOptions { outputFiles: AngularMemoryOutputFiles; - assets: Map; + assets: AngularOutputAssets; extensionMiddleware?: Connect.NextHandleFunction[]; indexHtmlTransformer?: (content: string) => Promise; componentStyles: Map; templateUpdates: Map; ssrMode: ServerSsrMode; + resetComponentUpdates: () => void; } export declare function createAngularSetupMiddlewaresPlugin(options: AngularSetupMiddlewaresPluginOptions): Plugin; export {}; diff --git a/src/tools/vite/plugins/setup-middlewares-plugin.js b/src/tools/vite/plugins/setup-middlewares-plugin.js index 94c437cd..7e3695ab 100644 --- a/src/tools/vite/plugins/setup-middlewares-plugin.js +++ b/src/tools/vite/plugins/setup-middlewares-plugin.js @@ -46,10 +46,10 @@ function createAngularSetupMiddlewaresPlugin(options) { name: 'vite:angular-setup-middlewares', enforce: 'pre', async configureServer(server) { - const { indexHtmlTransformer, outputFiles, extensionMiddleware, assets, componentStyles, templateUpdates, ssrMode, } = options; + const { indexHtmlTransformer, outputFiles, extensionMiddleware, assets, componentStyles, templateUpdates, ssrMode, resetComponentUpdates, } = options; // Headers, assets and resources get handled first server.middlewares.use((0, middlewares_1.createAngularHeadersMiddleware)(server)); - server.middlewares.use((0, middlewares_1.createAngularComponentMiddleware)(templateUpdates)); + server.middlewares.use((0, middlewares_1.createAngularComponentMiddleware)(server, templateUpdates)); server.middlewares.use((0, middlewares_1.createAngularAssetsMiddleware)(server, assets, outputFiles, componentStyles, await createEncapsulateStyle())); extensionMiddleware?.forEach((middleware) => server.middlewares.use(middleware)); // Returning a function, installs middleware after the main transform middleware but @@ -64,7 +64,7 @@ function createAngularSetupMiddlewaresPlugin(options) { server.middlewares.use((0, middlewares_1.createAngularSsrInternalMiddleware)(server, indexHtmlTransformer)); } server.middlewares.use(middlewares_1.angularHtmlFallbackMiddleware); - server.middlewares.use((0, middlewares_1.createAngularIndexHtmlMiddleware)(server, outputFiles, indexHtmlTransformer)); + server.middlewares.use((0, middlewares_1.createAngularIndexHtmlMiddleware)(server, outputFiles, resetComponentUpdates, indexHtmlTransformer)); }; }, }; diff --git a/src/tools/vite/utils.d.ts b/src/tools/vite/utils.d.ts index 350d692c..8cedb738 100644 --- a/src/tools/vite/utils.d.ts +++ b/src/tools/vite/utils.d.ts @@ -12,6 +12,9 @@ export type AngularMemoryOutputFiles = Map; +export type AngularOutputAssets = Map; export declare function pathnameWithoutBasePath(url: string, basePath: string): string; export declare function lookupMimeTypeFromRequest(url: string): string | undefined; export type EsbuildLoaderOption = Exclude['loader']; diff --git a/src/utils/environment-options.js b/src/utils/environment-options.js index 2140fffc..ac113cd1 100644 --- a/src/utils/environment-options.js +++ b/src/utils/environment-options.js @@ -83,7 +83,7 @@ exports.useJSONBuildLogs = isPresent(buildLogsJsonVariable) && isEnabled(buildLo const optimizeChunksVariable = process.env['NG_BUILD_OPTIMIZE_CHUNKS']; exports.shouldOptimizeChunks = isPresent(optimizeChunksVariable) && isEnabled(optimizeChunksVariable); const hmrComponentStylesVariable = process.env['NG_HMR_CSTYLES']; -exports.useComponentStyleHmr = !isPresent(hmrComponentStylesVariable) || !isDisabled(hmrComponentStylesVariable); +exports.useComponentStyleHmr = isPresent(hmrComponentStylesVariable) && isEnabled(hmrComponentStylesVariable); const hmrComponentTemplateVariable = process.env['NG_HMR_TEMPLATES']; exports.useComponentTemplateHmr = !isPresent(hmrComponentTemplateVariable) || !isDisabled(hmrComponentTemplateVariable); const partialSsrBuildVariable = process.env['NG_BUILD_PARTIAL_SSR']; diff --git a/src/utils/error.js b/src/utils/error.js index 61d1b55a..7e103212 100644 --- a/src/utils/error.js +++ b/src/utils/error.js @@ -11,10 +11,10 @@ var __importDefault = (this && this.__importDefault) || function (mod) { }; Object.defineProperty(exports, "__esModule", { value: true }); exports.assertIsError = assertIsError; -const assert_1 = __importDefault(require("assert")); +const node_assert_1 = __importDefault(require("node:assert")); function assertIsError(value) { const isError = value instanceof Error || // The following is needing to identify errors coming from RxJs. (typeof value === 'object' && value && 'name' in value && 'message' in value); - (0, assert_1.default)(isError, 'catch clause variable is not an Error instance'); + (0, node_assert_1.default)(isError, 'catch clause variable is not an Error instance'); } diff --git a/src/utils/i18n-options.d.ts b/src/utils/i18n-options.d.ts index c8a84eac..481a3221 100644 --- a/src/utils/i18n-options.d.ts +++ b/src/utils/i18n-options.d.ts @@ -29,7 +29,7 @@ export declare function createI18nOptions(projectMetadata: { i18n?: unknown; }, inline?: boolean | string[], logger?: { warn(message: string): void; -}): I18nOptions; +}, ssrEnabled?: boolean): I18nOptions; export declare function loadTranslations(locale: string, desc: LocaleDescription, workspaceRoot: string, loader: TranslationLoader, logger: { warn: (message: string) => void; error: (message: string) => void; diff --git a/src/utils/i18n-options.js b/src/utils/i18n-options.js index 024b3921..2303b191 100644 --- a/src/utils/i18n-options.js +++ b/src/utils/i18n-options.js @@ -39,13 +39,13 @@ function ensureString(value, name) { throw new Error(`Project field '${name}' is malformed. Expected a string.`); } } -function ensureValidsubPath(value, name) { +function ensureValidSubPath(value, name) { ensureString(value, name); if (!/^[\w-]*$/.test(value)) { throw new Error(`Project field '${name}' is invalid. It can only contain letters, numbers, hyphens, and underscores.`); } } -function createI18nOptions(projectMetadata, inline, logger) { +function createI18nOptions(projectMetadata, inline, logger, ssrEnabled) { const { i18n: metadata = {} } = projectMetadata; ensureObject(metadata, 'i18n'); const i18n = { @@ -71,14 +71,16 @@ function createI18nOptions(projectMetadata, inline, logger) { } if (metadata.sourceLocale.baseHref !== undefined) { ensureString(metadata.sourceLocale.baseHref, 'i18n.sourceLocale.baseHref'); - logger?.warn(`The 'baseHref' field under 'i18n.sourceLocale' is deprecated and will be removed in future versions. ` + - `Please use 'subPath' instead.\nNote: 'subPath' defines the URL segment for the locale, acting ` + - `as both the HTML base HREF and the directory name for output.\nBy default, ` + - `if not specified, 'subPath' uses the locale code.`); + if (ssrEnabled) { + logger?.warn(`'baseHref' in 'i18n.sourceLocale' may lead to undefined behavior when used with SSR. ` + + `Consider using 'subPath' instead.\n\n` + + `Note: 'subPath' specifies the URL segment for the locale, serving as both the HTML base HREF ` + + `and the output directory name.\nBy default, if not explicitly set, 'subPath' defaults to the locale code.`); + } rawSourceLocaleBaseHref = metadata.sourceLocale.baseHref; } if (metadata.sourceLocale.subPath !== undefined) { - ensureValidsubPath(metadata.sourceLocale.subPath, 'i18n.sourceLocale.subPath'); + ensureValidSubPath(metadata.sourceLocale.subPath, 'i18n.sourceLocale.subPath'); rawsubPath = metadata.sourceLocale.subPath; } if (rawsubPath !== undefined && rawSourceLocaleBaseHref !== undefined) { @@ -104,14 +106,16 @@ function createI18nOptions(projectMetadata, inline, logger) { translationFiles = normalizeTranslationFileOption(options.translation, locale, false); if ('baseHref' in options) { ensureString(options.baseHref, `i18n.locales.${locale}.baseHref`); - logger?.warn(`The 'baseHref' field under 'i18n.locales.${locale}' is deprecated and will be removed in future versions. ` + - `Please use 'subPath' instead.\nNote: 'subPath' defines the URL segment for the locale, acting ` + - `as both the HTML base HREF and the directory name for output.\nBy default, ` + - `if not specified, 'subPath' uses the locale code.`); + if (ssrEnabled) { + logger?.warn(`'baseHref' in 'i18n.locales.${locale}' may lead to undefined behavior when used with SSR. ` + + `Consider using 'subPath' instead.\n\n` + + `Note: 'subPath' specifies the URL segment for the locale, serving as both the HTML base HREF ` + + `and the output directory name.\nBy default, if not explicitly set, 'subPath' defaults to the locale code.`); + } baseHref = options.baseHref; } if ('subPath' in options) { - ensureString(options.subPath, `i18n.locales.${locale}.subPath`); + ensureValidSubPath(options.subPath, `i18n.locales.${locale}.subPath`); subPath = options.subPath; } if (subPath !== undefined && baseHref !== undefined) { @@ -131,17 +135,6 @@ function createI18nOptions(projectMetadata, inline, logger) { }; } } - // Check that subPaths are unique. - const localesData = Object.entries(i18n.locales); - for (let i = 0; i < localesData.length; i++) { - const [localeA, { subPath: subPathA }] = localesData[i]; - for (let j = i + 1; j < localesData.length; j++) { - const [localeB, { subPath: subPathB }] = localesData[j]; - if (subPathA === subPathB) { - throw new Error(`Invalid i18n configuration: Locales '${localeA}' and '${localeB}' cannot have the same subPath: '${subPathB}'.`); - } - } - } if (inline === true) { i18n.inlineLocales.add(i18n.sourceLocale); Object.keys(i18n.locales).forEach((locale) => i18n.inlineLocales.add(locale)); @@ -154,6 +147,17 @@ function createI18nOptions(projectMetadata, inline, logger) { i18n.inlineLocales.add(locale); } } + // Check that subPaths are unique only the locales that we are inlining. + const localesData = Object.entries(i18n.locales).filter(([locale]) => i18n.inlineLocales.has(locale)); + for (let i = 0; i < localesData.length; i++) { + const [localeA, { subPath: subPathA }] = localesData[i]; + for (let j = i + 1; j < localesData.length; j++) { + const [localeB, { subPath: subPathB }] = localesData[j]; + if (subPathA === subPathB) { + throw new Error(`Invalid i18n configuration: Locales '${localeA}' and '${localeB}' cannot have the same subPath: '${subPathB}'.`); + } + } + } return i18n; } function loadTranslations(locale, desc, workspaceRoot, loader, logger, usedFormats, duplicateTranslation) { diff --git a/src/utils/index-file/auto-csp.js b/src/utils/index-file/auto-csp.js index a6fb6aed..822aad22 100644 --- a/src/utils/index-file/auto-csp.js +++ b/src/utils/index-file/auto-csp.js @@ -112,7 +112,7 @@ async function autoCsp(html, unsafeEval = false) { rewriter.emitRaw(``); scriptContent = []; } - rewriter.on('startTag', (tag, html) => { + rewriter.on('startTag', (tag) => { if (tag.tagName === 'script') { openedScriptTag = tag; const src = getScriptAttributeValue(tag, 'src'); diff --git a/src/utils/index-file/index-html-generator.js b/src/utils/index-file/index-html-generator.js index dcaec3ec..d2e05419 100644 --- a/src/utils/index-file/index-html-generator.js +++ b/src/utils/index-file/index-html-generator.js @@ -32,7 +32,7 @@ class IndexHtmlGenerator { this.plugins = [augmentIndexHtmlPlugin(this), ...extraCommonPlugins, postTransformPlugin(this)]; // CSR plugins if (options?.optimization?.styles?.inlineCritical) { - this.csrPlugins.push(inlineCriticalCssPlugin(this)); + this.csrPlugins.push(inlineCriticalCssPlugin(this, !!options.autoCsp)); } this.csrPlugins.push(addNoncePlugin()); // SSR plugins @@ -127,11 +127,12 @@ function inlineFontsPlugin({ options }) { }); return async (html) => inlineFontsProcessor.process(html); } -function inlineCriticalCssPlugin(generator) { +function inlineCriticalCssPlugin(generator, autoCsp) { const inlineCriticalCssProcessor = new inline_critical_css_1.InlineCriticalCssProcessor({ minify: generator.options.optimization?.styles.minify, deployUrl: generator.options.deployUrl, readAsset: (filePath) => generator.readAsset(filePath), + autoCsp, }); return async (html, options) => inlineCriticalCssProcessor.process(html, { outputPath: options.outputPath }); } diff --git a/src/utils/index-file/inline-critical-css.d.ts b/src/utils/index-file/inline-critical-css.d.ts index 4fa9fba8..2b43e715 100644 --- a/src/utils/index-file/inline-critical-css.d.ts +++ b/src/utils/index-file/inline-critical-css.d.ts @@ -12,6 +12,7 @@ export interface InlineCriticalCssProcessorOptions { minify?: boolean; deployUrl?: string; readAsset?: (path: string) => Promise; + autoCsp?: boolean; } export declare class InlineCriticalCssProcessor { protected readonly options: InlineCriticalCssProcessorOptions; diff --git a/src/utils/index-file/inline-critical-css.js b/src/utils/index-file/inline-critical-css.js index db993337..d0d8c9e2 100644 --- a/src/utils/index-file/inline-critical-css.js +++ b/src/utils/index-file/inline-critical-css.js @@ -113,7 +113,7 @@ class BeastiesExtended extends BeastiesBase { } const returnValue = await super.embedLinkedStylesheet(link, document); const cspNonce = this.findCspNonce(document); - if (cspNonce) { + if (cspNonce || this.optionsExtended.autoCsp) { const beastiesMedia = link.getAttribute('onload')?.match(MEDIA_SET_HANDLER_PATTERN); if (beastiesMedia) { // If there's a Beasties-generated `onload` handler and the file has an Angular CSP nonce, @@ -128,11 +128,13 @@ class BeastiesExtended extends BeastiesBase { // a way of doing that at the moment so we fall back to doing it any time a `link` tag is // inserted. We mitigate it by only iterating the direct children of the `` which // should be pretty shallow. - document.head.children.forEach((child) => { - if (child.tagName === 'style' && !child.hasAttribute('nonce')) { - child.setAttribute('nonce', cspNonce); - } - }); + if (cspNonce) { + document.head.children.forEach((child) => { + if (child.tagName === 'style' && !child.hasAttribute('nonce')) { + child.setAttribute('nonce', cspNonce); + } + }); + } } return returnValue; } @@ -159,8 +161,10 @@ class BeastiesExtended extends BeastiesBase { return; } const script = document.createElement('script'); - script.setAttribute('nonce', nonce); script.textContent = LINK_LOAD_SCRIPT_CONTENT; + if (nonce) { + script.setAttribute('nonce', nonce); + } // Prepend the script to the head since it needs to // run as early as possible, before the `link` tags. document.head.insertBefore(script, link); diff --git a/src/utils/load-translations.js b/src/utils/load-translations.js index 84ee06d7..dc5e605c 100644 --- a/src/utils/load-translations.js +++ b/src/utils/load-translations.js @@ -41,8 +41,8 @@ var __importStar = (this && this.__importStar) || (function () { })(); Object.defineProperty(exports, "__esModule", { value: true }); exports.createTranslationLoader = createTranslationLoader; -const crypto_1 = require("crypto"); -const fs = __importStar(require("fs")); +const node_crypto_1 = require("node:crypto"); +const fs = __importStar(require("node:fs")); const load_esm_1 = require("./load-esm"); async function createTranslationLoader() { const { parsers, diagnostics } = await importParsers(); @@ -55,7 +55,7 @@ async function createTranslationLoader() { // Types don't overlap here so we need to use any. // eslint-disable-next-line @typescript-eslint/no-explicit-any const { locale, translations } = parser.parse(path, content, analysis.hint); - const integrity = 'sha256-' + (0, crypto_1.createHash)('sha256').update(content).digest('base64'); + const integrity = 'sha256-' + (0, node_crypto_1.createHash)('sha256').update(content).digest('base64'); return { format, locale, translations, diagnostics, integrity }; } else { diff --git a/src/utils/normalize-asset-patterns.js b/src/utils/normalize-asset-patterns.js index 5fe6f293..c7716c44 100644 --- a/src/utils/normalize-asset-patterns.js +++ b/src/utils/normalize-asset-patterns.js @@ -45,9 +45,9 @@ var __importDefault = (this && this.__importDefault) || function (mod) { Object.defineProperty(exports, "__esModule", { value: true }); exports.MissingAssetSourceRootException = void 0; exports.normalizeAssetPatterns = normalizeAssetPatterns; -const fs_1 = require("fs"); const node_assert_1 = __importDefault(require("node:assert")); -const path = __importStar(require("path")); +const node_fs_1 = require("node:fs"); +const path = __importStar(require("node:path")); class MissingAssetSourceRootException extends Error { constructor(path) { super(`The ${path} asset path must start with the project source root.`); @@ -73,7 +73,7 @@ function normalizeAssetPatterns(assetPatterns, workspaceRoot, projectRoot, proje let glob, input; let isDirectory = false; try { - isDirectory = (0, fs_1.statSync)(resolvedAssetPath).isDirectory(); + isDirectory = (0, node_fs_1.statSync)(resolvedAssetPath).isDirectory(); } catch { isDirectory = true; diff --git a/src/utils/normalize-cache.js b/src/utils/normalize-cache.js index f37ca6c7..72fdabfc 100644 --- a/src/utils/normalize-cache.js +++ b/src/utils/normalize-cache.js @@ -10,7 +10,7 @@ Object.defineProperty(exports, "__esModule", { value: true }); exports.normalizeCacheOptions = normalizeCacheOptions; const node_path_1 = require("node:path"); /** Version placeholder is replaced during the build process with actual package version */ -const VERSION = '19.1.0-next.2+sha-6fc31fd'; +const VERSION = '19.1.9+sha-d7747ed'; function hasCacheMetadata(value) { return (!!value && typeof value === 'object' && diff --git a/src/utils/server-rendering/esm-in-memory-loader/loader-hooks.js b/src/utils/server-rendering/esm-in-memory-loader/loader-hooks.js index 6f43298c..33caae9d 100644 --- a/src/utils/server-rendering/esm-in-memory-loader/loader-hooks.js +++ b/src/utils/server-rendering/esm-in-memory-loader/loader-hooks.js @@ -17,7 +17,6 @@ const node_assert_1 = __importDefault(require("node:assert")); const node_crypto_1 = require("node:crypto"); const node_path_1 = require("node:path"); const node_url_1 = require("node:url"); -const url_1 = require("url"); const javascript_transformer_1 = require("../../../tools/esbuild/javascript-transformer"); /** * @note For some unknown reason, setting `globalThis.ngServerMode = true` does not work when using ESM loader hooks. @@ -110,7 +109,7 @@ async function load(url, context, nextLoad) { // Only module files potentially require transformation. Angular libraries that would // need linking are ESM only. if (format === 'module' && isFileProtocol(url)) { - const filePath = (0, url_1.fileURLToPath)(url); + const filePath = (0, node_url_1.fileURLToPath)(url); let source = await javascriptTransformer.transformFile(filePath); if (filePath.includes('@angular/')) { // Prepend 'var ngServerMode=true;' to the source. diff --git a/src/utils/server-rendering/manifest.js b/src/utils/server-rendering/manifest.js index 22c114fe..a96c34bf 100644 --- a/src/utils/server-rendering/manifest.js +++ b/src/utils/server-rendering/manifest.js @@ -11,6 +11,7 @@ exports.SERVER_APP_ENGINE_MANIFEST_FILENAME = exports.SERVER_APP_MANIFEST_FILENA exports.generateAngularServerAppEngineManifest = generateAngularServerAppEngineManifest; exports.generateAngularServerAppManifest = generateAngularServerAppManifest; const node_path_1 = require("node:path"); +const node_vm_1 = require("node:vm"); const bundler_context_1 = require("../../tools/esbuild/bundler-context"); const utils_1 = require("../../tools/esbuild/utils"); const environment_options_1 = require("../environment-options"); @@ -115,9 +116,13 @@ function generateAngularServerAppManifest(additionalHtmlOutputFiles, outputFiles const extension = (0, node_path_1.extname)(file.path); if (extension === '.html' || (inlineCriticalCss && extension === '.css')) { const jsChunkFilePath = `assets-chunks/${file.path.replace(/[./]/g, '_')}.mjs`; - serverAssetsChunks.push((0, utils_1.createOutputFile)(jsChunkFilePath, `export default \`${escapeUnsafeChars(file.text)}\`;`, bundler_context_1.BuildOutputFileType.ServerApplication)); + const escapedContent = escapeUnsafeChars(file.text); + serverAssetsChunks.push((0, utils_1.createOutputFile)(jsChunkFilePath, `export default \`${escapedContent}\`;`, bundler_context_1.BuildOutputFileType.ServerApplication)); + // This is needed because JavaScript engines script parser convert `\r\n` to `\n` in template literals, + // which can result in an incorrect byte length. + const size = (0, node_vm_1.runInThisContext)(`new TextEncoder().encode(\`${escapedContent}\`).byteLength`); serverAssets[file.path] = - `{size: ${file.size}, hash: '${file.hash}', text: () => import('./${jsChunkFilePath}').then(m => m.default)}`; + `{size: ${size}, hash: '${file.hash}', text: () => import('./${jsChunkFilePath}').then(m => m.default)}`; } } // When routes have been extracted, mappings are no longer needed, as preloads will be included in the metadata. diff --git a/src/utils/server-rendering/prerender.js b/src/utils/server-rendering/prerender.js index d1b54e9a..e51d7f3b 100644 --- a/src/utils/server-rendering/prerender.js +++ b/src/utils/server-rendering/prerender.js @@ -16,6 +16,7 @@ const error_1 = require("../error"); const url_1 = require("../url"); const worker_pool_1 = require("../worker-pool"); const utils_1 = require("./esm-in-memory-loader/utils"); +const manifest_1 = require("./manifest"); const models_1 = require("./models"); async function prerenderPages(workspaceRoot, baseHref, appShellOptions, prerenderOptions, outputFiles, assets, outputMode, sourcemap = false, maxThreads = 1) { const outputFilesForWorker = {}; @@ -89,6 +90,12 @@ async function prerenderPages(workspaceRoot, baseHref, appShellOptions, prerende serializableRouteTreeNode, }; } + // Add the extracted routes to the manifest file. + // We could re-generate it from the start, but that would require a number of options to be passed down. + const manifest = outputFilesForWorker[manifest_1.SERVER_APP_MANIFEST_FILENAME]; + if (manifest) { + outputFilesForWorker[manifest_1.SERVER_APP_MANIFEST_FILENAME] = manifest.replace('routes: undefined,', `routes: ${JSON.stringify(serializableRouteTreeNodeForPrerender, undefined, 2)},`); + } // Render routes const { errors: renderingErrors, output } = await renderPages(baseHref, sourcemap, serializableRouteTreeNodeForPrerender, maxThreads, workspaceRoot, outputFilesForWorker, assetsReversed, outputMode, appShellRoute ?? appShellOptions?.route); errors.push(...renderingErrors); @@ -121,11 +128,11 @@ async function renderPages(baseHref, sourcemap, serializableRouteTreeNode, maxTh try { const renderingPromises = []; const appShellRouteWithLeadingSlash = appShellRoute && addLeadingSlash(appShellRoute); - const baseHrefWithLeadingSlash = addLeadingSlash(baseHref); + const baseHrefPathnameWithLeadingSlash = new URL(baseHref, 'http://localhost').pathname; for (const { route, redirectTo } of serializableRouteTreeNode) { // Remove the base href from the file output path. - const routeWithoutBaseHref = addTrailingSlash(route).startsWith(baseHrefWithLeadingSlash) - ? addLeadingSlash(route.slice(baseHrefWithLeadingSlash.length)) + const routeWithoutBaseHref = addTrailingSlash(route).startsWith(baseHrefPathnameWithLeadingSlash) + ? addLeadingSlash(route.slice(baseHrefPathnameWithLeadingSlash.length)) : route; const outPath = node_path_1.posix.join(removeLeadingSlash(routeWithoutBaseHref), 'index.html'); if (typeof redirectTo === 'string') { diff --git a/src/utils/server-rendering/render-worker.js b/src/utils/server-rendering/render-worker.js index 13153b7d..6f5b1835 100644 --- a/src/utils/server-rendering/render-worker.js +++ b/src/utils/server-rendering/render-worker.js @@ -7,14 +7,14 @@ * found in the LICENSE file at https://angular.dev/license */ Object.defineProperty(exports, "__esModule", { value: true }); -const worker_threads_1 = require("worker_threads"); +const node_worker_threads_1 = require("node:worker_threads"); const fetch_patch_1 = require("./fetch-patch"); const launch_server_1 = require("./launch-server"); const load_esm_from_memory_1 = require("./load-esm-from-memory"); /** * This is passed as workerData when setting up the worker via the `piscina` package. */ -const { outputMode, hasSsrEntry } = worker_threads_1.workerData; +const { outputMode, hasSsrEntry } = node_worker_threads_1.workerData; let serverURL = launch_server_1.DEFAULT_URL; /** * Renders each route in routes and writes them to //index.html. diff --git a/src/utils/server-rendering/routes-extractor-worker.js b/src/utils/server-rendering/routes-extractor-worker.js index 54db931a..b855aa05 100644 --- a/src/utils/server-rendering/routes-extractor-worker.js +++ b/src/utils/server-rendering/routes-extractor-worker.js @@ -7,7 +7,7 @@ * found in the LICENSE file at https://angular.dev/license */ Object.defineProperty(exports, "__esModule", { value: true }); -const worker_threads_1 = require("worker_threads"); +const node_worker_threads_1 = require("node:worker_threads"); const schema_1 = require("../../builders/application/schema"); const fetch_patch_1 = require("./fetch-patch"); const launch_server_1 = require("./launch-server"); @@ -15,7 +15,7 @@ const load_esm_from_memory_1 = require("./load-esm-from-memory"); /** * This is passed as workerData when setting up the worker via the `piscina` package. */ -const { outputMode, hasSsrEntry } = worker_threads_1.workerData; +const { outputMode, hasSsrEntry } = node_worker_threads_1.workerData; /** Renders an application based on a provided options. */ async function extractRoutes() { const serverURL = outputMode !== undefined && hasSsrEntry ? await (0, launch_server_1.launchServer)() : launch_server_1.DEFAULT_URL; diff --git a/src/utils/service-worker.d.ts b/src/utils/service-worker.d.ts index 28559329..9ad0cc4a 100644 --- a/src/utils/service-worker.d.ts +++ b/src/utils/service-worker.d.ts @@ -9,7 +9,7 @@ import type { Config, Filesystem } from '@angular/service-worker/config'; import { promises as fsPromises } from 'node:fs'; import { BuildOutputFile } from '../tools/esbuild/bundler-context'; import { BuildOutputAsset } from '../tools/esbuild/bundler-execution-result'; -export declare function augmentAppWithServiceWorker(appRoot: string, workspaceRoot: string, outputPath: string, baseHref: string, ngswConfigPath?: string, inputputFileSystem?: typeof fsPromises, outputFileSystem?: typeof fsPromises): Promise; +export declare function augmentAppWithServiceWorker(appRoot: string, workspaceRoot: string, outputPath: string, baseHref: string, ngswConfigPath?: string, inputFileSystem?: typeof fsPromises, outputFileSystem?: typeof fsPromises): Promise; export declare function augmentAppWithServiceWorkerEsbuild(workspaceRoot: string, configPath: string, baseHref: string, indexHtml: string | undefined, outputFiles: BuildOutputFile[], assetFiles: BuildOutputAsset[]): Promise<{ manifest: string; assetFiles: BuildOutputAsset[]; diff --git a/src/utils/service-worker.js b/src/utils/service-worker.js index 08fb5119..212b7779 100644 --- a/src/utils/service-worker.js +++ b/src/utils/service-worker.js @@ -43,9 +43,9 @@ Object.defineProperty(exports, "__esModule", { value: true }); exports.augmentAppWithServiceWorker = augmentAppWithServiceWorker; exports.augmentAppWithServiceWorkerEsbuild = augmentAppWithServiceWorkerEsbuild; exports.augmentAppWithServiceWorkerCore = augmentAppWithServiceWorkerCore; -const crypto = __importStar(require("crypto")); +const crypto = __importStar(require("node:crypto")); const node_fs_1 = require("node:fs"); -const path = __importStar(require("path")); +const path = __importStar(require("node:path")); const bundler_context_1 = require("../tools/esbuild/bundler-context"); const error_1 = require("./error"); const load_esm_1 = require("./load-esm"); @@ -133,7 +133,7 @@ class ResultFilesystem { throw new Error('Serviceworker manifest generator should not attempted to write.'); } } -async function augmentAppWithServiceWorker(appRoot, workspaceRoot, outputPath, baseHref, ngswConfigPath, inputputFileSystem = node_fs_1.promises, outputFileSystem = node_fs_1.promises) { +async function augmentAppWithServiceWorker(appRoot, workspaceRoot, outputPath, baseHref, ngswConfigPath, inputFileSystem = node_fs_1.promises, outputFileSystem = node_fs_1.promises) { // Determine the configuration file path const configPath = ngswConfigPath ? path.join(workspaceRoot, ngswConfigPath) @@ -141,7 +141,7 @@ async function augmentAppWithServiceWorker(appRoot, workspaceRoot, outputPath, b // Read the configuration file let config; try { - const configurationData = await inputputFileSystem.readFile(configPath, 'utf-8'); + const configurationData = await inputFileSystem.readFile(configPath, 'utf-8'); config = JSON.parse(configurationData); } catch (error) { @@ -158,11 +158,7 @@ async function augmentAppWithServiceWorker(appRoot, workspaceRoot, outputPath, b const result = await augmentAppWithServiceWorkerCore(config, new CliFilesystem(outputFileSystem, outputPath), baseHref); const copy = async (src, dest) => { const resolvedDest = path.join(outputPath, dest); - return inputputFileSystem === outputFileSystem - ? // Native FS (Builder). - inputputFileSystem.copyFile(src, resolvedDest, node_fs_1.constants.COPYFILE_FICLONE) - : // memfs (Webpack): Read the file from the input FS (disk) and write it to the output FS (memory). - outputFileSystem.writeFile(resolvedDest, await inputputFileSystem.readFile(src)); + return outputFileSystem.writeFile(resolvedDest, await inputFileSystem.readFile(src)); }; await outputFileSystem.writeFile(path.join(outputPath, 'ngsw.json'), result.manifest); for (const { source, destination } of result.assetFiles) { diff --git a/uniqueId b/uniqueId index 9df7c6da..c5a409b6 100644 --- a/uniqueId +++ b/uniqueId @@ -1 +1 @@ -Wed Jan 08 2025 19:42:08 GMT+0000 (Coordinated Universal Time) \ No newline at end of file +Wed Feb 26 2025 22:02:32 GMT+0000 (Coordinated Universal Time) \ No newline at end of file