Inicio / Artículos / Una app Astro en Node y Cloudflare Workers: problemas comunes en la configuración

Una app Astro en Node y Cloudflare Workers: problemas comunes en la configuración

Configuraciones Astro duales para Node y Workers: deduplicación en React, alias de Prisma Edge, archivos externos en Vite, memoria en CI y un punto de entrada fetch para Workers.

1027 palabras

Un mismo código se despliega en Node (con Docker autohospedado) y en Cloudflare Workers (en el edge). El árbol compartido se encuentra junto a astro.config.mjs y astro.config.cloudflare.mjs.

La configuración es viable. Para lograrla fue necesario realizar varias sesiones de depuración enfocadas, cada una revelando una configuración exclusiva para Cloudflare que el archivo de Node nunca necesitó. Los tutoriales iniciales rara vez documentan esas diferencias.

Las dos configuraciones son un 90% idénticas, y ese es el problema

A primera vista, dividir la lógica en un solo archivo de configuración parece ordenado. Pero se vuelve complicado cuando también se necesita otro adaptador, otra política de archivos externos, otro mapa de alias y ajustes separados de memoria para la compilación. Una larga estructura condicional que abarque todo eso resulta peor que dos archivos separados.

El compromiso radica en el mantenimiento: los ajustes compartidos deben sincronizarse manualmente. La resolución de módulos en React es un punto delicado, y por eso el archivo de Cloudflare incluye un comentario de advertencia:

// Must mirror astro.config.mjs's React handling. Without dedupe the
// production Rollup client build resolves react-dom's internal react to a
// different chunk than the islands' react, yielding two React instances ->
// "Cannot read properties of null (reading 'useEffect')" when IslandHydrator
// calls createRoot().render() on a hooked component.

Recuerde: React resolve.dedupe garantiza la corrección, no la perfección. Las copias duplicadas de React causan problemas con el primer hook, y generalmente el error se atribuye a su componente en lugar de a la configuración del bundler.

El cliente generado por Prisma no se resuelve en workerd

El cliente de Prisma 7 depende de importaciones mediante subrutas en Node, como #main-entry-point. Una compilación con Rollup para workerd no puede resolver esto. En su lugar, asigne un alias al módulo directamente a la entrada del edge:

const PRISMA_CLIENT_DIR = path.dirname(require.resolve('@prisma/client/package.json'));
const PRISMA_EDGE_ENTRY = path.resolve(PRISMA_CLIENT_DIR, '../../.prisma/client/edge.js');
resolve: {
  alias: {
    '.prisma/client/default': PRISMA_EDGE_ENTRY,
  },
}

Es importante cómo se deriva la ruta. A partir de Prisma 7.8, los archivos .prisma/client/ generados se encuentran dentro del paquete @prisma/client. Con pnpm, esto se convierte en una ubicación con hash como .pnpm/@prisma+client@<hash>/node_modules/.prisma/client/, en lugar de una carpeta node_modules raíz fija. Una ruta introducida una vez en una computadora portátil a menudo deja de funcionar con otro layout de hoist o hash del almacenamiento. Anclar desde @prisma/client/package.json permite superar esas diferencias.

La lista de externals y una entrada que se omite intencionalmente

Cualquier elemento exclusivo de Node debe permanecer fuera del paquete workerd. Muchos proyectos solo necesitan una lista corta de exclusiones:

const NODE_ONLY_EXTERNALS = ['ioredis'];

ioredis se carga mediante una import() dinámica detrás de isCloudflareRuntime(), por lo que los Workers nunca obtienen ese fragmento y su externalización sigue siendo segura.

pg **no** está en esa lista a propósito. @prisma/adapter-pg carga pg mediante una importación estática, y PrismaPg sigue ejecutándose en la ruta de Cloudflare Hyperdrive, por lo que el controlador debe estar dentro del paquete. Al tener activado nodejs_compat, ese cliente TCP utiliza la capa de compatibilidad con Node de Cloudflare. Marcar pg como externo provocó el error Uncaught Error: No such module "chunks/pg" cuando se cargó el worker.

Una regla importante: considere una dependencia como externa solo cuando todas las rutas que la importan son dinámicas y están protegidas por filtros. Una sola importación estática en cualquier lugar convierte una compilación correcta en un fallo después del despliegue que es más difícil de resolver.

El adaptador sobrescribe sus elementos externos, así que úntelos nuevamente

Este problema fue el más lento de identificar. Dentro de astro:build:setup, @astrojs/cloudflare fuerza vite.ssr.noExternal = true y restablece vite.build.rollupOptions.external a ['sharp']. Cualquier valor que haya escrito en ssr.external desaparece antes de que Rollup inicie.

Un plugin de Vite con enforce: 'post' que vuelve a escribir los elementos externos soluciona este problema:

{
  name: 'autonnel:cf-extra-externals',
  enforce: 'post',
  config(conf) {
    const existing = conf.build?.rollupOptions?.external;
    if (Array.isArray(existing)) {
      conf.build.rollupOptions.external = [...new Set([...existing, ...NODE_ONLY_EXTERNALS])];
    } else if (typeof existing === 'function') {
      const existingFn = existing;
      conf.build.rollupOptions.external = (id, parentId, isResolved) =>
        NODE_ONLY_EXTERNALS.includes(id) || existingFn(id, parentId, isResolved);
    }
    // ...string / RegExp / undefined branches
  },
}

Se requieren varias ramas: external ya podría ser un array, cadena, RegExp, función o undefined, y una futura versión del adaptador podría cambiarlo nuevamente. La nota adjunta al plugin indica que debería desaparecer una vez que @astrojs/cloudflare deje de sobrescribir ssr.external; se trata de una solución temporal para el comportamiento de una versión específica.

El proceso de compilación se quedó sin memoria en CI y no localmente

Al agrupar todas las entradas SSR en un único paquete workerd, se superó el límite de 2 GB del heap por defecto de Node. CI en Cloudflare falló al llegar a unos 1,99 GB, aunque una laptop de desarrollador lo procesó sin problemas; es un tipo extraño de error.

Dos flags de Vite eliminaron esa presión:

build: {
  sourcemap: false,
  reportCompressedSize: false,
},

Los mapas de origen ocupaban toda la memoria, y los workers los ignoran. reportCompressedSize también asigna una copia comprimida de cada bloque solo para imprimir una tabla de resumen más atractiva. Ninguno de estos métodos se compensa por sí mismo en este entorno.

La entrada del worker hace dos cosas que Node no necesita

Node proporciona gratuitamente un ciclo de vida para las solicitudes. En los Workers, debes implementarlo tú mismo:

export default {
  async fetch(request, env, ctx) {
    setRuntimeEnv(env);
    return runWithRequestDb(async () => {
      try {
        return await ssrHandler.fetch(request, env, ctx);
      } finally {
        ctx.waitUntil(disposeRequestDb());
      }
    });
  },
  async scheduled(_event, env) { /* ... */ },
};

setRuntimeEnv(env) existe porque en los Workers no existe process.env. Las vinculaciones aparecen como argumentos del manejador, por lo que el acceso a la configuración requiere un puente por solicitud. Al portar un servicio de Node, generalmente se modifican muchos archivos aquí; conectar el puente desde el principio es mejor que buscar posteriormente lecturas dispersas de process.env.FOO.

ctx.waitUntil(disposeRequestDb()) se encarga de la limpieza: libera el cliente de base de datos después de que se envíe la respuesta. Limpiarlo antes podría liberar un cliente del cual aún dependen las salidas en streaming.

¿Vale la pena repetir con dos destinos?

Sí, siempre y cuando el segundo destino tenga una función clara. Workers no es un botón gratuito para “desplegar en todas partes”. Agrega otro pipeline de compilación con modos de fallo distintos, y la mayoría de ellos se manifiestan durante el despliegue y no en las pruebas.

El trabajo sigue siendo manejable cuando la divergencia está delimitada: dos configuraciones más un módulo de entrada. El código del dominio debe evitar usar if (isWorkers) cuando ya existen adaptadores para la caché, el almacenamiento y la base de datos. ¿Faltan esas conexiones? Créelas antes de añadir el segundo entorno de ejecución. Hacer lo contrario lleva las verificaciones de ejecución a los flujos de checkout y a otros servicios principales.

Lecturas relacionadas