Skip to content

fix(bundler): add exposed modules to the federation tsconfig, and scaffold it - #124

Merged
Aukevanoost merged 9 commits into
mainfrom
fix/federation-tsconfig-exposes
Aug 11, 2026
Merged

fix(bundler): add exposed modules to the federation tsconfig, and scaffold it#124
Aukevanoost merged 9 commits into
mainfrom
fix/federation-tsconfig-exposes

Conversation

@Aukevanoost

@Aukevanoost Aukevanoost commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Exposed modules never made it into the federation tsconfig, so the angular-compiler plugin could not find them in the TypeScript program. Users had to list them by hand, and there was nothing pointing them at that — the message blames files/include on a file the schematic never created in the first place.

The bug

updateFederationTsConfig filtered out every entry point whose fileName starts with .:

const filtered = entryPoints
  .filter(ep => !ep.fileName.startsWith('.'))
  .map(ep => path.relative(tsconfigDir, ep.fileName).replace(/\\/g, '/'));

That is exactly how core hands over exposes. config.exposes[key].file is passed through verbatim from federation.config (normalizeExposes only wraps strings into { file }) and is workspace-root-relative — './projects/mfe1/src/bootstrap.ts'. Shared mappings are absolute, so only they ever reached include.

This is a regression. Before 9ae5548, createFederationTsConfig assigned those local entry points to tsconfig.files; the extraction refactor in b044cf5 kept only the mappings half.

Repro

Drop files: ["src/bootstrap.ts"] from projects/mfe1/tsconfig.federation.json in the playground and build:

projects/mfe1/src/bootstrap.ts:2:26: ERROR: [plugin: angular-compiler]
  File 'projects/mfe1/src/app/app.config.ts' not found in TypeScript compilation.

Nothing re-adds it. (It also takes esbuild down with fatal error: all goroutines are asleep - deadlock! — filed separately, not addressed here.)

Changes

fix(bundler) — resolve relative entry points against the workspace root

const toTsConfigRelative = (fileName: string) => {
  const absolute = path.isAbsolute(fileName) ? fileName : path.join(workspaceRoot, fileName);
  return path.relative(tsconfigDir, absolute).replace(/\\/g, '/');
};

No filtering: every entry point that gets bundled goes into the program. With ignoreUnusedDeps: false core hands over every tsconfig path mapping, used or not, and skipping those failed the build with the same "not found in TypeScript compilation" error this PR set out to fix.

feat(schematics) — scaffold <projectRoot>/tsconfig.federation.json

Generated by ng add and wired into the build and serve targets. It extends the app tsconfig because it also drives esbuild's module resolution and so needs the workspace paths:

{
  "extends": "./tsconfig.app.json",
  "files": ["src/app/app.ts"],
  "include": ["src/**/*.d.ts"]
}

extends, the include glob and the seeded files are all derived from projectRoot/projectSourceRoot and the exposed component, not hardcoded. Guards: skips if the file exists, skips if the project is already on the NF builder, falls back to the esbuild target's tsConfig, throws if neither has one. This also closes #101 — the builder updated that file but never created it, so a fresh clone hit ENOENT.

Note that files: [] on its own is a TypeScript error (TS18002); it is legal here only because the config also carries extends. Both keys have to stay.

fix(bundler) — only manage a tsconfig the target declares itself

The two commits above rewrote whatever tsconfig the build resolved to. For a project federated before this PR that is the app's tsconfig.app.json, since tsConfig on the NF target is what the builder falls back from.

Why the fallback is off limits — it is not just about comments. The builder replaces files, and on the Angular target's tsconfig that key is Angular's own. Current CLI templates (checked across @schematics/angular 20.3 → 22.0) scaffold include: ["src/**/*.ts"] with no files, so a rewrite there would be additive noise plus the loss of the two comment lines. But projects carried forward from older CLIs — the playground's own mfe1, mfe2 and host among them — have:

"files": ["src/main.ts"],
"include": ["src/**/*.d.ts"]

Replacing files there evicts src/main.ts from the program: include matches no .ts, and main.ts is not reachable from an expose because the import runs the other way. That same file is what the nested @angular/build:application target compiles with, so the app build dies with the very "not found in TypeScript compilation" error this PR set out to fix. ng update never rewrites tsconfig shape, so this is exactly the population the fallback exists to serve.

So the rewrite is gated on the NF target declaring a tsConfig of its own. Everyone else's tsconfig is left untouched, and update22 backfills the dedicated one; its collection version moves to 22.1.1 so ng update actually reaches projects already on 22.x. All four of its steps are idempotent.

Ownership split. The schematic owns extends and include; the builder owns files, and replaces it each build rather than appending:

{
  "extends": "./tsconfig.app.json",              // schematic
  "include": ["src/**/*.d.ts"],                  // schematic
  "files": ["src/app/app.ts", "../../libs/ui/src/index.ts"]  // builder, per build
}

files rather than include: it is an explicit file list (entry points are never globs), it is not filtered by the exclude inherited from tsconfig.app.json, and replacing it wholesale means an expose that was renamed or removed leaves nothing stale behind. Because the schematic seeds it from the component it just exposed, ng add followed by a build leaves a clean working tree.

A build with no entry points of its own — a plain host, no exposes and no shared mappings — falls back to the project's main.ts rather than handing the Angular compiler an empty program.

Follow-ups

docs(builders) — document entryPoints in both schemas. Neither had a description. It reads as an override and is not one: core resolves it as the fallback to exposes in the only place it consumes it (getUsedDependenciesFactory takes exposes first and falls back to the option), and the tsconfig's files treats it the same way. So it has no effect at all on a project that exposes anything, and the additive escape hatch is include in the federation tsconfig, which the builder never touches. Both entries now say so, and declare items: { type: "string" } to match schema.d.ts.

refactor(bundler) — the ownership flag is a boolean. managedTsConfig carried a path that was never read: the bundler only tested it for truthiness before rewriting tsConfigPath, which the path always equalled. manageTsConfig?: boolean says what it actually decides. Behaviour is unchanged, including for a target that deliberately points tsConfig at tsconfig.app.json — that is still managed.

fix(bundler) — fail with the path when the managed tsconfig is missing. Nothing upstream validates that file: core reads the workspace root tsconfig.base.json/tsconfig.json for shared mappings and only forwards fedOptions.tsConfig to the adapter, never opening it. So an absent file surfaced as a bare ENOENT from inside the bundler, naming a path with no context — #101's symptom for the config states the schematic cannot reach (a hand-edited angular.json, a gitignored tsconfig, a deleted file). It now throws before the read, naming the path as written in angular.json. The check sits after the nothing-to-compile return, so a build with no entry points of its own stays a no-op rather than failing on a file it never needed.

refactor(bundler)create-federation-tsconfig.tsupdate-federation-tsconfig.ts. It has not created anything since the schematic took over scaffolding; it replaces files in a file that must already exist. The name now matches its export and contrasts with the schematic's generate- step.

Verification

182 tests pass, tsc --noEmit clean, eslint clean.

The tsconfig mechanism is checked end-to-end against a real filesystem and real TypeScript config resolution (ts.parseJsonConfigFileContent over a scaffolded workspace: root tsconfig with paths, an tsconfig.app.json with comments, an expose, a mapped lib, an ambient .d.ts):

scenario resulting program diagnostics
as the schematic writes it app.ts, globals.d.ts none
build with an expose + a shared mapping libs/ui/src/index.ts, app.ts, globals.d.ts none
expose renamed away stale entry pruned none
host, zero entry points main.ts fallback none

compilerOptions.paths resolve through the two-level extends chain in all four, and tsconfig.app.json comes out byte-identical with its comments intact.

New coverage: 8 tests for the schematic step and 5 for the update22 backfill, neither of which had any. update-federation-tsconfig.spec.ts is reworked around files, including a regression test for the ignoreUnusedDeps: false case and one for the missing-file guard. Both sides of the ownership gate are covered in angular-bundler.spec.ts.

Worth re-running before merge: the earlier end-to-end pass in angular-examples/angular/nx was done against the first two commits, when entry points landed in include. They now land in files, so that run wants repeating on the current head.

Out of scope: mapping paths that released 22.1.x already appended to users' tsconfig.app.json include are left alone — they are indistinguishable from user-authored entries.

updateFederationTsConfig filtered out every entry point whose fileName
starts with '.', which is exactly how core hands over exposes -- their
`file` is passed through verbatim from federation.config and is
workspace-root-relative. Only shared mappings (absolute) ever made it
into `include`, so exposed modules had to be listed by hand or the
angular-compiler plugin failed with "File 'x' not found in TypeScript
compilation".

The extraction refactor in b044cf5 dropped the half of
createFederationTsConfig that assigned those local entry points to
`files`. Restore the behaviour by resolving relative fileNames against
the workspace root instead of discarding them, and append them to
`include` rather than overwriting `files` -- the config is now the
user's own tsconfig, not a generated copy.

The optimizedMappings gate moves into the function: mappings are still
only added once ignoreUnusedDeps has pruned them, but exposes are added
unconditionally.

Refs #113
The init schematic never created a federation tsconfig and never set
`tsConfig` on the generated build/serve targets, so the federation build
fell back to the app tsconfig and then wrote its entry points into it.
A fresh clone also hit ENOENT because the builder updates that file but
never creates it (#101).

Generate `<projectRoot>/tsconfig.federation.json` extending the app
tsconfig -- it drives esbuild's module resolution, so it needs the
workspace paths -- with an empty program the builder fills in per build.
Point both the build and serve targets at it.

Refs #101, #113
The federation tsconfig is now the builder's to rewrite only when the target
points at one. Without `tsConfig` the builder falls back to the Angular
target's own tsconfig, whose include already covers the exposes; rewriting
that stripped its comments for no gain.

`files` replaces `include` as the key the builder owns: it is an explicit
file list, is not filtered by the inherited `exclude`, and rewriting it
wholesale prunes entry points that were renamed or removed. The schematic
seeds it from the component it exposes, so the first build is a no-op, and
a build without entry points of its own falls back to main.ts rather than
handing the compiler an empty program.

Drops the `optimizedMappings` filter. With `ignoreUnusedDeps: false` core
hands over every tsconfig path mapping, used or not, and all of them are
bundled — leaving them out of the program failed the build with "not found
in TypeScript compilation".

update22 backfills the tsconfig for projects federated earlier; its
collection version moves to 22.1.1 so `ng update` reaches them.
Exposes from federation.config take precedence over the option in both
places core consults it, so the schema descriptions now say so and point
at the federation tsconfig's include for adding files to the program.
Also types the array's items as string, matching schema.d.ts.
managedTsConfig carried a path that was never read — the bundler only tested
it for truthiness and rewrote tsConfigPath, which the path always equalled.
manageTsConfig says what the flag actually decides.

Corrects the rationale recorded with it: the fallback tsconfig is off limits
not because its include already covers the exposes, but because `files` there
is Angular's. Projects scaffolded with the older files/include shape keep
main.ts in `files` and only .d.ts in `include`, so replacing files would drop
the app's own entry point from the program.
A tsconfig the NF target declares is read to have its files replaced, so an
absent one surfaced as a bare ENOENT from inside the bundler, naming a path
with no context. Throw before the read instead.

The check sits after the nothing-to-compile return, so a build with no entry
points of its own stays a no-op rather than failing on a file it never needed.
…tion-tsconfig

The module has not created anything since the schematic took over scaffolding;
it replaces `files` in a tsconfig that must already exist. The file name now
matches its export and contrasts with the schematic's generate- step.
@Aukevanoost
Aukevanoost merged commit fdd0210 into main Aug 11, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Builder updates the federation tsconfig but never creates it, so a fresh clone fails with ENOENT

1 participant