cf‑weather — A Serverless Dark‑Sky‑Inspired Weather App on Cloudflare Workers
Project Overview
cf‑weather is a serverless weather application that mimics the clean, minimalist experience of the now‑defunct Dark Sky app.
Running entirely on Cloudflare Workers, it serves a fast Astro‑based frontend, a lightweight Hono API, and stores data in Cloudflare KV and D1.
Why it matters:
- Zero‑ops hosting – no servers to provision or maintain.
- Edge‑first latency – requests are answered from the nearest Cloudflare data‑center.
- Developer‑friendly stack – TypeScript, Astro, Hono, and Zod make the codebase easy to extend.
Key Features
| Feature | What the user sees |
|---|---|
| Current Conditions | Large temperature, weather icon, and human‑readable summary. |
| Stats Bar | Wind, humidity, dew point, UV index (color‑coded), visibility, pressure. |
| Hourly Timeline | 24‑hour, color‑coded bar with temperature markers. |
| 7‑Day Forecast | Expandable rows showing precipitation, wind, UV, sunrise/sunset. |
| Weather Alerts | Collapsible NWS alerts for severe weather. |
| Historical Trends | 30‑day averages stored in Cloudflare D1 and visualised on demand. |
| Auto‑Geolocation | Detects the user’s location on load. |
| Response Caching | 10‑minute KV cache reduces API calls and speeds up responses. |
Architecture Overview
[Browser] ──► Astro frontend (static + hydration)
│
▼
Cloudflare Worker (Hono API)
│
┌────────────┴─────────────┐
│ │
▼ ▼
PirateWeather API Cloudflare KV (10‑min cache)
│
▼
Cloudflare D1 (SQLite) – historic data for trendsThe worker is the single entry point for all API calls; it fetches fresh data from PirateWeather, caches the result in KV for 10 minutes, and writes a copy to D1 for long‑term analytics.
How It Works
1. Frontend (Astro)
Astro renders a static shell and hydrates only the interactive parts (search bar, unit toggle).
The UI pulls data from the worker via the /api/weather/* endpoints.
---
// src/pages/index.astro
import WeatherCard from '../components/WeatherCard.astro';
const { lat, lon } = Astro.props; // injected by the client‑side script
---
<html lang="en">
<head>
<title>cf‑weather</title>
<link rel="icon" href="/favicon.svg" />
</head>
<body>
<WeatherCard lat={lat} lon={lon} />
</body>
</html>Note:
WeatherCardmakes afetch('/api/weather/forecast?...')request and renders the timeline, stats bar, and forecast rows.
2. API Layer (Hono + Zod)
All HTTP routes live in src/middleware/api.ts.
A typical “current weather” endpoint validates query parameters with Zod and then delegates to the weather service.
// src/middleware/api.ts
import { Hono } from 'hono';
import { z } from 'zod';
import { fetchWeatherData } from '../lib/weather-service';
const app = new Hono();
app.get('/api/weather/current', async (c) => {
const schema = z.object({
lat: z.string().regex(/^[-+]?\d+(\.\d+)?$/),
lon: z.string().regex(/^[-+]?\d+(\.\d+)?$/),
});
const { lat, lon } = schema.parse(c.req.query());
const data = await fetchWeatherData({ lat: Number(lat), lon: Number(lon) }, c.env);
return c.json(data);
});
export default app;3. Weather Service (PirateWeather + KV + D1)
The service abstracts the three data‑sources:
- KV cache – fast read/write of recent forecasts.
- PirateWeather API – authoritative source for current, hourly, and daily data.
- D1 – SQLite‑compatible store for historic entries used by the
/trendsendpoint.
// src/lib/weather-service.ts
import type { Env, Location, WeatherData } from '../types';
const CACHE_TTL = 600; // seconds
export async function fetchWeatherData(
location: Location,
env: Env
): Promise<WeatherData> {
const cacheKey = `forecast:${location.lat},${location.lon}`;
const cached = await env.WEATHER_CACHE.get(cacheKey, { type: 'json' });
if (cached) {
console.log('Cache hit:', cacheKey);
return cached as WeatherData;
}
console.log('Cache miss – fetching from PirateWeather');
const url = new URL(
`https://api.pirateweather.net/forecast/${env.PIRATE_WEATHER_API_KEY}/${location.lat},${location.lon}`
);
const resp = await fetch(url);
if (!resp.ok) throw new Error('Failed to fetch weather data');
const data = (await resp.json()) as WeatherData;
// Store in KV for quick reuse
await env.WEATHER_CACHE.put(cacheKey, JSON.stringify(data), {
expirationTtl: CACHE_TTL,
});
// Also persist a minimal record to D1 for trends (async, fire‑and‑forget)
const stmt = env.DB.prepare(
`INSERT INTO observations (lat, lon, timestamp, temperature) VALUES (?, ?, ?, ?)`
);
stmt.run(location.lat, location.lon, Date.now(), data.current.temp);
return data;
}4. Data Collector (Cron)
A scheduled job runs every six hours (0 */6 * * *) and invokes the same service to keep D1 populated.
// src/data-collector.ts
export default {
async fetch(request: Request, env: Env) {
const url = new URL(request.url);
if (url.pathname !== '/collect') return new Response('Not found', { status: 404 });
const lat = Number(url.searchParams.get('lat') ?? '40.7128');
const lon = Number(url.searchParams.get('lon') ?? '-74.0060');
const timezone = url.searchParams.get('timezone') ?? 'UTC';
console.log(`Collecting weather for ${lat},${lon} (${timezone})`);
await fetchWeatherData({ lat, lon }, env);
return new Response('OK', { status: 200 });
},
};The worker’s entry point (src/index.ts) routes /api/* to the Hono app and /collect to the collector.
// src/index.ts
import api from './middleware/api';
import type { Env } from './types';
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
const url = new URL(request.url);
if (url.pathname.startsWith('/api/')) {
return api.fetch(request, env, ctx);
}
if (url.pathname === '/collect') {
const collector = (await import('./data-collector')).default;
return collector.fetch(request, env);
}
// Fallback: serve the Astro built site
return env.ASTRO_ASSETS.fetch(request);
},
};5. Rate‑Limited Time Machine Endpoint (Recent Add)
The “time‑machine” route (/api/weather/timemachine) replays historic weather at a given Unix timestamp. It now includes a simple per‑IP rate limiter (10 requests per hour) to prevent abuse.
// src/middleware/api.ts (excerpt)
app.get('/api/weather/timemachine', async (c) => {
const schema = z.object({
lat: z.string(),
lon: z.string(),
timestamp: z.string().regex(/^\d+$/),
});
const { lat, lon, timestamp } = schema.parse(c.req.query());
// Simple in‑memory rate limit (illustrative; production uses KV)
const ip = c.req.header('CF-Connecting-IP') ?? 'unknown';
const limitKey = `rl:${ip}`;
const count = Number((await c.env.WEATHER_CACHE.get(limitKey)) ?? '0');
if (count >= 10) return c.text('Rate limit exceeded', 429);
await c.env.WEATHER_CACHE.put(limitKey, String(count + 1), {
expirationTtl: 3600,
});
const data = await fetchWeatherData({ lat: Number(lat), lon: Number(lon) }, c.env);
// Filter to the requested timestamp (simplified)
const historic = data.hourly.find((h) => h.time === Number(timestamp));
return c.json(historic ?? { error: 'No data for timestamp' });
});Getting Started
Clone the repo and install dependencies
BASHgit clone https://github.com/xxdesmus/cf-weather.git cd cf-weather npm installCreate Cloudflare resources
BASHnpx wrangler kv:namespace create "WEATHER_CACHE" npx wrangler d1 create "weather_history"Paste the returned IDs into
wrangler.jsonc.Run the schema migration
BASHnpx wrangler d1 execute weather_history --file=schema.sql --localAdd your PirateWeather API key
BASHnpx wrangler secret put PIRATE_WEATHER_API_KEYStart the local dev server
BASHnpx wrangler devOpen http://localhost:8787 to see the app.
Deploy
BASHnpm run deploy
Recent Developments
- Time‑Machine rate limiting – 10 requests / hour / IP (see the code snippet above).
CLAUDE.mdadded to give AI assistants clear guidance when working with the repository.- Mobile UI tweaks – improved layout for iPhone 12 Pro and other small‑screen devices.
- Weather favicon – a simple SVG combining sun and cloud for a nicer browser tab.
These updates polish the developer experience and protect the public API without changing the core user‑facing functionality.
Conclusion
cf‑weather demonstrates how a full‑stack, edge‑native application can be built with a minimal amount of code while delivering a responsive, Dark‑Sky‑like experience. By leveraging Cloudflare Workers, Astro, Hono, and PirateWeather, the project achieves:
- Sub‑second response times thanks to KV caching at the edge.
- Zero‑maintenance hosting – the entire stack runs on Cloudflare’s serverless platform.
- Extensible architecture – adding new data sources or UI components is straightforward.
Whether you’re exploring serverless patterns, learning Astro, or need a ready‑made weather front‑end, cf‑weather offers a clean, production‑grade reference implementation. Check out the source on GitHub and try the live demo at https://weather.unemployed.workers.dev
.