From 12528da271c2eafe12c424bdb5b9b63d775d95a0 Mon Sep 17 00:00:00 2001 From: Manfred Steyer Date: Fri, 20 Nov 2020 16:12:23 +0100 Subject: [PATCH] dynamic mf, sharing libs, bootstrap/main --- package-lock.json | 4 +- package.json | 3 +- packages/mf/README.md | 2 +- packages/mf/package.json | 2 +- packages/mf/src/index.ts | 1 + packages/mf/src/schematics/mf/schematic.ts | 36 ++++++-- packages/mf/src/{ => utils}/create-config.ts | 16 +++- packages/mf/src/utils/dynamic-federation.ts | 66 +++++++++++++++ packages/mf/src/utils/shared-mappings.ts | 84 +++++++++++++++++++ .../mf/src/utils/webpack-runtime-api.d.ts | 10 +++ packages/mf/src/webpack.ts | 1 + 11 files changed, 211 insertions(+), 14 deletions(-) rename packages/mf/src/{ => utils}/create-config.ts (62%) create mode 100644 packages/mf/src/utils/dynamic-federation.ts create mode 100644 packages/mf/src/utils/shared-mappings.ts create mode 100644 packages/mf/src/utils/webpack-runtime-api.d.ts create mode 100644 packages/mf/src/webpack.ts diff --git a/package-lock.json b/package-lock.json index b9a1010f..11ab6b9f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7265,7 +7265,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.1.3.tgz", "integrity": "sha512-KXPvOm8K9IJKFM0bmdn8QXh7udDh1g/giieX0NLCaMnb4hEiVFqnop2ImTXCc5e0/oHz3LTqmHGtExn5hfMkOA==", - "dev": true, "requires": { "minimist": "^1.2.5" } @@ -7757,8 +7756,7 @@ "minimist": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", - "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==", - "dev": true + "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==" }, "minipass": { "version": "3.1.3", diff --git a/package.json b/package.json index bcba552f..18137a0b 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,8 @@ }, "private": true, "dependencies": { - "cross-spawn": "^7.0.3" + "cross-spawn": "^7.0.3", + "json5": "^2.1.3" }, "devDependencies": { "@angular/cli": "^10.1.6", diff --git a/packages/mf/README.md b/packages/mf/README.md index 9444a29a..3358ef12 100644 --- a/packages/mf/README.md +++ b/packages/mf/README.md @@ -11,7 +11,7 @@ Big thanks to the following people who helped to make this possible: ## Prequisites -- Angular CLI 11 (currently BETA) +- Angular CLI 11 ## Motivation 💥 diff --git a/packages/mf/package.json b/packages/mf/package.json index d98014b3..b632a9be 100644 --- a/packages/mf/package.json +++ b/packages/mf/package.json @@ -1,6 +1,6 @@ { "name": "@angular-architects/module-federation", - "version": "1.1.0", + "version": "1.2.0-rc.5", "license": "MIT", "repository": { "type": "GitHub", diff --git a/packages/mf/src/index.ts b/packages/mf/src/index.ts index e69de29b..8a53d523 100644 --- a/packages/mf/src/index.ts +++ b/packages/mf/src/index.ts @@ -0,0 +1 @@ +export * from './utils/dynamic-federation'; \ No newline at end of file diff --git a/packages/mf/src/schematics/mf/schematic.ts b/packages/mf/src/schematics/mf/schematic.ts index 4db9ffc3..42a95e84 100644 --- a/packages/mf/src/schematics/mf/schematic.ts +++ b/packages/mf/src/schematics/mf/schematic.ts @@ -3,14 +3,11 @@ import { Rule, externalSchematic, } from '@angular-devkit/schematics'; -import { - updateWorkspace, -} from '@nrwl/workspace'; import { spawn } from 'cross-spawn'; import * as path from 'path'; -import { createConfig } from '../../create-config'; +import { createConfig } from '../../utils/create-config'; import { prodConfig } from './prod-config'; import { MfSchematicSchema } from './schema'; @@ -50,6 +47,25 @@ export function add(options: MfSchematicSchema): Rule { return config(options); } + +function makeMainAsync(main: string): Rule { + return async function (tree, context) { + + const mainPath = path.dirname(main); + const bootstrapName = path.join(mainPath, 'bootstrap.ts'); + + if (tree.exists(bootstrapName)) { + console.info(`${bootstrapName} already exists.`); + return; + } + + const mainContent = tree.read(main); + tree.create(bootstrapName, mainContent); + tree.overwrite(main, "import('./bootstrap');") + + } +} + export default function config (options: MfSchematicSchema): Rule { return async function (tree) { @@ -77,12 +93,21 @@ export default function config (options: MfSchematicSchema): Rule { const configPath = path.join(projectRoot, 'webpack.config.js').replace(/\\/g, '/'); const configProdPath = path.join(projectRoot, 'webpack.prod.config.js').replace(/\\/g, '/'); const port = parseInt(options.port); + const main = projectConfig.architect.build.options.main; + + const relWorkspaceRoot = path.relative(projectRoot, ''); + const tsConfigName = tree.exists('tsconfig.base.json') ? + 'tsconfig.base.json' : 'tsconfig.json'; + + const relTsConfigPath = path + .join(relWorkspaceRoot, tsConfigName) + .replace(/\\/g, '/'); if (isNaN(port)) { throw new Error(`Port must be a number!`); } - const webpackConfig = createConfig(projectName, projectRoot, port); + const webpackConfig = createConfig(projectName, relTsConfigPath, projectRoot, port); tree.create(configPath, webpackConfig); tree.create(configProdPath, prodConfig); @@ -97,6 +122,7 @@ export default function config (options: MfSchematicSchema): Rule { tree.overwrite('angular.json', JSON.stringify(workspace, null, '\t')); return chain([ + makeMainAsync(main), externalSchematic('ngx-build-plus', 'ng-add', { project: options.project }), // updateWorkspace((workspace) => { // const proj = workspace.projects.get(options.project); diff --git a/packages/mf/src/create-config.ts b/packages/mf/src/utils/create-config.ts similarity index 62% rename from packages/mf/src/create-config.ts rename to packages/mf/src/utils/create-config.ts index bd24f201..4b820623 100644 --- a/packages/mf/src/create-config.ts +++ b/packages/mf/src/utils/create-config.ts @@ -1,6 +1,11 @@ -export function createConfig(projectName: string, root: string, port: number): string { +export function createConfig(projectName: string, tsConfigName: string, root: string, port: number): string { return `const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin"); +const mf = require("@angular-architects/module-federation/webpack"); +const path = require("path"); + +const sharedMappings = new mf.SharedMappings(); +sharedMappings.register(path.join(__dirname, '${tsConfigName}')); module.exports = { output: { @@ -28,10 +33,15 @@ module.exports = { shared: { "@angular/core": { singleton: true, strictVersion: true }, "@angular/common": { singleton: true, strictVersion: true }, - "@angular/router": { singleton: true, strictVersion: true } + "@angular/router": { singleton: true, strictVersion: true }, + + // Uncomment for sharing lib of an Angular CLI or Nx workspace + // ...sharedMappings.getDescriptors() } - }) + }), + // Uncomment for sharing lib of an Angular CLI or Nx workspace + // sharedMappings.getPlugin(), ], }; `; diff --git a/packages/mf/src/utils/dynamic-federation.ts b/packages/mf/src/utils/dynamic-federation.ts new file mode 100644 index 00000000..27414070 --- /dev/null +++ b/packages/mf/src/utils/dynamic-federation.ts @@ -0,0 +1,66 @@ +const moduleMap = {}; +const remoteMap = {} +let isDefaultScopeInitialized = false; + +async function lookupExposedModule(remoteName: string, exposedModule: string): Promise { + const container = window[remoteName] as Container; + const factory = await container.get(exposedModule); + const Module = factory(); + return Module as T; +} + +async function initRemote(remoteName: string) { + const container = window[remoteName] as Container; + + // Do we still need to initialize the remote? + if (remoteMap[remoteName]) { + return container; + } + + // Do we still need to initialize the share scope? + if (!isDefaultScopeInitialized) { + await __webpack_init_sharing__('default'); + isDefaultScopeInitialized = true; + } + + await container.init(__webpack_share_scopes__.default); + remoteMap[remoteName] = true; + return container; +} + +export type LoadRemoteModuleOptions = { + remoteEntry?: string; + remoteName: string; + exposedModule: string +} + +export function loadRemoteEntry(remoteEntry: string, remoteName: string): Promise { + return new Promise((resolve, reject) => { + + // Is remoteEntry already loaded? + if (moduleMap[remoteEntry]) { + resolve(); + return; + } + + const script = document.createElement('script'); + script.src = remoteEntry; + + script.onerror = reject; + + script.onload = () => { + initRemote(remoteName); + moduleMap[remoteEntry] = true; + resolve(); + } + + document.body.append(script); + }); +} + +export async function loadRemoteModule(options: LoadRemoteModuleOptions): Promise { + if (options.remoteEntry) { + await loadRemoteEntry(options.remoteEntry, options.remoteName); + } + return await lookupExposedModule(options.remoteName, options.exposedModule); +} \ No newline at end of file diff --git a/packages/mf/src/utils/shared-mappings.ts b/packages/mf/src/utils/shared-mappings.ts new file mode 100644 index 00000000..1cf99aeb --- /dev/null +++ b/packages/mf/src/utils/shared-mappings.ts @@ -0,0 +1,84 @@ +import {NormalModuleReplacementPlugin} from 'webpack'; +import * as path from 'path'; +import * as fs from 'fs'; +import * as JSON5 from 'json5'; + + +interface KeyValuePair { + key: string; + value: string; +} + +export class SharedMappings { + + private mappings: KeyValuePair[] = []; + + register(tsConfigPath: string, shared: string[] = null): void { + + if (!path.isAbsolute(tsConfigPath)) { + throw new Error('SharedMappings.register: tsConfigPath needs to be an absolute path!'); + } + + const tsConfig = JSON5.parse( + fs.readFileSync(tsConfigPath, {encoding: 'UTF8'})); + const mappings = tsConfig?.compilerOptions?.paths; + const rootPath = path.normalize(path.dirname(tsConfigPath)); + + if (!mappings) { + return; + } + + for (const key in mappings) { + if (!shared || shared.length === 0 || shared.includes(key)) { + this.mappings.push({ + key, + value: path.normalize(path.join(rootPath, mappings[key][0])) + }); + } + } + } + + getPlugin(): NormalModuleReplacementPlugin { + return new NormalModuleReplacementPlugin(/./, (req) => { + const from = req.context; + const to = path.normalize(path.join(req.context, req.request)); + + if (!req.request.startsWith('.')) return; + + for (const m of this.mappings) { + const libFolder = path.normalize(path.dirname(m.value)); + if (!from.startsWith(libFolder) && to.startsWith(libFolder)) { + req.request = m.key; + // console.log('remapping', { from, to, libFolder }); + } + } + }); + } + + getDescriptors(): object { + const result = {}; + + for (const m of this.mappings) { + result[m.key] = { + import: m.value, + requiredVersion: false + }; + } + + return result; + } + + getDescriptor(mappedPath: string, requiredVersion: string = null): any { + + if (!this.mappings[mappedPath]) { + throw new Error('No mapping found for ' + mappedPath + ' in tsconfig'); + } + + return ({ + [mappedPath]: { + import: this.mappings[mappedPath], + requiredVersion: requiredVersion ?? false + } + }); + } +} diff --git a/packages/mf/src/utils/webpack-runtime-api.d.ts b/packages/mf/src/utils/webpack-runtime-api.d.ts new file mode 100644 index 00000000..f05ad046 --- /dev/null +++ b/packages/mf/src/utils/webpack-runtime-api.d.ts @@ -0,0 +1,10 @@ +type Scope = unknown; +type Factory = () => any; + +type Container = { + init(shareScope: Scope): void; + get(module: string): Factory; +}; + +declare const __webpack_init_sharing__: (shareScope: string) => Promise; +declare const __webpack_share_scopes__: { default: Scope }; diff --git a/packages/mf/src/webpack.ts b/packages/mf/src/webpack.ts new file mode 100644 index 00000000..26bce47e --- /dev/null +++ b/packages/mf/src/webpack.ts @@ -0,0 +1 @@ +export * from './utils/shared-mappings'; \ No newline at end of file