import ansiColors from 'ansi-colors'; import path from 'path'; import { rm } from 'fs/promises'; import fs from 'fs'; import { MANIFEST_EXT, Manifest, inferAutoPublicPath, } from '@module-federation/sdk'; import { ThirdPartyExtractor } from '@module-federation/third-party-dts-extractor'; import { retrieveRemoteConfig } from '../configurations/remotePlugin'; import { createTypesArchive, downloadTypesArchive } from './archiveHandler'; import { compileTs, retrieveMfAPITypesPath, retrieveMfTypesPath, } from './typeScriptCompiler'; import { retrieveHostConfig, retrieveRemoteInfo, } from '../configurations/hostPlugin'; import { DTSManagerOptions } from '../interfaces/DTSManagerOptions'; import { HostOptions, RemoteInfo } from '../interfaces/HostOptions'; import { REMOTE_API_TYPES_FILE_NAME, REMOTE_ALIAS_IDENTIFIER, HOST_API_TYPES_FILE_NAME, } from '../constant'; import { fileLog, logger } from '../../server'; import { axiosGet, cloneDeepOptions, isDebugMode } from './utils'; import { UpdateMode } from '../../server/constant'; export const MODULE_DTS_MANAGER_IDENTIFIER = 'MF DTS Manager'; interface UpdateTypesOptions { updateMode: UpdateMode; remoteName?: string; remoteTarPath?: string; remoteInfo?: RemoteInfo; once?: boolean; } class DTSManager { options: DTSManagerOptions; runtimePkgs: string[]; remoteAliasMap: Record>; loadedRemoteAPIAlias: Set; extraOptions: Record; updatedRemoteInfos: Record>; constructor(options: DTSManagerOptions) { this.options = cloneDeepOptions(options); this.runtimePkgs = [ '@module-federation/runtime', '@module-federation/enhanced/runtime', '@module-federation/runtime-tools', ]; this.loadedRemoteAPIAlias = new Set(); this.remoteAliasMap = {}; this.extraOptions = options?.extraOptions || {}; this.updatedRemoteInfos = {}; } generateAPITypes(mapComponentsToExpose: Record) { const exposePaths: Set = new Set(); const packageType = Object.keys(mapComponentsToExpose).reduce( (sum: string, exposeKey: string) => { const exposePath = path .join(REMOTE_ALIAS_IDENTIFIER, exposeKey) .split(path.sep) // Windows platform-specific file system path fix .join('/'); exposePaths.add(`'${exposePath}'`); const curType = `T extends '${exposePath}' ? typeof import('${exposePath}') :`; sum = curType + sum; return sum; }, 'any;', ); const exposePathKeys = [...exposePaths].join(' | '); return ` export type RemoteKeys = ${exposePathKeys}; type PackageType = ${packageType}`; } async extractRemoteTypes(options: ReturnType) { const { remoteOptions, tsConfig } = options; if (!remoteOptions.extractRemoteTypes) { return; } let hasRemotes = false; const remotes = remoteOptions.moduleFederationConfig.remotes; if (remotes) { if (Array.isArray(remotes)) { hasRemotes = Boolean(remotes.length); } else if (typeof remotes === 'object') { hasRemotes = Boolean(Object.keys(remotes).length); } } const mfTypesPath = retrieveMfTypesPath(tsConfig, remoteOptions); if (hasRemotes) { const tempHostOptions = { moduleFederationConfig: remoteOptions.moduleFederationConfig, typesFolder: path.join(mfTypesPath, 'node_modules'), remoteTypesFolder: remoteOptions?.hostRemoteTypesFolder || remoteOptions.typesFolder, deleteTypesFolder: true, context: remoteOptions.context, implementation: remoteOptions.implementation, abortOnError: false, }; await this.consumeArchiveTypes(tempHostOptions); } } async generateTypes() { try { const { options } = this; if (!options.remote) { throw new Error( 'options.remote is required if you want to generateTypes', ); } const { remoteOptions, tsConfig, mapComponentsToExpose } = retrieveRemoteConfig(options.remote); if (!Object.keys(mapComponentsToExpose).length) { return; } await this.extractRemoteTypes({ remoteOptions, tsConfig, mapComponentsToExpose, }); await compileTs(mapComponentsToExpose, tsConfig, remoteOptions); await createTypesArchive(tsConfig, remoteOptions); let apiTypesPath = ''; if (remoteOptions.generateAPITypes) { const apiTypes = this.generateAPITypes(mapComponentsToExpose); apiTypesPath = retrieveMfAPITypesPath(tsConfig, remoteOptions); fs.writeFileSync(apiTypesPath, apiTypes); } try { if (remoteOptions.deleteTypesFolder) { await rm(retrieveMfTypesPath(tsConfig, remoteOptions), { recursive: true, force: true, }); } } catch (err) { if (isDebugMode()) { console.error(err); } } logger.success('Federated types created correctly'); } catch (error) { if (this.options.remote?.abortOnError === false) { logger.error(`Unable to compile federated types, ${error}`); } else { throw error; } } } async requestRemoteManifest( remoteInfo: RemoteInfo, ): Promise> { try { if (!remoteInfo.url.includes(MANIFEST_EXT)) { return remoteInfo as Required; } const url = remoteInfo.url; const res = await axiosGet(url); const manifestJson = res.data as unknown as Manifest; if (!manifestJson.metaData.types.zip) { throw new Error(`Can not get ${remoteInfo.name}'s types archive url!`); } const addProtocol = (u: string): string => { if (u.startsWith('//')) { return `https:${u}`; } return u; }; let publicPath; if ('publicPath' in manifestJson.metaData) { publicPath = manifestJson.metaData.publicPath; } else { const getPublicPath = new Function(manifestJson.metaData.getPublicPath); if (manifestJson.metaData.getPublicPath.startsWith('function')) { publicPath = getPublicPath()(); } else { publicPath = getPublicPath(); } } if (publicPath === 'auto') { publicPath = inferAutoPublicPath(remoteInfo.url); } remoteInfo.zipUrl = new URL( path.join(addProtocol(publicPath), manifestJson.metaData.types.zip), ).href; if (!manifestJson.metaData.types.api) { console.warn(`Can not get ${remoteInfo.name}'s api types url!`); remoteInfo.apiTypeUrl = ''; return remoteInfo as Required; } remoteInfo.apiTypeUrl = new URL( path.join(addProtocol(publicPath), manifestJson.metaData.types.api), ).href; return remoteInfo as Required; } catch (_err) { fileLog( `fetch manifest failed, ${_err}, ${remoteInfo.name} will be ignored`, 'requestRemoteManifest', 'error', ); return remoteInfo as Required; } } async consumeTargetRemotes( hostOptions: Required, remoteInfo: Required, ) { if (!remoteInfo.zipUrl) { throw new Error(`Can not get ${remoteInfo.name}'s types archive url!`); } const typesDownloader = downloadTypesArchive(hostOptions); return typesDownloader([remoteInfo.alias, remoteInfo.zipUrl]); } async downloadAPITypes( remoteInfo: Required, destinationPath: string, ) { const { apiTypeUrl } = remoteInfo; if (!apiTypeUrl) { return; } try { const url = apiTypeUrl; const res = await axiosGet(url); let apiTypeFile = res.data as string; apiTypeFile = apiTypeFile.replaceAll( REMOTE_ALIAS_IDENTIFIER, remoteInfo.alias, ); const filePath = path.join(destinationPath, REMOTE_API_TYPES_FILE_NAME); fs.writeFileSync(filePath, apiTypeFile); this.loadedRemoteAPIAlias.add(remoteInfo.alias); } catch (err) { fileLog( `Unable to download "${remoteInfo.name}" api types, ${err}`, 'consumeTargetRemotes', 'error', ); } } consumeAPITypes(hostOptions: Required) { const apiTypeFileName = path.join( hostOptions.context, hostOptions.typesFolder, HOST_API_TYPES_FILE_NAME, ); try { const existedFile = fs.readFileSync(apiTypeFileName, 'utf-8'); const existedImports = new ThirdPartyExtractor('').collectTypeImports( existedFile, ); existedImports.forEach((existedImport) => { const alias = existedImport .split('./') .slice(1) .join('./') .replace('/apis.d.ts', ''); this.loadedRemoteAPIAlias.add(alias); }); } catch (err) { //noop } if (!this.loadedRemoteAPIAlias.size) { return; } const packageTypes: string[] = []; const remoteKeys: string[] = []; const importTypeStr = [...this.loadedRemoteAPIAlias] .sort() .map((alias, index) => { const remoteKey = `RemoteKeys_${index}`; const packageType = `PackageType_${index}`; packageTypes.push(`T extends ${remoteKey} ? ${packageType}`); remoteKeys.push(remoteKey); return `import type { PackageType as ${packageType},RemoteKeys as ${remoteKey} } from './${alias}/apis.d.ts';`; }) .join('\n'); const remoteKeysStr = `type RemoteKeys = ${remoteKeys.join(' | ')};`; const packageTypesStr = `type PackageType = ${[ ...packageTypes, 'Y', ].join(' :\n')} ;`; const runtimePkgs: Set = new Set(); [...this.runtimePkgs, ...hostOptions.runtimePkgs].forEach((pkg) => { runtimePkgs.add(pkg); }); const pkgsDeclareStr = [...runtimePkgs] .map((pkg) => { return `declare module "${pkg}" { ${remoteKeysStr} ${packageTypesStr} export function loadRemote(packageName: T): Promise>; export function loadRemote(packageName: T): Promise>; }`; }) .join('\n'); const fileStr = `${importTypeStr} ${pkgsDeclareStr} `; fs.writeFileSync( path.join( hostOptions.context, hostOptions.typesFolder, HOST_API_TYPES_FILE_NAME, ), fileStr, ); } async consumeArchiveTypes(options: HostOptions) { const { hostOptions, mapRemotesToDownload } = retrieveHostConfig(options); const downloadPromises = Object.entries(mapRemotesToDownload).map( async (item) => { const remoteInfo = item[1]; if (!this.remoteAliasMap[remoteInfo.alias]) { const requiredRemoteInfo = await this.requestRemoteManifest(remoteInfo); this.remoteAliasMap[remoteInfo.alias] = requiredRemoteInfo; } return this.consumeTargetRemotes( hostOptions, this.remoteAliasMap[remoteInfo.alias], ); }, ); const downloadPromisesResult = await Promise.allSettled(downloadPromises); return { hostOptions, downloadPromisesResult, }; } async consumeTypes() { try { const { options } = this; if (!options.host) { throw new Error('options.host is required if you want to consumeTypes'); } const { mapRemotesToDownload } = retrieveHostConfig(options.host); if (!Object.keys(mapRemotesToDownload).length) { return; } const { downloadPromisesResult, hostOptions } = await this.consumeArchiveTypes(options.host); // download apiTypes if (hostOptions.consumeAPITypes) { await Promise.all( downloadPromisesResult.map(async (item) => { if (item.status === 'rejected' || !item.value) { return; } const [alias, destinationPath] = item.value; const remoteInfo = this.remoteAliasMap[alias]; if (!remoteInfo) { return; } await this.downloadAPITypes(remoteInfo, destinationPath); }), ); this.consumeAPITypes(hostOptions); } logger.success('Federated types extraction completed'); } catch (err) { if (this.options.host?.abortOnError === false) { fileLog( `Unable to consume federated types, ${err}`, 'consumeTypes', 'error', ); } else { throw err; } } } async updateTypes(options: UpdateTypesOptions): Promise { try { // can use remoteTarPath directly in the future const { remoteName, updateMode, remoteInfo: updatedRemoteInfo, once, } = options; const hostName = this.options?.host?.moduleFederationConfig?.name; fileLog( `updateTypes options:, ${JSON.stringify(options, null, 2)}`, 'consumeTypes', 'info', ); if (updateMode === UpdateMode.POSITIVE && remoteName === hostName) { if (!this.options.remote) { return; } await this.generateTypes(); } else { const { remoteAliasMap } = this; if (!this.options.host) { return; } const { hostOptions, mapRemotesToDownload } = retrieveHostConfig( this.options.host, ); const loadedRemoteInfo = Object.values(remoteAliasMap).find( (i) => i.name === remoteName, ); const consumeTypes = async ( requiredRemoteInfo: Required, ) => { const [_alias, destinationPath] = await this.consumeTargetRemotes( hostOptions, requiredRemoteInfo, ); await this.downloadAPITypes(requiredRemoteInfo, destinationPath); }; if (!loadedRemoteInfo) { const remoteInfo = Object.values(mapRemotesToDownload).find( (item) => { return item.name === remoteName; }, ); if (remoteInfo) { if (!this.remoteAliasMap[remoteInfo.alias]) { const requiredRemoteInfo = await this.requestRemoteManifest(remoteInfo); this.remoteAliasMap[remoteInfo.alias] = requiredRemoteInfo; } await consumeTypes(this.remoteAliasMap[remoteInfo.alias]); } else if (updatedRemoteInfo) { const consumeDynamicRemoteTypes = async () => { await consumeTypes( this.updatedRemoteInfos[updatedRemoteInfo.name], ); this.consumeAPITypes(hostOptions); }; if (!this.updatedRemoteInfos[updatedRemoteInfo.name]) { const parsedRemoteInfo = retrieveRemoteInfo({ hostOptions: hostOptions, remoteAlias: updatedRemoteInfo.alias || updatedRemoteInfo.name, remote: updatedRemoteInfo.url, }); fileLog(`start request manifest`, 'consumeTypes', 'info'); this.updatedRemoteInfos[updatedRemoteInfo.name] = await this.requestRemoteManifest(parsedRemoteInfo); fileLog( `end request manifest, this.updatedRemoteInfos[updatedRemoteInfo.name]: ${JSON.stringify( this.updatedRemoteInfos[updatedRemoteInfo.name], null, 2, )}`, 'consumeTypes', 'info', ); await consumeDynamicRemoteTypes(); } if (!once && this.updatedRemoteInfos[updatedRemoteInfo.name]) { await consumeDynamicRemoteTypes(); } } } else { await consumeTypes(loadedRemoteInfo); } } } catch (err) { fileLog(`updateTypes fail, ${err}`, 'updateTypes', 'error'); } } } export { DTSManager };