Construye una API de tareas serverless en Cloudflare con Hono, D1, R2 y Prisma 7

Tutorial paso a paso: llevamos mi plantilla Hono + Prisma 7 a Cloudflare Workers, con D1 como base de datos, R2 para los adjuntos, autenticación JWT, despliegue con Wrangler y dominio propio, con el flujo completo probado en local.

Portada: API de tareas serverless en Cloudflare con Hono, D1, R2 y Prisma 7

En este tutorial vamos a coger una API Node.js con una arquitectura profesional y la vamos a convertir en una API serverless en Cloudflare Workers: base de datos SQL con D1, ficheros adjuntos en R2 (sin pagar por la transferencia de salida), autenticación JWT y tipado de extremo a extremo con TypeScript.

Las arquitecturas de backend tradicionales, sobre servidores dedicados o contenedores fijos, suelen tener dos problemas: pagas el servidor aunque nadie lo use y, si sirves muchos ficheros, la transferencia de salida (egress) acaba siendo una partida importante de la factura.

🧪
El flujo completo de este tutorial se ha probado en local en octubre de 2026 con Wrangler 4.148.0 (workerd 1.20261006.1, D1 y R2 emulados), Prisma 7.10.0 con @prisma/adapter-d1 7.10.0, Hono 4.13.10, Node.js 24.21.0 y la colección de Bruno ejecutada con @usebruno/cli: migración, login, registro, tareas, subida, descarga y borrado de adjuntos y las comprobaciones de seguridad, además de npm run dev y los tests de Vitest en Node. El coste de CPU de las contraseñas está medido en workerd y en Node. Lo que necesita una cuenta de Cloudflare (crear los recursos remotos, los secretos, el despliegue y el dominio propio) está explicado con los comandos oficiales, pero no se ha ejecutado en una cuenta real.

Introducción y arquitectura

Vamos a usar estas piezas:

  1. Hono v4: framework ultraligero que funciona igual en Node.js que en el runtime workerd de Cloudflare Workers. Si no lo conoces, tienes Hono a fondo.
  2. Cloudflare D1: base de datos relacional serverless basada en SQLite. La base de datos vive en una región (la más cercana a donde la creas) y puedes activar réplicas de lectura en otras. Lo explico con detalle en Cloudflare D1 a fondo.
  3. Cloudflare R2: almacenamiento de objetos compatible con la API de S3 que no cobra por la transferencia de salida; pagas el almacenamiento y las operaciones. Perfecto para adjuntos y documentos. Más en Cloudflare R2 a fondo.
  4. Prisma 7 + @prisma/adapter-d1: el ORM, con un cliente generado específicamente para Workers (runtime = "cloudflare").
  5. Wrangler: la CLI oficial de Cloudflare para desarrollo, emulación local y despliegue. Si quieres entender qué pasa por debajo, está Cloudflare Workers a fondo.
Esquema de la API: el cliente llama por HTTPS al Worker con Hono; en cada petición pasa por el puente de contexto, los middlewares globales y la autenticación antes de llegar a las rutas, que usan D1 para los datos y R2 para los ficheros
El Worker recibe la petición, la autentica y usa los bindings de D1 y R2. Diagrama: devutilites.

Paso 1: Clonar la plantilla base de Hono

Para no empezar desde cero con la autenticación, los middlewares y la estructura de carpetas, partiremos de mi plantilla de producción hono-prisma-production-starter, que explico pieza a pieza en este artículo:

# 1. Clona el repositorio
git clone https://github.com/Collanteslu/hono-prisma-production-starter.git hono-tasks-cloudflare

# 2. Entra al directorio del proyecto
cd hono-tasks-cloudflare

# 3. Instala las dependencias del proyecto
npm install

¿Qué incluye esta plantilla?

  • Arquitectura modular desacoplada (src/routes, src/services, src/lib, src/schemas).
  • Autenticación con JSON Web Tokens (access y refresh tokens con rotación).
  • Documentación OpenAPI generada automáticamente y visor interactivo con Scalar.
  • Validación estricta en tiempo de ejecución con Zod.
  • Formateo y linting ultrarrápido con Biome.
💡
El proyecto seguirá funcionando también en Node.js. Generaremos dos clientes de Prisma (uno para Node y otro para Workers), así que npm run dev y los tests de Vitest de la plantilla siguen funcionando: en mis pruebas pasan 143 de los 144 tests. El que falla es el que comprueba que todas las rutas están documentadas en OpenAPI, porque las rutas de adjuntos de este tutorial no lo están. Para probar D1 y R2 en local, usa npx wrangler dev.

Paso 2: Instalar Wrangler y autenticarse en Cloudflare

Wrangler es la herramienta oficial de línea de comandos para compilar, probar en local y desplegar Workers y sus recursos enlazados (D1 y R2).

2.1. Instalar Wrangler como dependencia de desarrollo

npm install -D wrangler

Verifica la versión instalada:

npx wrangler --version

2.2. Iniciar sesión en tu cuenta de Cloudflare

npx wrangler login

Este comando abre una ventana en tu navegador para autorizar a Wrangler en tu cuenta de Cloudflare y guarda las credenciales OAuth en tu máquina.

Paso 3: Crear la base de datos D1 y el bucket R2

Antes de tocar el código, creamos los recursos en Cloudflare.

3.1. Crear la base de datos D1

npx wrangler d1 create tasks-db

La terminal mostrará algo parecido a esto:

✅ Successfully created DB 'tasks-db' in region WEUR
Created your new D1 database.

To access your new D1 Database in your Worker, add the following snippet to your configuration file:
{
  "d1_databases": [
    {
      "binding": "DB",
      "database_name": "tasks-db",
      "database_id": "<TU_DATABASE_ID_AQUI>"
    }
  ]
}

Guarda el database_id: lo necesitarás en el siguiente paso para enlazar el Worker con tu base de datos. La base de datos se crea en la región más cercana a ti; si quieres elegirla, añade --location weur (u otra región).

3.2. Crear el bucket de almacenamiento R2

npx wrangler r2 bucket create tasks-attachments

Salida esperada:

Creating bucket 'tasks-attachments'...
✅ Created bucket 'tasks-attachments' with default storage class of Standard.

Paso 4: Configurar wrangler.jsonc y los secretos

En Cloudflare Workers, los recursos (D1, R2) llegan al código mediante bindings definidos en el archivo de configuración.

4.1. Crear el archivo wrangler.jsonc en la raíz del proyecto

Crea wrangler.jsonc sustituyendo el database_id por el que obtuviste en el paso 3:

{
  "$schema": "node_modules/wrangler/config-schema.json",
  "name": "hono-tasks-cloudflare",
  "main": "src/index.ts",
  "compatibility_date": "2026-09-01",
  "compatibility_flags": ["nodejs_compat"],
  "define": {
    "import.meta.url": "'file:///worker.js'"
  },
  "d1_databases": [
    {
      "binding": "DB",
      "database_name": "tasks-db",
      "database_id": "<TU_DATABASE_ID_AQUI>"
    }
  ],
  "r2_buckets": [
    {
      "binding": "ATTACHMENTS_BUCKET",
      "bucket_name": "tasks-attachments"
    }
  ],
  "vars": {
    "NODE_ENV": "production",
    "AUTH_MODE": "full"
  }
}

Tres detalles importantes de esta configuración:

  • nodejs_compat es imprescindible: la plantilla usa node:crypto, node:path y, como veremos, AsyncLocalStorage.
  • La compatibility_date no puede ser antigua. La plantilla valida la configuración leyendo process.env al arrancar. En Workers, process.env solo se rellena con tus variables y secretos si nodejs_compat está activo y la fecha de compatibilidad es del 1 de abril de 2025 o posterior (es el flag nodejs_compat_populate_process_env). Con una fecha anterior, el Worker ni siquiera arranca: la validación no encuentra JWT_SECRET y falla. Usa la fecha del día en que creas el proyecto.
  • El bloque define existe por el cliente de Prisma para Node. En el paso 5 generamos dos clientes y el de Node también acaba dentro del Worker (la plantilla importa de él los tipos y las clases de error de Prisma). Al cargarse, ese cliente ejecuta path.dirname(fileURLToPath(import.meta.url)), pero en el código que empaqueta Wrangler import.meta.url no tiene valor y el Worker no llega a arrancar (The "path" argument must be of type string). define sustituye import.meta.url por una URL fija al empaquetar; el valor da igual porque en Workers no se lee nada del disco. Afecta a todo el código empaquetado, así que si alguna dependencia usara import.meta.url para localizar ficheros, recibiría esa misma URL.

Si vas a consumir la API desde un frontend en el navegador, añade también "CORS_ORIGINS": "https://tu-frontend.com" en vars: en producción la plantilla no permite ningún origen por defecto.

4.2. ¿Cómo gestionar los secretos JWT (JWT_SECRET y JWT_REFRESH_SECRET)?

Las claves nunca deben escribirse en wrangler.jsonc ni subirse a Git. Se gestionan de dos maneras según el entorno.

A) En desarrollo local (.dev.vars)

Al ejecutar el servidor en tu máquina con npx wrangler dev, Wrangler lee automáticamente los secretos de un archivo local llamado .dev.vars (el equivalente a .env en Workers).

Genera claves criptográficas seguras de 48 bytes y crea el archivo:

cat <<EOF > .dev.vars
JWT_SECRET=$(openssl rand -base64 48)
JWT_REFRESH_SECRET=$(openssl rand -base64 48)
EOF

Añade a tu .gitignore el archivo de secretos y la carpeta donde Wrangler guarda la base de datos y el bucket locales:

.dev.vars
.wrangler/

B) En producción en Cloudflare (wrangler secret put)

Los secretos de producción se suben con wrangler secret put. No hace falta desplegar antes: si el Worker todavía no existe, Wrangler te pregunta si quieres crearlo y le añade el secreto. De hecho, conviene subirlos antes del primer despliegue, para que el Worker nunca llegue a arrancar sin ellos. Con una tubería, la clave no pasa por la pantalla ni por el historial de tu shell:

openssl rand -base64 48 | npx wrangler secret put JWT_SECRET
openssl rand -base64 48 | npx wrangler secret put JWT_REFRESH_SECRET

Paso 5: Instalar el adaptador Prisma D1 y modificar el esquema

5.1. Instalar el adaptador oficial y los tipos de Workers

npm install @prisma/adapter-d1
npm install -D @cloudflare/workers-types

5.2. Actualizar prisma/schema.prisma

Añade un segundo generador con runtime = "cloudflare" y el modelo TaskAttachment. El generador client de siempre sigue produciendo el cliente para Node.js (npm run dev, Vitest), y el nuevo genera en src/generated/client-cloudflare el cliente para Workers: el de Node no arranca en Workers y el de Workers no arranca en Node, así que necesitamos los dos.

datasource db {
  provider = "sqlite"
}

generator client {
  provider = "prisma-client"
  output   = "../src/generated/client"
}

generator cloudflare {
  provider = "prisma-client"
  output   = "../src/generated/client-cloudflare"
  runtime  = "cloudflare"
}

// ... el resto de modelos de la plantilla (User, Session, RefreshToken, AuditLog...) no cambian ...

model Task {
  id          String           @id @default(uuid())
  userId      String?
  title       String
  description String           @default("")
  completed   Boolean          @default(false)
  deletedAt   DateTime?
  createdAt   DateTime         @default(now())

  user        User?            @relation(fields: [userId], references: [id], onDelete: Cascade)
  attachments TaskAttachment[]

  @@index([userId, deletedAt, createdAt])
}

model TaskAttachment {
  id         String   @id @default(uuid())
  taskId     String
  fileName   String
  fileSize   Int
  mimeType   String
  storageKey String   @unique // Identificador del archivo en Cloudflare R2
  createdAt  DateTime @default(now())

  task       Task     @relation(fields: [taskId], references: [id], onDelete: Cascade)

  @@index([taskId])
}

Paso 6: Migraciones en Cloudflare D1 (local y remoto)

En una base de datos tradicional, prisma migrate dev se conecta directamente a la base de datos. Con D1, Prisma genera el SQL y lo aplicamos con Wrangler.

6.1. Generar el script SQL de migración

Usa prisma migrate diff con --to-schema para generar el SQL a partir de los modelos:

mkdir -p prisma/migrations
npx prisma migrate diff \
  --from-empty \
  --to-schema prisma/schema.prisma \
  --script > prisma/migrations/0001_initial_schema.sql

Para cambios posteriores del esquema, la guía oficial de Prisma usa --from-local-d1 en lugar de --from-empty, de forma que el SQL generado solo contenga las diferencias con tu base de datos local.

6.2. Aplicar la migración en local (para pruebas con wrangler dev)

npx wrangler d1 execute tasks-db --local --file=prisma/migrations/0001_initial_schema.sql

6.3. Aplicar la migración en la base de datos de producción

npx wrangler d1 execute tasks-db --remote --file=prisma/migrations/0001_initial_schema.sql --yes

6.4. Usuarios iniciales: demo en local, administrador real en producción

En local, para poder iniciar sesión inmediatamente con los usuarios de demo de la plantilla ([email protected] / password123). El hash ya está en el formato PBKDF2 del paso 7.1:

npx wrangler d1 execute tasks-db --local --command "
INSERT OR IGNORE INTO User (id, name, email, password, role, isBlocked, createdAt, emailVerifiedAt)
VALUES
  ('user-1', 'Luis Admin', '[email protected]', 'pbkdf2:sha256:100000:57e4b24d7fbe351597b73f2875dfce91:78e59dd6b33d3d705bb6d3eef7638a3b46599f119ecdd43080fc92165a608175', 'admin', 0, datetime('now'), datetime('now')),
  ('user-2', 'Ana García', '[email protected]', 'pbkdf2:sha256:100000:57e4b24d7fbe351597b73f2875dfce91:78e59dd6b33d3d705bb6d3eef7638a3b46599f119ecdd43080fc92165a608175', 'user', 0, datetime('now'), datetime('now'));
"
🔒
No siembres esos usuarios en producción. La contraseña password123 y el hash están publicados en este artículo y en el repositorio: cualquiera podría entrar como administrador en tu API. La propia plantilla desactiva los usuarios de demo con NODE_ENV=production.

En producción, crea tu propio administrador con una contraseña que solo conozcas tú. Este bloque la pide sin mostrarla, calcula el hash con Web Crypto en el mismo formato que src/utils/password.ts (paso 7.1) y lo inserta en D1 sin que la contraseña quede en el historial:

printf 'Contraseña del administrador: '; read -rs ADMIN_PW; echo
HASH=$(ADMIN_PW="$ADMIN_PW" node --input-type=module -e '
const salt = crypto.getRandomValues(new Uint8Array(16));
const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(process.env.ADMIN_PW), "PBKDF2", false, ["deriveBits"]);
const bits = await crypto.subtle.deriveBits({ name: "PBKDF2", salt, iterations: 100000, hash: "SHA-256" }, key, 256);
const hex = (b) => Buffer.from(b).toString("hex");
console.log(`pbkdf2:sha256:100000:${hex(salt)}:${hex(bits)}`);
')
ID=$(node -p 'crypto.randomUUID()')

npx wrangler d1 execute tasks-db --remote --command "
INSERT INTO User (id, name, email, password, role, isBlocked, createdAt, emailVerifiedAt)
VALUES ('$ID', 'Admin', '[email protected]', '$HASH', 'admin', 0, datetime('now'), datetime('now'));
"
unset ADMIN_PW HASH

6.5. Generar el cliente tipado de Prisma

npx prisma generate

Verás dos líneas Generated Prisma Client, una por generador: src/generated/client y src/generated/client-cloudflare. La carpeta src/generated/ está en el .gitignore de la plantilla, así que si despliegas desde CI, ejecuta este comando antes de wrangler deploy.

Paso 7: Adaptar el código a Cloudflare Workers

7.1. Alternativa a bcrypt: PBKDF2 con Web Crypto (src/utils/password.ts)

bcryptjs es JavaScript puro, y en Workers cada milisegundo de CPU cuenta: el plan gratuito permite 10 ms de CPU por petición y el de pago, 30 segundos por defecto (ampliables hasta 5 minutos). La alternativa nativa es PBKDF2-HMAC-SHA256 con Web Crypto (crypto.subtle):

  • La derivación la hace el runtime en código nativo (BoringSSL en Workers, OpenSSL en Node), mucho más rápido que bcrypt en JavaScript.
  • No necesita dependencias externas: crypto.subtle está en Workers y en Node.js 22.
  • Funciona igual en Node (desarrollo y tests) que en Cloudflare Workers.
  • Mantiene la compatibilidad con los hashes bcrypt que ya existan ($2a$, $2b$, $2y$).
// src/utils/password.ts
import bcrypt from "bcryptjs";

// 100.000 iteraciones es el máximo que admite Web Crypto en Cloudflare Workers en producción
// (en local, con wrangler dev, no hay límite: un valor mayor funcionaría en local y fallaría
// al desplegar). OWASP recomienda 600.000 para PBKDF2-HMAC-SHA256.
const PBKDF2_ITERATIONS = 100_000;
const PBKDF2_MAX_ITERATIONS = 100_000;
const PBKDF2_KEY_BYTES = 32;
const PBKDF2_SALT_BYTES = 16;
// pbkdf2:sha256:<iteraciones>:<sal, 16 bytes en hex>:<clave, 32 bytes en hex>
const PBKDF2_HASH_FORMAT = /^pbkdf2:sha256:(\d{1,7}):([0-9a-f]{32}):([0-9a-f]{64})$/;

let dummyHash: Promise<string> | undefined;

function timingSafeEqual(a: Uint8Array, b: Uint8Array): boolean {
  if (a.length !== b.length) return false;
  let diff = 0;
  for (let i = 0; i < a.length; i++) {
    diff |= a[i] ^ b[i];
  }
  return diff === 0;
}

function bytesToHex(bytes: Uint8Array): string {
  let hex = "";
  for (let i = 0; i < bytes.length; i++) {
    hex += bytes[i].toString(16).padStart(2, "0");
  }
  return hex;
}

// Solo recibe hex ya validado por PBKDF2_HASH_FORMAT (longitud par, caracteres 0-9a-f)
function hexToBytes(hex: string): Uint8Array {
  const bytes = new Uint8Array(hex.length / 2);
  for (let i = 0; i < bytes.length; i++) {
    bytes[i] = Number.parseInt(hex.substring(i * 2, i * 2 + 2), 16);
  }
  return bytes;
}

async function derivePbkdf2Key(
  password: string,
  salt: Uint8Array,
  iterations: number,
): Promise<Uint8Array> {
  const keyMaterial = await crypto.subtle.importKey(
    "raw",
    new TextEncoder().encode(password),
    { name: "PBKDF2" },
    false,
    ["deriveBits"],
  );

  const derivedBits = await crypto.subtle.deriveBits(
    { name: "PBKDF2", salt: salt as BufferSource, iterations, hash: "SHA-256" },
    keyMaterial,
    PBKDF2_KEY_BYTES * 8,
  );

  return new Uint8Array(derivedBits);
}

// Hash de referencia para comparar cuando el usuario no existe: el login tarda lo mismo
// exista o no la cuenta
export function getDummyHash(): Promise<string> {
  dummyHash ??= hashPassword(crypto.randomUUID());
  return dummyHash;
}

export async function hashPassword(plainPassword: string): Promise<string> {
  const salt = crypto.getRandomValues(new Uint8Array(PBKDF2_SALT_BYTES));
  const derived = await derivePbkdf2Key(plainPassword, salt, PBKDF2_ITERATIONS);
  return `pbkdf2:sha256:${PBKDF2_ITERATIONS}:${bytesToHex(salt)}:${bytesToHex(derived)}`;
}

export async function comparePassword(plainPassword: string, hash: string): Promise<boolean> {
  // Hashes bcrypt anteriores: siguen funcionando, pero cuestan unos 300 ms de CPU con coste 12
  if (hash.startsWith("$2a$") || hash.startsWith("$2b$") || hash.startsWith("$2y$")) {
    return bcrypt.compareSync(plainPassword, hash);
  }

  // Formato estricto: un hash corrupto o importado (clave vacía, hex impar, iteraciones
  // desorbitadas) se rechaza en lugar de compararse a medias
  const match = PBKDF2_HASH_FORMAT.exec(hash);
  if (!match) return false;
  const iterations = Number(match[1]);
  if (iterations < 1 || iterations > PBKDF2_MAX_ITERATIONS) return false;

  const actual = await derivePbkdf2Key(plainPassword, hexToBytes(match[2]), iterations);
  return timingSafeEqual(actual, hexToBytes(match[3]));
}

Algunos detalles de este fichero:

  • 100.000 iteraciones no es un número al azar: es el máximo de Workers. El runtime de Cloudflare rechaza en producción cualquier PBKDF2 con más de 100.000 iteraciones (Pbkdf2 failed: iteration counts above 100000 are not supported). En local, wrangler dev no aplica ese límite, así que un valor mayor funcionaría en tus pruebas y fallaría al desplegar. OWASP recomienda 600.000 iteraciones para PBKDF2-HMAC-SHA256: en Workers no se puede llegar, y es un compromiso que conviene conocer.
  • El formato del hash se valida entero antes de comparar: sal de 16 bytes, clave de 32 e iteraciones entre 1 y 100.000. Sin esa comprobación, un hash corrupto o importado con la clave vacía (pbkdf2:sha256:100000:<sal>:) aceptaría cualquier contraseña, y uno con millones de iteraciones bloquearía la CPU en cada intento de login.
  • getDummyHash() sirve para que el login tarde lo mismo exista o no el usuario, y así no se pueda averiguar qué emails están registrados. Usa crypto.randomUUID(), que existe como global en Workers y en Node, sin importar node:crypto.
  • Los hashes bcrypt antiguos se siguen comprobando con bcryptjs, pero cada uno cuesta unos 300 ms de CPU con coste 12. Si migras usuarios existentes, lo ideal es volver a calcular su hash con PBKDF2 la próxima vez que inicien sesión correctamente.
⏱️
PBKDF2 es unas 20 veces más barato que bcrypt, pero tampoco cabe en el plan gratuito. Lo he medido en workerd (wrangler dev) y en Node.js 24, en la misma máquina: un hash PBKDF2-SHA256 con 100.000 iteraciones cuesta unos 14,5 ms de CPU en workerd (15,8 ms en Node); bcrypt con coste 12, unos 300 ms (unos 78 ms con coste 10). El trabajo de Web Crypto se hace en código nativo, pero sigue siendo tiempo de CPU de tu Worker y cuenta para el límite. Con 10 ms por petición en Workers Free, el hash solo ya se pasa, y cada login hace al menos uno. Cloudflare tolera que una petición supere el límite de vez en cuando, pero si pasa de forma sistemática la corta con el error 1102. Esta API necesita Workers Paid (desde 5 dólares al mes, con 30 segundos de CPU por petición por defecto): lo explico en Cloudflare Workers: plan gratuito o de pago. Los tiempos de los servidores de Cloudflare no serán idénticos a los de mi máquina, pero el orden de magnitud sí. Bajar las iteraciones para entrar en 10 ms supondría quedarse muy por debajo de la recomendación de OWASP.

7.2. Crear el contexto de la petición en src/config/env.ts

En Workers, los bindings (D1, R2) no son variables globales: llegan con cada petición en c.env. Para que el resto del código pueda usarlos sin pasar c de función en función, guardamos los bindings de cada petición en un AsyncLocalStorage (disponible gracias a nodejs_compat). Añade al final de src/config/env.ts:

import { AsyncLocalStorage } from "node:async_hooks"; // junto al resto de imports

/** Bindings de Cloudflare (D1, R2, secretos) de la petición en curso */
export const requestContextStorage = new AsyncLocalStorage<Record<string, unknown>>();

7.3. Cliente de Prisma con @prisma/adapter-d1 y Proxy transparente (src/db.ts)

Para que todas las rutas existentes sigan usando import { prisma } from "../db.js" sin cambios, sustituimos el cliente fijo por un Proxy. En Workers delega en un cliente PrismaD1 creado para cada petición (igual que hace la guía oficial de Prisma para D1) con el cliente generado para Cloudflare; en Node sigue usando el cliente de siempre con libSQL.

En src/db.ts, cambia los imports del principio:

import path from "node:path";
import type { D1Database } from "@cloudflare/workers-types";
import { PrismaD1 } from "@prisma/adapter-d1";
import { PrismaLibSql } from "@prisma/adapter-libsql";
import { env, features, requestContextStorage } from "./config/env.js";
import { PrismaClient } from "./generated/client/client.js";
import { PrismaClient as PrismaCloudflareClient } from "./generated/client-cloudflare/client.js";
import { logger } from "./lib/logger.js";
import { hashPassword } from "./utils/password.js";

Y sustituye el bloque que crea el adaptador y el cliente (desde const adapter = new PrismaLibSql({ hasta el final de export const prisma = new PrismaClient({...})) por esto:

// Los hashes de contraseña y los secretos MFA se omiten por defecto en todas las consultas
const userOmit = { user: { password: true, totpSecret: true, totpLastStep: true } } as const;

export function createPrismaClient(d1: D1Database): PrismaClient {
  const adapter = new PrismaD1(d1);
  // Mismo esquema, dos generaciones: la API es idéntica, así que exponemos un único tipo
  return new PrismaCloudflareClient({ adapter, omit: userOmit }) as unknown as PrismaClient;
}

const isCloudflare =
  typeof (globalThis as unknown as { WebSocketPair?: unknown }).WebSocketPair !== "undefined";

let nodeClient: PrismaClient | undefined;

// Todas las rutas siguen usando `import { prisma } from "../db.js"`. En Workers, el Proxy delega
// en el cliente de la petición en curso (se crea la primera vez que se usa y vive en el store de
// AsyncLocalStorage, un objeto nuevo por petición). En Node (npm run dev, Vitest) usa el cliente
// de siempre, con libSQL.
export const prisma = new Proxy({} as PrismaClient, {
  get(_target, prop: string | symbol) {
    const store = requestContextStorage.getStore();
    let client: PrismaClient;
    if (store?.DB) {
      store.prisma ??= createPrismaClient(store.DB as D1Database);
      client = store.prisma as PrismaClient;
    } else if (isCloudflare) {
      throw new Error("prisma se ha usado fuera de una petición del Worker (no hay binding DB)");
    } else {
      nodeClient ??= new PrismaClient({
        adapter: new PrismaLibSql({
          url: libsqlUrl,
          authToken: env.TURSO_AUTH_TOKEN,
          timeout: 5000,
        }),
        omit: userOmit,
      });
      client = nodeClient;
    }
    const val = Reflect.get(client, prop);
    return typeof val === "function" ? val.bind(client) : val;
  },
});

El resto del fichero (resolveDatabaseUrl, la constante libsqlUrl, applySqlitePragmas y seedDatabase) se queda como está.

Sobre el as unknown as PrismaClient: los dos clientes salen del mismo esquema y tienen la misma API, así que exponer un único tipo es seguro para los modelos y las consultas. Lo que no comparten son las clases, aunque las clases de error de Prisma sí funcionan: lo comprobé provocando un P2002 con el cliente de Cloudflare, y err instanceof Prisma.PrismaClientKnownRequestError (importado del cliente de Node, como hace la plantilla) devolvió true y la API respondió 409.

Paso 8: Endpoints de archivos adjuntos con R2 (src/routes/attachments.ts)

Crea src/routes/attachments.ts para gestionar la subida, la descarga en streaming y el borrado de adjuntos en Cloudflare R2:

import type { R2Bucket } from "@cloudflare/workers-types";
import type { Context } from "hono";
import { features } from "../config/env.js";
import { prisma } from "../db.js";
import { createRouter } from "../lib/openapi.js";
import { errorResponse, successResponse } from "../lib/response.js";
import type { AppEnv } from "../types/index.js";

export const attachmentRoutes = createRouter();

const MAX_SIZE = 10 * 1024 * 1024; // 10 MB

// Mismo criterio que src/routes/tasks.ts: con autenticación, solo las tareas del usuario;
// con AUTH_MODE=none, solo las tareas públicas (userId null). Nunca "todas".
function ownerOf(c: Context<AppEnv>): string | null {
  return features.auth ? c.get("user").userId : null;
}

function getBucket(c: Context<AppEnv>): R2Bucket | undefined {
  return (c.env as { ATTACHMENTS_BUCKET?: R2Bucket } | undefined)?.ATTACHMENTS_BUCKET;
}

// 1. SUBIR ADJUNTO (POST /api/tasks/:taskId/attachments)
attachmentRoutes.post("/:taskId/attachments", async (c) => {
  const bucket = getBucket(c);
  if (!bucket) return errorResponse(c, "R2 Storage not configured", 500);

  const taskId = c.req.param("taskId");
  const task = await prisma.task.findFirst({
    where: { id: taskId, deletedAt: null, userId: ownerOf(c) },
  });
  if (!task) return errorResponse(c, "Task not found", 404);

  const body = await c.req.parseBody();
  const file = body.file;
  if (!file || !(file instanceof File)) {
    return errorResponse(c, "Valid file is required in 'file' field", 400);
  }
  if (file.size > MAX_SIZE) {
    return errorResponse(c, "File size exceeds the 10MB limit", 400);
  }

  // La clave en R2 no lleva el nombre original: solo IDs que genera el servidor
  const storageKey = `tasks/${taskId}/${crypto.randomUUID()}`;
  const mimeType = file.type || "application/octet-stream";

  await bucket.put(storageKey, await file.arrayBuffer(), {
    httpMetadata: { contentType: mimeType },
    customMetadata: { uploadedBy: ownerOf(c) ?? "anonymous" },
  });

  const attachment = await prisma.taskAttachment.create({
    data: { taskId, fileName: file.name, fileSize: file.size, mimeType, storageKey },
  });

  return successResponse(c, attachment, { status: 201 });
});

// 2. DESCARGAR ADJUNTO EN STREAMING (GET /api/tasks/:taskId/attachments/:attachmentId)
attachmentRoutes.get("/:taskId/attachments/:attachmentId", async (c) => {
  const bucket = getBucket(c);
  if (!bucket) return errorResponse(c, "R2 Storage not configured", 500);

  const { taskId, attachmentId } = c.req.param();
  const attachment = await prisma.taskAttachment.findFirst({
    where: { id: attachmentId, taskId, task: { deletedAt: null, userId: ownerOf(c) } },
  });
  if (!attachment) return errorResponse(c, "Attachment not found", 404);

  const object = await bucket.get(attachment.storageKey);
  if (!object) return errorResponse(c, "File not found in object storage", 404);

  const headers = new Headers();
  headers.set("Content-Type", attachment.mimeType);
  headers.set("X-Content-Type-Options", "nosniff");
  headers.set("ETag", object.httpEtag);
  // "attachment" + filename* codificado: el navegador descarga, no renderiza (evita XSS con
  // HTML/SVG subidos) y el nombre del usuario no puede romper la cabecera
  headers.set(
    "Content-Disposition",
    `attachment; filename*=UTF-8''${encodeURIComponent(attachment.fileName)}`,
  );

  return new Response(object.body as unknown as BodyInit, { headers });
});

// 3. ELIMINAR ADJUNTO (DELETE /api/tasks/:taskId/attachments/:attachmentId)
attachmentRoutes.delete("/:taskId/attachments/:attachmentId", async (c) => {
  const bucket = getBucket(c);
  if (!bucket) return errorResponse(c, "R2 Storage not configured", 500);

  const { taskId, attachmentId } = c.req.param();
  const attachment = await prisma.taskAttachment.findFirst({
    where: { id: attachmentId, taskId, task: { deletedAt: null, userId: ownerOf(c) } },
  });
  if (!attachment) return errorResponse(c, "Attachment not found", 404);

  await bucket.delete(attachment.storageKey);
  await prisma.taskAttachment.delete({ where: { id: attachmentId } });

  return successResponse(c, { message: "Attachment deleted successfully" });
});

Tres decisiones de seguridad en este fichero:

  • La propiedad de la tarea se comprueba siempre. ownerOf(c) devuelve el usuario del token, o null con AUTH_MODE=none (las tareas públicas no tienen dueño). No hay ningún caso en el que la consulta se quede sin filtro de usuario.
  • El nombre del fichero no entra en la clave de R2. Lo elige quien sube el fichero: puede llevar barras, comillas o cientos de caracteres. La clave solo contiene IDs generados por el servidor y el nombre original se guarda en D1.
  • La descarga es attachment, no inline. Si alguien sube un .html o un .svg con JavaScript y se sirviera inline desde el dominio de tu API, se ejecutaría en el navegador de quien lo abra. Con attachment, nosniff y el nombre codificado con filename*, el navegador simplemente descarga el fichero.

Paso 9: Conectar el middleware de contexto en src/index.ts

Aquí hay cuatro cambios, y el orden importa: en Hono, los middlewares y las rutas se ejecutan en el orden en que se registran.

1. El puente de contexto va justo después de const app = createRouter();, antes que cualquier otro middleware, para que todo lo que venga después (incluido /healthz) tenga acceso a D1. Importa requestContextStorage desde ./config/env.js y attachmentRoutes desde ./routes/attachments.js:

// 0. Puente de contexto de Cloudflare Workers (bindings D1, R2 y secretos de la petición)
app.use("*", async (c, next) => {
  if (c.env) {
    // Objeto nuevo por petición: aquí vivirá también el PrismaClient de esa petición
    return requestContextStorage.run({ ...(c.env as Record<string, unknown>) }, () => next());
  }
  await next();
});

2. Las rutas de adjuntos se montan junto a las de tareas, en la cadena de rutas que ya existe, que está después del bucle que aplica authMiddleware a /api/tasks/*:

const routes = withHealth
  // ... rutas de auth, sessions y users sin cambios ...
  .route("/api/tasks", taskRoutes)
  .route("/api/tasks", attachmentRoutes)
  .route("/api/audit-logs", whenEnabled(features.auth, auditRoutes));

Si montas attachmentRoutes al principio del fichero, antes de los middlewares, las rutas de adjuntos responden sin pasar por la autenticación (ni por las cabeceras de seguridad ni por el límite de tamaño): lo comprobé en local, y cualquiera sin token podía subir y descargar ficheros de cualquier tarea.

3. El límite de tamaño de la petición. La plantilla limita todos los cuerpos a 100 KB, así que la subida de un adjunto de más de 100 KB devolvería un 413. Cambia ese middleware para que la ruta de subida acepte hasta 10 MB y el resto de la API siga con 100 KB:

const ATTACHMENT_UPLOAD_PATH = /^\/api\/tasks\/[^/]+\/attachments$/;
app.use("*", (c, next) =>
  bodyLimit({
    // 10 MB solo para la subida de adjuntos; 100 KB para el resto de la API
    maxSize:
      c.req.method === "POST" && ATTACHMENT_UPLOAD_PATH.test(c.req.path)
        ? 10 * 1024 * 1024
        : 100 * 1024,
    onError: (c) =>
      c.json(
        {
          success: false as const,
          message: "Payload Too Large: Request body exceeds the maximum permitted limit of 100 KB.",
          meta: buildMeta(c),
        },
        413,
      ),
  })(c, next),
);

4. El arranque de Node no debe ejecutarse en Workers. Al final del fichero, la plantilla aplica los pragmas de SQLite, comprueba las migraciones leyendo el disco, ejecuta el seeder, arranca un temporizador de limpieza y abre el servidor HTTP. Nada de eso tiene sentido en un Worker, así que lo condicionamos todo, incluida la llamada a seedDatabase(), que está fuera de los dos bloques:

const isCloudflare =
  typeof (globalThis as unknown as { WebSocketPair?: unknown }).WebSocketPair !== "undefined";

if (!isCloudflare && process.env.NODE_ENV !== "test") {
  // Pragmas de SQLite, avisos de configuración y comprobación de migraciones (sin cambios)
  // ...
}

if (!isCloudflare) await seedDatabase();

let server: ReturnType<typeof serve> | undefined;

if (!isCloudflare && process.env.NODE_ENV !== "test") {
  // Tarea de limpieza, servidor HTTP de Node y apagado ordenado (sin cambios)
  // ...
}

export default app;

La tarea que purga las sesiones caducadas cada hora tampoco se ejecuta en Workers. Si la necesitas, el sitio natural es un Cron Trigger del propio Worker.

Paso 10: Pruebas locales y despliegue

10.1. Ejecutar en local con emulación de D1 y R2

npx wrangler dev --port 8787

Comprueba el healthcheck, que hace una consulta a D1:

curl http://localhost:8787/healthz
# {"status":"healthy","timestamp":"...","uptimeSeconds":0,"database":{"status":"connected","latencyMs":8}}

Y el flujo completo con curl: login, crear una tarea, subir un adjunto y descargarlo.

TOKEN=$(curl -s -X POST http://localhost:8787/api/auth/login \
  -H 'content-type: application/json' \
  -d '{"email":"[email protected]","password":"password123"}' | jq -r .accessToken)

TASK=$(curl -s -X POST http://localhost:8787/api/tasks \
  -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
  -d '{"title":"Tarea con adjunto"}' | jq -r .data.id)

echo hola > nota.txt
ATT=$(curl -s -X POST http://localhost:8787/api/tasks/$TASK/attachments \
  -H "authorization: Bearer $TOKEN" -F "[email protected]" | jq -r .data.id)

curl -i http://localhost:8787/api/tasks/$TASK/attachments/$ATT -H "authorization: Bearer $TOKEN"
# HTTP/1.1 200 OK
# Content-Type: text/plain
# Content-Disposition: attachment; filename*=UTF-8''nota.txt
# ...
# hola

En mis pruebas, además del camino feliz: sin token, la subida y la descarga devuelven 401; con el token de otro usuario, 404; un fichero de 300 KB se sube sin problema; uno de más de 10 MB devuelve 413; y el resto de la API sigue rechazando cuerpos de más de 100 KB.

10.2. Registrar los secretos y desplegar

Si no subiste los secretos en el paso 4, hazlo ahora, antes de desplegar:

openssl rand -base64 48 | npx wrangler secret put JWT_SECRET
openssl rand -base64 48 | npx wrangler secret put JWT_REFRESH_SECRET

Y despliega a producción con un solo comando:

npx wrangler deploy

La consola te devolverá la URL pública de tu Worker:

Uploaded hono-tasks-cloudflare (8.50 sec)
Deployed hono-tasks-cloudflare triggers (2.14 sec)
  https://hono-tasks-cloudflare.<tu-subdominio>.workers.dev

Verifica la API en producción:

curl https://hono-tasks-cloudflare.<tu-subdominio>.workers.dev/healthz

Paso 11: Cómo asignar un dominio o subdominio propio (por ejemplo, api.midominio.com)

Por defecto, Cloudflare te da una URL bajo *.workers.dev. Para usar un subdominio propio, el dominio tiene que estar gestionado en Cloudflare.

Opción A: desde el panel de Cloudflare

  1. Entra en dash.cloudflare.com.
  2. Ve a Workers & Pages.
  3. Haz clic en tu Worker: hono-tasks-cloudflare.
  4. Ve a Settings > Domains & Routes.
  5. Haz clic en Add > Custom Domain.
  6. Escribe tu subdominio (por ejemplo, api.midominio.com).
  7. Haz clic en Add Custom Domain. Cloudflare crea el registro DNS y el certificado TLS automáticamente.

Opción B: en wrangler.jsonc (infraestructura como código)

Añade la sección routes a tu wrangler.jsonc. Con custom_domain, el patrón es solo el nombre de host, sin /* ni rutas: Wrangler rechaza los comodines y las rutas en los dominios personalizados.

{
  "$schema": "node_modules/wrangler/config-schema.json",
  "name": "hono-tasks-cloudflare",
  "main": "src/index.ts",
  "compatibility_date": "2026-09-01",
  "compatibility_flags": ["nodejs_compat"],
  "routes": [
    {
      "pattern": "api.midominio.com",
      "custom_domain": true
    }
  ],
  "d1_databases": [ ... ],
  "r2_buckets": [ ... ]
}

Y ejecuta de nuevo:

npx wrangler deploy

Paso 12: Pruebas completas de la API con Bruno

La plantilla incluye una colección de Bruno, el cliente de APIs offline-first y de código abierto que guarda las peticiones en archivos de texto plano (.bru) que puedes versionar en Git.

12.1. Abrir la colección en Bruno

  1. Abre la aplicación de escritorio de Bruno.
  2. Haz clic en Open Collection y selecciona la carpeta bruno/ del repositorio.

12.2. Crear los entornos de Wrangler y de producción

La colección trae un único entorno, Local, que apunta al puerto 3011 del servidor Node. Crea dos más en bruno/environments/, uno para wrangler dev y otro para producción:

# bruno/environments/Wrangler.bru
vars {
  baseUrl: http://localhost:8787
}

# bruno/environments/Production.bru
vars {
  baseUrl: https://api.midominio.com
}

Y selecciona el que quieras en el desplegable de entornos (arriba a la derecha).

12.3. Flujo automático de tokens

No necesitas copiar ni pegar tokens JWT a mano entre peticiones:

  1. Abre la carpeta Auth y selecciona la petición Login Admin. En producción, cambia el email y la contraseña por los del administrador que creaste en el paso 6.4.
  2. Pulsa Send.
  3. El script de post-respuesta de Bruno intercepta la respuesta 200 OK y ejecuta:
bru.setVar("token", res.body.accessToken);
bru.setVar("refreshToken", res.body.refreshToken);
  1. A partir de ese momento, todas las peticiones (Tasks, Users, Sessions, Audit) se envían con la cabecera Authorization: Bearer {{token}} definida a nivel global en collection.bru.
  2. Si el token caduca (a los 15 minutos), ejecuta Auth > Refresh Token o vuelve a pulsar Send en Login Admin.
  3. Seguridad: los tokens viven solo en la memoria de Bruno; no se escriben en disco ni se suben a Git.

12.4. Probar la subida y la descarga de adjuntos en R2

Las peticiones de adjuntos no vienen en la colección: son nuevas de este tutorial. Primero, para no copiar el ID de la tarea a mano, añade al final de bruno/Tasks/Create Task.bru un script que lo guarde:

script:post-response {
  if (res.status === 201) bru.setVar("taskId", res.body.data.id);
}

Después crea bruno/Tasks/Upload Attachment.bru (cambia la ruta del fichero por uno tuyo):

meta {
  name: Upload Attachment
  type: http
  seq: 7
}

post {
  url: {{baseUrl}}/api/tasks/:taskId/attachments
  body: multipartForm
  auth: none
}

params:path {
  taskId: {{taskId}}
}

body:multipart-form {
  file: @file(/ruta/a/tu/fichero.pdf)
}

script:post-response {
  if (res.status === 201) bru.setVar("attachmentId", res.body.data.id);
}

Y bruno/Tasks/Download Attachment.bru:

meta {
  name: Download Attachment
  type: http
  seq: 8
}

get {
  url: {{baseUrl}}/api/tasks/:taskId/attachments/:attachmentId
  body: none
  auth: none
}

params:path {
  taskId: {{taskId}}
  attachmentId: {{attachmentId}}
}

Con eso, el flujo es: Login Admin → Tasks > Create Task → Tasks > Upload Attachment (el fichero se guarda en R2 y sus metadatos en D1) → Tasks > Download Attachment (el fichero llega en streaming desde R2). La cabecera Authorization la pone collection.bru, por eso las peticiones nuevas llevan auth: none, igual que las de la plantilla. Ejecuté esta misma secuencia con la CLI de Bruno contra wrangler dev y las cuatro peticiones respondieron correctamente.

Conclusión

Al llevar la plantilla hono-prisma-production-starter a Cloudflare con Hono, D1, R2 y Prisma 7:

  • Tu código se ejecuta cerca de tus usuarios: el Worker corre en la red de Cloudflare, presente en más de 300 ciudades. La base de datos D1 vive en una región, así que las consultas viajan hasta ella; si tus usuarios están repartidos por el mundo, puedes activar las réplicas de lectura.
  • Sin servidores encendidos sin hacer nada: no hay un VPS de 5 a 20 € al mes esperando peticiones. Eso sí, por el coste de CPU de calcular los hashes de contraseña (unos 14,5 ms con PBKDF2, por encima de los 10 ms del plan gratuito), esta API necesita el plan Workers Paid (desde 5 dólares al mes), que incluye una cantidad generosa de peticiones y CPU.
  • Sin pagar por la transferencia de salida: servir los adjuntos desde R2 no tiene coste de egress. Pagas el almacenamiento y las operaciones (subidas y descargas), con una capa gratuita mensual.
  • Tipado de extremo a extremo: Prisma 7, Zod y TypeScript mantienen la consistencia en cada capa.