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 a8b9ead..ecaa7ab 100644 --- a/package.json +++ b/package.json @@ -17,10 +17,11 @@ "@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", + "jsonc-parser": "^3.3.1", "mrmime": "^2.0.1", "watchpack": "^2.5.2" }, @@ -82,7 +83,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..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 @@ -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 @@ -66,9 +69,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 @@ -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 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/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-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}`); +} 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..b6abe32 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(); }); @@ -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 918b1af..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 @@ -106,7 +115,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, { @@ -155,5 +164,5 @@ export function createAngularBuildAdapter( } }; - return { setup, build, dispose }; + return { setup, build, dispose, disposeFederationContexts }; } 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, '/'); +}