Loader

MFELoader

Classe principale per caricamento dinamico dei microfrontend via ESM. Cache, dedup, validazione URL, CSS auto-loading.

Costruttore

class MFELoader {
  constructor()
}

Nessun parametro. Crea due Map interne:

  • loadedModules: Map<string, MFEModule> — cache dei moduli caricati
  • loadingPromises: Map<string, Promise<MFEModule>> — dedup delle richieste concorrenti
Crea un'istanza per shell, non condividerla tra bundle indipendenti. Il singleton globale mfeLoader è deprecato per evitare conflitti di cache.

Metodi

loadRemote(config: MFEConfig): Promise<MFEModule>

Carica dinamicamente un MFE. Gestisce caching, dedup, validazione URL, CSS auto-loading, validazione modulo e chiamata di onLoad().

const mfe = await loader.loadRemote({
  name: 'crm-module',
  url: 'https://mfes.example.com/crm/index.js',
  scope: 'crm',
  module: './module',
})

console.log(await mfe.getManifest())

Comportamento:

  1. Se loadedModules.has(name) → ritorna il modulo cached
  2. Se loadingPromises.has(name) → ritorna la promise già in corso
  3. Altrimenti: avvia caricamento e mette la promise in loadingPromises
  4. Al completamento (success o fail) rimuove la promise da loadingPromises
  5. In caso di successo, salva il modulo in loadedModules

Throw se:

  • URL non valido (new URL(url) fallisce)
  • Protocollo bloccato: data:, javascript:, blob:, vbscript:
  • Protocollo non consentito (qualsiasi diverso da http: / https:)
  • Network error durante HEAD del CSS sibling (network/CORS issue, non 404)
  • Stylesheet HEAD ritorna status >= 400 diverso da 404
  • <link> event onerror (CSS esiste ma non caricabile)
  • Modulo manca di getManifest/mount/unmount
  • Hook opzionale presente ma non funzione

clearCache(name: string): void

Rimuove un singolo modulo dalla cache:

  • elimina da loadedModules
  • elimina da loadingPromises
  • rimuove <link[data-mfe="{name}"]> dal <head>
loader.clearCache('crm-module')
// La prossima loadRemote('crm-module') ricaricherà da rete

clearAllCache(): void

Rimuove tutti i moduli dalla cache:

  • svuota loadedModules e loadingPromises
  • rimuove tutti i <link[data-mfe]> dal <head>

isLoaded(name: string): boolean

if (loader.isLoaded('crm-module')) {
  // già caricato e disponibile in cache
}

isLoading(name: string): boolean

if (loader.isLoading('crm-module')) {
  // caricamento in corso (potrebbe essere quasi pronto)
}

Sicurezza URL

Il loader applica una whitelist sui protocolli:

static readonly ALLOWED_PROTOCOLS = new Set(['https:', 'http:'])

static readonly BLOCKED_PROTOCOLS = new Set([
  'data:',
  'javascript:',
  'blob:',
  'vbscript:',
])

Esempi:

// ✓ OK
loader.loadRemote({ url: 'https://mfes.example.com/crm/index.js', /* ... */ })
loader.loadRemote({ url: 'http://localhost:5173/crm/index.js', /* ... */ })

// ✗ Throw: "Protocollo 'data:' bloccato per sicurezza..."
loader.loadRemote({ url: 'data:application/javascript,alert(1)', /* ... */ })

// ✗ Throw: "Protocollo 'file:' non consentito..."
loader.loadRemote({ url: 'file:///etc/passwd', /* ... */ })

// ✗ Throw: "URL non valido..."
loader.loadRemote({ url: 'index.js', /* ... */ })  // URL relativa
Anche con questa whitelist, verifica sempre che gli URL provengano da una config trusted (server-side render, firmata, env-validated) e non da input utente.

CSS auto-loading

Convenzione: il bundle JS ha uno stylesheet sibling con lo stesso path ma estensione .css.

URL bundle:    https://mfes.example.com/crm/index.js
URL CSS atteso: https://mfes.example.com/crm/index.css

Il loader:

  1. fa fetch(cssUrl, { method: 'HEAD' })
  2. se status 404 → skip silenzioso (CSS opzionale, è OK)
  3. se status >= 400 (e ≠ 404) → throw HTTP {status} loading stylesheet
  4. altrimenti → inietta <link rel="stylesheet" href="{cssUrl}" data-mfe="{name}"> e aspetta onload/onerror

I CSS sono tracciati per nome: clearCache(name) rimuove anche il <link> corrispondente.

Validazione struttura modulo

Il loader accetta sia default export sia named exports:

interface DynamicModuleExports {
  default?: Partial<MFEModule>
  getManifest?: MFEModule['getManifest']
  mount?: MFEModule['mount']
  unmount?: MFEModule['unmount']
  onLoad?: MFEModule['onLoad']
}

Risoluzione: se esiste module.default lo usa, altrimenti usa il modulo come oggetto.

Metodi obbligatori (throw se mancanti o non funzioni):

  • getManifest()
  • mount(container, context)
  • unmount()

Metodi opzionali (validati solo se presenti):

  • onLoad
  • onBeforeUnmount
  • onRouteChange
  • getViewComponent

Se presenti ma non funzioni: MFE "{name}" has invalid optional method "{method}": expected function, got {typeof}.

Pattern di errore

try {
  const mfe = await loader.loadRemote(config)
} catch (err) {
  // Il messaggio è sempre:
  // 'Failed to load MFE "{name}" from {url}: {causa}'
  console.error(err.message)
}

Test

Esempio Vitest con mock di import() e DOM:

import { describe, it, expect, vi } from 'vitest'
import { MFELoader } from '@pzeta/mfe-contracts/loader'

describe('MFELoader', () => {
  it('dedup richieste concorrenti', async () => {
    const loader = new MFELoader()
    const config = { name: 'x', url: 'https://x.com/x.js', scope: 'x', module: './m' }

    // entrambe le chiamate dovrebbero condividere la stessa Promise interna
    const [a, b] = await Promise.all([
      loader.loadRemote(config),
      loader.loadRemote(config),
    ])
    expect(a).toBe(b)
  })
})

Tipi correlati