@softarc/native-federation 4.3.2 → 4.4.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.
Files changed (41) hide show
  1. package/README.md +71 -1
  2. package/dist/config.d.ts +1 -0
  3. package/dist/config.js +2 -0
  4. package/dist/internal.d.ts +3 -2
  5. package/dist/internal.js +5 -1
  6. package/dist/lib/config/expand-mappings.d.ts +24 -0
  7. package/dist/lib/config/expand-mappings.js +88 -0
  8. package/dist/lib/config/get-used-dependencies.d.ts +0 -2
  9. package/dist/lib/config/get-used-dependencies.js +4 -40
  10. package/dist/lib/config/mapped-paths.d.ts +7 -2
  11. package/dist/lib/config/mapped-paths.js +21 -4
  12. package/dist/lib/config/mapping-utils.d.ts +32 -0
  13. package/dist/lib/config/mapping-utils.js +68 -0
  14. package/dist/lib/config/match-mapping.d.ts +9 -0
  15. package/dist/lib/config/match-mapping.js +46 -0
  16. package/dist/lib/config/remove-unused-deps.d.ts +2 -1
  17. package/dist/lib/config/remove-unused-deps.js +35 -2
  18. package/dist/lib/config/validate-mappings.d.ts +15 -0
  19. package/dist/lib/config/validate-mappings.js +28 -0
  20. package/dist/lib/config/with-native-federation.js +18 -6
  21. package/dist/lib/core/build/bundle-exposed-and-mappings.js +21 -8
  22. package/dist/lib/core/build/bundle-shared.js +9 -3
  23. package/dist/lib/core/build/resolve-shared-dirs.d.ts +16 -0
  24. package/dist/lib/core/build/resolve-shared-dirs.js +8 -4
  25. package/dist/lib/core/cache/cache-persistence.d.ts +4 -2
  26. package/dist/lib/core/cache/cache-persistence.js +27 -8
  27. package/dist/lib/core/normalize-options.d.ts +2 -2
  28. package/dist/lib/core/normalize-options.js +15 -9
  29. package/dist/lib/domain/config/federation-config.contract.d.ts +27 -2
  30. package/dist/lib/domain/utils/file-watcher.contract.d.ts +12 -0
  31. package/dist/lib/domain/utils/io-port.contract.d.ts +6 -3
  32. package/dist/lib/utils/file-watcher.d.ts +15 -4
  33. package/dist/lib/utils/file-watcher.js +112 -19
  34. package/dist/lib/utils/io/node-io-adapter.js +10 -5
  35. package/dist/lib/utils/package/entry-point-resolver.js +2 -5
  36. package/dist/lib/utils/package/package-info.d.ts +13 -0
  37. package/dist/lib/utils/package/package-info.js +30 -4
  38. package/dist/lib/utils/package/resolve-wildcard-keys.js +8 -2
  39. package/dist/lib/utils/path-patterns.d.ts +14 -0
  40. package/dist/lib/utils/path-patterns.js +12 -0
  41. package/package.json +5 -5
@@ -2,21 +2,25 @@ import { getRawMappedPaths } from "./mapped-paths.js";
2
2
  import { fromPackageJson } from "./share-utils.js";
3
3
  import { findRootTsConfigJson } from "./project-paths.js";
4
4
  import { isInSkipList, prepareSkipList } from "./default-skip-list.js";
5
+ import { normalizeMappingConfig, withoutSkippedMappings } from "./mapping-utils.js";
5
6
  import { logger } from "../utils/logger.js";
6
7
  function withNativeFederation(config) {
7
8
  const skip = prepareSkipList(config.skip ?? []);
8
9
  const chunks = config.chunks ?? true;
10
+ const mappingVersion = config.features?.mappingVersion ?? true;
11
+ const { paths, configs } = getRawMappedPaths(findRootTsConfigJson(), config.sharedMappings);
9
12
  const normalized = {
10
13
  $type: "classic",
11
14
  name: config.name ?? "",
12
15
  exposes: normalizeExposes(config.exposes),
13
16
  shared: normalizeShared(config, skip, chunks),
14
- sharedMappings: removeSkippedMappings(config, skip),
17
+ sharedMappings: withoutSkippedMappings(paths, skip),
18
+ sharedMappingsConfig: normalizeMappingConfigs(configs, mappingVersion),
15
19
  chunks,
16
20
  skip,
17
21
  externals: config.externals ?? [],
18
22
  features: {
19
- mappingVersion: config.features?.mappingVersion ?? true,
23
+ mappingVersion,
20
24
  ignoreUnusedDeps: config.features?.ignoreUnusedDeps ?? true,
21
25
  denseChunking: config.features?.denseChunking ?? false,
22
26
  denseExternals: config.features?.denseExternals ?? false,
@@ -74,10 +78,18 @@ function normalizeShared(config, skip, chunks) {
74
78
  result = Object.keys(result).filter((key) => !isInSkipList(key, skip)).reduce((acc, cur) => ({ ...acc, [cur]: result[cur] }), {});
75
79
  return result;
76
80
  }
77
- function removeSkippedMappings(config, skipList) {
78
- const rootTsConfigPath = findRootTsConfigJson();
79
- const paths = getRawMappedPaths(rootTsConfigPath, config.sharedMappings);
80
- return Object.entries(paths).filter(([, _import]) => !isInSkipList(_import, skipList)).reduce((acc, [_path, _import]) => ({ ...acc, [_path]: _import }), {});
81
+ const IGNORED_MAPPING_PROPS = ["build", "platform", "chunks", "packageInfo"];
82
+ function normalizeMappingConfigs(configs, mappingVersion) {
83
+ return Object.entries(configs).reduce((acc, [pattern, cfg]) => {
84
+ const ignored = IGNORED_MAPPING_PROPS.filter((prop) => cfg[prop] !== void 0);
85
+ if (ignored.length > 0) {
86
+ logger.warn(
87
+ `Mapping '${pattern}' sets ${ignored.join(", ")}, which mapped paths do not honour (they all share one bundle). Ignored.`
88
+ );
89
+ }
90
+ acc[pattern] = normalizeMappingConfig(cfg, mappingVersion);
91
+ return acc;
92
+ }, {});
81
93
  }
82
94
  export {
83
95
  withNativeFederation
@@ -7,6 +7,7 @@ import { nodeIo } from "../../utils/io/node-io-adapter.js";
7
7
  import { AbortedError } from "../../utils/errors.js";
8
8
  import { rewriteChunkImports } from "./rewrite-chunk-imports.js";
9
9
  import { getBuildAdapter } from "./build-adapter.js";
10
+ import { resolveMappingConfig } from "../../config/mapping-utils.js";
10
11
  async function bundleExposedAndMappings(config, fedOptions, externals, modifiedFiles, signal) {
11
12
  return bundleExposedAndMappingsCore(
12
13
  { adapter: getBuildAdapter() },
@@ -78,7 +79,13 @@ async function bundleExposedAndMappingsCore(deps, config, fedOptions, externals,
78
79
  for (const item of shared) {
79
80
  const distEntryFile = popFromResultMap(resultMap, item.outName);
80
81
  sharedResult.push(
81
- toSharedMappingInfo(item.fileName, item.key, path.basename(distEntryFile), config, fedOptions)
82
+ toSharedMappingInfo(
83
+ item.fileName,
84
+ item.key,
85
+ path.basename(distEntryFile),
86
+ config,
87
+ fedOptions
88
+ )
82
89
  );
83
90
  entryFiles.push(distEntryFile);
84
91
  }
@@ -112,9 +119,7 @@ function describeExposed(config, options) {
112
119
  const result = [];
113
120
  for (const key in config.exposes) {
114
121
  const expose = config.exposes[key];
115
- const localPath = normalize(
116
- path.normalize(path.join(options.workspaceRoot, expose.file))
117
- );
122
+ const localPath = normalize(path.normalize(path.join(options.workspaceRoot, expose.file)));
118
123
  result.push({
119
124
  key,
120
125
  outFileName: "",
@@ -135,13 +140,21 @@ function describeSharedMappings(config, fedOptions) {
135
140
  }
136
141
  function toSharedMappingInfo(mappedPath, mappedImport, outFileName, config, fedOptions) {
137
142
  const mappingVersion = config.features.mappingVersion ? getMappingVersion(mappedPath, fedOptions.workspaceRoot) : "";
143
+ const mappingConfig = resolveMappingConfig(
144
+ mappedImport,
145
+ config.sharedMappingsConfig,
146
+ config.features.mappingVersion
147
+ );
148
+ const version = mappingConfig.version ?? mappingVersion;
138
149
  return {
139
150
  packageName: mappedImport,
140
151
  outFileName,
141
- requiredVersion: mappingVersion.length > 0 ? "~" + mappingVersion : "",
142
- singleton: true,
143
- strictVersion: config.features.mappingVersion,
144
- version: mappingVersion,
152
+ requiredVersion: mappingConfig.requiredVersion ?? (version.length > 0 ? "~" + version : ""),
153
+ singleton: mappingConfig.singleton,
154
+ strictVersion: mappingConfig.strictVersion,
155
+ version,
156
+ ...mappingConfig.shareScope && { shareScope: mappingConfig.shareScope },
157
+ ...mappingConfig.pool && { pool: mappingConfig.pool },
145
158
  dev: !fedOptions.dev ? void 0 : {
146
159
  entryPoint: normalize(path.normalize(mappedPath))
147
160
  }
@@ -1,7 +1,8 @@
1
1
  import * as path from "path";
2
2
  import {
3
3
  sharedPackageJsonRepository,
4
- getPackageInfo
4
+ getPackageInfo,
5
+ installedVersions
5
6
  } from "../../utils/package/package-info.js";
6
7
  import { logger } from "../../utils/logger.js";
7
8
  import { nodeIo } from "../../utils/io/node-io-adapter.js";
@@ -41,13 +42,18 @@ async function bundleSharedCore(deps, sharedBundles, config, fedOptions, externa
41
42
  deps.io,
42
43
  deps.repo
43
44
  );
45
+ const resolvedVersions = installedVersions(Object.keys(sharedBundles), folder, deps.repo);
46
+ for (const [key, cfg] of Object.entries(sharedBundles)) {
47
+ if (cfg.packageInfo) resolvedVersions[key] = cfg.packageInfo.version ?? "";
48
+ }
44
49
  const checksum = getChecksumCore(
45
50
  deps.io,
46
51
  sharedBundles,
47
52
  fedOptions.dev ? "1" : "0",
48
53
  builderVersion,
49
- config.features.synthesizeCjsExports,
50
- contentSignals
54
+ config.features,
55
+ contentSignals,
56
+ resolvedVersions
51
57
  );
52
58
  const bundleCache = cacheEntryCore(
53
59
  deps.io,
@@ -8,6 +8,22 @@ export declare function resolveSharedPackageDirs(config: NormalizedFederationCon
8
8
  /** Realpath'd dirs of symlinked shared packages — the bounded watch set.
9
9
  * Deduped, since secondaries share a package dir. */
10
10
  export declare function linkedSharedDirs(config: NormalizedFederationConfig, fedOptions: NormalizedFederationOptions, io?: FileReaderPort, repo?: PackageJsonRepository): string[];
11
+ /**
12
+ * Source directories of the workspace libs in `config.sharedMappings` — the watch set
13
+ * for mappings, derived from config alone rather than from a bundler cache.
14
+ *
15
+ * Coarser than the inputs a build actually compiled, and deliberately so: it covers
16
+ * files added to a lib after the last build, which a compiled-inputs watch set cannot
17
+ * know about yet. It does not follow imports out of the lib, so an adapter that can
18
+ * enumerate its build inputs should watch both. See angular-adapter#94.
19
+ *
20
+ * How coarse is the caller's to bound: an entry point that is not a lib barrel widens the
21
+ * watch to whatever directory it sits in (`'@app/env': ['src/environments/environment.ts']`
22
+ * to that folder, `'@shared': ['src/index.ts']` to all of `src`), and with `sharedMappings`
23
+ * unset every tsconfig path becomes a mapping. Watch these natively rather than polled —
24
+ * they are source trees, not the dist output `linkedSharedDirs` exists for.
25
+ */
26
+ export declare function sharedMappingDirs(config: NormalizedFederationConfig): string[];
11
27
  /** Per-key content signal (max mtime of the resolved dir) for symlinked deps only.
12
28
  * Registry deps get no signal, keeping their checksum version-only. (Every key is
13
29
  * still resolved: detecting the symlink requires the realpath + lstat.) */
@@ -1,7 +1,7 @@
1
1
  import * as path from "path";
2
2
  import { nodeIo } from "../../utils/io/node-io-adapter.js";
3
3
  import { sharedPackageJsonRepository } from "../../utils/package/package-info.js";
4
- import { toPosix } from "../../utils/path-patterns.js";
4
+ import { isUnderDir, toPosix } from "../../utils/path-patterns.js";
5
5
  const folderOf = (fedOptions) => fedOptions.packageJson ? path.dirname(fedOptions.packageJson) : fedOptions.workspaceRoot;
6
6
  function resolveEntries(keys, folder, io, repo) {
7
7
  const out = [];
@@ -25,6 +25,10 @@ function linkedSharedDirs(config, fedOptions, io = nodeIo, repo = sharedPackageJ
25
25
  const entries = resolveEntries(Object.keys(config.shared), folderOf(fedOptions), io, repo);
26
26
  return [...new Set(entries.filter((e) => e.isSymlink).map((e) => e.realDir))];
27
27
  }
28
+ function sharedMappingDirs(config) {
29
+ const dirs = Object.keys(config.sharedMappings).map((entryPoint) => toPosix(path.dirname(entryPoint))).filter((dir) => !dir.includes("node_modules"));
30
+ return [...new Set(dirs)];
31
+ }
28
32
  function maxMtime(io, dir) {
29
33
  let max = 0;
30
34
  const walk = (d) => {
@@ -53,8 +57,7 @@ function affectedSharedKeys(modifiedFiles, dirs, io = nodeIo) {
53
57
  if (modifiedFiles.length === 0 || dirs.size === 0) return affected;
54
58
  const realFiles = modifiedFiles.map((f) => toPosix(io.realpath(f)));
55
59
  for (const [key, dir] of dirs) {
56
- const prefix = dir.endsWith("/") ? dir : dir + "/";
57
- if (realFiles.some((f) => f === dir || f.startsWith(prefix))) affected.add(key);
60
+ if (realFiles.some((f) => isUnderDir(f, dir))) affected.add(key);
58
61
  }
59
62
  return affected;
60
63
  }
@@ -62,5 +65,6 @@ export {
62
65
  affectedSharedKeys,
63
66
  linkedContentSignals,
64
67
  linkedSharedDirs,
65
- resolveSharedPackageDirs
68
+ resolveSharedPackageDirs,
69
+ sharedMappingDirs
66
70
  };
@@ -1,10 +1,12 @@
1
1
  import type { NormalizedExternalConfig } from '../../domain/config/external-config.contract.js';
2
+ import type { NormalizedFederationConfig } from '../../domain/config/federation-config.contract.js';
2
3
  import type { ChunkInfo, IntegrityMap, SharedInfo } from '../../domain/core/federation-info.contract.js';
3
4
  import type { FileReaderPort, FileWriterPort, HashPort } from '../../domain/utils/io-port.contract.js';
4
5
  export declare const getDefaultCachePath: (workspaceRoot: string) => string;
5
6
  export declare const getFilename: (title: string, dev?: boolean) => string;
6
- export declare const getChecksum: (shared: Record<string, NormalizedExternalConfig>, dev: "1" | "0", builderVersion?: string, synthesizeCjsExports?: boolean, contentSignals?: Record<string, string>) => string;
7
- export declare const getChecksumCore: (hash: HashPort, shared: Record<string, NormalizedExternalConfig>, dev: "1" | "0", builderVersion?: string, synthesizeCjsExports?: boolean, contentSignals?: Record<string, string>) => string;
7
+ export declare const getChecksum: (shared: Record<string, NormalizedExternalConfig>, dev: "1" | "0", builderVersion?: string, features?: FeatureFlags, contentSignals?: Record<string, string>, resolvedVersions?: Record<string, string>) => string;
8
+ export type FeatureFlags = Partial<NormalizedFederationConfig['features']>;
9
+ export declare const getChecksumCore: (hash: HashPort, shared: Record<string, NormalizedExternalConfig>, dev: "1" | "0", builderVersion?: string, features?: FeatureFlags, contentSignals?: Record<string, string>, resolvedVersions?: Record<string, string>) => string;
8
10
  export type CacheMetadata = {
9
11
  checksum: string;
10
12
  externals: SharedInfo[];
@@ -6,15 +6,31 @@ const getFilename = (title, dev) => {
6
6
  const devSuffix = dev ? "-dev" : "";
7
7
  return `${title}${devSuffix}.meta.json`;
8
8
  };
9
- const getChecksum = (shared, dev, builderVersion = "", synthesizeCjsExports = true, contentSignals = {}) => getChecksumCore(nodeIo, shared, dev, builderVersion, synthesizeCjsExports, contentSignals);
10
- const getChecksumCore = (hash, shared, dev, builderVersion = "", synthesizeCjsExports = true, contentSignals = {}) => {
9
+ const getChecksum = (shared, dev, builderVersion = "", features = {}, contentSignals = {}, resolvedVersions = {}) => getChecksumCore(nodeIo, shared, dev, builderVersion, features, contentSignals, resolvedVersions);
10
+ const featureState = (features) => Object.entries(features).sort(([a], [b]) => a < b ? -1 : 1).map(([flag, on]) => `${flag}=${on ? "1" : "0"}`).join(",");
11
+ const SHARED_INFO_FIELDS = [
12
+ "requiredVersion",
13
+ "singleton",
14
+ "strictVersion",
15
+ "shareScope",
16
+ "pool"
17
+ ];
18
+ const sharedInfoState = (config) => {
19
+ const values = SHARED_INFO_FIELDS.map((field) => config[field] ?? null);
20
+ return values.every((value) => value === null) ? "" : `!${JSON.stringify(values)}`;
21
+ };
22
+ const getChecksumCore = (hash, shared, dev, builderVersion = "", features = {}, contentSignals = {}, resolvedVersions = {}) => {
11
23
  const denseExternals = Object.keys(shared).sort().reduce((clean, external) => {
12
- const version = shared[external].version ? `@${shared[external].version}` : "";
24
+ const installed = resolvedVersions[external];
25
+ const declared = shared[external].version;
26
+ const version = installed ? `~${installed}` : declared ? `@${declared}` : "";
13
27
  const signal = contentSignals[external] ? `#${contentSignals[external]}` : "";
14
- return clean + ":" + external + version + signal;
28
+ return clean + ":" + external + version + sharedInfoState(shared[external]) + signal;
15
29
  }, "deps");
16
- const cjs = synthesizeCjsExports ? "1" : "0";
17
- return hash.hash("sha256", denseExternals + `:dev=${dev}:builder=${builderVersion}:cjs=${cjs}`).hex();
30
+ return hash.hash(
31
+ "sha256",
32
+ denseExternals + `:dev=${dev}:builder=${builderVersion}:features=${featureState(features)}`
33
+ ).hex();
18
34
  };
19
35
  const cacheEntryCore = (io, pathToCache, fileName) => {
20
36
  const metadataFile = path.join(pathToCache, fileName);
@@ -36,8 +52,11 @@ const cacheEntryCore = (io, pathToCache, fileName) => {
36
52
  io.mkdirp(fullOutputPath);
37
53
  cachedResult.files.forEach((file) => {
38
54
  const cachedFile = path.join(pathToCache, file);
39
- const distFileName = path.join(fullOutputPath, file);
40
- if (io.exists(cachedFile)) io.copyFile(cachedFile, distFileName);
55
+ if (!io.exists(cachedFile))
56
+ throw new Error(
57
+ `Cached artifact '${file}' recorded in '${metadataFile}' is missing. Delete '${pathToCache}' and rebuild.`
58
+ );
59
+ io.copyFile(cachedFile, path.join(fullOutputPath, file));
41
60
  });
42
61
  },
43
62
  clear: () => {
@@ -1,11 +1,11 @@
1
1
  import type { NormalizedFederationConfig } from '../domain/config/federation-config.contract.js';
2
2
  import type { FederationOptions, NormalizedFederationOptions } from '../domain/core/federation-options.contract.js';
3
- import type { FileReaderPort } from '../domain/utils/io-port.contract.js';
3
+ import type { FileReaderPort, GlobPort } from '../domain/utils/io-port.contract.js';
4
4
  import { type FederationCache } from '../../domain.js';
5
5
  import { getUsedDependenciesFactory } from '../config/get-used-dependencies.js';
6
6
  type ConfigLoader = (fullConfigPath: string) => Promise<NormalizedFederationConfig>;
7
7
  interface NormalizeFederationDeps {
8
- io: FileReaderPort;
8
+ io: FileReaderPort & GlobPort;
9
9
  loadConfig: ConfigLoader;
10
10
  usedDependenciesFactory?: typeof getUsedDependenciesFactory;
11
11
  }
@@ -2,6 +2,8 @@ import * as path from "path";
2
2
  import { pathToFileURL } from "url";
3
3
  import { nodeIo } from "../utils/io/node-io-adapter.js";
4
4
  import { removeUnusedDeps } from "../config/remove-unused-deps.js";
5
+ import { expandOrDropWildcards } from "../config/expand-mappings.js";
6
+ import { assertBarrelMappings } from "../config/validate-mappings.js";
5
7
  import { createFederationCache } from "./cache/federation-cache.js";
6
8
  import { getDefaultCachePath } from "./cache/cache-persistence.js";
7
9
  import { getUsedDependenciesFactory } from "../config/get-used-dependencies.js";
@@ -31,25 +33,29 @@ async function normalizeFederationOptionsCore(deps, options, cache) {
31
33
  cacheExternalArtifacts: options.cacheExternalArtifacts ?? true,
32
34
  federationCache
33
35
  };
34
- if (config.features.ignoreUnusedDeps) {
36
+ const nothingShared = Object.keys(config.shared).length === 0 && Object.keys(config.sharedMappings).length === 0;
37
+ if (nothingShared) {
38
+ logger.debug("Nothing is shared, skipping the used dependency scan.");
39
+ } else if (config.features.ignoreUnusedDeps) {
35
40
  const getUsedDeps = (deps.usedDependenciesFactory ?? getUsedDependenciesFactory)(
36
41
  options.workspaceRoot,
37
42
  options.entryPoints
38
43
  );
39
- config = removeUnusedDeps(getUsedDeps(config), config);
44
+ config = removeUnusedDeps(getUsedDeps(config), config, {
45
+ io: deps.io,
46
+ workspaceRoot: options.workspaceRoot
47
+ });
40
48
  logger.info("Removed unused dependencies.");
41
49
  logger.debug(
42
50
  'This can be disabled per dependency/external using the "includeSecondaries: {keepAll: true}" property. Or in general by disabling the "ignoreUnusedDeps" feature. '
43
51
  );
44
52
  } else {
45
- const withWildcard = Object.keys(config.sharedMappings).some((m) => m.includes("*"));
46
- if (withWildcard) {
47
- logger.warn(
48
- "Sharing mapped paths with wildcards (*) is only supported with ignoreUnusedDeps feature."
49
- );
50
- config.sharedMappings = Object.entries(config.sharedMappings).filter(([_path]) => !_path.includes("*")).reduce((acc, [_path, _import]) => ({ ...acc, [_path]: _import }), {});
51
- }
53
+ config.sharedMappings = expandOrDropWildcards(config, {
54
+ io: deps.io,
55
+ workspaceRoot: options.workspaceRoot
56
+ });
52
57
  }
58
+ assertBarrelMappings(config.sharedMappings);
53
59
  return { config, options: normalizedOptions };
54
60
  }
55
61
  function resolveProjectName(name) {
@@ -1,16 +1,40 @@
1
1
  import type { PreparedSkipList, SkipList } from './skip-list.contract.js';
2
2
  import type { PathToImport } from '../utils/mapped-path.contract.js';
3
- import type { NormalizedSharedExternalsConfig, SharedExternalsConfig } from './external-config.contract.js';
3
+ import type { ExternalConfig, NormalizedSharedExternalsConfig, SharedExternalsConfig } from './external-config.contract.js';
4
4
  export type ExposeEntry = {
5
5
  file: string;
6
6
  element?: string;
7
7
  };
8
+ export type SharedMappingEntry = string | [string[], ExternalConfig];
9
+ /** Selection pattern -> config, kept in declaration order: first match wins. */
10
+ export type SharedMappingConfigs = Record<string, ExternalConfig>;
11
+ /**
12
+ * Only the subset of `ExternalConfig` a mapping can act on — mappings all share the
13
+ * single `mapping-or-exposed` bundle, so `build`/`platform`/`chunks`/`packageInfo`
14
+ * have nothing to select. `requiredVersion` and `version` stay optional because their
15
+ * defaults are read from the mapped lib's package.json at build time.
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.
20
+ */
21
+ export interface NormalizedMappingConfig {
22
+ singleton: boolean;
23
+ strictVersion: boolean;
24
+ requiredVersion?: string;
25
+ version?: string;
26
+ shareScope?: string;
27
+ pool?: string;
28
+ includeSecondaries?: boolean;
29
+ resolveGlob?: boolean;
30
+ }
31
+ export type NormalizedSharedMappingConfigs = Record<string, NormalizedMappingConfig>;
8
32
  export interface FederationConfig {
9
33
  name?: string;
10
34
  exposes?: Record<string, string | ExposeEntry>;
11
35
  shared?: SharedExternalsConfig;
12
36
  platform?: 'browser' | 'node';
13
- sharedMappings?: Array<string>;
37
+ sharedMappings?: Array<SharedMappingEntry>;
14
38
  chunks?: boolean;
15
39
  skip?: SkipList;
16
40
  externals?: string[];
@@ -30,6 +54,7 @@ export interface NormalizedFederationConfig {
30
54
  exposes: Record<string, ExposeEntry>;
31
55
  shared: NormalizedSharedExternalsConfig;
32
56
  sharedMappings: PathToImport;
57
+ sharedMappingsConfig: NormalizedSharedMappingConfigs;
33
58
  skip: PreparedSkipList;
34
59
  chunks: boolean;
35
60
  externals: string[];
@@ -2,6 +2,18 @@ export interface NfFileWatcherOptions {
2
2
  onChange?: (path: string) => void;
3
3
  pollIntervalMs?: number;
4
4
  debounceMs?: number;
5
+ /** Drop events whose mtime is unchanged since the last one seen for that path.
6
+ * macOS FSEvents re-delivers 'changed' for recently edited files roughly every
7
+ * 30s; with a watch list of a few thousand sources that replay alone keeps a
8
+ * rebuild loop awake forever. Default: true. */
9
+ dedupeReplays?: boolean;
10
+ /** Grace period after a path's recorded mtime during which an event whose mtime
11
+ * and byte length both match still passes, covering a second save inside one
12
+ * mtime tick. The 2000 default is twice the coarsest mtime granularity in play
13
+ * (1s on gRPC-FUSE/NFS/WSL2 drvfs/HFS+); raise it for a filesystem coarser than
14
+ * that, or for a network mount whose server clock runs behind the client.
15
+ * See AGENTS.md "Replay dedupe". Default: 2000. */
16
+ replayGraceMs?: number;
5
17
  }
6
18
  interface AddPathsOptions {
7
19
  poll?: boolean;
@@ -5,6 +5,8 @@ export interface Digest {
5
5
  }
6
6
  export interface StatInfo {
7
7
  mtimeMs: number;
8
+ /** Byte length; 0 for directories. */
9
+ size: number;
8
10
  isSymbolicLink: boolean;
9
11
  }
10
12
  export interface FileReaderPort {
@@ -29,6 +31,7 @@ export interface FileWriterPort {
29
31
  export interface GlobPort {
30
32
  globFiles(pattern: string, opts: {
31
33
  cwd: string;
34
+ ignore?: string[];
32
35
  }): string[];
33
36
  }
34
37
  export interface HashPort {
@@ -45,9 +48,9 @@ interface WatchOptions {
45
48
  }
46
49
  export interface WatchPort {
47
50
  /**
48
- * For a recursive directory watch `onEvent` receives the changed entry's
49
- * filename relative to `path`; for a file it receives the path itself (or
50
- * null when the platform omits it).
51
+ * `onEvent` receives the changed entry's filename relative to `path` (null when
52
+ * the platform omits it). Watching a file reports that file's own basename, so a
53
+ * caller that registered a file can ignore the argument.
51
54
  */
52
55
  watch(path: string, opts: WatchOptions, onEvent: (filename: string | null) => void): WatchHandle;
53
56
  }
@@ -1,7 +1,18 @@
1
1
  import type { WatchPort, FileReaderPort } from '../domain/utils/io-port.contract.js';
2
2
  import type { NfFileWatcher, NfFileWatcherOptions } from '../domain/utils/file-watcher.contract.js';
3
3
  export declare function createNfWatcher(options?: NfFileWatcherOptions): NfFileWatcher;
4
- export declare function createNfWatcherCore(io: WatchPort & FileReaderPort, options?: NfFileWatcherOptions): NfFileWatcher;
5
- export declare function syncNfFileWatcher(watcher: NfFileWatcher, bundlerCache: {
6
- keys(): IterableIterator<string>;
7
- }, linkedDirs?: readonly string[]): void;
4
+ export declare function createNfWatcherCore(io: WatchPort & FileReaderPort, options?: NfFileWatcherOptions, now?: () => number): NfFileWatcher;
5
+ /** A bundler cache keyed by input path, or the input paths themselves. */
6
+ export type WatchSources = Iterable<string> | {
7
+ keys(): Iterable<string>;
8
+ };
9
+ /**
10
+ * Subscribe the watcher to the inputs the last build compiled.
11
+ *
12
+ * Passing a bundler cache only works when it is keyed by input path. A cache that
13
+ * records its inputs elsewhere yields an empty watch set and, silently, a dev server
14
+ * that serves stale bundles until restart — Angular's `SourceFileCache` extends `Map`
15
+ * but keeps its inputs in `typeScriptFileCache`/`referencedFiles`, which is
16
+ * angular-adapter#94. Adapters over such a cache must expand it and pass the paths.
17
+ */
18
+ export declare function syncNfFileWatcher(watcher: NfFileWatcher, sources: WatchSources, linkedDirs?: readonly string[]): void;
@@ -1,29 +1,82 @@
1
- import { join } from "path";
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 { toPosix } from "./path-patterns.js";
4
+ import { isUnderDir, toPosix } from "./path-patterns.js";
5
5
  function createNfWatcher(options = {}) {
6
6
  return createNfWatcherCore(nodeIo, options);
7
7
  }
8
- function createNfWatcherCore(io, options = {}) {
8
+ function createNfWatcherCore(io, options = {}, now = Date.now) {
9
9
  const { onChange } = options;
10
10
  const pollIntervalMs = options.pollIntervalMs ?? 300;
11
11
  const debounceMs = options.debounceMs ?? 0;
12
+ const dedupeReplays = options.dedupeReplays ?? true;
13
+ const replayGraceMs = options.replayGraceMs ?? 2e3;
12
14
  const watchers = /* @__PURE__ */ new Map();
13
15
  const dirtyPaths = /* @__PURE__ */ new Set();
14
- const deliver = (path) => {
16
+ const lastSeen = /* @__PURE__ */ new Map();
17
+ const trackedFiles = /* @__PURE__ */ new Set();
18
+ const fileDirWatchers = /* @__PURE__ */ new Map();
19
+ const dirKey = (p) => {
20
+ const posix = toPosix(p);
21
+ return posix.length > 1 ? posix.replace(/\/+$/, "") : posix;
22
+ };
23
+ const covers = (path, poll) => {
24
+ for (const [dir, watch] of watchers) {
25
+ if ((watch.poll || !poll) && isUnderDir(path, dir)) return true;
26
+ }
27
+ return false;
28
+ };
29
+ const supersede = (dir, poll) => {
30
+ for (const map of [watchers, fileDirWatchers]) {
31
+ for (const [key, watch] of map) {
32
+ if (watch.poll && !poll) continue;
33
+ if (map === watchers && key === dir) continue;
34
+ if (!isUnderDir(key, dir)) continue;
35
+ watch.handle.close();
36
+ map.delete(key);
37
+ }
38
+ }
39
+ };
40
+ const identityOf = (path) => {
41
+ let stat = io.stat(path);
42
+ if (stat?.isSymbolicLink) stat = io.stat(io.realpath(path));
43
+ return stat ? { mtimeMs: stat.mtimeMs, size: stat.size } : null;
44
+ };
45
+ const isReplay = (path, at) => {
46
+ const current = identityOf(path);
47
+ if (!current) {
48
+ lastSeen.delete(path);
49
+ return false;
50
+ }
51
+ const previous = lastSeen.get(path);
52
+ lastSeen.set(path, current);
53
+ if (!previous || previous.mtimeMs !== current.mtimeMs || previous.size !== current.size) {
54
+ return false;
55
+ }
56
+ return at - current.mtimeMs >= replayGraceMs;
57
+ };
58
+ let watchFailures = 0;
59
+ const watchFailed = (path) => {
60
+ if (++watchFailures > 1) return logger.debug(`Could not watch path '${path}'.`);
61
+ logger.warn(
62
+ `Could not watch '${path}'. Changes there will not trigger a rebuild; run with verbose logging to see any further watch failures.`
63
+ );
64
+ };
65
+ const deliver = (path, at) => {
66
+ if (dedupeReplays && isReplay(path, at)) return;
67
+ dirtyPaths.add(path);
15
68
  if (onChange) onChange(path);
16
- else dirtyPaths.add(path);
17
69
  };
18
- const pending = /* @__PURE__ */ new Set();
70
+ const pending = /* @__PURE__ */ new Map();
19
71
  let flushTimer;
20
72
  const flush = () => {
21
- for (const p of pending) deliver(p);
73
+ for (const [p, at] of pending) deliver(p, at);
22
74
  pending.clear();
23
75
  };
24
76
  const notify = (path) => {
25
- if (debounceMs <= 0) return deliver(path);
26
- pending.add(path);
77
+ const at = now();
78
+ if (debounceMs <= 0) return deliver(path, at);
79
+ if (!pending.has(path)) pending.set(path, at);
27
80
  if (flushTimer) clearTimeout(flushTimer);
28
81
  flushTimer = setTimeout(flush, debounceMs);
29
82
  flushTimer.unref?.();
@@ -31,16 +84,47 @@ function createNfWatcherCore(io, options = {}) {
31
84
  return {
32
85
  addPaths(paths, opts) {
33
86
  const list = typeof paths === "string" ? [paths] : [...paths];
34
- const poll = opts?.poll ? { intervalMs: pollIntervalMs } : void 0;
87
+ const shouldPoll = !!opts?.poll;
88
+ const poll = shouldPoll ? { intervalMs: pollIntervalMs } : void 0;
35
89
  for (const p of list) {
36
- if (watchers.has(p)) continue;
90
+ if (io.isDirectory(p)) {
91
+ const dir2 = dirKey(p);
92
+ if (watchers.has(dir2) || covers(dir2, shouldPoll)) continue;
93
+ try {
94
+ watchers.set(dir2, {
95
+ handle: io.watch(p, { recursive: true, poll }, (filename) => {
96
+ if (filename) notify(toPosix(join(p, filename)));
97
+ }),
98
+ poll: shouldPoll
99
+ });
100
+ } catch {
101
+ watchFailed(p);
102
+ continue;
103
+ }
104
+ supersede(dir2, shouldPoll);
105
+ continue;
106
+ }
107
+ const key = toPosix(p);
108
+ if (trackedFiles.has(key)) continue;
109
+ trackedFiles.add(key);
110
+ if (dedupeReplays && !lastSeen.has(key)) {
111
+ const identity = identityOf(key);
112
+ if (identity) lastSeen.set(key, identity);
113
+ }
114
+ if (covers(key, shouldPoll)) continue;
115
+ const dir = dirKey(dirname(p));
116
+ if (fileDirWatchers.has(dir)) continue;
37
117
  try {
38
- const handle = io.isDirectory(p) ? io.watch(p, { recursive: true, poll }, (filename) => {
39
- if (filename) notify(toPosix(join(p, filename)));
40
- }) : io.watch(p, { recursive: false, poll }, () => notify(toPosix(p)));
41
- watchers.set(p, handle);
118
+ fileDirWatchers.set(dir, {
119
+ handle: io.watch(dir, { recursive: false, poll }, (filename) => {
120
+ if (!filename) return;
121
+ const changed = toPosix(join(dir, filename));
122
+ if (trackedFiles.has(changed)) notify(changed);
123
+ }),
124
+ poll: shouldPoll
125
+ });
42
126
  } catch {
43
- logger.debug(`Could not watch path '${p}'.`);
127
+ watchFailed(dir);
44
128
  }
45
129
  }
46
130
  },
@@ -49,15 +133,24 @@ function createNfWatcherCore(io, options = {}) {
49
133
  mutate: (fn) => fn(dirtyPaths),
50
134
  async close() {
51
135
  if (flushTimer) clearTimeout(flushTimer);
52
- for (const handle of watchers.values()) {
136
+ for (const { handle } of [...watchers.values(), ...fileDirWatchers.values()]) {
53
137
  handle.close();
54
138
  }
55
139
  watchers.clear();
140
+ fileDirWatchers.clear();
141
+ trackedFiles.clear();
142
+ lastSeen.clear();
143
+ watchFailures = 0;
56
144
  }
57
145
  };
58
146
  }
59
- function syncNfFileWatcher(watcher, bundlerCache, linkedDirs = []) {
60
- const files = [...bundlerCache.keys()].filter((k) => !k.includes("node_modules"));
147
+ function toPaths(sources) {
148
+ if (Array.isArray(sources)) return sources;
149
+ const cache = sources;
150
+ return typeof cache.keys === "function" ? cache.keys() : sources;
151
+ }
152
+ function syncNfFileWatcher(watcher, sources, linkedDirs = []) {
153
+ const files = [...toPaths(sources)].filter((k) => !k.includes("node_modules"));
61
154
  if (files.length) watcher.addPaths(files);
62
155
  if (linkedDirs.length) watcher.addPaths(linkedDirs, { poll: true });
63
156
  }