{"id":51667411,"url":"https://github.com/ghondar/mf-shield","last_synced_at":"2026-07-14T21:02:09.271Z","repository":{"id":369260701,"uuid":"1287913135","full_name":"ghondar/mf-shield","owner":"ghondar","description":"A resilience shield for Module Federation: typed errors, access policies, timeouts, SRI, CSS-poison guard and React boundaries","archived":false,"fork":false,"pushed_at":"2026-07-04T10:46:22.000Z","size":114,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-07-04T12:14:31.533Z","etag":null,"topics":["error-boundary","micro-frontends","module-federation","react","resilience","runtime-plugin","sri"],"latest_commit_sha":null,"homepage":null,"language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/ghondar.png","metadata":{"files":{"readme":"README.es.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-07-03T05:40:44.000Z","updated_at":"2026-07-04T10:46:00.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/ghondar/mf-shield","commit_stats":null,"previous_names":["ghondar/mf-shield"],"tags_count":1,"template":false,"template_full_name":null,"purl":"pkg:github/ghondar/mf-shield","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ghondar%2Fmf-shield","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ghondar%2Fmf-shield/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ghondar%2Fmf-shield/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ghondar%2Fmf-shield/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/ghondar","download_url":"https://codeload.github.com/ghondar/mf-shield/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ghondar%2Fmf-shield/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":35478764,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-07-14T02:00:06.603Z","response_time":114,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["error-boundary","micro-frontends","module-federation","react","resilience","runtime-plugin","sri"],"created_at":"2026-07-14T21:02:04.723Z","updated_at":"2026-07-14T21:02:09.260Z","avatar_url":"https://github.com/ghondar.png","language":"TypeScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# mf-shield\n\n[![npm version](https://img.shields.io/npm/v/mf-shield)](https://www.npmjs.com/package/mf-shield)\n[![CI](https://github.com/ghondar/mf-shield/actions/workflows/ci.yml/badge.svg)](https://github.com/ghondar/mf-shield/actions/workflows/ci.yml)\n[![license: MIT](https://img.shields.io/npm/l/mf-shield)](./LICENSE)\n\n\u003e English docs → [README.md](./README.md)\n\nUn escudo de resiliencia para Module Federation. Encapsula las fallas típicas que tumban un host cuando carga remotes que no controlas al 100%: manifest caído, exposed module faltante, chunk 404, render crash, timeout, mismatch de versiones, CSS global peligroso y acceso directo no autorizado.\n\nLa librería NO es un sandbox. Es una capa de contención: mantiene el shell vivo y aísla la falla dentro del slot o del provider que la produjo, con errores tipados y políticas de acceso explícitas.\n\n## Qué resuelve\n\n- Carga de remotes detrás de un boundary con estados `loading` / `ready` / `failed` y fallback propio.\n- Timeout local por slot para remotes lentos, con error tipado.\n- Política de acceso evaluada **antes** del import remoto (la URL directa no descarga ni ejecuta el módulo).\n- Detección y remoción de CSS global inyectado por un remote.\n- Boundary self-safe para providers con lazy/data-loaders riesgosos.\n- Bootstrap de runtime y shared singletons sin repetir configuración por consumer.\n\n## Instalación\n\n```bash\npnpm add mf-shield\n```\n\nPeer dependencies (las instala tu app, no la librería):\n\n| Peer | Rango | Cuándo hace falta |\n|---|---|---|\n| `react` | `^18.2.0 \\|\\| ^19.0.0` | Solo si usas la entrada `/react` |\n| `@module-federation/runtime` | `^2.6.0` | Solo si usas la entrada `/federation` |\n\nAmbos peers son opcionales: la entrada core (`.`) es framework-agnostic y no importa React ni el runtime de MF.\n\n## Entradas\n\n| Import | Contenido | React |\n|---|---|---|\n| `mf-shield` | core: `evaluateRemoteAccess`, `denyRemoteAccess`, `allowRemoteAccess`, `withTimeout`, `removeCssPoison`, `createSharedSingleton`, `validateSharedSingletons`, `assertRemoteExports`, `validateRemoteEntries`, `toFederationResult` y errores tipados (`FederationTimeoutError`, `RemoteAccessDeniedError`, `RemoteModuleNullError`, `FederationIntegrityError`, `MissingRemoteExportError`) | No |\n| `mf-shield/federation` | `createFederationRuntime`, `createInstanceFederationRuntime`, `createLoaderFromInstance`, `initFederationShield`, `createFederatedLoader`, `buildOfflineManifest`, plugins de runtime `createRemoteAccessPlugin` / `createRemoteFallbackPlugin` / `createSriPlugin`, `resolveIntegrity`, tipos `RemoteEntry` / `FederationRuntimeOptions` / `SriPluginOptions` / `IntegritySource` / `ShieldInstanceOptions` / `RemoteStubMap` / `OfflineManifestInput` | No |\n| `mf-shield/react` | `RemoteSlot`, `RemoteBoundary`, `RemoteFallback`, `useCssPoisonGuard`, `ProviderSuspenseBoundary`, `ProviderBoundary`, `ProviderFallback`, el tipo `RemoteFallbackRenderer` y sus prop types | Sí |\n\nEl paquete se distribuye compilado (`dist`, ESM + CJS + tipos). No necesitas transpilar `node_modules`.\n\n## Recetas mínimas\n\n### 1. RemoteSlot con fallback + retry\n\n`RemoteSlot` monta el remote, muestra estado y cae en fallback si falla. Cambiar `retryKey` remonta el slot para reintentar.\n\n```tsx\nimport { useState } from \"react\";\nimport { RemoteSlot, type RemoteComponent, type RemoteSlotConfig } from \"mf-shield/react\";\n\nconst widgetSlot: RemoteSlotConfig = {\n  label: \"stable widget\",\n  timeoutMs: 800, // opcional: corta remotes lentos\n  load: async () =\u003e (await loadRemote\u003c{ RemoteWidget: RemoteComponent }\u003e(\"stable/Widget\")).RemoteWidget\n};\n\nexport function WidgetPanel() {\n  const [attempt, setAttempt] = useState(0);\n\n  return (\n    \u003csection\u003e\n      \u003cbutton type=\"button\" onClick={() =\u003e setAttempt(value =\u003e value + 1)}\u003e\n        Reintentar\n      \u003c/button\u003e\n      \u003cRemoteSlot config={widgetSlot} retryKey={attempt} /\u003e\n    \u003c/section\u003e\n  );\n}\n```\n\nSi el `load` rechaza (manifest caído, chunk 404, render crash capturado por el boundary interno), el slot renderiza `RemoteFallback` con `data-testid=\"remote-fallback\"` y el shell sigue vivo.\n\n#### Props tipadas del remoto (`props`) + observabilidad de fallas (`onError`, `onStatusChange`)\n\n`RemoteSlotConfig\u003cP\u003e` es genérico sobre las props del remoto. Pasa `props` para reenviarlas al remoto, y usa `onError` / `onStatusChange` para observar cada falla y transición de forma programática:\n\n```tsx\nconst typedSlot: RemoteSlotConfig\u003c{ userId: string }\u003e = {\n  label: \"user card\",\n  props: { userId: \"42\" }, // fluye hacia el remoto\n  onStatusChange: status =\u003e track(\"slot\", status), // \"loading\" → \"ready\" | \"failed\"\n  onError: ({ label, error }) =\u003e report(label, error), // acceso denegado, rechazo, timeout, render crash\n  load: async () =\u003e (await loadRemote\u003c{ UserCard: RemoteComponent\u003c{ userId: string }\u003e }\u003e(\"stable/UserCard\")).UserCard\n};\n```\n\n#### UI de fallback personalizada (`fallback`)\n\nPasa `fallback` en la config del slot para reemplazar la tarjeta `RemoteFallback` por defecto con tu propia UI temática. El renderer recibe `{ label, error }` y cubre **tanto** los fallos de carga (timeout, acceso denegado, módulo nulo, red) como los render crashes capturados por el boundary — el slot también lo reenvía a su `RemoteBoundary` interno.\n\n```tsx\nimport { RemoteSlot, type RemoteComponent, type RemoteFallbackRenderer, type RemoteSlotConfig } from \"mf-shield/react\";\n\nconst pokedexFallback: RemoteFallbackRenderer = ({ label, error }) =\u003e (\n  \u003csection className=\"pokedex-card pokedex-card--fainted\"\u003e\n    \u003cstrong\u003eEste Pokémon se debilitó\u003c/strong\u003e\n    \u003cp\u003eNo se pudo invocar {label}.\u003c/p\u003e\n    \u003ccode\u003e{error instanceof Error ? error.message : String(error)}\u003c/code\u003e\n  \u003c/section\u003e\n);\n\nconst cardsSlot: RemoteSlotConfig = {\n  label: \"pokemon cards\",\n  timeoutMs: 800,\n  fallback: pokedexFallback,\n  load: async () =\u003e (await loadRemote\u003c{ RemoteCards: RemoteComponent }\u003e(\"stable/Cards\")).RemoteCards\n};\n```\n\nSin `fallback`, la tarjeta por defecto (`data-testid=\"remote-fallback\"`) queda igual. Esto completa la historia de personalización: UI del slot/boundary (`fallback`) ← fallback del provider (`ProviderBoundary.fallback`) ← plugin de runtime (`createRemoteFallbackPlugin`, que reemplaza el módulo completo).\n\n### 2. Runtime + loader con errores tipados\n\n`createFederationRuntime` inicializa el runtime y devuelve un loader. `withTimeout` envuelve cualquier promesa y rechaza con `FederationTimeoutError`, que puedes discriminar con `instanceof`.\n\n```ts\nimport * as React from \"react\";\nimport * as ReactDOM from \"react-dom\";\nimport { createSharedSingleton, withTimeout, FederationTimeoutError } from \"mf-shield\";\nimport { createFederationRuntime } from \"mf-shield/federation\";\n\nconst loadRemote = createFederationRuntime({\n  name: \"pokedex_host\",\n  remoteEntries: {\n    stable: { name: \"stable\", entry: \"http://127.0.0.1:4174/mf-manifest.json\" }\n  },\n  shared: {\n    react: createSharedSingleton(\"19.2.5\", () =\u003e React),\n    \"react-dom\": createSharedSingleton(\"19.2.5\", () =\u003e ReactDOM)\n  }\n});\n\nasync function loadWidgetWithSla() {\n  try {\n    const mod = await withTimeout(loadRemote(\"stable/Widget\"), 800, \"stable widget\");\n    return mod;\n  } catch (error) {\n    if (error instanceof FederationTimeoutError) {\n      // Degrada a fallback local: el remote no cumplió el SLA.\n      return null;\n    }\n    throw error;\n  }\n}\n```\n\nSi ya inicializaste el runtime en otro lado, usa `createFederatedLoader(remoteEntries)` para obtener solo el loader sin re-inicializar.\n\n### 3. Política de acceso antes del import\n\n`denyRemoteAccess()` centraliza la decisión de bloqueo; `evaluateRemoteAccess()` la resuelve a `{ allowed, reason? }`. `RemoteSlot` la corre **antes** de ejecutar `load`, así una URL directa no autorizada nunca descarga ni ejecuta el remote.\n\n```ts\nimport { denyRemoteAccess, evaluateRemoteAccess } from \"mf-shield\";\n\nfunction canSeeAdminWidget(user: { role: string }) {\n  return user.role === \"admin\";\n}\n\nconst decision = evaluateRemoteAccess(() =\u003e\n  canSeeAdminWidget(currentUser) ? true : denyRemoteAccess(\"solo admins\")\n);\n\nif (!decision.allowed) {\n  console.warn(`bloqueado: ${decision.reason}`); // \"bloqueado: solo admins\"\n}\n```\n\nAplicado a un slot, la política vive en `canLoad`:\n\n```ts\nimport type { RemoteSlotConfig } from \"mf-shield/react\";\n\nconst adminSlot: RemoteSlotConfig = {\n  label: \"admin widget\",\n  canLoad: () =\u003e canSeeAdminWidget(currentUser) ? true : denyRemoteAccess(\"solo admins\"),\n  load: async () =\u003e (await loadRemote\u003c{ AdminWidget: RemoteComponent }\u003e(\"stable/AdminWidget\")).AdminWidget\n};\n```\n\nSi `canLoad` deniega, el slot cae en fallback con la razón y no se emite ninguna request al origen del remote.\n\n## Plugins de runtime\n\nAdemás del guard por slot (`canLoad`), la librería alinea con el modelo de extensión oficial de MF2 (`FederationRuntimePlugin`). Los plugins se pasan a `createFederationRuntime({ plugins: [...] })` y corren dentro del runtime, no por componente.\n\n### Política de acceso como plugin (`createRemoteAccessPlugin`)\n\nEvalúa una política en el hook `beforeRequest`, **antes** de resolver el remote. Recibe el `remoteName` (extraído de `\"\u003cremote\u003e/\u003cexpose\u003e\"`); si deniega, lanza `RemoteAccessDeniedError` (`federation: \u003creason\u003e`) y corta la resolución.\n\n```ts\nimport { denyRemoteAccess, allowRemoteAccess } from \"mf-shield\";\nimport { createFederationRuntime, createRemoteAccessPlugin } from \"mf-shield/federation\";\n\nconst accessPlugin = createRemoteAccessPlugin({\n  policy: remoteName =\u003e (remoteName === \"legacy\" ? denyRemoteAccess(\"legacy remote deshabilitado\") : allowRemoteAccess()),\n  onDenied: info =\u003e console.warn(`[app] bloqueado ${info.remote}: ${info.reason}`)\n});\n\nconst loadRemote = createFederationRuntime({ name: \"pokedex_host\", remoteEntries, plugins: [accessPlugin] });\n```\n\n### Fallback de carga como plugin (`createRemoteFallbackPlugin`)\n\nIntercepta fallos de carga en el hook `errorLoadRemote`. Devuelve un **objeto módulo** (mismo shape que expone el remote) para reemplazar el módulo caído, o `undefined` para dejar propagar el error.\n\n```ts\nimport { RemoteAccessDeniedError } from \"mf-shield\";\nimport { createFederationRuntime, createRemoteFallbackPlugin } from \"mf-shield/federation\";\nimport type { RemoteComponent } from \"mf-shield/react\";\n\nconst LocalFallback: RemoteComponent = () =\u003e \u003csection\u003eContenido de respaldo local\u003c/section\u003e;\n\nconst fallbackPlugin = createRemoteFallbackPlugin({\n  fallback: info =\u003e {\n    // Defiere a una denegación de acceso: deja que el guard gane.\n    if (info.error instanceof RemoteAccessDeniedError) return undefined;\n    // Reemplaza un fallo real de carga (lifecycle \"onLoad\") con un módulo local.\n    return { RemoteWidget: LocalFallback };\n  }\n});\n\nconst loadRemote = createFederationRuntime({ name: \"pokedex_host\", remoteEntries, plugins: [fallbackPlugin] });\n```\n\nContrato de retorno (verificado contra `@module-federation/runtime` 2.6.0): en `lifecycle: \"onLoad\"` un valor devuelto se usa como contenido del módulo (una función se trata como *module factory*); en `lifecycle: \"beforeRequest\"` MF interpreta el retorno como **args de request de reemplazo** para redirigir a otro remote — para propagar una denegación devuelve `undefined`. Nota: cuando `beforeRequest` lanza, MF re-emite `errorLoadRemote` con `lifecycle: \"onLoad\"`, así que si quieres que la denegación gane, verifica `info.error instanceof RemoteAccessDeniedError` como arriba.\n\n### Segundo runtime en la misma app (`createInstanceFederationRuntime`)\n\n`createFederationRuntime` usa `init`, que en 2.6.0 es **singleton por nombre**: un segundo `init` con otro nombre lanza `#RUNTIME-010`. Para un runtime adicional aislado (por ejemplo con otro set de plugins) usa `createInstanceFederationRuntime`, que crea una instancia independiente vía `createInstance` y liga el loader a ella:\n\n```ts\nimport { createInstanceFederationRuntime } from \"mf-shield/federation\";\n\nconst loadIsolated = createInstanceFederationRuntime({ name: \"widgets_host\", remoteEntries, plugins: [accessPlugin, fallbackPlugin] });\n```\n\n### Adopción sobre una instancia de runtime existente (`createLoaderFromInstance`)\n\nEl caso mayoritario en el mundo real: el plugin del bundler ya **auto-inicializó** el runtime y tu app lo obtiene con `getInstance()`. No quieres un segundo `init` ni `createInstance`: quieres blindar la instancia que ya tienes. `createLoaderFromInstance` recibe esa instancia como **parámetro** (la librería nunca llama a `getInstance()`, lo que la mantiene testeable y desacoplada del entry `enhanced/runtime` vs `runtime`) y devuelve el mismo loader tipado que `createFederatedLoader`.\n\n```ts\nimport { getInstance } from \"@module-federation/runtime\";\nimport { createLoaderFromInstance } from \"mf-shield/federation\";\nimport { createRemoteAccessPlugin, createRemoteFallbackPlugin } from \"mf-shield/federation\";\n\nconst instance = getInstance();\nif (!instance) throw new Error(\"MF runtime not initialized yet\"); // el null-guard es responsabilidad del caller\n\nconst loadRemote = createLoaderFromInstance(instance, {\n  plugins: [createRemoteAccessPlugin({ policy: allowPikachuOnly }), createRemoteFallbackPlugin({ fallback: stubMap })],\n  // remoteEntries es opcional: proveelo para registrar de forma perezosa (dedup por Set, igual que createFederatedLoader);\n  // omitelo para cargar ids contra los remotes que el plugin del bundler ya registró.\n  remoteEntries: { stable: { name: \"stable\", entry: \"http://127.0.0.1:4174/mf-manifest.json\" } }\n});\n\nconst mod = await loadRemote\u003c{ RemoteWidget: RemoteComponent }\u003e(\"stable/Widget\");\n```\n\n- Los `plugins` se registran **una sola vez** al crear el loader vía `instance.registerPlugins`.\n- Con `remoteEntries` omitido no se registra nada; los ids cargan contra los remotes ya registrados.\n- Un módulo nulo lanza `RemoteModuleNullError`, igual que los otros loaders.\n\n### Manifest offline + stubs (`createRemoteFallbackPlugin`)\n\nDos poderes aditivos sobre el mismo plugin de fallback, ambos opt-in.\n\n**Mapa declarativo de stubs.** En vez de una función `fallback` hecha a mano, pasa un mapa `id -\u003e stub`. Las claves son ids completos `\"\u003cremote\u003e/\u003cexpose\u003e\"`; `\"*\"` es un catch-all opcional. Un stub es el objeto módulo o una factory (sync o async). El plugin compila el mapa con el **gate de lifecycle** incorporado: los stubs se aplican **solo** cuando `info.lifecycle === \"onLoad\"` — devolver contenido de módulo en cualquier otro lifecycle corrompe el share scope (MF lo trata como args de request de reemplazo), que es justamente el endurecimiento que un fallback hecho a mano suele omitir.\n\n```ts\nimport { createRemoteFallbackPlugin } from \"mf-shield/federation\";\nimport type { RemoteComponent } from \"mf-shield/react\";\n\nconst FaintedCard: RemoteComponent = () =\u003e \u003csection\u003eEste Pokémon se debilitó\u003c/section\u003e;\n\nconst fallbackPlugin = createRemoteFallbackPlugin({\n  fallback: {\n    \"stable/Pokedex\": { RemotePokedex: FaintedCard },        // stub objeto\n    \"stable/Cards\": async () =\u003e import(\"./local-cards\"),      // factory async\n    \"*\": { Fallback: FaintedCard }                            // catch-all\n  }\n});\n```\n\n**Síntesis de manifest offline.** Cuando el fetch del manifest falla (provider caído, dev offline), el runtime ni siquiera puede empezar a resolver. Habilita `offlineManifest` para agregar un loaderHook `fetch`: intenta `globalThis.fetch` y, ante throw/reject, invoca `onOfflineManifest` y sirve un manifest sintetizado con `200` para que el runtime pueda continuar.\n\n```ts\nconst fallbackPlugin = createRemoteFallbackPlugin({\n  fallback: { \"*\": { Fallback: FaintedCard } },\n  offlineManifest: { name: \"stable\", globalName: \"stable_g\", publicPath: \"/stable/\" }, // o `true` para defaults\n  onOfflineManifest: ({ manifestUrl, error }) =\u003e console.warn(`[app] manifest offline para ${manifestUrl}`, error)\n});\n```\n\nEl shape sintetizado contiene exactamente los campos que `generateSnapshotFromManifest` (`@module-federation/sdk` 2.6.0) exige como obligatorios; `buildOfflineManifest(input?)` está exportada y es pura si quieres inspeccionarla o reusarla. Cuando `offlineManifest` se omite, el objeto plugin **no** tiene propiedad `fetch` alguna — cero cambio de comportamiento.\n\n### Componer con `@module-federation/retry-plugin`\n\nLos plugins del escudo componen con plugins oficiales de MF por la misma opción `plugins`. Para reintentos automáticos de fetch de manifest/chunks, agrega el retry-plugin oficial (instálalo en tu app; **no** es dependencia de esta librería):\n\n```bash\npnpm add @module-federation/retry-plugin\n```\n\n```ts\nimport { RetryPlugin } from \"@module-federation/retry-plugin\";\nimport { createFederationRuntime, createRemoteFallbackPlugin } from \"mf-shield/federation\";\n\nconst loadRemote = createFederationRuntime({\n  name: \"pokedex_host\",\n  remoteEntries,\n  plugins: [\n    RetryPlugin({ fetch: { retryTimes: 3 } }),\n    createRemoteFallbackPlugin({ fallback: () =\u003e ({ RemoteWidget: LocalFallback }) })\n  ]\n});\n```\n\nEl retry-plugin reintenta la descarga; el fallback-plugin cubre el caso en que, agotados los reintentos, el módulo sigue sin cargar.\n\n### Validar shared singletons (`validateSharedSingletons`)\n\nDetecta footguns de configuración `shared` sin lanzar. Devuelve advertencias legibles (`[]` cuando está limpio). `createFederationRuntime` la corre automáticamente y emite `console.warn(\"[mf-shield] …\")` una vez por creación de runtime; también puedes correrla suelta:\n\n```ts\nimport { validateSharedSingletons } from \"mf-shield\";\n\nconst warnings = validateSharedSingletons({\n  shared: { react: { version: \"19.2.5\", shareConfig: { singleton: true } } },\n  shareStrategy: \"version-first\"\n});\n// warnings: falta strictVersion, falta requiredVersion, y 'version-first' + singleton (MF #3209)\n```\n\nReglas: `singleton: true` sin `strictVersion`, `singleton: true` sin `requiredVersion`, y `shareStrategy: 'version-first'` combinado con cualquier singleton (puede cargar múltiples instancias del singleton y hace eager-load de todos los remote entries en el init).\n\n### Helpers de validación core (agnósticos, sin importar MF)\n\nTres helpers puros en el entry core (`mf-shield`) para endurecer contratos y componer con seguridad:\n\n| Helper | Resumen |\n|---|---|\n| `assertRemoteExports(module, id, expected)` | Asegura que un remote resuelto exponga los exports esperados; lanza `MissingRemoteExportError` (con `id` + `missing[]`) cuando alguno es `null`/`undefined`. En caso de éxito estrecha el tipo del módulo. |\n| `validateRemoteEntries(entries, policy?)` | Devuelve un reporte (`[]` cuando está limpio, nunca lanza) de issues de remote-entries: `duplicate-name`, `missing-entry`, `invalid-url`, `origin-not-allowed`, `insecure-entry`. Las entradas version-only (estilo registry) omiten los checks de URL; `localhost`/`127.0.0.1`/`[::1]` quedan exentos de `requireHttps`. |\n| `toFederationResult(thunk)` | Ejecuta un thunk sync o async y lo normaliza a un `FederationResult\u003cT, E\u003e` discriminado (`{ ok: true, value } \\| { ok: false, error }`), capturando tanto throws síncronos como rechazos. No es una mónada — un único combinador. Compone con `withTimeout` y los errores tipados. |\n\n```ts\nimport { assertRemoteExports, validateRemoteEntries, toFederationResult } from \"mf-shield\";\n\nconst result = await toFederationResult(() =\u003e withTimeout(loadRemote(\"stable/Pokedex\"), 800, \"pokedex\"));\nif (!result.ok) return renderFainted(result.error);\nassertRemoteExports(result.value, \"stable/Pokedex\", [\"RemotePokedex\"]); // fallo rápido ante drift de contrato\n```\n\n### CSS poison con debounce (`useCssPoisonGuard`)\n\n`useCssPoisonGuard` observa el `document.head` (o un `root` propio) y remueve CSS global inyectado por remotes. En remotes que inyectan estilos en ráfaga, pasa `debounceMs` para agrupar las remociones en una sola pasada trailing (default `0` = comportamiento inmediato). El callback `onPoisonRemoved` se toma por ref, así que cambiarlo no re-suscribe el observer.\n\n```tsx\nuseCssPoisonGuard({ debounceMs: 50, onPoisonRemoved: count =\u003e console.warn(`[app] removidos ${count} estilos`) });\n```\n\n## Subresource Integrity (`createSriPlugin`)\n\nCSP dice **de qué origen** carga un script; SRI dice **qué bytes exactos**. `createSriPlugin` aplica Subresource Integrity a los assets federados (remoteEntry, chunks y, opcionalmente, CSS/preload) vía los hooks oficiales `createScript` / `createLink` de MF: setea `integrity` + `crossorigin` en el elemento a partir de un hash que registras. Si los bytes no coinciden, el browser rechaza el script y el remote no se ejecuta.\n\n```ts\nimport { createFederationRuntime, createSriPlugin } from \"mf-shield/federation\";\n\nconst loadRemote = createFederationRuntime({\n  name: \"pokedex_host\",\n  remoteEntries: { app: { name: \"app\", entry: \"https://cdn.pokedex.example/app/v1.2.3/mf-manifest.json\" } },\n  plugins: [\n    createSriPlugin({\n      // url exacta del asset -\u003e hash \"sha384-...\"\n      integrity: {\n        \"https://cdn.pokedex.example/app/v1.2.3/remoteEntry.js\": \"sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8w\"\n      },\n      strict: true,        // default: url sin hash registrado -\u003e FederationIntegrityError\n      crossOrigin: \"anonymous\", // default; requerido para SRI cross-origin\n      onViolation: info =\u003e console.warn(`[app] sin hash SRI para ${info.url}`)\n    })\n  ]\n});\n```\n\n- `integrity` acepta un **mapa** `url -\u003e hash` (match por URL exacta, sin normalizar slash/query) o una **función** `(url) =\u003e hash | undefined` para lógica flexible (prefijos de origen, versiones).\n- `strict: true` (default) bloquea con `FederationIntegrityError` cualquier asset sin hash registrado; `strict: false` deja pasar sin `integrity` (útil para adopción gradual).\n- SRI fija los **bytes exactos**: cada deploy del remote que cambie el bundle debe **republicar** los hashes. Encaja con remotes **versionados/pinneados** (URLs inmutables por versión), no con URLs `latest` mutables.\n- Gotcha de reúso: si ya existe en el DOM un `\u003cscript\u003e` con un `src` que coincide, MF lo reutiliza y omite el hook `createScript`, así que un tag pre-existente para la misma URL nunca recibe `integrity`.\n\nGenera el hash de cada asset con:\n\n```bash\nopenssl dgst -sha384 -binary remoteEntry.js | openssl base64 -A\n# =\u003e pegar como \"sha384-\u003csalida\u003e\"\n```\n\nComposición: `createSriPlugin` corre en los mismos hooks de carga que usa el runtime y compone con `createRemoteAccessPlugin` / `createRemoteFallbackPlugin` y con el retry-plugin oficial por la misma opción `plugins`.\n\nCómo se combina con CSP (allowlist de origen, nonce + `strict-dynamic`, límites reales): ver [`docs/csp-guide.md`](../../docs/csp-guide.md).\n\n## Requisitos CSP\n\nLos remotes federados cargan manifest + chunks desde su propio origen en runtime. Tu Content-Security-Policy debe permitirlo:\n\n- `script-src`: incluye el origen de cada remote (p. ej. `https://remotes.pokedex.example`). En **producción no hace falta** `unsafe-eval`: los chunks son JS estático servido por el provider.\n- `connect-src`: incluye los mismos orígenes para el `fetch` del `mf-manifest.json` y de los chunks.\n- `style-src`: si los remotes inyectan estilos, contempla su origen (o `useCssPoisonGuard` para remover CSS global no deseado).\n\nHosts con CSP estricta (sin `unsafe-inline`): usa un **nonce** por request combinado con `strict-dynamic`, de modo que el loader raíz autorizado pueda cargar los chunks remotos sin allowlistar cada URL a mano.\n\nGuía práctica completa (allowlist por origen, nonce + `strict-dynamic`, `unsafe-eval` dev vs prod, cómo `createSriPlugin` complementa CSP y límites honestos): [`docs/csp-guide.md`](../../docs/csp-guide.md).\n\n## Compatibilidad\n\n| Bundler | Soporte | Notas |\n|---|---|---|\n| webpack | Soportado | Module Federation nativo |\n| rspack | Soportado | Module Federation nativo |\n| rsbuild | Soportado | Cubierto por la suite e2e |\n| vite | Runtime-level | Funciona vía `@module-federation/runtime`; sin dev mode oficial todavía |\n\n| React | Soporte |\n|---|---|\n| 18 | `peer ^18.2.0` |\n| 19 | `peer ^19.0.0` |\n\nLa entrada `/react` es la única que toca React; core y federation son agnósticas.\n\n## Seguridad (honesto)\n\nEsta librería **no es un sandbox**. Una vez cargado, el código remoto corre en el mismo realm que tu host: comparte `window`, `document`, memoria y prototipos. Es plenamente confiable desde el punto de vista de ejecución — puede hacer lo que quiera dentro de la página.\n\nLo que la librería sí aporta como mitigación:\n\n- **Allowlist de orígenes**: los remotes se registran explícitamente; no hay carga arbitraria.\n- **Política de acceso previa al import**: el guard corre antes de descargar el manifest.\n- **CSP fuerte**: recorta qué orígenes pueden servir script/estilos (ver [`docs/csp-guide.md`](../../docs/csp-guide.md)).\n- **SRI (Subresource Integrity)**: `createSriPlugin` verifica los bytes exactos de cada asset federado; en modo estricto bloquea cualquier chunk sin hash registrado.\n\nPara aislamiento real de código no confiable (CPU infinito, memoria extrema, DOM/CSS hostil, supply chain malicioso) necesitas otro nivel: Web Worker, iframe, shadow DOM o proceso separado. Ningún boundary same-realm reemplaza eso.\n\n## Suite de conformance\n\nEl comportamiento de estas protecciones está validado por una suite end-to-end de **26 escenarios de falla reales** (Playwright) que inyectan fallas reales — manifest caído / HTML en vez de JS / colgado sin responder, exposed module faltante, loader/render/async crash, mismatch de versiones, timeout, chunk 404, drift de contrato, multi-remote, retry/recovery, CSS poison (con y sin marca de poison), CPU burst, boundary de provider, plugins de runtime (acceso + fallback), SRI (hash incorrecto bloqueado + gate estricto sin hash), footguns de `shared` singleton (double React silencioso) y ruta directa no autorizada — más portabilidad de bundler (un host Vite reutiliza el escudo), y verifican que el shell nunca muere.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fghondar%2Fmf-shield","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fghondar%2Fmf-shield","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fghondar%2Fmf-shield/lists"}