Gestion de versiones
Tabla de contenidos
- Vision general
- Arquitectura
- Esquema de configuracion de versiones
- Arranque de la Shell App
- Pipeline de despliegue
- Rollback
- Brecha de atomicidad KV/D1
- Canary / Despliegue gradual
- Estrategia de invalidacion de cache
- Funcionalidades del Admin UI
- Esquema D1
- Referencias
Vision general
La gestion de versiones en tiempo de ejecucion permite a los operadores de la plataforma controlar exactamente que version de cada micro frontend (MFE) se carga en produccion, sin redesplegar la shell application ni ninguna otra parte de la infraestructura. El Admin UI proporciona una interfaz centralizada para fijar, promover y revertir versiones de MFEs entre entornos.
Capacidades principales
- Cambios de version sin redespliegue: Actualizamos un valor de configuracion y cada carga de pagina posterior recoge la nueva version del MFE. La shell application lee la configuracion de versiones activa durante el arranque e indica a Module Federation que cargue los remotes desde las URLs de CDN correspondientes.
- Rollback instantaneo: Los artefactos de compilaciones anteriores permanecen en R2/CDN indefinidamente. Revertir consiste en apuntar la configuracion de versiones a una URL anterior --- sin recompilar, sin redesplegar.
- Despliegues canary: Enrutamos un porcentaje de usuarios a una nueva version del MFE usando hashing consistente sobre la identidad del usuario. El porcentaje se incrementa gradualmente conforme crece la confianza.
- Fijacion por entorno: Mantenemos configuraciones de version independientes para
dev,stagingyproduction. Las versiones se promueven entre entornos con puertas de aprobacion explicitas. - Trazabilidad completa: Cada cambio de version se registra en D1 con la identidad del actor, marca temporal y tipo de evento, habilitando el cumplimiento normativo y el analisis post-incidente.
Arquitectura
Flujo de datos
El sistema de gestion de versiones sigue un patron de cache write-through donde D1 sirve como fuente de verdad persistente y KV proporciona lecturas de baja latencia en el edge.
1. El admin fija la version del MFE via UI
El operador selecciona un MFE, elige una version registrada en el desplegable y hace clic en "Activate". El Admin UI envia una peticion POST autenticada al Version Config Service.
2. El Config Service escribe en D1 (auditoria) + KV (lecturas rapidas)
El Worker del Version Config Service realiza una escritura transaccional:
- Inserta una fila en
deployment_eventsen D1 registrando la activacion. - Actualiza la tabla
version_configs: estableceis_active = falseen la fila previamente activa para ese MFE/entorno, yis_active = trueen la fila recien activada. - Escribe el JSON agregado de configuracion de versiones en KV bajo la clave
version-config:{environment}.
Como la escritura en D1 y en KV ocurren en la misma invocacion del Worker, el sistema proporciona consistencia fuerte en el caso comun. Sin embargo, D1 y KV son sistemas de almacenamiento independientes sin garantia transaccional cruzada --- ver Brecha de atomicidad KV/D1 para modos de fallo y mitigaciones.
3. La shell app obtiene la configuracion de versiones desde KV en cada carga de pagina
Cuando un usuario navega a la aplicacion, la logica de arranque de la shell app emite una peticion GET al endpoint del Version Config Service (p. ej., GET /api/v1/version-config?env=production). Este endpoint lee directamente de KV, garantizando tiempos de respuesta inferiores al milisegundo en cualquier ubicacion edge de Cloudflare a nivel mundial.
4. El runtime de Module Federation carga remotes desde URLs de CDN versionadas
La shell pasa las URLs entry de la configuracion de versiones a la llamada init() de Module Federation. El runtime obtiene el mf-manifest.json de cada MFE desde la ruta versionada en el CDN, y despues carga los chunks necesarios bajo demanda conforme el usuario navega a cada ruta.
Esquema de configuracion de versiones
Interfaz TypeScript
interface MfeVersionEntry {
/** Semver version string, e.g. "2.3.1" */
version: string;
/** Full URL to the mf-manifest.json for this version */
entry: string;
/** Subresource Integrity hash for the manifest (optional but recommended) */
integrity?: string;
/** ISO 8601 timestamp of when this version was activated */
updatedAt: string;
/** Email or identity of the user who activated this version */
updatedBy: string;
/** Canary configuration (present only during gradual rollouts) */
canary?: {
/** Version being rolled out */
version: string;
/** Entry URL for the canary version */
entry: string;
/** Percentage of traffic routed to canary (0-100) */
percentage: number;
/** Integrity hash for canary manifest */
integrity?: string;
};
}
interface VersionConfig {
[mfeName: string]: MfeVersionEntry;
}
Ejemplo de valor en KV
El siguiente JSON se almacena en KV bajo la clave version-config:production:
{
"mfe_dashboard": {
"version": "2.3.1",
"entry": "https://cdn.example.com/mfe-dashboard/v2.3.1/mf-manifest.json",
"integrity": "sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8w",
"updatedAt": "2025-03-15T10:30:00Z",
"updatedBy": "admin@example.com"
},
"mfe_settings": {
"version": "1.8.0",
"entry": "https://cdn.example.com/mfe-settings/v1.8.0/mf-manifest.json",
"integrity": "sha384-Li9vy3DqF8tnTXuiaAJuML3ky+er10rcgNR/VqsVpcw+ThHmYcwiB1pbOxEb2V2I",
"updatedAt": "2025-03-14T16:45:00Z",
"updatedBy": "admin@example.com"
},
"mfe_analytics": {
"version": "3.1.0",
"entry": "https://cdn.example.com/mfe-analytics/v3.1.0/mf-manifest.json",
"updatedAt": "2025-03-13T09:00:00Z",
"updatedBy": "deploy-bot@example.com",
"canary": {
"version": "3.2.0-rc.1",
"entry": "https://cdn.example.com/mfe-analytics/v3.2.0-rc.1/mf-manifest.json",
"percentage": 10
}
}
}
Convencion de nombres de claves en KV
| Patron de clave | Descripcion |
|---|---|
version-config:production | Configuracion de versiones activa para produccion |
version-config:staging | Configuracion de versiones activa para staging |
version-config:dev | Configuracion de versiones activa para dev |
version-history:{env}:{mfe_name} | Ultimas 50 versiones para consulta rapida del historial |
Arranque de la Shell App
La shell application lee la configuracion de versiones al arrancar y configura dinamicamente los remotes de Module Federation. Esto significa que la shell nunca tiene URLs de remotes fijadas en el codigo --- las descubre en tiempo de ejecucion.
Secuencia de arranque
- Obtener la configuracion de versiones del Version Config Service (respaldado por KV en el edge).
- Resolver asignaciones canary --- si algun MFE tiene configuracion canary, determinar si el usuario actual debe recibir la version canary mediante hashing consistente.
- Inicializar el runtime de Module Federation con remotes dinamicos derivados de la configuracion.
- Registrar las rutas que cargan de forma lazy los componentes expuestos de cada MFE.
- Renderizar la aplicacion.
Implementacion
// src/bootstrap.tsx
import { init, loadRemote } from '@module-federation/enhanced/runtime';
import { createRoot } from 'react-dom/client';
import { App } from './App';
import type { VersionConfig, MfeVersionEntry } from './types/version-config';
const VERSION_CONFIG_URL = 'https://config.example.com/api/v1/version-config';
/**
* Fetches the active version config from the Version Config Service.
* The service reads from KV, so this is fast at any edge location.
*/
async function fetchVersionConfig(): Promise<VersionConfig> {
const environment = import.meta.env.VITE_ENVIRONMENT ?? 'production';
const response = await fetch(`${VERSION_CONFIG_URL}?env=${environment}`, {
headers: { 'Accept': 'application/json' },
});
if (!response.ok) {
throw new Error(
`Failed to fetch version config: ${response.status} ${response.statusText}`
);
}
return response.json();
}
/**
* Determines which entry URL to use for an MFE, accounting for canary config.
* Uses consistent hashing on the user ID so the same user always gets the
* same version within a canary window.
*/
function resolveEntry(
mfeName: string,
config: MfeVersionEntry,
userId: string | null
): string {
if (!config.canary || !userId) {
return config.entry;
}
const hash = simpleHash(`${userId}:${mfeName}`);
const bucket = hash % 100;
if (bucket < config.canary.percentage) {
return config.canary.entry;
}
return config.entry;
}
/**
* Simple deterministic hash for canary bucketing.
* Not cryptographic --- just needs to be consistent and well-distributed.
*/
function simpleHash(input: string): number {
let hash = 0;
for (let i = 0; i < input.length; i++) {
const char = input.charCodeAt(i);
hash = ((hash << 5) - hash + char) | 0;
}
return Math.abs(hash);
}
/**
* Main bootstrap function.
*/
const bootstrap = async (): Promise<void> => {
try {
const config = await fetchVersionConfig();
const userId = localStorage.getItem('user_id');
// Build the remotes object for Module Federation init().
// Each remote includes an entry URL and, when available, an SRI integrity
// hash so that the browser rejects tampered bundles at load time.
const resolvedRemotes = Object.entries(config).map(([name, mfeEntry]) => {
const isCanary =
mfeEntry.canary && userId
? simpleHash(`${userId}:${name}`) % 100 < mfeEntry.canary.percentage
: false;
return {
name,
entry: isCanary ? mfeEntry.canary!.entry : mfeEntry.entry,
integrity: isCanary
? mfeEntry.canary!.integrity
: mfeEntry.integrity,
};
});
// Enforce SRI: reject any remote that was registered without an integrity hash.
// This prevents loading MFE bundles that cannot be verified against tampering.
const remotesWithoutIntegrity = resolvedRemotes.filter((r) => !r.integrity);
if (remotesWithoutIntegrity.length > 0) {
console.error(
'[Shell] SRI integrity hash missing for remotes:',
remotesWithoutIntegrity.map((r) => r.name)
);
throw new Error(
`SRI integrity hash is required for all MFE remotes. ` +
`Missing: ${remotesWithoutIntegrity.map((r) => r.name).join(', ')}`
);
}
init({
name: 'shell',
remotes: Object.fromEntries(
resolvedRemotes.map(({ name, entry }) => [name, { name, entry }])
),
shared: {
react: {
version: '19.2.4',
scope: 'default',
lib: () => import('react'),
shareConfig: { singleton: true, requiredVersion: '^19.2.4' },
},
'react-dom': {
version: '19.2.4',
scope: 'default',
lib: () => import('react-dom'),
shareConfig: { singleton: true, requiredVersion: '^19.2.4' },
},
},
});
const root = createRoot(document.getElementById('root')!);
root.render(<App versionConfig={config} />);
} catch (error) {
console.error('[Shell] Bootstrap failed:', error);
// Render a fallback error UI so the user is not left with a blank screen
const root = createRoot(document.getElementById('root')!);
root.render(
<div role="alert">
<h1>Application failed to load</h1>
<p>Please refresh the page or contact support.</p>
</div>
);
}
};
bootstrap();
Registro de rutas con MFEs cargados de forma lazy
// src/routes.tsx
import { lazy, Suspense } from 'react';
import { loadRemote } from '@module-federation/enhanced/runtime';
import type { RouteObject } from 'react-router-dom';
import { LoadingSpinner } from './components/LoadingSpinner';
import { MfeErrorBoundary } from './components/MfeErrorBoundary';
/**
* Creates a lazy React component backed by a Module Federation remote.
*/
function createRemoteComponent(remoteName: string, exposedModule: string) {
return lazy(async () => {
const module = await loadRemote<{ default: React.ComponentType }>(
`${remoteName}/${exposedModule}`
);
if (!module) {
throw new Error(`Failed to load remote module: ${remoteName}/${exposedModule}`);
}
return module;
});
}
const Dashboard = createRemoteComponent('mfe_dashboard', 'DashboardPage');
const Settings = createRemoteComponent('mfe_settings', 'SettingsPage');
const Analytics = createRemoteComponent('mfe_analytics', 'AnalyticsPage');
export const routes: RouteObject[] = [
{
path: '/dashboard',
element: (
<MfeErrorBoundary mfeName="mfe_dashboard">
<Suspense fallback={<LoadingSpinner />}>
<Dashboard />
</Suspense>
</MfeErrorBoundary>
),
},
{
path: '/settings/*',
element: (
<MfeErrorBoundary mfeName="mfe_settings">
<Suspense fallback={<LoadingSpinner />}>
<Settings />
</Suspense>
</MfeErrorBoundary>
),
},
{
path: '/analytics/*',
element: (
<MfeErrorBoundary mfeName="mfe_analytics">
<Suspense fallback={<LoadingSpinner />}>
<Analytics />
</Suspense>
</MfeErrorBoundary>
),
},
];
Pipeline de despliegue
Cuando un desarrollador fusiona un cambio en un MFE, el pipeline de CI compila el MFE, sube los artefactos a R2 y registra la nueva version en el Version Config Service. Es importante destacar que registrar no significa activar --- la nueva version permanece en el registro hasta que un admin (o una regla de promocion automatizada) la activa explicitamente.
Flujo de extremo a extremo
Script de CI
El siguiente workflow de GitHub Actions maneja los pasos 2 a 4.
# .github/workflows/deploy-mfe.yml
name: Deploy MFE
on:
push:
branches: [main]
env:
MFE_NAME: mfe-dashboard
R2_BUCKET: mfe-artifacts
CONFIG_SERVICE_URL: https://config.example.com/api/v1
jobs:
build-and-register:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '22'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Determine version
id: version
run: |
VERSION=$(node -p "require('./package.json').version")
SHORT_SHA=$(git rev-parse --short HEAD)
FULL_VERSION="${VERSION}+${SHORT_SHA}"
echo "version=${FULL_VERSION}" >> "$GITHUB_OUTPUT"
- name: Build MFE with Rsbuild
run: pnpm rsbuild build
env:
MFE_VERSION: ${{ steps.version.outputs.version }}
PUBLIC_PATH: https://cdn.example.com/${{ env.MFE_NAME }}/${{ steps.version.outputs.version }}/
- name: Compute integrity hash
id: integrity
run: |
HASH=$(shasum -b -a 384 dist/mf-manifest.json | awk '{ print $1 }' | xxd -r -p | base64)
echo "hash=sha384-${HASH}" >> "$GITHUB_OUTPUT"
- name: Upload to R2
uses: cloudflare/wrangler-action@v4
with:
command: r2 object put "${{ env.R2_BUCKET }}/${{ env.MFE_NAME }}/${{ steps.version.outputs.version }}/" --file=dist/ --recursive
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
- name: Register version with Config Service
run: |
curl -sf -X POST "${{ env.CONFIG_SERVICE_URL }}/versions" \
-H "Authorization: Bearer ${{ secrets.CONFIG_SERVICE_TOKEN }}" \
-H "Content-Type: application/json" \
-d '{
"mfeName": "${{ env.MFE_NAME }}",
"version": "${{ steps.version.outputs.version }}",
"entryUrl": "https://cdn.example.com/${{ env.MFE_NAME }}/${{ steps.version.outputs.version }}/mf-manifest.json",
"integrityHash": "${{ steps.integrity.outputs.hash }}",
"environment": "dev",
"createdBy": "ci-bot@example.com"
}'
- name: Auto-activate in dev environment
if: success()
run: |
curl -sf -X POST "${{ env.CONFIG_SERVICE_URL }}/versions/activate" \
-H "Authorization: Bearer ${{ secrets.CONFIG_SERVICE_TOKEN }}" \
-H "Content-Type: application/json" \
-d '{
"mfeName": "${{ env.MFE_NAME }}",
"version": "${{ steps.version.outputs.version }}",
"environment": "dev",
"activatedBy": "ci-bot@example.com"
}'
Version Config Service --- Endpoint de registro
// workers/version-config-service/src/handlers/register-version.ts
import { D1Database, KVNamespace } from '@cloudflare/workers-types';
interface RegisterVersionRequest {
mfeName: string;
version: string;
entryUrl: string;
integrityHash?: string;
environment: string;
createdBy: string;
}
export async function handleRegisterVersion(
request: Request,
env: { DB: D1Database; VERSION_KV: KVNamespace }
): Promise<Response> {
const body: RegisterVersionRequest = await request.json();
// Validate that the manifest is actually accessible before registering
const manifestCheck = await fetch(body.entryUrl, { method: 'HEAD' });
if (!manifestCheck.ok) {
return Response.json(
{ error: `Manifest not accessible at ${body.entryUrl}: ${manifestCheck.status}` },
{ status: 400 }
);
}
// Check for duplicate registration
const existing = await env.DB.prepare(
'SELECT id FROM version_configs WHERE environment = ? AND mfe_name = ? AND version = ?'
)
.bind(body.environment, body.mfeName, body.version)
.first();
if (existing) {
return Response.json(
{ error: 'Version already registered', existingId: existing.id },
{ status: 409 }
);
}
// Insert the version record (is_active defaults to false)
const result = await env.DB.prepare(
`INSERT INTO version_configs (environment, mfe_name, version, entry_url, integrity_hash, created_by)
VALUES (?, ?, ?, ?, ?, ?)`
)
.bind(
body.environment,
body.mfeName,
body.version,
body.entryUrl,
body.integrityHash ?? null,
body.createdBy
)
.run();
// Record the deployment event
await env.DB.prepare(
`INSERT INTO deployment_events (environment, mfe_name, version, event_type, created_by)
VALUES (?, ?, ?, 'registered', ?)`
)
.bind(body.environment, body.mfeName, body.version, body.createdBy)
.run();
return Response.json(
{ id: result.meta.last_row_id, status: 'registered' },
{ status: 201 }
);
}
Rollback
El rollback es una de las capacidades operativas mas criticas. Dado que todos los bundles de MFE desplegados previamente permanecen en R2/CDN, un rollback es simplemente un cambio de configuracion que apunta la configuracion de versiones de vuelta a la URL de una version anterior. No se necesita recompilar ni redesplegar.
Flujo de rollback
Handler de rollback
// workers/version-config-service/src/handlers/activate-version.ts
interface ActivateVersionRequest {
mfeName: string;
version: string;
environment: string;
activatedBy: string;
isRollback?: boolean;
}
export async function handleActivateVersion(
request: Request,
env: { DB: D1Database; VERSION_KV: KVNamespace }
): Promise<Response> {
const body: ActivateVersionRequest = await request.json();
// 1. Verify the target version exists and its bundle is accessible
const targetVersion = await env.DB.prepare(
'SELECT * FROM version_configs WHERE environment = ? AND mfe_name = ? AND version = ?'
)
.bind(body.environment, body.mfeName, body.version)
.first<VersionConfigRow>();
if (!targetVersion) {
return Response.json({ error: 'Version not found' }, { status: 404 });
}
const manifestCheck = await fetch(targetVersion.entry_url, { method: 'HEAD' });
if (!manifestCheck.ok) {
return Response.json(
{ error: `Bundle no longer accessible at ${targetVersion.entry_url}` },
{ status: 400 }
);
}
// 2-4. Wrap deactivation, activation, and audit event in a D1 batch
// transaction to prevent race conditions (e.g., two simultaneous
// activations leaving multiple rows with is_active = true).
const eventType = body.isRollback ? 'rollback' : 'activated';
const stmtDeactivate = env.DB.prepare(
`UPDATE version_configs SET is_active = false
WHERE environment = ? AND mfe_name = ? AND is_active = true`
).bind(body.environment, body.mfeName);
const stmtActivate = env.DB.prepare(
`UPDATE version_configs
SET is_active = true, activated_at = datetime('now'), activated_by = ?
WHERE id = ?`
).bind(body.activatedBy, targetVersion.id);
const stmtEvent = env.DB.prepare(
`INSERT INTO deployment_events (environment, mfe_name, version, event_type, metadata, created_by)
VALUES (?, ?, ?, ?, ?, ?)`
).bind(
body.environment,
body.mfeName,
body.version,
eventType,
JSON.stringify({ previousVersion: targetVersion.version }),
body.activatedBy
);
// D1 batch() executes all statements in a single transaction.
// If any statement fails, the entire batch is rolled back.
await env.DB.batch([stmtDeactivate, stmtActivate, stmtEvent]);
// 5. Rebuild and write the aggregated version config to KV
await syncVersionConfigToKV(env, body.environment);
return Response.json({ status: eventType, version: body.version });
}
/**
* Reads all active versions for an environment from D1 and writes the
* aggregated config to KV.
*/
async function syncVersionConfigToKV(
env: { DB: D1Database; VERSION_KV: KVNamespace },
environment: string
): Promise<void> {
const activeVersions = await env.DB.prepare(
'SELECT * FROM version_configs WHERE environment = ? AND is_active = true'
)
.bind(environment)
.all<VersionConfigRow>();
const config: Record<string, MfeVersionEntry> = {};
for (const row of activeVersions.results) {
config[row.mfe_name] = {
version: row.version,
entry: row.entry_url,
integrity: row.integrity_hash ?? undefined,
updatedAt: row.activated_at ?? row.created_at,
updatedBy: row.activated_by ?? row.created_by,
};
}
await env.VERSION_KV.put(
`version-config:${environment}`,
JSON.stringify(config),
{ metadata: { updatedAt: new Date().toISOString() } }
);
}
Consideraciones de rollback
| Aspecto | Mitigacion |
|---|---|
| Retraso en la propagacion de KV | Para rollbacks urgentes, tambien se debe purgar la cache de Cloudflare en el endpoint de configuracion de versiones usando la Cache API. Ver Estrategia de invalidacion de cache. |
| Continuidad de sesion | Los usuarios con una sesion activa seguiran ejecutando el codigo del MFE anterior hasta que refresquen o naveguen. La shell puede detectar discrepancias de version y mostrar un aviso suave: "Hay una nueva version disponible. Haz clic para refrescar." |
| Compatibilidad de estado compartido | Si la nueva version cambio la estructura del estado persistido (p. ej., localStorage, IndexedDB), revertir a la version anterior puede encontrar datos inesperados. Los MFEs deben usar claves de almacenamiento versionadas o migraciones de esquema. |
| Cache de CDN en bundles de MFE | Los bundles de MFE se sirven desde rutas versionadas (/v2.3.1/), por lo que son efectivamente inmutables. Revertir no requiere purgar caches de bundles --- los bundles anteriores ya estan cacheados bajo sus propias rutas. |
| Retencion de bundles | Nunca eliminar bundles antiguos de R2. Implementar una politica de retencion (p. ej., mantener las ultimas 20 versiones) para gestionar costes de almacenamiento preservando la capacidad de rollback. |
Brecha de atomicidad KV/D1
D1 (SQLite) y KV son sistemas de almacenamiento independientes. No existe una transaccion distribuida que abarque ambos, lo que significa que una escritura puede tener exito en un sistema y fallar en el otro. Esta seccion documenta los modos de fallo y las mitigaciones recomendadas.
Escenarios de fallo
| Escenario | Sintoma | Impacto |
|---|---|---|
| La escritura en D1 tiene exito, la escritura en KV falla | El registro de auditoria y la tabla version_configs reflejan la nueva version activa, pero la shell sigue cargando la version anterior porque KV aun contiene la configuracion previa. | Los usuarios ven versiones de MFE obsoletas. El Admin UI muestra la version como "activa" aunque no se esta sirviendo. |
| La escritura en D1 falla, la escritura en KV tiene exito | Improbable en la practica porque el codigo escribe en D1 primero, pero podria ocurrir si D1 hace commit y el Worker se cae antes de la escritura en KV, seguido de un reintento que omite D1 (por la restriccion UNIQUE) pero escribe en KV. | KV sirve una configuracion que no coincide con la fuente de verdad en D1. Una sincronizacion completa posterior de D1 a KV sobrescribiria el valor obsoleto en KV. |
| Batch parcial en D1 + escritura en KV | La transaccion batch de D1 (desactivar + activar + evento) tiene exito atomicamente, pero la escritura posterior en KV falla por un error transitorio de KV o timeout del Worker. | Igual que el primer escenario: D1 esta correcto, KV esta obsoleto. |
Estrategias de mitigacion
1. Reintento con idempotencia
La escritura en KV es inherentemente idempotente (un PUT con la misma clave y valor es seguro de repetir). Si la escritura en KV falla, el Config Service debe reintentarla un numero limitado de veces antes de devolver un error al llamante.
async function syncVersionConfigToKVWithRetry(
env: { DB: D1Database; VERSION_KV: KVNamespace },
environment: string,
maxRetries = 3
): Promise<void> {
const config = await buildVersionConfigFromD1(env, environment);
const payload = JSON.stringify(config);
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
await env.VERSION_KV.put(
`version-config:${environment}`,
payload,
{ metadata: { updatedAt: new Date().toISOString() } }
);
return; // Success
} catch (error) {
console.error(
`[syncKV] Attempt ${attempt}/${maxRetries} failed for env=${environment}:`,
error
);
if (attempt === maxRetries) throw error;
// Brief delay before retry
await new Promise((resolve) => setTimeout(resolve, 100 * attempt));
}
}
}
2. Trabajo de reconciliacion periodica
Un Worker programado (Cron Trigger) se ejecuta cada pocos minutos y reconcilia KV con D1. Para cada entorno, lee las versiones activas desde D1, construye el valor esperado de KV y sobrescribe KV si los valores difieren.
// workers/version-config-service/src/scheduled.ts
export default {
async scheduled(
_event: ScheduledEvent,
env: { DB: D1Database; VERSION_KV: KVNamespace }
): Promise<void> {
for (const environment of ['dev', 'staging', 'production']) {
const expected = await buildVersionConfigFromD1(env, environment);
const current = await env.VERSION_KV.get(`version-config:${environment}`);
if (JSON.stringify(expected) !== current) {
console.warn(
`[Reconciliation] KV drift detected for env=${environment}. Resyncing.`
);
await env.VERSION_KV.put(
`version-config:${environment}`,
JSON.stringify(expected),
{ metadata: { updatedAt: new Date().toISOString(), reconciledBy: 'cron' } }
);
}
}
},
};
3. La respuesta indica fallo parcial
Si D1 tiene exito pero KV falla (incluso tras reintentos), el endpoint de activacion debe devolver una respuesta que indique claramente el exito parcial para que el llamante pueda tomar acciones correctivas.
// In handleActivateVersion, after the D1 batch succeeds:
try {
await syncVersionConfigToKVWithRetry(env, body.environment);
} catch (kvError) {
console.error('[Activate] KV sync failed after D1 commit:', kvError);
return Response.json(
{
status: eventType,
version: body.version,
warning: 'D1 updated successfully but KV sync failed. '
+ 'The reconciliation job will correct this within minutes. '
+ 'You may also retry the activation.',
},
{ status: 207 } // 207 Multi-Status
);
}
Principio de diseno
D1 es la fuente de verdad. KV es una cache derivada, eventualmente consistente. Cualquier divergencia debe tratarse como un problema de obsolescencia de KV y resolverse re-derivando KV desde D1. El trabajo de reconciliacion proporciona la red de seguridad que garantiza que la divergencia sea siempre temporal y acotada.
Canary / Despliegue gradual
Despliegue basado en porcentaje
Los despliegues canary permiten probar una nueva version de MFE con una fraccion del trafico real de produccion antes de la activacion completa. La configuracion de versiones soporta un campo opcional canary en cualquier entrada de MFE.
Esquema de configuracion canary
interface CanaryConfig {
/** The canary version string */
version: string;
/** Entry URL for the canary mf-manifest.json */
entry: string;
/** Integrity hash for the canary manifest */
integrity?: string;
/** Percentage of users who should receive the canary (0-100) */
percentage: number;
/** When the canary was started */
startedAt: string;
/** Who initiated the canary */
startedBy: string;
}
Flujo canary en el Admin UI
- El admin navega a la lista de versiones del MFE y selecciona una version registrada.
- En lugar de "Activate", hace clic en "Start Canary".
- Establece el porcentaje inicial de trafico (p. ej., 5%).
- El Config Service escribe la configuracion canary anidada dentro de la entrada del MFE en KV.
- El admin monitoriza las tasas de error y el rendimiento en el dashboard.
- El admin incrementa el porcentaje gradualmente (5% -> 25% -> 50% -> 100%).
- Al 100%, el admin hace clic en "Promote" para activar completamente y eliminar la configuracion canary.
Logica canary en la shell
// src/canary.ts
/**
* Resolves the effective entry URL for an MFE, accounting for canary routing.
*
* Uses consistent hashing so the same user always lands in the same bucket
* for a given MFE. This prevents users from flipping between versions on
* successive page loads.
*/
export function resolveCanaryEntry(
mfeName: string,
config: MfeVersionEntry,
userId: string | null
): { entry: string; version: string; isCanary: boolean } {
// No canary config or no user ID: always serve the stable version.
// Anonymous users never get canary to avoid inconsistent experiences.
if (!config.canary || !userId) {
return { entry: config.entry, version: config.version, isCanary: false };
}
const bucket = consistentBucket(userId, mfeName);
if (bucket < config.canary.percentage) {
return {
entry: config.canary.entry,
version: config.canary.version,
isCanary: true,
};
}
return { entry: config.entry, version: config.version, isCanary: false };
}
/**
* Produces a stable integer in [0, 100) for a given user + MFE pair.
* Uses FNV-1a for speed and good distribution.
*/
function consistentBucket(userId: string, mfeName: string): number {
const input = `${userId}:${mfeName}`;
let hash = 0x811c9dc5; // FNV offset basis
for (let i = 0; i < input.length; i++) {
hash ^= input.charCodeAt(i);
hash = Math.imul(hash, 0x01000193); // FNV prime
}
return ((hash >>> 0) % 100);
}
Observabilidad canary
La shell reporta la version activa de cada MFE al stack de observabilidad para que las tasas de error y el rendimiento puedan segmentarse por version.
// src/telemetry.ts
export function reportMfeVersions(
resolvedVersions: Record<string, { version: string; isCanary: boolean }>
): void {
// Tag all subsequent telemetry with MFE versions
for (const [mfeName, { version, isCanary }] of Object.entries(resolvedVersions)) {
analytics.setGlobalTag(`mfe.${mfeName}.version`, version);
analytics.setGlobalTag(`mfe.${mfeName}.canary`, String(isCanary));
}
}
Promocion basada en entorno
Ademas de los despliegues canary dentro de un mismo entorno, la plataforma soporta la promocion de configuraciones de version entre entornos. Esto proporciona un camino estructurado desde desarrollo hasta produccion.
Flujo de promocion
| Etapa | Politica de activacion |
|---|---|
dev | Auto-activada por CI en cada push a main. |
staging | Promovida manualmente desde dev via Admin UI o API. |
production | Activada manualmente o desplegada como canary desde staging. |
Endpoint de promocion
// workers/version-config-service/src/handlers/promote-version.ts
interface PromoteRequest {
mfeName: string;
version: string;
fromEnvironment: string; // e.g., "staging"
toEnvironment: string; // e.g., "production"
promotedBy: string;
}
export async function handlePromoteVersion(
request: Request,
env: { DB: D1Database; VERSION_KV: KVNamespace }
): Promise<Response> {
const body: PromoteRequest = await request.json();
// Verify the version is active in the source environment
const sourceVersion = await env.DB.prepare(
`SELECT * FROM version_configs
WHERE environment = ? AND mfe_name = ? AND version = ? AND is_active = true`
)
.bind(body.fromEnvironment, body.mfeName, body.version)
.first<VersionConfigRow>();
if (!sourceVersion) {
return Response.json(
{ error: `Version ${body.version} is not active in ${body.fromEnvironment}` },
{ status: 400 }
);
}
// Check if this version is already registered in the target environment
const existingInTarget = await env.DB.prepare(
`SELECT id FROM version_configs
WHERE environment = ? AND mfe_name = ? AND version = ?`
)
.bind(body.toEnvironment, body.mfeName, body.version)
.first();
if (!existingInTarget) {
// Register the version in the target environment
await env.DB.prepare(
`INSERT INTO version_configs (environment, mfe_name, version, entry_url, integrity_hash, created_by)
VALUES (?, ?, ?, ?, ?, ?)`
)
.bind(
body.toEnvironment,
body.mfeName,
body.version,
sourceVersion.entry_url,
sourceVersion.integrity_hash,
body.promotedBy
)
.run();
}
// Activate it in the target environment (reuses the activation handler logic)
const activateRequest = new Request(request.url, {
method: 'POST',
headers: request.headers,
body: JSON.stringify({
mfeName: body.mfeName,
version: body.version,
environment: body.toEnvironment,
activatedBy: body.promotedBy,
}),
});
return handleActivateVersion(activateRequest, env);
}
Estrategia de invalidacion de cache
La configuracion de versiones se encuentra detras de multiples capas de cache. Comprender los retrasos de propagacion es esencial para un comportamiento operativo predecible.
Capas de cache
El admin escribe en el Config Service
│
▼
D1 (consistencia inmediata dentro del mismo colo)
│
▼
Escritura en KV (propagacion: ~60 segundos a todas las ubicaciones edge)
│
▼
Cache CDN de Cloudflare en el endpoint /api/v1/version-config
│
▼
Cache HTTP del navegador (si los headers Cache-Control lo permiten)
Linea temporal de propagacion
| Capa | Retraso tipico | Peor caso |
|---|---|---|
| Escritura en D1 | Inmediato | < 100ms |
| Propagacion global de KV | ~60 segundos | Hasta 60 segundos |
| Cache edge del CDN (si esta habilitada) | Depende del TTL | Hasta la duracion del TTL |
| Cache del navegador | Depende de los headers | Hasta la expiracion o refresco manual |
Estrategias de mitigacion
1. TTL corto en el endpoint de configuracion de versiones
El endpoint del Version Config Service establece headers de cache conservadores para asegurar la frescura.
// workers/version-config-service/src/handlers/get-config.ts
export async function handleGetConfig(
request: Request,
env: { VERSION_KV: KVNamespace }
): Promise<Response> {
const url = new URL(request.url);
const environment = url.searchParams.get('env') ?? 'production';
const config = await env.VERSION_KV.get(`version-config:${environment}`);
if (!config) {
return Response.json({}, { status: 200 });
}
return new Response(config, {
headers: {
'Content-Type': 'application/json',
// Short TTL: browsers and CDN edge will re-validate frequently
'Cache-Control': 'public, max-age=30, s-maxage=15, stale-while-revalidate=60',
// ETag for conditional requests
'ETag': `"${await hashContent(config)}"`,
},
});
}
2. Purga via Cache API para rollbacks urgentes
Cuando se realiza un rollback urgente, el Config Service purga proactivamente la cache del CDN.
// workers/version-config-service/src/cache.ts
export async function purgeVersionConfigCache(environment: string): Promise<void> {
const cache = caches.default;
const cacheKey = new Request(
`https://config.example.com/api/v1/version-config?env=${environment}`
);
await cache.delete(cacheKey);
}
3. Polling desde la shell
Para sesiones de larga duracion, la shell consulta actualizaciones de configuracion con un temporizador para que los usuarios eventualmente reciban las versiones mas recientes sin necesidad de recargar la pagina completa.
// src/version-poller.ts
const POLL_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
export function startVersionPoller(
currentConfig: VersionConfig,
onVersionChange: (newConfig: VersionConfig) => void
): () => void {
const intervalId = setInterval(async () => {
try {
const latestConfig = await fetchVersionConfig();
const hasChanges = Object.entries(latestConfig).some(
([name, entry]) => currentConfig[name]?.version !== entry.version
);
if (hasChanges) {
onVersionChange(latestConfig);
}
} catch (error) {
// Silently ignore polling errors --- the user is still running a valid version.
console.warn('[VersionPoller] Failed to check for updates:', error);
}
}, POLL_INTERVAL_MS);
return () => clearInterval(intervalId);
}
4. Push via WebSocket para notificacion inmediata
Para escenarios donde incluso un retraso de 5 minutos es inaceptable (p. ej., parches de seguridad criticos), la shell puede mantener una conexion WebSocket a un Durable Object que transmite eventos de cambio de version.
// src/version-websocket.ts
export function connectVersionWebSocket(
onVersionChange: (newConfig: VersionConfig) => void
): WebSocket {
const ws = new WebSocket('wss://config.example.com/ws/version-updates');
ws.addEventListener('message', (event) => {
try {
const message = JSON.parse(event.data);
if (message.type === 'version-changed') {
onVersionChange(message.config);
}
} catch {
// Ignore malformed messages
}
});
ws.addEventListener('close', () => {
// Reconnect with exponential backoff
setTimeout(() => connectVersionWebSocket(onVersionChange), 5000);
});
return ws;
}
5. Aviso de recarga suave
Cuando la shell detecta un cambio de version (via polling o WebSocket), no fuerza una recarga. En su lugar, muestra una notificacion no intrusiva.
// src/components/VersionUpdateBanner.tsx
import { useState } from 'react';
interface VersionUpdateBannerProps {
updatedMfes: string[];
}
export function VersionUpdateBanner({ updatedMfes }: VersionUpdateBannerProps) {
const [dismissed, setDismissed] = useState(false);
if (dismissed) return null;
return (
<div role="status" className="version-update-banner">
<p>
Updated versions available for: {updatedMfes.join(', ')}.
</p>
<button onClick={() => window.location.reload()}>
Refresh now
</button>
<button onClick={() => setDismissed(true)}>
Dismiss
</button>
</div>
);
}
Funcionalidades del Admin UI
El Admin UI es un MFE dedicado (o una aplicacion independiente) que proporciona control operativo sobre la gestion de versiones de MFEs. Se comunica con el Version Config Service mediante llamadas API autenticadas.
Dashboard
- Muestra la version activa actual de cada MFE, agrupada por entorno.
- Incluye un indicador de salud (verde/amarillo/rojo) basado en el ultimo resultado de health check.
- Resalta los MFEs con despliegues canary activos y su porcentaje de despliegue actual.
Historial de versiones con registro de auditoria
- Registro cronologico completo de cada evento de version: registro, activacion, desactivacion, rollback.
- Cada entrada muestra: marca temporal, version, tipo de evento, actor (quien) y metadatos opcionales.
- Filtrable por nombre de MFE, entorno, tipo de evento y rango de fechas.
- Los datos provienen de la tabla
deployment_eventsen D1.
Rollback en un clic
- Desde la vista de historial de versiones, cada version previamente activa tiene un boton "Revertir a esta version".
- Antes de ejecutar el rollback, el sistema realiza un health check previo para confirmar que el bundle sigue accesible en el CDN.
- Un dialogo de confirmacion muestra la version actual y la version objetivo lado a lado.
- Tras el rollback, el evento se registra y aparece inmediatamente en el registro de auditoria.
Configuracion canary
- Boton "Start Canary" en cualquier version registrada (inactiva).
- Slider o campo de entrada para establecer el porcentaje de trafico.
- Panel de metricas en tiempo real mostrando tasas de error de la version canary vs. la version estable.
- Boton "Promote" para graduar el canary a activacion completa.
- Boton "Abort Canary" para revertir inmediatamente todo el trafico a la version estable.
Health checks
Antes de activar cualquier version, el sistema realiza health checks automatizados.
// workers/version-config-service/src/health-check.ts
interface HealthCheckResult {
mfeName: string;
version: string;
manifestAccessible: boolean;
manifestValid: boolean;
exposedModulesAccessible: boolean;
responseTimeMs: number;
checkedAt: string;
}
export async function performHealthCheck(
entryUrl: string,
mfeName: string,
version: string
): Promise<HealthCheckResult> {
const start = Date.now();
const result: HealthCheckResult = {
mfeName,
version,
manifestAccessible: false,
manifestValid: false,
exposedModulesAccessible: false,
responseTimeMs: 0,
checkedAt: new Date().toISOString(),
};
try {
// Check that the manifest is accessible
const manifestResponse = await fetch(entryUrl);
result.manifestAccessible = manifestResponse.ok;
if (!manifestResponse.ok) return result;
// Validate manifest structure
const manifest = await manifestResponse.json();
result.manifestValid =
manifest != null &&
typeof manifest === 'object' &&
'exposes' in manifest;
if (!result.manifestValid) return result;
// Spot-check that at least one exposed module's entry chunk is accessible
const firstExpose = Object.values(manifest.exposes ?? {})[0] as
| { path: string }
| undefined;
if (firstExpose?.path) {
const baseUrl = entryUrl.replace(/\/[^/]+$/, '/');
const chunkResponse = await fetch(`${baseUrl}${firstExpose.path}`, {
method: 'HEAD',
});
result.exposedModulesAccessible = chunkResponse.ok;
}
} catch {
// Leave all checks as false
} finally {
result.responseTimeMs = Date.now() - start;
}
return result;
}
Control de acceso basado en roles (RBAC)
El acceso a las operaciones de gestion de versiones esta gobernado por roles gestionados a traves de WorkOS.
| Rol | Permisos |
|---|---|
viewer | Ver dashboard, historial de versiones y estado de salud |
developer | Todos los permisos de viewer + activar versiones en el entorno dev |
release-manager | Todos los permisos de developer + activar/revertir en staging y production, configurar despliegues canary |
admin | Todos los permisos + gestionar roles RBAC, configurar politicas de retencion |
// workers/version-config-service/src/middleware/auth.ts
import { WorkOS } from '@workos-inc/node';
const workos = new WorkOS(process.env.WORKOS_API_KEY);
type Permission = 'version:read' | 'version:activate:dev' | 'version:activate:staging' | 'version:activate:production' | 'canary:manage';
const ROLE_PERMISSIONS: Record<string, Permission[]> = {
viewer: ['version:read'],
developer: ['version:read', 'version:activate:dev'],
'release-manager': [
'version:read',
'version:activate:dev',
'version:activate:staging',
'version:activate:production',
'canary:manage',
],
admin: [
'version:read',
'version:activate:dev',
'version:activate:staging',
'version:activate:production',
'canary:manage',
],
};
export function requirePermission(permission: Permission) {
return async (request: Request): Promise<Response | null> => {
const sessionToken = request.headers.get('Authorization')?.replace('Bearer ', '');
if (!sessionToken) {
return Response.json({ error: 'Unauthorized' }, { status: 401 });
}
try {
const session = await workos.userManagement.authenticateWithSessionToken({
sessionToken,
});
const userRoles: string[] = session.organizationMemberships?.map(
(m) => m.role?.slug ?? 'viewer'
) ?? [];
const hasPermission = userRoles.some((role) =>
ROLE_PERMISSIONS[role]?.includes(permission)
);
if (!hasPermission) {
return Response.json({ error: 'Forbidden' }, { status: 403 });
}
return null; // null means "authorized, continue"
} catch {
return Response.json({ error: 'Invalid session' }, { status: 401 });
}
};
}
Esquema D1
La base de datos D1 proporciona almacenamiento persistente para registros de versiones y un registro de auditoria completo de todos los eventos de despliegue.
Tablas
-- Stores every version that has been registered for each MFE/environment pair.
-- Only one row per (environment, mfe_name) should have is_active = true at any time.
CREATE TABLE version_configs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
environment TEXT NOT NULL, -- 'dev', 'staging', 'production'
mfe_name TEXT NOT NULL, -- e.g., 'mfe_dashboard'
version TEXT NOT NULL, -- semver, e.g., '2.3.1' or '2.3.1+abc1234'
entry_url TEXT NOT NULL, -- full URL to mf-manifest.json
integrity_hash TEXT, -- SRI hash (e.g., 'sha384-...')
is_active BOOLEAN DEFAULT false, -- only one active version per (env, mfe_name)
activated_at DATETIME, -- when this version was activated
activated_by TEXT, -- who activated it
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
created_by TEXT NOT NULL -- who registered it (usually CI)
);
-- Prevent duplicate version registrations at the database level.
-- The application also checks for duplicates before inserting, but this
-- constraint provides a hard guarantee against race conditions.
CREATE UNIQUE INDEX uq_version_configs_env_mfe_version
ON version_configs (environment, mfe_name, version);
-- Indexes for common query patterns
CREATE INDEX idx_version_configs_active
ON version_configs (environment, mfe_name, is_active)
WHERE is_active = true;
CREATE INDEX idx_version_configs_lookup
ON version_configs (environment, mfe_name, version);
CREATE INDEX idx_version_configs_history
ON version_configs (environment, mfe_name, created_at DESC);
-- Records every state transition for full audit trail.
CREATE TABLE deployment_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
environment TEXT NOT NULL, -- 'dev', 'staging', 'production'
mfe_name TEXT NOT NULL, -- e.g., 'mfe_dashboard'
version TEXT NOT NULL, -- the version this event pertains to
event_type TEXT NOT NULL, -- 'registered', 'activated', 'deactivated', 'rollback'
metadata TEXT, -- JSON blob for additional context
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
created_by TEXT NOT NULL -- who triggered the event
);
-- Index for querying events by MFE and environment
CREATE INDEX idx_deployment_events_lookup
ON deployment_events (environment, mfe_name, created_at DESC);
-- Index for filtering by event type (useful for audit queries)
CREATE INDEX idx_deployment_events_type
ON deployment_events (event_type, created_at DESC);
Ejemplos de consultas
-- Get the currently active version for all MFEs in production
SELECT mfe_name, version, entry_url, activated_at, activated_by
FROM version_configs
WHERE environment = 'production' AND is_active = true;
-- Get version history for a specific MFE (most recent first)
SELECT version, is_active, activated_at, activated_by, created_at, created_by
FROM version_configs
WHERE environment = 'production' AND mfe_name = 'mfe_dashboard'
ORDER BY created_at DESC
LIMIT 20;
-- Get recent deployment events for audit
SELECT de.environment, de.mfe_name, de.version, de.event_type,
de.metadata, de.created_at, de.created_by
FROM deployment_events de
WHERE de.environment = 'production'
ORDER BY de.created_at DESC
LIMIT 50;
-- Count deployments per MFE in the last 30 days
SELECT mfe_name, COUNT(*) as deploy_count
FROM deployment_events
WHERE environment = 'production'
AND event_type = 'activated'
AND created_at >= datetime('now', '-30 days')
GROUP BY mfe_name
ORDER BY deploy_count DESC;
Referencias
- Cloudflare Workers KV --- Almacenamiento global de clave-valor con baja latencia, utilizado como cache en el edge para configuraciones de versiones.
- Cloudflare D1 --- Base de datos relacional basada en SQLite en el edge, utilizada como almacen persistente y registro de auditoria.
- Cloudflare R2 --- Almacenamiento de objetos para artefactos de compilacion de MFEs (bundles, manifests).
- Module Federation Runtime API --- Las APIs
init()yloadRemote()utilizadas por la shell para configurar y cargar dinamicamente los remotes de MFEs. - Module Federation v2 Manifest --- Documentacion de
mf-manifest.json, el archivo manifest generado por el plugin de Module Federation de Rsbuild. - Rsbuild Module Federation Plugin --- Configuracion en tiempo de compilacion para generar outputs de Module Federation con Rsbuild.
- WorkOS User Management --- Autenticacion y RBAC utilizados para asegurar el Admin UI y el Version Config Service.
- Subresource Integrity (SRI) --- Mecanismo de verificacion de integridad referenciado en el esquema de configuracion de versiones.