From 725fc1177dae74170465b27b9cd0db037cb3bd9f Mon Sep 17 00:00:00 2001 From: Aukevanoost Date: Thu, 24 Sep 2026 07:37:24 +0200 Subject: [PATCH 1/3] fix: apply the Angular target's define to exposed modules The federation esbuild context hardcoded its `define`, so identifiers the application target defines stayed unreplaced in exposed modules and threw a ReferenceError once a host loaded them. User defines go first so Angular's own flags win, as in the application builder. --- src/tools/esbuild/angular-bundler.spec.ts | 21 +++++++++++++++++++++ src/tools/esbuild/angular-bundler.ts | 1 + 2 files changed, 22 insertions(+) diff --git a/src/tools/esbuild/angular-bundler.spec.ts b/src/tools/esbuild/angular-bundler.spec.ts index 6689e45..11b94dc 100644 --- a/src/tools/esbuild/angular-bundler.spec.ts +++ b/src/tools/esbuild/angular-bundler.spec.ts @@ -145,4 +145,25 @@ describe('createAngularEsbuildContext', () => { expect(lastBuildOptions().entryPoints).toEqual([{ in: absolute, out: 'ui' }]); }); + + // #129: exposed modules left user defines unreplaced, throwing a ReferenceError in the host. + // Angular's own flags win over user defines, mirroring the application builder. + it('applies the builder define, keeping the Angular flags on top', async () => { + await createAngularEsbuildContext( + makeOptions({ + builderOptions: { + optimization: false, + sourceMap: false, + define: { BUILD_ID: "'abc'", ngJitMode: 'true' }, + }, + } as unknown as Partial), + 'mapping-or-exposed' + ); + + expect(lastBuildOptions().define).toEqual({ + BUILD_ID: "'abc'", + ngDevMode: 'false', + ngJitMode: 'false', + }); + }); }); diff --git a/src/tools/esbuild/angular-bundler.ts b/src/tools/esbuild/angular-bundler.ts index 1182413..656aa8e 100644 --- a/src/tools/esbuild/angular-bundler.ts +++ b/src/tools/esbuild/angular-bundler.ts @@ -174,6 +174,7 @@ export async function createAngularEsbuildContext( logLimit: 0, plugins: [compilerPlugin, commonjsPlugin(), ...customPlugins], define: { + ...builderOptions.define, ...(dev ? {} : { ngDevMode: 'false' }), ngJitMode: 'false', }, From 50be6bc68c2b255b9a24165715e79a916bad31d1 Mon Sep 17 00:00:00 2001 From: Aukevanoost Date: Thu, 24 Sep 2026 07:37:24 +0200 Subject: [PATCH 2/3] feat(builder): accept a define option and pass it to the Angular target Merged over the target's own `define`, so values can be supplied per invocation, e.g. `nx run app:build --define.BUILD_ID="\"'123'\""`. Closes #129 --- src/builders/build/builder.ts | 7 +++++++ src/builders/build/schema.d.ts | 1 + src/builders/build/schema.json | 5 +++++ 3 files changed, 13 insertions(+) diff --git a/src/builders/build/builder.ts b/src/builders/build/builder.ts index b24b948..94eb91b 100644 --- a/src/builders/build/builder.ts +++ b/src/builders/build/builder.ts @@ -218,6 +218,13 @@ export async function* runBuilder( ngBuilderOptions.outputPath = nfBuilderOptions.outputPath; } + if (nfBuilderOptions.define) { + ngBuilderOptions.define = { + ...ngBuilderOptions.define, + ...nfBuilderOptions.define, + }; + } + const declaresTsConfig = !!nfBuilderOptions.tsConfig && nfBuilderOptions.tsConfig.length > 0; diff --git a/src/builders/build/schema.d.ts b/src/builders/build/schema.d.ts index 4655fb0..d9811d5 100644 --- a/src/builders/build/schema.d.ts +++ b/src/builders/build/schema.d.ts @@ -16,6 +16,7 @@ export interface NfBuilderSchema extends JsonObject { esmsInitOptions: ESMSInitOptions; baseHref?: string; outputPath?: string; + define?: Record; projectName?: string; ssr: boolean; tsConfig?: string; diff --git a/src/builders/build/schema.json b/src/builders/build/schema.json index c78af2e..e3fc570 100644 --- a/src/builders/build/schema.json +++ b/src/builders/build/schema.json @@ -50,6 +50,11 @@ "outputPath": { "type": "string" }, + "define": { + "type": "object", + "additionalProperties": { "type": "string" }, + "description": "Global identifiers to replace at build time, merged over the Angular target's own 'define'. String values must be put in quotes." + }, "esmsInitOptions": { "type": "object", "description": "Options for esms-module-shims https://github.com/guybedford/es-module-shims?tab=readme-ov-file#init-options", From 6ff002a7221b74af24f154c020248449b55f36b6 Mon Sep 17 00:00:00 2001 From: Aukevanoost Date: Thu, 24 Sep 2026 07:50:26 +0200 Subject: [PATCH 3/3] fix(builder): pass the define option through to the dev server serveWithVite reads the build target's options itself, so the define merged into ngBuilderOptions never reached the main bundle under serve, while exposed modules did get it. Merge it in the internal Angular builder instead, next to the externals. Moves createInternalAngularBuilder into its own module so it can be tested. --- src/builders/build/builder.ts | 46 ++--------------- .../build/internal-angular-builder.spec.ts | 47 +++++++++++++++++ .../build/internal-angular-builder.ts | 50 +++++++++++++++++++ 3 files changed, 100 insertions(+), 43 deletions(-) create mode 100644 src/builders/build/internal-angular-builder.spec.ts create mode 100644 src/builders/build/internal-angular-builder.ts diff --git a/src/builders/build/builder.ts b/src/builders/build/builder.ts index 94eb91b..503ea7e 100644 --- a/src/builders/build/builder.ts +++ b/src/builders/build/builder.ts @@ -11,7 +11,6 @@ import { buildApplication, } from "@angular/build"; import { - buildApplicationInternal, normalizeDevServerOptions, serveWithVite, SourceFileCache, @@ -46,7 +45,7 @@ import { sharedMappingDirs, syncNfFileWatcher, } from "@softarc/native-federation/internal"; -import { type Plugin, type PluginBuild } from "esbuild"; +import { type PluginBuild } from "esbuild"; import { devHostInstancesPlugin } from "../../plugin/dev-host-instances-plugin.js"; import { withDiskCaseWorkspaceRoot } from "./../../utils/disk-case.js"; import { checkForInvalidImports } from "./../../utils/check-for-invalid-imports.js"; @@ -63,6 +62,7 @@ import { createAngularBuildAdapter } from "../../tools/esbuild/angular-esbuild-a import { createSharedMappingsPlugin } from "../../tools/esbuild/shared-mappings-plugin.js"; import { getI18nConfig, translateFederationArtifacts } from "./i18n.js"; import { updateScriptTags } from "./update-index-html.js"; +import { createInternalAngularBuilder } from "./internal-angular-builder.js"; const originalWrite = process.stderr.write.bind(process.stderr); @@ -87,47 +87,6 @@ process.stderr.write = function ( return originalWrite(chunk, encodingOrCallback as BufferEncoding, callback); }; -const createInternalAngularBuilder = - ( - externals: string[], - opts?: { instrumentForCoverage?: (filename: string) => boolean }, - ) => - ( - options: Parameters[0], - context: BuilderContext, - pluginsOrExtensions?: - | Plugin[] - | Parameters[2], - ) => { - let extensions: Parameters[2]; - if (pluginsOrExtensions && Array.isArray(pluginsOrExtensions)) { - extensions = { - codePlugins: pluginsOrExtensions, - }; - } else { - extensions = pluginsOrExtensions as Parameters< - typeof buildApplicationInternal - >[2]; - } - - // serveWithVite fetches its own browserOptions independently, so ngBuilderOptions - // modifications don't reach here. Add NF externals to externalDependencies so - // Angular routes them to optimizeDeps.exclude, preventing Vite from trying to - // pre-bundle packages that include native .node binaries. - options.externalDependencies = [ - ...(options.externalDependencies ?? []), - ...externals, - ]; - - if (opts?.instrumentForCoverage) { - options.instrumentForCoverage = opts.instrumentForCoverage; - } - - // Todo: share cache with Angular builder: https://github.com/angular/angular-cli/pull/32527 - // options.codeBundleCache = nfOptions.federationCache.bundlerCache; - return buildApplicationInternal(options, context, extensions); - }; - export async function* runBuilder( nfBuilderOptions: NfBuilderSchema & NfInternalOptions, builderContext: BuilderContext, @@ -564,6 +523,7 @@ export async function* runBuilder( appBuilderName, createInternalAngularBuilder(externals, { instrumentForCoverage: nfBuilderOptions.instrumentForCoverage, + define: nfBuilderOptions.define, }), context, nfBuilderOptions.skipHtmlTransform diff --git a/src/builders/build/internal-angular-builder.spec.ts b/src/builders/build/internal-angular-builder.spec.ts new file mode 100644 index 0000000..fb0635f --- /dev/null +++ b/src/builders/build/internal-angular-builder.spec.ts @@ -0,0 +1,47 @@ +import { buildApplicationInternal } from "@angular/build/private"; +import type { BuilderContext } from "@angular-devkit/architect"; + +import { createInternalAngularBuilder } from "./internal-angular-builder.js"; + +vi.mock("@angular/build/private", () => ({ + buildApplicationInternal: vi.fn(), +})); + +type Options = Parameters[0]; + +function run( + options: Partial, + opts?: Parameters[1], +) { + createInternalAngularBuilder(["@angular/core"], opts)( + options as Options, + {} as BuilderContext, + ); + return vi.mocked(buildApplicationInternal).mock.calls.at(-1)![0]; +} + +describe("createInternalAngularBuilder", () => { + beforeEach(() => vi.mocked(buildApplicationInternal).mockClear()); + + it("appends the federation externals", () => { + expect(run({ externalDependencies: ["fs"] }).externalDependencies).toEqual([ + "fs", + "@angular/core", + ]); + }); + + // #129: serveWithVite reads the build target's options itself, so the builder's define + // must be merged here or `serve --define` never reaches the main bundle. + it("merges the builder define over the target define", () => { + const options = run( + { define: { BUILD_ID: "'target'", KEEP: "true" } }, + { define: { BUILD_ID: "'cli'" } }, + ); + + expect(options.define).toEqual({ BUILD_ID: "'cli'", KEEP: "true" }); + }); + + it("leaves the target define alone when the builder sets none", () => { + expect(run({ define: { KEEP: "true" } }).define).toEqual({ KEEP: "true" }); + }); +}); diff --git a/src/builders/build/internal-angular-builder.ts b/src/builders/build/internal-angular-builder.ts new file mode 100644 index 0000000..c3faa4e --- /dev/null +++ b/src/builders/build/internal-angular-builder.ts @@ -0,0 +1,50 @@ +import { buildApplicationInternal } from "@angular/build/private"; +import { type BuilderContext } from "@angular-devkit/architect"; +import { type Plugin } from "esbuild"; + +export const createInternalAngularBuilder = + ( + externals: string[], + opts?: { + instrumentForCoverage?: (filename: string) => boolean; + define?: Record; + }, + ) => + ( + options: Parameters[0], + context: BuilderContext, + pluginsOrExtensions?: + Plugin[] | Parameters[2], + ) => { + let extensions: Parameters[2]; + if (pluginsOrExtensions && Array.isArray(pluginsOrExtensions)) { + extensions = { + codePlugins: pluginsOrExtensions, + }; + } else { + extensions = pluginsOrExtensions as Parameters< + typeof buildApplicationInternal + >[2]; + } + + // serveWithVite fetches its own browserOptions independently, so ngBuilderOptions + // modifications don't reach here. Add NF externals to externalDependencies so + // Angular routes them to optimizeDeps.exclude, preventing Vite from trying to + // pre-bundle packages that include native .node binaries. + options.externalDependencies = [ + ...(options.externalDependencies ?? []), + ...externals, + ]; + + if (opts?.instrumentForCoverage) { + options.instrumentForCoverage = opts.instrumentForCoverage; + } + + if (opts?.define) { + options.define = { ...options.define, ...opts.define }; + } + + // Todo: share cache with Angular builder: https://github.com/angular/angular-cli/pull/32527 + // options.codeBundleCache = nfOptions.federationCache.bundlerCache; + return buildApplicationInternal(options, context, extensions); + };