diff --git a/src/builders/build/builder.ts b/src/builders/build/builder.ts index b24b948..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, @@ -218,6 +177,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; @@ -557,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); + }; 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", 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', },