Fetch Shim Architecture

Lesson 20 — Static site architecture

The Problem: Dynamic API, Static Host

The FastAPI server handles 18+ API endpoints that accept query parameters, join database tables, and return JSON. GitHub Pages can only serve static files. Every JavaScript fetch('/api/...') call that works on the live server would return 404 on the static site.

The solution: intercept every fetch call and redirect it to a pre-built JSON file. The static build process pre-renders every known API response to a .json file on disk. A small JavaScript shim, injected into every HTML page, intercepts fetch() calls and translates dynamic URLs into static file paths. The browser never knows the difference — it receives the same JSON either way.

JavaScript Fetch Interception

The core technique is a monkey-patch of the global fetch function. The shim saves a reference to the original, replaces it with a wrapper that inspects each URL, and routes API calls to the correct static JSON file.

// Save original fetch, replace with interceptor
var originalFetch = window.fetch;

window.fetch = function(url) {
    // Only intercept /api/ calls
    if (url.indexOf('/api/') < 0)
        return originalFetch(url);

    // Parse the URL into path and query params
    var path = url.split('?')[0];
    var params = new URLSearchParams(url.split('?')[1] || '');

    // Route to the correct pre-built JSON file
    // ... pattern matching rules ...

    // Fallback: try path + .json
    return originalFetch(path + '.json');
};

The shim is injected into the <head> of every HTML page during the static build. It wraps the browser's native fetch and inspects URLs before they're sent. Non-API calls (stylesheets, images, other resources) pass through untouched. This means the shim has zero impact on non-API network requests.

URL-to-File Mapping Rules

Each API URL pattern maps to a predictable static file path. Query parameters become filename suffixes, keeping the mapping logic simple and deterministic.

API URL Pattern Static JSON File Notes
/api/occupations/search?q=... /api/occupations/search.json Client-side filtering from full index
/api/occupations/{soc}/wages?geo_type=X /api/occupations/{soc}/wages-X.json Separate file per geo_type
/api/trends/movers?metric=M&year=Y /api/trends/movers-Y_M.json Per-metric, per-year
/api/trends/{soc}?metric=M /api/trends/{soc}-M.json Per-metric trend data
/api/trends/compare/geography?soc_code=X /api/trends/compare/geography-X.json Per-occupation file

Query parameters become filename suffixes. The default metric (employment_count) maps to the bare filename; other metrics add a suffix. This convention keeps filenames predictable and the mapping logic simple. Adding a new endpoint means adding one new mapping rule and one new file-generation step in the build script.

Client-Side Search

Search is the most complex shim case because it requires filtering, not just file lookup. The solution downloads the full occupation index once, caches it as a Promise, and filters locally on every search request.

// Singleton: fetch full index once, cache as Promise
var searchCache = null;
function loadSearch(basePath) {
    if (!searchCache)
        searchCache = originalFetch(basePath + '/api/occupations/search.json')
            .then(function(r) { return r.json(); });
    return searchCache;
}

// Filter locally when user searches
loadSearch(basePath).then(function(data) {
    var query = params.get('q').toLowerCase();
    var results = data.results.filter(function(x) {
        return x.soc_code.indexOf(query) >= 0 ||
               x.occupation_title.toLowerCase().indexOf(query) >= 0;
    });
    return new Response(JSON.stringify({
        results: results.slice(offset, offset + limit),
        total: results.length
    }));
});

The full occupation index (1,447 entries) is small enough to download once and filter in JavaScript. This eliminates the need for a server-side search endpoint entirely. The cached Promise ensures only one network request is made, even if the user types multiple search queries in rapid succession. Each keystroke filters against the already-loaded data.

Client-Side Composition for Compare

The occupation comparison endpoint accepts an arbitrary list of SOC codes. No single pre-built file can cover every possible combination. Instead, the shim fetches the building blocks — individual per-occupation JSON files — and assembles the response client-side.

// /api/trends/compare/occupations?soc_codes=15-1252,11-1021
// No single pre-built file exists — compose from per-occupation files
var codes = params.get('soc_codes').split(',');
return Promise.all(codes.map(function(code) {
    return Promise.all([
        originalFetch('/api/trends/' + code + '.json'),
        originalFetch('/api/occupations/' + code + '.json')
    ]);
})).then(function(results) {
    // Assemble the comparison response from individual files
    return new Response(JSON.stringify({
        metric: metric,
        occupations: assembledData
    }));
});

Key pattern: When a pre-built endpoint can't exist (because the query accepts arbitrary code combinations), the shim fetches the building blocks and assembles the response client-side. This is the static-site equivalent of a server-side JOIN.

Path Rewriting for Subpath Deployment

GitHub Pages serves the site at /jobclass/, not /. Every link, asset path, and API URL must include this prefix. The static build script handles this with a simple string replacement pass over the rendered HTML.

# rewrite_paths() handles this at build time:
replacements = [
    ('"/api/',        '"/jobclass/api/'),
    ('"/occupation/', '"/jobclass/occupation/'),
    ('"/static/',     '"/jobclass/static/'),
    ('href="/"',      'href="/jobclass/"'),
    ...
]

The shim itself does NOT need rewriting — it extracts the base path at runtime from the current URL. If the page is served from /jobclass/trends/movers, the shim sees b = "/jobclass" and prepends it to all JSON file paths automatically. This means the same shim code works both locally (base path "") and on GitHub Pages (base path "/jobclass") without any changes.

The separation of concerns is deliberate: the build script rewrites HTML-level paths (links, script sources, stylesheet references), while the shim handles API-level paths at runtime. Neither needs to know about the other's work.