> ## Content Index
> Fetch the complete content index at: https://blog.devutilites.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# Construye una API de tareas serverless en Cloudflare con Hono, D1, R2 y Prisma 7
- URL: https://blog.devutilites.com/api-tareas-serverless-cloudflare-hono-d1-r2-prisma/
- Published: 2026-10-07T20:36:50.000Z
- Updated: 2026-10-07T20:36:50.000Z
- Description: 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.
- Author: Luis Collantes
- Tags: Cloudflare, Tutorial, Backend, Hono

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**](https://hono.dev/?ref=blog.devutilites.com): framework ultraligero que funciona igual en Node.js que en el runtime `workerd` de Cloudflare Workers. Si no lo conoces, tienes [Hono a fondo](https://blog.devutilites.com/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](https://blog.devutilites.com/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](https://blog.devutilites.com/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](https://blog.devutilites.com/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](https://blog.devutilites.com/content/images/2026/10/cf-tutorial-api-tareas-esquema.png)

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**](https://github.com/Collanteslu/hono-prisma-production-starter?ref=blog.devutilites.com), que explico pieza a pieza en [este artículo](https://blog.devutilites.com/hono-prisma-production-starter/):

```bash
# 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

```bash
npm install -D wrangler

```

Verifica la versión instalada:

```bash
npx wrangler --version

```

### 2.2\. Iniciar sesión en tu cuenta de Cloudflare

```bash
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

```bash
npx wrangler d1 create tasks-db

```

La terminal mostrará algo parecido a esto:

```text
✅ 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

```bash
npx wrangler r2 bucket create tasks-attachments

```

Salida esperada:

```text
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:

```jsonc
{
  "$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:

```bash
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:

```gitignore
.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:

```bash
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

```bash
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.

```prisma
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:

```bash
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`)

```bash
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

```bash
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 (`admin@example.com` / `password123`). El hash ya está en el formato PBKDF2 del paso 7.1:

```bash
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', 'admin@example.com', 'pbkdf2:sha256:100000:57e4b24d7fbe351597b73f2875dfce91:78e59dd6b33d3d705bb6d3eef7638a3b46599f119ecdd43080fc92165a608175', 'admin', 0, datetime('now'), datetime('now')),
  ('user-2', 'Ana García', 'ana@example.com', '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:

```bash
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', 'tu@email.com', '$HASH', 'admin', 0, datetime('now'), datetime('now'));
"
unset ADMIN_PW HASH

```

### 6.5\. Generar el cliente tipado de Prisma

```bash
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$`).

```typescript
// 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](https://blog.devutilites.com/cloudflare-workers-free-vs-paid/). 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`:

```typescript
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:

```typescript
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:

```typescript
// 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:

```typescript
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`:

```typescript
// 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/*`:

```typescript
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:

```typescript
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:

```typescript
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

```bash
npx wrangler dev --port 8787

```

Comprueba el healthcheck, que hace una consulta a D1:

```bash
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.

```bash
TOKEN=$(curl -s -X POST http://localhost:8787/api/auth/login \
  -H 'content-type: application/json' \
  -d '{"email":"admin@example.com","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 "file=@nota.txt" | 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:

```bash
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:

```bash
npx wrangler deploy

```

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

```text
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:

```bash
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](https://dash.cloudflare.com/?ref=blog.devutilites.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.

```jsonc
{
  "$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:

```bash
npx wrangler deploy

```

## Paso 12: Pruebas completas de la API con Bruno

La plantilla incluye una colección de [**Bruno**](https://usebruno.com/?ref=blog.devutilites.com), 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:

```text
# 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:

```javascript
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:

```text
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):

```text
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`:

```text
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.