diff --git a/apps/README.md b/apps/README.md index b78bd6d2..25ba7c62 100644 --- a/apps/README.md +++ b/apps/README.md @@ -76,11 +76,6 @@ These exist only because the demo lives inside the plugin's own repository: rebuilds `mf-runtime`, and ng-packagr recreates `dist/libs/mf-runtime`, which would yank the module out from under a dev-server already watching it. Using sources also means editing a library live-reloads the demo. -- `shell/tsconfig.app.json` sets `strict: false`. `libs/mf-runtime` is itself - compiled with `strict: false` and violates `strictNullChecks` and - `noImplicitAny`, and a strict program cannot consume those sources. Only the - app build is relaxed; the specs still type-check strictly. `mfe1` and `mfe2` - don't import the runtime, so they stay fully strict. Everything else is what `ng g @angular-architects/module-federation:init-webpack` generates for an Nx workspace: the `@nx/angular:webpack-browser` and diff --git a/apps/mfe1/tsconfig.spec.json b/apps/mfe1/tsconfig.spec.json index c5db0277..d9bf3849 100644 --- a/apps/mfe1/tsconfig.spec.json +++ b/apps/mfe1/tsconfig.spec.json @@ -2,7 +2,12 @@ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "../../dist/out-tsc", - "module": "commonjs", + // `@angular/core/testing` is reachable only through the package's `exports` + // map, which the inherited `moduleResolution: node` cannot read. Bundler + // resolution can, and it requires `module` to be es2015 or later — + // jest-preset-angular emits CommonJS regardless. + "module": "esnext", + "moduleResolution": "bundler", "types": ["jest", "node"] }, "files": ["src/test-setup.ts"], diff --git a/apps/mfe2/tsconfig.spec.json b/apps/mfe2/tsconfig.spec.json index c5db0277..d9bf3849 100644 --- a/apps/mfe2/tsconfig.spec.json +++ b/apps/mfe2/tsconfig.spec.json @@ -2,7 +2,12 @@ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "../../dist/out-tsc", - "module": "commonjs", + // `@angular/core/testing` is reachable only through the package's `exports` + // map, which the inherited `moduleResolution: node` cannot read. Bundler + // resolution can, and it requires `module` to be es2015 or later — + // jest-preset-angular emits CommonJS regardless. + "module": "esnext", + "moduleResolution": "bundler", "types": ["jest", "node"] }, "files": ["src/test-setup.ts"], diff --git a/apps/shell/tsconfig.app.json b/apps/shell/tsconfig.app.json index df12f72b..7d7a8070 100644 --- a/apps/shell/tsconfig.app.json +++ b/apps/shell/tsconfig.app.json @@ -2,14 +2,7 @@ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "../../dist/out-tsc", - "types": [], - // In-repo demo only. The inherited `paths` resolve - // @angular-architects/module-federation to libs/mf-runtime/src, which is - // itself compiled with `strict: false` (see libs/mf-runtime/tsconfig.json) - // and violates both strictNullChecks and noImplicitAny. A strict program - // cannot consume those sources, so this build matches the library. Only - // tsconfig.app.json is relaxed — the specs still type-check strictly. - "strict": false + "types": [] }, "files": ["src/main.ts", "src/polyfills.ts"], "include": ["src/**/*.d.ts"], diff --git a/apps/shell/tsconfig.spec.json b/apps/shell/tsconfig.spec.json index c5db0277..d9bf3849 100644 --- a/apps/shell/tsconfig.spec.json +++ b/apps/shell/tsconfig.spec.json @@ -2,7 +2,12 @@ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "../../dist/out-tsc", - "module": "commonjs", + // `@angular/core/testing` is reachable only through the package's `exports` + // map, which the inherited `moduleResolution: node` cannot read. Bundler + // resolution can, and it requires `module` to be es2015 or later — + // jest-preset-angular emits CommonJS regardless. + "module": "esnext", + "moduleResolution": "bundler", "types": ["jest", "node"] }, "files": ["src/test-setup.ts"], diff --git a/libs/mf-runtime/src/lib/loader/__fixtures__/esm-remote-entry.js b/libs/mf-runtime/src/lib/loader/__fixtures__/esm-remote-entry.js new file mode 100644 index 00000000..594141c9 --- /dev/null +++ b/libs/mf-runtime/src/lib/loader/__fixtures__/esm-remote-entry.js @@ -0,0 +1,17 @@ +// Stands in for a webpack-built ESM remote entry. `loadRemoteModuleEntry` does a +// dynamic `import()` of the remoteEntry string, which the spec transform turns +// into a `require()` — so pointing remoteEntry at this file's absolute path +// exercises the real code path instead of mocking it away. + +const calls = { init: [], get: [] }; + +exports.calls = calls; + +exports.init = (shareScope) => { + calls.init.push(shareScope); +}; + +exports.get = (exposedModule) => { + calls.get.push(exposedModule); + return () => ({ loadedFrom: 'esm-remote-entry', exposedModule }); +}; diff --git a/libs/mf-runtime/src/lib/loader/dynamic-federation.spec.ts b/libs/mf-runtime/src/lib/loader/dynamic-federation.spec.ts new file mode 100644 index 00000000..dd37f060 --- /dev/null +++ b/libs/mf-runtime/src/lib/loader/dynamic-federation.spec.ts @@ -0,0 +1,215 @@ +import { join } from 'path'; + +type DynamicFederation = typeof import('./dynamic-federation'); + +// The module keeps `config`, `containerMap` and the share-scope flag in module +// scope, so every test needs a freshly required copy. +let df: DynamicFederation; + +const globalRef = globalThis as unknown as Record; + +// Absolute path so the `import()` inside loadRemoteModuleEntry resolves it. +const ESM_REMOTE_ENTRY = join(__dirname, '__fixtures__/esm-remote-entry.js'); + +type ScriptContainer = { + init: jest.Mock; + get: jest.Mock; +}; + +function createScriptContainer(): ScriptContainer { + return { + init: jest.fn(), + get: jest.fn((exposedModule: string) => () => ({ + loadedFrom: 'script-remote-entry', + exposedModule, + })), + }; +} + +// A real remote entry script registers `window[remoteName]` as a side effect of +// executing; jsdom never fetches the src, so we do it on appendChild and then +// fire onload by hand. +function stubScriptLoading(containers: Record): { + srcs: string[]; +} { + const srcs: string[] = []; + + jest + .spyOn(document.body, 'appendChild') + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .mockImplementation((node: any) => { + srcs.push(node.src); + Object.assign(globalRef, containers); + node.onload(); + return node; + }); + + return { srcs }; +} + +beforeEach(async () => { + jest.resetModules(); + jest.restoreAllMocks(); + + globalRef['__webpack_init_sharing__'] = jest + .fn() + .mockResolvedValue(undefined); + globalRef['__webpack_share_scopes__'] = { default: { fakeScope: true } }; + + df = await import('./dynamic-federation'); +}); + +describe('loadRemoteModule via a manifest', () => { + it('loads a remote declared as type "module"', async () => { + await df.initFederation( + { mfe1: { type: 'module', remoteEntry: ESM_REMOTE_ENTRY } }, + true, + ); + + const module = await df.loadRemoteModule({ + type: 'manifest', + remoteName: 'mfe1', + exposedModule: './Component', + }); + + expect(module).toEqual({ + loadedFrom: 'esm-remote-entry', + exposedModule: './Component', + }); + }); + + it('loads a remote declared as type "script"', async () => { + const container = createScriptContainer(); + const { srcs } = stubScriptLoading({ mfe2: container }); + + await df.initFederation( + { + mfe2: { + type: 'script', + remoteEntry: 'http://localhost:4201/remoteEntry.js', + }, + }, + true, + ); + + const module = await df.loadRemoteModule({ + type: 'manifest', + remoteName: 'mfe2', + exposedModule: './Component', + }); + + expect(srcs).toEqual(['http://localhost:4201/remoteEntry.js']); + expect(container.init).toHaveBeenCalledWith({ fakeScope: true }); + expect(module).toEqual({ + loadedFrom: 'script-remote-entry', + exposedModule: './Component', + }); + }); + + it('defaults an entry with no type to "module"', async () => { + // parseConfig fills in `type: 'module'` for both shorthand strings and + // objects that omit it. + await df.initFederation({ mfe1: ESM_REMOTE_ENTRY }, true); + + expect(df.getManifest()).toEqual({ + mfe1: { type: 'module', remoteEntry: ESM_REMOTE_ENTRY }, + }); + + await expect( + df.loadRemoteModule({ + type: 'manifest', + remoteName: 'mfe1', + exposedModule: './Component', + }), + ).resolves.toBeDefined(); + }); + + it('supports the legacy two-string overload', async () => { + await df.initFederation( + { mfe1: { type: 'module', remoteEntry: ESM_REMOTE_ENTRY } }, + true, + ); + + const module = await df.loadRemoteModule('mfe1', './Component'); + + expect(module).toEqual({ + loadedFrom: 'esm-remote-entry', + exposedModule: './Component', + }); + }); + + it('throws when the remote is not in the manifest', async () => { + await df.initFederation( + { mfe1: { type: 'module', remoteEntry: ESM_REMOTE_ENTRY } }, + true, + ); + + await expect( + df.loadRemoteModule('unknown-mfe', './Component'), + ).rejects.toThrow('Manifest does not contain unknown-mfe'); + }); + + it('throws a diagnostic naming the remote and the bad type', async () => { + // The manifest is fetched at runtime and never validated, so a typo like + // this reaches loadRemoteModule intact. + await df.initFederation( + { mfe1: { type: 'esm', remoteEntry: ESM_REMOTE_ENTRY } } as never, + true, + ); + + await expect(df.loadRemoteModule('mfe1', './Component')).rejects.toThrow( + 'Unsupported type "esm" for remote "mfe1" - expected "module" or "script"', + ); + }); +}); + +describe('loadRemoteModule without a manifest', () => { + it('treats options with no type as a script remote', async () => { + const container = createScriptContainer(); + const { srcs } = stubScriptLoading({ mfe2: container }); + + const module = await df.loadRemoteModule({ + remoteName: 'mfe2', + remoteEntry: 'http://localhost:4201/remoteEntry.js', + exposedModule: './Component', + }); + + expect(srcs).toEqual(['http://localhost:4201/remoteEntry.js']); + expect(module).toEqual({ + loadedFrom: 'script-remote-entry', + exposedModule: './Component', + }); + }); + + it('skips loading the remote entry when none is given', async () => { + const container = createScriptContainer(); + const { srcs } = stubScriptLoading({ mfe2: container }); + + // The entry was already pulled in by an earlier loadRemoteEntry call. + await df.loadRemoteEntry('http://localhost:4201/remoteEntry.js', 'mfe2'); + expect(srcs).toEqual(['http://localhost:4201/remoteEntry.js']); + + const module = await df.loadRemoteModule({ + remoteName: 'mfe2', + exposedModule: './Component', + }); + + expect(srcs).toHaveLength(1); + expect(module).toEqual({ + loadedFrom: 'script-remote-entry', + exposedModule: './Component', + }); + }); +}); + +describe('loadRemoteEntry', () => { + it('throws when the legacy overload is called without a remoteName', async () => { + await expect( + (df.loadRemoteEntry as unknown as (remoteEntry: string) => Promise)( + 'http://localhost:4201/remoteEntry.js', + ), + ).rejects.toThrow( + 'No remoteName passed for remote entry "http://localhost:4201/remoteEntry.js"', + ); + }); +}); diff --git a/libs/mf-runtime/src/lib/loader/dynamic-federation.ts b/libs/mf-runtime/src/lib/loader/dynamic-federation.ts index 6a6e3f33..06f2f113 100644 --- a/libs/mf-runtime/src/lib/loader/dynamic-federation.ts +++ b/libs/mf-runtime/src/lib/loader/dynamic-federation.ts @@ -63,8 +63,7 @@ async function initRemote(container: Container, key: string) { } export type LoadRemoteEntryOptions = - | LoadRemoteEntryScriptOptions - | LoadRemoteEntryEsmOptions; + LoadRemoteEntryScriptOptions | LoadRemoteEntryEsmOptions; export type LoadRemoteEntryScriptOptions = { type?: 'script'; @@ -92,6 +91,9 @@ export async function loadRemoteEntry( ): Promise { if (typeof remoteEntryOrOptions === 'string') { const remoteEntry = remoteEntryOrOptions; + if (!remoteName) { + throw new Error(`No remoteName passed for remote entry "${remoteEntry}"`); + } return await loadRemoteScriptEntry(remoteEntry, remoteName, nonce); } else if (remoteEntryOrOptions.type === 'script') { const options = remoteEntryOrOptions; @@ -139,7 +141,9 @@ async function loadRemoteScriptEntry( script.onerror = reject; script.onload = () => { - const container = window[remoteName] as Container; + const container = (window as unknown as Record)[ + remoteName + ] as Container; initRemote(container, remoteName); containerMap[remoteName] = container; resolve(); @@ -174,74 +178,122 @@ export type LoadRemoteModuleManifestOptions = { exposedModule: string; }; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export async function loadRemoteModule( +// `LoadRemoteModuleOptions` after the manifest lookup and the legacy defaulting +// have been applied, so `type` is always one of the two loadable variants. +type ResolvedRemoteModuleOptions = + | { + type: 'script'; + remoteEntry?: string; + remoteName: string; + exposedModule: string; + nonce?: string; + } + | { type: 'module'; remoteEntry: string; exposedModule: string }; + +function resolveManifestEntry( remoteName: string, exposedModule: string, -): Promise; -export async function loadRemoteModule( - options: LoadRemoteModuleOptions, -): Promise; -export async function loadRemoteModule( - optionsOrRemoteName: LoadRemoteModuleOptions | string, - exposedModule?: string, -): Promise { - let loadRemoteEntryOptions: LoadRemoteEntryOptions; - let key: string; - let remoteEntry: string; - let options: LoadRemoteModuleOptions; +): ResolvedRemoteModuleOptions { + const manifestEntry = config[remoteName]; - if (typeof optionsOrRemoteName === 'string') { - options = { - type: 'manifest', - remoteName: optionsOrRemoteName, - exposedModule: exposedModule, - }; - } else { - options = optionsOrRemoteName; + if (!manifestEntry) { + throw new Error('Manifest does not contain ' + remoteName); } - // To support legacy API (< ng 13) - if (!options.type) { - const hasManifest = Object.keys(config).length > 0; - options.type = hasManifest ? 'manifest' : 'script'; - } + // The manifest is fetched at runtime and parseConfig does not validate it, so + // `type` is only nominally 'module' | 'script'. + const type: string = manifestEntry.type; - if (options.type === 'manifest') { - const manifestEntry = config[options.remoteName]; - if (!manifestEntry) { - throw new Error('Manifest does not contain ' + options.remoteName); - } - options = { - type: manifestEntry.type, - exposedModule: options.exposedModule, + if (type === 'script') { + return { + type: 'script', remoteEntry: manifestEntry.remoteEntry, - remoteName: - manifestEntry.type === 'script' ? options.remoteName : undefined, + remoteName, + exposedModule, }; - remoteEntry = manifestEntry.remoteEntry; - } else { - remoteEntry = options.remoteEntry; } - if (options.type === 'script') { - loadRemoteEntryOptions = { - type: 'script', - remoteEntry: options.remoteEntry, - remoteName: options.remoteName, - nonce: options.nonce, - }; - key = options.remoteName; - } else if (options.type === 'module') { - loadRemoteEntryOptions = { + if (type === 'module') { + return { type: 'module', - remoteEntry: options.remoteEntry, + remoteEntry: manifestEntry.remoteEntry, + exposedModule, }; - key = options.remoteEntry; } + throw new Error( + `Unsupported type "${type}" for remote "${remoteName}" - expected "module" or "script"`, + ); +} + +function resolveOptions( + optionsOrRemoteName: LoadRemoteModuleOptions | string, + exposedModule?: string, +): ResolvedRemoteModuleOptions { + if (typeof optionsOrRemoteName === 'string') { + if (!exposedModule) { + throw new Error( + `No exposedModule passed for remote "${optionsOrRemoteName}"`, + ); + } + return resolveManifestEntry(optionsOrRemoteName, exposedModule); + } + + const options = optionsOrRemoteName; + + if (options.type === 'module') { + return options; + } + + if (options.type === 'manifest') { + return resolveManifestEntry(options.remoteName, options.exposedModule); + } + + // To support legacy API (< ng 13): a missing type means manifest whenever one + // is loaded, and script otherwise. + if (!options.type && Object.keys(config).length > 0) { + return resolveManifestEntry(options.remoteName, options.exposedModule); + } + + const type: string = options.type ?? 'script'; + if (type !== 'script') { + throw new Error( + `Unsupported type "${type}" for remote "${options.remoteName}" - expected "module" or "script"`, + ); + } + + return { ...options, type: 'script' }; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export async function loadRemoteModule( + remoteName: string, + exposedModule: string, +): Promise; +export async function loadRemoteModule( + options: LoadRemoteModuleOptions, +): Promise; +export async function loadRemoteModule( + optionsOrRemoteName: LoadRemoteModuleOptions | string, + exposedModule?: string, +): Promise { + const options = resolveOptions(optionsOrRemoteName, exposedModule); + + const key = + options.type === 'script' ? options.remoteName : options.remoteEntry; + + const remoteEntry = options.remoteEntry; if (remoteEntry) { - await loadRemoteEntry(loadRemoteEntryOptions); + await loadRemoteEntry( + options.type === 'script' + ? { + type: 'script', + remoteEntry, + remoteName: options.remoteName, + nonce: options.nonce, + } + : { type: 'module', remoteEntry }, + ); } return await lookupExposedModule(key, options.exposedModule); diff --git a/libs/mf-runtime/src/test-setup.ts b/libs/mf-runtime/src/test-setup.ts index 0295e20b..66fbd4f0 100644 --- a/libs/mf-runtime/src/test-setup.ts +++ b/libs/mf-runtime/src/test-setup.ts @@ -1,5 +1,5 @@ -// @ts-expect-error https://thymikee.github.io/jest-preset-angular/docs/getting-started/test-environment -globalThis.ngJest = { +// https://thymikee.github.io/jest-preset-angular/docs/getting-started/test-environment +(globalThis as unknown as Record)['ngJest'] = { testEnvironmentOptions: { errorOnUnknownElements: true, errorOnUnknownProperties: true, diff --git a/libs/mf-runtime/tsconfig.json b/libs/mf-runtime/tsconfig.json index 50487a0b..a808df06 100644 --- a/libs/mf-runtime/tsconfig.json +++ b/libs/mf-runtime/tsconfig.json @@ -4,7 +4,7 @@ "target": "es2022", "useDefineForClassFields": false, "forceConsistentCasingInFileNames": true, - "strict": false, + "strict": true, "noImplicitOverride": true, "noPropertyAccessFromIndexSignature": true, "noImplicitReturns": true, diff --git a/libs/mf/src/utils/__fixtures__/tsconfig.paths.json b/libs/mf/src/utils/__fixtures__/tsconfig.paths.json new file mode 100644 index 00000000..7d1dc5ae --- /dev/null +++ b/libs/mf/src/utils/__fixtures__/tsconfig.paths.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "paths": { + "@angular-architects/module-federation": ["mf-runtime/src/index.ts"], + "tslib": ["tslib/src/index.ts"], + "@angular/router/upgrade": ["router-upgrade/src/index.ts"], + "my-lib": ["my-lib/src/index.ts"], + "opt-out-lib": ["opt-out-lib/src/index.ts"] + } + } +} diff --git a/libs/mf/src/utils/with-mf-plugin.spec.ts b/libs/mf/src/utils/with-mf-plugin.spec.ts new file mode 100644 index 00000000..2b082abf --- /dev/null +++ b/libs/mf/src/utils/with-mf-plugin.spec.ts @@ -0,0 +1,95 @@ +import { join } from 'path'; + +// findRootTsConfigJson() walks up from cwd() to the real workspace tsconfig. +// Point it at a fixture instead so the mappings under test are fixed. +jest.mock('./share-utils', () => ({ + ...jest.requireActual('./share-utils'), + findRootTsConfigJson: jest.fn(), +})); + +import { findRootTsConfigJson } from './share-utils'; +import { withModuleFederationPlugin } from './with-mf-plugin'; + +const FIXTURE_TSCONFIG = join(__dirname, '__fixtures__/tsconfig.paths.json'); + +beforeEach(() => { + (findRootTsConfigJson as jest.Mock).mockReturnValue(FIXTURE_TSCONFIG); +}); + +// `shared: {}` keeps shareAll() — which reads the real root package.json — out +// of these tests. The sharedMappings descriptors are merged into it either way. +function build(config: Record) { + // withModuleFederationPlugin mutates the config it is handed, merging the + // mapping descriptors into `shared` before passing it to the webpack plugin. + const mfConfig: Record = { shared: {}, ...config }; + const result = withModuleFederationPlugin(mfConfig); + + return { + aliases: Object.keys(result.resolve.alias), + shared: Object.keys(mfConfig['shared']), + }; +} + +describe('withModuleFederationPlugin sharedMappings', () => { + it('maps a library that is not skip-listed', () => { + const { aliases, shared } = build({ sharedMappings: ['my-lib'] }); + + expect(aliases).toEqual(['my-lib']); + expect(shared).toEqual(['my-lib']); + }); + + it('drops entries on DEFAULT_SKIP_LIST', () => { + const { aliases, shared } = build({ + sharedMappings: [ + '@angular-architects/module-federation', + 'tslib', + 'my-lib', + ], + }); + + expect(aliases).toEqual(['my-lib']); + expect(shared).toEqual(['my-lib']); + }); + + it('drops entries on DEFAULT_SECONDARIES_SKIP_LIST', () => { + const { aliases, shared } = build({ + sharedMappings: ['@angular/router/upgrade', 'my-lib'], + }); + + expect(aliases).toEqual(['my-lib']); + expect(shared).toEqual(['my-lib']); + }); + + it('drops entries named in the caller-supplied skip option', () => { + const { aliases, shared } = build({ + sharedMappings: ['opt-out-lib', 'my-lib'], + skip: ['opt-out-lib'], + }); + + expect(aliases).toEqual(['my-lib']); + expect(shared).toEqual(['my-lib']); + }); + + it('maps nothing when every entry is skipped', () => { + // An empty array must not be mistaken for "no sharedMappings given", which + // is what turns on the map-every-tsconfig-path behaviour below. + const { aliases, shared } = build({ sharedMappings: ['tslib'] }); + + expect(aliases).toEqual([]); + expect(shared).toEqual([]); + }); + + it('maps every tsconfig path when sharedMappings is omitted', () => { + // Pre-existing asymmetry: the skip list is only consulted for an explicit + // sharedMappings array, so this path still maps skip-listed keys. + const { aliases } = build({}); + + expect(aliases).toEqual([ + '@angular-architects/module-federation', + 'tslib', + '@angular/router/upgrade', + 'my-lib', + 'opt-out-lib', + ]); + }); +}); diff --git a/libs/mf/src/utils/with-mf-plugin.ts b/libs/mf/src/utils/with-mf-plugin.ts index f4509713..444c8d17 100644 --- a/libs/mf/src/utils/with-mf-plugin.ts +++ b/libs/mf/src/utils/with-mf-plugin.ts @@ -10,7 +10,7 @@ import { ModifyEntryPlugin } from './modify-entry-plugin'; import ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin'); export function withModuleFederationPlugin(config: unknown) { - const sharedMappings = config['sharedMappings']; + let sharedMappings = config['sharedMappings']; delete config['sharedMappings']; const skip = [ @@ -22,7 +22,7 @@ export function withModuleFederationPlugin(config: unknown) { delete config['skip']; if (sharedMappings) { - sharedMappings.filter((m) => !skip.includes(m)); + sharedMappings = sharedMappings.filter((m) => !skip.includes(m)); } const mappings = new SharedMappings();