|

Der Cloudflare Worker — Ein dünner Proxy, der nichts sieht (Teil 4/6)

In den Teilen 2 und 3 habe ich immer wieder gesagt: „Der Server schiebt nur verschlüsselte Bytes herum." Zeit, das zu beweisen. Lass uns den Cloudflare Worker bauen — das dünnstmögliche Backend, das Authentifizierungs-Header hinzufügt, SSE streamt, statische Assets ausliefert und absolut nichts anderes tut.

Der gesamte Worker ist 145 Zeilen lang. Lass uns jede einzelne durchgehen.

Warum Cloudflare Workers?

Drei Gründe:

  1. Edge-First. Der Worker läuft in 300+ Rechenzentren weltweit. Dein Teamkollege in Berlin und dein Teamkollege in São Paulo bekommen beide unter 50ms Latenz zum nächsten Worker.
  2. Keine Cold Starts (für Workers, nicht Pages Functions). Das V8-Isolate-Modell bedeutet, dass dein Code immer warm ist.
  3. Großzügiger Free Tier. 100.000 Requests/Tag im kostenlosen Plan. Für eine Planning-Poker-App ist das eine MENGE Sprint-Plannings.

Und weil wir bereits Upstash Redis verwenden (das ebenfalls am Edge läuft), bleibt der gesamte Request-Pfad — Browser → Worker → Upstash — überall schnell.

Architektur: was der Worker wirklich tut

Browser (SPA)
  ├── POST  /upstash           → Worker fügt Authorization hinzu → Upstash REST
  ├── POST  /upstash/pipeline  → Worker fügt Authorization hinzu → Upstash Pipeline
  └── GET   /upstash/subscribe/:channel
              → Worker: handleSSE() [umgeht Hono, nutzt TransformStream]
              → Upstash SSE Pub/Sub Stream → Browser

Das war’s. Drei Routen. Der Worker:

  • Empfängt einen Request vom Browser (verschlüsselter Body)
  • Fügt Authorization: Bearer <token> zu den Headern hinzu
  • Leitet ihn an Upstash weiter
  • Sendet die Antwort zurück

Er entschlüsselt nie etwas. Er parst nie den Body. Er hat keine Ahnung, was ein „Raum" oder eine „Stimme" ist.

Hono einrichten

Hono ist ein winziges Web-Framework, gebaut für Edge-Runtimes. Stell dir Express vor, aber mit 14KB und von Grund auf für Cloudflare Workers konzipiert:

import { Hono } from "hono";
import { cors } from "hono/cors";

interface Env {
  UPSTASH_URL: string;
  UPSTASH_TOKEN: string;
  ASSETS: { fetch: (req: Request) => Promise<Response> };
}

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

app.use("*", cors({
  origin: "*",
  allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
  allowHeaders: ["Content-Type"],
}));

Das Env-Interface ist, wo die Cloudflare-Bindings leben. UPSTASH_URL und UPSTASH_TOKEN sind Secrets (gesetzt über wrangler secret put). ASSETS ist das Static-Asset-Binding, das die Vite-gebaute SPA ausliefert.

Die Auth-Proxy-Route

Das ist der Kern des Workers — und es ist fast komisch einfach:

app.all("/upstash*", async (c) => {
  const ip = c.req.header("CF-Connecting-IP") ?? "unknown";
  if (isRateLimited(ip)) return c.json({ error: "Too many requests" }, 429);

  const upstashBase = c.env.UPSTASH_URL.replace(/\/$/, "");
  const path = c.req.path.slice("/upstash".length) || "/";
  const search = new URL(c.req.url).search;
  const target = `${upstashBase}${path}${search}`;

  const isBodyless = c.req.method === "GET" || c.req.method === "HEAD";
  return fetch(target, {
    method: c.req.method,
    headers: {
      Authorization: `Bearer ${c.env.UPSTASH_TOKEN}`,
      ...(isBodyless ? {} : { "Content-Type": c.req.header("Content-Type") ?? "application/json" }),
    },
    body: isBodyless ? undefined : c.req.raw.body,
  });
});

Ein paar Dinge passieren hier:

Route-Matching: /upstash* — Beachte den fehlenden Schrägstrich. Das matcht absichtlich sowohl /upstash (einzelne Befehle) als auch /upstash/pipeline (gebatchte Schreibvorgänge). Ein Handler, zwei Endpoints.

Pfad-Umschreibung: /upstash-Prefix abschneiden, den Rest auf die Upstash-REST-URL kleben. /upstash/pipeline wird zu https://your-db.upstash.io/pipeline.

Body-Passthrough: Der Worker parst, validiert oder transformiert den Request-Body nicht. Es ist ein opakes Blob aus verschlüsseltem JSON. Der Worker fügt nur Auth hinzu und leitet weiter.

Die Upstash-Credentials (UPSTASH_URL, UPSTASH_TOKEN) erreichen nie den Browser. In Produktion hat die SPA keine VITE_UPSTASH_*-Umgebungsvariablen. Sie sendet Requests an /upstash (Same-Origin), und der Worker injiziert den Auth-Header serverseitig.

Rate-Limiting

Auch wenn das ein kostenloses Tool ist, möchte ich nicht, dass ein hyperaktiver Client Upstash bombardiert:

const HARD_LIMIT = 120; // Requests pro Minute pro IP
const rateLimits = new Map<string, { count: number; resetAt: number }>();

function isRateLimited(ip: string): boolean {
  const now = Date.now();
  const entry = rateLimits.get(ip);
  if (!entry || now >= entry.resetAt) {
    rateLimits.set(ip, { count: 1, resetAt: now + 60_000 });
    return false;
  }
  return ++entry.count > HARD_LIMIT;
}

120 Requests pro Minute pro IP. Das sind ungefähr 2 pro Sekunde, was mehr als genug ist, selbst für eine hektische Schätzungs-Session. Das Rate-Limit ist im Speicher (pro Worker-Isolate), sodass es Worker-Neustarts nicht überlebt — aber es muss nicht kugelsicher sein. Es ist eine Bremsschwelle, keine Festung.

Der CF-Connecting-IP-Header wird von Cloudflare injiziert und kann vom Client nicht gefälscht werden. Gut genug für diesen Anwendungsfall.

SSE-Streaming — der knifflige Teil

Hier wird es interessant. SSE (Server-Sent Events) ist eine langlebige HTTP-Antwort, bei der der Server kontinuierlich data:-Zeilen schreibt. Der Browser liest sie in Echtzeit.

Der Haken? Honos Middleware bricht SSE. Wenn du eine Streaming-Response aus einer Hono-Route zurückgibst, versucht Honos CORS-Middleware (und jede andere Middleware), die Response-Header oder den Body zu berühren, was den Stream vorzeitig schließen kann.

Die Lösung: SSE behandeln, bevor Hono die Anfrage überhaupt sieht.

export default {
  async fetch(req: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    const { pathname } = new URL(req.url);
    if (pathname.startsWith("/upstash/subscribe/")) {
      return handleSSE(req, env);  // Hono komplett umgehen
    }
    return app.fetch(req, env, ctx);
  },
} satisfies ExportedHandler<Env>;

Der fetch-Handler auf Modulebene fängt SSE-Requests ab und leitet sie an handleSSE() weiter. Alles andere geht durch Hono. Saubere Trennung.

Die handleSSE-Funktion selbst nutzt eine TransformStream-Pipe:

async function handleSSE(req: Request, env: Env): Promise<Response> {
  const { pathname } = new URL(req.url);
  const channel = pathname.slice("/upstash/subscribe/".length);
  
  const upstream = await fetch(
    `${env.UPSTASH_URL.replace(/\/$/, "")}/subscribe/${channel}`, 
    {
      headers: { Authorization: `Bearer ${env.UPSTASH_TOKEN}` },
    }
  );

  if (!upstream.body) {
    return new Response(JSON.stringify({ error: "Upstream unavailable" }), { status: 502 });
  }

  const { readable, writable } = new TransformStream<Uint8Array, Uint8Array>();
  const writer = writable.getWriter();

  const pump = async () => {
    const reader = upstream.body!.getReader();
    try {
      while (true) {
        const { done, value } = await reader.read();
        if (done) break;
        await writer.write(value);
      }
    } finally {
      writer.close().catch(() => {});
    }
  };

  pump().catch(() => writer.close().catch(() => {}));

  return new Response(readable, {
    headers: {
      "Content-Type": "text/event-stream",
      "Cache-Control": "no-cache",
      "Access-Control-Allow-Origin": "*",
    },
  });
}

Warum TransformStream statt einfach upstream zurückzugeben? Weil die direkte Rückgabe eines fetch()-Aufrufs für langlebige Streams in Cloudflare Workers nicht zuverlässig funktioniert. Das TransformStream-Pattern gibt der Runtime etwas Stabiles zum Festhalten.

Die pump()-Funktion liest Chunks von Upstash und schreibt sie an den Client. Wenn eine Seite stirbt, wird der Stream sauber geschlossen. Keine hängenden Verbindungen, keine Zombie-Reader.

Statisches Asset-Serving (SPA-Fallback)

Der Worker liefert auch die Vite-gebaute SPA aus:

app.get("*", async (c) => {
  if (!c.env.ASSETS) return c.notFound();
  
  const res = await c.env.ASSETS.fetch(c.req.raw);
  
  // Vite versioniert JS/CSS — für immer cachen
  if (c.req.path.startsWith("/assets/")) {
    const headers = new Headers(res.headers);
    headers.set("Cache-Control", "public, max-age=31536000, immutable");
    return new Response(res.body, { status: res.status, headers });
  }
  
  // SPA-Fallback: index.html für jede Nicht-API, Nicht-Asset 404 ausliefern
  if (res.status === 404) {
    return c.env.ASSETS.fetch(new Request(new URL("/", c.req.url)));
  }
  
  return res;
});

Cache-Control immutable auf /assets/* sagt den Browsern: „Diese Datei wird sich nie ändern. Frag gar nicht erst nach." Da Vite Inhalts-Hashes zu Dateinamen hinzufügt (main-abc123.js), bedeutet ein neuer Build einen neuen Dateinamen. Alte Cache-Einträge werden automatisch irrelevant.

SPA-Fallback fängt React-Router-Pfade wie /planning-poker/room/abc123 ab und liefert index.html aus. Der clientseitige Router übernimmt dann die Navigation.

Adapter-Auto-Auswahl

Die SPA wählt automatisch den richtigen Adapter-Modus:

UmgebungModusUpstash-Zugriff
Deployed (Same-Origin Worker)proxyÜber Worker /upstash* — keine Credentials im Browser
Lokale Entwicklung (VITE_UPSTASH_* gesetzt)directBrowser → Upstash direkt mit lokalen Credentials
VITE_WORKER_URL gesetztproxyÜber explizite Worker-URL

In Produktion teilen sich SPA und Worker denselben Origin. Der Adapter erkennt das und sendet Requests an /upstash (relativer Pfad). Keine VITE_WORKER_URL nötig — es funktioniert einfach.

In der lokalen Entwicklung verbindest du dich direkt mit Upstash über die VITE_UPSTASH_*-Umgebungsvariablen. Gleicher Adapter-Code, anderer Modus.

Command-Limits

Jeder Raum hat ein progressives Schreiblimit, um Missbrauch zu verhindern:

SpielerSchreiblimit
150
2100
3500
4+1.000

Diese Limits leben im Adapter (nicht im Worker), weil der Worker kein Konzept von „Räumen" hat — er sieht nur verschlüsselte Blobs. Der Browser trackt cmdCount im Raum-State und erzwingt das Limit clientseitig.

Bei 90% des Limits erscheint ein Warnbanner. Bei 100% wird der Raum read-only und eine Toast-Benachrichtigung feuert:

⚠️ Command-Limit nähert sich (450 / 500)
🔒 Command-Limit erreicht — dieser Raum ist jetzt read-only.

Für eine normale Sprint-Planning-Session sind 1.000 Commands mehr als genug. Das sind ungefähr 100 Runden mit 10-Spieler-Schätzung mit Platz zum Atmen.

Die wrangler config

Das Deployment-Setup ist sauber:

{
  "name": "planning-poker",
  "main": "worker/src/index.ts",
  "compatibility_date": "2025-01-01",
  "compatibility_flags": ["nodejs_compat"],
  "assets": {
    "directory": "./dist",
    "not_found_handling": "single-page-application",
    "binding": "ASSETS"
  },
  "observability": { "enabled": true },
  "build": {
    "command": "npm install --prefix worker && pnpm build"
  }
}

Der build.command läuft automatisch vor wrangler deploy — er installiert die eigenen Abhängigkeiten des Workers und baut die Vite SPA. Ein Befehl deployt alles:

npx wrangler deploy

Was als Nächstes kommt

Wir haben einen Zero-Knowledge-Proxy gebaut, der zwischen deinem Browser und Upstash Redis sitzt. Er fügt Authentifizierung hinzu, streamt SSE, liefert Assets aus, rate-limitiert Clients und tut all das, ohne jemals ein einziges Klartext-Byte zu sehen.

In Teil 5 geht es um Tests, CI und Code-Qualität — wie Vitest, Testing Library und GitHub Actions das Ganze vor dem Auseinanderfallen bewahren.

Weiter voran und genieße jeden Schritt deiner Coding-Reise.