diff --git a/README.md b/README.md index 912b582..2dd4190 100644 --- a/README.md +++ b/README.md @@ -641,11 +641,27 @@ A common local-development setup is to build a shared library in its own repo (w Since Native Federation shares such a library as an _external_ (it is excluded from Angular's own build), a plain `ng serve` used to ignore edits to it — the change lived under `node_modules`, which the build watcher skips, so you had to clear the cache or restart the dev server to see it. Since version `22.0.6` (requires `@softarc/native-federation` ≥ `4.3.2`), the adapter detects linked shared packages and re-bundles them automatically on change. +Two things about that detection have since changed (requires `@softarc/native-federation` ≥ `4.5.0`): + +- **A symlink alone no longer counts as linked.** Package managers that symlink by default — pnpm's default `isolated` linker, or Yarn's `nodeLinker: pnpm` — make _every_ dependency a symlink, which used to put the whole dependency graph on the watch list. A package is now treated as a live checkout only when its real path resolves **outside** every `node_modules` tree, which is exactly what `npm link` produces and what a package manager's internal symlink does not. +- **Watching linked packages is opt-in**, via the `watchLinkedDeps` builder option below. It is off by default because watching means polling the linked checkout for as long as the dev server runs. + +Turning the option off does **not** mean edits to a linked library are ignored. A library's type declarations are a TypeScript input, and the adapter resolves them to their real path outside `node_modules` — so an ng-packagr rebuild, which rewrites `dist/*.d.ts` alongside the JavaScript, is already noticed and re-bundled with the option off. What the option adds is coverage of changes that touch no such input: a JavaScript-only edit, or a rebuild whose emitted types come out byte-identical. + +A cold `ng build` re-bundles a changed linked library either way, since the adapter checksums each linked package's content on every build. A running `ng serve` is weaker: with the option off, a change touching no watched input can stay stale until you restart the server or run a build. + +So that the default is never a silent surprise, a watching build that finds a linked shared package while the option is off says so once at startup: + +``` +INFO Detected npm-linked shared packages: @my-scope/my-lib. Set 'watchLinkedDeps' to true to rebuild when they change. +``` + #### Requirements - The library is listed in your `federation.config.*` `shared` section (via `shareAll`, an explicit `shared` entry, or `sharedMappings`). -- Its package directory under `node_modules` is a **symlink** — i.e. it was linked with `npm link` (or your package manager's equivalent), not installed from a registry. +- Its package directory under `node_modules` is a **symlink pointing outside `node_modules`** — i.e. it was linked with `npm link` (or your package manager's equivalent), not installed from a registry and not symlinked by your package manager's own linker. - The library is rebuilt on change so the symlink target actually updates. With an Angular library this means running `ng build --watch` (ng-packagr) in the library's repo. +- For the changes an ng-packagr rebuild does not cover on its own — see above — `watchLinkedDeps` is set to `true` on the builder target you are running. #### Workflow @@ -663,13 +679,33 @@ npm link @my-scope/my-lib ng serve ``` +Watching is opt-in, so enable it on the target you serve with: + +```json +"serve": { + "builder": "@angular-architects/native-federation:build", + "options": { + "target": "host:serve-original:development", + "watchLinkedDeps": true + } +} +``` + +The option is available on both the `:build` and `:remote` builders and defaults to `false`. + Now edit a source file in the library. ng-packagr rebuilds its `dist/`, and the adapter picks up the change, re-bundles the affected shared external, and logs `Done!` — no manual cache clear or dev-server restart needed. To also refresh the browser automatically when the rebuild finishes, enable SSE-based reloading as described in [Shell reloading when MFE finishes building for local development](#shell-reloading-when-mfe-finishes-building-for-local-development) (`initFederation(manifest, { sse: true })`). Otherwise, a manual browser refresh will show the update. #### How it works -The adapter resolves the real path of each symlinked shared package and adds it to the federation file watcher. Because linked packages live under `node_modules`, they are watched via polling, and a short debounce coalesces ng-packagr's atomic multi-file writes into a single rebuild. Only the shared externals affected by the change are re-bundled; regular (registry-installed) dependencies keep the version-only cache fast path, so there is no rebuild churn or performance regression for non-linked packages. +The adapter resolves the real path of each shared package and treats it as a live checkout when that path lies outside every `node_modules` tree. With `watchLinkedDeps` enabled, those directories are added to the federation file watcher; a short debounce coalesces ng-packagr's atomic multi-file writes into a single rebuild. Only the shared externals affected by the change are re-bundled; regular (registry-installed) dependencies keep the version-only cache fast path, so there is no rebuild churn or performance regression for non-linked packages. + +Watching a linked checkout polls it, which is why it is off by default: a registry dependency is bundled once and cached by checksum, so its bytes cannot change without its version changing, and watching it could never change an outcome. Only a linked checkout changes content under a fixed version. + +Angular draws the same line but wires it to `preserveSymlinks`: when it watches the project root it ignores `**/node_modules/**`, and skips that ignore when `preserveSymlinks` is on, precisely so `npm link` keeps working. It has to overload one flag because its resolver decides which path esbuild sees. The adapter does not, because it resolves each shared package's real path itself — so `watchLinkedDeps` governs watching and nothing else. + +That separation is worth keeping, so do **not** reach for `preserveSymlinks` here. It changes module resolution: it is the classic route to loading two copies of a singleton like `@angular/core`, and under pnpm it makes every dependency resolve through `.pnpm`. It would also shrink the watch set rather than grow it, since the adapter skips any path with a `node_modules` segment and `preserveSymlinks` is exactly what makes a linked library's files report as `node_modules/@my-scope/my-lib/…` instead of their real location. ## FAQ diff --git a/package.json b/package.json index dcfb635..cb00e2f 100644 --- a/package.json +++ b/package.json @@ -17,11 +17,12 @@ "@angular-devkit/core": "~22.1.0", "@angular-devkit/schematics": "~22.1.0", "@chialab/esbuild-plugin-commonjs": "^0.19.0", - "@softarc/native-federation": "^4.4.0", + "@softarc/native-federation": "~4.5.0-next.1", "@softarc/native-federation-orchestrator": "^4.5.2", "es-module-shims": "^2.8.0", "esbuild": "^0.28.0", - "mrmime": "^2.0.1" + "mrmime": "^2.0.1", + "watchpack": "^2.5.2" }, "repository": { "type": "git", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9353527..4f55cc3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,8 +24,8 @@ importers: specifier: ^0.19.0 version: 0.19.1 '@softarc/native-federation': - specifier: ^4.4.0 - version: 4.4.1(typescript@6.0.3) + specifier: ~4.5.0-next.1 + version: 4.5.0-next.1(typescript@6.0.3) '@softarc/native-federation-orchestrator': specifier: ^4.5.2 version: 4.6.0 @@ -38,13 +38,16 @@ importers: mrmime: specifier: ^2.0.1 version: 2.0.1 + watchpack: + specifier: ^2.5.2 + version: 2.5.2 devDependencies: '@angular/build': specifier: ~22.1.0 - version: 22.1.5(@angular/compiler-cli@22.1.3(@angular/compiler@22.1.3)(typescript@6.0.3))(@angular/compiler@22.1.3)(@types/node@24.10.15)(chokidar@5.0.0)(jiti@2.7.0)(postcss@8.5.26)(rollup@4.62.5)(tslib@2.8.1)(typescript@6.0.3)(vitest@4.1.11)(yaml@2.9.0) + version: 22.1.5(@angular/compiler-cli@22.1.3(@angular/compiler@22.1.3)(typescript@6.0.3))(@angular/compiler@22.1.3)(@types/node@24.10.15)(chokidar@5.0.0)(jiti@2.7.0)(postcss@8.5.26)(rollup@4.62.5)(supports-color@7.2.0)(tslib@2.8.1)(typescript@6.0.3)(vitest@4.1.11)(yaml@2.9.0) '@eslint/js': specifier: ^10.0.1 - version: 10.0.1(eslint@10.9.0(jiti@2.7.0)) + version: 10.0.1(eslint@10.9.0(jiti@2.7.0)(supports-color@7.2.0)) '@nx/devkit': specifier: ^23.1.0 version: 23.1.1(nx@23.1.1) @@ -56,7 +59,7 @@ importers: version: 4.1.11(vitest@4.1.11) eslint: specifier: ^10.8.0 - version: 10.9.0(jiti@2.7.0) + version: 10.9.0(jiti@2.7.0)(supports-color@7.2.0) globals: specifier: ^17.0.0 version: 17.11.0 @@ -77,7 +80,7 @@ importers: version: 6.0.3 typescript-eslint: specifier: ^8.65.0 - version: 8.67.0(eslint@10.9.0(jiti@2.7.0))(typescript@6.0.3) + version: 8.67.0(eslint@10.9.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) vite: specifier: ^7.0.0 version: 7.3.6(@types/node@24.10.15)(jiti@2.7.0)(lightningcss@1.33.0)(sass@1.101.0)(yaml@2.9.0) @@ -1834,8 +1837,8 @@ packages: resolution: {integrity: sha512-8529SINGc3zBdKZcyUsP9vGj4EFjUOljy+43qfwzYSgC3FwkHcAjddf/p86t8kI9CmCp0JGeLkM1IjJ7xahVXQ==, tarball: https://registry.npmjs.org/@softarc/native-federation-orchestrator/-/native-federation-orchestrator-4.6.0.tgz} engines: {node: '>=24.16.0'} - '@softarc/native-federation@4.4.1': - resolution: {integrity: sha512-YPaWGbOHfl/Xs5HWQr9Ns3hUf056dA3qojsV2qEOYvNZ1JLYy9QC7Dgdvt6/CienxsLZnnymA3hHOMZs7jJvbA==, tarball: https://registry.npmjs.org/@softarc/native-federation/-/native-federation-4.4.1.tgz} + '@softarc/native-federation@4.5.0-next.1': + resolution: {integrity: sha512-HD8BO7b2d/9UIT4nSkq6D99mqB7DKQ35ONAkBpyx41/ojM98bvwzfJxL2UNtFvKqzlYaAeJZ/h0a5EagZ1IuZQ==, tarball: https://registry.npmjs.org/@softarc/native-federation/-/native-federation-4.5.0-next.1.tgz} '@softarc/sheriff-core@0.19.6': resolution: {integrity: sha512-KACxHG9sS7kNWgnnBODzdr14kMLMrJVlQKc+tViUP03p2fRwNhESOA49bz51Yn7dro1mbtMmmFjICLmZSJDZZA==, tarball: https://registry.npmjs.org/@softarc/sheriff-core/-/sheriff-core-0.19.6.tgz} @@ -3534,7 +3537,7 @@ snapshots: transitivePeerDependencies: - chokidar - '@angular/build@22.1.5(@angular/compiler-cli@22.1.3(@angular/compiler@22.1.3)(typescript@6.0.3))(@angular/compiler@22.1.3)(@types/node@24.10.15)(chokidar@5.0.0)(jiti@2.7.0)(postcss@8.5.26)(rollup@4.62.5)(tslib@2.8.1)(typescript@6.0.3)(vitest@4.1.11)(yaml@2.9.0)': + '@angular/build@22.1.5(@angular/compiler-cli@22.1.3(@angular/compiler@22.1.3)(typescript@6.0.3))(@angular/compiler@22.1.3)(@types/node@24.10.15)(chokidar@5.0.0)(jiti@2.7.0)(postcss@8.5.26)(rollup@4.62.5)(supports-color@7.2.0)(tslib@2.8.1)(typescript@6.0.3)(vitest@4.1.11)(yaml@2.9.0)': dependencies: '@ampproject/remapping': 2.3.0 '@angular-devkit/architect': 0.2201.5(chokidar@5.0.0) @@ -3548,7 +3551,7 @@ snapshots: beasties: 0.4.3 browserslist: 4.28.8 esbuild: 0.28.2 - https-proxy-agent: 9.1.0 + https-proxy-agent: 9.1.0(supports-color@7.2.0) jsonc-parser: 3.3.1 listr2: 11.0.0 magic-string: 1.0.0 @@ -3741,7 +3744,6 @@ snapshots: dependencies: '@emnapi/wasi-threads': 1.2.2 tslib: 2.8.1 - optional: true '@emnapi/core@1.4.5': dependencies: @@ -3756,7 +3758,6 @@ snapshots: '@emnapi/runtime@1.11.2': dependencies: tslib: 2.8.1 - optional: true '@emnapi/runtime@1.4.5': dependencies: @@ -3769,7 +3770,6 @@ snapshots: '@emnapi/wasi-threads@1.2.2': dependencies: tslib: 2.8.1 - optional: true '@esbuild/aix-ppc64@0.28.2': optional: true @@ -3849,14 +3849,14 @@ snapshots: '@esbuild/win32-x64@0.28.2': optional: true - '@eslint-community/eslint-utils@4.10.1(eslint@10.9.0(jiti@2.7.0))': + '@eslint-community/eslint-utils@4.10.1(eslint@10.9.0(jiti@2.7.0)(supports-color@7.2.0))': dependencies: - eslint: 10.9.0(jiti@2.7.0) + eslint: 10.9.0(jiti@2.7.0)(supports-color@7.2.0) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} - '@eslint/config-array@0.23.5': + '@eslint/config-array@0.23.5(supports-color@7.2.0)': dependencies: '@eslint/object-schema': 3.0.5 debug: 4.4.3(supports-color@7.2.0) @@ -3872,9 +3872,9 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/js@10.0.1(eslint@10.9.0(jiti@2.7.0))': + '@eslint/js@10.0.1(eslint@10.9.0(jiti@2.7.0)(supports-color@7.2.0))': optionalDependencies: - eslint: 10.9.0(jiti@2.7.0) + eslint: 10.9.0(jiti@2.7.0)(supports-color@7.2.0) '@eslint/object-schema@3.0.5': {} @@ -4116,8 +4116,8 @@ snapshots: '@napi-rs/wasm-runtime@0.2.4': dependencies: - '@emnapi/core': 1.4.5 - '@emnapi/runtime': 1.4.5 + '@emnapi/core': 1.11.2 + '@emnapi/runtime': 1.11.2 '@tybys/wasm-util': 0.9.0 '@napi-rs/wasm-runtime@1.2.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': @@ -4675,7 +4675,7 @@ snapshots: dependencies: semver: 7.8.5 - '@softarc/native-federation@4.4.1(typescript@6.0.3)': + '@softarc/native-federation@4.5.0-next.1(typescript@6.0.3)': dependencies: '@softarc/sheriff-core': 0.19.6(typescript@6.0.3) chalk: 6.0.0 @@ -4721,15 +4721,15 @@ snapshots: dependencies: undici-types: 7.16.0 - '@typescript-eslint/eslint-plugin@8.67.0(@typescript-eslint/parser@8.67.0(eslint@10.9.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.9.0(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/eslint-plugin@8.67.0(@typescript-eslint/parser@8.67.0(eslint@10.9.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.9.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.67.0(eslint@10.9.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/parser': 8.67.0(eslint@10.9.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) '@typescript-eslint/scope-manager': 8.67.0 - '@typescript-eslint/type-utils': 8.67.0(eslint@10.9.0(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/utils': 8.67.0(eslint@10.9.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/type-utils': 8.67.0(eslint@10.9.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/utils': 8.67.0(eslint@10.9.0(jiti@2.7.0)(supports-color@7.2.0))(typescript@6.0.3) '@typescript-eslint/visitor-keys': 8.67.0 - eslint: 10.9.0(jiti@2.7.0) + eslint: 10.9.0(jiti@2.7.0)(supports-color@7.2.0) ignore: 7.0.6 natural-compare: 1.4.0 ts-api-utils: 2.5.0(typescript@6.0.3) @@ -4737,14 +4737,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.67.0(eslint@10.9.0(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/parser@8.67.0(eslint@10.9.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': dependencies: '@typescript-eslint/scope-manager': 8.67.0 '@typescript-eslint/types': 8.67.0 '@typescript-eslint/typescript-estree': 8.67.0(typescript@6.0.3) '@typescript-eslint/visitor-keys': 8.67.0 debug: 4.4.3(supports-color@7.2.0) - eslint: 10.9.0(jiti@2.7.0) + eslint: 10.9.0(jiti@2.7.0)(supports-color@7.2.0) typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -4767,13 +4767,13 @@ snapshots: dependencies: typescript: 6.0.3 - '@typescript-eslint/type-utils@8.67.0(eslint@10.9.0(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/type-utils@8.67.0(eslint@10.9.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': dependencies: '@typescript-eslint/types': 8.67.0 '@typescript-eslint/typescript-estree': 8.67.0(typescript@6.0.3) - '@typescript-eslint/utils': 8.67.0(eslint@10.9.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/utils': 8.67.0(eslint@10.9.0(jiti@2.7.0)(supports-color@7.2.0))(typescript@6.0.3) debug: 4.4.3(supports-color@7.2.0) - eslint: 10.9.0(jiti@2.7.0) + eslint: 10.9.0(jiti@2.7.0)(supports-color@7.2.0) ts-api-utils: 2.5.0(typescript@6.0.3) typescript: 6.0.3 transitivePeerDependencies: @@ -4796,13 +4796,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.67.0(eslint@10.9.0(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/utils@8.67.0(eslint@10.9.0(jiti@2.7.0)(supports-color@7.2.0))(typescript@6.0.3)': dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@10.9.0(jiti@2.7.0)) + '@eslint-community/eslint-utils': 4.10.1(eslint@10.9.0(jiti@2.7.0)(supports-color@7.2.0)) '@typescript-eslint/scope-manager': 8.67.0 '@typescript-eslint/types': 8.67.0 '@typescript-eslint/typescript-estree': 8.67.0(typescript@6.0.3) - eslint: 10.9.0(jiti@2.7.0) + eslint: 10.9.0(jiti@2.7.0)(supports-color@7.2.0) typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -5237,11 +5237,11 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@10.9.0(jiti@2.7.0): + eslint@10.9.0(jiti@2.7.0)(supports-color@7.2.0): dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@10.9.0(jiti@2.7.0)) + '@eslint-community/eslint-utils': 4.10.1(eslint@10.9.0(jiti@2.7.0)(supports-color@7.2.0)) '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.23.5 + '@eslint/config-array': 0.23.5(supports-color@7.2.0) '@eslint/config-helpers': 0.7.0 '@eslint/core': 1.2.1 '@eslint/plugin-kit': 0.7.2 @@ -5455,7 +5455,7 @@ snapshots: transitivePeerDependencies: - supports-color - https-proxy-agent@9.1.0: + https-proxy-agent@9.1.0(supports-color@7.2.0): dependencies: agent-base: 9.0.0 debug: 4.4.3(supports-color@7.2.0) @@ -6354,13 +6354,13 @@ snapshots: dependencies: prelude-ls: 1.2.1 - typescript-eslint@8.67.0(eslint@10.9.0(jiti@2.7.0))(typescript@6.0.3): + typescript-eslint@8.67.0(eslint@10.9.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.67.0(@typescript-eslint/parser@8.67.0(eslint@10.9.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.9.0(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/parser': 8.67.0(eslint@10.9.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/eslint-plugin': 8.67.0(@typescript-eslint/parser@8.67.0(eslint@10.9.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.9.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/parser': 8.67.0(eslint@10.9.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) '@typescript-eslint/typescript-estree': 8.67.0(typescript@6.0.3) - '@typescript-eslint/utils': 8.67.0(eslint@10.9.0(jiti@2.7.0))(typescript@6.0.3) - eslint: 10.9.0(jiti@2.7.0) + '@typescript-eslint/utils': 8.67.0(eslint@10.9.0(jiti@2.7.0)(supports-color@7.2.0))(typescript@6.0.3) + eslint: 10.9.0(jiti@2.7.0)(supports-color@7.2.0) typescript: 6.0.3 transitivePeerDependencies: - supports-color diff --git a/src/builders/build/builder.ts b/src/builders/build/builder.ts index 4180e54..eab0c8a 100644 --- a/src/builders/build/builder.ts +++ b/src/builders/build/builder.ts @@ -50,6 +50,7 @@ import { type Plugin, type PluginBuild } from "esbuild"; import { devHostInstancesPlugin } from "../../plugin/dev-host-instances-plugin.js"; import { checkForInvalidImports } from "./../../utils/check-for-invalid-imports.js"; import { federationSourceFiles } from "./../../utils/federation-source-files.js"; +import { watchpackWatch } from "./../../utils/watchpack-watch.js"; import { federationBuildNotifier } from "./federation-build-notifier.js"; import { createFederationFreshness, @@ -289,6 +290,7 @@ export async function* runBuilder( tsConfig: federationTsConfig, verbose: ngBuilderOptions.verbose, watch: ngBuilderOptions.watch, + watchLinkedDeps: nfBuilderOptions.watchLinkedDeps === true, dev: !!nfBuilderOptions.dev, entryPoints, buildNotifications: nfBuilderOptions.buildNotifications, @@ -467,6 +469,7 @@ export async function* runBuilder( const nfWatcher: NfFileWatcher | undefined = watch ? createNfWatcher({ + watch: watchpackWatch, // Coalesce ng-packagr's atomic multi-write bursts into one rebuild. debounceMs: 100, onChange: (p) => { diff --git a/src/builders/build/schema.d.ts b/src/builders/build/schema.d.ts index c8c8be7..b26f291 100644 --- a/src/builders/build/schema.d.ts +++ b/src/builders/build/schema.d.ts @@ -11,6 +11,7 @@ export interface NfBuilderSchema extends JsonObject { buildNotifications?: BuildNotificationOptions; federationConfigPath?: string; watch?: boolean; + watchLinkedDeps?: boolean; skipHtmlTransform: boolean; esmsInitOptions: ESMSInitOptions; baseHref?: string; diff --git a/src/builders/build/schema.json b/src/builders/build/schema.json index f55cb85..c78af2e 100644 --- a/src/builders/build/schema.json +++ b/src/builders/build/schema.json @@ -18,6 +18,11 @@ "watch": { "type": "boolean" }, + "watchLinkedDeps": { + "type": "boolean", + "default": false, + "description": "Watch npm-linked shared libraries so rebuilding one reloads the host. Off by default: it polls the linked checkout for as long as the dev server runs. A cold build re-bundles a changed linked library either way; this option covers the changes a running dev server would otherwise miss." + }, "port": { "type": "number", "default": 0 diff --git a/src/builders/remote/builder.ts b/src/builders/remote/builder.ts index 1a7e8e8..2caf23f 100644 --- a/src/builders/remote/builder.ts +++ b/src/builders/remote/builder.ts @@ -95,6 +95,7 @@ export async function* runRemoteBuilder( tsConfig: federationTsConfig, verbose: nfBuilderOptions.verbose, watch: nfBuilderOptions.watch, + watchLinkedDeps: nfBuilderOptions.watchLinkedDeps === true, dev: !!nfBuilderOptions.dev, entryPoints, cacheExternalArtifacts: nfBuilderOptions.cacheExternalArtifacts !== false, diff --git a/src/builders/remote/change-watcher.ts b/src/builders/remote/change-watcher.ts index c3f8871..a5b9fea 100644 --- a/src/builders/remote/change-watcher.ts +++ b/src/builders/remote/change-watcher.ts @@ -1,4 +1,5 @@ import { createNfWatcher, type NfFileWatcher } from '@softarc/native-federation/internal'; +import { watchpackWatch } from '../../utils/watchpack-watch.js'; export interface DebouncedChangeWatcher { watcher: NfFileWatcher; @@ -31,6 +32,7 @@ export function createDebouncedChangeWatcher( }; const watcher = createNfWatcher({ + watch: watchpackWatch, onChange: p => { pendingPaths.add(p); scheduleNotify(); diff --git a/src/builders/remote/schema.d.ts b/src/builders/remote/schema.d.ts index 517010e..394685d 100644 --- a/src/builders/remote/schema.d.ts +++ b/src/builders/remote/schema.d.ts @@ -7,6 +7,7 @@ export interface NfRemoteBuilderSchema extends JsonObject { dev: boolean; rebuildDelay: number; watch: boolean; + watchLinkedDeps?: boolean; outputPath?: string; projectName?: string; verbose?: boolean; diff --git a/src/builders/remote/schema.json b/src/builders/remote/schema.json index 4ea8a8f..3f6787c 100644 --- a/src/builders/remote/schema.json +++ b/src/builders/remote/schema.json @@ -19,6 +19,11 @@ "type": "boolean", "default": false }, + "watchLinkedDeps": { + "type": "boolean", + "default": false, + "description": "Watch npm-linked shared libraries so rebuilding one reloads the host. Off by default: it polls the linked checkout for as long as the dev server runs. A cold build re-bundles a changed linked library either way; this option covers the changes a running dev server would otherwise miss." + }, "entryPoints": { "type": "array", "items": { "type": "string" }, diff --git a/src/utils/watchpack-watch.spec.ts b/src/utils/watchpack-watch.spec.ts new file mode 100644 index 0000000..64f4ecd --- /dev/null +++ b/src/utils/watchpack-watch.spec.ts @@ -0,0 +1,126 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { watchpackWatch } from './watchpack-watch'; + +// Watchpack is event-driven, but the events still cross a real fs boundary, so every +// assertion waits for a condition rather than a fixed delay. +const waitFor = async ( + predicate: () => boolean, + timeoutMs = 8000 +): Promise => { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (predicate()) return; + await new Promise(r => setTimeout(r, 25)); + } + throw new Error('timed out waiting for a watch event'); +}; + +// Watchpack only reports changes newer than the watch's start time, and mtime +// granularity on some filesystems is a full second. +const settle = () => new Promise(r => setTimeout(r, 300)); + +describe('watchpackWatch', () => { + let root: string; + const handles: { close(): void }[] = []; + + const watch = ( + target: string, + opts: { recursive: boolean; poll?: { intervalMs: number } } + ): string[] => { + const seen: string[] = []; + handles.push(watchpackWatch(target, opts, f => f && seen.push(f))); + return seen; + }; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'nf-watchpack-')); + }); + + afterEach(() => { + for (const h of handles.splice(0)) h.close(); + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('reports a changed file relative to the watched directory', async () => { + fs.writeFileSync(path.join(root, 'a.ts'), 'one'); + const seen = watch(root, { recursive: false }); + await settle(); + + fs.writeFileSync(path.join(root, 'a.ts'), 'two'); + await waitFor(() => seen.includes('a.ts')); + }); + + it('reports a file added after the watch started', async () => { + const seen = watch(root, { recursive: false }); + await settle(); + + fs.writeFileSync(path.join(root, 'new.ts'), 'hello'); + await waitFor(() => seen.includes('new.ts')); + }); + + // Watchpack descends whatever it is handed, so `recursive: false` cannot stop it the + // way fs.watch does — it only caps the reported path at depth 1. Two levels down, and + // modifying an existing file so no parent directory's mtime moves: the only way 'sub' + // can be reported is watchpack having descended to it. + it('collapses a change below depth 1 onto its depth-1 ancestor when not recursive', async () => { + const nested = path.join(root, 'sub', 'deeper'); + fs.mkdirSync(nested, { recursive: true }); + fs.writeFileSync(path.join(nested, 'deep.ts'), 'one'); + const seen = watch(root, { recursive: false }); + await settle(); + + fs.writeFileSync(path.join(nested, 'deep.ts'), 'two'); + await waitFor(() => seen.includes('sub')); + expect(seen.filter(f => f.includes('deep.ts'))).toEqual([]); + }); + + it('reports nested changes when recursive', async () => { + const nested = path.join(root, 'sub'); + fs.mkdirSync(nested); + fs.writeFileSync(path.join(nested, 'deep.ts'), 'one'); + const seen = watch(root, { recursive: true }); + await settle(); + + fs.writeFileSync(path.join(nested, 'deep.ts'), 'two'); + await waitFor(() => seen.some(f => f.endsWith('deep.ts'))); + }); + + it('ignores node_modules under the watched root', async () => { + const modules = path.join(root, 'node_modules', 'pkg'); + fs.mkdirSync(modules, { recursive: true }); + fs.writeFileSync(path.join(modules, 'index.js'), 'one'); + const seen = watch(root, { recursive: true }); + await settle(); + + fs.writeFileSync(path.join(modules, 'index.js'), 'two'); + await settle(); + expect(seen.filter(f => f.includes('node_modules'))).toEqual([]); + }); + + // What `ng build my-lib` does to dist: the whole directory is replaced, not + // rewritten in place. A raw recursive fs.watch misses this and then dies. + it('survives the watched directory being swapped out', async () => { + const dist = path.join(root, 'dist'); + fs.mkdirSync(dist); + fs.writeFileSync(path.join(dist, 'main.js'), 'one'); + + const seen = watch(dist, { recursive: true, poll: { intervalMs: 100 } }); + await settle(); + + const replacement = path.join(root, 'dist-next'); + fs.mkdirSync(replacement); + fs.writeFileSync(path.join(replacement, 'main.js'), 'two'); + fs.rmSync(dist, { recursive: true, force: true }); + fs.renameSync(replacement, dist); + + await waitFor(() => seen.some(f => f.endsWith('main.js'))); + + // And the watch is still live afterwards, which is the half fs.watch loses. + seen.length = 0; + fs.writeFileSync(path.join(dist, 'main.js'), 'three'); + await waitFor(() => seen.some(f => f.endsWith('main.js'))); + }); +}); diff --git a/src/utils/watchpack-watch.ts b/src/utils/watchpack-watch.ts new file mode 100644 index 0000000..06d33d2 --- /dev/null +++ b/src/utils/watchpack-watch.ts @@ -0,0 +1,40 @@ +import * as path from 'node:path'; +import Watchpack from 'watchpack'; +import type { WatchPort } from '@softarc/native-federation/internal'; + +/** + * Watchpack-backed replacement for core's built-in watch. Event-driven like `fs.watch` + * for a source directory, but it survives the `dist` swap `ng build ` does, which + * `fs.watch` reports once before dying on the deleted inode. + */ +export const watchpackWatch: WatchPort['watch'] = (watchPath, opts, onEvent) => { + const root = path.resolve(watchPath); + + const watchpack = new Watchpack({ + // Core hands us realpaths; following would only descend into .pnpm. + followSymlinks: false, + // Don't drop: watchpack only re-covers a swapped directory while polling. + poll: opts.poll ? opts.poll.intervalMs : false, + ignored: entry => { + const rel = path.relative(root, entry); + if (!rel || rel.startsWith('..')) return false; + const segments = rel.split(path.sep); + if (segments.includes('node_modules')) return true; + // Watchpack descends either way; this only collapses a deeper change onto its + // depth-1 ancestor. fs.watch would report nothing at all, but core's only + // non-recursive watch drops the collapsed path as an untracked file anyway. + return !opts.recursive && segments.length > 1; + }, + }); + + const emit = (entry: string): void => { + const rel = path.relative(root, entry); + if (rel) onEvent(rel); + }; + + watchpack.on('change', emit); + watchpack.on('remove', emit); + watchpack.watch({ directories: [root], startTime: Date.now() }); + + return { close: () => watchpack.close() }; +};