qiwi/module-federation-manifest-plugin

View on GitHub
src/main/ts/plugin.ts

Summary

Maintainability
A
0 mins
Test Coverage
A
97%
import type { ModuleFederationManifestPluginHooks } from './hooks'
import type { ModuleFederationManifest } from './schema'

import { AsyncSeriesHook } from 'tapable'
import webpack, { type Chunk, type Module } from 'webpack'

import {
  consumedModuleParser,
  exposedModuleParser,
  providedModuleParser,
  remoteModuleParser,
} from './identifier-parsers'

type ModuleFederationPluginOptions = ConstructorParameters<typeof webpack.container.ModuleFederationPlugin>[0]

export interface ModuleFederationManifestPluginOptions {
  filename?: string
}

const undefinedOrNotEmptyObject = <T extends {}>(obj: T): T | undefined => {
  return Object.keys(obj).length ? obj : undefined
}

const compilationHooks = new WeakMap<webpack.Compilation, ModuleFederationManifestPluginHooks>()

export class ModuleFederationManifestPlugin {
  private federationPluginOptions!: ModuleFederationPluginOptions

  constructor(private options: ModuleFederationManifestPluginOptions) {}

  apply(compiler: webpack.Compiler) {
    const pluginName = this.constructor.name
    const federationPlugin = compiler.options.plugins.find(
      (plugin) => plugin?.constructor.name === 'ModuleFederationPlugin',
    ) as
      | (webpack.container.ModuleFederationPlugin & {
          _options: ModuleFederationManifestPluginOptions
        })
      | undefined

    if (!federationPlugin) {
      throw new Error('Module Federation Manifest Plugin cannot be used without Module Federation plugin')
    }

    this.federationPluginOptions = { ...federationPlugin._options }

    compiler.hooks.thisCompilation.tap(pluginName, (compilation) => {
      compilation.hooks.processAssets.tapPromise(
        {
          name: pluginName,
          stage: webpack.Compilation.PROCESS_ASSETS_STAGE_REPORT,
        },
        async () => await this.processWebpackAssets(compilation),
      )
    })
  }

  static getHooks(compilation: webpack.Compilation): ModuleFederationManifestPluginHooks {
    let hooks = compilationHooks.get(compilation)
    if (!hooks) {
      hooks = {
        onManifestCreated: new AsyncSeriesHook(['manifest']),
      }
      compilationHooks.set(compilation, hooks)
    }

    return hooks
  }

  private createManifest(
    publicPath: string,
    entryChunk: Chunk | undefined,
    modules: Set<Module>,
  ): ModuleFederationManifest {
    const entry: ModuleFederationManifest['entry'] = entryChunk?.files && {
      path: Array.from(entryChunk.files)[0],
    }

    const remotes: ModuleFederationManifest['remotes'] = {}
    const consumes: ModuleFederationManifest['consumes'] = {}
    const provides: ModuleFederationManifest['provides'] = {}
    const exposes: ModuleFederationManifest['exposes'] = {}

    for (const module of Array.from(modules)) {
      const identifier = module.identifier()

      if (!identifier) {
        continue
      }

      if (exposedModuleParser.canActive(identifier)) {
        const modules = exposedModuleParser.parse(identifier)
        modules.forEach((module) => {
          exposes[module.request] = {
            name: module.name,
          }
        })
        continue
      }

      if (remoteModuleParser.canActive(identifier)) {
        const { remoteName, moduleName } = remoteModuleParser.parse(identifier)
        remotes[remoteName] = remotes[remoteName] || {
          modules: [],
        }
        remotes[remoteName].modules.push(moduleName)
        continue
      }

      if (consumedModuleParser.canActive(identifier)) {
        const { name, version, shareScope, singleton, eager, strictVersion } = consumedModuleParser.parse(identifier)
        consumes[name] = consumes[name] || []
        consumes[name].push({ version, shareScope, singleton, eager, strictVersion })
        continue
      }

      if (providedModuleParser.canActive(identifier)) {
        const { name, version, shareScope } = providedModuleParser.parse(identifier)
        provides[name] = provides[name] || []
        provides[name].push({ version, shareScope })
        continue
      }
    }

    return {
      name: this.federationPluginOptions.name as string,
      publicPath,
      entry,
      exposes: undefinedOrNotEmptyObject(exposes),
      provides: undefinedOrNotEmptyObject(provides),
      consumes: undefinedOrNotEmptyObject(consumes),
      remotes: undefinedOrNotEmptyObject(remotes),
    }
  }

  private emitManifestAsset(compilation: webpack.Compilation, manifest: ModuleFederationManifest) {
    if (!this.options.filename) {
      return
    }

    const source = new webpack.sources.RawSource(JSON.stringify(manifest))
    compilation.emitAsset(this.options.filename, source)
  }

  private async processWebpackAssets(compilation: webpack.Compilation): Promise<void> {
    const manifest = this.createManifest(
      compilation.outputOptions.publicPath as string,
      this.getRemoteEntryChunk(compilation.chunks),
      compilation.modules,
    )

    await ModuleFederationManifestPlugin.getHooks(compilation).onManifestCreated.promise(manifest)

    this.emitManifestAsset(compilation, manifest)
  }

  private getRemoteEntryChunk(chunks: Set<Chunk>): Chunk | undefined {
    return Array.from(chunks).find((chunk) => chunk.name === this.federationPluginOptions.name)
  }
}