diff --git a/package.json b/package.json index cb00e2f..638f250 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "@angular-devkit/core": "~22.1.0", "@angular-devkit/schematics": "~22.1.0", "@chialab/esbuild-plugin-commonjs": "^0.19.0", - "@softarc/native-federation": "~4.5.0-next.1", + "@softarc/native-federation": "~4.5.0", "@softarc/native-federation-orchestrator": "^4.5.2", "es-module-shims": "^2.8.0", "esbuild": "^0.28.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4f55cc3..e381765 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,8 +24,8 @@ importers: specifier: ^0.19.0 version: 0.19.1 '@softarc/native-federation': - specifier: ~4.5.0-next.1 - version: 4.5.0-next.1(typescript@6.0.3) + specifier: ~4.5.0 + version: 4.5.0(typescript@6.0.3) '@softarc/native-federation-orchestrator': specifier: ^4.5.2 version: 4.6.0 @@ -1837,8 +1837,8 @@ packages: resolution: {integrity: sha512-8529SINGc3zBdKZcyUsP9vGj4EFjUOljy+43qfwzYSgC3FwkHcAjddf/p86t8kI9CmCp0JGeLkM1IjJ7xahVXQ==, tarball: https://registry.npmjs.org/@softarc/native-federation-orchestrator/-/native-federation-orchestrator-4.6.0.tgz} engines: {node: '>=24.16.0'} - '@softarc/native-federation@4.5.0-next.1': - resolution: {integrity: sha512-HD8BO7b2d/9UIT4nSkq6D99mqB7DKQ35ONAkBpyx41/ojM98bvwzfJxL2UNtFvKqzlYaAeJZ/h0a5EagZ1IuZQ==, tarball: https://registry.npmjs.org/@softarc/native-federation/-/native-federation-4.5.0-next.1.tgz} + '@softarc/native-federation@4.5.0': + resolution: {integrity: sha512-FixGtwyRT7o8nANxecr0UDTZWTzhzLqH+cE3clntzhx/t4BNkNQwqaOZlcyjHNTI6l6nH4zIY4Ct7jaHCjPO3A==, tarball: https://registry.npmjs.org/@softarc/native-federation/-/native-federation-4.5.0.tgz} '@softarc/sheriff-core@0.19.6': resolution: {integrity: sha512-KACxHG9sS7kNWgnnBODzdr14kMLMrJVlQKc+tViUP03p2fRwNhESOA49bz51Yn7dro1mbtMmmFjICLmZSJDZZA==, tarball: https://registry.npmjs.org/@softarc/sheriff-core/-/sheriff-core-0.19.6.tgz} @@ -4675,7 +4675,7 @@ snapshots: dependencies: semver: 7.8.5 - '@softarc/native-federation@4.5.0-next.1(typescript@6.0.3)': + '@softarc/native-federation@4.5.0(typescript@6.0.3)': dependencies: '@softarc/sheriff-core': 0.19.6(typescript@6.0.3) chalk: 6.0.0 diff --git a/src/builders/build/builder.ts b/src/builders/build/builder.ts index eab0c8a..729e8d6 100644 --- a/src/builders/build/builder.ts +++ b/src/builders/build/builder.ts @@ -48,6 +48,7 @@ import { } from "@softarc/native-federation/internal"; import { type Plugin, 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"; import { federationSourceFiles } from "./../../utils/federation-source-files.js"; import { watchpackWatch } from "./../../utils/watchpack-watch.js"; @@ -128,8 +129,12 @@ const createInternalAngularBuilder = export async function* runBuilder( nfBuilderOptions: NfBuilderSchema & NfInternalOptions, - context: BuilderContext, + builderContext: BuilderContext, ): AsyncIterable { + // One root for the whole invocation, ours and Angular's alike — the two halves compare + // each other's paths as plain strings (watch sets, cache keys). + const context = withDiskCaseWorkspaceRoot(builderContext); + let target = targetFromTargetString(nfBuilderOptions.target); let targetOptions = (await context.getTargetOptions( diff --git a/src/builders/remote/builder.ts b/src/builders/remote/builder.ts index 2caf23f..cd1eafb 100644 --- a/src/builders/remote/builder.ts +++ b/src/builders/remote/builder.ts @@ -29,6 +29,7 @@ import { } from '@softarc/native-federation/internal'; import { createAngularBuildAdapter } from '../../tools/esbuild/angular-esbuild-adapter.js'; +import { withDiskCaseWorkspaceRoot } from '../../utils/disk-case.js'; import { checkForInvalidImports } from '../../utils/check-for-invalid-imports.js'; import { federationSourceFiles } from '../../utils/federation-source-files.js'; @@ -52,8 +53,11 @@ import { export async function* runRemoteBuilder( nfBuilderOptions: NfRemoteBuilderSchema & NfRemoteInternalOptions, - context: BuilderContext + builderContext: BuilderContext ): AsyncIterable { + // One root for the whole invocation — see withDiskCaseWorkspaceRoot. + const context = withDiskCaseWorkspaceRoot(builderContext); + const federationTsConfig = nfBuilderOptions.tsConfig; const outputBase = nfBuilderOptions.outputPath ?? `dist/${context.target!.project}`; const browserOutputPath = path.join(outputBase, 'browser'); diff --git a/src/tools/esbuild/angular-bundler.spec.ts b/src/tools/esbuild/angular-bundler.spec.ts index 6d90931..8db0a78 100644 --- a/src/tools/esbuild/angular-bundler.spec.ts +++ b/src/tools/esbuild/angular-bundler.spec.ts @@ -115,4 +115,24 @@ describe('createAngularEsbuildContext', () => { expect(updateFederationTsConfig).not.toHaveBeenCalled(); }); + + // #117: left relative, esbuild resolves these through its own working directory, which need + // not agree with the root the TypeScript program was built from. + it('anchors workspace-root-relative entry points on the workspace root', async () => { + await createAngularEsbuildContext(makeOptions()); + + expect(lastBuildOptions().entryPoints).toEqual([ + { in: path.join(workspaceRoot, 'apps/example/src/main.ts'), out: 'main' }, + ]); + }); + + // Core hands shared mappings over absolute already. + it('leaves an already-absolute entry point untouched', async () => { + const absolute = path.join(workspaceRoot, 'libs', 'ui', 'src', 'index.ts'); + await createAngularEsbuildContext( + makeOptions({ entryPoints: [{ fileName: absolute, outName: 'ui.js' }] }) + ); + + expect(lastBuildOptions().entryPoints).toEqual([{ in: absolute, out: 'ui' }]); + }); }); diff --git a/src/tools/esbuild/angular-bundler.ts b/src/tools/esbuild/angular-bundler.ts index 759dab4..025eb81 100644 --- a/src/tools/esbuild/angular-bundler.ts +++ b/src/tools/esbuild/angular-bundler.ts @@ -147,7 +147,11 @@ export async function createAngularEsbuildContext(options: NormalizedContextOpti const config: esbuild.BuildOptions = { entryPoints: entryPoints.map(ep => ({ - in: ep.fileName, + // Anchored on workspaceRoot rather than left relative (core hands exposes over + // workspace-root-relative): esbuild resolves relative entry points through its own + // working directory, which need not be the root the TypeScript program — and with it + // the compiler plugin's cache keys — was built from. + in: path.isAbsolute(ep.fileName) ? ep.fileName : path.join(workspaceRoot, ep.fileName), out: path.parse(ep.outName).name, })), outdir, diff --git a/src/utils/disk-case.spec.ts b/src/utils/disk-case.spec.ts new file mode 100644 index 0000000..6360c08 --- /dev/null +++ b/src/utils/disk-case.spec.ts @@ -0,0 +1,98 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +import type { BuilderContext } from '@angular-devkit/architect'; + +import { toDiskCase, withDiskCaseWorkspaceRoot } from './disk-case.js'; + +vi.mock('fs'); + +function mockNativeRealpath(impl: (p: string) => string): void { + // vi.mock('fs') stubs realpathSync but not the `.native` property hanging off it. + vi.mocked(fs).realpathSync = Object.assign(vi.fn(), { + native: vi.fn(impl), + }) as unknown as typeof fs.realpathSync; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('toDiskCase', () => { + // The reported case: Nx inherits `c:\…` from the shell while the filesystem stores `C:\…`. + it('adopts the on-disk spelling when only the case differs', () => { + mockNativeRealpath(() => 'C:\\ws\\project'); + + expect(toDiskCase('c:\\ws\\project')).toBe(path.normalize('C:\\ws\\project')); + }); + + it('accepts a correction that also differs in separator style', () => { + mockNativeRealpath(() => 'C:/ws/project'); + + expect(toDiskCase('c:\\ws\\project')).toBe(path.normalize('C:/ws/project')); + }); + + it('ignores a trailing slash when deciding whether the paths are the same', () => { + mockNativeRealpath(() => 'C:/ws/project'); + + expect(toDiskCase('c:/ws/project/')).toBe(path.normalize('C:/ws/project')); + }); + + // A symlinked workspace root must stay on the path it was handed: npm-linked and pnpm + // setups resolve to a different directory entirely, not to a re-cased one. + it('keeps the input when realpath resolves to a different directory', () => { + mockNativeRealpath(() => '/real/checkout'); + + expect(toDiskCase('/links/project')).toBe('/links/project'); + }); + + it('keeps the input when realpath throws', () => { + mockNativeRealpath(() => { + throw new Error('ENOENT'); + }); + + expect(toDiskCase('/gone')).toBe('/gone'); + }); + + it('is a no-op when the spelling already matches', () => { + mockNativeRealpath(p => p); + + expect(toDiskCase('/ws/project')).toBe('/ws/project'); + }); +}); + +describe('withDiskCaseWorkspaceRoot', () => { + function contextWith(workspaceRoot: string) { + return { + workspaceRoot, + target: { project: 'example' }, + logger: { warn: vi.fn() }, + getProjectMetadata: async () => ({ root: 'apps/example' }), + } as unknown as BuilderContext; + } + + it('returns a context carrying the on-disk spelling of the root', () => { + mockNativeRealpath(() => 'C:\\ws'); + const context = contextWith('c:\\ws'); + + expect(withDiskCaseWorkspaceRoot(context).workspaceRoot).toBe(path.normalize('C:\\ws')); + }); + + it('keeps the rest of the context reachable', async () => { + mockNativeRealpath(() => 'C:\\ws'); + const context = contextWith('c:\\ws'); + + const derived = withDiskCaseWorkspaceRoot(context); + + expect(derived.target).toBe(context.target); + expect(derived.logger).toBe(context.logger); + await expect(derived.getProjectMetadata('example')).resolves.toEqual({ root: 'apps/example' }); + }); + + it('hands back the very same context when nothing needed correcting', () => { + mockNativeRealpath(p => p); + const context = contextWith('/ws'); + + expect(withDiskCaseWorkspaceRoot(context)).toBe(context); + }); +}); diff --git a/src/utils/disk-case.ts b/src/utils/disk-case.ts new file mode 100644 index 0000000..7d6807f --- /dev/null +++ b/src/utils/disk-case.ts @@ -0,0 +1,58 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +import type { BuilderContext } from '@angular-devkit/architect'; + +/** + * Windows reports the same directory under whatever drive-letter case the caller used, so the + * root Nx inherits from the invoking shell can differ by case alone from the one esbuild's own + * working directory and `process.cwd()` produce. Everything downstream compares paths derived + * from it as plain strings — most damagingly the angular-compiler plugin's emitted-file cache, + * whose keys follow the TypeScript program (and thus the workspace root) while its lookups + * follow esbuild. See issue #117. + */ +export function withDiskCaseWorkspaceRoot(context: BuilderContext): BuilderContext { + const workspaceRoot = toDiskCase(context.workspaceRoot); + + if (workspaceRoot === context.workspaceRoot) { + return context; + } + + // Derived, not spread: the architect context's methods close over the original object, and a + // non-enumerable or accessor member would not survive a copy. + return Object.create(context, { + workspaceRoot: { value: workspaceRoot, enumerable: true }, + }) as BuilderContext; +} + +/** + * The on-disk spelling of `p`, but only when it differs from `p` by case alone. `realpath` also + * resolves symlinks, and adopting that result would move npm-linked and pnpm workspaces off the + * path they were handed. Mirrors core's `toDiskCase`, which reads disk through an io port where + * this reaches `fs` directly. + */ +export function toDiskCase(p: string): string { + let real: string; + + try { + // `fs.realpathSync` walks the components of the string it was given and only rewrites the + // ones that are symlinks, so it preserves the caller's casing. Only the native variant + // reports the case as stored on disk. + real = fs.realpathSync.native(p); + } catch { + return p; + } + + if (real === p || !differsOnlyByCase(real, p)) { + return p; + } + + return path.normalize(real); +} + +// Separator style and a trailing slash are not differences worth rejecting a correction over. +const strip = (p: string): string => p.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase(); + +function differsOnlyByCase(a: string, b: string): boolean { + return strip(a) === strip(b); +}