wandres.dev
DE PAGES A WORKERS · la convergencia

Migrar un proyecto: de functions a un Worker con assets

La parte de la migración que exige escribir código. Cómo se pasa del enrutado por nombre de archivo del directorio `functions/` a un único handler `fetch`, cómo se traduce el contexto de una función de Pages al par de argumentos de un Worker, cómo se declara el bloque `assets` para que el front-end siga sirviéndose gratis, y cómo se mueven los bindings y las variables del panel al manifiesto versionado.

⏱ 20 min

Todo lo anterior de este nivel se resuelve rellenando campos. Esta lección es la excepción: migrar el directorio functions/ a un Worker exige reescribir código, porque lo que cambia no es un nombre sino el mecanismo por el que una petición encuentra su respuesta. En Pages, la estructura de carpetas era el enrutador; en Workers, el enrutador lo escribes tú o lo delega una librería. Suena a retroceso y no lo es: se cambia una convención implícita, cómoda hasta que deja de encajar, por un punto de entrada explícito donde todo el flujo de una petición es legible en un archivo. Vamos a hacer esa traducción paso a paso, con el manifiesto, el enrutado y los bindings, que son las tres piezas que hay que mover.

🎯 Al terminar esta lección sabrás
  • Traducir el enrutado por nombre de archivo del directorio functions/ a un handler fetch único.
  • Convertir el contexto de una función de Pages al par de argumentos de un Worker.
  • Declarar el bloque assets para conservar el front-end estático y su gratuidad.
  • Mover bindings, variables y secretos del panel al manifiesto y a la línea de comandos.

Del árbol de carpetas a un punto de entrada

La primera pieza que hay que mover es la que más miedo da y menos trabajo tiene si se ataca con método: el enrutado.

Un proyecto de Pages con funciones tiene una forma reconocible: un directorio functions/ cuyo árbol define las rutas. Un archivo en functions/api/usuarios.ts atiende la ruta de usuarios; un archivo con nombre entre corchetes, como functions/api/usuarios/[id].ts, captura un parámetro dinámico. La convención es cómoda porque no hay que escribir enrutado, y frágil porque el mapa real de tu API solo existe repartido por el sistema de archivos.

Un Worker invierte eso: hay un solo módulo apuntado por main, con un handler fetch que recibe todas las peticiones que no resolvió un asset. La migración consiste, por tanto, en convertir un árbol en una función. Para una API pequeña basta con inspeccionar la URL a mano:

export default {
  async fetch(request, env, ctx) {
    const url = new URL(request.url);
    if (url.pathname === "/api/usuarios" && request.method === "GET") {
      return Response.json(await listarUsuarios(env));
    }
    const match = url.pathname.match(/^\/api\/usuarios\/([^/]+)$/);
    if (match) {
      return Response.json(await obtenerUsuario(env, match[1]));
    }
    return new Response("No encontrado", { status: 404 });
  },
};

En cuanto la API pasa de unas pocas rutas, escribir expresiones regulares a mano deja de compensar y conviene delegar en un router del ecosistema, que devuelve la ergonomía de declarar rutas sin devolver la magia del sistema de archivos. El punto importante es conceptual: el enrutado deja de ser un efecto secundario de dónde guardaste un archivo y pasa a ser una parte legible y auditable de tu programa.

Hay un beneficio inmediato de esa explicitud que se aprecia el primer día de la migración. Con el árbol de functions/, responder a la pregunta de qué rutas expone la aplicación exigía recorrer carpetas y conocer la convención de nombres, incluidos los casos raros de captura de parámetros y de rutas comodín. Con un handler único, esa pregunta se responde leyendo un archivo de arriba abajo, en el orden exacto en que se evalúan las coincidencias. El orden, que antes era una regla del framework que había que consultar, pasa a ser una propiedad visible de tu código.

flowchart TD
P[Directorio functions con un archivo por ruta] --> T[Traduccion]
T --> M[Un modulo apuntado por main]
M --> F[Handler fetch unico]
F --> R[Router explicito o comprobaciones de pathname]
R --> H[Handlers reutilizados de Pages]
style M fill:#89b4fa,color:#11111b
style H fill:#a6e3a1,color:#11111b

Traducir el contexto de la función

La segunda pieza del código es la firma. Una función de Pages recibía un único objeto de contexto del que se extraían la petición, el entorno y los parámetros de ruta. Un Worker recibe tres argumentos posicionales: la petición, el entorno y el contexto de ejecución. La correspondencia es directa y mecánica.

En una función de Pages En un Worker Nota
El campo de petición del contexto Primer argumento request Mismo objeto estándar de la plataforma
El campo de entorno del contexto Segundo argumento env Ahora con todos los bindings, sin categorías parciales
El campo de parámetros de ruta Lo devuelve tu router Ya no lo inyecta la convención de nombres
La función para continuar la cadena env.ASSETS.fetch Delegar en la maquinaria de assets
El método para diferir trabajo ctx.waitUntil Mismo comportamiento, ahora en el tercer argumento

La consecuencia práctica es que el cuerpo de tus funciones casi siempre sobrevive intacto. Lo que cambia es la envoltura: dejas de exportar una función por archivo y pasas a llamar a esas mismas funciones desde el enrutador. Si tu lógica ya estaba en módulos separados y las funciones de Pages solo la invocaban, la migración se reduce a reescribir la capa más delgada del proyecto.

Merece atención especial la fila del middleware. En Pages, la forma de interceptar peticiones era un archivo especial que envolvía a los demás y llamaba a una función para continuar la cadena. En un Worker, interceptar es simplemente hacer algo antes de decidir la respuesta, y continuar la cadena es delegar en env.ASSETS.fetch o en el siguiente tramo de tu código. El patrón se vuelve tan explícito que a menudo desaparece como concepto:

export default {
  async fetch(request, env, ctx) {
    const inicio = Date.now();
    const respuesta = await enrutar(request, env);
    ctx.waitUntil(registrar(env, request, Date.now() - inicio));
    return respuesta;
  },
};

Ese fragmento hace lo que antes exigía un archivo de middleware y su convención asociada: mide, responde y difiere el registro para no retrasar al usuario. No hay ninguna capacidad nueva, solo el mismo comportamiento escrito donde se lee.

Assets, bindings y secretos

Con el código resuelto, queda mover la configuración. El bloque assets conserva el front-end: apunta al directorio de build, expone opcionalmente el binding ASSETS para poder servir archivos desde el código, y define qué hacer cuando una ruta no corresponde a ningún archivo.

{
  "name": "mi-app",
  "main": "src/index.ts",
  "compatibility_date": "2026-03-01",
  "assets": {
    "directory": "./dist",
    "binding": "ASSETS",
    "not_found_handling": "single-page-application",
    "run_worker_first": ["/api/*"]
  },
  "vars": { "ENTORNO": "produccion" },
  "d1_databases": [
    { "binding": "DB", "database_name": "app", "database_id": "..." }
  ],
  "kv_namespaces": [{ "binding": "CACHE", "id": "..." }]
}

Merece la pena detenerse en run_worker_first. Por defecto, cada petición intenta primero encontrar un asset y solo cae en tu código si no hay archivo, lo que mantiene gratuito el front-end. Declarar las rutas de API en esa lista garantiza que el Worker las vea siempre, sin depender de que no exista por casualidad un archivo con ese nombre en el build. Es una precaución barata que evita una clase entera de bugs sutiles.

Los bindings que en Pages se configuraban desde el panel pasan a ser bloques del manifiesto, y ese cambio es una mejora de fondo: la configuración se versiona con el código, se revisa en un pull request y se reproduce en local. Las variables no sensibles viven en vars; los secretos, nunca ahí:

npx wrangler secret put API_TOKEN
npx wrangler types
npx wrangler dev

El comando de tipos regenera las definiciones de tu env a partir del manifiesto, de modo que el compilador te avisa si accedes a un binding que no declaraste. Es la red de seguridad más útil durante una migración, porque los errores típicos de esta fase son exactamente ese: código que esperaba una variable que se quedó en el panel del proyecto viejo.

Comprobar antes de mover el dominio

Una migración se declara terminada cuando el Worker responde igual que el proyecto anterior, no cuando compila. Conviene tener una lista corta de comprobaciones y ejecutarla contra la URL de previsualización antes de tocar ningún dominio.

npx wrangler versions upload
curl -I https://VERSION.mi-app.workers.dev/
curl -s https://VERSION.mi-app.workers.dev/api/usuarios
curl -I https://VERSION.mi-app.workers.dev/ruta-que-no-existe

Las cuatro cosas que fallan casi siempre son las mismas y conviene buscarlas de forma deliberada. La primera es el comportamiento ante rutas inexistentes: si tu front-end es una aplicación de página única, not_found_handling debe devolver el documento principal, y si no lo es, debe devolver tu página de error con el código correcto. La segunda son las cabeceras que antes venía del archivo _headers y que ahora tienen que estar declaradas o añadidas en código; se detectan comparando la respuesta vieja y la nueva sobre el mismo recurso. La tercera son las redirecciones heredadas de _redirects, sobre todo las que preservaban parámetros de consulta. Y la cuarta es cualquier ruta de API que casualmente coincida con el nombre de un archivo del build, que es el motivo por el que conviene declararlas en run_worker_first.

Con esas comprobaciones en verde, mover el dominio es un cambio pequeño y reversible. Y si prefieres no moverlo entero de golpe, las rutas te permiten enviar al Worker solo una sección del sitio y dejar el resto donde está, migrando por partes en lugar de por proyectos.

💡
Migra con las dos superficies vivas

No conviertas la migración en un salto sin red. El proyecto de Pages puede seguir sirviendo tráfico mientras despliegas el Worker nuevo en una URL de previsualización y lo pruebas de verdad. Cuando el Worker responda igual, mueve el dominio; y si algo sale mal, devolverlo al proyecto anterior es un cambio de DNS, no un despliegue de emergencia. Si tu sitio es grande, puedes ir más fino todavía y usar rutas para mandar al Worker solo una parte del tráfico, migrando por secciones en lugar de por sitios enteros.

Toda convención implícita es deuda diferida contra el día que no encaje

El enrutado por sistema de archivos es una de las convenciones más queridas de la última década de desarrollo web, y merece la pena entender exactamente qué te da y qué te cobra, porque el patrón se repite mucho más allá de este caso concreto. Lo que te da es innegable: elimina una capa entera de código repetitivo, hace que la estructura del proyecto se lea como el mapa de la aplicación, y convierte crear una ruta en crear un archivo, que es la operación más barata que existe. Lo que te cobra es sutil y se paga tarde. Cobra que el comportamiento del sistema ya no esté escrito en ninguna parte, sino distribuido en una convención que hay que conocer para leer el proyecto; cobra que el orden de resolución entre rutas ambiguas sea una regla del framework y no una decisión tuya; y cobra, sobre todo, que el día que necesites algo que la convención no contempla no tengas dónde ponerlo, porque el punto de extensión no existe. Ese día llega siempre: una ruta que necesita autenticarse antes que las demás, un experimento que reparte tráfico, una versión de la API que debe convivir con la anterior. La convención implícita es entonces deuda diferida, y se paga entera de golpe. Un punto de entrada explícito hace el intercambio contrario. Cobra por adelantado unas cuantas líneas de enrutado que la convención te regalaba, y a cambio te da un lugar donde el flujo completo de una petición es legible de arriba abajo, un sitio evidente donde insertar lo que no cabía en ninguna convención, y la posibilidad de razonar sobre el orden sin consultar documentación. Ninguna de las dos opciones es correcta en abstracto: la implícita gana en proyectos pequeños y homogéneos, la explícita gana en cuanto aparece la primera excepción. Lo que sí es siempre correcto es tomar la decisión sabiendo cuál de los dos costes estás eligiendo, en lugar de heredarla del andamiaje que usaste el primer día.

⚔️ Migra un proyecto de Pages a un Worker
  1. Inventaría el directorio functions/ de un proyecto y dibuja el mapa completo de rutas que la convención de archivos estaba generando por ti.
  2. Escribe un handler fetch que reproduzca ese mapa, primero a mano con comprobaciones de pathname y después con un router del ecosistema.
  3. Traduce el contexto de una función que use parámetros de ruta y trabajo diferido a la firma de tres argumentos de un Worker.
  4. Declara el bloque assets con binding, not_found_handling y run_worker_first para tus rutas de API, y explica qué bug evita esa última lista.
  5. Mueve variables y bindings al manifiesto, sube los secretos con el comando correspondiente, regenera los tipos y comprueba que el compilador detecta un binding que olvidaste declarar.