From 65a0dc310677e305150f5c8dbd7fb54ad14e0d37 Mon Sep 17 00:00:00 2001 From: Aukevanoost Date: Wed, 23 Sep 2026 12:23:25 +0200 Subject: [PATCH 1/4] fix: give each federation build context its own tsconfig Core now builds shared mappings apart from exposed modules, one Angular context per mapping bundle plus mapping-or-exposed, all against the one federation tsconfig. Rewriting its `files` per context left the other contexts compiling without their entry points: Angular re-reads the tsconfig on every rebuild and serves a dropped file from the shared SourceFileCache, so a mapping edited in watch mode kept its old content without an error. A union of all entry points would make every context compile everything instead. Each context now compiles against a generated tsconfig in the cache dir that extends the federation tsconfig and lists only its own entry points. The federation tsconfig is no longer rewritten, the schematics stop seeding `files`, and a `files` key left in an existing one gets a one-time warning. Default typeRoots are pinned to what the federation tsconfig resolves to, since TypeScript derives them from the leaf config's directory. Refs #138 --- package.json | 1 - pnpm-lock.yaml | 3 - src/builders/build/schema.d.ts | 6 +- src/schematics/init/schematic.ts | 12 +- .../generate-federation-tsconfig.spec.ts | 37 +-- .../steps/generate-federation-tsconfig.ts | 24 +- src/schematics/update22/schematic.spec.ts | 1 - src/schematics/update22/schematic.ts | 7 - src/tools/esbuild/angular-bundler.spec.ts | 48 ++-- src/tools/esbuild/angular-bundler.ts | 36 +-- .../esbuild/angular-esbuild-adapter.spec.ts | 2 +- src/tools/esbuild/angular-esbuild-adapter.ts | 2 +- .../update-federation-tsconfig.spec.ts | 146 ----------- .../esbuild/update-federation-tsconfig.ts | 80 ------- .../esbuild/write-context-tsconfig.spec.ts | 226 ++++++++++++++++++ src/tools/esbuild/write-context-tsconfig.ts | 110 +++++++++ 16 files changed, 402 insertions(+), 339 deletions(-) delete mode 100644 src/tools/esbuild/update-federation-tsconfig.spec.ts delete mode 100644 src/tools/esbuild/update-federation-tsconfig.ts create mode 100644 src/tools/esbuild/write-context-tsconfig.spec.ts create mode 100644 src/tools/esbuild/write-context-tsconfig.ts diff --git a/package.json b/package.json index a8b9ead..8857f2c 100644 --- a/package.json +++ b/package.json @@ -82,7 +82,6 @@ "eslint": "^10.8.0", "globals": "^17.0.0", "jiti": "^2.6.0", - "json5": "^2.2.3", "knip": "^6.17.1", "tslib": "^2.3.0", "typescript": "~6.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 31ca244..d97e772 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -66,9 +66,6 @@ importers: jiti: specifier: ^2.6.0 version: 2.7.0 - json5: - specifier: ^2.2.3 - version: 2.2.3 knip: specifier: ^6.17.1 version: 6.35.1 diff --git a/src/builders/build/schema.d.ts b/src/builders/build/schema.d.ts index b26f291..4655fb0 100644 --- a/src/builders/build/schema.d.ts +++ b/src/builders/build/schema.d.ts @@ -35,10 +35,8 @@ export type NfInternalOptions = { instrumentForCoverage?: (filename: string) => boolean; /** - * Whether the tsconfig the federation build resolved to is the builder's to rewrite (see - * tools/esbuild/update-federation-tsconfig.ts). True only when the NF target declares a - * `tsConfig` of its own; without one the build falls back to the Angular target's tsconfig, - * where `files` is Angular's — replacing it would drop main.ts from the app's own program. + * Whether each build context gets a generated tsconfig extending the resolved one. True only + * when the NF target declares a `tsConfig` of its own. */ manageTsConfig?: boolean; diff --git a/src/schematics/init/schematic.ts b/src/schematics/init/schematic.ts index 7e926f1..f199430 100644 --- a/src/schematics/init/schematic.ts +++ b/src/schematics/init/schematic.ts @@ -51,7 +51,6 @@ export default function config(options: NfSchematicSchema): Rule { projectRoot, projectSourceRoot, manifestPath, - main, } = normalized; updatePolyfills(tree, polyfills); @@ -82,16 +81,7 @@ export default function config(options: NfSchematicSchema): Rule { const ssr = isSsrProject(normalized); const server = ssr ? getSsrFilePath(normalized) : ''; - // Seed the federation program with what the generated config exposes, so the first build - // finds the tsconfig already correct. Where the exposes are unknown (a host, a config we - // did not write, or a project without a recognisable app component) main.ts stands in — - // the same fallback the builder applies. - const exposesAppComponent = - !exists && options.type === 'remote' && appComponent !== 'update-this.ts'; - - const federationTsConfig = generateFederationTsConfig(tree, normalized, [ - exposesAppComponent ? appComponent : main, - ]); + const federationTsConfig = generateFederationTsConfig(tree, normalized); updateWorkspaceConfig(tree, normalized, workspace, workspaceFileName, ssr, federationTsConfig); diff --git a/src/schematics/init/steps/generate-federation-tsconfig.spec.ts b/src/schematics/init/steps/generate-federation-tsconfig.spec.ts index 7e76fee..d63e158 100644 --- a/src/schematics/init/steps/generate-federation-tsconfig.spec.ts +++ b/src/schematics/init/steps/generate-federation-tsconfig.spec.ts @@ -3,8 +3,6 @@ import { EmptyTree, type Tree } from '@angular-devkit/schematics'; import { generateFederationTsConfig } from './generate-federation-tsconfig.js'; import type { NormalizedOptions } from './normalize-options.js'; -const EXPOSED = ['projects/mfe1/src/app/app.ts']; - function makeOptions(overrides: Partial = {}): NormalizedOptions { return { polyfills: [] as unknown as string, @@ -38,34 +36,22 @@ describe('generateFederationTsConfig', () => { tree = new EmptyTree(); }); - it('creates a federation tsconfig extending the app tsconfig', () => { - const result = generateFederationTsConfig(tree, makeOptions(), EXPOSED); + // Each build context supplies its own `files` (tools/esbuild/write-context-tsconfig.ts), so + // a seeded list would only go stale. + it('creates a federation tsconfig extending the app tsconfig, without files', () => { + const result = generateFederationTsConfig(tree, makeOptions()); expect(result).toBe('projects/mfe1/tsconfig.federation.json'); expect(read(tree, result)).toEqual({ extends: './tsconfig.app.json', - files: ['src/app/app.ts'], include: ['src/**/*.d.ts'], }); }); - // An empty `files` list is a TypeScript error (TS18002) unless the config also extends - // another one, so neither key may be dropped from the generated shape. - it('always emits both extends and a non-empty files list', () => { - const result = generateFederationTsConfig(tree, makeOptions(), [ - 'projects/mfe1/src/main.ts', - ]); - - const tsconfig = read(tree, result); - expect(tsconfig.extends).toBeTruthy(); - expect(tsconfig.files).toEqual(['src/main.ts']); - }); - it('derives the include glob from the project source root', () => { const result = generateFederationTsConfig( tree, - makeOptions({ projectSourceRoot: 'projects/mfe1/app-src' }), - EXPOSED + makeOptions({ projectSourceRoot: 'projects/mfe1/app-src' }) ); expect(read(tree, result).include).toEqual(['app-src/**/*.d.ts']); @@ -83,8 +69,7 @@ describe('generateFederationTsConfig', () => { }, }, }, - }), - EXPOSED + }) ); expect(read(tree, result).extends).toBe('../../tsconfig.app.json'); @@ -93,7 +78,7 @@ describe('generateFederationTsConfig', () => { it('leaves an existing federation tsconfig untouched', () => { tree.create('projects/mfe1/tsconfig.federation.json', '{ "files": ["src/bootstrap.ts"] }'); - const result = generateFederationTsConfig(tree, makeOptions(), EXPOSED); + const result = generateFederationTsConfig(tree, makeOptions()); expect(read(tree, result)).toEqual({ files: ['src/bootstrap.ts'] }); }); @@ -102,7 +87,7 @@ describe('generateFederationTsConfig', () => { const options = makeOptions(); options.projectConfig.architect.build.builder = '@angular-architects/native-federation:build'; - const result = generateFederationTsConfig(tree, options, EXPOSED); + const result = generateFederationTsConfig(tree, options); expect(tree.exists(result)).toBe(false); }); @@ -118,8 +103,7 @@ describe('generateFederationTsConfig', () => { esbuild: { options: { tsConfig: 'projects/mfe1/tsconfig.app.json' } }, }, }, - }), - EXPOSED + }) ); expect(read(tree, result).extends).toBe('./tsconfig.app.json'); @@ -133,8 +117,7 @@ describe('generateFederationTsConfig', () => { projectConfig: { architect: { build: { builder: '@angular/build:application', options: {} } }, }, - }), - EXPOSED + }) ) ).toThrow('has no tsConfig'); }); diff --git a/src/schematics/init/steps/generate-federation-tsconfig.ts b/src/schematics/init/steps/generate-federation-tsconfig.ts index 31a6ebb..bee8627 100644 --- a/src/schematics/init/steps/generate-federation-tsconfig.ts +++ b/src/schematics/init/steps/generate-federation-tsconfig.ts @@ -17,22 +17,12 @@ export interface FederationTsConfigOptions { projectSourceRoot: string; /** Workspace-relative path of the tsconfig to extend, usually the app's. */ appTsConfig: string; - /** Workspace-relative entry points seeding the program. */ - entryPoints: string[]; } -/** - * Writes the tsconfig the federation build compiles against. It covers the exposes and shared - * mappings rather than the app entry, so `files` is a plain list of entry points that the - * builder rewrites per build (see tools/esbuild/update-federation-tsconfig.ts) and `include` - * only picks up ambient declarations. It extends the app tsconfig because it also drives - * esbuild's module resolution and so needs its paths. - * - * Both `extends` and `files` have to stay present: TypeScript reports an empty `files` list - * (TS18002) unless the config also extends another one. - */ +// No `files`: each build context supplies its own (tools/esbuild/write-context-tsconfig.ts). +// Extends the app tsconfig for its paths, which esbuild's module resolution also needs. export function writeFederationTsConfig(tree: Tree, options: FederationTsConfigOptions): string { - const { projectRoot, projectSourceRoot, appTsConfig, entryPoints } = options; + const { projectRoot, projectSourceRoot, appTsConfig } = options; const federationTsConfig = federationTsConfigPath(projectRoot); @@ -44,7 +34,6 @@ export function writeFederationTsConfig(tree: Tree, options: FederationTsConfigO JSON.stringify( { extends: extendsPath.startsWith('.') ? extendsPath : `./${extendsPath}`, - files: entryPoints.map(entry => toPosix(path.relative(projectRoot, entry))), include: [`${sourceDir}/**/*.d.ts`], }, null, @@ -55,11 +44,7 @@ export function writeFederationTsConfig(tree: Tree, options: FederationTsConfigO return federationTsConfig; } -export function generateFederationTsConfig( - tree: Tree, - options: NormalizedOptions, - entryPoints: string[] -): string { +export function generateFederationTsConfig(tree: Tree, options: NormalizedOptions): string { const { projectConfig, projectRoot, projectSourceRoot } = options; const federationTsConfig = federationTsConfigPath(projectRoot); @@ -80,6 +65,5 @@ export function generateFederationTsConfig( projectRoot, projectSourceRoot, appTsConfig, - entryPoints, }); } diff --git a/src/schematics/update22/schematic.spec.ts b/src/schematics/update22/schematic.spec.ts index 0e62cfe..813def7 100644 --- a/src/schematics/update22/schematic.spec.ts +++ b/src/schematics/update22/schematic.spec.ts @@ -56,7 +56,6 @@ describe('update22 — federation tsconfig', () => { expect(readJson(tree, 'projects/mfe1/tsconfig.federation.json')).toEqual({ extends: './tsconfig.app.json', - files: ['src/main.ts'], include: ['src/**/*.d.ts'], }); diff --git a/src/schematics/update22/schematic.ts b/src/schematics/update22/schematic.ts index 25832ef..674a348 100644 --- a/src/schematics/update22/schematic.ts +++ b/src/schematics/update22/schematic.ts @@ -79,13 +79,6 @@ function generateFederationTsConfigs( projectRoot, projectSourceRoot, appTsConfig, - // The exposes live in federation.config.mjs, which is not ours to parse; main.ts - // keeps the program non-empty until the first build fills in the real entries. - entryPoints: [ - original.options.browser ?? - original.options.main ?? - path.join(projectSourceRoot, "main.ts"), - ], }); console.log(`Generated ${federationTsConfig}`); diff --git a/src/tools/esbuild/angular-bundler.spec.ts b/src/tools/esbuild/angular-bundler.spec.ts index 8db0a78..6689e45 100644 --- a/src/tools/esbuild/angular-bundler.spec.ts +++ b/src/tools/esbuild/angular-bundler.spec.ts @@ -4,7 +4,7 @@ import type { CompilerPluginOptions } from '@angular/build/private'; import { createAngularEsbuildContext } from './angular-bundler.js'; import { createAwaitableCompilerPlugin } from './create-awaitable-compiler-plugin.js'; -import { updateFederationTsConfig } from './update-federation-tsconfig.js'; +import { writeContextTsConfig } from './write-context-tsconfig.js'; import type { NormalizedContextOptions } from '../../utils/normalize-context-options.js'; vi.mock('esbuild', () => ({ context: vi.fn().mockResolvedValue({ rebuild: vi.fn() }) })); @@ -23,7 +23,9 @@ vi.mock('./create-awaitable-compiler-plugin.js', () => ({ .mockReturnValue([{ name: 'angular-compiler', setup: vi.fn() }, Promise.resolve()]), })); -vi.mock('./update-federation-tsconfig.js', () => ({ updateFederationTsConfig: vi.fn() })); +vi.mock('./write-context-tsconfig.js', () => ({ + writeContextTsConfig: vi.fn().mockReturnValue('/cache/tsconfig/abc.mapping-bundle.json'), +})); vi.mock('@chialab/esbuild-plugin-commonjs', () => ({ default: () => ({ name: 'commonjs', setup: vi.fn() }), @@ -67,12 +69,14 @@ describe('createAngularEsbuildContext', () => { it('throws when no tsconfig is configured', async () => { const options = makeOptions({ tsConfigPath: undefined }); - await expect(createAngularEsbuildContext(options)).rejects.toThrow('tsConfigPath is required'); + await expect(createAngularEsbuildContext(options, 'mapping-or-exposed')).rejects.toThrow( + 'tsConfigPath is required' + ); }); // #98 it('passes the normalized tsconfig path to esbuild, not only to the plugin', async () => { - await createAngularEsbuildContext(makeOptions()); + await createAngularEsbuildContext(makeOptions(), 'mapping-or-exposed'); const expected = path.join(workspaceRoot, 'apps/example/tsconfig.app.json'); @@ -84,7 +88,7 @@ describe('createAngularEsbuildContext', () => { expect(pluginOptions.tsconfig).toBe(expected); }); - it('updates the tsconfig the NF target declared, passing the fallback entry points', async () => { + it('compiles against a per-context tsconfig when the NF target declared one', async () => { await createAngularEsbuildContext( makeOptions({ builderOptions: { @@ -93,33 +97,38 @@ describe('createAngularEsbuildContext', () => { manageTsConfig: true, fallbackEntryPoints: ['apps/example/src/main.ts'], }, - } as unknown as Partial) + } as unknown as Partial), + 'mapping-bundle' ); - // updateFederationTsConfig joins the workspace root itself - expect(updateFederationTsConfig).toHaveBeenCalledWith( + expect(writeContextTsConfig).toHaveBeenCalledWith({ workspaceRoot, - 'apps/example/tsconfig.app.json', - expect.anything(), - ['apps/example/src/main.ts'] - ); - expect(lastBuildOptions().tsconfig).toBe( - path.join(workspaceRoot, 'apps/example/tsconfig.app.json') - ); + tsConfigPath: 'apps/example/tsconfig.app.json', + cacheDir: '/cache', + bundleName: 'mapping-bundle', + entryPoints: [{ fileName: 'apps/example/src/main.ts', outName: 'main.js' }], + fallbackEntryPoints: ['apps/example/src/main.ts'], + }); + + const [pluginOptions] = vi.mocked(createAwaitableCompilerPlugin).mock.calls[0] as [ + CompilerPluginOptions, + ]; + expect(pluginOptions.tsconfig).toBe('/cache/tsconfig/abc.mapping-bundle.json'); + expect(lastBuildOptions().tsconfig).toBe('/cache/tsconfig/abc.mapping-bundle.json'); }); // Without `tsConfig` on the NF target the builder falls back to the Angular target's own // tsconfig, which is the user's file and must be left alone. it('leaves the tsconfig alone when the NF target declared none', async () => { - await createAngularEsbuildContext(makeOptions()); + await createAngularEsbuildContext(makeOptions(), 'mapping-or-exposed'); - expect(updateFederationTsConfig).not.toHaveBeenCalled(); + expect(writeContextTsConfig).not.toHaveBeenCalled(); }); // #117: left relative, esbuild resolves these through its own working directory, which need // not agree with the root the TypeScript program was built from. it('anchors workspace-root-relative entry points on the workspace root', async () => { - await createAngularEsbuildContext(makeOptions()); + await createAngularEsbuildContext(makeOptions(), 'mapping-or-exposed'); expect(lastBuildOptions().entryPoints).toEqual([ { in: path.join(workspaceRoot, 'apps/example/src/main.ts'), out: 'main' }, @@ -130,7 +139,8 @@ describe('createAngularEsbuildContext', () => { it('leaves an already-absolute entry point untouched', async () => { const absolute = path.join(workspaceRoot, 'libs', 'ui', 'src', 'index.ts'); await createAngularEsbuildContext( - makeOptions({ entryPoints: [{ fileName: absolute, outName: 'ui.js' }] }) + makeOptions({ entryPoints: [{ fileName: absolute, outName: 'ui.js' }] }), + 'mapping-bundle' ); expect(lastBuildOptions().entryPoints).toEqual([{ in: absolute, out: 'ui' }]); diff --git a/src/tools/esbuild/angular-bundler.ts b/src/tools/esbuild/angular-bundler.ts index 025eb81..1182413 100644 --- a/src/tools/esbuild/angular-bundler.ts +++ b/src/tools/esbuild/angular-bundler.ts @@ -16,9 +16,12 @@ import { normalizeOptimization, normalizeSourceMaps } from '../../utils/normaliz import { createAwaitableCompilerPlugin } from './create-awaitable-compiler-plugin.js'; import type { NormalizedContextOptions } from '../../utils/normalize-context-options.js'; -import { updateFederationTsConfig } from './update-federation-tsconfig.js'; +import { writeContextTsConfig } from './write-context-tsconfig.js'; -export async function createAngularEsbuildContext(options: NormalizedContextOptions): Promise<{ +export async function createAngularEsbuildContext( + options: NormalizedContextOptions, + bundleName: string +): Promise<{ ctx: esbuild.BuildContext; pluginDisposed: Promise; }> { @@ -35,9 +38,9 @@ export async function createAngularEsbuildContext(options: NormalizedContextOpti platform, } = options; - let tsConfigPath = options.tsConfigPath; + const federationTsConfig = options.tsConfigPath; - if (!tsConfigPath) { + if (!federationTsConfig) { throw new Error('tsConfigPath is required for Angular/esbuild context creation'); } @@ -77,20 +80,17 @@ export async function createAngularEsbuildContext(options: NormalizedContextOpti } } - // Only a tsconfig the NF target explicitly points at is ours to rewrite. Without one this is - // the Angular target's own tsconfig, where `files` belongs to Angular — replacing it there - // drops main.ts from the app's program on any project scaffolded with the older - // `files: ["src/main.ts"]` / `include: ["src/**/*.d.ts"]` shape. - if (builderOptions.manageTsConfig) { - updateFederationTsConfig( - workspaceRoot, - tsConfigPath, - entryPoints, - builderOptions.fallbackEntryPoints - ); - } - - tsConfigPath = path.join(workspaceRoot, tsConfigPath); + // Without an NF-declared tsconfig this is the Angular target's own, which is left as is. + const tsConfigPath = builderOptions.manageTsConfig + ? writeContextTsConfig({ + workspaceRoot, + tsConfigPath: federationTsConfig, + cacheDir: cache.cachePath, + bundleName, + entryPoints, + fallbackEntryPoints: builderOptions.fallbackEntryPoints, + }) + : path.join(workspaceRoot, federationTsConfig); const pluginOptions: CompilerPluginOptions = { sourcemap: !!sourcemapOptions.scripts && (sourcemapOptions.hidden ? 'external' : true), diff --git a/src/tools/esbuild/angular-esbuild-adapter.spec.ts b/src/tools/esbuild/angular-esbuild-adapter.spec.ts index 35e348f..c68388a 100644 --- a/src/tools/esbuild/angular-esbuild-adapter.spec.ts +++ b/src/tools/esbuild/angular-esbuild-adapter.spec.ts @@ -77,7 +77,7 @@ describe('createAngularBuildAdapter', () => { // second setup with the same name is a no-op await adapter.setup('remote', {} as never); - expect(createAngularEsbuildContext).toHaveBeenCalledTimes(1); + expect(createAngularEsbuildContext).toHaveBeenCalledWith(expect.anything(), 'remote'); expect(createNodeModulesEsbuildContext).not.toHaveBeenCalled(); }); diff --git a/src/tools/esbuild/angular-esbuild-adapter.ts b/src/tools/esbuild/angular-esbuild-adapter.ts index 918b1af..bee1a36 100644 --- a/src/tools/esbuild/angular-esbuild-adapter.ts +++ b/src/tools/esbuild/angular-esbuild-adapter.ts @@ -106,7 +106,7 @@ export function createAngularBuildAdapter( const normalizedOptions = normalizeContextOptions(ngBuilderOptions, context, adapterOptions); const { ctx, pluginDisposed } = normalizedOptions.isMappingOrExposed - ? await createAngularEsbuildContext(normalizedOptions) + ? await createAngularEsbuildContext(normalizedOptions, name) : await createNodeModulesEsbuildContext(normalizedOptions); bundleContextCache.set(name, { diff --git a/src/tools/esbuild/update-federation-tsconfig.spec.ts b/src/tools/esbuild/update-federation-tsconfig.spec.ts deleted file mode 100644 index 83aaa11..0000000 --- a/src/tools/esbuild/update-federation-tsconfig.spec.ts +++ /dev/null @@ -1,146 +0,0 @@ -import fs from 'fs'; -import path from 'path'; -import JSON5 from 'json5'; - -import { updateFederationTsConfig } from './update-federation-tsconfig.js'; -import type { EntryPoint } from '@softarc/native-federation'; - -vi.mock('fs'); - -function entry(fileName: string): EntryPoint { - return { fileName, outName: 'out.js' } as EntryPoint; -} - -function written() { - return JSON.parse(String(vi.mocked(fs.writeFileSync).mock.calls[0]![1])); -} - -describe('updateFederationTsConfig', () => { - afterEach(() => { - vi.mocked(fs.existsSync).mockReset(); - vi.mocked(fs.readFileSync).mockReset(); - vi.mocked(fs.writeFileSync).mockReset(); - }); - - it('returns early without touching fs when there is nothing to compile', () => { - updateFederationTsConfig('/ws', 'tsconfig.fed.json', [], []); - - expect(fs.readFileSync).not.toHaveBeenCalled(); - expect(fs.writeFileSync).not.toHaveBeenCalled(); - }); - - it('throws naming the tsconfig when the file the target points at is missing', () => { - vi.mocked(fs.existsSync).mockReturnValue(false); - - expect(() => - updateFederationTsConfig('/ws', 'projects/mfe1/tsconfig.fed.json', [ - entry('./projects/mfe1/src/bootstrap.ts'), - ]) - ).toThrow(/"projects\/mfe1\/tsconfig\.fed\.json" does not exist/); - - expect(fs.readFileSync).not.toHaveBeenCalled(); - expect(fs.writeFileSync).not.toHaveBeenCalled(); - }); - - it('resolves workspace-root-relative exposes against the workspace root', () => { - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.readFileSync).mockReturnValue(JSON5.stringify({ files: [] }) as never); - - updateFederationTsConfig('/ws', 'projects/mfe1/tsconfig.fed.json', [ - entry('./projects/mfe1/src/bootstrap.ts'), - ]); - - expect(written().files).toEqual(['src/bootstrap.ts']); - }); - - it('resolves absolute mapping entry points relative to the tsconfig dir', () => { - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.readFileSync).mockReturnValue(JSON5.stringify({ files: [] }) as never); - - updateFederationTsConfig('/ws', 'tsconfig.fed.json', [entry('/ws/src/a.ts')]); - - expect(fs.writeFileSync).toHaveBeenCalledTimes(1); - expect(written().files).toEqual(['src/a.ts']); - }); - - // Regression: with `ignoreUnusedDeps: false` core hands over every tsconfig path mapping, - // used or not. They are all bundled, so they all have to be in the program. - it('keeps mapping entry points alongside exposes', () => { - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.readFileSync).mockReturnValue(JSON5.stringify({ files: [] }) as never); - - updateFederationTsConfig('/ws', 'tsconfig.fed.json', [ - entry('/ws/libs/unused/src/index.ts'), - entry('./src/bootstrap.ts'), - ]); - - expect(written().files).toEqual(['libs/unused/src/index.ts', 'src/bootstrap.ts']); - }); - - it('replaces the previous files, dropping entry points that are gone', () => { - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.readFileSync).mockReturnValue( - JSON5.stringify({ files: ['src/renamed-away.ts'], include: ['src/**/*.d.ts'] }) as never - ); - - updateFederationTsConfig('/ws', 'tsconfig.fed.json', [entry('./src/a.ts')]); - - expect(written()).toEqual({ files: ['src/a.ts'], include: ['src/**/*.d.ts'] }); - }); - - it('deduplicates entry points resolving to the same file', () => { - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.readFileSync).mockReturnValue(JSON5.stringify({ files: [] }) as never); - - updateFederationTsConfig('/ws', 'tsconfig.fed.json', [ - entry('/ws/src/a.ts'), - entry('./src/a.ts'), - ]); - - expect(written().files).toEqual(['src/a.ts']); - }); - - it('falls back to the given entry points when the build has none of its own', () => { - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.readFileSync).mockReturnValue(JSON5.stringify({ files: [] }) as never); - - updateFederationTsConfig('/ws', 'projects/host/tsconfig.fed.json', [], [ - 'projects/host/src/main.ts', - ]); - - expect(written().files).toEqual(['src/main.ts']); - }); - - it('creates the files array when the tsconfig has none', () => { - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.readFileSync).mockReturnValue(JSON5.stringify({ compilerOptions: {} }) as never); - - updateFederationTsConfig('/ws', 'tsconfig.fed.json', [entry('/ws/src/a.ts')]); - - expect(written().files).toEqual(['src/a.ts']); - }); - - it('normalizes OS-specific backslash separators to forward slashes', () => { - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.readFileSync).mockReturnValue(JSON5.stringify({ files: [] }) as never); - // Simulate Windows: path.relative returns single-backslash separators. - const relativeSpy = vi - .spyOn(path, 'relative') - .mockReturnValue('..\\libs\\shared\\src\\index.ts'); - - updateFederationTsConfig('/ws', 'tsconfig.fed.json', [entry('/libs/shared/src/index.ts')]); - - expect(written().files).toEqual(['../libs/shared/src/index.ts']); - - relativeSpy.mockRestore(); - }); - - it('does not write when the resulting config is unchanged', () => { - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.readFileSync).mockReturnValue(JSON5.stringify({ files: ['src/a.ts'] }) as never); - - updateFederationTsConfig('/ws', 'tsconfig.fed.json', [entry('/ws/src/a.ts')]); - - expect(fs.writeFileSync).not.toHaveBeenCalled(); - }); -}); diff --git a/src/tools/esbuild/update-federation-tsconfig.ts b/src/tools/esbuild/update-federation-tsconfig.ts deleted file mode 100644 index 8413139..0000000 --- a/src/tools/esbuild/update-federation-tsconfig.ts +++ /dev/null @@ -1,80 +0,0 @@ -import type { EntryPoint } from '@softarc/native-federation'; -import path from 'path'; -import fs from 'fs'; -import JSON5 from 'json5'; -import { isDeepStrictEqual } from 'util'; - -/** - * Puts the federation entry points into the federation tsconfig's `files`, so the - * angular-compiler plugin finds them in the TypeScript program. - * - * The two keys are owned by different sides: the schematic writes `extends` and `include` - * (see schematics/init/steps/generate-federation-tsconfig.ts), this writes `files`. Because - * `files` is replaced rather than appended to, an expose that was renamed or removed leaves - * nothing behind. - * - * Only ever call this for a tsconfig the NF target explicitly points at — it is rewritten - * as plain JSON, which drops any comments the file had. - */ -export function updateFederationTsConfig( - workspaceRoot: string, - tsConfigPath: string, - entryPoints: EntryPoint[], - fallbackEntryPoints: string[] = [] -): void { - const fullTsConfigPath = path.join(workspaceRoot, tsConfigPath); - const tsconfigDir = path.dirname(fullTsConfigPath); - - // Core hands exposes over workspace-root-relative and shared mappings absolute. - const toTsConfigRelative = (fileName: string) => { - const absolute = path.isAbsolute(fileName) ? fileName : path.join(workspaceRoot, fileName); - - return path.relative(tsconfigDir, absolute).replace(/\\/g, '/'); - }; - - const resolved = entryPoints.map(ep => toTsConfigRelative(ep.fileName)); - - // A host without exposes or shared mappings has no entry points of its own; the app's - // main.ts keeps the program from being empty. - const files = [ - ...new Set(resolved.length > 0 ? resolved : fallbackEntryPoints.map(toTsConfigRelative)), - ]; - - if (files.length === 0) { - return; - } - - if (!fs.existsSync(fullTsConfigPath)) { - throw new Error( - `The federation tsconfig "${tsConfigPath}" does not exist, so the exposed modules and ` + - `shared mappings cannot be added to the TypeScript program.` - ); - } - - const tsconfigAsString = fs.readFileSync(fullTsConfigPath, 'utf-8'); - const tsconfig = JSON5.parse(tsconfigAsString); - - tsconfig.files = files; - - const content = JSON5.stringify(tsconfig, null, 2); - - if (!doesFileExistAndJsonEqual(fullTsConfigPath, content)) { - fs.writeFileSync(fullTsConfigPath, JSON.stringify(tsconfig, null, 2)); - } -} - -function doesFileExistAndJsonEqual(filePath: string, content: string): boolean { - if (!fs.existsSync(filePath)) { - return false; - } - - try { - const currentContent = fs.readFileSync(filePath, 'utf-8'); - const currentJson = JSON5.parse(currentContent); - const newJson = JSON5.parse(content); - - return isDeepStrictEqual(currentJson, newJson); - } catch { - return false; - } -} diff --git a/src/tools/esbuild/write-context-tsconfig.spec.ts b/src/tools/esbuild/write-context-tsconfig.spec.ts new file mode 100644 index 0000000..d733aa6 --- /dev/null +++ b/src/tools/esbuild/write-context-tsconfig.spec.ts @@ -0,0 +1,226 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import ts from 'typescript'; + +import { logger } from '@softarc/native-federation/internal'; + +import { writeContextTsConfig } from './write-context-tsconfig.js'; +import type { EntryPoint } from '@softarc/native-federation'; + +function entry(fileName: string): EntryPoint { + return { fileName, outName: 'out.js' } as EntryPoint; +} + +function touch(ws: string, file: string, content = '') { + const full = path.join(ws, file); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, content); +} + +// What TypeScript (and so Angular's compiler plugin) makes of the generated config. +function parse(configPath: string) { + const { config } = ts.readConfigFile(configPath, ts.sys.readFile); + return ts.parseJsonConfigFileContent(config, ts.sys, path.dirname(configPath), {}, configPath); +} + +const posix = (p: string) => p.replace(/\\/g, '/'); + +describe('writeContextTsConfig', () => { + let ws: string; + let cacheDir: string; + + beforeEach(() => { + ws = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'nf-context-tsconfig-'))); + cacheDir = path.join(ws, 'node_modules/.cache/native-federation'); + + // Every Angular workspace has its own typescript; the type roots are read through it. + fs.mkdirSync(path.join(ws, 'node_modules')); + fs.symlinkSync( + path.resolve('node_modules/typescript'), + path.join(ws, 'node_modules/typescript') + ); + + // The shape the init schematic writes: extends the app tsconfig, `files` seeded, `include` + // only picking up ambient declarations. + touch( + ws, + 'projects/mfe1/tsconfig.app.json', + JSON.stringify({ compilerOptions: { paths: { '@libs/ui': ['../../libs/ui/src/index.ts'] } } }) + ); + touch( + ws, + 'projects/mfe1/tsconfig.federation.json', + JSON.stringify({ + extends: './tsconfig.app.json', + files: ['src/stale-from-schematic.ts'], + include: ['src/**/*.d.ts'], + }) + ); + touch(ws, 'projects/mfe1/src/typings.d.ts'); + touch(ws, 'projects/mfe1/src/bootstrap.ts'); + touch(ws, 'projects/mfe1/src/main.ts'); + touch(ws, 'libs/ui/src/index.ts'); + }); + + afterEach(() => { + fs.rmSync(ws, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + const write = (bundleName: string, entryPoints: EntryPoint[], fallbackEntryPoints?: string[]) => + writeContextTsConfig({ + workspaceRoot: ws, + tsConfigPath: 'projects/mfe1/tsconfig.federation.json', + cacheDir, + bundleName, + entryPoints, + fallbackEntryPoints, + }); + + it('throws naming the tsconfig when the file the target points at is missing', () => { + expect(() => + writeContextTsConfig({ + workspaceRoot: ws, + tsConfigPath: 'projects/mfe2/tsconfig.federation.json', + cacheDir, + bundleName: 'mapping-or-exposed', + entryPoints: [entry('./projects/mfe2/src/bootstrap.ts')], + }) + ).toThrow(/"projects\/mfe2\/tsconfig\.federation\.json" does not exist/); + }); + + it('writes into the cache dir and leaves the federation tsconfig untouched', () => { + const federationTsConfig = path.join(ws, 'projects/mfe1/tsconfig.federation.json'); + const before = fs.readFileSync(federationTsConfig, 'utf-8'); + + const written = write('mapping-or-exposed', [entry('./projects/mfe1/src/bootstrap.ts')]); + + expect(path.dirname(written)).toBe(path.join(cacheDir, 'tsconfig')); + expect(fs.readFileSync(federationTsConfig, 'utf-8')).toBe(before); + }); + + // The #138 regression: each context's program holds only its own entry points, whatever the + // other contexts wrote, while `include` and `paths` still come from the federation tsconfig. + it('gives each build context a program of its own entry points', () => { + const mappings = write('mapping-bundle', [entry(path.join(ws, 'libs/ui/src/index.ts'))]); + const exposed = write('mapping-or-exposed', [entry('./projects/mfe1/src/bootstrap.ts')]); + + expect(mappings).not.toBe(exposed); + + expect(parse(mappings).fileNames).toEqual([ + posix(path.join(ws, 'libs/ui/src/index.ts')), + posix(path.join(ws, 'projects/mfe1/src/typings.d.ts')), + ]); + expect(parse(exposed).fileNames).toEqual([ + posix(path.join(ws, 'projects/mfe1/src/bootstrap.ts')), + posix(path.join(ws, 'projects/mfe1/src/typings.d.ts')), + ]); + expect(parse(exposed).options.paths).toEqual({ '@libs/ui': ['../../libs/ui/src/index.ts'] }); + }); + + // The cache dir is workspace-wide and every remote has a 'mapping-or-exposed' context. + it('keys the file on the federation tsconfig, so two projects never share one', () => { + touch(ws, 'projects/mfe2/tsconfig.federation.json', JSON.stringify({ files: [] })); + + const mfe1 = write('mapping-or-exposed', [entry('./projects/mfe1/src/bootstrap.ts')]); + const mfe2 = writeContextTsConfig({ + workspaceRoot: ws, + tsConfigPath: 'projects/mfe2/tsconfig.federation.json', + cacheDir, + bundleName: 'mapping-or-exposed', + entryPoints: [entry('./projects/mfe2/src/bootstrap.ts')], + }); + + expect(mfe1).not.toBe(mfe2); + }); + + it('falls back to the given entry points when the context has none of its own', () => { + const written = write('mapping-or-exposed', [], ['projects/mfe1/src/main.ts']); + + expect(parse(written).fileNames[0]).toBe(posix(path.join(ws, 'projects/mfe1/src/main.ts'))); + }); + + it('deduplicates entry points resolving to the same file', () => { + const written = write('mapping-or-exposed', [ + entry(path.join(ws, 'projects/mfe1/src/bootstrap.ts')), + entry('./projects/mfe1/src/bootstrap.ts'), + ]); + + expect(JSON.parse(fs.readFileSync(written, 'utf-8')).files).toEqual([ + posix(path.join(ws, 'projects/mfe1/src/bootstrap.ts')), + ]); + }); + + // `mapping-` names come from package names; keep them to one path segment. + it('keeps the bundle name to a single file name', () => { + const written = write('mapping-@scope/ui', [entry(path.join(ws, 'libs/ui/src/index.ts'))]); + + expect(path.dirname(written)).toBe(path.join(cacheDir, 'tsconfig')); + }); + + // Default typeRoots are every node_modules/@types above the leaf config. From the cache dir + // that would skip e.g. a pnpm workspace package's own projects/mfe1/node_modules/@types. + it('pins the type roots the federation tsconfig resolves to', () => { + const written = write('mapping-or-exposed', [entry('./projects/mfe1/src/bootstrap.ts')]); + const typeRoots = parse(written).options.typeRoots!; + + expect(typeRoots[0]).toBe(posix(path.join(ws, 'projects/mfe1/node_modules/@types'))); + expect(typeRoots.some(root => root.startsWith(posix(cacheDir)))).toBe(false); + }); + + it('keeps typeRoots the federation tsconfig sets explicitly', () => { + touch( + ws, + 'projects/mfe1/tsconfig.app.json', + JSON.stringify({ compilerOptions: { typeRoots: ['./custom-types'] } }) + ); + + const written = write('mapping-or-exposed', [entry('./projects/mfe1/src/bootstrap.ts')]); + + expect(parse(written).options.typeRoots).toEqual([ + posix(path.join(ws, 'projects/mfe1/custom-types')), + ]); + }); + + it('does not rewrite the file when its content is unchanged', () => { + const written = write('mapping-or-exposed', [entry('./projects/mfe1/src/bootstrap.ts')]); + const past = new Date(Date.now() - 60_000); + fs.utimesSync(written, past, past); + + write('mapping-or-exposed', [entry('./projects/mfe1/src/bootstrap.ts')]); + + expect(fs.statSync(written).mtimeMs).toBe(past.getTime()); + }); + + // Tsconfigs generated before the schematic stopped seeding `files` still carry one. + it("warns once that the federation tsconfig's own files are ignored", () => { + const warn = vi.spyOn(logger, 'warn').mockImplementation(() => undefined); + + write('mapping-bundle', [entry(path.join(ws, 'libs/ui/src/index.ts'))]); + write('mapping-or-exposed', [entry('./projects/mfe1/src/bootstrap.ts')]); + + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0]![0]).toMatch( + /"projects\/mfe1\/tsconfig\.federation\.json" lists "files"/ + ); + }); + + // The app tsconfig it extends often has the older `files: ["src/main.ts"]` shape. + it('does not warn about files inherited from the extended tsconfig', () => { + const warn = vi.spyOn(logger, 'warn').mockImplementation(() => undefined); + touch(ws, 'projects/mfe1/tsconfig.app.json', JSON.stringify({ files: ['src/main.ts'] })); + touch( + ws, + 'projects/mfe1/tsconfig.federation.json', + JSON.stringify({ extends: './tsconfig.app.json', include: ['src/**/*.d.ts'] }) + ); + + const written = write('mapping-or-exposed', [entry('./projects/mfe1/src/bootstrap.ts')]); + + expect(warn).not.toHaveBeenCalled(); + expect(parse(written).fileNames[0]).toBe( + posix(path.join(ws, 'projects/mfe1/src/bootstrap.ts')) + ); + }); +}); diff --git a/src/tools/esbuild/write-context-tsconfig.ts b/src/tools/esbuild/write-context-tsconfig.ts new file mode 100644 index 0000000..0a5de02 --- /dev/null +++ b/src/tools/esbuild/write-context-tsconfig.ts @@ -0,0 +1,110 @@ +import type { EntryPoint } from '@softarc/native-federation'; +import { createHash } from 'crypto'; +import path from 'path'; +import fs from 'fs'; +import { createRequire } from 'module'; +import type * as TypeScript from 'typescript'; +import { logger } from '@softarc/native-federation/internal'; + +export interface ContextTsConfigOptions { + workspaceRoot: string; + tsConfigPath: string; + cacheDir: string; + bundleName: string; + entryPoints: EntryPoint[]; + fallbackEntryPoints?: string[]; +} + +const warnedAboutFiles = new Set(); + +// One tsconfig per build context, so each program holds only its own entry points (#138). +export function writeContextTsConfig(options: ContextTsConfigOptions): string { + const { workspaceRoot, tsConfigPath, cacheDir, bundleName, entryPoints } = options; + const federationTsConfig = path.resolve(workspaceRoot, tsConfigPath); + + if (!fs.existsSync(federationTsConfig)) { + throw new Error( + `The federation tsconfig "${tsConfigPath}" does not exist, so the exposed modules and ` + + `shared mappings cannot be added to the TypeScript program.` + ); + } + + const federation = readFederationTsConfig(workspaceRoot, federationTsConfig); + + if (federation.ownFiles && !warnedAboutFiles.has(federationTsConfig)) { + warnedAboutFiles.add(federationTsConfig); + logger.warn( + `"${tsConfigPath}" lists "files", which the federation build ignores: every build ` + + `context compiles its own exposes and shared mappings. Remove "files" to silence this.` + ); + } + + // Core hands exposes over workspace-root-relative and shared mappings absolute. + const resolved = entryPoints.map(ep => path.resolve(workspaceRoot, ep.fileName)); + + // A host without exposes or shared mappings would otherwise get an empty program. + const files = [ + ...new Set( + resolved.length > 0 + ? resolved + : (options.fallbackEntryPoints ?? []).map(file => path.resolve(workspaceRoot, file)) + ), + ].map(toPosix); + + const contextTsConfig = path.join( + cacheDir, + 'tsconfig', + `${hashOf(federationTsConfig)}.${bundleName.replace(/[^A-Za-z0-9._-]/g, '_')}.json` + ); + + const content = JSON.stringify( + { + extends: toPosix(federationTsConfig), + compilerOptions: { typeRoots: federation.typeRoots.map(toPosix) }, + files, + }, + null, + 2 + ); + + if (!fs.existsSync(contextTsConfig) || fs.readFileSync(contextTsConfig, 'utf-8') !== content) { + fs.mkdirSync(path.dirname(contextTsConfig), { recursive: true }); + fs.writeFileSync(contextTsConfig, content); + } + + return contextTsConfig; +} + +function readFederationTsConfig( + workspaceRoot: string, + federationTsConfig: string +): { ownFiles: boolean; typeRoots: string[] } { + // The workspace's compiler, the one Angular builds with; the adapter ships none. + const ts: typeof TypeScript = createRequire(path.join(workspaceRoot, 'package.json'))( + 'typescript' + ); + const { config } = ts.readConfigFile(federationTsConfig, ts.sys.readFile); + // Before parsing, which copies an inherited `files` into `config`. + const ownFiles = Array.isArray(config?.files) && config.files.length > 0; + const { options } = ts.parseJsonConfigFileContent( + config ?? {}, + ts.sys, + path.dirname(federationTsConfig), + undefined, + federationTsConfig + ); + + return { + ownFiles, + // Defaults are resolved from the leaf config's directory, which is now the cache dir. + typeRoots: ts.getEffectiveTypeRoots(options, ts.sys) ?? [], + }; +} + +function hashOf(value: string): string { + return createHash('sha1').update(value).digest('hex').slice(0, 8); +} + +function toPosix(p: string): string { + return p.replace(/\\/g, '/'); +} From a052b0ec118c9b40da616177605008e3f5df4121 Mon Sep 17 00:00:00 2001 From: Aukevanoost Date: Wed, 23 Sep 2026 12:26:06 +0200 Subject: [PATCH 2/4] fix: dispose every federation build context before the app build Only 'mapping-or-exposed' was disposed, but core now builds each mapping bundle as a context of its own, so their compiler plugins never reset Angular's shared TS compilation state before the app build (#47). A mappings-only host has no 'mapping-or-exposed' at all: that dispose threw and the catch hid it, leaving every context undisposed. A full dispose() is no option either, since it stops esbuild, which the app build still needs. The adapter now offers disposeFederationContexts(), which disposes every mapping and exposed context and leaves esbuild running. Refs #138 --- src/builders/build/builder.ts | 6 +- .../esbuild/angular-esbuild-adapter.spec.ts | 58 +++++++++++++++++++ src/tools/esbuild/angular-esbuild-adapter.ts | 41 ++++++++----- 3 files changed, 86 insertions(+), 19 deletions(-) diff --git a/src/builders/build/builder.ts b/src/builders/build/builder.ts index 1b0f14a..b24b948 100644 --- a/src/builders/build/builder.ts +++ b/src/builders/build/builder.ts @@ -526,10 +526,10 @@ export async function* runBuilder( process.exit(1); } - // Dispose the finished federation context so its compiler-plugin onDispose resets - // Angular's shared TS compilation state before the app build (#47); watch reuses it. + // Each compiler plugin's onDispose resets Angular's shared TS compilation state before the + // app build (#47); watch reuses the contexts. if (!watch) { - await adapter.dispose("mapping-or-exposed").catch(() => undefined); + await adapter.disposeFederationContexts(); } syncFederationWatcher(); diff --git a/src/tools/esbuild/angular-esbuild-adapter.spec.ts b/src/tools/esbuild/angular-esbuild-adapter.spec.ts index c68388a..b6abe32 100644 --- a/src/tools/esbuild/angular-esbuild-adapter.spec.ts +++ b/src/tools/esbuild/angular-esbuild-adapter.spec.ts @@ -139,4 +139,62 @@ describe('createAngularBuildAdapter', () => { // cache is cleared, so a subsequent build fails again await expect(adapter.build('remote')).rejects.toThrow('No context found'); }); + + // #138: core builds each mapping bundle apart from the exposed modules, under names the + // builder does not know, and a mappings-only host has no 'mapping-or-exposed' at all. + it('disposes every mapping and exposed context without stopping esbuild', async () => { + const mappingCtx = makeCtx(); + const exposedCtx = makeCtx(); + const sharedCtx = makeCtx(); + vi.mocked(createAngularEsbuildContext) + .mockResolvedValueOnce({ ctx: mappingCtx as never, pluginDisposed: Promise.resolve() }) + .mockResolvedValueOnce({ ctx: exposedCtx as never, pluginDisposed: Promise.resolve() }); + vi.mocked(createNodeModulesEsbuildContext).mockResolvedValue({ + ctx: sharedCtx as never, + pluginDisposed: Promise.resolve(), + }); + + const adapter = createAngularBuildAdapter(ngBuilderOptions, context); + await adapter.setup('mapping-bundle', {} as never); + await adapter.setup('mapping-or-exposed', {} as never); + vi.mocked(normalizeContextOptions).mockReturnValue( + normalizedWith({ isMappingOrExposed: false }) as never + ); + await adapter.setup('browser-shared', {} as never); + + await adapter.disposeFederationContexts(); + + expect(mappingCtx.dispose).toHaveBeenCalledTimes(1); + expect(exposedCtx.dispose).toHaveBeenCalledTimes(1); + expect(sharedCtx.dispose).not.toHaveBeenCalled(); + expect(esbuild.stop).not.toHaveBeenCalled(); + await expect(adapter.build('mapping-bundle')).rejects.toThrow('No context found'); + await expect(adapter.build('browser-shared')).resolves.toBeDefined(); + }); + + it('waits for each compiler plugin to finish disposing', async () => { + let releasePlugin!: () => void; + const pluginDisposed = new Promise(resolve => (releasePlugin = resolve)); + vi.mocked(createAngularEsbuildContext).mockResolvedValue({ + ctx: makeCtx() as never, + pluginDisposed, + }); + const adapter = createAngularBuildAdapter(ngBuilderOptions, context); + await adapter.setup('mapping-bundle', {} as never); + + let done = false; + const disposing = adapter.disposeFederationContexts().then(() => (done = true)); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(done).toBe(false); + + releasePlugin(); + await disposing; + expect(done).toBe(true); + }); + + it('is a no-op when there are no mapping or exposed contexts', async () => { + const adapter = createAngularBuildAdapter(ngBuilderOptions, context); + + await expect(adapter.disposeFederationContexts()).resolves.toBeUndefined(); + }); }); diff --git a/src/tools/esbuild/angular-esbuild-adapter.ts b/src/tools/esbuild/angular-esbuild-adapter.ts index bee1a36..3647c02 100644 --- a/src/tools/esbuild/angular-esbuild-adapter.ts +++ b/src/tools/esbuild/angular-esbuild-adapter.ts @@ -59,27 +59,23 @@ function setNgServerMode(): void { } } +export interface AngularBuildAdapter extends NFBuildAdapter { + // Disposes every mapping and exposed context, leaving esbuild running for the app build. + disposeFederationContexts(): Promise; +} + export function createAngularBuildAdapter( ngBuilderOptions: ApplicationBuilderOptions & NfInternalOptions, context: BuilderContext -): NFBuildAdapter { +): AngularBuildAdapter { const bundleContextCache = new Map(); - const dispose = async (name?: string): Promise => { - if (name) { - if (!bundleContextCache.has(name)) - throw new Error(`Could not dispose of non-existing build '${name}'`); - - const entry = bundleContextCache.get(name)!; - await entry.ctx.dispose(); - await entry.pluginDisposed; - bundleContextCache.delete(name); - return; - } - + const disposeWhere = async (matches: (entry: EsbuildContextResult) => boolean) => { const disposals: Promise[] = []; - for (const [, entry] of bundleContextCache) { + for (const [name, entry] of bundleContextCache) { + if (!matches(entry)) continue; + bundleContextCache.delete(name); disposals.push( (async () => { await entry.ctx.dispose(); @@ -87,12 +83,25 @@ export function createAngularBuildAdapter( })() ); } - bundleContextCache.clear(); + await Promise.all(disposals); + }; + const dispose = async (name?: string): Promise => { + if (name) { + if (!bundleContextCache.has(name)) + throw new Error(`Could not dispose of non-existing build '${name}'`); + + await disposeWhere(entry => entry.name === name); + return; + } + + await disposeWhere(() => true); await esbuild.stop(); }; + const disposeFederationContexts = () => disposeWhere(entry => entry.isMappingOrExposed); + const setup = async ( name: string, adapterOptions: NFBuildAdapterOptions @@ -155,5 +164,5 @@ export function createAngularBuildAdapter( } }; - return { setup, build, dispose }; + return { setup, build, dispose, disposeFederationContexts }; } From 188c1ec3d2c7bd363ab673a273e6349328ed1dd5 Mon Sep 17 00:00:00 2001 From: Aukevanoost Date: Wed, 23 Sep 2026 12:31:51 +0200 Subject: [PATCH 3/4] feat(schematics): remove "files" from tsconfig.federation.json in 22.2.0 Each build context now supplies its own `files`, so the list the schematics used to seed only goes stale and triggers a build warning. The 22.2.0 migration removes it from every project's tsconfig.federation.json, editing in place so the file keeps its comments and formatting. Other tsconfigs are left alone. Refs #138 --- migration-collection.json | 6 + package.json | 1 + pnpm-lock.yaml | 3 + src/schematics/update22-2/schema.json | 7 ++ src/schematics/update22-2/schematic.spec.ts | 132 ++++++++++++++++++++ src/schematics/update22-2/schematic.ts | 39 ++++++ 6 files changed, 188 insertions(+) create mode 100644 src/schematics/update22-2/schema.json create mode 100644 src/schematics/update22-2/schematic.spec.ts create mode 100644 src/schematics/update22-2/schematic.ts diff --git a/migration-collection.json b/migration-collection.json index 3697745..fe14e91 100644 --- a/migration-collection.json +++ b/migration-collection.json @@ -14,6 +14,12 @@ "factory": "./src/schematics/update22/schematic", "schema": "./src/schematics/update22/schema.json", "description": "migrating native-federation to the v22 ESM standard and generating a tsconfig.federation.json per federated project" + }, + "update22-2": { + "version": "22.2.0", + "factory": "./src/schematics/update22-2/schematic", + "schema": "./src/schematics/update22-2/schema.json", + "description": "removing the no longer used \"files\" from each tsconfig.federation.json" } } } diff --git a/package.json b/package.json index 8857f2c..8958bb0 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "@softarc/native-federation-orchestrator": "^4.6.0", "es-module-shims": "^2.8.0", "esbuild": "^0.28.0", + "jsonc-parser": "^3.3.1", "mrmime": "^2.0.1", "watchpack": "^2.5.2" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d97e772..cdc8fd2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: esbuild: specifier: ^0.28.0 version: 0.28.2 + jsonc-parser: + specifier: ^3.3.1 + version: 3.3.1 mrmime: specifier: ^2.0.1 version: 2.0.1 diff --git a/src/schematics/update22-2/schema.json b/src/schematics/update22-2/schema.json new file mode 100644 index 0000000..9efbb4e --- /dev/null +++ b/src/schematics/update22-2/schema.json @@ -0,0 +1,7 @@ +{ + "$schema": "http://json-schema.org/schema", + "$id": "update22-2", + "title": "", + "type": "object", + "properties": {} +} diff --git a/src/schematics/update22-2/schematic.spec.ts b/src/schematics/update22-2/schematic.spec.ts new file mode 100644 index 0000000..b74515a --- /dev/null +++ b/src/schematics/update22-2/schematic.spec.ts @@ -0,0 +1,132 @@ +import { EmptyTree, type Tree } from '@angular-devkit/schematics'; + +import update22_2 from './schematic.js'; + +const NF_BUILDER = '@angular-architects/native-federation:build'; + +function seed(tree: Tree, projects: Record) { + const workspace = { projects: {} as Record }; + for (const [name, { root }] of Object.entries(projects)) { + workspace.projects[name] = { root, architect: { build: { builder: NF_BUILDER } } }; + } + tree.create('angular.json', JSON.stringify(workspace)); +} + +function read(tree: Tree, path: string) { + return tree.read(path)!.toString('utf8'); +} + +describe('update22-2 — drop files from tsconfig.federation.json', () => { + let tree: Tree; + let context: { logger: { info: ReturnType; warn: ReturnType } }; + + beforeEach(() => { + tree = new EmptyTree(); + context = { logger: { info: vi.fn(), warn: vi.fn() } }; + }); + + // The shape the init/update22 schematics wrote before 22.2. + it('removes files and keeps the rest', async () => { + seed(tree, { mfe1: { root: 'projects/mfe1' } }); + tree.create( + 'projects/mfe1/tsconfig.federation.json', + JSON.stringify( + { + extends: './tsconfig.app.json', + files: ['src/main.ts'], + include: ['src/**/*.d.ts'], + }, + null, + 2 + ) + ); + + await update22_2()(tree, context as never); + + expect(JSON.parse(read(tree, 'projects/mfe1/tsconfig.federation.json'))).toEqual({ + extends: './tsconfig.app.json', + include: ['src/**/*.d.ts'], + }); + }); + + // tsconfigs are JSONC; a JSON.parse/stringify round trip would drop the user's comments. + it('keeps comments in place', async () => { + seed(tree, { mfe1: { root: 'projects/mfe1' } }); + tree.create( + 'projects/mfe1/tsconfig.federation.json', + [ + '{', + ' // compiled by the federation build', + ' "extends": "./tsconfig.app.json",', + ' "files": [', + ' "src/main.ts"', + ' ],', + ' "include": ["src/**/*.d.ts"],', + '}', + ].join('\n') + ); + + await update22_2()(tree, context as never); + + const text = read(tree, 'projects/mfe1/tsconfig.federation.json'); + expect(text).toContain('// compiled by the federation build'); + expect(text).not.toContain('"files"'); + expect(text).toContain('"include": ["src/**/*.d.ts"]'); + }); + + it('covers every project, including one at the workspace root', async () => { + seed(tree, { host: { root: '' }, mfe1: { root: 'projects/mfe1' } }); + tree.create('tsconfig.federation.json', '{ "files": ["src/main.ts"] }'); + tree.create('projects/mfe1/tsconfig.federation.json', '{ "files": ["src/main.ts"] }'); + + await update22_2()(tree, context as never); + + expect(JSON.parse(read(tree, 'tsconfig.federation.json'))).toEqual({}); + expect(JSON.parse(read(tree, 'projects/mfe1/tsconfig.federation.json'))).toEqual({}); + }); + + it('leaves a federation tsconfig without files byte-for-byte alone', async () => { + seed(tree, { mfe1: { root: 'projects/mfe1' } }); + const original = '{\n "extends": "./tsconfig.app.json" // unchanged\n}\n'; + tree.create('projects/mfe1/tsconfig.federation.json', original); + + await update22_2()(tree, context as never); + + expect(read(tree, 'projects/mfe1/tsconfig.federation.json')).toBe(original); + }); + + // Only the file the schematics generate is ours; tsconfig.app.json's `files` is Angular's. + it('never touches other tsconfigs', async () => { + seed(tree, { mfe1: { root: 'projects/mfe1' } }); + const app = '{ "files": ["src/main.ts"] }'; + tree.create('projects/mfe1/tsconfig.app.json', app); + + await update22_2()(tree, context as never); + + expect(read(tree, 'projects/mfe1/tsconfig.app.json')).toBe(app); + }); + + it('skips a tsconfig it cannot parse, with a warning', async () => { + seed(tree, { mfe1: { root: 'projects/mfe1' } }); + const broken = '{ "files": ["src/main.ts" '; + tree.create('projects/mfe1/tsconfig.federation.json', broken); + + await update22_2()(tree, context as never); + + expect(read(tree, 'projects/mfe1/tsconfig.federation.json')).toBe(broken); + expect(context.logger.warn).toHaveBeenCalledWith( + expect.stringMatching(/Remove its "files" by hand/) + ); + }); + + it('is idempotent', async () => { + seed(tree, { mfe1: { root: 'projects/mfe1' } }); + tree.create('projects/mfe1/tsconfig.federation.json', '{ "files": [], "include": [] }'); + + await update22_2()(tree, context as never); + const afterFirst = read(tree, 'projects/mfe1/tsconfig.federation.json'); + await update22_2()(tree, context as never); + + expect(read(tree, 'projects/mfe1/tsconfig.federation.json')).toBe(afterFirst); + }); +}); diff --git a/src/schematics/update22-2/schematic.ts b/src/schematics/update22-2/schematic.ts new file mode 100644 index 0000000..3baae6f --- /dev/null +++ b/src/schematics/update22-2/schematic.ts @@ -0,0 +1,39 @@ +import type { Rule, SchematicContext, Tree } from '@angular-devkit/schematics'; +import { applyEdits, modify, parse, type ParseError } from 'jsonc-parser'; + +import { getWorkspaceFileName } from '../init/steps/normalize-options.js'; +import { federationTsConfigPath } from '../init/steps/generate-federation-tsconfig.js'; + +// Each build context now supplies its own `files` (tools/esbuild/write-context-tsconfig.ts), +// so the list in tsconfig.federation.json only goes stale. +export default function update22_2(): Rule { + return (tree: Tree, context: SchematicContext) => { + const workspace = JSON.parse(tree.read(getWorkspaceFileName(tree))?.toString('utf8') ?? '{}'); + + for (const project of Object.values<{ root?: string }>(workspace.projects ?? {})) { + removeFiles(tree, context, federationTsConfigPath((project?.root ?? '').replace(/\\/g, '/'))); + } + }; +} + +function removeFiles(tree: Tree, context: SchematicContext, tsConfig: string): void { + const text = tree.read(tsConfig)?.toString('utf8'); + if (text === undefined) return; + + const errors: ParseError[] = []; + const json = parse(text, errors, { allowTrailingComma: true }); + + if (errors.length > 0) { + context.logger.warn(`Skipping ${tsConfig}: it is not valid JSON. Remove its "files" by hand.`); + return; + } + + if (json?.files === undefined) return; + + // Edits in place, so the file keeps its comments and formatting. + const edits = modify(text, ['files'], undefined, { + formattingOptions: { insertSpaces: true, tabSize: 2 }, + }); + tree.overwrite(tsConfig, applyEdits(text, edits)); + context.logger.info(`Removed "files" from ${tsConfig}`); +} From 2e72be9617afb92d60583f75c0006ef1e12e506e Mon Sep 17 00:00:00 2001 From: Aukevanoost Date: Wed, 23 Sep 2026 13:02:38 +0200 Subject: [PATCH 4/4] chore(deps): follow @softarc/native-federation onto 4.7.0 --- package.json | 2 +- pnpm-lock.yaml | 12 +++++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 8958bb0..ecaa7ab 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "@angular-devkit/core": "~22.1.0", "@angular-devkit/schematics": "~22.1.0", "@chialab/esbuild-plugin-commonjs": "^0.19.0", - "@softarc/native-federation": "~4.6.0", + "@softarc/native-federation": "~4.7.0", "@softarc/native-federation-orchestrator": "^4.6.0", "es-module-shims": "^2.8.0", "esbuild": "^0.28.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cdc8fd2..ba99c86 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,8 +24,8 @@ importers: specifier: ^0.19.0 version: 0.19.1 '@softarc/native-federation': - specifier: ~4.6.0 - version: 4.6.0 + specifier: ~4.7.0 + version: 4.7.0 '@softarc/native-federation-orchestrator': specifier: ^4.6.0 version: 4.6.1 @@ -1781,8 +1781,8 @@ packages: resolution: {integrity: sha512-n5XZvWCFPeLvZ5y42fH79X9L1MG+BMBLlbhKOxIVXbju+52BAQL6D+NoYOuOPTwtX5wFafwhzAs15bkggF3VSw==, tarball: https://registry.npmjs.org/@softarc/native-federation-orchestrator/-/native-federation-orchestrator-4.6.1.tgz} engines: {node: '>=24.16.0'} - '@softarc/native-federation@4.6.0': - resolution: {integrity: sha512-Cd+z7CRpQYHagZUkjR9jCO6fWxl7rh0VfXcv4uhnCIopzWwwCMN0ltBvfSCjC5hso1gfPdOSlxGzhrqtprbZcw==, tarball: https://registry.npmjs.org/@softarc/native-federation/-/native-federation-4.6.0.tgz} + '@softarc/native-federation@4.7.0': + resolution: {integrity: sha512-YDeF0MSQGXLRP3ucDzuLiR6C10pKwX/cDgDTCMOgt9muUnF7ALufHUaq0K7iWcnSOcKocWiPNi2CMS/3BXxL0A==, tarball: https://registry.npmjs.org/@softarc/native-federation/-/native-federation-4.7.0.tgz} '@softarc/sheriff-core@0.19.6': resolution: {integrity: sha512-KACxHG9sS7kNWgnnBODzdr14kMLMrJVlQKc+tViUP03p2fRwNhESOA49bz51Yn7dro1mbtMmmFjICLmZSJDZZA==, tarball: https://registry.npmjs.org/@softarc/sheriff-core/-/sheriff-core-0.19.6.tgz} @@ -4625,10 +4625,12 @@ snapshots: dependencies: semver: 7.8.5 - '@softarc/native-federation@4.6.0': + '@softarc/native-federation@4.7.0': dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 '@softarc/sheriff-core': 0.19.6(typescript@6.0.3) chalk: 6.0.0 + es-module-lexer: 2.3.2 esbuild: 0.28.2 fast-glob: 3.3.3 json5: 2.2.3