From ac3f1d51e61fd373c5ce3841dbdbfb47980edf78 Mon Sep 17 00:00:00 2001 From: Aukevanoost Date: Fri, 17 Jul 2026 14:06:06 +0200 Subject: [PATCH] fix: Added support for npm link --- README.md | 36 ++++ package.json | 4 +- pnpm-lock.yaml | 31 ++-- src/builders/build/builder.ts | 310 +++++++++++++++++++++------------ src/builders/remote/builder.ts | 17 +- 5 files changed, 263 insertions(+), 135 deletions(-) diff --git a/README.md b/README.md index 183d2c8..5c10cd5 100644 --- a/README.md +++ b/README.md @@ -581,6 +581,42 @@ For complete implementation details, configuration options, please refer to the **📖 [Fixing DX Friction: Automatic Shell Reloading in Native Federation](https://www.angulararchitects.io/en/blog/fixing-dx-friction-automatic-shell-reloading-in-native-federation/)** +### Developing `npm link`ed shared libraries + +A common local-development setup is to build a shared library in its own repo (with `ng build --watch`, which runs ng-packagr) and pull it into your host via [`npm link`](https://docs.npmjs.com/cli/commands/npm-link). This symlinks the library's `dist/` into your host's `node_modules`, so you can iterate on the library and the host together. + +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.5` (requires `@softarc/native-federation` ≥ `4.3.2`), the adapter detects linked shared packages and re-bundles them automatically on 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. +- 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. + +#### Workflow + +```bash +# 1. In the shared library's repo — build to dist/ and keep watching +ng build --watch + +# 2. Publish the built package to the local npm link registry +cd dist/my-lib && npm link + +# 3. In the host repo — link the package into node_modules +npm link @my-scope/my-lib + +# 4. Run the host as usual +ng serve +``` + +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. + ## FAQ ### When to use this package? diff --git a/package.json b/package.json index 1ebfdc9..1ce1e29 100644 --- a/package.json +++ b/package.json @@ -17,8 +17,8 @@ "@angular-devkit/core": "~22.0.0", "@angular-devkit/schematics": "~22.0.0", "@chialab/esbuild-plugin-commonjs": "^0.19.0", - "@softarc/native-federation": "^4.3.0", - "@softarc/native-federation-orchestrator": "^4.5.0", + "@softarc/native-federation": "^4.3.2", + "@softarc/native-federation-orchestrator": "^4.5.2", "es-module-shims": "^2.8.0", "esbuild": "^0.28.0", "mrmime": "^2.0.1" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 60bc804..3d0577f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,11 +21,11 @@ importers: specifier: ^0.19.0 version: 0.19.1 '@softarc/native-federation': - specifier: ^4.3.0 - version: 4.3.0(typescript@6.0.3) + specifier: ^4.3.2 + version: 4.3.2(typescript@6.0.3) '@softarc/native-federation-orchestrator': - specifier: ^4.5.0 - version: 4.5.0 + specifier: ^4.5.2 + version: 4.5.2 es-module-shims: specifier: ^2.8.0 version: 2.8.2 @@ -1816,12 +1816,12 @@ packages: resolution: {integrity: sha512-e+1Ob5Kxt9QH2Gige0Bl56CXclG819RMl5Fso3kwO58+9AQnR2QxvjbetB7LDRl+YEG3TXCE4s3vdrV4gqRRAQ==, tarball: https://registry.npmjs.org/@schematics/angular/-/angular-22.0.5.tgz} engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} - '@softarc/native-federation-orchestrator@4.5.0': - resolution: {integrity: sha512-PC5qwtDA6VEYtwBacgc4HTp9AaTV6EX420m3mgW3xMsfhKByh71AZjpWcGWluVynlx8Lz/DcBQFM4o1xOv/U2Q==, tarball: https://registry.npmjs.org/@softarc/native-federation-orchestrator/-/native-federation-orchestrator-4.5.0.tgz} + '@softarc/native-federation-orchestrator@4.5.2': + resolution: {integrity: sha512-3GM65AwdaDhqlbU9X7hg5TqF8FZfqHfIyKv2F28XIZwy1KPImVMKQUd28IUP7K8ROiFhMNltpwggGSF1xDZm/w==, tarball: https://registry.npmjs.org/@softarc/native-federation-orchestrator/-/native-federation-orchestrator-4.5.2.tgz} engines: {node: '>=22.14.0'} - '@softarc/native-federation@4.3.0': - resolution: {integrity: sha512-ahOQOTrvAQyfdg2ZdtduVW9C6iJpgrOYUaAsA2EWmdACT5Nm3NJRtGV/EdcPLC3jHjpiHHaFfUjy1bNoFOg+1w==, tarball: https://registry.npmjs.org/@softarc/native-federation/-/native-federation-4.3.0.tgz} + '@softarc/native-federation@4.3.2': + resolution: {integrity: sha512-kRYaAWXNDtj5GURzs0yucPeQ4lODxgnyoUUoNqfM7H4ePIYZFOV+GLu8MkXSHokswOirjSmGAaSZZHkvCT/JIg==, tarball: https://registry.npmjs.org/@softarc/native-federation/-/native-federation-4.3.2.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} @@ -3732,7 +3732,6 @@ snapshots: dependencies: '@emnapi/wasi-threads': 1.2.2 tslib: 2.8.1 - optional: true '@emnapi/core@1.4.5': dependencies: @@ -3747,7 +3746,6 @@ snapshots: '@emnapi/runtime@1.11.1': dependencies: tslib: 2.8.1 - optional: true '@emnapi/runtime@1.4.5': dependencies: @@ -3760,7 +3758,6 @@ snapshots: '@emnapi/wasi-threads@1.2.2': dependencies: tslib: 2.8.1 - optional: true '@esbuild/aix-ppc64@0.27.7': optional: true @@ -4199,8 +4196,8 @@ snapshots: '@napi-rs/wasm-runtime@0.2.4': dependencies: - '@emnapi/core': 1.4.5 - '@emnapi/runtime': 1.4.5 + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 '@tybys/wasm-util': 0.9.0 '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)': @@ -4511,7 +4508,7 @@ snapshots: detect-libc: 2.1.2 is-glob: 4.0.3 node-addon-api: 7.1.1 - picomatch: 4.0.4 + picomatch: 4.0.5 optionalDependencies: '@parcel/watcher-android-arm64': 2.5.6 '@parcel/watcher-darwin-arm64': 2.5.6 @@ -4687,11 +4684,11 @@ snapshots: transitivePeerDependencies: - chokidar - '@softarc/native-federation-orchestrator@4.5.0': + '@softarc/native-federation-orchestrator@4.5.2': dependencies: semver: 7.8.5 - '@softarc/native-federation@4.3.0(typescript@6.0.3)': + '@softarc/native-federation@4.3.2(typescript@6.0.3)': dependencies: '@softarc/sheriff-core': 0.19.6(typescript@6.0.3) chalk: 5.6.2 @@ -4988,7 +4985,7 @@ snapshots: brace-expansion@5.0.6: dependencies: - balanced-match: 4.0.3 + balanced-match: 4.0.4 brace-expansion@5.0.7: dependencies: diff --git a/src/builders/build/builder.ts b/src/builders/build/builder.ts index 7b5eb12..0ed503e 100644 --- a/src/builders/build/builder.ts +++ b/src/builders/build/builder.ts @@ -36,6 +36,7 @@ import { AbortedError, createNfWatcher, getDefaultCachePath, + linkedSharedDirs, logger, type NfFileWatcher, RebuildQueue, @@ -295,6 +296,11 @@ export async function* runBuilder( logger.measure(start, "To load the federation config."); const externals = getExternals(normalized.config); + + // Realpath'd dirs of npm-linked shared packages (`[]` if none, making the + // syncNfFileWatcher calls below a no-op) so the linked lib's real source is watched. + const linkedDirs = linkedSharedDirs(normalized.config, normalized.options); + const plugins = [ { name: "externals", @@ -402,9 +408,39 @@ export async function* runBuilder( let first = true; + // A linked shared-package edit never makes Angular's iterator emit (it's an external), + // so we wake the watch loop directly: notifyChange resolves changeSignal, which the + // loop races against Angular's next output to drive a federation-only rebuild. + let notifyChange: () => void = () => {}; + let changeSignal: Promise = new Promise( + (r) => (notifyChange = r), + ); + let changeTagged: Promise<{ kind: "watcher" }> = changeSignal.then(() => ({ + kind: "watcher" as const, + })); + const resetChangeSignal = (): void => { + changeSignal = new Promise((r) => (notifyChange = r)); + changeTagged = changeSignal.then(() => ({ kind: "watcher" as const })); + }; + const isUnderLinkedDir = (p: string): boolean => + linkedDirs.some((d) => p === d || p.startsWith(d + path.sep)); + + // watcherRef lets onChange reach the watcher without a const self-reference. + const watcherRef: { current?: NfFileWatcher } = {}; const nfWatcher: NfFileWatcher | undefined = watch - ? createNfWatcher() + ? createNfWatcher({ + // Coalesce ng-packagr's atomic multi-write bursts into one rebuild. + debounceMs: 100, + onChange: (p) => { + // Core stops filling the dirty buffer once onChange is set, so refill it + // here (Set.add stays idempotent if core is later fixed). Only wake the + // loop for linked edits; others ride the next Angular-driven rebuild. + watcherRef.current?.mutate((s) => s.add(p)); + if (isUnderLinkedDir(p)) notifyChange(); + }, + }) : undefined; + watcherRef.current = nfWatcher; if (nfWatcher) { nfWatcher.addPaths( @@ -442,6 +478,7 @@ export async function* runBuilder( syncNfFileWatcher( nfWatcher, normalized.options.federationCache.bundlerCache, + linkedDirs, ); } @@ -498,129 +535,174 @@ export async function* runBuilder( let ngBuildStatus: { success: boolean } = { success: false }; + // Shared by both rebuild triggers. RebuildQueue serializes calls (aborts + awaits the + // previous build before starting the next), so the two drivers never run this at once. + const runFederationRebuild = async ( + signal: AbortSignal, + ): Promise<{ success: boolean; cancelled?: boolean }> => { + try { + if (signal?.aborted) { + throw new AbortedError("Build canceled before starting"); + } + + await new Promise((resolve, reject) => { + const timeout = setTimeout( + resolve, + Math.max(10, nfBuilderOptions.rebuildDelay), + ); + + if (signal) { + const abortHandler = () => { + clearTimeout(timeout); + reject(new AbortedError("[builder] During delay.")); + }; + signal.addEventListener("abort", abortHandler, { + once: true, + }); + } + }); + + if (signal?.aborted) { + throw new AbortedError("[builder] Before federation build."); + } + + // Invalidate only files that changed since the last rebuild, falling back to all + // source files when the buffer is empty (e.g. first watch rebuild). Unlike the + // remote builder, the buffer is cleared eagerly here because Angular's iterator — + // not this buffer — re-triggers the next Angular-driven rebuild. + const changedFiles = nfWatcher ? [...nfWatcher.get()] : []; + + if (nfWatcher) nfWatcher.clear(); + + federationResult = await rebuildForFederation( + normalized.config, + normalized.options, + externals, + changedFiles, + signal, + ); + + if (nfWatcher) { + syncNfFileWatcher( + nfWatcher, + normalized.options.federationCache.bundlerCache, + linkedDirs, + ); + } + + if (signal?.aborted) { + throw new AbortedError("[builder] After federation build."); + } + + if (hasLocales && localeFilter) { + translateFederationArtifacts( + i18n, + localeFilter, + outputOptions.base, + federationResult, + ); + } + + if (signal?.aborted) { + throw new AbortedError("[builder] After federation translations."); + } + + logger.info("Done!"); + + if (isLocalDevelopment) { + federationBuildNotifier.broadcastBuildCompletion(); + } + return { success: true }; + } catch (error) { + if (error instanceof AbortedError) { + logger.verbose( + "Rebuild was canceled. Cancellation point: " + error?.message, + ); + federationBuildNotifier.broadcastBuildCancellation(); + return { success: false, cancelled: true }; + } + logger.error("Federation rebuild failed!"); + if (ngBuilderOptions.verbose) console.error(error); + if (isLocalDevelopment) { + federationBuildNotifier.broadcastBuildError(error); + } + return { success: false }; + } + }; + try { - let buildResult = await builderIterator.next(); + // In-flight Angular output, tagged for the race below and only reassigned when + // consumed (so a linked-package rebuild preserves it). The tag captures a rejected + // iterator instead of rejecting — re-thrown on consume — so a losing race can't leak + // an unhandled rejection. + let angularNext = builderIterator.next(); + let angularTagged = angularNext.then( + (r) => ({ kind: "angular" as const, r, err: undefined as unknown }), + (err) => ({ kind: "angular" as const, r: undefined, err }), + ); + const advanceAngular = (): void => { + angularNext = builderIterator.next(); + angularTagged = angularNext.then( + (r) => ({ kind: "angular" as const, r, err: undefined as unknown }), + (err) => ({ kind: "angular" as const, r: undefined, err }), + ); + }; - while (!buildResult.done) { + while (true) { + const trigger = await Promise.race([angularTagged, changeTagged]); + + // A linked shared-package edit: Angular produced nothing, so rebuild federation + // only and leave the pending Angular output in flight for the next race. + if (trigger.kind === "watcher") { + resetChangeSignal(); + if (first || !nfWatcher || nfWatcher.get().size === 0) continue; + + // Interrupt on the next linked edit so a fresh save folds into one rebuild. + const trackResult = await rebuildQueue.track( + runFederationRebuild, + changeSignal, + ); + if (trackResult.type === "completed" && !trackResult.result.cancelled) { + yield { success: trackResult.result.success }; + } + continue; + } + + if (trigger.err) throw trigger.err; + const buildResult = trigger.r!; + if (buildResult.done) break; if (buildResult.value) ngBuildStatus = buildResult.value; + // Consume this output and prime the next; the primed promise doubles as the + // interrupt for the rebuild below (a fresh Angular build aborts the stale one). + advanceAngular(); + if (!ngBuildStatus.success) { logger.warn( "Skipping federation artifacts because Angular build failed.", ); - buildResult = await builderIterator.next(); - } else if (!first && watch) { - const nextOutputPromise = builderIterator.next(); + first = false; + continue; + } - const trackResult = await rebuildQueue.track( - async (signal: AbortSignal) => { - try { - if (signal?.aborted) { - throw new AbortedError("Build canceled before starting"); - } - - await new Promise((resolve, reject) => { - const timeout = setTimeout( - resolve, - Math.max(10, nfBuilderOptions.rebuildDelay), - ); - - if (signal) { - const abortHandler = () => { - clearTimeout(timeout); - reject(new AbortedError("[builder] During delay.")); - }; - signal.addEventListener("abort", abortHandler, { - once: true, - }); - } - }); - - if (signal?.aborted) { - throw new AbortedError("[builder] Before federation build."); - } - - // Invalidate only files that changed since the last rebuild, falling back to all - // source files when the buffer is empty (e.g. first watch rebuild). Unlike the - // remote builder, the buffer is cleared eagerly here because Angular's iterator — - // not this buffer — re-triggers the next rebuild. - const changedFiles = nfWatcher ? [...nfWatcher.get()] : []; - - if (nfWatcher) nfWatcher.clear(); - - federationResult = await rebuildForFederation( - normalized.config, - normalized.options, - externals, - changedFiles, - signal, - ); - - if (nfWatcher) { - syncNfFileWatcher( - nfWatcher, - normalized.options.federationCache.bundlerCache, - ); - } - - if (signal?.aborted) { - throw new AbortedError("[builder] After federation build."); - } - - if (hasLocales && localeFilter) { - translateFederationArtifacts( - i18n, - localeFilter, - outputOptions.base, - federationResult, - ); - } - - if (signal?.aborted) { - throw new AbortedError( - "[builder] After federation translations.", - ); - } - - logger.info("Done!"); - - if (isLocalDevelopment) { - federationBuildNotifier.broadcastBuildCompletion(); - } - return { success: true }; - } catch (error) { - if (error instanceof AbortedError) { - logger.verbose( - "Rebuild was canceled. Cancellation point: " + error?.message, - ); - federationBuildNotifier.broadcastBuildCancellation(); - return { success: false, cancelled: true }; - } - logger.error("Federation rebuild failed!"); - if (ngBuilderOptions.verbose) console.error(error); - if (isLocalDevelopment) { - federationBuildNotifier.broadcastBuildError(error); - } - return { success: false }; - } - }, - nextOutputPromise, - ); + // The initial output's federation artifacts were already built above; only + // rebuild on subsequent outputs, and only in watch mode. + if (first || !watch) { + first = false; + continue; + } - // Same trackResult shape as the remote builder, plus the iterator pump: - // the 'interrupted' branch feeds Angular's next output back into the loop - // (the remote builder has no iterator, so it just loops on its watcher). - if (trackResult.type === "completed") { - if (!trackResult.result.cancelled) { - ngBuildStatus = { success: trackResult.result.success }; - yield ngBuildStatus; - } - buildResult = await nextOutputPromise; - } else { - buildResult = trackResult.value; - } - } else { - buildResult = await builderIterator.next(); + const trackResult = await rebuildQueue.track( + runFederationRebuild, + angularNext, + ); + + // 'completed': the primed output is still pending — the next race consumes it. + // 'interrupted': the primed output resolved (a newer Angular build) — the next + // race consumes that resolved promise immediately, matching the old feed-back. + if (trackResult.type === "completed" && !trackResult.result.cancelled) { + ngBuildStatus = { success: trackResult.result.success }; + yield ngBuildStatus; } first = false; } diff --git a/src/builders/remote/builder.ts b/src/builders/remote/builder.ts index 68af8c6..482e267 100644 --- a/src/builders/remote/builder.ts +++ b/src/builders/remote/builder.ts @@ -22,6 +22,7 @@ import { AbortedError, getDefaultCachePath, syncNfFileWatcher, + linkedSharedDirs, } from '@softarc/native-federation/internal'; import { createAngularBuildAdapter } from '../../tools/esbuild/angular-esbuild-adapter.js'; @@ -96,6 +97,10 @@ export async function* runRemoteBuilder( const externals = getExternals(normalized.config); + // Realpath'd dirs of npm-linked shared packages (`[]` if none, making the + // syncNfFileWatcher calls below a no-op) so the linked lib's real source is watched. + const linkedDirs = linkedSharedDirs(normalized.config, normalized.options); + const assetEntries = normalizeRemoteAssetEntries( nfBuilderOptions.assets, context.workspaceRoot, @@ -131,7 +136,11 @@ export async function* runRemoteBuilder( await copyAllAssets(assetEntries, absoluteBrowserOutput, context.workspaceRoot); if (changeWatcher) { - syncNfFileWatcher(changeWatcher.watcher, normalized.options.federationCache.bundlerCache); + syncNfFileWatcher( + changeWatcher.watcher, + normalized.options.federationCache.bundlerCache, + linkedDirs + ); } const rebuildQueue = new RebuildQueue(); @@ -188,7 +197,11 @@ export async function* runRemoteBuilder( // remain in pendingPaths and will drive the next iteration. for (const p of changedFiles) changeWatcher.pendingPaths.delete(p); - syncNfFileWatcher(changeWatcher.watcher, normalized.options.federationCache.bundlerCache); + syncNfFileWatcher( + changeWatcher.watcher, + normalized.options.federationCache.bundlerCache, + linkedDirs + ); if (signal?.aborted) { throw new AbortedError('[remote-builder] After federation build.');