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:
| Layer | Technology |
|---|---|
| Frontend | Astro (static generation) + vanilla CSS |
| API | Hono (tiny router) running inside a Cloudflare Worker |
| Data collector | Worker‑only script that pulls data from PirateWeather and stores it in KV (for quick look‑ups) and D1 (for historic trend analysis) |
| Rate limiting | Simple per‑IP counter stored in KV (“Time Machine” feature) |
| Configuration | Typescript 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