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
4 changes: 2 additions & 2 deletions libs/native-federation-core/src/lib/config/share-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { logger } from '../utils/logger';

import {
KeyValuePair,
resolveWildcardKeys,
resolvePackageJsonExportsWildcard,
} from '../utils/resolve-wildcard-keys';

let inferVersion = false;
Expand Down Expand Up @@ -319,7 +319,7 @@ function resolveGlobSecondaries(
let items: Array<string | KeyValuePair> = [];
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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ function normalizeShared(

function normalizeSharedMappings(
config: FederationConfig,
skip: PreparedSkipList,
skipList: PreparedSkipList,
): Array<MappedPath> {
const rootTsConfigPath = findRootTsConfigJson();

Expand All @@ -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;
}
Expand Down
14 changes: 0 additions & 14 deletions libs/native-federation-core/src/lib/utils/mapped-paths.d.ts

This file was deleted.

37 changes: 30 additions & 7 deletions libs/native-federation-core/src/lib/utils/mapped-paths.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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' }),
Expand All @@ -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;
Expand Down
153 changes: 116 additions & 37 deletions libs/native-federation-core/src/lib/utils/resolve-wildcard-keys.ts
Original file line number Diff line number Diff line change
@@ -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(/(?<!\*)\*(?!\*)/g, '**/*');
}
const asteriskIndex = normalizedPattern.indexOf('*');
if (asteriskIndex === -1) {
return [];
}

function compilePattern(pattern: string) {
const tokens = pattern.split(/(\*\*|\*)/);
const regexParts = [];
const prefix = normalizedPattern.substring(0, asteriskIndex);
const suffix = normalizedPattern.substring(asteriskIndex + 1);

for (const token of tokens) {
if (token === '*') {
regexParts.push('(.*)');
} else {
regexParts.push(escapeRegex(token));
}
const searchPath = path.join(cwd, prefix);

let entries: string[];
try {
entries = fs.readdirSync(searchPath);
} catch {
return [];
}

return new RegExp(`^${regexParts.join('')}$`);
}
const keys: KeyValuePair[] = [];

for (const entry of entries) {
const entryPath = path.join(searchPath, entry);

let stats: fs.Stats;
try {
stats = fs.statSync(entryPath);
} catch {
continue;
}

function withoutWildcard(template: string, wildcardValues: string[]) {
const tokens = template.split(/(\*\*|\*)/);
let result = '';
let i = 0;
for (const token of tokens) {
if (token === '*') {
result += wildcardValues[i++];
} else {
result += token;
if (!stats.isDirectory()) {
continue;
}

let modulePath = path.join(prefix, entry, suffix).replace(/\\/g, '/');
const fullPath = path.join(cwd, modulePath);

let fullPathStats: fs.Stats;
try {
fullPathStats = fs.statSync(fullPath);
} catch {
continue;
}

if (fullPathStats.isDirectory()) {
const indexFile = TS_INDEX_FILES.find((indexFile) =>
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,
Expand All @@ -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,
});
}
Expand Down
28 changes: 28 additions & 0 deletions libs/native-federation/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading
Loading