diff --git a/migration-collection.json b/migration-collection.json index b26a6c6..3697745 100644 --- a/migration-collection.json +++ b/migration-collection.json @@ -10,10 +10,10 @@ "description": "migrating to v18" }, "update22": { - "version": "22.0.0", + "version": "22.1.1", "factory": "./src/schematics/update22/schematic", "schema": "./src/schematics/update22/schema.json", - "description": "migrating native-federation to the v22 ESM standard" + "description": "migrating native-federation to the v22 ESM standard and generating a tsconfig.federation.json per federated project" } } } diff --git a/src/builders/build/builder.ts b/src/builders/build/builder.ts index 21aa119..4180e54 100644 --- a/src/builders/build/builder.ts +++ b/src/builders/build/builder.ts @@ -211,16 +211,25 @@ export async function* runBuilder( ngBuilderOptions.outputPath = nfBuilderOptions.outputPath; } - const federationTsConfig = - !!nfBuilderOptions.tsConfig && nfBuilderOptions.tsConfig.length > 0 - ? nfBuilderOptions.tsConfig - : ngBuilderOptions.tsConfig; + const declaresTsConfig = + !!nfBuilderOptions.tsConfig && nfBuilderOptions.tsConfig.length > 0; + + const federationTsConfig = declaresTsConfig + ? nfBuilderOptions.tsConfig! + : ngBuilderOptions.tsConfig; + + const entryPoints: string[] | undefined = + nfBuilderOptions.entryPoints && nfBuilderOptions.entryPoints.length > 0 + ? nfBuilderOptions.entryPoints + : [path.join(path.dirname(federationTsConfig), "src/main.ts")]; const adapter = createAngularBuildAdapter( { ...ngBuilderOptions, plugins: nfBuilderOptions.plugins, instrumentForCoverage: nfBuilderOptions.instrumentForCoverage, + manageTsConfig: declaresTsConfig, + fallbackEntryPoints: entryPoints, }, context, ); @@ -265,11 +274,6 @@ export async function* runBuilder( ? browserOutputPath : path.join(outputOptions.base, outputOptions.browser, localeFilter[0]!); - const entryPoints: string[] | undefined = - nfBuilderOptions.entryPoints && nfBuilderOptions.entryPoints.length > 0 - ? nfBuilderOptions.entryPoints - : [path.join(path.dirname(federationTsConfig), "src/main.ts")]; - const cachePath = getDefaultCachePath(context.workspaceRoot); const normalized = await normalizeFederationOptions( diff --git a/src/builders/build/schema.d.ts b/src/builders/build/schema.d.ts index 374711e..c8c8be7 100644 --- a/src/builders/build/schema.d.ts +++ b/src/builders/build/schema.d.ts @@ -32,4 +32,18 @@ export type NfInternalOptions = { * Used exclusively for tests and shouldn't be used for other kinds of builds. */ 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. + */ + manageTsConfig?: boolean; + + /** + * Roots keeping the federation program non-empty when a build has no entry points of its + * own — core's reachability entry points, which default to the project's main.ts. + */ + fallbackEntryPoints?: string[]; }; diff --git a/src/builders/build/schema.json b/src/builders/build/schema.json index 5297cd8..f55cb85 100644 --- a/src/builders/build/schema.json +++ b/src/builders/build/schema.json @@ -23,7 +23,9 @@ "default": 0 }, "entryPoints": { - "type": "array" + "type": "array", + "items": { "type": "string" }, + "description": "Fallback entry points, used only when the project has nothing federated of its own (no exposes, no shared mappings). They seed the federation tsconfig's 'files' and the unused-dependency scan. Exposes from federation.config always take precedence, so this cannot override or narrow them; to add extra files to the TypeScript program, use 'include' in the federation tsconfig instead. Defaults to 'src/main.ts' resolved next to the federation tsconfig." }, "rebuildDelay": { "type": "number", @@ -61,7 +63,7 @@ }, "tsConfig": { "type": "string", - "description": "A specific tsconfig file for the nf remotes and exposed modules. It also drives esbuild's module resolution, so it must declare or extend the workspace baseUrl/paths." + "description": "A specific tsconfig file for the nf remotes and exposed modules. It also drives esbuild's module resolution, so it must declare or extend the workspace baseUrl/paths. The builder owns this file's `files` array and rewrites it on every build; comments are not preserved. Leave it unset to compile against the Angular target's own tsconfig, which the builder never rewrites." }, "cacheExternalArtifacts": { "type": "boolean", diff --git a/src/builders/remote/builder.ts b/src/builders/remote/builder.ts index 37a5d19..1a7e8e8 100644 --- a/src/builders/remote/builder.ts +++ b/src/builders/remote/builder.ts @@ -64,17 +64,26 @@ export async function* runRemoteBuilder( context ); - const adapter = createAngularBuildAdapter(ngBuilderOptions, context); - setBuildAdapter(adapter); - setLogLevel(nfBuilderOptions.verbose ? 'verbose' : 'info'); - - // Unlike the regular build builder, remote never bundles a main.ts / polyfills. - // Entry points come from the schema override or, when omitted, from the - // `exposes` map in federation.config.{mjs,js} (resolved by normalizeFederationOptions). + // Unlike the regular build builder, remote never bundles a main.ts / polyfills. Entry points + // come from the `exposes` map in federation.config.{mjs,js}; the schema option is only a + // fallback for when there are none, so passing `undefined` when it is omitted keeps core + // from treating an empty list as a deliberate one. const entryPoints: string[] | undefined = nfBuilderOptions.entryPoints?.length ? nfBuilderOptions.entryPoints : undefined; + const adapter = createAngularBuildAdapter( + { + ...ngBuilderOptions, + // Required by the schema, so the tsconfig is always the builder's to manage. + manageTsConfig: true, + fallbackEntryPoints: entryPoints, + }, + context + ); + setBuildAdapter(adapter); + setLogLevel(nfBuilderOptions.verbose ? 'verbose' : 'info'); + const cachePath = getDefaultCachePath(context.workspaceRoot); const normalized = await normalizeFederationOptions( diff --git a/src/builders/remote/schema.json b/src/builders/remote/schema.json index 5f83a36..4ea8a8f 100644 --- a/src/builders/remote/schema.json +++ b/src/builders/remote/schema.json @@ -8,7 +8,7 @@ "properties": { "tsConfig": { "type": "string", - "description": "Path to the tsconfig used to compile the exposed modules and shared mappings. It also drives esbuild's module resolution, so it must declare or extend the workspace baseUrl/paths." + "description": "Path to the tsconfig used to compile the exposed modules and shared mappings. It also drives esbuild's module resolution, so it must declare or extend the workspace baseUrl/paths. The builder owns this file's `files` array and rewrites it on every build; comments are not preserved." }, "dev": { "type": "boolean", @@ -20,7 +20,9 @@ "default": false }, "entryPoints": { - "type": "array" + "type": "array", + "items": { "type": "string" }, + "description": "Fallback entry points, used only when the project has nothing federated of its own (no exposes, no shared mappings). They seed the federation tsconfig's 'files' and the unused-dependency scan. Exposes from federation.config always take precedence, so this cannot override or narrow them; to add extra files to the TypeScript program, use 'include' in the federation tsconfig instead. Unset by default — a remote's exposes are normally all it bundles." }, "rebuildDelay": { "type": "number", diff --git a/src/schematics/init/schematic.ts b/src/schematics/init/schematic.ts index 61946cc..f9d27fc 100644 --- a/src/schematics/init/schematic.ts +++ b/src/schematics/init/schematic.ts @@ -12,6 +12,7 @@ import { updatePolyfills } from './steps/update-polyfills.js'; import { generateRemoteMap } from './steps/generate-remote-map.js'; import { generateFederationConfig } from './steps/generate-federation-config.js'; import { updateWorkspaceConfig } from './steps/update-workspace-config.js'; +import { generateFederationTsConfig } from './steps/generate-federation-tsconfig.js'; import { addDependencies } from './steps/add-dependencies.js'; import { makeMainAsync } from './steps/make-main-async.js'; import { makeServerAsync } from './steps/make-server-async.js'; @@ -69,7 +70,18 @@ export default function config(options: NfSchematicSchema): Rule { const ssr = isSsrProject(normalized); const server = ssr ? getSsrFilePath(normalized) : ''; - updateWorkspaceConfig(tree, normalized, workspace, workspaceFileName, ssr); + // 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, + ]); + + updateWorkspaceConfig(tree, normalized, workspace, workspaceFileName, ssr, federationTsConfig); addDependencies(tree, context, ssr); diff --git a/src/schematics/init/steps/generate-federation-tsconfig.spec.ts b/src/schematics/init/steps/generate-federation-tsconfig.spec.ts new file mode 100644 index 0000000..7e76fee --- /dev/null +++ b/src/schematics/init/steps/generate-federation-tsconfig.spec.ts @@ -0,0 +1,141 @@ +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, + projectName: 'mfe1', + projectRoot: 'projects/mfe1', + projectSourceRoot: 'projects/mfe1/src', + manifestPath: '', + manifestRelPath: '', + main: 'projects/mfe1/src/main.ts', + port: 4200, + projectConfig: { + architect: { + build: { + builder: '@angular/build:application', + options: { tsConfig: 'projects/mfe1/tsconfig.app.json' }, + }, + }, + }, + ...overrides, + }; +} + +function read(tree: Tree, path: string) { + return JSON.parse(tree.read(path)!.toString('utf8')); +} + +describe('generateFederationTsConfig', () => { + let tree: Tree; + + beforeEach(() => { + tree = new EmptyTree(); + }); + + it('creates a federation tsconfig extending the app tsconfig', () => { + const result = generateFederationTsConfig(tree, makeOptions(), EXPOSED); + + 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 + ); + + expect(read(tree, result).include).toEqual(['app-src/**/*.d.ts']); + }); + + it('points extends at a tsconfig that lives outside the project root', () => { + const result = generateFederationTsConfig( + tree, + makeOptions({ + projectConfig: { + architect: { + build: { + builder: '@angular/build:application', + options: { tsConfig: 'tsconfig.app.json' }, + }, + }, + }, + }), + EXPOSED + ); + + expect(read(tree, result).extends).toBe('../../tsconfig.app.json'); + }); + + it('leaves an existing federation tsconfig untouched', () => { + tree.create('projects/mfe1/tsconfig.federation.json', '{ "files": ["src/bootstrap.ts"] }'); + + const result = generateFederationTsConfig(tree, makeOptions(), EXPOSED); + + expect(read(tree, result)).toEqual({ files: ['src/bootstrap.ts'] }); + }); + + it('does nothing when the project is already on the federation builder', () => { + const options = makeOptions(); + options.projectConfig.architect.build.builder = '@angular-architects/native-federation:build'; + + const result = generateFederationTsConfig(tree, options, EXPOSED); + + expect(tree.exists(result)).toBe(false); + }); + + // esbuild is where a previous run parked the original build target. + it('falls back to the esbuild target tsConfig', () => { + const result = generateFederationTsConfig( + tree, + makeOptions({ + projectConfig: { + architect: { + build: { builder: '@angular/build:application', options: {} }, + esbuild: { options: { tsConfig: 'projects/mfe1/tsconfig.app.json' } }, + }, + }, + }), + EXPOSED + ); + + expect(read(tree, result).extends).toBe('./tsconfig.app.json'); + }); + + it('throws when no tsConfig can be found', () => { + expect(() => + generateFederationTsConfig( + tree, + makeOptions({ + 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 new file mode 100644 index 0000000..31a6ebb --- /dev/null +++ b/src/schematics/init/steps/generate-federation-tsconfig.ts @@ -0,0 +1,85 @@ +import type { Tree } from '@angular-devkit/schematics'; +import type { NormalizedOptions } from './normalize-options.js'; +import * as path from 'path'; + +const NF_BUILDER = '@angular-architects/native-federation:build'; + +function toPosix(p: string): string { + return p.replace(/\\/g, '/'); +} + +export function federationTsConfigPath(projectRoot: string): string { + return toPosix(path.join(projectRoot, 'tsconfig.federation.json')); +} + +export interface FederationTsConfigOptions { + projectRoot: string; + 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. + */ +export function writeFederationTsConfig(tree: Tree, options: FederationTsConfigOptions): string { + const { projectRoot, projectSourceRoot, appTsConfig, entryPoints } = options; + + const federationTsConfig = federationTsConfigPath(projectRoot); + + const extendsPath = toPosix(path.relative(projectRoot, appTsConfig)); + const sourceDir = toPosix(path.relative(projectRoot, projectSourceRoot)); + + tree.create( + federationTsConfig, + JSON.stringify( + { + extends: extendsPath.startsWith('.') ? extendsPath : `./${extendsPath}`, + files: entryPoints.map(entry => toPosix(path.relative(projectRoot, entry))), + include: [`${sourceDir}/**/*.d.ts`], + }, + null, + 2 + ) + ); + + return federationTsConfig; +} + +export function generateFederationTsConfig( + tree: Tree, + options: NormalizedOptions, + entryPoints: string[] +): string { + const { projectConfig, projectRoot, projectSourceRoot } = options; + + const federationTsConfig = federationTsConfigPath(projectRoot); + + if (projectConfig.architect.build.builder === NF_BUILDER || tree.exists(federationTsConfig)) { + return federationTsConfig; + } + + const appTsConfig = + projectConfig.architect.build.options?.tsConfig ?? + projectConfig.architect.esbuild?.options?.tsConfig; + + if (!appTsConfig) { + throw new Error(`The build target of ${options.projectName} has no tsConfig!`); + } + + return writeFederationTsConfig(tree, { + projectRoot, + projectSourceRoot, + appTsConfig, + entryPoints, + }); +} diff --git a/src/schematics/init/steps/update-workspace-config.ts b/src/schematics/init/steps/update-workspace-config.ts index 36624fb..582415d 100644 --- a/src/schematics/init/steps/update-workspace-config.ts +++ b/src/schematics/init/steps/update-workspace-config.ts @@ -6,7 +6,8 @@ export function updateWorkspaceConfig( options: NormalizedOptions, workspace: any, workspaceFileName: string, - ssr: boolean + ssr: boolean, + federationTsConfig: string ) { const { projectConfig, projectName, port } = options; @@ -43,6 +44,7 @@ export function updateWorkspaceConfig( builder: '@angular-architects/native-federation:build', options: { cacheExternalArtifacts: true, + tsConfig: federationTsConfig, }, configurations: { production: { @@ -96,6 +98,7 @@ export function updateWorkspaceConfig( builder: '@angular-architects/native-federation:build', options: { target: `${projectName}:serve-original:development`, + tsConfig: federationTsConfig, rebuildDelay: 500, cacheExternalArtifacts: true, dev: true, diff --git a/src/schematics/update22/schematic.spec.ts b/src/schematics/update22/schematic.spec.ts new file mode 100644 index 0000000..0e62cfe --- /dev/null +++ b/src/schematics/update22/schematic.spec.ts @@ -0,0 +1,124 @@ +import { EmptyTree, type Tree } from '@angular-devkit/schematics'; + +import update22 from './schematic.js'; + +const NF_BUILDER = '@angular-architects/native-federation:build'; + +// A project as the init schematic left it before v22.1.1: NF build/serve targets with no +// tsConfig, and the original application builder parked under `esbuild`. +function makeWorkspace(overrides: Record = {}) { + return { + projects: { + mfe1: { + root: 'projects/mfe1', + sourceRoot: 'projects/mfe1/src', + architect: { + build: { builder: NF_BUILDER, options: { cacheExternalArtifacts: true } }, + esbuild: { + builder: '@angular/build:application', + options: { + browser: 'projects/mfe1/src/main.ts', + tsConfig: 'projects/mfe1/tsconfig.app.json', + }, + }, + serve: { builder: NF_BUILDER, options: { target: 'mfe1:serve-original:development' } }, + }, + ...overrides, + }, + }, + }; +} + +function seed(tree: Tree, workspace: unknown) { + tree.create('angular.json', JSON.stringify(workspace)); + return tree; +} + +function readJson(tree: Tree, path: string) { + return JSON.parse(tree.read(path)!.toString('utf8')); +} + +function architect(tree: Tree, project = 'mfe1') { + return readJson(tree, 'angular.json').projects[project].architect; +} + +describe('update22 — federation tsconfig', () => { + let tree: Tree; + + beforeEach(() => { + tree = new EmptyTree(); + }); + + it('generates the federation tsconfig and wires every NF target to it', async () => { + seed(tree, makeWorkspace()); + + await update22()(tree, {} as never); + + expect(readJson(tree, 'projects/mfe1/tsconfig.federation.json')).toEqual({ + extends: './tsconfig.app.json', + files: ['src/main.ts'], + include: ['src/**/*.d.ts'], + }); + + const targets = architect(tree); + expect(targets.build.options.tsConfig).toBe('projects/mfe1/tsconfig.federation.json'); + expect(targets.serve.options.tsConfig).toBe('projects/mfe1/tsconfig.federation.json'); + // Untouched: the app build keeps compiling against its own tsconfig. + expect(targets.esbuild.options.tsConfig).toBe('projects/mfe1/tsconfig.app.json'); + }); + + it('is idempotent', async () => { + seed(tree, makeWorkspace()); + + await update22()(tree, {} as never); + const afterFirst = readJson(tree, 'angular.json'); + + // The second run must not throw on the tsconfig it already created. + await update22()(tree, {} as never); + + expect(readJson(tree, 'angular.json')).toEqual(afterFirst); + }); + + it('keeps a tsConfig the target already declares', async () => { + const workspace = makeWorkspace(); + workspace.projects.mfe1.architect.build.options.tsConfig = 'projects/mfe1/custom.json'; + seed(tree, workspace); + + await update22()(tree, {} as never); + + expect(architect(tree).build.options.tsConfig).toBe('projects/mfe1/custom.json'); + }); + + it('leaves an existing federation tsconfig alone but still wires it up', async () => { + seed(tree, makeWorkspace()); + tree.create('projects/mfe1/tsconfig.federation.json', '{ "files": ["src/bootstrap.ts"] }'); + + await update22()(tree, {} as never); + + expect(readJson(tree, 'projects/mfe1/tsconfig.federation.json')).toEqual({ + files: ['src/bootstrap.ts'], + }); + expect(architect(tree).build.options.tsConfig).toBe('projects/mfe1/tsconfig.federation.json'); + }); + + it('skips projects that are not federated', async () => { + seed(tree, { + projects: { + app: { + root: 'projects/app', + sourceRoot: 'projects/app/src', + architect: { + build: { + builder: '@angular/build:application', + options: { tsConfig: 'projects/app/tsconfig.app.json' }, + }, + }, + }, + }, + }); + + await update22()(tree, {} as never); + + expect(tree.exists('projects/app/tsconfig.federation.json')).toBe(false); + }); +}); diff --git a/src/schematics/update22/schematic.ts b/src/schematics/update22/schematic.ts index e09b7ae..25832ef 100644 --- a/src/schematics/update22/schematic.ts +++ b/src/schematics/update22/schematic.ts @@ -1,5 +1,9 @@ import type { Rule, Tree } from "@angular-devkit/schematics"; import { getWorkspaceFileName } from "../init/schematic.js"; +import { + federationTsConfigPath, + writeFederationTsConfig, +} from "../init/steps/generate-federation-tsconfig.js"; import * as path from "path"; @@ -9,7 +13,9 @@ const BETA_PACKAGE = "@angular-architects/native-federation-v4"; const NF_BUILDER = `${NF_PACKAGE}:build`; const BETA_BUILDER = `${BETA_PACKAGE}:build`; -// `ng update` migration for v22: brings every project onto the ESM standard. +// `ng update` migration for v22: brings every project onto the ESM standard and onto its own +// federation tsconfig. Every step re-runs safely, so the collection entry can be bumped to a +// later version to reach projects that already ran an earlier one. export default function update22(): Rule { return async function (tree: Tree) { const workspaceFileName = getWorkspaceFileName(tree); @@ -18,11 +24,86 @@ export default function update22(): Rule { ); normalizeBuilderReferences(tree, workspace, workspaceFileName); + generateFederationTsConfigs(tree, workspace, workspaceFileName); migrateFederationConfigs(tree, workspace); normalizeMainTsImports(tree, workspace); }; } +/** + * Give every federated project its own tsconfig.federation.json. Without one the builder + * compiles the federation artifacts against the Angular target's tsconfig — the whole app, + * for every build — and would have to rewrite that file to add the exposes to its program. + */ +function generateFederationTsConfigs( + tree: Tree, + workspace: any, + workspaceFileName: string, +): void { + let modified = false; + + for (const projectName of Object.keys(workspace.projects ?? {})) { + const project = workspace.projects[projectName]; + const architect = project?.architect ?? {}; + + const targets = Object.values(architect).filter( + (target: any) => + target?.builder === NF_BUILDER && !target?.options?.tsConfig, + ) as { options?: Record }[]; + + if (targets.length === 0) { + continue; + } + + const projectRoot: string = (project.root ?? "").replace(/\\/g, "/"); + const federationTsConfig = federationTsConfigPath(projectRoot); + + if (!tree.exists(federationTsConfig)) { + // `esbuild` is where the init schematic parked the original build target. + const original = architect.esbuild ?? architect.build; + const appTsConfig = original?.options?.tsConfig; + + if (!appTsConfig) { + console.warn( + `Skipping ${projectName}: its build target has no tsConfig, so ` + + `${federationTsConfig} cannot be generated.`, + ); + continue; + } + + const projectSourceRoot: string = ( + project.sourceRoot ?? path.join(projectRoot, "src") + ).replace(/\\/g, "/"); + + writeFederationTsConfig(tree, { + 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}`); + } + + for (const target of targets) { + target.options ??= {}; + target.options.tsConfig = federationTsConfig; + } + + modified = true; + } + + if (modified) { + tree.overwrite(workspaceFileName, JSON.stringify(workspace, null, "\t")); + } +} + // Rename the beta builder back and ensure NF targets carry entryPoints/projectName. function normalizeBuilderReferences( tree: Tree, diff --git a/src/tools/esbuild/angular-bundler.spec.ts b/src/tools/esbuild/angular-bundler.spec.ts index b86c389..6d90931 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 './create-federation-tsconfig.js'; +import { updateFederationTsConfig } from './update-federation-tsconfig.js'; import type { NormalizedContextOptions } from '../../utils/normalize-context-options.js'; vi.mock('esbuild', () => ({ context: vi.fn().mockResolvedValue({ rebuild: vi.fn() }) })); @@ -23,7 +23,7 @@ vi.mock('./create-awaitable-compiler-plugin.js', () => ({ .mockReturnValue([{ name: 'angular-compiler', setup: vi.fn() }, Promise.resolve()]), })); -vi.mock('./create-federation-tsconfig.js', () => ({ updateFederationTsConfig: vi.fn() })); +vi.mock('./update-federation-tsconfig.js', () => ({ updateFederationTsConfig: vi.fn() })); vi.mock('@chialab/esbuild-plugin-commonjs', () => ({ default: () => ({ name: 'commonjs', setup: vi.fn() }), @@ -84,17 +84,35 @@ describe('createAngularEsbuildContext', () => { expect(pluginOptions.tsconfig).toBe(expected); }); - it('joins the workspace root once when mappings are optimized', async () => { - await createAngularEsbuildContext(makeOptions({ optimizedMappings: true })); + it('updates the tsconfig the NF target declared, passing the fallback entry points', async () => { + await createAngularEsbuildContext( + makeOptions({ + builderOptions: { + optimization: false, + sourceMap: false, + manageTsConfig: true, + fallbackEntryPoints: ['apps/example/src/main.ts'], + }, + } as unknown as Partial) + ); // updateFederationTsConfig joins the workspace root itself expect(updateFederationTsConfig).toHaveBeenCalledWith( workspaceRoot, 'apps/example/tsconfig.app.json', - expect.anything() + expect.anything(), + ['apps/example/src/main.ts'] ); expect(lastBuildOptions().tsconfig).toBe( path.join(workspaceRoot, 'apps/example/tsconfig.app.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()); + + expect(updateFederationTsConfig).not.toHaveBeenCalled(); + }); }); diff --git a/src/tools/esbuild/angular-bundler.ts b/src/tools/esbuild/angular-bundler.ts index d792a2f..759dab4 100644 --- a/src/tools/esbuild/angular-bundler.ts +++ b/src/tools/esbuild/angular-bundler.ts @@ -16,7 +16,7 @@ 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 './create-federation-tsconfig.js'; +import { updateFederationTsConfig } from './update-federation-tsconfig.js'; export async function createAngularEsbuildContext(options: NormalizedContextOptions): Promise<{ ctx: esbuild.BuildContext; @@ -33,7 +33,6 @@ export async function createAngularEsbuildContext(options: NormalizedContextOpti hash, chunks, platform, - optimizedMappings, } = options; let tsConfigPath = options.tsConfigPath; @@ -78,8 +77,17 @@ export async function createAngularEsbuildContext(options: NormalizedContextOpti } } - if (optimizedMappings) { - updateFederationTsConfig(workspaceRoot, tsConfigPath, entryPoints); + // 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); diff --git a/src/tools/esbuild/create-federation-tsconfig.spec.ts b/src/tools/esbuild/create-federation-tsconfig.spec.ts deleted file mode 100644 index b228695..0000000 --- a/src/tools/esbuild/create-federation-tsconfig.spec.ts +++ /dev/null @@ -1,89 +0,0 @@ -import fs from 'fs'; -import path from 'path'; -import JSON5 from 'json5'; - -import { updateFederationTsConfig } from './create-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; -} - -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 all entry points are local', () => { - updateFederationTsConfig('/ws', 'tsconfig.fed.json', [entry('./local-a'), entry('./local-b')]); - - expect(fs.readFileSync).not.toHaveBeenCalled(); - expect(fs.writeFileSync).not.toHaveBeenCalled(); - }); - - it('appends non-local entry points relative to the tsconfig dir, skipping locals', () => { - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.readFileSync).mockReturnValue(JSON5.stringify({ include: ['existing.ts'] }) as never); - - updateFederationTsConfig('/ws', 'tsconfig.fed.json', [ - entry('/ws/src/a.ts'), - entry('./skip.ts'), - ]); - - expect(fs.writeFileSync).toHaveBeenCalledTimes(1); - const written = JSON.parse(String(vi.mocked(fs.writeFileSync).mock.calls[0]![1])); - expect(written.include).toEqual(['existing.ts', 'src/a.ts']); - }); - - it('does not duplicate an include that is already present', () => { - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.readFileSync).mockReturnValue(JSON5.stringify({ include: ['src/a.ts'] }) as never); - - updateFederationTsConfig('/ws', 'tsconfig.fed.json', [ - entry('/ws/src/a.ts'), - entry('/ws/src/b.ts'), - ]); - - const written = JSON.parse(String(vi.mocked(fs.writeFileSync).mock.calls[0]![1])); - expect(written.include).toEqual(['src/a.ts', 'src/b.ts']); - }); - - it('creates the include 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')]); - - const written = JSON.parse(String(vi.mocked(fs.writeFileSync).mock.calls[0]![1])); - expect(written.include).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({ include: [] }) 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')]); - - const written = JSON.parse(String(vi.mocked(fs.writeFileSync).mock.calls[0]![1])); - expect(written.include).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({ include: ['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/create-federation-tsconfig.ts b/src/tools/esbuild/create-federation-tsconfig.ts deleted file mode 100644 index 9a59432..0000000 --- a/src/tools/esbuild/create-federation-tsconfig.ts +++ /dev/null @@ -1,61 +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'; - -/** - * Updates the federation tsconfig to include optimized mapping entry points. - * Only modifies the file when there are non-local entry points to add. - */ -export function updateFederationTsConfig( - workspaceRoot: string, - tsConfigPath: string, - entryPoints: EntryPoint[] -): void { - const fullTsConfigPath = path.join(workspaceRoot, tsConfigPath); - const tsconfigDir = path.dirname(fullTsConfigPath); - - const filtered = entryPoints - .filter(ep => !ep.fileName.startsWith('.')) - .map(ep => path.relative(tsconfigDir, ep.fileName).replace(/\\/g, '/')); - - if (filtered.length === 0) { - return; - } - - const tsconfigAsString = fs.readFileSync(fullTsConfigPath, 'utf-8'); - const tsconfig = JSON5.parse(tsconfigAsString); - - if (!tsconfig.include) { - tsconfig.include = []; - } - - for (const ep of filtered) { - if (!tsconfig.include.includes(ep)) { - tsconfig.include.push(ep); - } - } - - 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/update-federation-tsconfig.spec.ts b/src/tools/esbuild/update-federation-tsconfig.spec.ts new file mode 100644 index 0000000..83aaa11 --- /dev/null +++ b/src/tools/esbuild/update-federation-tsconfig.spec.ts @@ -0,0 +1,146 @@ +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 new file mode 100644 index 0000000..8413139 --- /dev/null +++ b/src/tools/esbuild/update-federation-tsconfig.ts @@ -0,0 +1,80 @@ +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; + } +}