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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 5 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 6 additions & 1 deletion src/builders/build/builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -128,8 +129,12 @@ const createInternalAngularBuilder =

export async function* runBuilder(
nfBuilderOptions: NfBuilderSchema & NfInternalOptions,
context: BuilderContext,
builderContext: BuilderContext,
): AsyncIterable<BuilderOutput> {
// 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(
Expand Down
6 changes: 5 additions & 1 deletion src/builders/remote/builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -52,8 +53,11 @@ import {

export async function* runRemoteBuilder(
nfBuilderOptions: NfRemoteBuilderSchema & NfRemoteInternalOptions,
context: BuilderContext
builderContext: BuilderContext
): AsyncIterable<BuilderOutput> {
// 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');
Expand Down
20 changes: 20 additions & 0 deletions src/tools/esbuild/angular-bundler.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }]);
});
});
6 changes: 5 additions & 1 deletion src/tools/esbuild/angular-bundler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
98 changes: 98 additions & 0 deletions src/utils/disk-case.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
58 changes: 58 additions & 0 deletions src/utils/disk-case.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Loading