From 14e22f80f0397b8a98e0c5e6615b5d62541af1ab Mon Sep 17 00:00:00 2001 From: Aukevanoost Date: Wed, 28 Oct 2026 13:13:29 +0100 Subject: [PATCH 1/2] feat(remote): accept Angular's object form of outputPath (#188) --- src/builders/remote/builder.ts | 15 +++++---- src/builders/remote/output-path.spec.ts | 43 +++++++++++++++++++++++++ src/builders/remote/output-path.ts | 12 +++++++ src/builders/remote/schema.d.ts | 2 +- src/builders/remote/schema.json | 14 +++++++- 5 files changed, 77 insertions(+), 9 deletions(-) create mode 100644 src/builders/remote/output-path.spec.ts create mode 100644 src/builders/remote/output-path.ts diff --git a/src/builders/remote/builder.ts b/src/builders/remote/builder.ts index 8ee479c4..f5cf3408 100644 --- a/src/builders/remote/builder.ts +++ b/src/builders/remote/builder.ts @@ -3,9 +3,9 @@ import './setup-builder-env-variables.js'; import * as path from 'path'; -import { existsSync, mkdirSync, rmSync } from 'fs'; +import { mkdirSync } from 'fs'; -import { SourceFileCache } from '@angular/build/private'; +import { deleteOutputDir, SourceFileCache } from '@angular/build/private'; import { type BuilderContext, type BuilderOutput, createBuilder } from '@angular-devkit/architect'; @@ -36,6 +36,7 @@ import { resolveCachePath } from '../../utils/resolve-cache-path.js'; import type { NfRemoteBuilderSchema, NfRemoteInternalOptions } from './schema.js'; import { resolveNgBuilderOptions } from './resolve-ng-options.js'; +import { resolveBrowserOutputPath } from './output-path.js'; import { inferFederationConfigPath } from './infer-config-path.js'; import { createDebouncedChangeWatcher } from './change-watcher.js'; import { @@ -60,8 +61,10 @@ export async function* runRemoteBuilder( const context = withDiskCaseWorkspaceRoot(builderContext); const federationTsConfig = nfBuilderOptions.tsConfig; - const outputBase = nfBuilderOptions.outputPath ?? `dist/${context.target!.project}`; - const browserOutputPath = path.join(outputBase, 'browser'); + const browserOutputPath = resolveBrowserOutputPath( + nfBuilderOptions.outputPath, + context.target!.project + ); const absoluteBrowserOutput = path.resolve(context.workspaceRoot, browserOutputPath); const { ngBuilderOptions, projectRoot, projectSourceRoot } = await resolveNgBuilderOptions( @@ -147,9 +150,7 @@ export async function* runRemoteBuilder( } } - if (existsSync(absoluteBrowserOutput)) { - rmSync(absoluteBrowserOutput, { recursive: true }); - } + await deleteOutputDir(context.workspaceRoot, absoluteBrowserOutput); mkdirSync(absoluteBrowserOutput, { recursive: true }); try { diff --git a/src/builders/remote/output-path.spec.ts b/src/builders/remote/output-path.spec.ts new file mode 100644 index 00000000..f6a8cc90 --- /dev/null +++ b/src/builders/remote/output-path.spec.ts @@ -0,0 +1,43 @@ +import * as path from 'path'; + +import { deleteOutputDir } from '@angular/build/private'; + +import { resolveBrowserOutputPath } from './output-path.js'; + +describe('resolveBrowserOutputPath', () => { + it('defaults to dist//browser', () => { + expect(resolveBrowserOutputPath(undefined, 'mfe1')).toBe(path.join('dist/mfe1/browser')); + }); + + // Existing string configs must not move their output (#188). + it('appends browser to a string outputPath', () => { + expect(resolveBrowserOutputPath('dist/apps/mfe1', 'mfe1')).toBe( + path.join('dist/apps/mfe1/browser') + ); + }); + + it('defaults browser to "browser" in the object form, as Angular does', () => { + expect(resolveBrowserOutputPath({ base: 'dist/apps/mfe1' }, 'mfe1')).toBe( + path.join('dist/apps/mfe1/browser') + ); + }); + + // `browser: ''` must not be treated as missing: it writes straight into base, like + // the build builder does for Angular's `outputPath: { base, browser: '' }` (#188). + it('writes directly into base when browser is empty', () => { + expect(resolveBrowserOutputPath({ base: 'dist/apps/mfe1', browser: '' }, 'mfe1')).toBe( + path.join('dist/apps/mfe1') + ); + }); + + // The object form makes the workspace root reachable as an output folder; the builder + // relies on Angular's deleteOutputDir to refuse clearing it. + it('cannot clear the workspace root through deleteOutputDir', async () => { + const root = path.resolve('/workspace'); + const output = resolveBrowserOutputPath({ base: '.', browser: '' }, 'mfe1'); + + await expect(deleteOutputDir(root, output)).rejects.toThrow( + 'Output path MUST not be project root directory!' + ); + }); +}); diff --git a/src/builders/remote/output-path.ts b/src/builders/remote/output-path.ts new file mode 100644 index 00000000..4a063fc1 --- /dev/null +++ b/src/builders/remote/output-path.ts @@ -0,0 +1,12 @@ +import * as path from 'path'; + +import type { NfRemoteBuilderSchema } from './schema.js'; + +export function resolveBrowserOutputPath( + outputPath: NfRemoteBuilderSchema['outputPath'], + project: string +): string { + const { base, browser = 'browser' } = + typeof outputPath === 'object' ? outputPath : { base: outputPath ?? `dist/${project}` }; + return path.join(base, browser); +} diff --git a/src/builders/remote/schema.d.ts b/src/builders/remote/schema.d.ts index 192e3afc..05c8e8d3 100644 --- a/src/builders/remote/schema.d.ts +++ b/src/builders/remote/schema.d.ts @@ -8,7 +8,7 @@ export interface NfRemoteBuilderSchema extends JsonObject { rebuildDelay: number; watch: boolean; watchLinkedDeps?: boolean; - outputPath?: string; + outputPath?: string | { base: string; browser?: string }; projectName?: string; verbose?: boolean; entryPoints?: string[]; diff --git a/src/builders/remote/schema.json b/src/builders/remote/schema.json index 2c3d29d0..202ee50c 100644 --- a/src/builders/remote/schema.json +++ b/src/builders/remote/schema.json @@ -38,7 +38,19 @@ "type": "string" }, "outputPath": { - "type": "string" + "description": "Output folder, relative to the workspace root. A string writes to '/browser'. The object form follows Angular's: files go to '/', and 'browser' defaults to 'browser'. Set it to '' to write directly into 'base'; that folder is emptied before every build.", + "oneOf": [ + { "type": "string" }, + { + "type": "object", + "properties": { + "base": { "type": "string" }, + "browser": { "type": "string" } + }, + "required": ["base"], + "additionalProperties": false + } + ] }, "verbose": { "type": "boolean", From b34019df7ea0b71a77e91d6f248fe3e542d5e2ae Mon Sep 17 00:00:00 2001 From: Aukevanoost Date: Wed, 28 Oct 2026 14:16:58 +0100 Subject: [PATCH 2/2] fix(remote): restrict outputPath.browser to a folder name, as Angular does --- src/builders/remote/output-path.spec.ts | 13 ------------- src/builders/remote/schema.json | 4 ++-- 2 files changed, 2 insertions(+), 15 deletions(-) diff --git a/src/builders/remote/output-path.spec.ts b/src/builders/remote/output-path.spec.ts index f6a8cc90..13632c1f 100644 --- a/src/builders/remote/output-path.spec.ts +++ b/src/builders/remote/output-path.spec.ts @@ -1,7 +1,5 @@ import * as path from 'path'; -import { deleteOutputDir } from '@angular/build/private'; - import { resolveBrowserOutputPath } from './output-path.js'; describe('resolveBrowserOutputPath', () => { @@ -29,15 +27,4 @@ describe('resolveBrowserOutputPath', () => { path.join('dist/apps/mfe1') ); }); - - // The object form makes the workspace root reachable as an output folder; the builder - // relies on Angular's deleteOutputDir to refuse clearing it. - it('cannot clear the workspace root through deleteOutputDir', async () => { - const root = path.resolve('/workspace'); - const output = resolveBrowserOutputPath({ base: '.', browser: '' }, 'mfe1'); - - await expect(deleteOutputDir(root, output)).rejects.toThrow( - 'Output path MUST not be project root directory!' - ); - }); }); diff --git a/src/builders/remote/schema.json b/src/builders/remote/schema.json index 202ee50c..3ea3a5ca 100644 --- a/src/builders/remote/schema.json +++ b/src/builders/remote/schema.json @@ -38,14 +38,14 @@ "type": "string" }, "outputPath": { - "description": "Output folder, relative to the workspace root. A string writes to '/browser'. The object form follows Angular's: files go to '/', and 'browser' defaults to 'browser'. Set it to '' to write directly into 'base'; that folder is emptied before every build.", + "description": "Output folder, relative to the workspace root. A string writes to '/browser'. The object form follows Angular's: files go to '/', and 'browser' defaults to 'browser'; set 'browser' to '' to write directly into 'base'. The output folder is emptied when the builder starts.", "oneOf": [ { "type": "string" }, { "type": "object", "properties": { "base": { "type": "string" }, - "browser": { "type": "string" } + "browser": { "type": "string", "pattern": "^[-\\w\\.]*$" } }, "required": ["base"], "additionalProperties": false