fix: return JSON errors for unmatched local API routes

Test development and built-preview route registration, preserve development-only credential editing, and document local preview configuration.
This commit is contained in:
Sameh Khamis committed 2026-09-12 12:43:54 -07:00
1 parent a0f07b752f
commit 280fc00a51
8 files changed
+205 -4

No files matched your search

+3
View File
@@ -13,6 +13,9 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
## [Unreleased] ## [Unreleased]
- Restore data-provider routes under local build preview and return JSON 404s
for unmatched API requests. Credential editing remains development-only.
- Extract CCTV catalog/media and Radio Browser directory providers into focused - Extract CCTV catalog/media and Radio Browser directory providers into focused
Node modules, preserving their routes and policies and isolating CCTV catalogs Node modules, preserving their routes and policies and isolating CCTV catalogs
by provider instance and application root. by provider instance and application root.
+10
View File
@@ -27,6 +27,16 @@ through Pinokio; the terminal path above remains the contributor path.
Open `http://localhost:4173`. Before sending a PR run `npm run build`, `npm test`, and `npm run test:track` (dev server must be up) — **all three must stay green.** Open `http://localhost:4173`. Before sending a PR run `npm run build`, `npm test`, and `npm run test:track` (dev server must be up) — **all three must stay green.**
## Checking a built app locally
Run `npm run build` followed by `npm run preview`. Preview serves the built
frontend and local data-provider APIs. Keep optional server credentials in the
ignored `.env`; browser keys are embedded during the build, so rebuild after
changing them. Provider Settings and `/api/setup/*` are development-only: edit
configuration through the development app or environment file. Unknown API
paths return JSON 404 responses. Vite preview is for checking a local build;
it is not a production server.
## Good first contributions ## Good first contributions
The highest-leverage places to jump in: The highest-leverage places to jump in:
+12
View File
@@ -1,5 +1,17 @@
# God's Eye View Current State # God's Eye View Current State
## Local build preview
After `npm run build`, `npm run preview` serves the built app with the same data
provider routes as development, including aircraft, satellites, terrain, traffic,
FIRMS, GBFS, Overpass and CCTV/media. Unmatched `/api` requests return a JSON 404
in both modes instead of the application HTML. Browser routes retain SPA fallback.
Credential editing (`/api/setup/*` and Provider Settings) is development-only;
preview returns JSON 404 for those endpoints. Server credentials come from the
local environment; browser keys are captured at build time. Rebuild after changing
a browser key. Preview is for local build verification, not a production server.
## CCTV and radio provider modules ## CCTV and radio provider modules
CCTV catalog acquisition, source normalization and frame/media delivery now live CCTV catalog acquisition, source normalization and frame/media delivery now live
+3 -1
View File
@@ -81,5 +81,7 @@
"server/providers/radio/constants.js", "server/providers/radio/constants.js",
"server/providers/radio/stations.js", "server/providers/radio/stations.js",
"server/providers/radio/transport.js", "server/providers/radio/transport.js",
"src/tooling/mediaProviders.test.mjs" "src/tooling/mediaProviders.test.mjs",
"server/standalone/api-not-found.js",
"src/tooling/previewServing.test.mjs"
] ]
+17
View File
@@ -0,0 +1,17 @@
/** Finish unmatched API requests before Vite's HTML fallback. Install last. */
export function apiNotFoundPlugin() {
const install = (server) => {
server.middlewares.use('/api', (_req, res) => {
res.writeHead(404, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
});
res.end(JSON.stringify({ error: 'Unknown API route' }));
});
};
return {
name: 'api-not-found',
configureServer: install,
configurePreviewServer: install,
};
}
+2 -1
View File
@@ -2,6 +2,7 @@ import { fileURLToPath } from 'node:url';
import { defineConfig, loadEnv } from 'vite'; import { defineConfig, loadEnv } from 'vite';
import { createBrowserViteConfig } from '../../build/vite.js'; import { createBrowserViteConfig } from '../../build/vite.js';
import { localProviderPlugins } from '../providers/local.js'; import { localProviderPlugins } from '../providers/local.js';
import { apiNotFoundPlugin } from './api-not-found.js';
const root = fileURLToPath(new URL('../../', import.meta.url)); const root = fileURLToPath(new URL('../../', import.meta.url));
@@ -12,7 +13,7 @@ export default defineConfig(({ mode }) => {
if (process.env[key] === undefined) process.env[key] = value; if (process.env[key] === undefined) process.env[key] = value;
} }
return createBrowserViteConfig({ return createBrowserViteConfig({
plugins: localProviderPlugins(), plugins: [...localProviderPlugins(), apiNotFoundPlugin()],
googleApiKey: process.env.GOOGLE_MAPS_API_KEY, googleApiKey: process.env.GOOGLE_MAPS_API_KEY,
cesiumToken: process.env.CESIUM_ION_TOKEN, cesiumToken: process.env.CESIUM_ION_TOKEN,
host: process.env.HOST, host: process.env.HOST,
+155
View File
@@ -0,0 +1,155 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { mkdtemp, writeFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { build, createServer, preview } from 'vite';
import { localProviderPlugins } from '../../server/providers/local.js';
import { apiNotFoundPlugin } from '../../server/standalone/api-not-found.js';
test('data providers have both hooks; credential editing stays development-only', () => {
for (const plugin of localProviderPlugins()) {
if (plugin.name === 'gev-key-setup') {
assert.equal(plugin.configurePreviewServer, undefined);
assert.equal(
plugin.apply({}, { command: 'serve', isPreview: true }),
false,
);
assert.equal(
plugin.apply({}, { command: 'serve', isPreview: false }),
true,
);
continue;
}
assert.equal(typeof plugin.configureServer, 'function', plugin.name);
assert.equal(typeof plugin.configurePreviewServer, 'function', plugin.name);
}
});
test('real dev and built-preview servers serve provider JSON and terminate unknown APIs', async (t) => {
const root = await mkdtemp(path.join(tmpdir(), 'gev-preview-'));
t.after(() => rm(root, { recursive: true, force: true }));
await writeFile(
path.join(root, 'index.html'),
'<!doctype html><title>Preview fixture</title><p>Built application</p>',
);
for (const [name, value] of Object.entries({
AISSTREAM_API_KEY: '',
FIRMS_MAP_KEY: '',
FIRMS_API_KEY: '',
TOMTOM_API_KEY: '',
GOOGLE_MAPS_API_KEY: '',
GOOGLE_MAPS_SERVER_API_KEY: '',
OPENAI_API_KEY: '',
OPENSKY_AUTH_MODE: 'anon',
OPENSKY_CLIENT_ID: '',
OPENSKY_CLIENT_SECRET: '',
CCTV_FORCE_AUSTIN: '0',
CCTV_SOURCES_FILE: path.join(root, 'absent.json'),
CCTV_SOURCES_JSON: JSON.stringify([
{ id: 'fixture', lat: 30.27, lon: -97.74 },
]),
})) {
const before = process.env[name];
process.env[name] = value;
t.after(() => {
if (before === undefined) delete process.env[name];
else process.env[name] = before;
});
}
const nativeFetch = globalThis.fetch;
t.mock.method(globalThis, 'fetch', async (raw, options) => {
const url = new URL(raw);
if (url.hostname === '127.0.0.1') return nativeFetch(raw, options);
assert.ok(
['opensky-network.org', 'api.adsb.lol'].includes(url.hostname),
url.origin,
);
return Response.json({
time: Math.floor(Date.now() / 1000),
states: [],
ac: [],
});
});
const base = {
root,
configFile: false,
envFile: false,
publicDir: false,
logLevel: 'silent',
};
await build(base);
for (const isPreview of [false, true]) {
const config = {
...base,
plugins: [...localProviderPlugins(), apiNotFoundPlugin()],
server: { host: '127.0.0.1', port: 0, hmr: false },
preview: { host: '127.0.0.1', port: 0 },
};
const server = isPreview
? await preview(config)
: await createServer(config);
if (!isPreview) await server.listen();
const origin = `http://127.0.0.1:${server.httpServer.address().port}`;
try {
for (const [route, status] of [
['/api/opensky', 200],
['/api/adsblol/mil', 200],
['/api/adsbdb/type/invalid', 400],
['/api/firms/status', 200],
['/api/terrain/heights?points=invalid', 400],
['/api/overpass', 405],
['/api/cctv/sources', 200],
['/api/gbfs/', 400],
['/api/tomtom/status', 200],
['/api/radio/unknown', 404],
['/api/setup/status', isPreview ? 404 : 200],
['/api/setup/update', 404],
['/api/does-not-exist', 404],
['/api', 404],
]) {
const response = await fetch(origin + route);
assert.equal(
response.status,
status,
`${isPreview ? 'preview' : 'dev'} ${route}`,
);
assert.match(
response.headers.get('content-type'),
/application\/json/,
route,
);
const body = await response.json();
if (route === '/api/cctv/sources')
assert.equal(body.sources[0].id, 'fixture');
if (
route === '/api/does-not-exist' ||
(isPreview && route.startsWith('/api/setup'))
) {
assert.deepEqual(body, { error: 'Unknown API route' });
assert.equal(response.headers.get('cache-control'), 'no-store');
}
}
const tle = await fetch(origin + '/api/celestrak/invalid!');
assert.equal(tle.status, 400);
assert.equal(await tle.text(), 'invalid group');
if (isPreview) {
const write = await fetch(origin + '/api/setup/keys', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ GOOGLE_MAPS_API_KEY: 'must-not-save' }),
});
assert.equal(write.status, 404);
assert.deepEqual(await write.json(), { error: 'Unknown API route' });
}
for (const route of ['/', '/application-route', '/apiary']) {
const response = await fetch(origin + route);
assert.equal(response.status, 200);
assert.match(response.headers.get('content-type'), /text\/html/);
assert.match(await response.text(), /Built application/);
}
} finally {
await server.close();
}
}
});
+3 -2
View File
@@ -63,10 +63,11 @@ test('root config retains existing named exports and standalone provider order',
assert.equal(compatibility[name], value, name); assert.equal(compatibility[name], value, name);
const config = standaloneConfig({ mode: 'test' }); const config = standaloneConfig({ mode: 'test' });
assert.deepEqual( assert.deepEqual(
config.plugins.slice(1).map((plugin) => plugin.name), config.plugins.slice(1, -1).map((plugin) => plugin.name),
providers.localProviderPlugins().map((plugin) => plugin.name), providers.localProviderPlugins().map((plugin) => plugin.name),
); );
assert.equal(config.plugins.at(-1).name, 'gev-key-setup'); assert.equal(config.plugins.at(-2).name, 'gev-key-setup');
assert.equal(config.plugins.at(-1).name, 'api-not-found');
}); });
test('build export resolves in Node and has no browser fallback', async () => { test('build export resolves in Node and has no browser fallback', async () => {