diff --git a/README.md b/README.md index d19b001..3323090 100644 --- a/README.md +++ b/README.md @@ -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/). diff --git a/src/config.ts b/src/config.ts index e2bea9d..10be667 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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'; diff --git a/src/config/share-utils.spec.ts b/src/config/share-utils.spec.ts index 1fbf831..da3adfd 100644 --- a/src/config/share-utils.spec.ts +++ b/src/config/share-utils.spec.ts @@ -3,6 +3,7 @@ import { shareAll, withNativeFederation, getDefaultPlatform, + autoShareScope, SERVER_DEPENDENCIES, } from './share-utils.js'; import { NG_SKIP_LIST } from './angular-skip-list.js'; @@ -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(); }); @@ -140,6 +151,74 @@ describe('withNativeFederation', () => { }); }); +describe('autoShareScope', () => { + it('derives ng. 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'); diff --git a/src/config/share-utils.ts b/src/config/share-utils.ts index 67ad339..405e5c9 100644 --- a/src/config/share-utils.ts +++ b/src/config/share-utils.ts @@ -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, @@ -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 { diff --git a/tsconfig.tsbuildinfo b/tsconfig.tsbuildinfo new file mode 100644 index 0000000..6f82b2b --- /dev/null +++ b/tsconfig.tsbuildinfo @@ -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"} \ No newline at end of file