A non-coder's guide to understanding this weather API from the ground up
- What This Project Does
- The Evolution: Every Commit Explained
- File Structure: What Each File Does
- The Code: Section by Section
- How It All Connects
- Deployment & Infrastructure
San Francisco has the most dramatic "microclimates" of any US city. This means the weather can be completely different in neighborhoods just a few miles apart. When a weather app says "San Francisco: 58°F," that's essentially useless because:
- The Outer Sunset might be 52°F and foggy
- The Mission District might be 65°F and sunny
- These neighborhoods are only 3 miles apart!
Weather apps typically use a single reading (often from SFO airport) and call it "San Francisco weather." That doesn't match what people actually experience.
This project creates an API (a way for computer programs to request data) that:
- Gathers data from 400+ PurpleAir sensors across the city (these are small air quality monitors that also measure temperature and humidity)
- Organizes the sensors by 49 different SF neighborhoods
- Calculates the average temperature and humidity for each neighborhood
- Returns this data to anyone who asks, free of charge, no signup required
Think of an API like a restaurant menu. You (the customer/program) look at the menu and place an order. The kitchen (the server) prepares what you asked for and brings it back. You don't need to know how the kitchen works—you just ask for what you want and get it.
In this case:
- You ask: "What's the weather in the Mission?"
- API answers: "It's 68°F with 45% humidity, based on 29 sensors"
A "commit" is like saving a snapshot of your project. It's a checkpoint that records what changed and why. Here's the complete history of this project, from first to last:
What was created:
- The entire working API from scratch
- Support for 50 SF neighborhoods
- A nice-looking website that explains how to use the API
- Documentation (README)
- All the configuration needed to run on Cloudflare
In plain English: This was launch day. Julian built the whole thing and made it live at microclimates.solofounders.com.
What changed: Deleted README-styled.html, README.html, and README.pdf
Why: These were probably files Julian used to preview how the documentation would look. Once the project was published, they weren't needed anymore—just clutter.
What changed: A tiny CSS fix in the website code
Why: The link to GitHub wasn't showing up properly on phones. Small visual bug fix.
What changed: Made the GitHub link text white instead of another color
Why: On the dark green header, the link was hard to read. Making it white fixed the contrast.
What changed: Removed 446 lines of website code, keeping only 104 lines of essential API code
Why: Originally, the code included a fancy marketing website with lots of styling. Julian decided to keep the API simple and move the website elsewhere (or simplify it). The landing page was stripped down to just the basics.
What changed: Updated the instructions on how to add this to Claude/Clawdbot
Why: Made the copy/paste instructions clearer so users know exactly what to do.
What changed: Made the README match the website instructions
Why: Consistency—the README (on GitHub) should say the same thing as the website.
Who: Dhravya Shah (a contributor, not Julian)
What changed:
- Switched from a custom rate limiter to Cloudflare's built-in one
- Changed configuration from
wrangler.tomltowrangler.jsonc(a more modern format) - Added proper TypeScript type definitions
Why: The original rate limiter was custom-built. Cloudflare provides a native (built-in) rate limiter that's better maintained and more reliable. Using the platform's built-in tools is usually smarter than reinventing the wheel.
What's a rate limiter? It prevents any single user from overwhelming the server with too many requests. Like a bouncer that says "slow down, buddy" if someone's hitting the API 1000 times per second.
What changed: Added smart fallback when a neighborhood has no sensors
Problem solved: Some SF neighborhoods (like Sea Cliff or Lands End) might not have any PurpleAir sensors installed. Previously, you'd just get empty data.
The fix: Now, if you ask for weather in a neighborhood with no sensors, the API finds the nearest neighborhood that does have sensors and uses that data. It also tells you honestly: "We don't have sensors here, but the closest neighborhood with data is X."
Example: Ask for Sea Cliff weather → API says "No sensors here, but here's data from Outer Richmond (the nearest neighborhood with sensors)."
What changed: Added smart filtering for bad sensor data
Problem solved: Sometimes a single sensor goes haywire. Maybe it's in direct sunlight, near an oven, or just malfunctioning. One sensor reporting 95°F when every neighbor says 65°F would throw off the average.
The fix: The API now:
- Looks at each neighborhood's temperature
- Compares it to the 3 nearest neighborhoods
- If the reading is more than 10°F different AND the neighborhood only has 1-2 sensors, it's flagged as suspicious
- The suspicious reading is replaced with the neighbor average
- The API tells you this happened (transparency)
Example: Potrero Hill shows 85°F from 1 sensor, but Mission, SoMa, and Dogpatch all show ~65°F. The API says "That 85°F reading looked wrong, here's the corrected 65°F based on neighbors."
Here's every file in the project and what it's for:
What it is: The project's "ID card"
What it contains:
- Project name (
sf-weather-proxy) - Version (
1.0.0) - Scripts (commands you can run):
npm run dev→ test locallynpm run deploy→ publish to the internetnpm run tail→ watch live logs
- Dependencies (other software this project needs)
Analogy: Like the ingredients list and cooking instructions on a recipe card.
What it is: Cloudflare-specific configuration
What it contains:
- Project name for Cloudflare:
sf-microclimates - Which file runs the code:
src/index.ts - The web address it responds to:
microclimates.solofounders.com - Cache settings (save data for 1 hour = 3600 seconds)
- Rate limiter settings (max 100 requests per minute per user)
- Cache storage ID (where cached data lives on Cloudflare)
Analogy: Like giving a mail carrier specific instructions: "Deliver this package to this address, use this locker for storage, and don't accept more than 100 letters per minute from the same person."
What it is: TypeScript compiler settings
What it tells TypeScript:
- Use modern JavaScript (ES2021)
- Use Cloudflare's special features
- Be strict about catching errors
- Only look at files in the
src/folder
Analogy: Like settings on a camera—you're telling it how to process the picture.
What it is: A list of files Git should ignore
What it ignores:
node_modules/(downloaded libraries—too big).wrangler/(temporary Cloudflare files).dev.vars(secret API keys—never share these!)*.log(log files).DS_Store(Mac junk files)
Why: Some files are either sensitive (secrets), temporary (logs), or huge (libraries). No need to track them in version control.
What it is: Legal permission to use this code
What it says: MIT License = "Do whatever you want with this code, just include this copyright notice. We're not responsible if something breaks."
Why it matters: Without a license, code is technically "all rights reserved" by default. The MIT license explicitly says "this is free to use."
What it is: The project's instruction manual
What it contains:
- What the project does
- How to use it
- All available API endpoints
- How to set up your own copy
- Credits
Why it exists: When someone finds this project on GitHub, this is the first thing they see. It's the front door welcome mat.
What it is: THE code. Everything that makes the API work.
Why one file? This project is small enough that putting everything in one file keeps it simple. Larger projects split code across many files.
(Full breakdown in the next section)
What it is: A fancier version of the homepage
What it contains: A beautifully designed webpage with:
- Nice typography (Instrument Serif + Inter fonts)
- A forest green and cream color scheme
- Solo Founders branding
- Instructions for using the API
- Copy/paste instructions for adding to Claude/Clawdbot
Note: The current code uses a simpler embedded HTML string instead of this file. This file was created earlier but may not be actively used now.
What it is: Auto-generated type definitions
What it does: Tells TypeScript exactly what Cloudflare features are available. This is generated by running wrangler types—you don't edit it manually.
Analogy: Like an auto-generated dictionary of all the words (features) you're allowed to use.
What it is: Exact versions of all dependencies
Why it exists: When you install libraries, their versions can change. This "lock file" records exactly which version was installed, so anyone else installing this project gets identical copies.
Let's walk through src/index.ts piece by piece.
/**
* SF Microclimates API - Cloudflare Worker
* Real-time temperature & humidity data for San Francisco neighborhoods
* Powered by PurpleAir sensor data
*/Just a description. Good practice to explain what a file does at the top.
What are types? They're like forms or templates that describe what data looks like.
export interface Env {
PURPLEAIR_API_KEY: string;
CACHE: KVNamespace;
CACHE_TTL_SECONDS: string;
RATE_LIMITER: RateLimit;
}This describes what "environment variables" the API expects:
PURPLEAIR_API_KEY: A secret password to access PurpleAir dataCACHE: A storage space on Cloudflare for saving dataCACHE_TTL_SECONDS: How long to keep cached data (TTL = Time To Live)RATE_LIMITER: The bouncer that limits requests
interface PurpleAirSensor {
sensor_index: number;
latitude: number;
longitude: number;
temperature?: number;
humidity?: number;
}This describes what data comes from each PurpleAir sensor:
sensor_index: A unique ID numberlatitude/longitude: GPS coordinatestemperature: The reading (optional—might be missing)humidity: The moisture reading (also optional)
interface NeighborhoodData {
temp_f: number | null;
humidity: number | null;
sensor_count: number;
}This describes the calculated weather for each neighborhood:
temp_f: Temperature in Fahrenheit (or null if no data)humidity: Humidity percentage (or null)sensor_count: How many sensors provided this data
const LANDING_HTML = `<!DOCTYPE html>...`;This is the simple webpage shown when you visit the root URL. It's written directly in the code as a string (text). It includes:
- A blue hero banner with the title
- A quick example of how to use the API
- A list of endpoints
- Information about neighborhoods
- Links to PurpleAir and GitHub
const SF_NEIGHBORHOODS: Record<string, { name: string; bounds: {...} }> = {
financial_district: {
name: "Financial District",
bounds: { nwLat: 37.7960, nwLng: -122.4050, seLat: 37.7880, seLng: -122.3920 }
},
// ... 48 more neighborhoods
};This is the heart of the geography. Each neighborhood is defined by:
- A key (like
mission,castro,marina) - A display name (like "Mission District", "Castro")
- A bounding box (a rectangle on the map)
What's a bounding box? Imagine drawing a rectangle around a neighborhood on a map:
nwLat/nwLng= Northwest corner (top-left)seLat/seLng= Southeast corner (bottom-right)
Any sensor whose GPS coordinates fall inside this rectangle is considered part of that neighborhood.
All 49 neighborhoods include:
- Financial District, Chinatown, Union Square, Tenderloin, Civic Center
- Embarcadero, Rincon Hill, South Beach, North Beach, Telegraph Hill
- Russian Hill, Nob Hill, Marina, Pacific Heights, Presidio
- Sea Cliff, Lands End, Inner/Outer Richmond, Inner/Outer Sunset
- Parkside, Haight-Ashbury, Lower Haight, Hayes Valley, Cole Valley
- Castro, Noe Valley, Mission District, SoMa, Mission Bay
- Twin Peaks, Diamond Heights, Glen Park, Forest Hill, West Portal
- St. Francis Wood, Bernal Heights, Potrero Hill, Dogpatch
- Bayview, Hunters Point, Excelsior, Visitacion Valley
- Crocker Amazon, Ingleside, Oceanview, Merced Heights, Lakeside, Stonestown
const SF_BOUNDS = {
nwLat: 37.8120,
nwLng: -122.5200,
seLat: 37.7080,
seLng: -122.3550
};This is the "big box" around all of San Francisco. When fetching sensors from PurpleAir, we only ask for sensors inside this area—no need to get data from Oakland or Daly City.
function isInBounds(lat, lng, bounds): boolean {
return lat <= bounds.nwLat && lat >= bounds.seLat &&
lng >= bounds.nwLng && lng <= bounds.seLng;
}Given GPS coordinates and a bounding box, this returns true if the point is inside, false if outside.
function getNeighborhoodCenter(bounds) {
return {
lat: (bounds.nwLat + bounds.seLat) / 2,
lng: (bounds.nwLng + bounds.seLng) / 2
};
}Averages the corners to find the middle point. Used for measuring distances between neighborhoods.
function getDistance(lat1, lng1, lat2, lng2): number {
return Math.sqrt(Math.pow(lat2 - lat1, 2) + Math.pow(lng2 - lng1, 2));
}This is the Pythagorean theorem (a² + b² = c²) to calculate straight-line distance. It's a simplification that works fine for small areas like a city.
function findNearestNeighborhood(targetKey, availableKeys): string | null {
// Find the center of the target neighborhood
// Loop through all neighborhoods with data
// Calculate distance to each
// Return the closest one
}Used for the fallback feature: if Sea Cliff has no sensors, this finds the nearest neighborhood that does (probably Outer Richmond).
function findNearestNeighborhoods(targetKey, availableKeys, count): string[] {
// Similar to above, but returns multiple neighbors
// Sorted by distance
}Used for outlier detection: to check if a reading makes sense, compare it to the 3 nearest neighborhoods.
const OUTLIER_THRESHOLD_F = 10; // Flag if >10°F different
const OUTLIER_MIN_NEIGHBORS = 3; // Need at least 3 to compare
function detectAndCorrectOutliers(neighborhoods) {
// For each neighborhood with data:
// 1. Find the 3 nearest neighbors
// 2. Calculate their average temperature
// 3. If this neighborhood is >10°F different AND has ≤2 sensors:
// - Flag it as an outlier
// - Replace with the neighbor average
// - Include metadata explaining what happened
return correctedData;
}This is data quality control. A single broken sensor shouldn't ruin the whole neighborhood's reading.
Example metadata when an outlier is corrected:
{
"outlier_corrected": {
"original_temp_f": 95,
"neighbor_avg_f": 65,
"diff_f": 30,
"reason": "Single sensor reading 95°F was 30°F off from neighbors (avg 65°F)"
}
}function assignToNeighborhood(lat, lng): string | null {
for (const [key, neighborhood] of Object.entries(SF_NEIGHBORHOODS)) {
if (isInBounds(lat, lng, neighborhood.bounds)) {
return key;
}
}
return null; // Sensor is in SF but not in any defined neighborhood
}Given a sensor's GPS coordinates, this figures out which neighborhood it belongs to by checking each bounding box.
async function fetchPurpleAirData(apiKey): Promise<PurpleAirSensor[]> {
const fields = "sensor_index,latitude,longitude,temperature,humidity";
const url = `https://api.purpleair.com/v1/sensors?fields=${fields}&location_type=0&nwlat=...`;
const response = await fetch(url, { headers: { "X-API-Key": apiKey } });
const data = await response.json();
// Transform the raw array data into nice objects
return sensors;
}This reaches out to PurpleAir's API and asks: "Give me all outdoor sensors (location_type=0) in San Francisco, and tell me their GPS, temperature, and humidity."
Note about location_type=0: PurpleAir sensors can be indoor (1) or outdoor (0). We only want outdoor sensors for weather data.
function aggregateByNeighborhood(sensors): Record<string, NeighborhoodData> {
// Create empty buckets for each neighborhood
const neighborhoodSensors = { mission: { temps: [], humidities: [] }, ... };
// Put each sensor in its neighborhood's bucket
for (const sensor of sensors) {
const neighborhood = assignToNeighborhood(sensor.latitude, sensor.longitude);
if (neighborhood) {
neighborhoodSensors[neighborhood].temps.push(sensor.temperature);
neighborhoodSensors[neighborhood].humidities.push(sensor.humidity);
}
}
// Calculate averages for each neighborhood
const TEMP_CORRECTION_F = 8; // PurpleAir sensors run hot
for each neighborhood:
avgTemp = sum(temps) / count - 8°F // Correction factor
avgHumidity = sum(humidities) / count
return results;
}Important: There's an 8°F correction factor! PurpleAir sensors are inside plastic enclosures that heat up, so they consistently read about 8°F warmer than actual air temperature. This correction brings them in line with official readings.
async function getWeatherData(env): Promise<WeatherResponse> {
const cacheKey = "sf-weather-data";
const cacheTtl = 3600; // 1 hour
// Step 1: Check if we have cached data
const cached = await env.CACHE.get(cacheKey);
if (cached) return JSON.parse(cached); // Cache hit! Return immediately.
// Step 2: No cache, fetch fresh data
const sensors = await fetchPurpleAirData(env.PURPLEAIR_API_KEY);
const rawNeighborhoods = aggregateByNeighborhood(sensors);
// Step 3: Clean up outliers
const neighborhoods = detectAndCorrectOutliers(rawNeighborhoods);
// Step 4: Save to cache for next time
await env.CACHE.put(cacheKey, JSON.stringify(response), { expirationTtl: cacheTtl });
return response;
}Why cache?
- PurpleAir limits API calls
- Users don't need second-by-second updates (weather doesn't change that fast)
- Caching makes responses faster
- 1-hour cache is a good balance
function jsonResponse(data, status = 200): Response {
return new Response(JSON.stringify(data, null, 2), {
status,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*", // Allow any website to use this
"Cache-Control": "public, max-age=300" // Browsers can cache for 5 min
}
});
}
function errorResponse(message, status = 500): Response {
return jsonResponse({ error: message }, status);
}These create properly formatted JSON responses with the right headers.
What's CORS (Access-Control-Allow-Origin: *)? Browsers normally block websites from fetching data from different domains for security. This header says "it's okay, anyone can access this API from any website."
This is where everything comes together—the "front door" that handles incoming requests:
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const path = url.pathname;
// STEP 1: Rate Limiting
const clientIP = request.headers.get("CF-Connecting-IP");
const { success } = await env.RATE_LIMITER.limit({ key: clientIP });
if (!success) {
return errorResponse("Rate limit exceeded. Try again in a minute.", 429);
}
// STEP 2: Handle CORS preflight
if (request.method === "OPTIONS") {
return new Response(null, { headers: corsHeaders });
}
// STEP 3: Only accept GET requests
if (request.method !== "GET") {
return errorResponse("Method not allowed", 405);
}
// STEP 4: Route to the right handler based on URL path
try {
if (path === "/sf-weather" || path === "/sf-weather/") {
// Return all neighborhoods
return jsonResponse(await getWeatherData(env));
}
if (path.match(/^\/sf-weather\/([a-z_]+)$/)) {
// Return single neighborhood (with fallback if needed)
const neighborhoodKey = path.split('/')[2];
// ... handle fallback logic ...
return jsonResponse(neighborhoodData);
}
if (path === "/neighborhoods") {
// Return list of all neighborhood names
return jsonResponse({ neighborhoods: [...] });
}
if (path === "/") {
// Return the landing page HTML
return new Response(LANDING_HTML, { headers: { "Content-Type": "text/html" } });
}
// Nothing matched
return errorResponse("Not found", 404);
} catch (error) {
return errorResponse(error.message, 500);
}
}
};Let's trace what happens when someone types:
curl https://microclimates.solofounders.com/sf-weather/mission
1. Request arrives at Cloudflare
- Cloudflare's global network receives the request
- It routes to the nearest data center
2. Worker wakes up
- The code in
src/index.tsruns - The
fetch()function is called with the request
3. Rate limit check
- Gets the user's IP address from headers
- Asks the rate limiter: "Is this IP okay?"
- If they've made >100 requests this minute: "Rate limit exceeded" (429)
- Otherwise: proceed
4. Parse the URL
- Path is
/sf-weather/mission - Matches the neighborhood pattern
- Extract
missionas the neighborhood key
5. Get weather data
- Check Cloudflare KV cache for
sf-weather-data - Cache hit: Return cached data immediately (fast!)
- Cache miss:
- Call PurpleAir API with the API key
- Get ~400 sensors in SF
- Assign each sensor to a neighborhood by GPS
- Calculate averages (with 8°F correction)
- Run outlier detection
- Save to cache for 1 hour
- Return data
6. Extract Mission data
- Find
missionin the neighborhoods object - Check sensor count:
- If
sensor_count > 0: Use Mission's data - If
sensor_count = 0: Find nearest neighborhood with data, use that (with fallback notice)
- If
7. Format response
{
"updated": "2026-01-26T08:00:00.000Z",
"neighborhood": "mission",
"name": "Mission District",
"temp_f": 68,
"humidity": 45,
"sensor_count": 29
}8. Return to user
- Headers: JSON content type, CORS enabled, 5-minute browser cache
- Body: The formatted JSON
- Cloudflare delivers to user
Total time: Usually under 100ms (mostly the cache lookup)
Cloudflare KV is like a global storage locker:
- Write: After fetching from PurpleAir, data is saved with a 1-hour expiration
- Read: Every request checks KV first—99% of requests hit cache
- Expire: After 1 hour, cached data is deleted automatically
- Next request: Cache miss triggers a fresh PurpleAir fetch
Why this matters:
- PurpleAir has API rate limits—can't call it 1000x/second
- Most users don't need second-by-second updates
- Cache makes responses lightning fast (Cloudflare KV is distributed globally)
What is a Worker?
- Code that runs on Cloudflare's edge network (data centers worldwide)
- No servers to manage—just upload code, Cloudflare handles the rest
- Scales automatically—whether you get 10 requests or 10 million
- Runs close to users (low latency)
- Install dependencies:
npm install - Set up secrets:
wrangler secret put PURPLEAIR_API_KEY - Deploy:
npm run deploy(orwrangler deploy) - Done—live at the configured URL
| Component | What It Does |
|---|---|
| TypeScript | The programming language (JavaScript with types) |
| Cloudflare Workers | Runs the code globally |
| Cloudflare KV | Stores cached weather data |
| Cloudflare Rate Limiting | Prevents abuse |
| PurpleAir API | Source of sensor data |
| Wrangler | Tool for deploying to Cloudflare |
- Cloudflare Workers: Free tier = 100,000 requests/day
- Cloudflare KV: Free tier = 100,000 reads/day
- PurpleAir API: Free for personal use
- Total: $0 for most use cases
| Endpoint | Returns |
|---|---|
GET / |
Landing page (HTML) |
GET /sf-weather |
All 49 neighborhoods with weather data |
GET /sf-weather/mission |
Single neighborhood (replace mission with any valid key) |
GET /neighborhoods |
List of all neighborhood keys and names |
financial_district, chinatown, union_square, tenderloin, civic_center, embarcadero, rincon_hill, south_beach, north_beach, telegraph_hill, russian_hill, nob_hill, marina, pacific_heights, presidio, sea_cliff, lands_end, inner_richmond, outer_richmond, inner_sunset, outer_sunset, parkside, haight, lower_haight, hayes_valley, cole_valley, castro, noe_valley, mission, soma, mission_bay, twin_peaks, diamond_heights, glen_park, forest_hill, west_portal, st_francis_wood, bernal_heights, potrero_hill, dogpatch, bayview, hunters_point, excelsior, visitacion_valley, crocker_amazon, ingleside, oceanview, merced_heights, lakeside, stonestown
This project is a well-crafted, focused API that solves a real problem: getting accurate, hyperlocal weather for San Francisco. It demonstrates:
- Good architecture: Simple, single-file design that's easy to understand
- Data quality: Outlier detection and fallback logic handle edge cases
- Performance: Caching makes it fast and reduces API calls
- Reliability: Rate limiting prevents abuse
- Accessibility: Free to use, no signup required
Whether you're building a weather widget, training an AI, or just settling arguments about whether it's foggy in the Richmond, this API has you covered.
Built by Solo Founders · Powered by PurpleAir · MIT License