Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -33,5 +33,54 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => {
harness.expectFile('dist/browser/main.js').content.not.toContain('12345');
harness.expectFile('dist/browser/main.js').content.toContain('67890');
});

it('should apply file replacements inside web workers', async () => {
harness.useTarget('build', {
...BASE_OPTIONS,
fileReplacements: [{ replace: './src/app/env.ts', with: './src/app/env.prod.ts' }],
});

await harness.writeFile('src/app/env.ts', `export const value = 'development';`);
await harness.writeFile('src/app/env.prod.ts', `export const value = 'production';`);

await harness.writeFile(
'src/app/worker.ts',
`import { value } from './env';\nself.postMessage(value);`,
);

await harness.writeFile(
'src/app/app.component.ts',
`
import { Component } from '@angular/core';
@Component({
selector: 'app-root',
standalone: false,
template: '<h1>Worker Test</h1>',
})
export class AppComponent {
worker = new Worker(new URL('./worker', import.meta.url), { type: 'module' });
}
`,
);

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

// Verify the worker output file exists
expect(harness.hasFileMatch('dist/browser', /^worker-[A-Z0-9]{8}\.js$/)).toBeTrue();

// Find the worker filename from the main bundle and read its content
const mainContent = harness.readFile('dist/browser/main.js');
const workerMatch = mainContent.match(/worker-([A-Z0-9]{8})\.js/);
expect(workerMatch).not.toBeNull();

if (workerMatch) {
const workerFilename = `dist/browser/${workerMatch[0]}`;
// The worker bundle should contain the replaced (production) value
harness.expectFile(workerFilename).content.toContain('production');
// The worker bundle should NOT contain the original (development) value
harness.expectFile(workerFilename).content.not.toContain('development');
}
});
});
});
145 changes: 137 additions & 8 deletions packages/angular/build/src/tools/esbuild/angular/compiler-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import type {
} from 'esbuild';
import assert from 'node:assert';
import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { readFile } from 'node:fs/promises';
import * as path from 'node:path';
import { maxWorkers, useTypeChecking } from '../../../utils/environment-options';
Expand Down Expand Up @@ -278,12 +279,21 @@ export function createCompilerPlugin(
metafile: workerResult.metafile,
});

referencedFileTracker.add(
containingFile,
Object.keys(workerResult.metafile.inputs).map((input) =>
path.join(build.initialOptions.absWorkingDir ?? '', input),
),
);
const metafileInputPaths = Object.keys(workerResult.metafile.inputs)
// When file replacements are used, the worker entry is passed via stdin and
// esbuild reports it as "<stdin>" in the metafile. Exclude this virtual entry
// since it does not correspond to a real file path.
.filter((input) => input !== '<stdin>')
.map((input) => path.join(build.initialOptions.absWorkingDir ?? '', input));

// Always ensure the actual worker entry file is tracked as a dependency even when
// the build used stdin (e.g. due to file replacements). This guarantees rebuilds
// are triggered when the source worker file changes.
if (!metafileInputPaths.includes(fullWorkerPath)) {
metafileInputPaths.push(fullWorkerPath);
}

referencedFileTracker.add(containingFile, metafileInputPaths);

// Return bundled worker file entry name to be used in the built output
const workerCodeFile = workerResult.outputFiles.find((file) =>
Expand Down Expand Up @@ -757,27 +767,146 @@ function createCompilerOptionsTransformer(
};
}

/**
* Rewrites static import/export specifiers in a TypeScript/JavaScript source file to apply
* file replacements. For each relative or absolute specifier that resolves to a path present
* in the `fileReplacements` map, the specifier is replaced with the corresponding replacement
* path. This allows file replacements to be honoured inside web worker bundles, where the
* esbuild synchronous API does not support plugins.
*
* Only the entry-file level is rewritten; transitive imports are handled because the rewritten
* specifiers point directly to the replacement files on disk, so esbuild will bundle them
* normally.
*
* @param contents Raw source text of the worker entry file.
* @param workerDir Absolute directory of the worker entry file (used to resolve relative specifiers).
* @param fileReplacements Map from original absolute path to replacement absolute path.
* @returns The rewritten source text, or the original text if no replacements are needed.
*/
function applyFileReplacementsToContent(
contents: string,
workerDir: string,
fileReplacements: Record<string, string>,
): string {
// Matches static import/export specifiers:
// import ... from 'specifier'
// export ... from 'specifier'
// import 'specifier'
// Captures the quote character (group 1) and the specifier (group 2).
const importExportRe = /\b(?:import|export)\b[^;'"]*?(['"])([^'"]+)\1/g;

// Extensions to try when resolving a specifier without an explicit extension.
const candidateExtensions = ['.ts', '.tsx', '.mts', '.cts', '.js', '.mjs', '.cjs'];

let result = contents;
let match: RegExpExecArray | null;

while ((match = importExportRe.exec(contents)) !== null) {
const specifier = match[2];

// Only process relative specifiers; bare package-name imports are not file-path replacements.
if (!specifier.startsWith('.') && !path.isAbsolute(specifier)) {
continue;
}

const resolvedBase = path.isAbsolute(specifier)
? specifier
: path.join(workerDir, specifier);

let replacementPath: string | undefined;

// First check if the specifier already includes an extension and resolves directly.
const directCandidate = path.normalize(resolvedBase);
replacementPath = fileReplacements[directCandidate];

if (!replacementPath) {
// Try appending each supported extension to resolve extensionless specifiers.
for (const ext of candidateExtensions) {
const candidate = path.normalize(resolvedBase + ext);
replacementPath = fileReplacements[candidate];
if (replacementPath) {
break;
}
}
}

if (replacementPath) {
// Replace only the specifier part within the matched import/export statement.
const fullMatch = match[0];
const quote = match[1];
const newSpecifier = replacementPath.replaceAll('\\', '/');
const escapedSpecifier = specifier.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const newMatch = fullMatch.replace(
new RegExp(`${quote}${escapedSpecifier}${quote}`),
`${quote}${newSpecifier}${quote}`,
);
result = result.replace(fullMatch, newMatch);
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Consider using a more robust replacement logic that anchors to the start of the line and uses a callback function to avoid issues with special characters in paths. This also simplifies the loop and avoids manual string manipulation.

  // Matches static import/export specifiers at the start of a line:
  // Captures the prefix (group 1), quote (group 2), specifier (group 3), and closing quote (group 2).
  const importExportRe = /^(\s*(?:import|export)\b[^;'"\n]*?(['"]))([^'"\n]+)(\2)/gm;

  // Extensions to try when resolving a specifier without an explicit extension.
  const candidateExtensions = ['.ts', '.tsx', '.mts', '.cts', '.js', '.mjs', '.cjs'];

  return contents.replace(importExportRe, (fullMatch, prefix, quote, specifier) => {
    // Only process relative specifiers; bare package-name imports are not file-path replacements.
    if (!specifier.startsWith('.') && !path.isAbsolute(specifier)) {
      return fullMatch;
    }

    const resolvedBase = path.isAbsolute(specifier)
      ? specifier
      : path.join(workerDir, specifier);

    let replacementPath: string | undefined;

    // First check if the specifier already includes an extension and resolves directly.
    const directCandidate = path.normalize(resolvedBase);
    replacementPath = fileReplacements[directCandidate];

    if (!replacementPath) {
      // Try appending each supported extension to resolve extensionless specifiers.
      for (const ext of candidateExtensions) {
        const candidate = path.normalize(resolvedBase + ext);
        replacementPath = fileReplacements[candidate];
        if (replacementPath) {
          break;
        }
      }
    }

    if (replacementPath) {
      const newSpecifier = replacementPath.replaceAll('\\', '/');
      return prefix + newSpecifier + quote;
    }

    return fullMatch;
  });


return result;
}
Comment on lines +782 to +830
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The manual rewriting of import specifiers in the worker entry file has several limitations and potential issues:

  1. Transitive Imports: This approach only applies replacements to the entry file. Any imports within the dependency graph of the worker (i.e., files imported by the entry file) will not have replacements applied because esbuild.buildSync does not support plugins. This is a significant limitation if shared services or constants (which might import the environment) are used in the worker.
  2. Regex Robustness: The current regex can match import/export keywords inside comments or strings. Since these must be top-level statements in ESM, it's safer to anchor the regex to the start of the line.
  3. Replacement Safety: Using String.prototype.replace with a string as the second argument (line 843) can lead to incorrect output if the replacement path contains special characters like $ (e.g., in some Windows paths).
  4. Resolution Logic: The manual resolution (lines 818-831) doesn't handle directory imports (e.g., import './utils' resolving to ./utils/index.ts).

While the transitive imports issue is an architectural limitation of the synchronous worker bundling, the other issues can be mitigated by using a more robust replacement approach.


function bundleWebWorker(
build: PluginBuild,
pluginOptions: CompilerPluginOptions,
workerFile: string,
) {
try {
return build.esbuild.buildSync({
// If file replacements are configured, apply them to the worker entry file so that the
// synchronous esbuild build honours the same substitutions as the main application build.
// The esbuild synchronous API does not support plugins (which normally handle file
// replacements for the main build), so we rewrite the entry file's import specifiers
// before bundling. Imports in the rewritten file point directly to the replacement paths,
// which esbuild then resolves and bundles normally.
let entryPoints: string[] | undefined;
let stdin: { contents: string; resolveDir: string; loader: Loader } | undefined;

if (pluginOptions.fileReplacements) {
// Check whether the worker entry file itself is being replaced.
const entryReplacement = pluginOptions.fileReplacements[path.normalize(workerFile)];
const effectiveWorkerFile = entryReplacement ?? workerFile;

// Rewrite any direct imports that are covered by file replacements.
const workerDir = path.dirname(effectiveWorkerFile);
const originalContents = readFileSync(effectiveWorkerFile, 'utf-8');
const rewrittenContents = applyFileReplacementsToContent(
originalContents,
workerDir,
pluginOptions.fileReplacements,
);

if (rewrittenContents !== originalContents || entryReplacement) {
// Use stdin to pass the rewritten content so that the correct bundle is produced.
// Infer the esbuild loader from the effective worker file extension.
const stdinLoader: Loader =
path.extname(effectiveWorkerFile).toLowerCase() === '.tsx' ? 'tsx' : 'ts';
stdin = { contents: rewrittenContents, resolveDir: workerDir, loader: stdinLoader };
} else {
entryPoints = [workerFile];
}
} else {
entryPoints = [workerFile];
}

const result = build.esbuild.buildSync({
...build.initialOptions,
platform: 'browser',
write: false,
bundle: true,
metafile: true,
format: 'esm',
entryNames: 'worker-[hash]',
entryPoints: [workerFile],
entryPoints,
stdin,
sourcemap: pluginOptions.sourcemap,
// Zone.js is not used in Web workers so no need to disable
supported: undefined,
// Plugins are not supported in sync esbuild calls
plugins: undefined,
});

return result;
} catch (error) {
if (error && typeof error === 'object' && 'errors' in error && 'warnings' in error) {
return error as BuildFailure;
Expand Down
60 changes: 60 additions & 0 deletions packages/angular/cli/src/commands/update/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,17 @@ export default class UpdateCommandModule extends CommandModule<UpdateCommandArgs
logger.info('Collecting installed dependencies...');

const rootDependencies = await packageManager.getProjectDependencies();

// In npm/pnpm/yarn workspace setups the package manager's `list` command is
// executed against the workspace root, so it may only surface the root
// workspace's direct dependencies. When the Angular project lives inside a
// workspace member its own `package.json` entries (e.g. `@angular/core`) will
// be absent from that list. To preserve the pre-v21 behaviour we supplement
// the map with any packages declared in the Angular project root's
// `package.json` that are resolvable from `node_modules` but were not already
// returned by the package manager.
await supplementWithLocalDependencies(rootDependencies, this.context.root);

logger.info(`Found ${rootDependencies.size} dependencies.`);

const workflow = new NodeWorkflow(this.context.root, {
Expand Down Expand Up @@ -675,6 +686,55 @@ export default class UpdateCommandModule extends CommandModule<UpdateCommandArgs
}
}

/**
* Supplements the given dependency map with packages that are declared in the
* Angular project root's `package.json` but were not returned by the package
* manager's `list` command.
*
* In npm/pnpm/yarn workspace setups the package manager runs against the
* workspace root, which may not include dependencies that only appear in a
* workspace member's `package.json`. Reading the member's `package.json`
* directly and resolving the installed version from `node_modules` restores
* the behaviour that was present before the package-manager abstraction was
* introduced in v21.
*
* @param dependencies The map to supplement in place.
* @param projectRoot The root directory of the Angular project (workspace member).
*/
export async function supplementWithLocalDependencies(
dependencies: Map<string, InstalledPackage>,
projectRoot: string,
): Promise<void> {
const localManifest = await readPackageManifest(path.join(projectRoot, 'package.json'));
if (!localManifest) {
return;
}

const localDeps: Record<string, string> = {
...localManifest.dependencies,
...localManifest.devDependencies,
...localManifest.peerDependencies,
};

for (const depName of Object.keys(localDeps)) {
if (dependencies.has(depName)) {
continue;
}
const pkgJsonPath = findPackageJson(projectRoot, depName);
if (!pkgJsonPath) {
continue;
}
const installed = await readPackageManifest(pkgJsonPath);
if (installed?.version) {
dependencies.set(depName, {
name: depName,
version: installed.version,
path: path.dirname(pkgJsonPath),
});
}
}
}

async function readPackageManifest(manifestPath: string): Promise<PackageManifest | undefined> {
try {
const content = await fs.readFile(manifestPath, 'utf8');
Expand Down
Loading
Loading