Skip to content

Commit fdd0210

Browse files
authored
Merge pull request #124 from native-federation/fix/federation-tsconfig-exposes
fix(bundler): add exposed modules to the federation tsconfig, and scaffold it
2 parents 65211b9 + ee2269d commit fdd0210

18 files changed

Lines changed: 763 additions & 184 deletions

migration-collection.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,10 @@
1010
"description": "migrating to v18"
1111
},
1212
"update22": {
13-
"version": "22.0.0",
13+
"version": "22.1.1",
1414
"factory": "./src/schematics/update22/schematic",
1515
"schema": "./src/schematics/update22/schema.json",
16-
"description": "migrating native-federation to the v22 ESM standard"
16+
"description": "migrating native-federation to the v22 ESM standard and generating a tsconfig.federation.json per federated project"
1717
}
1818
}
1919
}

src/builders/build/builder.ts

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -211,16 +211,25 @@ export async function* runBuilder(
211211
ngBuilderOptions.outputPath = nfBuilderOptions.outputPath;
212212
}
213213

214-
const federationTsConfig =
215-
!!nfBuilderOptions.tsConfig && nfBuilderOptions.tsConfig.length > 0
216-
? nfBuilderOptions.tsConfig
217-
: ngBuilderOptions.tsConfig;
214+
const declaresTsConfig =
215+
!!nfBuilderOptions.tsConfig && nfBuilderOptions.tsConfig.length > 0;
216+
217+
const federationTsConfig = declaresTsConfig
218+
? nfBuilderOptions.tsConfig!
219+
: ngBuilderOptions.tsConfig;
220+
221+
const entryPoints: string[] | undefined =
222+
nfBuilderOptions.entryPoints && nfBuilderOptions.entryPoints.length > 0
223+
? nfBuilderOptions.entryPoints
224+
: [path.join(path.dirname(federationTsConfig), "src/main.ts")];
218225

219226
const adapter = createAngularBuildAdapter(
220227
{
221228
...ngBuilderOptions,
222229
plugins: nfBuilderOptions.plugins,
223230
instrumentForCoverage: nfBuilderOptions.instrumentForCoverage,
231+
manageTsConfig: declaresTsConfig,
232+
fallbackEntryPoints: entryPoints,
224233
},
225234
context,
226235
);
@@ -265,11 +274,6 @@ export async function* runBuilder(
265274
? browserOutputPath
266275
: path.join(outputOptions.base, outputOptions.browser, localeFilter[0]!);
267276

268-
const entryPoints: string[] | undefined =
269-
nfBuilderOptions.entryPoints && nfBuilderOptions.entryPoints.length > 0
270-
? nfBuilderOptions.entryPoints
271-
: [path.join(path.dirname(federationTsConfig), "src/main.ts")];
272-
273277
const cachePath = getDefaultCachePath(context.workspaceRoot);
274278

275279
const normalized = await normalizeFederationOptions(

src/builders/build/schema.d.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,18 @@ export type NfInternalOptions = {
3232
* Used exclusively for tests and shouldn't be used for other kinds of builds.
3333
*/
3434
instrumentForCoverage?: (filename: string) => boolean;
35+
36+
/**
37+
* Whether the tsconfig the federation build resolved to is the builder's to rewrite (see
38+
* tools/esbuild/update-federation-tsconfig.ts). True only when the NF target declares a
39+
* `tsConfig` of its own; without one the build falls back to the Angular target's tsconfig,
40+
* where `files` is Angular's — replacing it would drop main.ts from the app's own program.
41+
*/
42+
manageTsConfig?: boolean;
43+
44+
/**
45+
* Roots keeping the federation program non-empty when a build has no entry points of its
46+
* own — core's reachability entry points, which default to the project's main.ts.
47+
*/
48+
fallbackEntryPoints?: string[];
3549
};

src/builders/build/schema.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@
2323
"default": 0
2424
},
2525
"entryPoints": {
26-
"type": "array"
26+
"type": "array",
27+
"items": { "type": "string" },
28+
"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."
2729
},
2830
"rebuildDelay": {
2931
"type": "number",
@@ -61,7 +63,7 @@
6163
},
6264
"tsConfig": {
6365
"type": "string",
64-
"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."
66+
"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."
6567
},
6668
"cacheExternalArtifacts": {
6769
"type": "boolean",

src/builders/remote/builder.ts

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -64,17 +64,26 @@ export async function* runRemoteBuilder(
6464
context
6565
);
6666

67-
const adapter = createAngularBuildAdapter(ngBuilderOptions, context);
68-
setBuildAdapter(adapter);
69-
setLogLevel(nfBuilderOptions.verbose ? 'verbose' : 'info');
70-
71-
// Unlike the regular build builder, remote never bundles a main.ts / polyfills.
72-
// Entry points come from the schema override or, when omitted, from the
73-
// `exposes` map in federation.config.{mjs,js} (resolved by normalizeFederationOptions).
67+
// Unlike the regular build builder, remote never bundles a main.ts / polyfills. Entry points
68+
// come from the `exposes` map in federation.config.{mjs,js}; the schema option is only a
69+
// fallback for when there are none, so passing `undefined` when it is omitted keeps core
70+
// from treating an empty list as a deliberate one.
7471
const entryPoints: string[] | undefined = nfBuilderOptions.entryPoints?.length
7572
? nfBuilderOptions.entryPoints
7673
: undefined;
7774

75+
const adapter = createAngularBuildAdapter(
76+
{
77+
...ngBuilderOptions,
78+
// Required by the schema, so the tsconfig is always the builder's to manage.
79+
manageTsConfig: true,
80+
fallbackEntryPoints: entryPoints,
81+
},
82+
context
83+
);
84+
setBuildAdapter(adapter);
85+
setLogLevel(nfBuilderOptions.verbose ? 'verbose' : 'info');
86+
7887
const cachePath = getDefaultCachePath(context.workspaceRoot);
7988

8089
const normalized = await normalizeFederationOptions(

src/builders/remote/schema.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
"properties": {
99
"tsConfig": {
1010
"type": "string",
11-
"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."
11+
"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."
1212
},
1313
"dev": {
1414
"type": "boolean",
@@ -20,7 +20,9 @@
2020
"default": false
2121
},
2222
"entryPoints": {
23-
"type": "array"
23+
"type": "array",
24+
"items": { "type": "string" },
25+
"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."
2426
},
2527
"rebuildDelay": {
2628
"type": "number",

src/schematics/init/schematic.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { updatePolyfills } from './steps/update-polyfills.js';
1212
import { generateRemoteMap } from './steps/generate-remote-map.js';
1313
import { generateFederationConfig } from './steps/generate-federation-config.js';
1414
import { updateWorkspaceConfig } from './steps/update-workspace-config.js';
15+
import { generateFederationTsConfig } from './steps/generate-federation-tsconfig.js';
1516
import { addDependencies } from './steps/add-dependencies.js';
1617
import { makeMainAsync } from './steps/make-main-async.js';
1718
import { makeServerAsync } from './steps/make-server-async.js';
@@ -69,7 +70,18 @@ export default function config(options: NfSchematicSchema): Rule {
6970
const ssr = isSsrProject(normalized);
7071
const server = ssr ? getSsrFilePath(normalized) : '';
7172

72-
updateWorkspaceConfig(tree, normalized, workspace, workspaceFileName, ssr);
73+
// Seed the federation program with what the generated config exposes, so the first build
74+
// finds the tsconfig already correct. Where the exposes are unknown (a host, a config we
75+
// did not write, or a project without a recognisable app component) main.ts stands in —
76+
// the same fallback the builder applies.
77+
const exposesAppComponent =
78+
!exists && options.type === 'remote' && appComponent !== 'update-this.ts';
79+
80+
const federationTsConfig = generateFederationTsConfig(tree, normalized, [
81+
exposesAppComponent ? appComponent : main,
82+
]);
83+
84+
updateWorkspaceConfig(tree, normalized, workspace, workspaceFileName, ssr, federationTsConfig);
7385

7486
addDependencies(tree, context, ssr);
7587

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import { EmptyTree, type Tree } from '@angular-devkit/schematics';
2+
3+
import { generateFederationTsConfig } from './generate-federation-tsconfig.js';
4+
import type { NormalizedOptions } from './normalize-options.js';
5+
6+
const EXPOSED = ['projects/mfe1/src/app/app.ts'];
7+
8+
function makeOptions(overrides: Partial<NormalizedOptions> = {}): NormalizedOptions {
9+
return {
10+
polyfills: [] as unknown as string,
11+
projectName: 'mfe1',
12+
projectRoot: 'projects/mfe1',
13+
projectSourceRoot: 'projects/mfe1/src',
14+
manifestPath: '',
15+
manifestRelPath: '',
16+
main: 'projects/mfe1/src/main.ts',
17+
port: 4200,
18+
projectConfig: {
19+
architect: {
20+
build: {
21+
builder: '@angular/build:application',
22+
options: { tsConfig: 'projects/mfe1/tsconfig.app.json' },
23+
},
24+
},
25+
},
26+
...overrides,
27+
};
28+
}
29+
30+
function read(tree: Tree, path: string) {
31+
return JSON.parse(tree.read(path)!.toString('utf8'));
32+
}
33+
34+
describe('generateFederationTsConfig', () => {
35+
let tree: Tree;
36+
37+
beforeEach(() => {
38+
tree = new EmptyTree();
39+
});
40+
41+
it('creates a federation tsconfig extending the app tsconfig', () => {
42+
const result = generateFederationTsConfig(tree, makeOptions(), EXPOSED);
43+
44+
expect(result).toBe('projects/mfe1/tsconfig.federation.json');
45+
expect(read(tree, result)).toEqual({
46+
extends: './tsconfig.app.json',
47+
files: ['src/app/app.ts'],
48+
include: ['src/**/*.d.ts'],
49+
});
50+
});
51+
52+
// An empty `files` list is a TypeScript error (TS18002) unless the config also extends
53+
// another one, so neither key may be dropped from the generated shape.
54+
it('always emits both extends and a non-empty files list', () => {
55+
const result = generateFederationTsConfig(tree, makeOptions(), [
56+
'projects/mfe1/src/main.ts',
57+
]);
58+
59+
const tsconfig = read(tree, result);
60+
expect(tsconfig.extends).toBeTruthy();
61+
expect(tsconfig.files).toEqual(['src/main.ts']);
62+
});
63+
64+
it('derives the include glob from the project source root', () => {
65+
const result = generateFederationTsConfig(
66+
tree,
67+
makeOptions({ projectSourceRoot: 'projects/mfe1/app-src' }),
68+
EXPOSED
69+
);
70+
71+
expect(read(tree, result).include).toEqual(['app-src/**/*.d.ts']);
72+
});
73+
74+
it('points extends at a tsconfig that lives outside the project root', () => {
75+
const result = generateFederationTsConfig(
76+
tree,
77+
makeOptions({
78+
projectConfig: {
79+
architect: {
80+
build: {
81+
builder: '@angular/build:application',
82+
options: { tsConfig: 'tsconfig.app.json' },
83+
},
84+
},
85+
},
86+
}),
87+
EXPOSED
88+
);
89+
90+
expect(read(tree, result).extends).toBe('../../tsconfig.app.json');
91+
});
92+
93+
it('leaves an existing federation tsconfig untouched', () => {
94+
tree.create('projects/mfe1/tsconfig.federation.json', '{ "files": ["src/bootstrap.ts"] }');
95+
96+
const result = generateFederationTsConfig(tree, makeOptions(), EXPOSED);
97+
98+
expect(read(tree, result)).toEqual({ files: ['src/bootstrap.ts'] });
99+
});
100+
101+
it('does nothing when the project is already on the federation builder', () => {
102+
const options = makeOptions();
103+
options.projectConfig.architect.build.builder = '@angular-architects/native-federation:build';
104+
105+
const result = generateFederationTsConfig(tree, options, EXPOSED);
106+
107+
expect(tree.exists(result)).toBe(false);
108+
});
109+
110+
// esbuild is where a previous run parked the original build target.
111+
it('falls back to the esbuild target tsConfig', () => {
112+
const result = generateFederationTsConfig(
113+
tree,
114+
makeOptions({
115+
projectConfig: {
116+
architect: {
117+
build: { builder: '@angular/build:application', options: {} },
118+
esbuild: { options: { tsConfig: 'projects/mfe1/tsconfig.app.json' } },
119+
},
120+
},
121+
}),
122+
EXPOSED
123+
);
124+
125+
expect(read(tree, result).extends).toBe('./tsconfig.app.json');
126+
});
127+
128+
it('throws when no tsConfig can be found', () => {
129+
expect(() =>
130+
generateFederationTsConfig(
131+
tree,
132+
makeOptions({
133+
projectConfig: {
134+
architect: { build: { builder: '@angular/build:application', options: {} } },
135+
},
136+
}),
137+
EXPOSED
138+
)
139+
).toThrow('has no tsConfig');
140+
});
141+
});

0 commit comments

Comments
 (0)