cf‑weather — A Serverless Dark‑Sky‑Inspired Weather App on Cloudflare Workers

30 Jan 2026 • 6 min read

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

FeatureWhat the user sees
Current ConditionsLarge temperature, weather icon, and human‑readable summary.
Stats BarWind, humidity, dew point, UV index (color‑coded), visibility, pressure.
Hourly Timeline24‑hour, color‑coded bar with temperature markers.
7‑Day ForecastExpandable rows showing precipitation, wind, UV, sunrise/sunset.
Weather AlertsCollapsible NWS alerts for severe weather.
Historical Trends30‑day averages stored in Cloudflare D1 and visualised on demand.
Auto‑GeolocationDetects the user’s location on load.
Response Caching10‑minute KV cache reduces API calls and speeds up responses.

Architecture Overview

TEXT
[Browser] ──► Astro frontend (static + hydration)
        Cloudflare Worker (Hono API)
   ┌────────────┴─────────────┐
   │                          │
   ▼                          ▼
PirateWeather API        Cloudflare KV (10‑min cache)
Cloudflare D1 (SQLite) – historic data for trends

The 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.

TSX
---
// 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>cfweather</title>
    <link rel="icon" href="/favicon.svg" />
  </head>
  <body>
    <WeatherCard lat={lat} lon={lon} />
  </body>
</html>

Note: WeatherCard makes a fetch('/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.

TS
// 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 /trends endpoint.
TS
// 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.

TS
// 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.

TS
// 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.

TS
// 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

  1. Clone the repo and install dependencies

    BASH
    git clone https://github.com/xxdesmus/cf-weather.git
    cd cf-weather
    npm install
  2. Create Cloudflare resources

    BASH
    npx wrangler kv:namespace create "WEATHER_CACHE"
    npx wrangler d1 create "weather_history"

    Paste the returned IDs into wrangler.jsonc.

  3. Run the schema migration

    BASH
    npx wrangler d1 execute weather_history --file=schema.sql --local
  4. Add your PirateWeather API key

    BASH
    npx wrangler secret put PIRATE_WEATHER_API_KEY
  5. Start the local dev server

    BASH
    npx wrangler dev

    Open http://localhost:8787 to see the app.

  6. Deploy

    BASH
    npm run deploy

Recent Developments

  • Time‑Machine rate limiting – 10 requests / hour / IP (see the code snippet above).
  • CLAUDE.md added 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 .

Start searching

Enter keywords to search articles.