From b93a057480b58e8ee3e8abcf85828b1515619d6b Mon Sep 17 00:00:00 2001 From: Steven Prybylynskyi Date: Mon, 8 Jan 2024 23:05:59 +0100 Subject: [PATCH] feat: added download-federated-types script --- .gitignore | 4 + README.md | 21 ++- jest.config.ts | 1 + package.json | 8 +- src/__tests__/plugin.spec.ts | 6 +- .../download-federated-types.test.ts | 156 ++++++++++++++++ src/bin/download-federated-types.ts | 57 ++++++ src/bin/helpers/assertRunningFromRoot.ts | 8 + .../helpers/getFederationConfig.ts} | 12 +- .../helpers/getOptionsFromWebpackConfig.ts | 47 +++++ src/bin/helpers/index.ts | 3 + src/{ => bin}/make-federated-types.ts | 21 ++- src/compileTypes/compileTypes.ts | 63 +++++++ .../helpers/getTSConfigCompilerOptions.ts | 17 ++ .../helpers/includeTypesFromNodeModules.ts | 39 ++++ src/compileTypes/helpers/index.ts | 3 + .../helpers/reportCompileDiagnostic.ts | 10 + src/compileTypes/index.ts | 2 + ...rewritePathsWithExposedFederatedModules.ts | 62 +++++++ .../__tests__/downloadTypes.test.ts | 127 +++++++++++++ src/downloadTypes/downloadTypes.ts | 64 +++++++ .../getRemoteManifestUrls.ts} | 5 +- src/downloadTypes/helpers/downloadOptions.ts | 5 + .../helpers/downloadRemoteEntryManifest.ts | 20 ++ .../helpers/downloadRemoteEntryTypes.ts | 49 +++++ .../downloadRemoteEntryURLsFromManifests.ts | 52 ++++++ src/downloadTypes/helpers/index.ts | 3 + src/downloadTypes/index.ts | 2 + src/helpers/compileTypes.ts | 173 ------------------ src/helpers/downloadTypes.ts | 168 ----------------- src/helpers/index.ts | 4 + src/helpers/logger.ts | 4 +- src/helpers/strings.ts | 4 + src/helpers/toCamelCase.ts | 4 - src/models/CompileTypesResult.ts | 4 + src/models/FederationConfig.ts | 4 + src/models/ModuleFederationPluginOptions.ts | 3 + .../ModuleFederationTypesPluginOptions.ts | 21 +++ src/models/RemoteEntryUrls.ts | 1 + src/models/RemoteManifest.ts | 4 + src/models/RemoteManifestUrls.ts | 1 + src/models/RemotesRegistryManifest.ts | 5 + src/models/index.ts | 8 + src/models/types.ts | 0 src/plugin.ts | 20 +- src/remote-npm-package-typings.ts | 3 - src/types.ts | 46 ----- 47 files changed, 907 insertions(+), 437 deletions(-) create mode 100644 src/bin/__tests__/download-federated-types.test.ts create mode 100644 src/bin/download-federated-types.ts create mode 100644 src/bin/helpers/assertRunningFromRoot.ts rename src/{helpers/cli.ts => bin/helpers/getFederationConfig.ts} (65%) create mode 100644 src/bin/helpers/getOptionsFromWebpackConfig.ts create mode 100644 src/bin/helpers/index.ts rename src/{ => bin}/make-federated-types.ts (69%) create mode 100644 src/compileTypes/compileTypes.ts create mode 100644 src/compileTypes/helpers/getTSConfigCompilerOptions.ts create mode 100644 src/compileTypes/helpers/includeTypesFromNodeModules.ts create mode 100644 src/compileTypes/helpers/index.ts create mode 100644 src/compileTypes/helpers/reportCompileDiagnostic.ts create mode 100644 src/compileTypes/index.ts create mode 100644 src/compileTypes/rewritePathsWithExposedFederatedModules.ts create mode 100644 src/downloadTypes/__tests__/downloadTypes.test.ts create mode 100644 src/downloadTypes/downloadTypes.ts rename src/{helpers/cloudbedsRemoteManifests.ts => downloadTypes/getRemoteManifestUrls.ts} (95%) create mode 100644 src/downloadTypes/helpers/downloadOptions.ts create mode 100644 src/downloadTypes/helpers/downloadRemoteEntryManifest.ts create mode 100644 src/downloadTypes/helpers/downloadRemoteEntryTypes.ts create mode 100644 src/downloadTypes/helpers/downloadRemoteEntryURLsFromManifests.ts create mode 100644 src/downloadTypes/helpers/index.ts create mode 100644 src/downloadTypes/index.ts delete mode 100644 src/helpers/compileTypes.ts delete mode 100644 src/helpers/downloadTypes.ts create mode 100644 src/helpers/index.ts create mode 100644 src/helpers/strings.ts delete mode 100644 src/helpers/toCamelCase.ts create mode 100644 src/models/CompileTypesResult.ts create mode 100644 src/models/FederationConfig.ts create mode 100644 src/models/ModuleFederationPluginOptions.ts create mode 100644 src/models/ModuleFederationTypesPluginOptions.ts create mode 100644 src/models/RemoteEntryUrls.ts create mode 100644 src/models/RemoteManifest.ts create mode 100644 src/models/RemoteManifestUrls.ts create mode 100644 src/models/RemotesRegistryManifest.ts create mode 100644 src/models/index.ts create mode 100644 src/models/types.ts delete mode 100644 src/remote-npm-package-typings.ts delete mode 100644 src/types.ts diff --git a/.gitignore b/.gitignore index 3f4811c..4ef715f 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,7 @@ node_modules # Mac *.DS_Store **/*.DS_Store + +# Tests +coverage +report.json diff --git a/README.md b/README.md index cea8850..b5bb120 100644 --- a/README.md +++ b/README.md @@ -60,12 +60,21 @@ Or it can be added to `package.json`: ### CLI options -| Option | Default value | Description | -|-------------------------------|---------------|--------------------------------------------------------------------------------| -| `--output-types-folder`, `-o` | `dist/@types` | Path to the output folder, absolute or relative to the working directory | -| `--global-types`, `-g` | `src/@types` | Path to project's global ambient type definitions, relative to the working dir | -| `--federation-config`, `-c` | `src/@types` | Path to federation.config, relative to the working dir | -| `--tsconfig`, `-t` | `src/@types` | Path to tsconfig.json, relative to the working dir | +#### download-federated-types +| Option | Default value | Description | +|-------------------------------|-------------------|---------------------------| +| `--webpack-config` | `webpack/prod.ts` | Path to webpack-config.js | + +If the config is written in TypeScript, the script should be called with `npx ts-node`. + +#### make-federated-types +| Option | Default value | Description | +|-------------------------------|-------------------|--------------------------------------------------------------------------------| +| `--output-types-folder`, `-o` | `dist/@types` | Path to the output folder, absolute or relative to the working directory | +| `--global-types`, `-g` | `src/@types` | Path to project's global ambient type definitions, relative to the working dir | +| `--federation-config`, `-c` | `src/@types` | Path to federation.config, relative to the working dir | +| `--tsconfig`, `-t` | `src/@types` | Path to tsconfig.json, relative to the working dir | +| `--webpack-config` | `webpack/prod.ts` | Path to webpack-config.js | ## Plugin Configuration diff --git a/jest.config.ts b/jest.config.ts index 9321766..64d1224 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -2,4 +2,5 @@ export default { preset: 'ts-jest', rootDir: 'src', clearMocks: true, + coverageDirectory: '/../coverage', }; diff --git a/package.json b/package.json index ec176bd..91dda4c 100644 --- a/package.json +++ b/package.json @@ -13,13 +13,15 @@ "dist" ], "bin": { - "make-federated-types": "dist/make-federated-types.js", - "download-federated-types": "dist/download-federated-types.js" + "make-federated-types": "dist/bin/make-federated-types.js", + "download-federated-types": "dist/bin/download-federated-types.js" }, "scripts": { "build": "tsc", "lint": "eslint 'src/**/*.ts'", - "test": "jest" + "test": "jest", + "test:coverage": "jest --coverage", + "test:coverage:ci": "jest --verbose --ci --coverage --json --testLocationInResults --outputFile=report.json --maxWorkers=50%" }, "dependencies": { "download": "^8.0.0", diff --git a/src/__tests__/plugin.spec.ts b/src/__tests__/plugin.spec.ts index 066a526..efd6ba0 100644 --- a/src/__tests__/plugin.spec.ts +++ b/src/__tests__/plugin.spec.ts @@ -2,16 +2,16 @@ import webpack, { Compilation, Compiler, } from 'webpack'; -import { downloadTypes } from '../helpers/downloadTypes'; +import { downloadTypes } from '../downloadTypes/downloadTypes'; import { ModuleFederationTypesPlugin } from '../plugin'; import { ModuleFederationPluginOptions, ModuleFederationTypesPluginOptions, -} from '../types'; +} from '../models'; import { DEFAULT_DIR_DOWNLOADED_TYPES, DEFAULT_DIR_EMITTED_TYPES, } from '../constants'; -jest.mock('../helpers/downloadTypes'); +jest.mock('../downloadTypes/downloadTypes'); const mockDownloadTypes = downloadTypes as jest.MockedFunction; const mockAfterEmit = jest.fn(); diff --git a/src/bin/__tests__/download-federated-types.test.ts b/src/bin/__tests__/download-federated-types.test.ts new file mode 100644 index 0000000..576791a --- /dev/null +++ b/src/bin/__tests__/download-federated-types.test.ts @@ -0,0 +1,156 @@ +import { + DEFAULT_DIR_DOWNLOADED_TYPES, DEFAULT_DIR_EMITTED_TYPES, +} from '../../constants'; +import { + downloadTypes, getRemoteManifestUrls, +} from '../../downloadTypes'; +import { getOptionsFromWebpackConfig } from '../helpers'; + +jest.mock('minimist', () => (args: string[]) => { + const webpackConfigIndex = args.findIndex(arg => arg === '--webpack-config'); + return webpackConfigIndex > -1 + ? { 'webpack-config': args[webpackConfigIndex + 1] } + : {}; +}); +jest.mock('../../downloadTypes', () => ({ + downloadTypes: jest.fn(), + getRemoteManifestUrls: jest.fn(), +})); +jest.mock('../helpers', () => ({ + assertRunningFromRoot: jest.fn(() => true), + getOptionsFromWebpackConfig: jest.fn(), +})); + +const mockDownloadTypes = downloadTypes as jest.MockedFunction; +const mockGetOptionsFromWebpackConfig = getOptionsFromWebpackConfig as jest + .MockedFunction; +const mockGetRemoteManifestUrls = getRemoteManifestUrls as jest + .MockedFunction; + +const validOptions: ReturnType = { + mfPluginOptions: { + remotes: { + app1: 'app1@https://app1-url/remoteEntry.js', + app2: 'app1@https://app2-url/remoteEntry.js', + }, + }, + mfTypesPluginOptions: { + remoteEntryUrls: { url1: 'http://valid-url' }, + dirDownloadedTypes: 'custom-dist/types', + dirEmittedTypes: 'src/@wmf-types/types', + }, +}; + +describe('download-federated-types', () => { + const originalArgv = process.argv; + const originalProcessExit = process.exit; + const mockConsoleLog = jest.spyOn(console, 'log').mockImplementation(); + const mockConsoleError = jest.spyOn(console, 'error').mockImplementation(); + process.exit = jest.fn() as never; + + beforeEach(() => { + process.argv = ['node', 'download-federated-types']; + + mockGetRemoteManifestUrls.mockReturnValue({}); + mockGetOptionsFromWebpackConfig.mockReturnValue({ + mfPluginOptions: {}, + mfTypesPluginOptions: {}, + }); + }); + + afterEach(() => { + process.argv = originalArgv; + }); + + afterAll(() => { + process.exit = originalProcessExit; + }); + + test('exits when remote URL is invalid', () => { + const remoteEntryUrls = { url1: 'invalid-url' }; + mockGetOptionsFromWebpackConfig.mockReturnValue({ + mfPluginOptions: {}, + mfTypesPluginOptions: { + remoteEntryUrls, + }, + }); + + jest.isolateModules(() => { + require('../download-federated-types'); + }); + + expect(mockGetOptionsFromWebpackConfig).toHaveBeenCalledWith('webpack/prod.ts'); + expect(mockConsoleError).toHaveBeenCalledWith('One or more remote URLs are invalid:', remoteEntryUrls); + expect(process.exit).toHaveBeenCalledWith(1); + }); + + test('exits when remote manifest URL is invalid', () => { + const manifestUrls = { url1: 'invalid-url' }; + mockGetRemoteManifestUrls.mockReturnValue(manifestUrls); + + jest.isolateModules(() => { + require('../download-federated-types'); + }); + + expect(mockGetRemoteManifestUrls).toHaveBeenCalled(); + expect(process.exit).toHaveBeenCalledWith(1); + expect(mockConsoleError).toHaveBeenCalledWith('One or more remote manifest URLs are invalid:', manifestUrls); + }); + + test('calls downloadTypes with correct arguments and logs success on valid URLs', async () => { + const manifestUrls = { url1: 'https://manifest-registry' }; + mockGetRemoteManifestUrls.mockReturnValue(manifestUrls); + mockGetOptionsFromWebpackConfig.mockReturnValue(validOptions); + + jest.isolateModules(() => { + require('../download-federated-types'); + }); + await Promise.resolve(); + + expect(mockDownloadTypes).toHaveBeenCalledWith( + validOptions.mfTypesPluginOptions.dirEmittedTypes, + validOptions.mfTypesPluginOptions.dirDownloadedTypes, + validOptions.mfPluginOptions.remotes, + validOptions.mfTypesPluginOptions.remoteEntryUrls, + manifestUrls, + ); + expect(mockConsoleLog).toHaveBeenCalledWith('Successfully downloaded federated types.'); + }); + + test('exits with error when downloadTypes throws an error', async () => { + mockDownloadTypes.mockRejectedValue(new Error('Error downloading types')); + + jest.isolateModules(() => { + require('../download-federated-types'); + }); + await Promise.resolve(); + + expect(mockConsoleError).toHaveBeenCalledWith('Error downloading federated types:', expect.any(Error)); + expect(process.exit).toHaveBeenCalledWith(1); + }); + + test('uses default directories when mfTypesPluginOptions does not provide them', () => { + jest.isolateModules(() => { + require('../download-federated-types'); + }); + + expect(mockDownloadTypes).toHaveBeenCalledWith( + DEFAULT_DIR_EMITTED_TYPES, + DEFAULT_DIR_DOWNLOADED_TYPES, + undefined, + undefined, + {}, + ); + }); + + test('parses argv and uses custom webpack config path', () => { + process.argv[2] = '--webpack-config'; + process.argv[3] = 'custom/webpack.config.ts'; + + jest.isolateModules(() => { + require('../download-federated-types'); + }); + + expect(mockGetOptionsFromWebpackConfig).toHaveBeenCalledWith('custom/webpack.config.ts'); + }); +}); diff --git a/src/bin/download-federated-types.ts b/src/bin/download-federated-types.ts new file mode 100644 index 0000000..063aadd --- /dev/null +++ b/src/bin/download-federated-types.ts @@ -0,0 +1,57 @@ +#!/usr/bin/env node + +import parseArgs from 'minimist'; + +import { + DEFAULT_DIR_DOWNLOADED_TYPES, DEFAULT_DIR_EMITTED_TYPES, +} from '../constants'; +import { + downloadTypes, getRemoteManifestUrls, +} from '../downloadTypes'; +import { + isEveryUrlValid, setLogger, +} from '../helpers'; + +import { + assertRunningFromRoot, getOptionsFromWebpackConfig, +} from './helpers'; + +assertRunningFromRoot(); + +type Argv = { + 'webpack-config'?: string, +}; + +const argv = parseArgs(process.argv.slice(2)); +const webpackConfigPath = argv['webpack-config'] || 'webpack/prod.ts'; + +const { mfPluginOptions, mfTypesPluginOptions } = getOptionsFromWebpackConfig(webpackConfigPath); + +const remoteManifestUrls = getRemoteManifestUrls(mfTypesPluginOptions)!; + +if (!isEveryUrlValid(Object.values({ ...mfTypesPluginOptions.remoteEntryUrls }))) { + console.error('One or more remote URLs are invalid:', mfTypesPluginOptions.remoteEntryUrls); + process.exit(1); +} +if (!isEveryUrlValid(Object.values({ ...remoteManifestUrls }))) { + console.error('One or more remote manifest URLs are invalid:', remoteManifestUrls); + process.exit(1); +} + +(async () => { + setLogger(console); + + try { + await downloadTypes( + mfTypesPluginOptions?.dirEmittedTypes || DEFAULT_DIR_EMITTED_TYPES, + mfTypesPluginOptions?.dirDownloadedTypes || DEFAULT_DIR_DOWNLOADED_TYPES, + mfPluginOptions.remotes, + mfTypesPluginOptions.remoteEntryUrls, + remoteManifestUrls, + ); + console.log('Successfully downloaded federated types.'); + } catch (error) { + console.error('Error downloading federated types:', error); + process.exit(1); + } +})(); diff --git a/src/bin/helpers/assertRunningFromRoot.ts b/src/bin/helpers/assertRunningFromRoot.ts new file mode 100644 index 0000000..9e21fdc --- /dev/null +++ b/src/bin/helpers/assertRunningFromRoot.ts @@ -0,0 +1,8 @@ +import fs from 'fs'; + +export function assertRunningFromRoot(): void { + if (!fs.readdirSync('./').includes('node_modules')) { + console.error('ERROR: Script must be run from the root directory of the project'); + process.exit(1); + } +} diff --git a/src/helpers/cli.ts b/src/bin/helpers/getFederationConfig.ts similarity index 65% rename from src/helpers/cli.ts rename to src/bin/helpers/getFederationConfig.ts index 8b8dfd7..c6c5bb8 100644 --- a/src/helpers/cli.ts +++ b/src/bin/helpers/getFederationConfig.ts @@ -1,15 +1,7 @@ -import fs from 'fs'; import path from 'path'; -import { FEDERATION_CONFIG_FILE } from '../constants'; -import { FederationConfig } from '../types'; - -export function assertRunningFromRoot(): void { - if (!fs.readdirSync('./').includes('node_modules')) { - console.error('ERROR: Script must be run from the root directory of the project'); - process.exit(1); - } -} +import { FEDERATION_CONFIG_FILE } from '../../constants'; +import { FederationConfig } from '../../models'; export function getFederationConfig(customConfigPath?: string): FederationConfig { const federationConfigPath = path.resolve(customConfigPath || FEDERATION_CONFIG_FILE); diff --git a/src/bin/helpers/getOptionsFromWebpackConfig.ts b/src/bin/helpers/getOptionsFromWebpackConfig.ts new file mode 100644 index 0000000..b002233 --- /dev/null +++ b/src/bin/helpers/getOptionsFromWebpackConfig.ts @@ -0,0 +1,47 @@ +import { Compiler } from 'webpack'; + +import { ModuleFederationTypesPluginOptions } from '../../models'; + +export function getOptionsFromWebpackConfig(webpackConfigPath: string) { + let webpackConfig: Compiler['options']; + try { + webpackConfig = require(webpackConfigPath); + webpackConfig = ((webpackConfig as unknown as Dict).default as Compiler['options']) || webpackConfig; + } catch (error) { + console.error(`Failed to import webpack config from ${webpackConfigPath}:`, error); + process.exit(1); + } + + if (!webpackConfig) { + console.error(`Empty webpack config loaded from ${webpackConfigPath}`); + process.exit(1); + } + + function getModuleFederationPluginOptions(config: Compiler['options']) { + const plugin = config.plugins.find( + nextPlugin => nextPlugin!.constructor.name === 'ModuleFederationPlugin', + ); + // eslint-disable-next-line no-underscore-dangle + return (plugin as Dict)?._options as Dict & { remotes?: Dict }; + } + + function getModuleFederationTypesPluginOptions(config: Compiler['options']) { + const plugin = config.plugins.find( + nextPlugin => nextPlugin!.constructor.name === 'ModuleFederationTypesPlugin', + ); + return (plugin as Dict)?.options as ModuleFederationTypesPluginOptions; + } + + const mfPluginOptions = getModuleFederationPluginOptions(webpackConfig); + const mfTypesPluginOptions = getModuleFederationTypesPluginOptions(webpackConfig); + + if (!mfTypesPluginOptions || !mfPluginOptions) { + console.error('Could not find required ModuleFederation plugin options in the webpack config.'); + process.exit(1); + } + + return { + mfPluginOptions, + mfTypesPluginOptions, + }; +} diff --git a/src/bin/helpers/index.ts b/src/bin/helpers/index.ts new file mode 100644 index 0000000..80ec9d2 --- /dev/null +++ b/src/bin/helpers/index.ts @@ -0,0 +1,3 @@ +export * from './assertRunningFromRoot'; +export * from './getFederationConfig'; +export * from './getOptionsFromWebpackConfig'; diff --git a/src/make-federated-types.ts b/src/bin/make-federated-types.ts similarity index 69% rename from src/make-federated-types.ts rename to src/bin/make-federated-types.ts index 1bb99f7..ec74897 100644 --- a/src/make-federated-types.ts +++ b/src/bin/make-federated-types.ts @@ -6,21 +6,25 @@ import parseArgs from 'minimist'; import { DEFAULT_DIR_DIST, DEFAULT_DIR_EMITTED_TYPES, DEFAULT_DIR_GLOBAL_TYPES, TS_CONFIG_FILE, -} from './constants'; +} from '../constants'; import { compileTypes, rewritePathsWithExposedFederatedModules, -} from './helpers/compileTypes'; +} from '../compileTypes'; +import { setLogger } from '../helpers'; +import { FederationConfig } from '../models'; + import { - assertRunningFromRoot, getFederationConfig, -} from './helpers/cli'; + assertRunningFromRoot, getFederationConfig, getOptionsFromWebpackConfig, +} from './helpers'; assertRunningFromRoot(); type Argv = { 'global-types': string, - 'federation-config': string, + 'federation-config'?: string, 'output-types-folder': string, 'tsconfig': string, + 'webpack-config'?: string, } const argv = parseArgs(process.argv.slice(2), { @@ -32,7 +36,10 @@ const argv = parseArgs(process.argv.slice(2), { } as Partial, }); -const federationConfig = getFederationConfig(argv['federation-config']); +const webpackConfigPath = argv['webpack-config'] || 'webpack/prod.ts'; +const federationConfig = webpackConfigPath + ? getOptionsFromWebpackConfig(webpackConfigPath).mfPluginOptions as unknown as FederationConfig + : getFederationConfig(argv['federation-config']); const compileFiles = Object.values(federationConfig.exposes); const outDir = argv['output-types-folder'] || path.join(DEFAULT_DIR_DIST, DEFAULT_DIR_EMITTED_TYPES); @@ -42,6 +49,8 @@ const tsconfigPath = argv.tsconfig || TS_CONFIG_FILE; console.log(`Emitting types for ${compileFiles.length} exposed module(s)`); +setLogger(console); + const { isSuccess, typeDefinitions } = compileTypes( tsconfigPath, compileFiles, diff --git a/src/compileTypes/compileTypes.ts b/src/compileTypes/compileTypes.ts new file mode 100644 index 0000000..4a61e6b --- /dev/null +++ b/src/compileTypes/compileTypes.ts @@ -0,0 +1,63 @@ +import fs from 'fs'; + +import ts from 'typescript'; + +import { CompileTypesResult } from '../models'; +import { + getAllFilePaths, getLogger, +} from '../helpers'; + +import { + getTSConfigCompilerOptions, reportCompileDiagnostic, +} from './helpers'; + +export function compileTypes( + tsconfigPath: string, + exposedComponents: string[], + outFile: string, + dirGlobalTypes: string, +): CompileTypesResult { + const logger = getLogger(); + + const exposedFileNames = Object.values(exposedComponents); + const { moduleResolution, ...compilerOptions } = getTSConfigCompilerOptions(tsconfigPath); + + Object.assign(compilerOptions, { + declaration: true, + emitDeclarationOnly: true, + noEmit: false, + outFile, + } as ts.CompilerOptions); + + // Expand lib name to a file name according to https://stackoverflow.com/a/69617124/1949503 + if (compilerOptions.lib) { + compilerOptions.lib = compilerOptions.lib.map( + fileName => (fileName.includes('.d.ts') ? fileName : `lib.${fileName}.d.ts`).toLowerCase(), + ); + } + + // Create a Program with an in-memory emit to avoid a case when wrong typings are downloaded + let fileContent = ''; + const host = ts.createCompilerHost(compilerOptions); + host.writeFile = (_fileName: string, contents: string) => { + fileContent = contents; + return contents; + }; + + // Including global type definitions from `src/@types` directory + if (fs.existsSync(dirGlobalTypes)) { + exposedFileNames.push( + ...getAllFilePaths(`./${dirGlobalTypes}`).filter(filePath => filePath.endsWith('.d.ts')), + ); + } + logger.log('Including a set of root files in compilation', exposedFileNames); + + const program = ts.createProgram(exposedFileNames, compilerOptions, host); + const { diagnostics, emitSkipped } = program.emit(); + diagnostics.forEach(reportCompileDiagnostic); + + return { + isSuccess: !emitSkipped, + typeDefinitions: fileContent, + }; +} diff --git a/src/compileTypes/helpers/getTSConfigCompilerOptions.ts b/src/compileTypes/helpers/getTSConfigCompilerOptions.ts new file mode 100644 index 0000000..aaa5798 --- /dev/null +++ b/src/compileTypes/helpers/getTSConfigCompilerOptions.ts @@ -0,0 +1,17 @@ +import path from 'path'; + +import ts from 'typescript'; + +import { getLogger } from '../../helpers'; + +export function getTSConfigCompilerOptions(tsconfigFileNameOrPath: string): ts.CompilerOptions { + const logger = getLogger(); + + const tsconfigPath = path.resolve(tsconfigFileNameOrPath); + if (!tsconfigPath) { + logger.error('ERROR: Could not find a valid tsconfig.json'); + process.exit(1); + } + + return require(tsconfigPath).compilerOptions; +} diff --git a/src/compileTypes/helpers/includeTypesFromNodeModules.ts b/src/compileTypes/helpers/includeTypesFromNodeModules.ts new file mode 100644 index 0000000..abd50b9 --- /dev/null +++ b/src/compileTypes/helpers/includeTypesFromNodeModules.ts @@ -0,0 +1,39 @@ +import { FederationConfig } from '../../models'; +import { getLogger } from '../../helpers'; + +export function includeTypesFromNodeModules(federationConfig: FederationConfig, typings: string): string { + const logger = getLogger(); + let typingsWithNpmPackages = typings; + + const exposedNpmPackages = Object.entries(federationConfig.exposes) + .filter(([, packagePath]) => ( + !packagePath.startsWith('.') + || packagePath.startsWith('./node_modules/') + )) + .map(([exposedModuleKey, exposeTargetPath]) => [ + exposedModuleKey.replace(/^\.\//, ''), + exposeTargetPath.replace('./node_modules/', ''), + ]); + + // language=TypeScript + const createNpmModule = (exposedModuleKey: string, packageName: string) => ` + declare module "${federationConfig.name}/${exposedModuleKey}" { + export * from "${packageName}" + } + `; + + if (exposedNpmPackages.length) { + logger.log('Including typings for npm packages:', exposedNpmPackages); + } + + try { + exposedNpmPackages.forEach(([exposedModuleKey, packageName]) => { + typingsWithNpmPackages += `\n${createNpmModule(exposedModuleKey, packageName)}`; + }); + } catch (err) { + logger.warn('Typings was not included for npm package:', (err as Dict)?.url); + logger.log(err); + } + + return typingsWithNpmPackages; +} diff --git a/src/compileTypes/helpers/index.ts b/src/compileTypes/helpers/index.ts new file mode 100644 index 0000000..b06d413 --- /dev/null +++ b/src/compileTypes/helpers/index.ts @@ -0,0 +1,3 @@ +export * from './getTSConfigCompilerOptions'; +export * from './includeTypesFromNodeModules'; +export * from './reportCompileDiagnostic'; diff --git a/src/compileTypes/helpers/reportCompileDiagnostic.ts b/src/compileTypes/helpers/reportCompileDiagnostic.ts new file mode 100644 index 0000000..aba67b1 --- /dev/null +++ b/src/compileTypes/helpers/reportCompileDiagnostic.ts @@ -0,0 +1,10 @@ +import ts from 'typescript'; + +import { getLogger } from '../../helpers'; + +export function reportCompileDiagnostic(diagnostic: ts.Diagnostic): void { + const logger = getLogger(); + const { line } = diagnostic.file!.getLineAndCharacterOfPosition(diagnostic.start!); + logger.log('TS Error', diagnostic.code, ':', ts.flattenDiagnosticMessageText(diagnostic.messageText, ts.sys.newLine)); + logger.log(' at', `${diagnostic.file!.fileName}:${line + 1}`, '\n'); +} diff --git a/src/compileTypes/index.ts b/src/compileTypes/index.ts new file mode 100644 index 0000000..42bc8a4 --- /dev/null +++ b/src/compileTypes/index.ts @@ -0,0 +1,2 @@ +export * from './compileTypes'; +export * from './rewritePathsWithExposedFederatedModules'; diff --git a/src/compileTypes/rewritePathsWithExposedFederatedModules.ts b/src/compileTypes/rewritePathsWithExposedFederatedModules.ts new file mode 100644 index 0000000..5975604 --- /dev/null +++ b/src/compileTypes/rewritePathsWithExposedFederatedModules.ts @@ -0,0 +1,62 @@ +import path from 'path'; +import fs from 'fs'; + +import mkdirp from 'mkdirp'; + +import { FederationConfig } from '../models'; + +import { includeTypesFromNodeModules } from './helpers'; + +export function rewritePathsWithExposedFederatedModules( + federationConfig: FederationConfig, + outFile: string, + typings: string, +): void { + const regexDeclareModule = /declare module "(.*)"/g; + const declaredModulePaths: string[] = []; + + // Collect all instances of `declare module "..."` + for ( + let execResults: null | string[] = regexDeclareModule.exec(typings); + execResults !== null; + execResults = regexDeclareModule.exec(typings) + ) { + declaredModulePaths.push(execResults[1]); + } + + let typingsUpdated: string = typings; + + // Replace and prefix paths by exposed remote names + declaredModulePaths.forEach(importPath => { + // Aliases are not included in the emitted declarations hence the need to use `endsWith` + const [exposedModuleKey, ...exposedModuleNameAliases] = Object.keys(federationConfig.exposes) + .filter(key => ( + federationConfig.exposes[key].endsWith(importPath) + || federationConfig.exposes[key].replace(/\.[^./]*$/, '').endsWith(importPath) + )) + .map(key => key.replace(/^\.\//, '')); + + let federatedModulePath = exposedModuleKey + ? `${federationConfig.name}/${exposedModuleKey}` + : `#not-for-import/${federationConfig.name}/${importPath}`; + + federatedModulePath = federatedModulePath.replace(/\/index$/, ''); + + // language=TypeScript + const createAliasModule = (modulePath: string) => ` + declare module "${federationConfig.name}/${modulePath}" { + export * from "${federatedModulePath}" + } + `; + + typingsUpdated = [ + typingsUpdated.replace(RegExp(`"${importPath}"`, 'g'), `"${federatedModulePath}"`), + ...exposedModuleNameAliases.map(createAliasModule), + ].join('\n'); + }); + + typingsUpdated = includeTypesFromNodeModules(federationConfig, typingsUpdated); + + mkdirp.sync(path.dirname(outFile)); + fs.writeFileSync(outFile, typingsUpdated.replace(/\r\n/g, '\n')); +} diff --git a/src/downloadTypes/__tests__/downloadTypes.test.ts b/src/downloadTypes/__tests__/downloadTypes.test.ts new file mode 100644 index 0000000..fc30ecf --- /dev/null +++ b/src/downloadTypes/__tests__/downloadTypes.test.ts @@ -0,0 +1,127 @@ +import { setLogger } from '../../helpers'; +import { downloadTypes } from '../downloadTypes'; +import { + downloadRemoteEntryTypes, downloadRemoteEntryURLsFromManifests, +} from '../helpers'; + +jest.mock('../helpers', () => ({ + ...jest.requireActual('../helpers'), + downloadRemoteEntryTypes: jest.fn(), + downloadRemoteEntryURLsFromManifests: jest.fn().mockResolvedValue({}), +})); +const mockDownloadRemoteEntryTypes = downloadRemoteEntryTypes as jest + .MockedFunction; +const mockDownloadRemoteEntryURLsFromManifests = downloadRemoteEntryURLsFromManifests as jest + .MockedFunction; + +const dirEmittedTypes = 'dist/@types'; +const dirDownloadedTypes = 'src/@types/remotes'; + +const mockLogger = { + log: jest.fn(), + warn: jest.fn(), + error: jest.fn(), +}; +setLogger(mockLogger); + +describe('downloadTypes', () => { + afterEach(() => { + mockDownloadRemoteEntryURLsFromManifests.mockResolvedValue({}); + }); + + test('handles successful download', async () => { + const remotesFromConfig = { + mfdApp1: 'mfdApp1@[mfdApp1Url]/remoteEntry.js', + mfdApp2: 'mfdApp2@[mfdApp2Url]/remoteEntry.js', + }; + const remoteEntryBaseUrl = 'http://example.com'; + const remoteEntryUrls = { mfdApp2: `${remoteEntryBaseUrl}/remoteEntry.js` }; + const remoteManifestUrls = { registry: 'http://example.com/remote-entries.json' }; + + mockDownloadRemoteEntryURLsFromManifests.mockResolvedValue({}); + mockDownloadRemoteEntryTypes.mockResolvedValue(); + + await downloadTypes( + dirEmittedTypes, + dirDownloadedTypes, + remotesFromConfig, + remoteEntryUrls, + remoteManifestUrls, + ); + + expect(mockDownloadRemoteEntryURLsFromManifests).toHaveBeenCalledWith(remoteManifestUrls); + expect(mockDownloadRemoteEntryTypes).toHaveBeenCalledWith( + 'mfdApp1', + remotesFromConfig.mfdApp1, + `[mfdApp1Url]/${dirEmittedTypes}/index.d.ts`, + dirDownloadedTypes, + ); + expect(mockDownloadRemoteEntryTypes).toHaveBeenCalledWith( + 'mfdApp2', + remotesFromConfig.mfdApp2, + `${remoteEntryBaseUrl}/${dirEmittedTypes}/index.d.ts`, + dirDownloadedTypes, + ); + }); + + test('handles invalid remote URLs', async () => { + const remotesFromConfig = { mfdExample: 'mfdApp1@https://example.com/remoteEntry.js' }; + const remoteManifestUrls = { mfdExample: 'invalid-url' }; + const error = new Error('Invalid URL'); + (error as unknown as Dict).url = 'invalid-url'; + + mockDownloadRemoteEntryURLsFromManifests.mockRejectedValue(error); + + await downloadTypes( + dirEmittedTypes, + dirDownloadedTypes, + remotesFromConfig, + undefined, + remoteManifestUrls, + ); + + expect(mockLogger.warn).toHaveBeenCalledWith('Failed to load remote manifest file:', 'invalid-url'); + expect(mockLogger.log).toHaveBeenCalledWith(error); + }); + + test('handles download function failure', async () => { + const remoteName = 'mfdExample'; + const remotesFromConfig = { [remoteName]: 'mfdExample@https://example.com/remoteEntry.js' }; + const remoteEntryUrls = { [remoteName]: 'http://example.com/remoteEntry.js' }; + const error = new Error('Download failed'); + + mockDownloadRemoteEntryTypes.mockImplementationOnce(() => { throw error; }); + + await downloadTypes( + dirEmittedTypes, + dirDownloadedTypes, + remotesFromConfig, + remoteEntryUrls, + ); + + expect(mockLogger.error).toHaveBeenCalledWith( + `${remoteName}: '${remotesFromConfig[remoteName]}' is not a valid remote federated module URL`, + ); + expect(mockLogger.log).toHaveBeenCalledWith(error); + }); + + test('handles download failure', async () => { + const remoteName = 'mfdExample'; + const remotesFromConfig = { [remoteName]: 'mfdExample@https://example.com/remoteEntry.js' }; + const remoteEntryUrls = { [remoteName]: 'http://example.com/remoteEntry.js' }; + const error = new Error('Download failed'); + (error as unknown as Dict).url = 'invalid-url'; + + mockDownloadRemoteEntryTypes.mockRejectedValue(error); + + await downloadTypes( + dirEmittedTypes, + dirDownloadedTypes, + remotesFromConfig, + remoteEntryUrls, + ); + + expect(mockLogger.warn).toHaveBeenCalledWith('Failed to load remote types from:', 'invalid-url'); + expect(mockLogger.log).toHaveBeenCalledWith(error); + }); +}); diff --git a/src/downloadTypes/downloadTypes.ts b/src/downloadTypes/downloadTypes.ts new file mode 100644 index 0000000..c90b12c --- /dev/null +++ b/src/downloadTypes/downloadTypes.ts @@ -0,0 +1,64 @@ +import { + RemoteEntryUrls, RemoteManifestUrls, +} from '../models'; +import { getLogger } from '../helpers'; + +import { + downloadRemoteEntryTypes, downloadRemoteEntryURLsFromManifests, +} from './helpers'; + +export async function downloadTypes( + dirEmittedTypes: string, + dirDownloadedTypes: string, + remotesFromFederationConfig?: Dict, + remoteEntryUrls?: RemoteEntryUrls, + remoteManifestUrls?: RemoteManifestUrls, +): Promise { + const logger = getLogger(); + let remoteEntryUrlsResolved: RemoteEntryUrls = {}; + + try { + remoteEntryUrlsResolved = { + ...remoteEntryUrls, + ...await downloadRemoteEntryURLsFromManifests(remoteManifestUrls), + }; + } catch (err) { + logger.warn('Failed to load remote manifest file:', (err as Dict)?.url); + logger.log(err); + return; + } + + const promises: Promise[] = []; + + Object.entries(remotesFromFederationConfig || {}).forEach(([remoteName, remoteLocation]) => { + try { + const remoteEntryUrl = remoteEntryUrlsResolved[remoteName] || remoteLocation.split('@')[1]; + + const remoteEntryBaseUrl = remoteEntryUrl.endsWith('.js') + ? remoteEntryUrl.split('/').slice(0, -1).join('/') + : remoteEntryUrl; + + const promiseDownload = downloadRemoteEntryTypes( + remoteName, + remoteLocation, + `${remoteEntryBaseUrl}/${dirEmittedTypes}/index.d.ts`, + dirDownloadedTypes, + ); + + promises.push(promiseDownload); + } catch (err) { + logger.error(`${remoteName}: '${remoteLocation}' is not a valid remote federated module URL`); + logger.log(err); + } + }); + + try { + await Promise.all(promises); + } catch (err) { + logger.warn('Failed to load remote types from:', (err as Dict)?.url); + logger.log(err); + return; + } + + return; +} diff --git a/src/helpers/cloudbedsRemoteManifests.ts b/src/downloadTypes/getRemoteManifestUrls.ts similarity index 95% rename from src/helpers/cloudbedsRemoteManifests.ts rename to src/downloadTypes/getRemoteManifestUrls.ts index 360bcb1..869d760 100644 --- a/src/helpers/cloudbedsRemoteManifests.ts +++ b/src/downloadTypes/getRemoteManifestUrls.ts @@ -2,11 +2,10 @@ import { CloudbedsCloudfrontDomain, CLOUDBEDS_REMOTES_MANIFEST_FILE_NAME, } from '../constants'; +import { isValidUrl } from '../helpers'; import { ModuleFederationTypesPluginOptions, RemoteManifestUrls, -} from '../types'; - -import { isValidUrl } from './validation'; +} from '../models'; export function getRemoteManifestUrls(options?: ModuleFederationTypesPluginOptions): RemoteManifestUrls | undefined { if (options?.cloudbedsRemoteManifestsBaseUrl !== undefined) { diff --git a/src/downloadTypes/helpers/downloadOptions.ts b/src/downloadTypes/helpers/downloadOptions.ts new file mode 100644 index 0000000..221f1e2 --- /dev/null +++ b/src/downloadTypes/helpers/downloadOptions.ts @@ -0,0 +1,5 @@ +import download from 'download'; + +export const downloadOptions: download.DownloadOptions = { + rejectUnauthorized: false, +}; diff --git a/src/downloadTypes/helpers/downloadRemoteEntryManifest.ts b/src/downloadTypes/helpers/downloadRemoteEntryManifest.ts new file mode 100644 index 0000000..bd2ab41 --- /dev/null +++ b/src/downloadTypes/helpers/downloadRemoteEntryManifest.ts @@ -0,0 +1,20 @@ +import download from 'download'; + +import { getLogger } from '../../helpers'; + +import { downloadOptions } from './downloadOptions'; + +export async function downloadRemoteEntryManifest(url: string): Promise { + const logger = getLogger(); + + if (url.includes('{version}')) { + const versionJsonUrl = `${url.match(/^https:\/\/[^/]+/)}/version.json`; + const { version } = JSON.parse((await download(versionJsonUrl, downloadOptions)).toString()); + url = url.replace('{version}', version); + } + + logger.log(`Downloading remote manifest from ${url}`); + const json = (await download(url, downloadOptions)).toString(); + + return JSON.parse(json); +} diff --git a/src/downloadTypes/helpers/downloadRemoteEntryTypes.ts b/src/downloadTypes/helpers/downloadRemoteEntryTypes.ts new file mode 100644 index 0000000..3d6f4cc --- /dev/null +++ b/src/downloadTypes/helpers/downloadRemoteEntryTypes.ts @@ -0,0 +1,49 @@ +import path from 'path'; +import fs from 'fs'; + +import mkdirp from 'mkdirp'; +import download from 'download'; + +import { getLogger } from '../../helpers'; + +import { downloadOptions } from './downloadOptions'; + +export async function downloadRemoteEntryTypes( + remoteName: string, + remoteLocation: string, + dtsUrl: string, + dirDownloadedTypes: string, +): Promise { + const logger = getLogger(); + const remoteOriginalName = remoteLocation.split('@')[0]; + const outDir = path.join(dirDownloadedTypes, remoteName); + const outFile = path.join(outDir, 'index.d.ts'); + let shouldWriteFile = true; + + mkdirp.sync(outDir); + + let types = (await download(dtsUrl, downloadOptions)).toString(); + + // Replace original remote name (as defined in remote microapp's WMF config's `name` field) + // with a name (an alias) that is used in `remotes` object. Usually these are same. + if (remoteName !== remoteOriginalName) { + types = types.replace( + new RegExp(`declare module "${remoteOriginalName}(.*)"`, 'g'), + (_, $1) => `declare module "${remoteName}${$1}"`, + ); + } + + // Prevent webpack from recompiling the bundle by not writing the file if it has not changed + if (fs.existsSync(outFile)) { + const typesFormer = fs.readFileSync(outFile).toString(); + shouldWriteFile = typesFormer !== types; + } + + if (shouldWriteFile) { + logger.info('Downloaded types from', dtsUrl); + logger.info(fs.existsSync(outFile) ? 'Updating' : 'Creating', outFile); + fs.writeFileSync(outFile, types); + } else { + logger.log('Typings have not changed, skipping writing', outFile); + } +} diff --git a/src/downloadTypes/helpers/downloadRemoteEntryURLsFromManifests.ts b/src/downloadTypes/helpers/downloadRemoteEntryURLsFromManifests.ts new file mode 100644 index 0000000..086d87c --- /dev/null +++ b/src/downloadTypes/helpers/downloadRemoteEntryURLsFromManifests.ts @@ -0,0 +1,52 @@ +import { + getLogger, isValidUrl, toCamelCase, +} from '../../helpers'; +import { + RemoteEntryUrls, RemoteManifest, RemoteManifestUrls, RemotesRegistryManifest, +} from '../../models'; + +import { downloadRemoteEntryManifest } from './downloadRemoteEntryManifest'; + +/** + * Download remote entry manifest file(s) + * The origin of a remote entry URL is used as base URL for type definitions + */ +export async function downloadRemoteEntryURLsFromManifests(remoteManifestUrls?: RemoteManifestUrls) + : Promise { + if (!remoteManifestUrls) { + return {}; + } + + const logger = getLogger(); + const remoteEntryURLs: RemoteEntryUrls = {}; + + logger.log('Remote manifest URLs', remoteManifestUrls); + + const { artifactsBaseUrl, ...manifestUrls } = remoteManifestUrls; + + const remoteManifests = (await Promise.all( + Object.values(manifestUrls).map(url => downloadRemoteEntryManifest(url)), + )) as (RemoteManifest | RemotesRegistryManifest | RemoteEntryUrls)[]; + + // Combine remote entry URLs from all manifests + Object.keys(manifestUrls).forEach((remoteName, index) => { + if (remoteName === 'registry') { + const remotesManifest = remoteManifests[index]; + if (Array.isArray(remotesManifest)) { + (remoteManifests[index] as RemotesRegistryManifest).forEach(remoteManifest => { + remoteEntryURLs[remoteManifest.scope] = remoteManifest.url; + }); + } else { + Object.entries(remotesManifest as RemoteEntryUrls).forEach(([appName, url]) => { + remoteEntryURLs[toCamelCase(appName)] = isValidUrl(url) ? url : `${artifactsBaseUrl}/${appName}/${url}`; + }); + } + } else { + remoteEntryURLs[remoteName] = (remoteManifests[index] as RemoteManifest).url; + } + }); + + logger.log('Remote entry URLs', remoteEntryURLs); + + return remoteEntryURLs; +} diff --git a/src/downloadTypes/helpers/index.ts b/src/downloadTypes/helpers/index.ts new file mode 100644 index 0000000..c6dfe55 --- /dev/null +++ b/src/downloadTypes/helpers/index.ts @@ -0,0 +1,3 @@ +export * from './downloadRemoteEntryTypes'; +export * from './downloadRemoteEntryManifest'; +export * from './downloadRemoteEntryURLsFromManifests'; diff --git a/src/downloadTypes/index.ts b/src/downloadTypes/index.ts new file mode 100644 index 0000000..16c192f --- /dev/null +++ b/src/downloadTypes/index.ts @@ -0,0 +1,2 @@ +export * from './downloadTypes'; +export * from './getRemoteManifestUrls'; diff --git a/src/helpers/compileTypes.ts b/src/helpers/compileTypes.ts deleted file mode 100644 index 51f4b90..0000000 --- a/src/helpers/compileTypes.ts +++ /dev/null @@ -1,173 +0,0 @@ -import fs from 'fs'; -import path from 'path'; - -import mkdirp from 'mkdirp'; -import ts from 'typescript'; - -import { - CompileTypesResult, FederationConfig, -} from '../types'; - -import { getLogger } from './logger'; -import { getAllFilePaths } from './files'; - -export function getTSConfigCompilerOptions(tsconfigFileNameOrPath: string): ts.CompilerOptions { - const logger = getLogger(); - - const tsconfigPath = path.resolve(tsconfigFileNameOrPath); - if (!tsconfigPath) { - logger.error('ERROR: Could not find a valid tsconfig.json'); - process.exit(1); - } - - return require(tsconfigPath).compilerOptions; -} - -export function reportCompileDiagnostic(diagnostic: ts.Diagnostic): void { - const logger = getLogger(); - const { line } = diagnostic.file!.getLineAndCharacterOfPosition(diagnostic.start!); - logger.log('TS Error', diagnostic.code, ':', ts.flattenDiagnosticMessageText(diagnostic.messageText, ts.sys.newLine)); - logger.log(' at', `${diagnostic.file!.fileName}:${line + 1}`, '\n'); -} - -export function compileTypes( - tsconfigPath: string, - exposedComponents: string[], - outFile: string, - dirGlobalTypes: string, -): CompileTypesResult { - const logger = getLogger(); - - const exposedFileNames = Object.values(exposedComponents); - const { moduleResolution, ...compilerOptions } = getTSConfigCompilerOptions(tsconfigPath); - - Object.assign(compilerOptions, { - declaration: true, - emitDeclarationOnly: true, - noEmit: false, - outFile, - } as ts.CompilerOptions); - - // Expand lib name to a file name according to https://stackoverflow.com/a/69617124/1949503 - if (compilerOptions.lib) { - compilerOptions.lib = compilerOptions.lib.map( - fileName => (fileName.includes('.d.ts') ? fileName : `lib.${fileName}.d.ts`).toLowerCase(), - ); - } - - // Create a Program with an in-memory emit to avoid a case when wrong typings are downloaded - let fileContent = ''; - const host = ts.createCompilerHost(compilerOptions); - host.writeFile = (_fileName: string, contents: string) => { - fileContent = contents; - return contents; - }; - - // Including global type definitions from `src/@types` directory - if (fs.existsSync(dirGlobalTypes)) { - exposedFileNames.push( - ...getAllFilePaths(`./${dirGlobalTypes}`).filter(filePath => filePath.endsWith('.d.ts')), - ); - } - logger.log('Including a set of root files in compilation', exposedFileNames); - - const program = ts.createProgram(exposedFileNames, compilerOptions, host); - const { diagnostics, emitSkipped } = program.emit(); - diagnostics.forEach(reportCompileDiagnostic); - - return { - isSuccess: !emitSkipped, - typeDefinitions: fileContent, - }; -} - -export function includeTypesFromNodeModules(federationConfig: FederationConfig, typings: string): string { - const logger = getLogger(); - let typingsWithNpmPackages = typings; - - const exposedNpmPackages = Object.entries(federationConfig.exposes) - .filter(([, packagePath]) => ( - !packagePath.startsWith('.') - || packagePath.startsWith('./node_modules/') - )) - .map(([exposedModuleKey, exposeTargetPath]) => [ - exposedModuleKey.replace(/^\.\//, ''), - exposeTargetPath.replace('./node_modules/', ''), - ]); - - // language=TypeScript - const createNpmModule = (exposedModuleKey: string, packageName: string) => ` - declare module "${federationConfig.name}/${exposedModuleKey}" { - export * from "${packageName}" - } - `; - - if (exposedNpmPackages.length) { - logger.log('Including typings for npm packages:', exposedNpmPackages); - } - - try { - exposedNpmPackages.forEach(([exposedModuleKey, packageName]) => { - typingsWithNpmPackages += `\n${createNpmModule(exposedModuleKey, packageName)}`; - }); - } catch (err) { - logger.warn('Typings was not included for npm package:', (err as Dict)?.url); - logger.log(err); - } - - return typingsWithNpmPackages; -} - -export function rewritePathsWithExposedFederatedModules( - federationConfig: FederationConfig, - outFile: string, - typings: string, -): void { - const regexDeclareModule = /declare module "(.*)"/g; - const declaredModulePaths: string[] = []; - - // Collect all instances of `declare module "..."` - for ( - let execResults: null | string[] = regexDeclareModule.exec(typings); - execResults !== null; - execResults = regexDeclareModule.exec(typings) - ) { - declaredModulePaths.push(execResults[1]); - } - - let typingsUpdated: string = typings; - - // Replace and prefix paths by exposed remote names - declaredModulePaths.forEach(importPath => { - // Aliases are not included in the emitted declarations hence the need to use `endsWith` - const [exposedModuleKey, ...exposedModuleNameAliases] = Object.keys(federationConfig.exposes) - .filter(key => ( - federationConfig.exposes[key].endsWith(importPath) - || federationConfig.exposes[key].replace(/\.[^./]*$/, '').endsWith(importPath) - )) - .map(key => key.replace(/^\.\//, '')); - - let federatedModulePath = exposedModuleKey - ? `${federationConfig.name}/${exposedModuleKey}` - : `#not-for-import/${federationConfig.name}/${importPath}`; - - federatedModulePath = federatedModulePath.replace(/\/index$/, ''); - - // language=TypeScript - const createAliasModule = (modulePath: string) => ` - declare module "${federationConfig.name}/${modulePath}" { - export * from "${federatedModulePath}" - } - `; - - typingsUpdated = [ - typingsUpdated.replace(RegExp(`"${importPath}"`, 'g'), `"${federatedModulePath}"`), - ...exposedModuleNameAliases.map(createAliasModule), - ].join('\n'); - }); - - typingsUpdated = includeTypesFromNodeModules(federationConfig, typingsUpdated); - - mkdirp.sync(path.dirname(outFile)); - fs.writeFileSync(outFile, typingsUpdated.replace(/\r\n/g, '\n')); -} diff --git a/src/helpers/downloadTypes.ts b/src/helpers/downloadTypes.ts deleted file mode 100644 index 744e6cb..0000000 --- a/src/helpers/downloadTypes.ts +++ /dev/null @@ -1,168 +0,0 @@ -import fs from 'fs'; -import path from 'path'; - -import download from 'download'; -import mkdirp from 'mkdirp'; - -import { - RemoteManifest, RemoteManifestUrls, RemotesRegistryManifest, RemoteEntryUrls, -} from '../types'; - -import { getLogger } from './logger'; -import { toCamelCase } from './toCamelCase'; -import { isValidUrl } from './validation'; - -const downloadOptions: download.DownloadOptions = { rejectUnauthorized: false }; - -async function downloadRemoteEntryManifest(url: string): Promise { - const logger = getLogger(); - - if (url.includes('{version}')) { - const versionJsonUrl = `${url.match(/^https:\/\/[^/]+/)}/version.json`; - const { version } = JSON.parse((await download(versionJsonUrl, downloadOptions)).toString()); - url = url.replace('{version}', version); - } - - logger.log(`Downloading remote manifest from ${url}`); - const json = (await download(url, downloadOptions)).toString(); - - return JSON.parse(json); -} - -async function downloadRemoteEntryTypes( - remoteName: string, - remoteLocation: string, - dtsUrl: string, - dirDownloadedTypes: string, -): Promise { - const logger = getLogger(); - const remoteOriginalName = remoteLocation.split('@')[0]; - const outDir = path.join(dirDownloadedTypes, remoteName); - const outFile = path.join(outDir, 'index.d.ts'); - let shouldWriteFile = true; - - mkdirp.sync(outDir); - - let types = (await download(dtsUrl, downloadOptions)).toString(); - - // Replace original remote name (as defined in remote microapp's WMF config's `name` field) - // with a name (an alias) that is used in `remotes` object. Usually these are same. - if (remoteName !== remoteOriginalName) { - types = types.replace( - new RegExp(`declare module "${remoteOriginalName}(.*)"`, 'g'), - (_, $1) => `declare module "${remoteName}${$1}"`, - ); - } - - // Prevent webpack from recompiling the bundle by not writing the file if it has not changed - if (fs.existsSync(outFile)) { - const typesFormer = fs.readFileSync(outFile).toString(); - shouldWriteFile = typesFormer !== types; - } - - if (shouldWriteFile) { - logger.info('Downloaded types from', dtsUrl); - logger.info('Updating', outFile); - fs.writeFileSync(outFile, types); - } else { - logger.log('Typings have not changed, skipping writing', outFile); - } -} - -/** - * Download remote entry manifest file(s) - * The origin of a remote entry URL is used as base URL for type definitions - */ -export async function downloadRemoteEntryURLsFromManifests(remoteManifestUrls?: RemoteManifestUrls) - : Promise { - if (!remoteManifestUrls) { return {}; } - - const logger = getLogger(); - const remoteEntryURLs: RemoteEntryUrls = {}; - - logger.log('Remote manifest URLs', remoteManifestUrls); - - const { artifactsBaseUrl, ...manifestUrls } = remoteManifestUrls; - - const remoteManifests = (await Promise.all( - Object.values(manifestUrls).map(url => downloadRemoteEntryManifest(url)), - )) as (RemoteManifest | RemotesRegistryManifest | RemoteEntryUrls)[]; - - // Combine remote entry URLs from all manifests - Object.keys(manifestUrls).forEach((remoteName, index) => { - if (remoteName === 'registry') { - const remotesManifest = remoteManifests[index]; - if (Array.isArray(remotesManifest)) { - (remoteManifests[index] as RemotesRegistryManifest).forEach(remoteManifest => { - remoteEntryURLs[remoteManifest.scope] = remoteManifest.url; - }); - } else { - Object.entries(remotesManifest as RemoteEntryUrls).forEach(([appName, url]) => { - remoteEntryURLs[toCamelCase(appName)] = isValidUrl(url) ? url : `${artifactsBaseUrl}/${appName}/${url}`; - }); - } - } else { - remoteEntryURLs[remoteName] = (remoteManifests[index] as RemoteManifest).url; - } - }); - - logger.log('Remote entry URLs', remoteEntryURLs); - - return remoteEntryURLs; -} - -export async function downloadTypes( - dirEmittedTypes: string, - dirDownloadedTypes: string, - remotesFromFederationConfig: Dict, - remoteEntryUrls?: RemoteEntryUrls, - remoteManifestUrls?: RemoteManifestUrls, -): Promise { - const logger = getLogger(); - let remoteEntryUrlsResolved: RemoteEntryUrls = {}; - - try { - remoteEntryUrlsResolved = { - ...remoteEntryUrls, - ...await downloadRemoteEntryURLsFromManifests(remoteManifestUrls), - }; - } catch (err) { - logger.warn('Failed to load remote manifest file: ', (err as Dict)?.url); - logger.log(err); - return; - } - - const promises: Promise[] = []; - - Object.entries(remotesFromFederationConfig).forEach(([remoteName, remoteLocation]) => { - try { - const remoteEntryUrl = remoteEntryUrlsResolved[remoteName] || remoteLocation.split('@')[1]; - - const remoteEntryBaseUrl = remoteEntryUrl.endsWith('.js') - ? remoteEntryUrl.split('/').slice(0, -1).join('/') - : remoteEntryUrl; - - const promiseDownload = downloadRemoteEntryTypes( - remoteName, - remoteLocation, - `${remoteEntryBaseUrl}/${dirEmittedTypes}/index.d.ts`, - dirDownloadedTypes, - ); - - promises.push(promiseDownload); - } catch (err) { - logger.error(`${remoteName}: '${remoteLocation}' is not a valid remote federated module URL`); - logger.log(err); - } - }); - - try { - await Promise.all(promises); - } catch (err) { - logger.warn('Failed to load remote types from:', (err as Dict)?.url); - logger.log(err); - return; - } - - return; -} diff --git a/src/helpers/index.ts b/src/helpers/index.ts new file mode 100644 index 0000000..a2c03ec --- /dev/null +++ b/src/helpers/index.ts @@ -0,0 +1,4 @@ +export * from './files'; +export * from './logger'; +export * from './strings'; +export * from './validation'; diff --git a/src/helpers/logger.ts b/src/helpers/logger.ts index ed2d7e6..e56797a 100644 --- a/src/helpers/logger.ts +++ b/src/helpers/logger.ts @@ -8,8 +8,8 @@ export function getLogger(): Compilation['logger'] { return loggerInstance || console; } -export function setLogger(logger: Compilation['logger']): Compilation['logger'] { - loggerInstance = logger; +export function setLogger(logger: TLogger): TLogger { + loggerInstance = logger as Compilation['logger']; return logger; } diff --git a/src/helpers/strings.ts b/src/helpers/strings.ts new file mode 100644 index 0000000..537fec6 --- /dev/null +++ b/src/helpers/strings.ts @@ -0,0 +1,4 @@ +export function toCamelCase(stringInKebabCase: string): string { + return stringInKebabCase + .replace(/-(\w)/g, (_, group) => group.toUpperCase()); +} diff --git a/src/helpers/toCamelCase.ts b/src/helpers/toCamelCase.ts deleted file mode 100644 index 09cb6ab..0000000 --- a/src/helpers/toCamelCase.ts +++ /dev/null @@ -1,4 +0,0 @@ -export function toCamelCase(kebabCase: string): string { - return kebabCase - .replace(/-(\w)/g, (_, group) => group.toUpperCase()); -} diff --git a/src/models/CompileTypesResult.ts b/src/models/CompileTypesResult.ts new file mode 100644 index 0000000..e8674d7 --- /dev/null +++ b/src/models/CompileTypesResult.ts @@ -0,0 +1,4 @@ +export type CompileTypesResult = { + isSuccess: boolean, + typeDefinitions: string, +}; diff --git a/src/models/FederationConfig.ts b/src/models/FederationConfig.ts new file mode 100644 index 0000000..dcb275f --- /dev/null +++ b/src/models/FederationConfig.ts @@ -0,0 +1,4 @@ +export type FederationConfig = { + name: string, + exposes: Dict, +} diff --git a/src/models/ModuleFederationPluginOptions.ts b/src/models/ModuleFederationPluginOptions.ts new file mode 100644 index 0000000..328e26e --- /dev/null +++ b/src/models/ModuleFederationPluginOptions.ts @@ -0,0 +1,3 @@ +import { container } from 'webpack'; + +export type ModuleFederationPluginOptions = ConstructorParameters[0]; diff --git a/src/models/ModuleFederationTypesPluginOptions.ts b/src/models/ModuleFederationTypesPluginOptions.ts new file mode 100644 index 0000000..823375b --- /dev/null +++ b/src/models/ModuleFederationTypesPluginOptions.ts @@ -0,0 +1,21 @@ +import { RemoteEntryUrls } from './RemoteEntryUrls'; +import { RemoteManifestUrls } from './RemoteManifestUrls'; + +export type ModuleFederationTypesPluginOptions = { + dirEmittedTypes?: string, + dirGlobalTypes?: string, + dirDownloadedTypes?: string, + + disableDownladingRemoteTypes?: boolean, + disableTypeCompilation?: boolean, + downloadTypesWhenIdleIntervalInSeconds?: number, + moduleFederationPluginName?: string, + remoteEntryUrls?: RemoteEntryUrls, + remoteManifestUrls?: RemoteManifestUrls, + remoteManifestUrl?: string, + + cloudbedsRemoteManifestsBaseUrl?: string | '' + | 'dev' | 'dev-ga' + | 'stage' | 'stage-ga' + | 'prod' | 'prod-ga', +} diff --git a/src/models/RemoteEntryUrls.ts b/src/models/RemoteEntryUrls.ts new file mode 100644 index 0000000..8112616 --- /dev/null +++ b/src/models/RemoteEntryUrls.ts @@ -0,0 +1 @@ +export type RemoteEntryUrls = Dict; diff --git a/src/models/RemoteManifest.ts b/src/models/RemoteManifest.ts new file mode 100644 index 0000000..b500130 --- /dev/null +++ b/src/models/RemoteManifest.ts @@ -0,0 +1,4 @@ +export type RemoteManifest = { + [key: string]: unknown, + url: string, +} diff --git a/src/models/RemoteManifestUrls.ts b/src/models/RemoteManifestUrls.ts new file mode 100644 index 0000000..6a3e137 --- /dev/null +++ b/src/models/RemoteManifestUrls.ts @@ -0,0 +1 @@ +export type RemoteManifestUrls = Record<'artifactsBaseUrl' | 'registry' | string, string>; diff --git a/src/models/RemotesRegistryManifest.ts b/src/models/RemotesRegistryManifest.ts new file mode 100644 index 0000000..59a371b --- /dev/null +++ b/src/models/RemotesRegistryManifest.ts @@ -0,0 +1,5 @@ +export type RemotesRegistryManifest = { + [key: string]: unknown, + scope: string, + url: string, +}[]; diff --git a/src/models/index.ts b/src/models/index.ts new file mode 100644 index 0000000..13210ef --- /dev/null +++ b/src/models/index.ts @@ -0,0 +1,8 @@ +export * from './CompileTypesResult'; +export * from './FederationConfig'; +export * from './ModuleFederationPluginOptions'; +export * from './ModuleFederationTypesPluginOptions'; +export * from './RemoteEntryUrls'; +export * from './RemoteManifest'; +export * from './RemoteManifestUrls'; +export * from './RemotesRegistryManifest'; diff --git a/src/models/types.ts b/src/models/types.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/plugin.ts b/src/plugin.ts index ca4e206..43dcdf0 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -13,20 +13,20 @@ import { DEFAULT_DOWNLOAD_TYPES_INTERVAL_IN_SECONDS, TS_CONFIG_FILE, } from './constants'; -import { getRemoteManifestUrls } from './helpers/cloudbedsRemoteManifests'; import { compileTypes, rewritePathsWithExposedFederatedModules, -} from './helpers/compileTypes'; -import { downloadTypes } from './helpers/downloadTypes'; +} from './compileTypes'; import { - getLoggerHint, setLogger, -} from './helpers/logger'; -import { isEveryUrlValid } from './helpers/validation'; + downloadTypes, getRemoteManifestUrls, +} from './downloadTypes'; +import { + getLoggerHint, isEveryUrlValid, setLogger, +} from './helpers'; import { FederationConfig, ModuleFederationPluginOptions, ModuleFederationTypesPluginOptions, -} from './types'; +} from './models'; let isCompiledOnce = false; const isDownloadedOnce = false; @@ -115,9 +115,9 @@ export class ModuleFederationTypesPlugin implements WebpackPluginInstance { const downloadRemoteTypes = async () => downloadTypes( dirEmittedTypes, dirDownloadedTypes, - remotes as Dict, - remoteEntryUrls, - remoteManifestUrls, + remotes as Dict, + remoteEntryUrls, + remoteManifestUrls, ); // Determine whether compilation of types should be performed continuously diff --git a/src/remote-npm-package-typings.ts b/src/remote-npm-package-typings.ts deleted file mode 100644 index 5f1cc75..0000000 --- a/src/remote-npm-package-typings.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const remoteNpmPackageTypings = { - '@cloudbeds/ui-library': ['@chakra-ui'], -}; diff --git a/src/types.ts b/src/types.ts deleted file mode 100644 index 61293c4..0000000 --- a/src/types.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { container } from 'webpack'; - -export type FederationConfig = { - name: string, - exposes: Dict, -} - -export type CompileTypesResult = { - isSuccess: boolean, - typeDefinitions: string, -}; - -export type RemoteManifest = { - [key: string]: unknown, - url: string, -} - -export type RemotesRegistryManifest = { - [key: string]: unknown, - scope: string, - url: string, -}[]; - -export type RemoteEntryUrls = Dict; -export type RemoteManifestUrls = Record<'artifactsBaseUrl' | 'registry' | string, string>; - -export type ModuleFederationPluginOptions = ConstructorParameters[0]; - -export type ModuleFederationTypesPluginOptions = { - dirEmittedTypes?: string, - dirGlobalTypes?: string, - dirDownloadedTypes?: string, - - disableDownladingRemoteTypes?: boolean, - disableTypeCompilation?: boolean, - downloadTypesWhenIdleIntervalInSeconds?: number, - moduleFederationPluginName?: string, - remoteEntryUrls?: RemoteEntryUrls, - remoteManifestUrls?: RemoteManifestUrls, - remoteManifestUrl?: string, - - cloudbedsRemoteManifestsBaseUrl?: string | '' - | 'dev' | 'dev-ga' - | 'stage' | 'stage-ga' - | 'prod' | 'prod-ga', -}