Skip to content

Commit cc1b7f3

Browse files
authored
Merge pull request #1083 from /issues/1081
feat: Added support for shared-mapping glob patterns
2 parents fa1dedc + 8eaf55a commit cc1b7f3

File tree

7 files changed

+231
-68
lines changed

7 files changed

+231
-68
lines changed

libs/native-federation-core/src/lib/config/share-utils.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import { logger } from '../utils/logger';
1919

2020
import {
2121
KeyValuePair,
22-
resolveWildcardKeys,
22+
resolvePackageJsonExportsWildcard,
2323
} from '../utils/resolve-wildcard-keys';
2424

2525
let inferVersion = false;
@@ -319,7 +319,7 @@ function resolveGlobSecondaries(
319319
let items: Array<string | KeyValuePair> = [];
320320
if (key.includes('*')) {
321321
if (!resolveGlob) return items;
322-
const expanded = resolveWildcardKeys(key, entry, libPath);
322+
const expanded = resolvePackageJsonExportsWildcard(key, entry, libPath);
323323
items = expanded
324324
.map((e) => ({
325325
key: path.join(parent, e.key),

libs/native-federation-core/src/lib/config/with-native-federation.ts

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ function normalizeShared(
108108

109109
function normalizeSharedMappings(
110110
config: FederationConfig,
111-
skip: PreparedSkipList,
111+
skipList: PreparedSkipList,
112112
): Array<MappedPath> {
113113
const rootTsConfigPath = findRootTsConfigJson();
114114

@@ -117,13 +117,7 @@ function normalizeSharedMappings(
117117
sharedMappings: config.sharedMappings,
118118
});
119119

120-
const result = paths.filter(
121-
(p) => !isInSkipList(p.key, skip) && !p.key.includes('*'),
122-
);
123-
124-
if (paths.find((p) => p.key.includes('*'))) {
125-
logger.warn('Sharing mapped paths with wildcards (*) not supported');
126-
}
120+
const result = paths.filter((p) => !isInSkipList(p.key, skipList));
127121

128122
return result;
129123
}

libs/native-federation-core/src/lib/utils/mapped-paths.d.ts

Lines changed: 0 additions & 14 deletions
This file was deleted.

libs/native-federation-core/src/lib/utils/mapped-paths.ts

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import * as path from 'path';
22
import * as fs from 'fs';
33
import * as JSON5 from 'json5';
4+
import { logger } from '../utils/logger';
5+
import { resolveTsConfigWildcard } from './resolve-wildcard-keys';
46

57
export interface MappedPath {
68
key: string;
@@ -29,11 +31,14 @@ export function getMappedPaths({
2931
if (!rootPath) {
3032
rootPath = path.normalize(path.dirname(rootTsConfigPath));
3133
}
32-
const shareAll = !sharedMappings;
34+
const shareAllMappings = !sharedMappings;
3335

3436
if (!sharedMappings) {
3537
sharedMappings = [];
3638
}
39+
const globSharedMappings = sharedMappings
40+
.filter((m) => m.endsWith('*'))
41+
.map((m) => m.slice(0, -1));
3742

3843
const tsConfig = JSON5.parse(
3944
fs.readFileSync(rootTsConfigPath, { encoding: 'utf-8' }),
@@ -46,14 +51,32 @@ export function getMappedPaths({
4651
}
4752

4853
for (const key in mappings) {
49-
const libPath = path.normalize(path.join(rootPath, mappings[key][0]));
54+
if (mappings[key].length > 1) {
55+
logger.warn(
56+
'[shared-mapping][' +
57+
key +
58+
'] A mapping path with more than 1 entryPoint is currently not supported, falling back to the first path.',
59+
);
60+
}
61+
const libPaths = key.includes('*')
62+
? resolveTsConfigWildcard(key, mappings[key][0], rootPath).map(
63+
({ key, value }) => ({
64+
key,
65+
path: path.normalize(path.join(rootPath, value)),
66+
}),
67+
)
68+
: [{ key, path: path.normalize(path.join(rootPath, mappings[key][0])) }];
5069

51-
if (sharedMappings.includes(key) || shareAll) {
52-
result.push({
53-
key,
54-
path: libPath,
70+
libPaths
71+
.filter(
72+
(mapping) =>
73+
shareAllMappings ||
74+
sharedMappings.includes(mapping.key) ||
75+
globSharedMappings.some((m) => mapping.key.startsWith(m)),
76+
)
77+
.forEach((mapping) => {
78+
result.push(mapping);
5579
});
56-
}
5780
}
5881

5982
return result;

libs/native-federation-core/src/lib/utils/resolve-wildcard-keys.ts

Lines changed: 116 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,62 +1,138 @@
11
import fg from 'fast-glob';
2+
import * as fs from 'fs';
3+
import * as path from 'path';
24

35
export type KeyValuePair = {
46
key: string;
57
value: string;
68
};
79

8-
function escapeRegex(str: string) {
9-
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
10-
}
10+
// TypeScript's module resolution for directories checks these in order
11+
// @see https://www.typescriptlang.org/docs/handbook/modules/theory.html#module-resolution
12+
const TS_INDEX_FILES = [
13+
'index.ts',
14+
'index.tsx',
15+
'index.mts',
16+
'index.cts',
17+
'index.d.ts',
18+
'index.js',
19+
'index.jsx',
20+
'index.mjs',
21+
'index.cjs',
22+
];
23+
24+
/**
25+
* Resolves tsconfig wildcard paths.
26+
*
27+
* In tsconfig.json, paths like `@features/*` → `libs/features/src/*` work as follows:
28+
* - The `*` captures a single path segment (the module name)
29+
* - When importing `@features/feature-a`, TypeScript captures `feature-a`
30+
* - It then replaces `*` in the value pattern: `libs/features/src/feature-a`
31+
*
32+
* For discovery, we find all directories at the wildcard position that TypeScript
33+
* would recognize as valid modules (directories with index files or package.json).
34+
*
35+
* @see https://www.typescriptlang.org/tsconfig/#paths
36+
*/
37+
export function resolveTsConfigWildcard(
38+
keyPattern: string,
39+
valuePattern: string,
40+
cwd: string,
41+
): KeyValuePair[] {
42+
const normalizedPattern = valuePattern.replace(/^\.?\/+/, '');
1143

12-
// Convert package.json exports pattern to glob pattern
13-
// * in exports means "one segment", but for glob we need **/* for deep matching
14-
// Src: https://hirok.io/posts/package-json-exports#exposing-all-package-files
15-
function convertExportsToGlob(pattern: string) {
16-
return pattern.replace(/(?<!\*)\*(?!\*)/g, '**/*');
17-
}
44+
const asteriskIndex = normalizedPattern.indexOf('*');
45+
if (asteriskIndex === -1) {
46+
return [];
47+
}
1848

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

23-
for (const token of tokens) {
24-
if (token === '*') {
25-
regexParts.push('(.*)');
26-
} else {
27-
regexParts.push(escapeRegex(token));
28-
}
52+
const searchPath = path.join(cwd, prefix);
53+
54+
let entries: string[];
55+
try {
56+
entries = fs.readdirSync(searchPath);
57+
} catch {
58+
return [];
2959
}
3060

31-
return new RegExp(`^${regexParts.join('')}$`);
32-
}
61+
const keys: KeyValuePair[] = [];
62+
63+
for (const entry of entries) {
64+
const entryPath = path.join(searchPath, entry);
65+
66+
let stats: fs.Stats;
67+
try {
68+
stats = fs.statSync(entryPath);
69+
} catch {
70+
continue;
71+
}
3372

34-
function withoutWildcard(template: string, wildcardValues: string[]) {
35-
const tokens = template.split(/(\*\*|\*)/);
36-
let result = '';
37-
let i = 0;
38-
for (const token of tokens) {
39-
if (token === '*') {
40-
result += wildcardValues[i++];
41-
} else {
42-
result += token;
73+
if (!stats.isDirectory()) {
74+
continue;
4375
}
76+
77+
let modulePath = path.join(prefix, entry, suffix).replace(/\\/g, '/');
78+
const fullPath = path.join(cwd, modulePath);
79+
80+
let fullPathStats: fs.Stats;
81+
try {
82+
fullPathStats = fs.statSync(fullPath);
83+
} catch {
84+
continue;
85+
}
86+
87+
if (fullPathStats.isDirectory()) {
88+
const indexFile = TS_INDEX_FILES.find((indexFile) =>
89+
fs.existsSync(path.join(fullPath, indexFile)),
90+
);
91+
92+
if (!indexFile) continue;
93+
modulePath = path.join(modulePath, indexFile);
94+
} else if (!fullPathStats.isFile()) {
95+
continue;
96+
}
97+
98+
const key = keyPattern.replace('*', entry);
99+
100+
keys.push({
101+
key,
102+
value: modulePath,
103+
});
44104
}
45-
return result;
105+
106+
return keys;
46107
}
47108

48-
export function resolveWildcardKeys(
109+
/**
110+
* Resolves package.json exports wildcard patterns.
111+
*
112+
* In package.json exports, patterns like `./features/*.js` → `./src/features/*.js` work as follows:
113+
* - The `*` is a literal string replacement that can include path separators
114+
* - Importing `pkg/features/a/b.js` captures `a/b` and replaces `*` → `./src/features/a/b.js`
115+
* - This matches actual files, not directories
116+
*
117+
* @see https://nodejs.org/api/packages.html#subpath-patterns
118+
*/
119+
export function resolvePackageJsonExportsWildcard(
49120
keyPattern: string,
50121
valuePattern: string,
51122
cwd: string,
52123
): KeyValuePair[] {
53124
const normalizedPattern = valuePattern.replace(/^\.?\/+/, '');
54125

55-
const globPattern = convertExportsToGlob(normalizedPattern);
126+
const asteriskIndex = normalizedPattern.indexOf('*');
127+
if (asteriskIndex === -1) {
128+
return [];
129+
}
56130

57-
const regex = compilePattern(normalizedPattern);
131+
const prefix = normalizedPattern.substring(0, asteriskIndex);
132+
const suffix = normalizedPattern.substring(asteriskIndex + 1);
58133

59-
const files = fg.sync(globPattern, {
134+
// fast-glob requires **/* pattern for matching files at any depth
135+
const files = fg.sync(prefix + '**/*' + suffix, {
60136
cwd,
61137
onlyFiles: true,
62138
deep: Infinity,
@@ -67,11 +143,14 @@ export function resolveWildcardKeys(
67143
for (const file of files) {
68144
const relPath = file.replace(/\\/g, '/').replace(/^\.\//, '');
69145

70-
const wildcards = relPath.match(regex);
71-
if (!wildcards) continue;
146+
const captured = suffix
147+
? relPath.slice(prefix.length, -suffix.length)
148+
: relPath.slice(prefix.length);
149+
150+
const key = keyPattern.replace('*', captured);
72151

73152
keys.push({
74-
key: withoutWildcard(keyPattern, wildcards.slice(1)),
153+
key,
75154
value: relPath,
76155
});
77156
}

libs/native-federation/README.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,34 @@ module.exports = withNativeFederation({
233233

234234
> Our `init` schematic shown above generates this file for you.
235235
236+
### Sharing Mapped Paths and Mapping Versions
237+
238+
In monorepo setups, mapped paths from your `tsconfig` are shared by default. You can restrict this behavior via `sharedMappings`.
239+
240+
If you additionally want to provide version metadata for mapped paths, enable `features.mappingVersion`.
241+
242+
```javascript
243+
const { withNativeFederation, shareAll } = require('@angular-architects/native-federation/config');
244+
245+
module.exports = withNativeFederation({
246+
shared: {
247+
...shareAll({
248+
singleton: true,
249+
strictVersion: true,
250+
requiredVersion: 'auto',
251+
}),
252+
},
253+
sharedMappings: ['@my-org/auth-lib', '@my-org/ui/*'],
254+
features: {
255+
mappingVersion: true,
256+
},
257+
});
258+
```
259+
260+
If `sharedMappings` is omitted, all discovered mapped paths are shared. For more details, see [FAQ for Sharing Libraries](./docs/share-faq.md).
261+
262+
`sharedMappings` reads mapped paths from the workspace root tsconfig file: `tsconfig.base.json` if it exists, otherwise `tsconfig.json`.
263+
236264
### Initializing the Host
237265

238266
When bootstrapping the host (shell), Native Federation (`projects\shell\src\main.ts`) is initialized:

0 commit comments

Comments
 (0)