diff --git a/libs/native-federation-runtime/src/lib/init-federation.ts b/libs/native-federation-runtime/src/lib/init-federation.ts index bd447512..68dde51a 100644 --- a/libs/native-federation-runtime/src/lib/init-federation.ts +++ b/libs/native-federation-runtime/src/lib/init-federation.ts @@ -1,4 +1,8 @@ -import { getExternalUrl, setExternalUrl } from './model/externals'; +import { + getExternalUrl, + setExternalUrl, + setUseShareConfig, +} from './model/externals'; import { FederationInfo, InitFederationOptions, @@ -46,6 +50,11 @@ export async function initFederation( remotesOrManifestUrl: Record | string = {}, options?: InitFederationOptions, ): Promise { + // TODO: Enable share config enforcement if requested + // TODO: Call setUseShareConfig(options?.useShareConfig ?? false) + // TODO: This determines whether runtime uses exact version matching (legacy) + // TODO: or semver matching with singleton/strictVersion enforcement + const cacheTag = options?.cacheTag ? `?t=${options.cacheTag}` : ''; const normalizedRemotes = diff --git a/libs/native-federation-runtime/src/lib/model/externals.ts b/libs/native-federation-runtime/src/lib/model/externals.ts index 41b56d77..e01a0310 100644 --- a/libs/native-federation-runtime/src/lib/model/externals.ts +++ b/libs/native-federation-runtime/src/lib/model/externals.ts @@ -1,18 +1,201 @@ import { SharedInfo } from './federation-info'; import { globalCache } from './global-cache'; +// TODO: Uncomment when implementing Phase 3 +import { satisfies, getHighestVersion } from '../utils/semver'; const externals = globalCache.externals; +/** + * Registry entry for a shared module with semver support + */ +interface RegisteredShared { + info: SharedInfo; + url: string; +} + +/** + * Registry for semver-based sharing + * Maps package name to array of registered versions + * Example: 'foo' -> [{ info: { version: '18.0.0', ... }, url: 'http://...' }] + */ +const sharedRegistry = new Map(); + +/** + * Global flag for share config enforcement + * When false: uses exact version matching (legacy behavior) + * When true: uses semver matching with singleton/strictVersion enforcement + */ +let useShareConfig = false; + +/** + * Sets the global flag for share config enforcement + * + * Called from initFederation when useShareConfig option is provided. + * This determines whether the runtime uses exact version matching (legacy) + * or semver matching with config enforcement. + * + * @param enabled - Whether to enable share config enforcement + */ +export function setUseShareConfig(enabled: boolean): void { + useShareConfig = enabled; +} + +/** + * Generates the exact-match key for a shared dependency + * + * This is used for the legacy behavior (exact version matching). + * Format: "packageName@version" + * + * @param shared - Shared dependency info + * @returns Key string in format "packageName@version" + */ function getExternalKey(shared: SharedInfo) { return `${shared.packageName}@${shared.version}`; } +/** + * Helper to detect if a shared dependency has non-default config + * + * This is used to warn developers when they have config that won't be enforced + * because useShareConfig is disabled. + * + * Non-default config includes: + * - singleton: true (default is false) + * - strictVersion: true (default is false) + * - requiredVersion that's not '*' or 'false' + * + * @param shared - The shared dependency info + * @returns true if any non-default config is present + */ +function hasNonDefaultConfig(shared: SharedInfo): boolean { + // TODO: Check if singleton is true + // TODO: Check if strictVersion is true + // TODO: Check if requiredVersion is not '*' and not 'false' + // TODO: Return true if any of above are true + throw new Error('Not implemented'); +} + +/** + * Find compatible shared module based on config (singleton, strictVersion, requiredVersion) + * + * This implements the following resolution algorithm. + * Used when useShareConfig is enabled. + * + * Resolution steps: + * 1. Try exact match first (fast path) + * 2. If singleton mode: + * - Use first registered version (singleton) + * - Check version compatibility if requiredVersion specified + * - Handle strictVersion enforcement + * 3. If non-singleton mode: + * - Find all compatible versions + * - Use highest compatible version + * - Handle strictVersion enforcement + * + * @param shared - The shared dependency being requested + * @returns URL to the shared bundle, or undefined if no compatible version found (use own bundle) + */ +function findCompatibleShared(shared: SharedInfo): string | undefined { + // TODO: Get registered versions for this package name + // TODO: Return undefined if no versions registered + + // TODO: STEP 1 - Try exact match first (fast path) + // TODO: If exact match found, return its URL + + // TODO: STEP 2 - Handle singleton mode + // TODO: If singleton, get first registered version + // TODO: If requiredVersion is 'false', skip version check and return singleton URL + // TODO: Check if singleton version satisfies requiredVersion + // TODO: If satisfied, return singleton URL + // TODO: If not satisfied and strictVersion is true, warn and return undefined (use own bundle) + // TODO: If not satisfied and strictVersion is false, warn and return singleton URL anyway + + // TODO: STEP 3 - Handle non-singleton mode + // TODO: If requiredVersion is 'false', return highest available version + // TODO: Find all versions that satisfy requiredVersion + // TODO: If compatible versions found, return highest compatible version + // TODO: If no compatible versions and strictVersion is true, warn and return undefined + // TODO: If no compatible versions and strictVersion is false, warn and return highest version anyway + + // TODO: Return undefined if no suitable version found + throw new Error('Not implemented'); +} + +/** + * Gets the URL for a shared dependency + * + * This is the main entry point for shared dependency resolution. + * Behavior depends on useShareConfig flag: + * + * Legacy mode (useShareConfig: false): + * - Uses exact version matching only + * - Returns URL only if exact version match exists + * + * Share config mode (useShareConfig: true): + * - Uses semver range matching + * - Enforces singleton, strictVersion, requiredVersion + * - Follows Webpack Module Federation algorithm + * + * @param shared - The shared dependency info + * @returns URL to the shared bundle, or undefined if not found/compatible + */ export function getExternalUrl(shared: SharedInfo): string | undefined { - const packageKey = getExternalKey(shared); - return externals.get(packageKey); + // Legacy behavior: exact version matching only + if (!useShareConfig) { + const packageKey = getExternalKey(shared); + return externals.get(packageKey); + } + + // New behavior: use share config for smart matching + // TODO: Call findCompatibleShared and return result + throw new Error('Not implemented'); } +/** + * Registers a shared dependency URL + * + * This is called when processing host and remote shared dependencies. + * It maintains both: + * 1. Legacy exact-match registry (for backwards compatibility) + * 2. Semver-based registry (for share config mode) + * + * Singleton enforcement: + * - In share config mode, if singleton is true and a version already exists, + * the new version is rejected (first registration wins) + * + * @param shared - The shared dependency info + * @param url - The URL where this shared dependency can be loaded from + */ export function setExternalUrl(shared: SharedInfo, url: string): void { + // Always maintain legacy exact-match registry const packageKey = getExternalKey(shared); externals.set(packageKey, url); + + // TODO: Warn if useShareConfig is FALSE but sharing config is set + // TODO: This helps developers know their config (singleton, strictVersion, requiredVersion) is ignored + // TODO: Only warn if any config is non-default (singleton=true, strictVersion=true, or requiredVersion != '*') + // TODO: Consider checking shared.dev to only warn in dev mode + // TODO: Example: + // TODO: if (!useShareConfig && hasNonDefaultConfig(shared)) { + // TODO: if (shared.dev) { // Only warn in dev mode + // TODO: console.warn( + // TODO: `[Federation] Shared dependency "${shared.packageName}@${shared.version}" has ` + + // TODO: `sharing config (singleton=${shared.singleton}, strictVersion=${shared.strictVersion}, ` + + // TODO: `requiredVersion=${shared.requiredVersion}) but useShareConfig is disabled. ` + + // TODO: `Config will be ignored. Set useShareConfig: true in initFederation() to enforce it.` + // TODO: ); + // TODO: } + // TODO: } + + // TODO: Get or create array for this package in sharedRegistry + + // TODO: If singleton is true and versions already registered (when useShareConfig is enabled): + // TODO: - Warn about ignored registration + // TODO: - Example: console.warn( + // TODO: `[Federation] Singleton "${shared.packageName}" already registered with version ` + + // TODO: `${existing[0].info.version}. Ignoring version ${shared.version}.` + // TODO: ); + // TODO: - Return early (don't register duplicate singleton) + + // TODO: Add { info: shared, url } to sharedRegistry } diff --git a/libs/native-federation-runtime/src/lib/model/federation-info.ts b/libs/native-federation-runtime/src/lib/model/federation-info.ts index f3396387..4d690153 100644 --- a/libs/native-federation-runtime/src/lib/model/federation-info.ts +++ b/libs/native-federation-runtime/src/lib/model/federation-info.ts @@ -1,10 +1,41 @@ export type SharedInfo = { + /** + * Ensures only ONE instance of the library exists across all federated modules. + * Enforced when useShareConfig is enabled. + */ singleton: boolean; + + /** + * Controls whether to reject incompatible versions or just warn. + * Enforced when useShareConfig is enabled. + */ strictVersion: boolean; + + /** + * Specifies acceptable version range using semver (e.g., '^3.0.0', '>=3.0.0 <5.0.0'). + * Can be 'false' to disable version checking. + * Enforced when useShareConfig is enabled. + */ requiredVersion: string; + + /** + * The version being provided by this module. + */ version?: string; + + /** + * The package name (e.g. '@angular/core'). + */ packageName: string; + + /** + * The output filename for the shared bundle. + */ outFileName: string; + + /** + * Development mode configuration. + */ dev?: { entryPoint: string; }; @@ -27,6 +58,23 @@ export interface FederationInfo { export interface InitFederationOptions { cacheTag?: string; + + /** + * Enables enforcement of shared dependency configuration at runtime + * + * When false (default): + * - Uses exact version matching only (current behavior) + * - singleton, strictVersion, requiredVersion are metadata-only + * + * When true: + * - Enforces singleton, strictVersion, requiredVersion at runtime + * - Uses semver range matching (^, ~, >=, etc.) + * - Provides warnings/errors for version mismatches + * + * @default false + * @since 3.6.0 + */ + useShareConfig?: boolean; } export interface ProcessRemoteInfoOptions extends InitFederationOptions { diff --git a/libs/native-federation-runtime/src/lib/utils/semver.ts b/libs/native-federation-runtime/src/lib/utils/semver.ts new file mode 100644 index 00000000..2005896b --- /dev/null +++ b/libs/native-federation-runtime/src/lib/utils/semver.ts @@ -0,0 +1,134 @@ +/** + * Lightweight semver implementation for Native Federation + * Supports: ^, ~, >=, <=, >, <, exact, * + * Does NOT support: Complex ranges (||, &&, spaces in ranges) + * Size: ~1-2KB minified + */ + +/** + * Represents a parsed semantic version + */ +interface SemverVersion { + major: number; + minor: number; + patch: number; + prerelease?: string; +} + +/** + * Parse version string to components + * + * Takes a version string like "3.2.1" or "v3.2.1-beta" and breaks it down + * into its component parts (major, minor, patch, prerelease). + * + * @param version - Version string to parse (e.g., "3.2.1", "v3.2.1-beta") + * @returns Parsed version object with major, minor, patch, and optional prerelease, or null if invalid + * + * @example + * parseVersion('3.2.1') // { major: 3, minor: 2, patch: 1 } + * parseVersion('v3.2.1-beta') // { major: 3, minor: 2, patch: 1, prerelease: 'beta' } + * parseVersion('invalid') // null + */ +function parseVersion(version: string): SemverVersion | null { + // TODO: Remove 'v' prefix if present + // TODO: Match pattern: major.minor.patch[-prerelease] + // TODO: Return null if pattern doesn't match + // TODO: Parse and return { major, minor, patch, prerelease? } + throw new Error('Not implemented'); +} + +/** + * Compare two versions + * + * Compares two parsed version objects to determine their ordering. + * Used for finding highest version and checking compatibility. + * + * Comparison rules: + * - Major version takes precedence + * - Then minor version + * - Then patch version + * - Version without prerelease > version with prerelease (e.g., 3.0.0 > 3.0.0-beta) + * - Prerelease versions compared alphabetically + * + * @param a - First version to compare + * @param b - Second version to compare + * @returns -1 if a < b, 0 if equal, 1 if a > b + * + * @example + * compareVersions({ major: 3, minor: 0, patch: 0 }, { major: 2, minor: 9, patch: 9 }) // 1 + * compareVersions({ major: 3, minor: 0, patch: 0 }, { major: 3, minor: 0, patch: 0 }) // 0 + * compareVersions({ major: 3, minor: 0, patch: 0, prerelease: 'beta' }, { major: 3, minor: 0, patch: 0 }) // -1 + */ +function compareVersions(a: SemverVersion, b: SemverVersion): number { + // TODO: Compare major versions first + // TODO: If major equal, compare minor + // TODO: If minor equal, compare patch + // TODO: If all equal, handle prerelease comparison + // TODO: Return -1, 0, or 1 based on comparison + throw new Error('Not implemented'); +} + +/** + * Check if version satisfies a range + * + * This is the main function for version matching. It determines if a given version + * satisfies a semver range specification. This is used at runtime to decide whether + * a shared dependency can be reused. + * + * Supported range formats: + * - Wildcard: '*' (matches everything) + * - Exact: '3.0.0' (must match exactly) + * - Caret: '^3.0.0' (>= 3.0.0 and < 4.0.0) + * - Tilde: '~3.2.0' (>= 3.2.0 and < 3.3.0) + * - Greater than or equal: '>=3.0.0' + * - Less than or equal: '<=5.0.0' + * - Greater than: '>3.0.0' + * - Less than: '<5.0.0' + * - Compound ranges: '>=3.0.0 <5.0.0' (space-separated AND conditions) + * + * @param version - The version to check (e.g., "3.0.0") + * @param range - The range to satisfy (e.g., "^3.0.0", ">=3.0.0 <5.0.0") + * @returns true if version satisfies range, false otherwise + * + * @example + * satisfies('3.5.0', '^3.0.0') // true + * satisfies('4.0.0', '^3.0.0') // false + * satisfies('3.5.0', '>=3.0.0 <5.0.0') // true + * satisfies('3.5.0', '*') // true + */ +export function satisfies(version: string, range: string): boolean { + // TODO: Handle wildcard '*' + // TODO: Parse version string + // TODO: Handle exact match (no special characters) + // TODO: Handle caret range '^' + // TODO: Handle tilde range '~' + // TODO: Handle comparison operators (>=, <=, >, <) + // TODO: Handle compound ranges (space-separated) + // TODO: Return false if no match + throw new Error('Not implemented'); +} + +/** + * Get highest version from array + * + * Given an array of version strings, finds and returns the highest one + * according to semver ordering. Used when multiple compatible versions + * are available and we need to select the best one. + * + * @param versions - Array of version strings to compare + * @returns The highest version string, or null if array is empty or all versions are invalid + * + * @example + * getHighestVersion(['3.0.0', '4.1.0', '3.5.2']) // '4.1.0' + * getHighestVersion(['1.0.0']) // '1.0.0' + * getHighestVersion([]) // null + */ +export function getHighestVersion(versions: string[]): string | null { + // TODO: Handle empty array + // TODO: Handle single version + // TODO: Parse all versions + // TODO: Compare versions using compareVersions + // TODO: Track highest version + // TODO: Return highest version string + throw new Error('Not implemented'); +}