|

The Cloudflare Worker — A Thin Proxy That Sees Nothing (Part 4/6)

In Parts 2 and 3, I kept saying “the server just shuffles encrypted bytes.” Time to prove it. Let’s build the Cloudflare Worker — the thinnest possible backend that adds authentication headers, streams SSE, serves static assets, and does absolutely nothing else.

The entire Worker is 145 lines. Let’s walk through every one.

Why Cloudflare Workers?

Three reasons:

  1. Edge-first. The Worker runs in 300+ data centers worldwide. Your teammate in Berlin and your teammate in São Paulo both get sub-50ms latency to the nearest Worker.
  2. Zero cold starts (for Workers, not Pages Functions). The V8 isolate model means your code is always warm.
  3. Free tier is generous. 100,000 requests/day on the free plan. For a planning poker app, that’s a LOT of sprint plannings.

And because we’re already using Upstash Redis (which also runs on the edge), the entire request path — browser → Worker → Upstash — stays fast everywhere.

Architecture: what the Worker actually does

Browser (SPA)
  ├── POST  /upstash           → Worker adds Authorization → Upstash REST
  ├── POST  /upstash/pipeline  → Worker adds Authorization → Upstash pipeline
  └── GET   /upstash/subscribe/:channel
              → Worker: handleSSE() [bypasses Hono, uses TransformStream]
              → Upstash SSE pub/sub stream → browser

That’s it. Three routes. The Worker:

  • Receives a request from the browser (encrypted body)
  • Adds Authorization: Bearer <token> to the headers
  • Forwards it to Upstash
  • Sends the response back

It never decrypts anything. It never parses the body. It has no idea what a “room” or a “vote” is.

Setting up Hono

Hono is a tiny web framework built for edge runtimes. Think Express, but weighing 14KB and designed for Cloudflare Workers from the ground up:

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"],
}));

The Env interface is where Cloudflare bindings live. UPSTASH_URL and UPSTASH_TOKEN are secrets (set via wrangler secret put). ASSETS is the static asset binding that serves the Vite-built SPA.

The auth proxy route

This is the core of the Worker — and it’s almost comically simple:

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,
  });
});

A few things going on:

Route matching: /upstash* — Note the missing slash. This intentionally matches both /upstash (single commands) and /upstash/pipeline (batched writes). One handler, two endpoints.

Path rewriting: Strip the /upstash prefix, glue the rest onto the Upstash REST URL. /upstash/pipeline becomes https://your-db.upstash.io/pipeline.

Body passthrough: The Worker doesn’t parse, validate, or transform the request body. It’s an opaque blob of encrypted JSON. The Worker just adds auth and forwards.

The Upstash credentials (UPSTASH_URL, UPSTASH_TOKEN) never reach the browser. In production, the SPA doesn’t have any VITE_UPSTASH_* environment variables. It sends requests to /upstash (same-origin), and the Worker injects the auth header server-side.

Rate limiting

Even though this is a free tool, I don’t want one hyperactive client hammering Upstash:

const HARD_LIMIT = 120; // requests per minute per 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 per minute per IP. That’s roughly 2 per second, which is more than enough for even a frantic estimation session. The rate limit is in-memory (per Worker isolate), so it won’t survive Worker restarts — but it doesn’t need to be bulletproof. It’s a speed bump, not a fortress.

The CF-Connecting-IP header is Cloudflare-injected and can’t be spoofed by the client. Good enough for this use case.

SSE streaming — the tricky part

This is where it gets interesting. SSE (Server-Sent Events) is a long-lived HTTP response where the server keeps writing data: lines. The browser reads them in real time.

The catch? Hono’s middleware breaks SSE. If you return a streaming response from a Hono route, Hono’s CORS middleware (and any other middleware) tries to touch the response headers or body, which can close the stream prematurely.

The solution: handle SSE before Hono even sees the request.

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);  // bypass Hono entirely
    }
    return app.fetch(req, env, ctx);
  },
} satisfies ExportedHandler<Env>;

The module-level fetch handler intercepts SSE requests and routes them to handleSSE(). Everything else goes through Hono. Clean separation.

The handleSSE function itself uses a TransformStream pipe:

async function handleSSE(req: Request, env: Env): Promise<Response> {
  const ip = req.headers.get("CF-Connecting-IP") ?? "unknown";
  if (isRateLimited(ip)) {
    return new Response(JSON.stringify({ error: "Too many requests" }), { status: 429 });
  }

  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": "*",
    },
  });
}

Why TransformStream instead of just returning upstream? Because returning the upstream fetch() directly doesn’t work reliably in Cloudflare Workers for long-lived streams. The TransformStream pattern gives the runtime something stable to hold onto.

The pump() function reads chunks from Upstash and writes them to the client. If either side dies, the stream closes cleanly. No hanging connections, no zombie readers.

Static asset serving (SPA fallback)

The Worker also serves the Vite-built SPA:

app.get("*", async (c) => {
  if (!c.env.ASSETS) return c.notFound();
  
  const res = await c.env.ASSETS.fetch(c.req.raw);
  
  // Vite fingerprints JS/CSS — cache them forever
  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: serve index.html for any non-API, non-asset 404
  if (res.status === 404) {
    return c.env.ASSETS.fetch(new Request(new URL("/", c.req.url)));
  }
  
  return res;
});

Cache-Control immutable on /assets/* tells browsers: “This file will never change. Don’t even bother checking.” Since Vite adds content hashes to filenames (main-abc123.js), a new build means a new filename. Old cache entries become irrelevant automatically.

SPA fallback catches React Router paths like /planning-poker/room/abc123 and serves index.html. The client-side router then handles the navigation.

Adapter auto-selection

The SPA automatically picks the right adapter mode:

EnvironmentModeUpstash access
Deployed (same-origin Worker)proxyVia Worker /upstash* — no credentials in browser
Local dev (VITE_UPSTASH_* set)directBrowser → Upstash directly with local credentials
VITE_WORKER_URL setproxyVia explicit Worker URL

In production, the SPA and the Worker share the same origin. The adapter detects this and sends requests to /upstash (relative path). No VITE_WORKER_URL needed — it just works.

In local development, you point directly at Upstash with the VITE_UPSTASH_* environment variables. Same adapter code, different mode.

Command limits

Each room has a progressive write cap to prevent abuse:

PlayersWrite cap
150
2100
3500
4+1,000

These limits live in the adapter (not the Worker) because the Worker has no concept of “rooms” — it only sees encrypted blobs. The browser tracks cmdCount in the room state and enforces the limit client-side.

At 90% of the cap, a warning banner appears. At 100%, the room goes read-only and a toast notification fires:

⚠️ Approaching command limit (450 / 500)
🔒 Command limit reached — this room is now read-only.

For a normal sprint planning session, 1,000 commands is plenty. That’s roughly 100 rounds of 10-player estimation with room to spare.

The wrangler config

The deployment setup is clean:

{
  "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"
  }
}

The build.command runs automatically before wrangler deploy — it installs the Worker’s own dependencies and builds the Vite SPA. One command deploys everything:

npx wrangler deploy

What’s next

We’ve built a zero-knowledge proxy that sits between your browser and Upstash Redis. It adds authentication, streams SSE, serves assets, rate-limits clients, and does all of this without ever seeing a single plaintext byte.

In Part 5, we’ll cover testing, CI, and code quality — how Vitest, Testing Library, and GitHub Actions keep the whole thing from falling apart.

Keep pushing forward and savor every step of your coding journey.