@softarc/native-federation 4.4.1 → 4.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -363,7 +363,7 @@ shared: share({
363
363
  })
364
364
  ```
365
365
 
366
- Finally, it's also possible to break out of the `ignoreUnusedDeps` feature for specific externals if desired, for example when sharing a whole suite of interconnected external dependencies like @angular/core. This can be handy when you want to avoid the chance of cross-version secondary entrypoints being used by the different micro frontends. E.g. mfe1 uses @angular/core v20.1.0 and mfe2 uses @angular/core/rxjs-interop v20.0.8, then you might want consistent use of v20.1.0 so rxjs-interop should be exported by mfe1. The `keepAll` prop allows you to enforce this:
366
+ Finally, it's also possible to exempt the secondary entry points of a specific external from the `ignoreUnusedDeps` feature, for example when sharing a whole suite of interconnected external dependencies like @angular/core. This can be handy when you want to avoid the chance of cross-version secondary entrypoints being used by the different micro frontends. E.g. mfe1 uses @angular/core v20.1.0 and mfe2 uses @angular/core/rxjs-interop v20.0.8, then you might want consistent use of v20.1.0 so rxjs-interop should be exported by mfe1. The `keepAll` prop allows you to enforce this:
367
367
 
368
368
  ```typescript
369
369
  shared: share({
@@ -377,6 +377,10 @@ shared: share({
377
377
  })
378
378
  ```
379
379
 
380
+ `keepAll` is read per **package family**, not per entry point: every entry point of @angular/core is published as long as _something_ still reaches @angular/core, but a package nothing imports at all is pruned anyway. That is what keeps the feature meaningful when `keepAll` is applied to every package at once — it exempts the secondaries from reachability, not the package itself. For a package with no secondary entry points the family is the package itself, so the flag changes nothing there. Use `ignoreUnusedDeps: false` to publish everything unconditionally.
381
+
382
+ Note that mapped paths read the same flag differently: there, `keepAll` opts the mapping out of reachability entirely (see [Keeping mappings that nothing imports](#keeping-mappings-that-nothing-imports)).
383
+
380
384
  The API for configuring and using Native Federation is very similar to the one provided by our Module Federation plugin [@angular-architects/module-federation](https://www.npmjs.com/package/@angular-architects/module-federation). Hence, most of the articles on it are also valid for Native Federation.
381
385
 
382
386
  ### Sharing
@@ -487,7 +491,7 @@ module.exports = withNativeFederation({
487
491
  });
488
492
  ```
489
493
 
490
- - `keepAll` keeps the mapping even when nothing imports it. This is read exactly as it is for a shared package, so a bare `includeSecondaries: true` opts out too.
494
+ - `keepAll` keeps the mapping even when nothing imports it, and on a mapping a bare `includeSecondaries: true` means the same thing — a mapping has no secondary entry points, so the flag can only mean "exempt from reachability". A shared package reads it differently: `true` is the default there and only means "share the secondaries", so `{ keepAll: true }` is the only spelling that affects pruning — and even then the package itself still has to be reached.
491
495
  - `resolveGlob` is additionally required for **wildcard** mappings. A wildcard is a pattern rather than an entry point, and normally only the reachability scan turns it into concrete files; `resolveGlob` expands it against the filesystem instead. Without it, a wildcard mapping is dropped with a warning.
492
496
 
493
497
  An expanded wildcard is named by the same rule the reachability scan uses, so `libs/ui/*` matching `libs/ui/button/index.ts` is shared as `@my-org/ui/button`.
@@ -496,7 +500,7 @@ Expansion only accepts **entry points**. A glob cannot tell a library's public s
496
500
 
497
501
  That restriction is not cosmetic: **only barrel imports can be shared as a mapped path.** A mapped path is advertised under its import specifier and marked external, so the specifier has to be one a browser import map can resolve, and a dot in the last segment does not resolve (see [vitejs/vite#21036](https://github.com/vitejs/vite/issues/21036)).
498
502
 
499
- So the rule is simply *would this end up in `remoteEntry.json`?* If it would, a non-barrel specifier fails the build:
503
+ So the rule is simply _would this end up in `remoteEntry.json`?_ If it would, a non-barrel specifier fails the build:
500
504
 
501
505
  ```
502
506
  Invalid 'shared mappings' config. Only barrel imports can be shared as a sharedMapping:
@@ -693,7 +697,7 @@ For a zero-build integration, declare your remotes in a manifest and include the
693
697
  </script>
694
698
 
695
699
  <!-- Include the orchestrator -->
696
- <script src="https://unpkg.com/@softarc/native-federation-orchestrator@4.5.1/quickstart.mjs"></script>
700
+ <script src="https://unpkg.com/@softarc/native-federation-orchestrator@4.6.0/quickstart.mjs"></script>
697
701
  ```
698
702
 
699
703
  The `mfe-loader-available` event signals that the orchestrator has fetched the
@@ -15,5 +15,6 @@ export { isCjsCandidate, classifyByExtension, hasEsmSyntax, type ModuleFormat, }
15
15
  export { isIdentifierName, planCjsWrap, buildSyntheticCjsEntry, isEsmInteropError, } from './lib/utils/package/cjs-named-exports.js';
16
16
  export type { NfFileWatcher, NfFileWatcherOptions, } from './lib/domain/utils/file-watcher.contract.js';
17
17
  export { syncNfFileWatcher, createNfWatcher, type WatchSources } from './lib/utils/file-watcher.js';
18
+ export type { WatchPort, WatchHandle } from './lib/domain/utils/io-port.contract.js';
18
19
  export { linkedSharedDirs, sharedMappingDirs } from './lib/core/build/resolve-shared-dirs.js';
19
20
  export { isUnderDir, isUnderAnyDir } from './lib/utils/path-patterns.js';
@@ -1,7 +1,8 @@
1
+ import type { FileReaderPort } from '../domain/utils/io-port.contract.js';
1
2
  export interface ConfigurationContext {
2
3
  workspaceRoot?: string;
3
4
  packageJson?: string;
4
5
  }
5
- export declare function useWorkspace(workspaceRoot: string): void;
6
+ export declare function useWorkspace(workspaceRoot: string, io?: FileReaderPort): void;
6
7
  export declare function usePackageJson(packageJson?: string): void;
7
8
  export declare function getConfigContext(): ConfigurationContext;
@@ -1,6 +1,8 @@
1
+ import { nodeIo } from "../utils/io/node-io-adapter.js";
2
+ import { toDiskCase } from "../utils/disk-case.js";
1
3
  let _context = {};
2
- function useWorkspace(workspaceRoot) {
3
- _context = { ..._context, workspaceRoot };
4
+ function useWorkspace(workspaceRoot, io = nodeIo) {
5
+ _context = { ..._context, workspaceRoot: toDiskCase(io, workspaceRoot) };
4
6
  }
5
7
  function usePackageJson(packageJson) {
6
8
  _context = { ..._context, packageJson };
@@ -4,6 +4,7 @@ import { sharedPackageJsonRepository, tryGetPackageInfo } from "../utils/package
4
4
  import { getExternalImportsCore } from "./get-external-imports.js";
5
5
  import { nodeIo } from "../utils/io/node-io-adapter.js";
6
6
  import { isSharedMapping, matchMapping } from "./match-mapping.js";
7
+ import { logger } from "../utils/logger.js";
7
8
  import * as path from "path";
8
9
  const defaultDeps = {
9
10
  io: nodeIo,
@@ -71,6 +72,8 @@ function addTransientDeps(packages, workspaceRoot, deps) {
71
72
  }
72
73
  function resolveUsedMappings(fileInfos, workspaceRoot, sharedMappings) {
73
74
  const usedMappings = {};
75
+ const matchesIgnoringCase = createCaseInsensitiveMatcher(sharedMappings);
76
+ const caseOnlyMisses = /* @__PURE__ */ new Set();
74
77
  for (const fileName of Object.keys(fileInfos)) {
75
78
  const fullFileName = path.join(workspaceRoot, fileName);
76
79
  if (isSharedMapping(fullFileName, sharedMappings)) continue;
@@ -80,10 +83,27 @@ function resolveUsedMappings(fileInfos, workspaceRoot, sharedMappings) {
80
83
  const fullImport = path.join(workspaceRoot, imp);
81
84
  const match = matchMapping(fullImport, sharedMappings);
82
85
  if (match) usedMappings[fullImport] = match;
86
+ else if (matchesIgnoringCase(fullImport)) caseOnlyMisses.add(fullImport);
83
87
  }
84
88
  }
89
+ warnOnCaseOnlyMisses(caseOnlyMisses);
85
90
  return usedMappings;
86
91
  }
92
+ function createCaseInsensitiveMatcher(sharedMappings) {
93
+ const lowerCased = Object.fromEntries(
94
+ Object.entries(sharedMappings).map(([sharedPath, sharedImport]) => [
95
+ sharedPath.toLowerCase(),
96
+ sharedImport
97
+ ])
98
+ );
99
+ return (filePath) => matchMapping(filePath.toLowerCase(), lowerCased) !== null;
100
+ }
101
+ function warnOnCaseOnlyMisses(misses) {
102
+ if (misses.size === 0) return;
103
+ logger.warn(
104
+ `${misses.size} import(s) match a shared mapping only when case is ignored, so those libraries were pruned from remoteEntry.json -- e.g. '${[...misses][0]}'.`
105
+ );
106
+ }
87
107
  export {
88
108
  getUsedDependenciesFactory,
89
109
  getUsedDependenciesFactoryCore
@@ -2,4 +2,4 @@ import type { FileReaderPort } from '../domain/utils/io-port.contract.js';
2
2
  export declare function findRootTsConfigJson(): string;
3
3
  export declare function findRootTsConfigJsonCore(io: FileReaderPort): string;
4
4
  export declare function findPackageJson(io: FileReaderPort, folder: string): string;
5
- export declare function inferProjectPath(projectPath: string | undefined): string;
5
+ export declare function inferProjectPath(projectPath: string | undefined, io?: FileReaderPort): string;
@@ -2,11 +2,12 @@ import * as path from "path";
2
2
  import { cwd } from "process";
3
3
  import { getConfigContext } from "./configuration-context.js";
4
4
  import { nodeIo } from "../utils/io/node-io-adapter.js";
5
+ import { toDiskCase } from "../utils/disk-case.js";
5
6
  function findRootTsConfigJson() {
6
7
  return findRootTsConfigJsonCore(nodeIo);
7
8
  }
8
9
  function findRootTsConfigJsonCore(io) {
9
- const packageJson = findPackageJson(io, cwd());
10
+ const packageJson = findPackageJson(io, toDiskCase(io, cwd()));
10
11
  const projectRoot = path.dirname(packageJson);
11
12
  const tsConfigBaseJson = path.join(projectRoot, "tsconfig.base.json");
12
13
  const tsConfigJson = path.join(projectRoot, "tsconfig.json");
@@ -29,7 +30,7 @@ function findPackageJson(io, folder) {
29
30
  "no package.json found. Searched the following folder and all parents: " + folder
30
31
  );
31
32
  }
32
- function inferProjectPath(projectPath) {
33
+ function inferProjectPath(projectPath, io = nodeIo) {
33
34
  if (!projectPath && getConfigContext().packageJson) {
34
35
  projectPath = path.dirname(getConfigContext().packageJson || "");
35
36
  }
@@ -37,7 +38,7 @@ function inferProjectPath(projectPath) {
37
38
  projectPath = getConfigContext().workspaceRoot || "";
38
39
  }
39
40
  if (!projectPath) {
40
- projectPath = cwd();
41
+ projectPath = toDiskCase(io, cwd());
41
42
  }
42
43
  return projectPath;
43
44
  }
@@ -3,17 +3,26 @@ import {
3
3
  expandWildcardMapping,
4
4
  isWildcardMapping
5
5
  } from "./expand-mappings.js";
6
+ import { inferPackageFromSecondary } from "../utils/normalize.js";
6
7
  import { logger } from "../utils/logger.js";
7
8
  function removeUnusedDeps(usedDependencies, config, ctx) {
8
- const filteredDependencies = Object.entries(config.shared).filter(([shared, meta]) => !!meta.includeSecondaries || usedDependencies.external.has(shared)).reduce((acc, [shared, meta]) => ({ ...acc, [shared]: meta }), {});
9
+ const usedPackages = new Set([...usedDependencies.external].map(inferPackageFromSecondary));
10
+ const filteredDependencies = Object.entries(config.shared).filter(
11
+ ([shared, meta]) => meta.includeSecondaries ? usedPackages.has(inferPackageFromSecondary(shared)) : usedDependencies.external.has(shared)
12
+ ).reduce((acc, [shared, meta]) => ({ ...acc, [shared]: meta }), {});
13
+ const sharedMappings = withoutSkippedMappings(
14
+ { ...keptMappings(config, ctx), ...usedDependencies.internal },
15
+ config.skip
16
+ );
17
+ if (Object.keys(config.sharedMappings).length > 0 && Object.keys(sharedMappings).length === 0) {
18
+ logger.warn(
19
+ "No shared mapping is reachable from the entry points, so remoteEntry.json will ship without this workspace's libraries. Disable 'ignoreUnusedDeps' to publish them anyway."
20
+ );
21
+ }
9
22
  return {
10
23
  ...config,
11
24
  shared: filteredDependencies,
12
- // Both halves can contain wildcard-expanded imports, which the skip list has not seen yet.
13
- sharedMappings: withoutSkippedMappings(
14
- { ...keptMappings(config, ctx), ...usedDependencies.internal },
15
- config.skip
16
- )
25
+ sharedMappings
17
26
  };
18
27
  }
19
28
  function keptMappings(config, ctx) {
@@ -8,7 +8,7 @@ import {
8
8
  import { logger } from "../utils/logger.js";
9
9
  import { nodeIo } from "../utils/io/node-io-adapter.js";
10
10
  import { findPackageJson, inferProjectPath } from "./project-paths.js";
11
- import { isInferVersion, lookupVersion } from "./version-lookup.js";
11
+ import { isInferVersion, lookupVersion, applyAutoRequiredOptions } from "./version-lookup.js";
12
12
  import { addSecondaries, getSecondaries } from "./secondaries.js";
13
13
  const fromPackageJson = (baseCfg, projectPath) => {
14
14
  const skipList = [...DEFAULT_SKIP_LIST];
@@ -59,8 +59,19 @@ function shareAllCore(io, config, opts = {}, repo = sharedPackageJsonRepository)
59
59
  if (!!opts.overrides && Object.keys(opts.overrides).some((o) => key.startsWith(o))) {
60
60
  continue;
61
61
  }
62
- const inferVersion = !config.requiredVersion || config.requiredVersion === "auto";
63
- const requiredVersion = inferVersion ? versions[key] : config.requiredVersion;
62
+ const requiredVersionCfg = config.requiredVersion ?? void 0;
63
+ const isAutoObject = typeof requiredVersionCfg === "object";
64
+ const inferVersion = !requiredVersionCfg || requiredVersionCfg === "auto" || isAutoObject;
65
+ let requiredVersion;
66
+ if (inferVersion) {
67
+ const base = versions[key];
68
+ requiredVersion = isAutoObject ? applyAutoRequiredOptions(base, {
69
+ range: requiredVersionCfg.range,
70
+ version: requiredVersionCfg.version ?? config.version
71
+ }) : base;
72
+ } else {
73
+ requiredVersion = requiredVersionCfg;
74
+ }
64
75
  if (!sharedExternals[key]) {
65
76
  sharedExternals[key] = { ...config, requiredVersion };
66
77
  }
@@ -100,11 +111,20 @@ function shareCore(io, configuredShareObjects, projectPath = "", skipList = DEFA
100
111
  const result = {};
101
112
  for (const key in shareObjects) {
102
113
  let includeSecondaries = false;
103
- const shareObject = shareObjects[key];
104
- if (shareObject.requiredVersion === "auto" || isInferVersion() && typeof shareObject.requiredVersion === "undefined" || (shareObject.requiredVersion?.length ?? 1) < 1) {
105
- const version = lookupVersion(key, projectPath, repo);
106
- shareObject.requiredVersion = version;
107
- shareObject.version = version.replace(/^\D*/, "");
114
+ const { requiredVersion: requiredVersionCfg, ...rest } = shareObjects[key];
115
+ const shareObject = {
116
+ ...rest,
117
+ ...typeof requiredVersionCfg === "string" && {
118
+ requiredVersion: requiredVersionCfg
119
+ }
120
+ };
121
+ if (requiredVersionCfg === "auto" || isInferVersion() && typeof requiredVersionCfg === "undefined" || typeof requiredVersionCfg === "object" || (requiredVersionCfg?.length ?? 1) < 1) {
122
+ const isAutoObject = typeof requiredVersionCfg === "object";
123
+ const explicitVersion = isAutoObject ? requiredVersionCfg.version ?? shareObject.version : void 0;
124
+ const resolvedVersion = explicitVersion && explicitVersion !== "auto" ? explicitVersion : void 0;
125
+ const raw = resolvedVersion ?? lookupVersion(key, projectPath, repo);
126
+ shareObject.requiredVersion = isAutoObject && requiredVersionCfg.range ? applyAutoRequiredOptions(raw, { range: requiredVersionCfg.range }) : raw;
127
+ shareObject.version = raw.replace(/^\D*/, "");
108
128
  }
109
129
  if (typeof shareObject.includeSecondaries === "undefined") {
110
130
  shareObject.includeSecondaries = true;
@@ -2,3 +2,7 @@ import type { PackageJsonRepository } from '../domain/utils/package-json.contrac
2
2
  export declare function setInferVersion(infer: boolean): void;
3
3
  export declare function isInferVersion(): boolean;
4
4
  export declare function lookupVersion(key: string, workspaceRoot: string, repo: PackageJsonRepository): string;
5
+ export declare function applyAutoRequiredOptions(baseVersion: string, opts?: {
6
+ range?: 'exact' | '^' | '~' | 'minor' | 'patch';
7
+ version?: string;
8
+ }): string;
@@ -30,7 +30,34 @@ function lookupVersionInMap(key, versions) {
30
30
  }
31
31
  return versions[key];
32
32
  }
33
+ function applyAutoRequiredOptions(baseVersion, opts) {
34
+ const explicit = opts?.version && opts.version !== "auto" ? opts.version : void 0;
35
+ const raw = (explicit ?? baseVersion ?? "").trim();
36
+ if (!opts || !opts.range) return raw;
37
+ const requested = opts.range;
38
+ const singleTokenMatch = raw.match(
39
+ /^(?:[~^<>=]*\s*)?v?(\d+)(?:\.(\d+))?(?:\.(\d+))?((?:[-+][\w.]+)?)$/
40
+ );
41
+ if (!singleTokenMatch) {
42
+ return raw;
43
+ }
44
+ const [, major, minor = "0", patch = "0", extra] = singleTokenMatch;
45
+ const bareVersion = `${major}.${minor}.${patch}${extra}`;
46
+ switch (requested) {
47
+ case "exact":
48
+ return bareVersion;
49
+ case "^":
50
+ case "minor":
51
+ return `^${bareVersion}`;
52
+ case "~":
53
+ case "patch":
54
+ return `~${bareVersion}`;
55
+ default:
56
+ return raw;
57
+ }
58
+ }
33
59
  export {
60
+ applyAutoRequiredOptions,
34
61
  isInferVersion,
35
62
  lookupVersion,
36
63
  setInferVersion
@@ -6,9 +6,11 @@ import { logger } from "../../utils/logger.js";
6
6
  import { AbortedError } from "../../utils/errors.js";
7
7
  import { addExternalsToCache } from "../cache/federation-cache.js";
8
8
  import { planSharedBundles } from "./shared-bundle-plan.js";
9
+ import { hintUnwatchedLinkedDeps } from "./resolve-shared-dirs.js";
9
10
  async function buildForFederation(config, fedOptions, externals, signal) {
10
11
  logger.info("Building federation artifacts");
11
12
  logger.notice("Skip packages you don't want to share in your federation config");
13
+ hintUnwatchedLinkedDeps(config, fedOptions);
12
14
  await executeSharedBundlePlans(planSharedBundles(config, externals), config, fedOptions, signal);
13
15
  const start = process.hrtime();
14
16
  const artifactInfo = await bundleExposedAndMappings(
@@ -6,8 +6,11 @@ import type { PackageJsonRepository } from '../../domain/utils/package-json.cont
6
6
  * file-mapping agree on one identity for symlinked (npm-linked) deps. */
7
7
  export declare function resolveSharedPackageDirs(config: NormalizedFederationConfig, fedOptions: NormalizedFederationOptions, io?: FileReaderPort, repo?: PackageJsonRepository): Map<string, string>;
8
8
  /** Realpath'd dirs of symlinked shared packages — the bounded watch set.
9
- * Deduped, since secondaries share a package dir. */
9
+ * Deduped, since secondaries share a package dir. Empty unless `watchLinkedDeps` is on. */
10
10
  export declare function linkedSharedDirs(config: NormalizedFederationConfig, fedOptions: NormalizedFederationOptions, io?: FileReaderPort, repo?: PackageJsonRepository): string[];
11
+ /** Names the npm-linked shared packages a watching build leaves unwatched -- see
12
+ * AGENTS.md "What to watch". Advisory only, so a resolution failure must not fail the build. */
13
+ export declare function hintUnwatchedLinkedDeps(config: NormalizedFederationConfig, fedOptions: NormalizedFederationOptions, io?: FileReaderPort, repo?: PackageJsonRepository): void;
11
14
  /**
12
15
  * Source directories of the workspace libs in `config.sharedMappings` — the watch set
13
16
  * for mappings, derived from config alone rather than from a bundler cache.
@@ -24,9 +27,12 @@ export declare function linkedSharedDirs(config: NormalizedFederationConfig, fed
24
27
  * they are source trees, not the dist output `linkedSharedDirs` exists for.
25
28
  */
26
29
  export declare function sharedMappingDirs(config: NormalizedFederationConfig): string[];
27
- /** Per-key content signal (max mtime of the resolved dir) for symlinked deps only.
30
+ /** Per-key content signal (max mtime of the resolved dir) for linked checkouts only.
28
31
  * Registry deps get no signal, keeping their checksum version-only. (Every key is
29
- * still resolved: detecting the symlink requires the realpath + lstat.) */
32
+ * still resolved: detecting the symlink requires the realpath + lstat.)
33
+ *
34
+ * Deliberately not gated on `watchLinkedDeps`: that option decides whether an edit is
35
+ * noticed live, never whether the next build is correct. */
30
36
  export declare function linkedContentSignals(keys: string[], folder: string, io?: FileReaderPort, repo?: PackageJsonRepository): Record<string, string>;
31
37
  /** `config.shared` keys whose package directory contains at least one modified file. */
32
38
  export declare function affectedSharedKeys(modifiedFiles: readonly string[], dirs: Map<string, string>, io?: FileReaderPort): Set<string>;
@@ -1,7 +1,11 @@
1
1
  import * as path from "path";
2
2
  import { nodeIo } from "../../utils/io/node-io-adapter.js";
3
- import { sharedPackageJsonRepository } from "../../utils/package/package-info.js";
4
- import { isUnderDir, toPosix } from "../../utils/path-patterns.js";
3
+ import { logger } from "../../utils/logger.js";
4
+ import {
5
+ getPkgFolder,
6
+ sharedPackageJsonRepository
7
+ } from "../../utils/io/package-json-repository.js";
8
+ import { isOutsideNodeModules, isUnderDir, toPosix } from "../../utils/path-patterns.js";
5
9
  const folderOf = (fedOptions) => fedOptions.packageJson ? path.dirname(fedOptions.packageJson) : fedOptions.workspaceRoot;
6
10
  function resolveEntries(keys, folder, io, repo) {
7
11
  const out = [];
@@ -9,10 +13,12 @@ function resolveEntries(keys, folder, io, repo) {
9
13
  const pkgJsonPath = repo.findDepPackageJson(key, folder);
10
14
  if (!pkgJsonPath) continue;
11
15
  const pkgDir = path.dirname(pkgJsonPath);
16
+ const realDir = toPosix(io.realpath(pkgDir));
12
17
  out.push({
13
18
  key,
14
- realDir: toPosix(io.realpath(pkgDir)),
15
- isSymlink: !!io.stat(pkgDir)?.isSymbolicLink
19
+ realDir,
20
+ // pnpm's default linker symlinks every dep, so the link alone proves nothing.
21
+ isLinkedCheckout: !!io.stat(pkgDir)?.isSymbolicLink && isOutsideNodeModules(realDir)
16
22
  });
17
23
  }
18
24
  return out;
@@ -22,24 +28,50 @@ function resolveSharedPackageDirs(config, fedOptions, io = nodeIo, repo = shared
22
28
  return new Map(entries.map((e) => [e.key, e.realDir]));
23
29
  }
24
30
  function linkedSharedDirs(config, fedOptions, io = nodeIo, repo = sharedPackageJsonRepository) {
31
+ if (!fedOptions.watchLinkedDeps) return [];
25
32
  const entries = resolveEntries(Object.keys(config.shared), folderOf(fedOptions), io, repo);
26
- return [...new Set(entries.filter((e) => e.isSymlink).map((e) => e.realDir))];
33
+ return [...new Set(entries.filter((e) => e.isLinkedCheckout).map((e) => e.realDir))];
34
+ }
35
+ function hintUnwatchedLinkedDeps(config, fedOptions, io = nodeIo, repo = sharedPackageJsonRepository) {
36
+ if (!fedOptions.watch || fedOptions.watchLinkedDeps) return;
37
+ try {
38
+ const names = /* @__PURE__ */ new Map();
39
+ for (const entry of resolveEntries(
40
+ Object.keys(config.shared),
41
+ folderOf(fedOptions),
42
+ io,
43
+ repo
44
+ )) {
45
+ if (entry.isLinkedCheckout && !names.has(entry.realDir)) {
46
+ names.set(entry.realDir, getPkgFolder(entry.key));
47
+ }
48
+ }
49
+ if (names.size === 0) return;
50
+ logger.notice(
51
+ `Detected npm-linked shared packages: ${[...names.values()].join(", ")}. Set 'watchLinkedDeps' to true to rebuild when they change.`
52
+ );
53
+ } catch {
54
+ }
27
55
  }
28
56
  function sharedMappingDirs(config) {
29
- const dirs = Object.keys(config.sharedMappings).map((entryPoint) => toPosix(path.dirname(entryPoint))).filter((dir) => !dir.includes("node_modules"));
57
+ const dirs = Object.keys(config.sharedMappings).map((entryPoint) => toPosix(path.dirname(entryPoint))).filter(isOutsideNodeModules);
30
58
  return [...new Set(dirs)];
31
59
  }
32
60
  function maxMtime(io, dir) {
33
61
  let max = 0;
62
+ const bump = (s) => {
63
+ if (s && s.mtimeMs > max) max = s.mtimeMs;
64
+ };
34
65
  const walk = (d) => {
35
66
  for (const name of io.readDir(d)) {
36
67
  const full = path.join(d, name);
37
- if (io.isDirectory(full)) walk(full);
38
- else {
39
- let s = io.stat(full);
40
- if (s?.isSymbolicLink) s = io.stat(io.realpath(full));
41
- if (s && s.mtimeMs > max) max = s.mtimeMs;
68
+ const entry = io.stat(full);
69
+ if (entry?.isSymbolicLink) {
70
+ if (!io.isDirectory(full)) bump(io.stat(io.realpath(full)));
71
+ continue;
42
72
  }
73
+ if (io.isDirectory(full)) walk(full);
74
+ else bump(entry);
43
75
  }
44
76
  };
45
77
  walk(dir);
@@ -47,8 +79,15 @@ function maxMtime(io, dir) {
47
79
  }
48
80
  function linkedContentSignals(keys, folder, io = nodeIo, repo = sharedPackageJsonRepository) {
49
81
  const signals = {};
82
+ const byDir = /* @__PURE__ */ new Map();
50
83
  for (const entry of resolveEntries(keys, folder, io, repo)) {
51
- if (entry.isSymlink) signals[entry.key] = String(maxMtime(io, entry.realDir));
84
+ if (!entry.isLinkedCheckout) continue;
85
+ let signal = byDir.get(entry.realDir);
86
+ if (signal === void 0) {
87
+ signal = String(maxMtime(io, entry.realDir));
88
+ byDir.set(entry.realDir, signal);
89
+ }
90
+ signals[entry.key] = signal;
52
91
  }
53
92
  return signals;
54
93
  }
@@ -63,6 +102,7 @@ function affectedSharedKeys(modifiedFiles, dirs, io = nodeIo) {
63
102
  }
64
103
  export {
65
104
  affectedSharedKeys,
105
+ hintUnwatchedLinkedDeps,
66
106
  linkedContentSignals,
67
107
  linkedSharedDirs,
68
108
  resolveSharedPackageDirs,
@@ -9,6 +9,7 @@ import { getDefaultCachePath } from "./cache/cache-persistence.js";
9
9
  import { getUsedDependenciesFactory } from "../config/get-used-dependencies.js";
10
10
  import { logger } from "../utils/logger.js";
11
11
  import { normalizePackageName } from "../utils/normalize.js";
12
+ import { toDiskCase } from "../utils/disk-case.js";
12
13
  const defaultConfigLoader = async (fullConfigPath) => (await import(pathToFileURL(fullConfigPath).href))?.default;
13
14
  async function normalizeFederationOptions(options, cache) {
14
15
  return normalizeFederationOptionsCore(
@@ -18,14 +19,16 @@ async function normalizeFederationOptions(options, cache) {
18
19
  );
19
20
  }
20
21
  async function normalizeFederationOptionsCore(deps, options, cache) {
21
- const fullConfigPath = path.join(options.workspaceRoot, options.federationConfig);
22
+ const workspaceRoot = toDiskCase(deps.io, options.workspaceRoot);
23
+ const packageJson = options.packageJson && toDiskCase(deps.io, options.packageJson);
24
+ const fullConfigPath = path.join(workspaceRoot, options.federationConfig);
22
25
  if (!deps.io.exists(fullConfigPath)) {
23
26
  throw new Error("Expected " + fullConfigPath);
24
27
  }
25
28
  let config = await deps.loadConfig(fullConfigPath);
26
29
  const projectName = resolveProjectName(options.projectName ?? config.name);
27
30
  const suppliedCache = cache ?? createFederationCache(
28
- getDefaultCachePath(options.workspaceRoot)
31
+ getDefaultCachePath(workspaceRoot)
29
32
  );
30
33
  const federationCache = {
31
34
  ...suppliedCache,
@@ -34,9 +37,12 @@ async function normalizeFederationOptionsCore(deps, options, cache) {
34
37
  };
35
38
  const normalizedOptions = {
36
39
  ...options,
40
+ workspaceRoot,
41
+ ...packageJson && { packageJson },
37
42
  entryPoints: options.entryPoints ?? Object.values(config.exposes ?? {}).map((e) => e.file),
38
43
  projectName,
39
44
  cacheExternalArtifacts: options.cacheExternalArtifacts ?? true,
45
+ watchLinkedDeps: options.watchLinkedDeps ?? false,
40
46
  federationCache
41
47
  };
42
48
  const nothingShared = Object.keys(config.shared).length === 0 && Object.keys(config.sharedMappings).length === 0;
@@ -44,21 +50,21 @@ async function normalizeFederationOptionsCore(deps, options, cache) {
44
50
  logger.debug("Nothing is shared, skipping the used dependency scan.");
45
51
  } else if (config.features.ignoreUnusedDeps) {
46
52
  const getUsedDeps = (deps.usedDependenciesFactory ?? getUsedDependenciesFactory)(
47
- options.workspaceRoot,
53
+ workspaceRoot,
48
54
  options.entryPoints
49
55
  );
50
56
  config = removeUnusedDeps(getUsedDeps(config), config, {
51
57
  io: deps.io,
52
- workspaceRoot: options.workspaceRoot
58
+ workspaceRoot
53
59
  });
54
60
  logger.info("Removed unused dependencies.");
55
61
  logger.debug(
56
- 'This can be disabled per dependency/external using the "includeSecondaries: {keepAll: true}" property. Or in general by disabling the "ignoreUnusedDeps" feature. '
62
+ 'Keep everything with "ignoreUnusedDeps: false", or one mapping with "includeSecondaries: {keepAll: true}". On a shared package that flag only keeps the secondaries of a package something still imports.'
57
63
  );
58
64
  } else {
59
65
  config.sharedMappings = expandOrDropWildcards(config, {
60
66
  io: deps.io,
61
- workspaceRoot: options.workspaceRoot
67
+ workspaceRoot
62
68
  });
63
69
  }
64
70
  assertBarrelMappings(config.sharedMappings);
@@ -3,6 +3,16 @@ export type IncludeSecondariesOptions = {
3
3
  resolveGlob?: boolean;
4
4
  keepAll?: boolean;
5
5
  } | boolean;
6
+ export interface AutoRequiredOptions {
7
+ version?: 'auto' | string;
8
+ /** Controls how the resolved package.json version is emitted.
9
+ * - 'exact' => "1.2.3"
10
+ * - '^' | '~' => '^1.2.3' or '~1.2.3'
11
+ * - 'minor' => maps to '^' (allow minor bumps)
12
+ * - 'patch' => maps to '~' (allow patch bumps)
13
+ */
14
+ range?: 'exact' | '^' | '~' | 'minor' | 'patch';
15
+ }
6
16
  export interface ExternalConfig {
7
17
  singleton?: boolean;
8
18
  strictVersion?: boolean;
@@ -39,8 +49,10 @@ export interface NormalizedExternalConfig {
39
49
  }
40
50
  export type SharedExternalsConfig = Record<string, ExternalConfig>;
41
51
  export type NormalizedSharedExternalsConfig = Record<string, NormalizedExternalConfig>;
42
- export type ShareAllExternalsOptions = ExternalConfig;
43
- export type ShareExternalsOptions = SharedExternalsConfig;
52
+ export type ShareAllExternalsOptions = Omit<ExternalConfig, 'requiredVersion'> & {
53
+ requiredVersion?: string | AutoRequiredOptions;
54
+ };
55
+ export type ShareExternalsOptions = Record<string, ShareAllExternalsOptions>;
44
56
  export type ResolvedExternalConfig = Omit<ExternalConfig, 'includeSecondaries'> & {
45
57
  includeSecondaries?: boolean;
46
58
  };
@@ -14,9 +14,9 @@ export type SharedMappingConfigs = Record<string, ExternalConfig>;
14
14
  * have nothing to select. `requiredVersion` and `version` stay optional because their
15
15
  * defaults are read from the mapped lib's package.json at build time.
16
16
  *
17
- * `includeSecondaries` collapses to a boolean exactly as it does for a shared external:
18
- * it means "exempt from `ignoreUnusedDeps` pruning". `resolveGlob` is lifted out of it
19
- * because a mapping has no secondary entry points for it to apply to.
17
+ * `includeSecondaries` collapses to a boolean meaning "exempt from `ignoreUnusedDeps`
18
+ * pruning" stronger than on a shared external, where it only exempts the secondaries.
19
+ * `resolveGlob` is lifted out of it because a mapping has no secondaries to apply it to.
20
20
  */
21
21
  export interface NormalizedMappingConfig {
22
22
  singleton: boolean;
@@ -10,6 +10,11 @@ export interface FederationOptions {
10
10
  verbose?: boolean;
11
11
  dev?: boolean;
12
12
  watch?: boolean;
13
+ /** Poll-watch npm-linked shared deps so rebuilding the linked lib live-reloads the host.
14
+ * Off by default: a registry dep is bundled once and cached by checksum, so watching
15
+ * node_modules cannot change an outcome. With this off a linked lib still re-bundles on
16
+ * the next build, it just does not live-reload. */
17
+ watchLinkedDeps?: boolean;
13
18
  packageJson?: string;
14
19
  entryPoints?: string[];
15
20
  buildNotifications?: BuildNotificationOptions;
@@ -19,4 +24,5 @@ export interface NormalizedFederationOptions<TBundlerCache = unknown> extends Fe
19
24
  entryPoints: string[];
20
25
  projectName: string;
21
26
  cacheExternalArtifacts: boolean;
27
+ watchLinkedDeps: boolean;
22
28
  }
@@ -1,4 +1,11 @@
1
+ import type { WatchPort } from './io-port.contract.js';
1
2
  export interface NfFileWatcherOptions {
3
+ /** Watch implementation, defaulting to Node's fs. The built-in poll is dependency-free
4
+ * but sweeps the tree every `pollIntervalMs`; a host that already ships a real watcher
5
+ * should pass it here. An event-driven implementation may ignore `opts.poll`, but must
6
+ * then survive inode replacement on its own: a polled dir supersedes the native watches
7
+ * beneath it, so a missed rename-replace is never re-covered. */
8
+ watch?: WatchPort['watch'];
2
9
  onChange?: (path: string) => void;
3
10
  pollIntervalMs?: number;
4
11
  debounceMs?: number;
@@ -20,6 +20,8 @@ export interface FileReaderPort {
20
20
  /** Immediate child entry names (not full paths). Empty array on ENOENT, never throws. */
21
21
  readDir(path: string): string[];
22
22
  realpath(path: string): string;
23
+ /** The path as disk spells it, including case on case-insensitive filesystems. */
24
+ realpathNative(path: string): string;
23
25
  stat(path: string): StatInfo | null;
24
26
  }
25
27
  export interface FileWriterPort {
@@ -0,0 +1,7 @@
1
+ import type { FileReaderPort } from '../domain/utils/io-port.contract.js';
2
+ /**
3
+ * Every absolute path that gets string-compared later descends from a root supplied by the
4
+ * invoking tool, and on Windows two tools can report one root with different drive-letter case.
5
+ * Correcting it at the root keeps the comparison sites unchanged.
6
+ */
7
+ export declare function toDiskCase(io: FileReaderPort, p: string): string;
@@ -0,0 +1,14 @@
1
+ import * as path from "path";
2
+ import { toPosix } from "./path-patterns.js";
3
+ function toDiskCase(io, p) {
4
+ const real = io.realpathNative(p);
5
+ if (real === p || !differsOnlyByCase(real, p)) return p;
6
+ return path.normalize(real);
7
+ }
8
+ function differsOnlyByCase(a, b) {
9
+ const strip = (s) => toPosix(s).replace(/\/+$/, "").toLowerCase();
10
+ return strip(a) === strip(b);
11
+ }
12
+ export {
13
+ toDiskCase
14
+ };
@@ -1,12 +1,13 @@
1
1
  import { dirname, join } from "path";
2
2
  import { nodeIo } from "./io/node-io-adapter.js";
3
3
  import { logger } from "./logger.js";
4
- import { isUnderDir, toPosix } from "./path-patterns.js";
4
+ import { isOutsideNodeModules, isUnderDir, toPosix } from "./path-patterns.js";
5
5
  function createNfWatcher(options = {}) {
6
6
  return createNfWatcherCore(nodeIo, options);
7
7
  }
8
8
  function createNfWatcherCore(io, options = {}, now = Date.now) {
9
9
  const { onChange } = options;
10
+ const watch = options.watch ?? ((p, o, cb) => io.watch(p, o, cb));
10
11
  const pollIntervalMs = options.pollIntervalMs ?? 300;
11
12
  const debounceMs = options.debounceMs ?? 0;
12
13
  const dedupeReplays = options.dedupeReplays ?? true;
@@ -21,18 +22,18 @@ function createNfWatcherCore(io, options = {}, now = Date.now) {
21
22
  return posix.length > 1 ? posix.replace(/\/+$/, "") : posix;
22
23
  };
23
24
  const covers = (path, poll) => {
24
- for (const [dir, watch] of watchers) {
25
- if ((watch.poll || !poll) && isUnderDir(path, dir)) return true;
25
+ for (const [dir, watch2] of watchers) {
26
+ if ((watch2.poll || !poll) && isUnderDir(path, dir)) return true;
26
27
  }
27
28
  return false;
28
29
  };
29
30
  const supersede = (dir, poll) => {
30
31
  for (const map of [watchers, fileDirWatchers]) {
31
- for (const [key, watch] of map) {
32
- if (watch.poll && !poll) continue;
32
+ for (const [key, watch2] of map) {
33
+ if (watch2.poll && !poll) continue;
33
34
  if (map === watchers && key === dir) continue;
34
35
  if (!isUnderDir(key, dir)) continue;
35
- watch.handle.close();
36
+ watch2.handle.close();
36
37
  map.delete(key);
37
38
  }
38
39
  }
@@ -92,7 +93,7 @@ function createNfWatcherCore(io, options = {}, now = Date.now) {
92
93
  if (watchers.has(dir2) || covers(dir2, shouldPoll)) continue;
93
94
  try {
94
95
  watchers.set(dir2, {
95
- handle: io.watch(p, { recursive: true, poll }, (filename) => {
96
+ handle: watch(p, { recursive: true, poll }, (filename) => {
96
97
  if (filename) notify(toPosix(join(p, filename)));
97
98
  }),
98
99
  poll: shouldPoll
@@ -116,7 +117,7 @@ function createNfWatcherCore(io, options = {}, now = Date.now) {
116
117
  if (fileDirWatchers.has(dir)) continue;
117
118
  try {
118
119
  fileDirWatchers.set(dir, {
119
- handle: io.watch(dir, { recursive: false, poll }, (filename) => {
120
+ handle: watch(dir, { recursive: false, poll }, (filename) => {
120
121
  if (!filename) return;
121
122
  const changed = toPosix(join(dir, filename));
122
123
  if (trackedFiles.has(changed)) notify(changed);
@@ -150,7 +151,7 @@ function toPaths(sources) {
150
151
  return typeof cache.keys === "function" ? cache.keys() : sources;
151
152
  }
152
153
  function syncNfFileWatcher(watcher, sources, linkedDirs = []) {
153
- const files = [...toPaths(sources)].filter((k) => !k.includes("node_modules"));
154
+ const files = [...toPaths(sources)].filter(isOutsideNodeModules);
154
155
  if (files.length) watcher.addPaths(files);
155
156
  if (linkedDirs.length) watcher.addPaths(linkedDirs, { poll: true });
156
157
  }
@@ -40,6 +40,15 @@ const nodeIo = {
40
40
  return path2;
41
41
  }
42
42
  },
43
+ // Only the native variant reports the spelling stored on disk: the JS realpathSync walks the
44
+ // components of the input string and rewrites only the ones that are symlinks.
45
+ realpathNative(path2) {
46
+ try {
47
+ return fs.realpathSync.native(path2);
48
+ } catch {
49
+ return path2;
50
+ }
51
+ },
43
52
  stat(path2) {
44
53
  try {
45
54
  const s = fs.lstatSync(path2);
@@ -96,6 +105,7 @@ function pollWatch(root, recursive, intervalMs, onEvent) {
96
105
  return;
97
106
  }
98
107
  for (const entry of entries) {
108
+ if (entry.name === "node_modules") continue;
99
109
  const full = path.join(dir, entry.name);
100
110
  if (entry.isDirectory()) {
101
111
  if (recursive) walk(full);
@@ -26,3 +26,11 @@ export declare function substituteWildcard(template: string, captured: string):
26
26
  * `libs/ui-**` reads as `libs/ui-*` and silently matches nothing one level down.
27
27
  */
28
28
  export declare function toGlobPattern({ prefix, suffix }: WildcardPattern): string;
29
+ /**
30
+ * A dev checkout rather than an installed package. A symlink alone cannot tell them apart:
31
+ * pnpm's default linker points every dep at `…/node_modules/.pnpm/<pkg>@<ver>/node_modules/
32
+ * <pkg>`, while `npm link` resolves to the checkout. Matched anywhere in the path, since a
33
+ * monorepo's store can sit above the workspace root, and by segment so a checkout under
34
+ * `node_modules_backup` still counts.
35
+ */
36
+ export declare const isOutsideNodeModules: (dir: string) => boolean;
@@ -28,8 +28,10 @@ function substituteWildcard(template, captured) {
28
28
  function toGlobPattern({ prefix, suffix }) {
29
29
  return prefix.slice(0, prefix.lastIndexOf("/") + 1) + "**/*" + suffix;
30
30
  }
31
+ const isOutsideNodeModules = (dir) => !toPosix(dir).split("/").includes("node_modules");
31
32
  export {
32
33
  captureWildcard,
34
+ isOutsideNodeModules,
33
35
  isUnderAnyDir,
34
36
  isUnderDir,
35
37
  matchesWildcard,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softarc/native-federation",
3
- "version": "4.4.1",
3
+ "version": "4.5.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "packageManager": "pnpm@11.18.0",
@@ -26,7 +26,7 @@
26
26
  "eslint": "^10.4.1",
27
27
  "globals": "^17.3.0",
28
28
  "jiti": "^2.6.1",
29
- "jsdom": "^29.0.0",
29
+ "jsdom": "^30.0.0",
30
30
  "knip": "^6.26.0",
31
31
  "prettier": "^3.9.4",
32
32
  "tslib": "^2.3.0",