diff --git a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/angular/compiler-plugin.ts b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/angular/compiler-plugin.ts index 46f0d4eba39f..e1999f5fd36c 100644 --- a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/angular/compiler-plugin.ts +++ b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/angular/compiler-plugin.ts @@ -11,7 +11,6 @@ import type { OnStartResult, OutputFile, PartialMessage, - PartialNote, Plugin, PluginBuild, } from 'esbuild'; @@ -30,95 +29,14 @@ import { profileSync, resetCumulativeDurations, } from '../profiling'; -import { BundleStylesheetOptions, bundleComponentStylesheet } from '../stylesheets'; +import { BundleStylesheetOptions, bundleComponentStylesheet } from '../stylesheets/bundle-options'; import { AngularCompilation, FileEmitter } from './angular-compilation'; import { AngularHostOptions } from './angular-host'; import { AotCompilation } from './aot-compilation'; +import { convertTypeScriptDiagnostic } from './diagnostics'; import { JitCompilation } from './jit-compilation'; import { setupJitPluginCallbacks } from './jit-plugin-callbacks'; -/** - * Converts TypeScript Diagnostic related information into an esbuild compatible note object. - * Related information is a subset of a full TypeScript Diagnostic and also used for diagnostic - * notes associated with the main Diagnostic. - * @param info The TypeScript diagnostic relative information to convert. - * @returns An esbuild diagnostic message as a PartialMessage object - */ -function convertTypeScriptDiagnosticInfo( - info: ts.DiagnosticRelatedInformation, - textPrefix?: string, -): PartialNote { - const newLine = platform() === 'win32' ? '\r\n' : '\n'; - let text = ts.flattenDiagnosticMessageText(info.messageText, newLine); - if (textPrefix) { - text = textPrefix + text; - } - - const note: PartialNote = { text }; - - if (info.file) { - note.location = { - file: info.file.fileName, - length: info.length, - }; - - // Calculate the line/column location and extract the full line text that has the diagnostic - if (info.start) { - const { line, character } = ts.getLineAndCharacterOfPosition(info.file, info.start); - note.location.line = line + 1; - note.location.column = character; - - // The start position for the slice is the first character of the error line - const lineStartPosition = ts.getPositionOfLineAndCharacter(info.file, line, 0); - - // The end position for the slice is the first character of the next line or the length of - // the entire file if the line is the last line of the file (getPositionOfLineAndCharacter - // will error if a nonexistent line is passed). - const { line: lastLineOfFile } = ts.getLineAndCharacterOfPosition( - info.file, - info.file.text.length - 1, - ); - const lineEndPosition = - line < lastLineOfFile - ? ts.getPositionOfLineAndCharacter(info.file, line + 1, 0) - : info.file.text.length; - - note.location.lineText = info.file.text.slice(lineStartPosition, lineEndPosition).trimEnd(); - } - } - - return note; -} - -/** - * Converts a TypeScript Diagnostic message into an esbuild compatible message object. - * @param diagnostic The TypeScript diagnostic to convert. - * @returns An esbuild diagnostic message as a PartialMessage object - */ -function convertTypeScriptDiagnostic(diagnostic: ts.Diagnostic): PartialMessage { - let codePrefix = 'TS'; - let code = `${diagnostic.code}`; - if (diagnostic.source === 'ngtsc') { - codePrefix = 'NG'; - // Remove `-99` Angular prefix from diagnostic code - code = code.slice(3); - } - - const message: PartialMessage = { - ...convertTypeScriptDiagnosticInfo(diagnostic, `${codePrefix}${code}: `), - // Store original diagnostic for reference if needed downstream - detail: diagnostic, - }; - - if (diagnostic.relatedInformation?.length) { - message.notes = diagnostic.relatedInformation.map((info) => - convertTypeScriptDiagnosticInfo(info), - ); - } - - return message; -} - const USING_WINDOWS = platform() === 'win32'; const WINDOWS_SEP_REGEXP = new RegExp(`\\${path.win32.sep}`, 'g'); diff --git a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/angular/diagnostics.ts b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/angular/diagnostics.ts new file mode 100644 index 000000000000..c1269ee80d34 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/angular/diagnostics.ts @@ -0,0 +1,99 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import type { PartialMessage, PartialNote } from 'esbuild'; +import { platform } from 'node:os'; +import { + Diagnostic, + DiagnosticRelatedInformation, + flattenDiagnosticMessageText, + getLineAndCharacterOfPosition, + getPositionOfLineAndCharacter, +} from 'typescript'; + +/** + * Converts TypeScript Diagnostic related information into an esbuild compatible note object. + * Related information is a subset of a full TypeScript Diagnostic and also used for diagnostic + * notes associated with the main Diagnostic. + * @param info The TypeScript diagnostic relative information to convert. + * @returns An esbuild diagnostic message as a PartialMessage object + */ +function convertTypeScriptDiagnosticInfo( + info: DiagnosticRelatedInformation, + textPrefix?: string, +): PartialNote { + const newLine = platform() === 'win32' ? '\r\n' : '\n'; + let text = flattenDiagnosticMessageText(info.messageText, newLine); + if (textPrefix) { + text = textPrefix + text; + } + + const note: PartialNote = { text }; + + if (info.file) { + note.location = { + file: info.file.fileName, + length: info.length, + }; + + // Calculate the line/column location and extract the full line text that has the diagnostic + if (info.start) { + const { line, character } = getLineAndCharacterOfPosition(info.file, info.start); + note.location.line = line + 1; + note.location.column = character; + + // The start position for the slice is the first character of the error line + const lineStartPosition = getPositionOfLineAndCharacter(info.file, line, 0); + + // The end position for the slice is the first character of the next line or the length of + // the entire file if the line is the last line of the file (getPositionOfLineAndCharacter + // will error if a nonexistent line is passed). + const { line: lastLineOfFile } = getLineAndCharacterOfPosition( + info.file, + info.file.text.length - 1, + ); + const lineEndPosition = + line < lastLineOfFile + ? getPositionOfLineAndCharacter(info.file, line + 1, 0) + : info.file.text.length; + + note.location.lineText = info.file.text.slice(lineStartPosition, lineEndPosition).trimEnd(); + } + } + + return note; +} + +/** + * Converts a TypeScript Diagnostic message into an esbuild compatible message object. + * @param diagnostic The TypeScript diagnostic to convert. + * @returns An esbuild diagnostic message as a PartialMessage object + */ +export function convertTypeScriptDiagnostic(diagnostic: Diagnostic): PartialMessage { + let codePrefix = 'TS'; + let code = `${diagnostic.code}`; + if (diagnostic.source === 'ngtsc') { + codePrefix = 'NG'; + // Remove `-99` Angular prefix from diagnostic code + code = code.slice(3); + } + + const message: PartialMessage = { + ...convertTypeScriptDiagnosticInfo(diagnostic, `${codePrefix}${code}: `), + // Store original diagnostic for reference if needed downstream + detail: diagnostic, + }; + + if (diagnostic.relatedInformation?.length) { + message.notes = diagnostic.relatedInformation.map((info) => + convertTypeScriptDiagnosticInfo(info), + ); + } + + return message; +} diff --git a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/angular/jit-plugin-callbacks.ts b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/angular/jit-plugin-callbacks.ts index 029a17aaf565..5c9790e37fcd 100644 --- a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/angular/jit-plugin-callbacks.ts +++ b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/angular/jit-plugin-callbacks.ts @@ -10,7 +10,7 @@ import type { OutputFile, PluginBuild } from 'esbuild'; import { readFile } from 'node:fs/promises'; import path from 'node:path'; import { LoadResultCache } from '../load-result-cache'; -import { BundleStylesheetOptions, bundleComponentStylesheet } from '../stylesheets'; +import { BundleStylesheetOptions, bundleComponentStylesheet } from '../stylesheets/bundle-options'; import { JIT_NAMESPACE_REGEXP, JIT_STYLE_NAMESPACE, diff --git a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/index.ts b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/index.ts index d66f2d7ff708..c573f2a168f9 100644 --- a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/index.ts +++ b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/index.ts @@ -29,10 +29,10 @@ import { createGlobalScriptsBundleOptions } from './global-scripts'; import { extractLicenses } from './license-extractor'; import { LoadResultCache } from './load-result-cache'; import { BrowserEsbuildOptions, NormalizedBrowserOptions, normalizeOptions } from './options'; -import { shutdownSassWorkerPool } from './sass-plugin'; import { Schema as BrowserBuilderOptions } from './schema'; import { createSourcemapIngorelistPlugin } from './sourcemap-ignorelist-plugin'; -import { createStylesheetBundleOptions } from './stylesheets'; +import { createStylesheetBundleOptions } from './stylesheets/bundle-options'; +import { shutdownSassWorkerPool } from './stylesheets/sass-plugin'; import type { ChangedFiles } from './watcher'; interface RebuildState { diff --git a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/stylesheets.ts b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/stylesheets/bundle-options.ts similarity index 98% rename from packages/angular_devkit/build_angular/src/builders/browser-esbuild/stylesheets.ts rename to packages/angular_devkit/build_angular/src/builders/browser-esbuild/stylesheets/bundle-options.ts index 3a1d9f021e4f..9ea74e7aa027 100644 --- a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/stylesheets.ts +++ b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/stylesheets/bundle-options.ts @@ -8,11 +8,11 @@ import type { BuildOptions, OutputFile } from 'esbuild'; import path from 'node:path'; +import { BundlerContext } from '../esbuild'; +import { LoadResultCache } from '../load-result-cache'; import { createCssPlugin } from './css-plugin'; import { createCssResourcePlugin } from './css-resource-plugin'; -import { BundlerContext } from './esbuild'; import { createLessPlugin } from './less-plugin'; -import { LoadResultCache } from './load-result-cache'; import { createSassPlugin } from './sass-plugin'; /** diff --git a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/css-plugin.ts b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/stylesheets/css-plugin.ts similarity index 100% rename from packages/angular_devkit/build_angular/src/builders/browser-esbuild/css-plugin.ts rename to packages/angular_devkit/build_angular/src/builders/browser-esbuild/stylesheets/css-plugin.ts diff --git a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/css-resource-plugin.ts b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/stylesheets/css-resource-plugin.ts similarity index 100% rename from packages/angular_devkit/build_angular/src/builders/browser-esbuild/css-resource-plugin.ts rename to packages/angular_devkit/build_angular/src/builders/browser-esbuild/stylesheets/css-resource-plugin.ts diff --git a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/less-plugin.ts b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/stylesheets/less-plugin.ts similarity index 100% rename from packages/angular_devkit/build_angular/src/builders/browser-esbuild/less-plugin.ts rename to packages/angular_devkit/build_angular/src/builders/browser-esbuild/stylesheets/less-plugin.ts diff --git a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/sass-plugin.ts b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/stylesheets/sass-plugin.ts similarity index 97% rename from packages/angular_devkit/build_angular/src/builders/browser-esbuild/sass-plugin.ts rename to packages/angular_devkit/build_angular/src/builders/browser-esbuild/stylesheets/sass-plugin.ts index abc63ca7a3ec..7fe6997e5dc1 100644 --- a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/sass-plugin.ts +++ b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/stylesheets/sass-plugin.ts @@ -15,8 +15,8 @@ import type { CompileResult, Exception, Syntax } from 'sass'; import type { FileImporterWithRequestContextOptions, SassWorkerImplementation, -} from '../../sass/sass-service'; -import type { LoadResultCache } from './load-result-cache'; +} from '../../../sass/sass-service'; +import type { LoadResultCache } from '../load-result-cache'; export interface SassPluginOptions { sourcemap: boolean; @@ -113,7 +113,7 @@ async function compileString( ): Promise { // Lazily load Sass when a Sass file is found if (sassWorkerPool === undefined) { - const sassService = await import('../../sass/sass-service'); + const sassService = await import('../../../sass/sass-service'); sassWorkerPool = new sassService.SassWorkerImplementation(true); } diff --git a/packages/angular_devkit/build_angular/src/builders/dev-server/vite-server.ts b/packages/angular_devkit/build_angular/src/builders/dev-server/vite-server.ts index 7bd67ed4a09c..e7e6ca02f11c 100644 --- a/packages/angular_devkit/build_angular/src/builders/dev-server/vite-server.ts +++ b/packages/angular_devkit/build_angular/src/builders/dev-server/vite-server.ts @@ -8,6 +8,7 @@ import type { BuilderContext } from '@angular-devkit/architect'; import type { json } from '@angular-devkit/core'; +import type { OutputFile } from 'esbuild'; import { lookup as lookupMimeType } from 'mrmime'; import assert from 'node:assert'; import { BinaryLike, createHash } from 'node:crypto'; @@ -55,71 +56,25 @@ export async function* serveWithVite( let server: ViteDevServer | undefined; let listeningAddress: AddressInfo | undefined; - const outputFiles = new Map(); - const assets = new Map(); + const generatedFiles = new Map(); + const assetFiles = new Map(); // TODO: Switch this to an architect schedule call when infrastructure settings are supported for await (const result of buildEsbuildBrowser(browserOptions, context, { write: false })) { assert(result.outputFiles, 'Builder did not provide result files.'); // Analyze result files for changes - const seen = new Set(['/index.html']); - for (const file of result.outputFiles) { - const filePath = '/' + normalizePath(file.path); - seen.add(filePath); - - // Skip analysis of sourcemaps - if (filePath.endsWith('.map')) { - outputFiles.set(filePath, { - contents: file.contents, - size: file.contents.byteLength, - updated: false, - }); - - continue; - } - - let fileHash: Buffer | undefined; - const existingRecord = outputFiles.get(filePath); - if (existingRecord && existingRecord.size === file.contents.byteLength) { - // Only hash existing file when needed - if (existingRecord.hash === undefined) { - existingRecord.hash = hashContent(existingRecord.contents); - } + analyzeResultFiles(result.outputFiles, generatedFiles); - // Compare against latest result output - fileHash = hashContent(file.contents); - if (fileHash.equals(existingRecord.hash)) { - // Same file - existingRecord.updated = false; - continue; - } - } - - outputFiles.set(filePath, { - contents: file.contents, - size: file.contents.byteLength, - hash: fileHash, - updated: true, - }); - } - - // Clear stale output files - for (const file of outputFiles.keys()) { - if (!seen.has(file)) { - outputFiles.delete(file); - } - } - - assets.clear(); + assetFiles.clear(); if (result.assetFiles) { for (const asset of result.assetFiles) { - assets.set('/' + normalizePath(asset.destination), asset.source); + assetFiles.set('/' + normalizePath(asset.destination), asset.source); } } if (server) { // Invalidate any updated files - for (const [file, record] of outputFiles) { + for (const [file, record] of generatedFiles) { if (record.updated) { const updatedModules = server.moduleGraph.getModulesByFile(file); updatedModules?.forEach((m) => server?.moduleGraph.invalidateModule(m)); @@ -137,7 +92,8 @@ export async function* serveWithVite( } } else { // Setup server and start listening - server = await setupServer(serverOptions, outputFiles, assets); + const serverConfiguration = await setupServer(serverOptions, generatedFiles, assetFiles); + server = await createServer(serverConfiguration); await server.listen(); listeningAddress = server.httpServer?.address() as AddressInfo; @@ -160,11 +116,64 @@ export async function* serveWithVite( } } -async function setupServer( +function analyzeResultFiles( + resultFiles: OutputFile[], + generatedFiles: Map, +) { + const seen = new Set(['/index.html']); + for (const file of resultFiles) { + const filePath = '/' + normalizePath(file.path); + seen.add(filePath); + + // Skip analysis of sourcemaps + if (filePath.endsWith('.map')) { + generatedFiles.set(filePath, { + contents: file.contents, + size: file.contents.byteLength, + updated: false, + }); + + continue; + } + + let fileHash: Buffer | undefined; + const existingRecord = generatedFiles.get(filePath); + if (existingRecord && existingRecord.size === file.contents.byteLength) { + // Only hash existing file when needed + if (existingRecord.hash === undefined) { + existingRecord.hash = hashContent(existingRecord.contents); + } + + // Compare against latest result output + fileHash = hashContent(file.contents); + if (fileHash.equals(existingRecord.hash)) { + // Same file + existingRecord.updated = false; + continue; + } + } + + generatedFiles.set(filePath, { + contents: file.contents, + size: file.contents.byteLength, + hash: fileHash, + updated: true, + }); + } + + // Clear stale output files + for (const file of generatedFiles.keys()) { + if (!seen.has(file)) { + generatedFiles.delete(file); + } + } +} + +export async function setupServer( serverOptions: NormalizedDevServerOptions, outputFiles: Map, assets: Map, -): Promise { +): Promise { const proxy = await loadProxyConfiguration( serverOptions.workspaceRoot, serverOptions.proxyConfig, @@ -330,7 +339,5 @@ async function setupServer( } } - const server = await createServer(configuration); - - return server; + return configuration; }