diff --git a/libs/native-federation-core/src/lib/config/share-utils.ts b/libs/native-federation-core/src/lib/config/share-utils.ts index 624c263c..5689aef1 100644 --- a/libs/native-federation-core/src/lib/config/share-utils.ts +++ b/libs/native-federation-core/src/lib/config/share-utils.ts @@ -19,7 +19,7 @@ import { logger } from '../utils/logger'; import { KeyValuePair, - resolveWildcardKeys, + resolvePackageJsonExportsWildcard, } from '../utils/resolve-wildcard-keys'; let inferVersion = false; @@ -319,7 +319,7 @@ function resolveGlobSecondaries( let items: Array = []; if (key.includes('*')) { if (!resolveGlob) return items; - const expanded = resolveWildcardKeys(key, entry, libPath); + const expanded = resolvePackageJsonExportsWildcard(key, entry, libPath); items = expanded .map((e) => ({ key: path.join(parent, e.key), diff --git a/libs/native-federation-core/src/lib/config/with-native-federation.ts b/libs/native-federation-core/src/lib/config/with-native-federation.ts index 10a2b89d..c6432001 100644 --- a/libs/native-federation-core/src/lib/config/with-native-federation.ts +++ b/libs/native-federation-core/src/lib/config/with-native-federation.ts @@ -108,7 +108,7 @@ function normalizeShared( function normalizeSharedMappings( config: FederationConfig, - skip: PreparedSkipList, + skipList: PreparedSkipList, ): Array { const rootTsConfigPath = findRootTsConfigJson(); @@ -117,13 +117,7 @@ function normalizeSharedMappings( sharedMappings: config.sharedMappings, }); - const result = paths.filter( - (p) => !isInSkipList(p.key, skip) && !p.key.includes('*'), - ); - - if (paths.find((p) => p.key.includes('*'))) { - logger.warn('Sharing mapped paths with wildcards (*) not supported'); - } + const result = paths.filter((p) => !isInSkipList(p.key, skipList)); return result; } diff --git a/libs/native-federation-core/src/lib/utils/mapped-paths.d.ts b/libs/native-federation-core/src/lib/utils/mapped-paths.d.ts deleted file mode 100644 index a9d47dea..00000000 --- a/libs/native-federation-core/src/lib/utils/mapped-paths.d.ts +++ /dev/null @@ -1,14 +0,0 @@ -export interface MappedPath { - key: string; - path: string; -} -export interface GetMappedPathsOptions { - rootTsConfigPath: string; - sharedMappings?: string[]; - rootPath?: string; -} -export declare function getMappedPaths({ - rootTsConfigPath, - sharedMappings, - rootPath, -}: GetMappedPathsOptions): Array; diff --git a/libs/native-federation-core/src/lib/utils/mapped-paths.ts b/libs/native-federation-core/src/lib/utils/mapped-paths.ts index 5a5a4137..833861f0 100644 --- a/libs/native-federation-core/src/lib/utils/mapped-paths.ts +++ b/libs/native-federation-core/src/lib/utils/mapped-paths.ts @@ -1,6 +1,8 @@ import * as path from 'path'; import * as fs from 'fs'; import * as JSON5 from 'json5'; +import { logger } from '../utils/logger'; +import { resolveTsConfigWildcard } from './resolve-wildcard-keys'; export interface MappedPath { key: string; @@ -29,11 +31,14 @@ export function getMappedPaths({ if (!rootPath) { rootPath = path.normalize(path.dirname(rootTsConfigPath)); } - const shareAll = !sharedMappings; + const shareAllMappings = !sharedMappings; if (!sharedMappings) { sharedMappings = []; } + const globSharedMappings = sharedMappings + .filter((m) => m.endsWith('*')) + .map((m) => m.slice(0, -1)); const tsConfig = JSON5.parse( fs.readFileSync(rootTsConfigPath, { encoding: 'utf-8' }), @@ -46,14 +51,32 @@ export function getMappedPaths({ } for (const key in mappings) { - const libPath = path.normalize(path.join(rootPath, mappings[key][0])); + if (mappings[key].length > 1) { + logger.warn( + '[shared-mapping][' + + key + + '] A mapping path with more than 1 entryPoint is currently not supported, falling back to the first path.', + ); + } + const libPaths = key.includes('*') + ? resolveTsConfigWildcard(key, mappings[key][0], rootPath).map( + ({ key, value }) => ({ + key, + path: path.normalize(path.join(rootPath, value)), + }), + ) + : [{ key, path: path.normalize(path.join(rootPath, mappings[key][0])) }]; - if (sharedMappings.includes(key) || shareAll) { - result.push({ - key, - path: libPath, + libPaths + .filter( + (mapping) => + shareAllMappings || + sharedMappings.includes(mapping.key) || + globSharedMappings.some((m) => mapping.key.startsWith(m)), + ) + .forEach((mapping) => { + result.push(mapping); }); - } } return result; diff --git a/libs/native-federation-core/src/lib/utils/resolve-wildcard-keys.ts b/libs/native-federation-core/src/lib/utils/resolve-wildcard-keys.ts index cbe46f7e..3ee6282c 100644 --- a/libs/native-federation-core/src/lib/utils/resolve-wildcard-keys.ts +++ b/libs/native-federation-core/src/lib/utils/resolve-wildcard-keys.ts @@ -1,62 +1,138 @@ import fg from 'fast-glob'; +import * as fs from 'fs'; +import * as path from 'path'; export type KeyValuePair = { key: string; value: string; }; -function escapeRegex(str: string) { - return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} +// TypeScript's module resolution for directories checks these in order +// @see https://www.typescriptlang.org/docs/handbook/modules/theory.html#module-resolution +const TS_INDEX_FILES = [ + 'index.ts', + 'index.tsx', + 'index.mts', + 'index.cts', + 'index.d.ts', + 'index.js', + 'index.jsx', + 'index.mjs', + 'index.cjs', +]; + +/** + * Resolves tsconfig wildcard paths. + * + * In tsconfig.json, paths like `@features/*` → `libs/features/src/*` work as follows: + * - The `*` captures a single path segment (the module name) + * - When importing `@features/feature-a`, TypeScript captures `feature-a` + * - It then replaces `*` in the value pattern: `libs/features/src/feature-a` + * + * For discovery, we find all directories at the wildcard position that TypeScript + * would recognize as valid modules (directories with index files or package.json). + * + * @see https://www.typescriptlang.org/tsconfig/#paths + */ +export function resolveTsConfigWildcard( + keyPattern: string, + valuePattern: string, + cwd: string, +): KeyValuePair[] { + const normalizedPattern = valuePattern.replace(/^\.?\/+/, ''); -// Convert package.json exports pattern to glob pattern -// * in exports means "one segment", but for glob we need **/* for deep matching -// Src: https://hirok.io/posts/package-json-exports#exposing-all-package-files -function convertExportsToGlob(pattern: string) { - return pattern.replace(/(? + fs.existsSync(path.join(fullPath, indexFile)), + ); + + if (!indexFile) continue; + modulePath = path.join(modulePath, indexFile); + } else if (!fullPathStats.isFile()) { + continue; + } + + const key = keyPattern.replace('*', entry); + + keys.push({ + key, + value: modulePath, + }); } - return result; + + return keys; } -export function resolveWildcardKeys( +/** + * Resolves package.json exports wildcard patterns. + * + * In package.json exports, patterns like `./features/*.js` → `./src/features/*.js` work as follows: + * - The `*` is a literal string replacement that can include path separators + * - Importing `pkg/features/a/b.js` captures `a/b` and replaces `*` → `./src/features/a/b.js` + * - This matches actual files, not directories + * + * @see https://nodejs.org/api/packages.html#subpath-patterns + */ +export function resolvePackageJsonExportsWildcard( keyPattern: string, valuePattern: string, cwd: string, ): KeyValuePair[] { const normalizedPattern = valuePattern.replace(/^\.?\/+/, ''); - const globPattern = convertExportsToGlob(normalizedPattern); + const asteriskIndex = normalizedPattern.indexOf('*'); + if (asteriskIndex === -1) { + return []; + } - const regex = compilePattern(normalizedPattern); + const prefix = normalizedPattern.substring(0, asteriskIndex); + const suffix = normalizedPattern.substring(asteriskIndex + 1); - const files = fg.sync(globPattern, { + // fast-glob requires **/* pattern for matching files at any depth + const files = fg.sync(prefix + '**/*' + suffix, { cwd, onlyFiles: true, deep: Infinity, @@ -67,11 +143,14 @@ export function resolveWildcardKeys( for (const file of files) { const relPath = file.replace(/\\/g, '/').replace(/^\.\//, ''); - const wildcards = relPath.match(regex); - if (!wildcards) continue; + const captured = suffix + ? relPath.slice(prefix.length, -suffix.length) + : relPath.slice(prefix.length); + + const key = keyPattern.replace('*', captured); keys.push({ - key: withoutWildcard(keyPattern, wildcards.slice(1)), + key, value: relPath, }); } diff --git a/libs/native-federation/README.md b/libs/native-federation/README.md index 0b0aa78a..885df3e4 100644 --- a/libs/native-federation/README.md +++ b/libs/native-federation/README.md @@ -233,6 +233,34 @@ module.exports = withNativeFederation({ > Our `init` schematic shown above generates this file for you. +### Sharing Mapped Paths and Mapping Versions + +In monorepo setups, mapped paths from your `tsconfig` are shared by default. You can restrict this behavior via `sharedMappings`. + +If you additionally want to provide version metadata for mapped paths, enable `features.mappingVersion`. + +```javascript +const { withNativeFederation, shareAll } = require('@angular-architects/native-federation/config'); + +module.exports = withNativeFederation({ + shared: { + ...shareAll({ + singleton: true, + strictVersion: true, + requiredVersion: 'auto', + }), + }, + sharedMappings: ['@my-org/auth-lib', '@my-org/ui/*'], + features: { + mappingVersion: true, + }, +}); +``` + +If `sharedMappings` is omitted, all discovered mapped paths are shared. For more details, see [FAQ for Sharing Libraries](./docs/share-faq.md). + +`sharedMappings` reads mapped paths from the workspace root tsconfig file: `tsconfig.base.json` if it exists, otherwise `tsconfig.json`. + ### Initializing the Host When bootstrapping the host (shell), Native Federation (`projects\shell\src\main.ts`) is initialized: diff --git a/libs/native-federation/docs/share-faq.md b/libs/native-federation/docs/share-faq.md index e751ebe6..7f0bed04 100644 --- a/libs/native-federation/docs/share-faq.md +++ b/libs/native-federation/docs/share-faq.md @@ -14,6 +14,59 @@ skip: [ This speeds up your build and the initial page load. Also, it gives you automatic page reloads within your application when changing the source code. +## Sharing Selected Mapped Paths with `sharedMappings` + +In Nx/monorepo setups, Native Federation shares all libraries from your `tsconfig` path mappings by default. + +If you only want to share selected mapped paths, you can use `sharedMappings` in your `federation.config.js`: + +```js +module.exports = withNativeFederation({ + shared: { + ...shareAll({ + singleton: true, + strictVersion: true, + requiredVersion: 'auto', + }), + }, + sharedMappings: ['@my-org/auth-lib', '@my-org/ui/*'], +}); +``` + +Notes: + +- `sharedMappings` is optional. If you omit it, all mapped paths are shared. +- You can use wildcard suffixes (for example, `@my-org/ui/*`) to include multiple mapped paths. +- `skip` still applies and can be used to exclude mapped paths even if they were selected via `sharedMappings`. +- Mapped paths are read from the workspace root tsconfig file: `tsconfig.base.json` if present, otherwise `tsconfig.json`. +- The workspace root is detected by searching upward from the current working directory until a `package.json` is found. + +## Mapping Versions via `features.mappingVersion` + +If your mapped paths point to libraries that have a `package.json` with a `version`, you can include this version in federation metadata by enabling `features.mappingVersion`. + +```js +module.exports = withNativeFederation({ + shared: { + ...shareAll({ + singleton: true, + strictVersion: true, + requiredVersion: 'auto', + }), + }, + sharedMappings: ['@my-org/auth-lib', '@my-org/ui/*'], + features: { + mappingVersion: true, + }, +}); +``` + +Notes: + +- Default is `false`. +- If enabled, Native Federation tries to read the version from the mapped library's nearest `package.json`. +- If no `package.json` version is found for a mapping, the version remains empty. + ## Using Multiple Framework Versions After compiling an Angular application, the compilation is accessing Angular's private API. As private APIs do not align with semver, there is no guarantee that your compiled application works with a different version of Angular. Even having a different minor or patch version at runtime can lead to issues.