Fetch Shim 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.