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
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,46 @@ export default withNativeFederation({

When enabled, instead of listing each chunk as a separate shared dependency, chunks are grouped by bundle name in a dedicated `chunks` object. Each shared dependency gets a `bundle` property linking it to its chunk bundle. This results in a smaller `remoteEntry.json` and allows chunks to be skipped if the dependency is not used in the final import map.

### Version-Pinned Share Scopes

A `shareScope` isolates shared dependencies into a named bucket, so packages are only shared between remotes that use the same scope. The `autoShareScope` helper derives that name from a dependency's declared version, letting you pin sharing to a version line without hardcoding the number.

```js
import { withNativeFederation, shareAll, autoShareScope } from '@angular-architects/native-federation/config';

export default withNativeFederation({
// Only share with remotes built against the same Angular minor, e.g. "ng21.1"
shareScope: autoShareScope(),

shared: {
...shareAll({ singleton: true, strictVersion: true, requiredVersion: 'auto' }),
},
});
```

The `level` option controls the granularity of the generated scope (given `@angular/core` is `21.1.4`):

| `level` | Result |
| --------- | ------------ |
| `'major'` | `"ng21"` |
| `'minor'` | `"ng21.1"` (default) |
| `'patch'` | `"ng21.1.4"` |

You can also point it at another package or set a per-dependency scope:

```js
export default withNativeFederation({
shareScope: autoShareScope({ level: 'patch' }),

shared: {
// Override the scope for a single package
rxjs: { singleton: true, shareScope: autoShareScope({ dependency: 'rxjs' }) },
},
});
```

The version is read from `dependencies`, `devDependencies` or `peerDependencies` in your `package.json`. `autoShareScope` throws if the dependency isn't declared, or if the declared version lacks enough segments for the requested `level`.

### SSR and Hydration

We support Angular's SSR and (Incremental) Hydration. Please find [more information here](https://www.angulararchitects.io/blog/ssr-and-hydration-with-native-federation-for-angular/).
Expand Down
8 changes: 7 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
export { share, shareAll, withNativeFederation } from './config/share-utils.js';
export {
share,
shareAll,
withNativeFederation,
autoShareScope,
type PackageShareScopeOptions,
} from './config/share-utils.js';
export { NG_SKIP_LIST } from './config/angular-skip-list.js';
export { shareAngularLocales } from './config/angular-locales.js';
79 changes: 79 additions & 0 deletions src/config/share-utils.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
shareAll,
withNativeFederation,
getDefaultPlatform,
autoShareScope,
SERVER_DEPENDENCIES,
} from './share-utils.js';
import { NG_SKIP_LIST } from './angular-skip-list.js';
Expand All @@ -18,6 +19,16 @@ vi.mock('@softarc/native-federation/config', () => ({
withNativeFederation: (...args: unknown[]) => mockCoreWithNativeFederation(...args),
}));

const mockExistsSync = vi.fn((p: string) => p.endsWith('package.json'));
const mockReadFileSync = vi.fn(() =>
JSON.stringify({ dependencies: { '@angular/core': '^21.1.4' } }),
);

vi.mock('node:fs', () => ({
existsSync: (...args: unknown[]) => mockExistsSync(...(args as [string])),
readFileSync: () => mockReadFileSync(),
}));

afterEach(() => {
vi.clearAllMocks();
});
Expand Down Expand Up @@ -140,6 +151,74 @@ describe('withNativeFederation', () => {
});
});

describe('autoShareScope', () => {
it('derives ng<major>.<minor> from the declared @angular/core version by default', () => {
mockReadFileSync.mockReturnValueOnce(
JSON.stringify({ dependencies: { '@angular/core': '^21.1.4' } }),
);

expect(autoShareScope({ projectPath: '/project' })).toBe('ng21.1');
});

it('pins to the major version at level "major"', () => {
mockReadFileSync.mockReturnValueOnce(
JSON.stringify({ dependencies: { '@angular/core': '^21.1.4' } }),
);

expect(autoShareScope({ level: 'major', projectPath: '/project' })).toBe('ng21');
});

it('pins to the patch version at level "patch"', () => {
mockReadFileSync.mockReturnValueOnce(
JSON.stringify({ dependencies: { '@angular/core': '^21.1.4' } }),
);

expect(autoShareScope({ level: 'patch', projectPath: '/project' })).toBe('ng21.1.4');
});

it('strips range prefixes and prerelease suffixes', () => {
mockReadFileSync.mockReturnValueOnce(
JSON.stringify({ dependencies: { '@angular/core': '~22.0.0-next.3' } }),
);

expect(autoShareScope({ projectPath: '/project' })).toBe('ng22.0');
mockReadFileSync.mockReturnValueOnce(
JSON.stringify({ dependencies: { '@angular/core': '~22.0.0-next.3' } }),
);
expect(autoShareScope({ level: 'patch', projectPath: '/project' })).toBe('ng22.0.0');
});

it('falls back to devDependencies and peerDependencies', () => {
mockReadFileSync.mockReturnValueOnce(
JSON.stringify({ devDependencies: { '@angular/core': '20.2.1' } }),
);
expect(autoShareScope({ projectPath: '/project' })).toBe('ng20.2');

mockReadFileSync.mockReturnValueOnce(
JSON.stringify({ peerDependencies: { '@angular/core': '19.0.0' } }),
);
expect(autoShareScope({ projectPath: '/project' })).toBe('ng19.0');
});

it('throws when @angular/core is not a declared dependency', () => {
mockReadFileSync.mockReturnValueOnce(
JSON.stringify({ dependencies: { rxjs: '7.0.0' } }),
);

expect(() => autoShareScope({ projectPath: '/project' })).toThrow(/@angular\/core/);
});

it('throws when the requested level has no matching version segment', () => {
mockReadFileSync.mockReturnValueOnce(
JSON.stringify({ dependencies: { '@angular/core': '21' } }),
);

expect(() => autoShareScope({ level: 'patch', projectPath: '/project' })).toThrow(
/patch/,
);
});
});

describe('getDefaultPlatform', () => {
it.each(SERVER_DEPENDENCIES)('returns "node" when a server dep (%s) is shared', dep => {
expect(getDefaultPlatform([dep])).toBe('node');
Expand Down
71 changes: 71 additions & 0 deletions src/config/share-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ import {
} from "@softarc/native-federation/config";
import { NG_SKIP_LIST } from "./angular-skip-list.js";
import type { NormalizedSharedExternalsConfig } from "@softarc/native-federation/internal";
import { existsSync, readFileSync } from "node:fs";
import * as path from "node:path";
import { cwd } from "node:process";

export function shareAll(
config: ShareAllExternalsOptions,
Expand Down Expand Up @@ -63,6 +66,74 @@ export function getDefaultPlatform(deps: string[]): "browser" | "node" {
return hasServerDep ? "node" : "browser";
}

export interface PackageShareScopeOptions {
level?: "major" | "minor" | "patch";
/** Package whose version drives the scope. Defaults to `@angular/core`. */
dependency?: string;
/** Directory to start resolving `package.json` from. Defaults to `cwd()`. */
projectPath?: string;
prefix?: string;
}

/**
* Builds a version-pinned share scope (e.g. `"ng21.1"`) from a dependency's
* declared version, so internals are only shared between remotes on the same
* version line.
*
* ```ts
* withNativeFederation({
* shareScope: autoShareScope({ level: 'patch' }), // e.g. "ng21.1.4"
* shared: { '@angular/core': { shareScope: autoShareScope() } }, // "ng21.1"
* });
* ```
*
* @throws when the version can't be resolved or lacks the requested `level`.
*/
export function autoShareScope(opts: PackageShareScopeOptions = {}): string {
const {
level = "minor",
projectPath = cwd(),
dependency = "@angular/core",
prefix = "ng",
} = opts;

let dir = projectPath;
while (
!existsSync(path.join(dir, "package.json")) &&
path.dirname(dir) !== dir
)
dir = path.dirname(dir);

const pkgPath = path.join(dir, "package.json");
const pkg = existsSync(pkgPath)
? JSON.parse(readFileSync(pkgPath, "utf-8"))
: {};
const version =
pkg.dependencies?.[dependency] ??
pkg.devDependencies?.[dependency] ??
pkg.peerDependencies?.[dependency];
const match = version ? /(\d+)(?:\.(\d+))?(?:\.(\d+))?/.exec(version) : null;
if (!match)
throw new Error(
`autoShareScope() could not resolve an '${dependency}' version from ${pkgPath}`,
);

const [, major, minor, patch] = match;
if (level === "major") return `${prefix}${major}`;
if (level === "minor") {
if (minor === undefined)
throw new Error(
`autoShareScope({ level: 'minor' }) could not resolve a minor version from '${version}' in ${pkgPath}`,
);
return `${prefix}${major}.${minor}`;
}
if (minor === undefined || patch === undefined)
throw new Error(
`autoShareScope({ level: 'patch' }) could not resolve a patch version from '${version}' in ${pkgPath}`,
);
return `${prefix}${major}.${minor}.${patch}`;
}

function removeNgLocales(
shared: NormalizedSharedExternalsConfig,
): NormalizedSharedExternalsConfig {
Expand Down
1 change: 1 addition & 0 deletions tsconfig.tsbuildinfo
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"fileNames":[],"fileInfos":[],"root":[],"options":{"composite":true,"declarationMap":true,"importHelpers":true,"module":199,"noEmitOnError":true,"noFallthroughCasesInSwitch":true,"noImplicitOverride":true,"noImplicitReturns":true,"noUncheckedIndexedAccess":true,"noUnusedLocals":true,"noUnusedParameters":true,"skipLibCheck":true,"strict":true,"target":99},"version":"6.0.3"}
Loading