Introduction

In this post I’ll walk you through the complete source code of a server‑less weather application that runs entirely on Cloudflare’s edge network.
The stack is intentionally lightweight:

LayerTechnology
FrontendAstro (static generation) + vanilla CSS
APIHono (tiny router) running inside a Cloudflare Worker
Data collectorWorker‑only script that pulls data from PirateWeather and stores it in KV (for quick look‑ups) and D1 (for historic trend analysis)
Rate limitingSimple per‑IP counter stored in KV (“Time Machine” feature)
ConfigurationTypescript typings, environment‑variable helpers, and a CLAUDE.md file that guides Claude Code when it edits the repo

All of the code snippets below come directly from the repository diffs, so you can copy‑paste them into a fresh Cloudflare Workers project and have a working app in minutes.


System diagram

flowchart LR
    subgraph Edge[Cloudflare Edge]
        direction TB
        Worker[Worker (src/index.ts)]
        KV[KV Namespace] 
        D1[SQLite D1 Database]
        Pirate[PirateWeather API]
    end

    subgraph Frontend[Astro Frontend (src/pages/index.astro)]
        UI[User Interface]
    end

    UI -->|GET /| Worker
    Worker -->|Routes| Hono[Hono API (src/middleware/api.ts)]
    Hono -->|GET /api/weather| WeatherService[src/lib/weather-service.ts]
    WeatherService -->|Cache miss| Pirate
    WeatherService -->|Cache hit| KV
    WeatherService -->|Store historic| D1
    Worker -->|POST /collect| Collector[src/data-collector.ts]
    Collector -->|Fetch & store| WeatherService
    Collector -->|Rate‑limit (Time Machine)| KV

The diagram mirrors the actual file structure: the entry point (src/index.ts) delegates to Hono for all /api/* routes, while a separate /collect endpoint triggers the data‑collector script. Caching lives in KV, historic data lives in D1, and rate‑limiting uses a lightweight KV counter.


1️⃣ Project layout

src/
 ├─ env.d.ts                # TypeScript reference for Astro’s generated types
 ├─ index.ts                # Cloudflare Worker entry point
 ├─ lib/
 │   └─ weather-service.ts   # Fetches + caches weather data
 ├─ middleware/
 │   ├─ api.ts               # Hono router + rate‑limit logic
 │   └─ index.ts             # Middleware wrapper for Astro
 ├─ data-collector.ts       # Cron‑like collector (triggered via /collect)
 ├─ pages/
 │   └─ index.astro          # Astro page (Dark‑Sky‑inspired UI)
 ├─ styles/
 │   └─ global.css           # Tiny reset – Tailwind stripped out
 └─ types.ts                 # Shared TypeScript types

2️⃣ Types – src/types.ts

// src/types.ts
export interface Location {
  lat: number;
  lon: number;
  /** IANA timezone, e.g. "America/New_York". Optional – defaults to UTC. */
  timezone?: string;
  /** Unit system chosen by the client (default is auto‑detected). */
  units?: 'us' | 'metric';
}

/** Shape of the raw response we store in KV/D1 */
export interface WeatherData {
  current: {
    temperature: number;
    condition: string;
    icon: string;
    timestamp: number;
  };
  hourly: Array<{
    temperature: number;
    condition: string;
    icon: string;
    timestamp: number;
  }>;
  daily: Array<{
    temperatureHigh: number;
    temperatureLow: number;
    condition: string;
    icon: string;
    timestamp: number;
  }>;
}

/** Minimal timeline data used by the UI */
export interface TimelineData {
  time: number;      // Unix epoch seconds
  color: string;     // Background colour for the bar
  text: string;      // Human‑readable label (e.g. "Clear", "Rain")
}

/** Environment bindings injected by Cloudflare Workers */
export interface Env {
  /** KV namespace for caching weather payloads */
  WEATHER_KV: KVNamespace;
  /** D1 database for historic trend data */
  WEATHER_DB: D1Database;
  /** PirateWeather API key */
  PIRATE_WEATHER_API_KEY: string;
}

The added units field (introduced in commit 6fdcbe1) lets the client request either imperial (us) or metric data. The UI auto‑detects the user’s locale and passes the appropriate value.


3️⃣ Global CSS – src/styles/global.css

The original Tailwind setup was stripped out to keep the bundle tiny. All we need is a simple reset:

/* src/styles/global.css */
html, body {
  margin: 0;
  padding: 0;
  font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
  background: #0e0e0e;
  color: #e0e0e0;
}

Why the change?
The UI now relies on a handful of handcrafted CSS rules (see the Astro page) rather than the full Tailwind runtime, which reduces the worker’s bundle size well below Cloudflare’s 1 MB limit.


4️⃣ Weather Service – src/lib/weather-service.ts

This module does the heavy lifting: it checks KV first, falls back to the PirateWeather API, writes the fresh payload back to KV (TTL = 10 min), and stores a copy in D1 for later trend analysis.

// src/lib/weather-service.ts
import type { Env, Location, WeatherData } from '../types';

const CACHE_TTL = 600; // seconds (10 min)
const PIRATE_WEATHER_API_URL = 'https://api.pirateweather.net';

export async function fetchWeatherData(
  location: Location,
  env: Env
): Promise<WeatherData> {
  const cacheKey = `${location.lat},${location.lon},${location.units ?? 'auto'}`;

  // 1️⃣ Try KV cache first
  const cached = await env.WEATHER_KV.get<WeatherData>(cacheKey, 'json');
  if (cached) {
    console.log('Cache hit for', cacheKey);
    return cached;
  }

  // 2️⃣ Cache miss – request PirateWeather
  console.log('Cache miss, fetching from PirateWeather API for location:', cacheKey);
  const url = new URL(`${PIRATE_WEATHER_API_URL}/forecast/${env