構築するもの: CatalogAPI

この記事では、 カタログAPI: カタログ用の REST API JWT 認証、画像アップロード、全文検索、レート制限を備えた製品。 建築用途 一人で Cloudflare: コンピューティングにはワーカー、データベースには D1 リレーショナル、資産ストレージ用の R2、セッションとレート制限用の KV。サーバーゼロ ゼロ VPC、300 以上の PoP への自動グローバル展開を管理します。

ケーススタディのテクノロジースタック

  • 計算: Cloudflare ワーカー (TypeScript、Hono.js フレームワーク)
  • データベース: 自動移行を備えた D1 (SQLite)
  • オブジェクトストレージ: 製品画像のR2
  • キャッシュ/セッション: 労働者KV
  • 認証: Workers Secret に秘密キーを含む JWT
  • レート制限: アトミックKVを備えたスライディングウィンドウ
  • CI/CD: 自動テストとデプロイメントを備えた GitHub アクション
  • 監視: Cloudflare ワーカーが R2 にログプッシュする

プロジェクトの構造

catalog-api/
  src/
    index.ts              # Entry point, setup Hono
    middleware/
      auth.ts             # JWT verification middleware
      rateLimit.ts        # Sliding window rate limiter
      cors.ts             # CORS headers
    routes/
      products.ts         # CRUD prodotti
      categories.ts       # CRUD categorie
      uploads.ts          # Upload immagini su R2
      auth.ts             # Login / refresh token
    services/
      jwt.ts              # Firma e verifica JWT
      search.ts           # Full-text search su D1
    types/
      env.ts              # Interfaccia Env (binding)
      models.ts           # Interfacce dati
  migrations/
    0001_initial.sql      # Schema D1 iniziale
    0002_full_text.sql    # FTS5 extension
  test/
    routes/
      products.test.ts
      auth.test.ts
    integration/
      upload.test.ts
  wrangler.toml
  vitest.config.ts

スキーム D1: データベース設計

-- migrations/0001_initial.sql

CREATE TABLE categories (
  id        TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
  name      TEXT NOT NULL UNIQUE,
  slug      TEXT NOT NULL UNIQUE,
  created_at INTEGER DEFAULT (unixepoch())
);

CREATE TABLE products (
  id           TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
  name         TEXT NOT NULL,
  slug         TEXT NOT NULL UNIQUE,
  description  TEXT,
  price        REAL NOT NULL CHECK (price >= 0),
  stock        INTEGER NOT NULL DEFAULT 0 CHECK (stock >= 0),
  category_id  TEXT REFERENCES categories(id) ON DELETE SET NULL,
  image_key    TEXT,           -- chiave R2 per l'immagine principale
  status       TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive', 'deleted')),
  created_at   INTEGER DEFAULT (unixepoch()),
  updated_at   INTEGER DEFAULT (unixepoch())
);

CREATE INDEX idx_products_category ON products(category_id);
CREATE INDEX idx_products_status ON products(status);
CREATE INDEX idx_products_price ON products(price);

CREATE TABLE users (
  id           TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
  email        TEXT NOT NULL UNIQUE,
  password_hash TEXT NOT NULL,
  role         TEXT NOT NULL DEFAULT 'viewer' CHECK (role IN ('admin', 'editor', 'viewer')),
  created_at   INTEGER DEFAULT (unixepoch())
);

-- migrations/0002_full_text.sql
-- FTS5 per ricerca full-text efficiente
CREATE VIRTUAL TABLE products_fts USING fts5(
  id UNINDEXED,
  name,
  description,
  content='products',
  content_rowid='rowid'
);

-- Trigger per mantenere sincronizzata la FTS table
CREATE TRIGGER products_ai AFTER INSERT ON products BEGIN
  INSERT INTO products_fts(rowid, id, name, description)
  VALUES (new.rowid, new.id, new.name, new.description);
END;

CREATE TRIGGER products_au AFTER UPDATE ON products BEGIN
  UPDATE products_fts SET name = new.name, description = new.description
  WHERE id = new.id;
END;

CREATE TRIGGER products_ad AFTER DELETE ON products BEGIN
  DELETE FROM products_fts WHERE id = old.id;
END;

エントリ ポイント: Workers の Hono.js

Hono.js Workers で最も使用されている Web フレームワークです。 エッジ向けに書かれており (Node.js 依存関係なし)、超高速ルーターを備えています。 TypeScript を使用した、コンポーザブルでタイプセーフなミドルウェア。

// src/index.ts
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { secureHeaders } from 'hono/secure-headers';
import { timing } from 'hono/timing';

import { authMiddleware } from './middleware/auth';
import { rateLimitMiddleware } from './middleware/rateLimit';
import { productsRouter } from './routes/products';
import { categoriesRouter } from './routes/categories';
import { uploadsRouter } from './routes/uploads';
import { authRouter } from './routes/auth';
import type { Env } from './types/env';

const app = new Hono<{ Bindings: Env }>();

// Middleware globali
app.use('*', cors({
  origin: ['https://myapp.com', 'https://admin.myapp.com'],
  allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
  allowHeaders: ['Content-Type', 'Authorization'],
  maxAge: 86400,
}));

app.use('*', secureHeaders());
app.use('*', timing());

// Rate limiting su tutte le route API
app.use('/api/*', rateLimitMiddleware);

// Route pubbliche (no auth)
app.route('/api/auth', authRouter);
app.get('/api/products', async (c) => {
  // GET list e pubblico, senza autenticazione
  const { default: handler } = await import('./routes/products');
  return handler.fetch(c.req.raw, c.env, c.executionCtx);
});

// Route protette (richiedono JWT)
app.use('/api/products/*', authMiddleware);
app.use('/api/categories/*', authMiddleware);
app.use('/api/uploads/*', authMiddleware);

app.route('/api/products', productsRouter);
app.route('/api/categories', categoriesRouter);
app.route('/api/uploads', uploadsRouter);

// Health check
app.get('/health', (c) => c.json({ status: 'ok', timestamp: Date.now() }));

// 404 handler
app.notFound((c) => c.json({ error: 'Not Found' }, 404));

// Error handler
app.onError((err, c) => {
  console.error('Unhandled error:', err);
  return c.json({ error: 'Internal Server Error' }, 500);
});

export default app;

認証: ワーカー シークレットを含む JWT

// src/services/jwt.ts
// Web Crypto API (disponibile in Workers, nessuna dipendenza esterna)

const JWT_ALGORITHM = 'HS256';

export async function signJWT(
  payload: Record<string, unknown>,
  secret: string,
  expiresInSeconds = 3600
): Promise<string> {
  const header = { alg: JWT_ALGORITHM, typ: 'JWT' };

  const now = Math.floor(Date.now() / 1000);
  const fullPayload = {
    ...payload,
    iat: now,
    exp: now + expiresInSeconds,
  };

  const encodedHeader = btoa(JSON.stringify(header)).replace(/=/g, '');
  const encodedPayload = btoa(JSON.stringify(fullPayload)).replace(/=/g, '');
  const data = `${encodedHeader}.${encodedPayload}`;

  const key = await crypto.subtle.importKey(
    'raw',
    new TextEncoder().encode(secret),
    { name: 'HMAC', hash: 'SHA-256' },
    false,
    ['sign']
  );

  const signature = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(data));
  const encodedSignature = btoa(String.fromCharCode(...new Uint8Array(signature))).replace(/=/g, '');

  return `${data}.${encodedSignature}`;
}

export async function verifyJWT(
  token: string,
  secret: string
): Promise<Record<string, unknown> | null> {
  try {
    const [encodedHeader, encodedPayload, encodedSignature] = token.split('.');

    if (!encodedHeader || !encodedPayload || !encodedSignature) return null;

    // Verifica firma
    const data = `${encodedHeader}.${encodedPayload}`;
    const key = await crypto.subtle.importKey(
      'raw',
      new TextEncoder().encode(secret),
      { name: 'HMAC', hash: 'SHA-256' },
      false,
      ['verify']
    );

    const signatureBytes = Uint8Array.from(atob(encodedSignature.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0));
    const valid = await crypto.subtle.verify('HMAC', key, signatureBytes, new TextEncoder().encode(data));

    if (!valid) return null;

    // Verifica scadenza
    const payload = JSON.parse(atob(encodedPayload)) as { exp: number };
    if (payload.exp < Math.floor(Date.now() / 1000)) return null;

    return payload as Record<string, unknown>;
  } catch {
    return null;
  }
}

// src/middleware/auth.ts
import type { MiddlewareHandler } from 'hono';
import { verifyJWT } from '../services/jwt';
import type { Env } from '../types/env';

export const authMiddleware: MiddlewareHandler<{ Bindings: Env }> = async (c, next) => {
  const authHeader = c.req.header('Authorization');

  if (!authHeader?.startsWith('Bearer ')) {
    return c.json({ error: 'Missing or invalid Authorization header' }, 401);
  }

  const token = authHeader.slice(7);
  const payload = await verifyJWT(token, c.env.JWT_SECRET);

  if (!payload) {
    return c.json({ error: 'Invalid or expired token' }, 401);
  }

  // Passa il payload al contesto per i route handler successivi
  c.set('user', payload);
  await next();
};

レート制限: KV によるスライディング ウィンドウ

// src/middleware/rateLimit.ts
// Sliding window rate limiter: massimo N richieste per finestra temporale

import type { MiddlewareHandler } from 'hono';
import type { Env } from '../types/env';

const WINDOW_SIZE_SECONDS = 60;  // Finestra di 1 minuto
const MAX_REQUESTS = 100;         // 100 richieste per finestra (per IP)

export const rateLimitMiddleware: MiddlewareHandler<{ Bindings: Env }> = async (c, next) => {
  // Usa l'IP del client come chiave (Cloudflare lo espone in cf.connectingIp)
  const clientIp = c.req.header('CF-Connecting-IP') ??
                   c.req.header('X-Forwarded-For') ??
                   'unknown';

  const now = Math.floor(Date.now() / 1000);
  const windowStart = now - WINDOW_SIZE_SECONDS;
  const key = `ratelimit:${clientIp}:${Math.floor(now / WINDOW_SIZE_SECONDS)}`;

  // Recupera il contatore corrente per questa finestra
  const currentCount = parseInt(await c.env.RATE_LIMIT_KV.get(key) ?? '0');

  if (currentCount >= MAX_REQUESTS) {
    const resetAt = (Math.floor(now / WINDOW_SIZE_SECONDS) + 1) * WINDOW_SIZE_SECONDS;
    return c.json(
      { error: 'Rate limit exceeded', resetAt },
      429,
      {
        'X-RateLimit-Limit': String(MAX_REQUESTS),
        'X-RateLimit-Remaining': '0',
        'X-RateLimit-Reset': String(resetAt),
        'Retry-After': String(resetAt - now),
      }
    );
  }

  // Incrementa il contatore
  const newCount = currentCount + 1;
  c.executionCtx.waitUntil(
    c.env.RATE_LIMIT_KV.put(key, String(newCount), {
      expirationTtl: WINDOW_SIZE_SECONDS * 2,
    })
  );

  // Aggiungi header informativi
  c.header('X-RateLimit-Limit', String(MAX_REQUESTS));
  c.header('X-RateLimit-Remaining', String(MAX_REQUESTS - newCount));

  await next();
};

製品ルート: D1 を使用した CRUD

// src/routes/products.ts
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import type { Env } from '../types/env';

const productsRouter = new Hono<{ Bindings: Env }>();

// Schema di validazione con Zod
const createProductSchema = z.object({
  name: z.string().min(1).max(200),
  slug: z.string().min(1).regex(/^[a-z0-9-]+$/),
  description: z.string().max(5000).optional(),
  price: z.number().min(0),
  stock: z.number().int().min(0).default(0),
  category_id: z.string().uuid().optional(),
});

// GET /api/products - Lista con paginazione, filtri e ricerca
productsRouter.get('/', async (c) => {
  const { page = '1', limit = '20', category, search, min_price, max_price } = c.req.query();

  const pageNum = Math.max(1, parseInt(page));
  const limitNum = Math.min(100, Math.max(1, parseInt(limit)));
  const offset = (pageNum - 1) * limitNum;

  let query: string;
  const params: (string | number)[] = [];

  if (search) {
    // Full-text search via FTS5
    query = `
      SELECT p.*, c.name as category_name
      FROM products_fts f
      JOIN products p ON f.id = p.id
      LEFT JOIN categories c ON p.category_id = c.id
      WHERE products_fts MATCH ? AND p.status = 'active'
    `;
    params.push(search);
  } else {
    query = `
      SELECT p.*, c.name as category_name
      FROM products p
      LEFT JOIN categories c ON p.category_id = c.id
      WHERE p.status = 'active'
    `;
  }

  if (category) {
    query += ` AND c.slug = ?`;
    params.push(category);
  }

  if (min_price) {
    query += ` AND p.price >= ?`;
    params.push(parseFloat(min_price));
  }

  if (max_price) {
    query += ` AND p.price <= ?`;
    params.push(parseFloat(max_price));
  }

  // Count totale per paginazione
  const countQuery = `SELECT COUNT(*) as total FROM (${query})`;
  const countResult = await c.env.DB.prepare(countQuery).bind(...params).first<{ total: number }>();
  const total = countResult?.total ?? 0;

  // Query paginata
  query += ` ORDER BY p.created_at DESC LIMIT ? OFFSET ?`;
  params.push(limitNum, offset);

  const products = await c.env.DB.prepare(query).bind(...params).all();

  return c.json({
    data: products.results,
    pagination: {
      page: pageNum,
      limit: limitNum,
      total,
      pages: Math.ceil(total / limitNum),
    },
  });
});

// GET /api/products/:id
productsRouter.get('/:id', async (c) => {
  const id = c.req.param('id');

  const product = await c.env.DB.prepare(`
    SELECT p.*, c.name as category_name, c.slug as category_slug
    FROM products p
    LEFT JOIN categories c ON p.category_id = c.id
    WHERE p.id = ? AND p.status != 'deleted'
  `).bind(id).first();

  if (!product) {
    return c.json({ error: 'Product not found' }, 404);
  }

  // Aggiungi URL pre-firmato per l'immagine R2 (valido 1 ora)
  let imageUrl: string | null = null;
  if ((product as { image_key?: string }).image_key) {
    const key = (product as { image_key: string }).image_key;
    // Usa URL pubblica R2 (richiede bucket pubblico o URL custom domain)
    imageUrl = `https://assets.myapp.com/${key}`;
  }

  return c.json({ ...product, imageUrl });
});

// POST /api/products - Crea prodotto (richiede auth admin/editor)
productsRouter.post('/', zValidator('json', createProductSchema), async (c) => {
  const user = c.get('user') as { role: string } | undefined;

  if (!user || !['admin', 'editor'].includes(user.role)) {
    return c.json({ error: 'Insufficient permissions' }, 403);
  }

  const data = c.req.valid('json');

  // Verifica univocità slug
  const existing = await c.env.DB.prepare(
    'SELECT id FROM products WHERE slug = ?'
  ).bind(data.slug).first();

  if (existing) {
    return c.json({ error: 'Slug already exists' }, 409);
  }

  const result = await c.env.DB.prepare(`
    INSERT INTO products (name, slug, description, price, stock, category_id)
    VALUES (?, ?, ?, ?, ?, ?)
    RETURNING *
  `).bind(
    data.name,
    data.slug,
    data.description ?? null,
    data.price,
    data.stock,
    data.category_id ?? null
  ).first();

  return c.json(result, 201);
});

// PATCH /api/products/:id
productsRouter.patch('/:id', async (c) => {
  const id = c.req.param('id');
  const user = c.get('user') as { role: string } | undefined;

  if (!user || !['admin', 'editor'].includes(user.role)) {
    return c.json({ error: 'Insufficient permissions' }, 403);
  }

  const body = await c.req.json() as Partial<{ name: string; price: number; stock: number; status: string }>;

  const updates: string[] = [];
  const values: (string | number)[] = [];

  if (body.name !== undefined) { updates.push('name = ?'); values.push(body.name); }
  if (body.price !== undefined) { updates.push('price = ?'); values.push(body.price); }
  if (body.stock !== undefined) { updates.push('stock = ?'); values.push(body.stock); }
  if (body.status !== undefined) { updates.push('status = ?'); values.push(body.status); }

  if (updates.length === 0) {
    return c.json({ error: 'No fields to update' }, 400);
  }

  updates.push('updated_at = unixepoch()');
  values.push(id);

  const result = await c.env.DB.prepare(
    `UPDATE products SET ${updates.join(', ')} WHERE id = ? AND status != 'deleted' RETURNING *`
  ).bind(...values).first();

  if (!result) {
    return c.json({ error: 'Product not found' }, 404);
  }

  return c.json(result);
});

export { productsRouter };

画像をR2にアップロード

// src/routes/uploads.ts
import { Hono } from 'hono';
import type { Env } from '../types/env';

const uploadsRouter = new Hono<{ Bindings: Env }>();

const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/avif'];
const MAX_SIZE_BYTES = 5 * 1024 * 1024; // 5MB

uploadsRouter.post('/product-image/:productId', async (c) => {
  const productId = c.req.param('productId');
  const user = c.get('user') as { role: string } | undefined;

  if (!user || !['admin', 'editor'].includes(user.role)) {
    return c.json({ error: 'Insufficient permissions' }, 403);
  }

  // Verifica che il prodotto esista
  const product = await c.env.DB.prepare(
    "SELECT id FROM products WHERE id = ? AND status != 'deleted'"
  ).bind(productId).first();

  if (!product) {
    return c.json({ error: 'Product not found' }, 404);
  }

  const formData = await c.req.formData();
  const file = formData.get('image') as File | null;

  if (!file) {
    return c.json({ error: 'No image file provided' }, 400);
  }

  // Validazione tipo e dimensione
  if (!ALLOWED_TYPES.includes(file.type)) {
    return c.json({
      error: `Invalid file type. Allowed: ${ALLOWED_TYPES.join(', ')}`
    }, 400);
  }

  if (file.size > MAX_SIZE_BYTES) {
    return c.json({ error: 'File too large. Maximum 5MB' }, 400);
  }

  // Genera chiave R2 univoca
  const ext = file.type.split('/')[1];
  const r2Key = `products/${productId}/${crypto.randomUUID()}.${ext}`;

  // Upload su R2
  await c.env.ASSETS_BUCKET.put(r2Key, file.stream(), {
    httpMetadata: {
      contentType: file.type,
      cacheControl: 'public, max-age=31536000, immutable',
    },
    customMetadata: {
      productId,
      originalName: file.name,
      uploadedAt: new Date().toISOString(),
    },
  });

  // Aggiorna il prodotto con la nuova chiave R2
  // Prima elimina l'immagine precedente se esiste
  const oldProduct = await c.env.DB.prepare(
    'SELECT image_key FROM products WHERE id = ?'
  ).bind(productId).first<{ image_key: string | null }>();

  if (oldProduct?.image_key) {
    // Elimina la vecchia immagine in background
    c.executionCtx.waitUntil(
      c.env.ASSETS_BUCKET.delete(oldProduct.image_key)
    );
  }

  await c.env.DB.prepare(
    'UPDATE products SET image_key = ?, updated_at = unixepoch() WHERE id = ?'
  ).bind(r2Key, productId).run();

  return c.json({
    key: r2Key,
    url: `https://assets.myapp.com/${r2Key}`,
    size: file.size,
    type: file.type,
  }, 201);
});

// DELETE /api/uploads/product-image/:productId
uploadsRouter.delete('/product-image/:productId', async (c) => {
  const productId = c.req.param('productId');
  const user = c.get('user') as { role: string } | undefined;

  if (!user || user.role !== 'admin') {
    return c.json({ error: 'Admin required' }, 403);
  }

  const product = await c.env.DB.prepare(
    'SELECT image_key FROM products WHERE id = ?'
  ).bind(productId).first<{ image_key: string | null }>();

  if (!product?.image_key) {
    return c.json({ error: 'No image found for this product' }, 404);
  }

  await c.env.ASSETS_BUCKET.delete(product.image_key);

  await c.env.DB.prepare(
    'UPDATE products SET image_key = NULL, updated_at = unixepoch() WHERE id = ?'
  ).bind(productId).run();

  return new Response(null, { status: 204 });
});

export { uploadsRouter };

wrangler.toml の完成

# wrangler.toml
name = "catalog-api"
main = "src/index.ts"
compatibility_date = "2025-01-01"
compatibility_flags = ["nodejs_compat"]

# Worker deve avere Workers Paid Plan per D1, R2 e CPU > 10ms
account_id = "YOUR_ACCOUNT_ID"

[[d1_databases]]
binding = "DB"
database_name = "catalog-db"
database_id = "YOUR_D1_DATABASE_ID"
migrations_dir = "migrations"  # wrangler d1 migrations apply catalog-db

[[r2_buckets]]
binding = "ASSETS_BUCKET"
bucket_name = "catalog-assets"
# Configura custom domain per URL pubblici: https://assets.myapp.com

[[kv_namespaces]]
binding = "RATE_LIMIT_KV"
id = "YOUR_KV_NAMESPACE_ID"

# Secrets (impostati con: wrangler secret put JWT_SECRET)
# JWT_SECRET = ...  (non nel file, usa wrangler secret)

[vars]
ENVIRONMENT = "production"
MAX_UPLOAD_MB = "5"

# Configurazione per staging
[env.staging]
name = "catalog-api-staging"
vars = { ENVIRONMENT = "staging" }

[[env.staging.d1_databases]]
binding = "DB"
database_name = "catalog-db-staging"
database_id = "YOUR_STAGING_D1_ID"

# Routes: mappa il Worker al custom domain
[[routes]]
pattern = "api.myapp.com/*"
zone_id = "YOUR_ZONE_ID"

移行 D1: スキームの適用

# Crea il database D1
npx wrangler d1 create catalog-db

# Applica le migration iniziali
npx wrangler d1 migrations apply catalog-db

# In staging (dry run per verificare)
npx wrangler d1 migrations apply catalog-db --env staging --dry-run

# Query diretta per debug (locale)
npx wrangler d1 execute catalog-db --local --command "SELECT count(*) FROM products"

# Esporta snapshot per backup
npx wrangler d1 export catalog-db --output backup-$(date +%Y%m%d).sql

CI/CD: GitHub アクションの完了

# .github/workflows/deploy.yml
name: Test and Deploy

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

env:
  NODE_VERSION: '20'

jobs:
  test:
    name: Test
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: 'npm'

      - run: npm ci

      - name: TypeScript check
        run: npx tsc --noEmit

      - name: Lint
        run: npm run lint

      - name: Unit + Integration tests
        run: npx vitest run --coverage
        env:
          CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}

      - name: Upload coverage to Codecov
        uses: codecov/codecov-action@v4
        with:
          token: ${{ secrets.CODECOV_TOKEN }}

  deploy-staging:
    name: Deploy Staging
    needs: test
    runs-on: ubuntu-latest
    if: github.event_name == 'pull_request'

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: 'npm'
      - run: npm ci

      - name: Apply D1 migrations (staging)
        run: npx wrangler d1 migrations apply catalog-db --env staging
        env:
          CLOUDFLARE_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
          CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}

      - name: Deploy to staging
        run: npx wrangler deploy --env staging
        env:
          CLOUDFLARE_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
          CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}

      - name: Run smoke tests against staging
        run: npm run test:e2e
        env:
          API_BASE_URL: https://catalog-api-staging.myapp.com

  deploy-production:
    name: Deploy Production
    needs: test
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: 'npm'
      - run: npm ci

      - name: Apply D1 migrations (production)
        run: npx wrangler d1 migrations apply catalog-db
        env:
          CLOUDFLARE_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
          CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}

      - name: Deploy to production
        run: npx wrangler deploy
        env:
          CLOUDFLARE_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
          CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}

      - name: Purge Cloudflare Cache dopo deploy
        run: |
          curl -X POST "https://api.cloudflare.com/client/v4/zones/${{ secrets.CF_ZONE_ID }}/purge_cache" \
            -H "Authorization: Bearer ${{ secrets.CF_API_TOKEN }}" \
            -H "Content-Type: application/json" \
            --data '{"purge_everything":true}'

可観測性: ログとモニタリング

// src/middleware/logging.ts
// Middleware di logging strutturato verso R2 (via Logpush) o console

import type { MiddlewareHandler } from 'hono';
import type { Env } from '../types/env';

interface RequestLog {
  timestamp: string;
  method: string;
  path: string;
  status: number;
  durationMs: number;
  country: string;
  datacenter: string;
  ip: string;
  userAgent: string;
  userId?: string;
}

export const loggingMiddleware: MiddlewareHandler<{ Bindings: Env }> = async (c, next) => {
  const startTime = Date.now();

  await next();

  const durationMs = Date.now() - startTime;
  const user = c.get('user') as { id?: string } | undefined;

  const log: RequestLog = {
    timestamp: new Date().toISOString(),
    method: c.req.method,
    path: new URL(c.req.url).pathname,
    status: c.res.status,
    durationMs,
    country: c.req.header('CF-IPCountry') ?? 'unknown',
    datacenter: (c.req.raw as Request & { cf?: { colo?: string } }).cf?.colo ?? 'unknown',
    ip: c.req.header('CF-Connecting-IP') ?? 'unknown',
    userAgent: c.req.header('User-Agent') ?? 'unknown',
    userId: user?.id,
  };

  // Log strutturato verso console (visibile in Cloudflare Dashboard)
  console.log(JSON.stringify(log));

  // Configura Cloudflare Logpush nel dashboard per inviare automaticamente
  // i Workers logs verso R2, Datadog, Splunk, ecc.
};

// Alerting: monitora errori 5xx su Workers Analytics Engine

コストとスケーリング: 経済分析

成分 価格 有料プランに含まれています (月額 5 ドル)
労働者の要望 0.30 ドル / 100 万ドル (込み後) 1000万/月
ワーカーの CPU 時間 0.02 ドル / 100 万 CPU-ms 3,000 万 CPU-ms/月
D1 行の読み取り 0.001 ドル / 100 万ドル 250億/月
D1 行が書き込まれました 1.00ドル/100万ドル 5,000万/月
R2ストレージ $0.015 / GB / 月 10GB
R2の操作 0.36 ドル / 100 万ドル クラス A クラスA 100万、クラスB 1000万
KV 読み取り 0.50ドル/100万ドル 1000万/月

中トラフィック API (100 万リクエスト/月、100K D1 書き込み、10GB R2) の場合: 総費用は約です 月額 5 ~ 15 ドル。建築 AWS で同等のコスト (API Gateway + Lambda + RDS + S3 + CloudFront) 簡単に 150~400ドル/月 同じスケールで。

シリーズの結論

このケーススタディは、REST API を構築できることを実証しました。 認証、アップロード、全文検索、レート制限を備えた完全な本番環境、 使用して 一人で Cloudflareプラットフォーム。結果はアプリケーションです 300 以上のグローバル データセンターに分散され、ミリ秒未満のレイテンシで拡張可能 無限に自動化され、コストは従来のクラウド アーキテクチャの数分の一です。

記事 1 で紹介した V8 分離株はすべての基礎です。 これにより、世界中のどこでも、コールド スタートなしで TypeScript コードを実行できます。 VPC を使用しないサーバー管理。運用上の複雑さが解消されます。 ビジネスの複雑さは依然としてあなたのものです。

関連シリーズ: その他のリソース

  • 大規模な Kubernetes (シリーズ 36): 労働者が不足していて労働者が必要な場合 より多くの CPU と RAM を備えたコンテナの数。
  • イベント駆動型アーキテクチャ (シリーズ 39): ワーカー + キュー バインディング 非同期パターンと処理パイプライン。
  • DevSecOps (シリーズ 05): コードとしての依存関係とポリシーのセキュリティ スキャン 労働者のエコシステムに適用されます。