Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions migration-collection.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
}
22 changes: 13 additions & 9 deletions src/builders/build/builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
Expand Down Expand Up @@ -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(
Expand Down
14 changes: 14 additions & 0 deletions src/builders/build/schema.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
};
6 changes: 4 additions & 2 deletions src/builders/build/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
23 changes: 16 additions & 7 deletions src/builders/remote/builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
6 changes: 4 additions & 2 deletions src/builders/remote/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
14 changes: 13 additions & 1 deletion src/schematics/init/schematic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);

Expand Down
141 changes: 141 additions & 0 deletions src/schematics/init/steps/generate-federation-tsconfig.spec.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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');
});
});
Loading
Loading