diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b7500b40..3ac9ef983 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,9 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md ## [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 Node modules, preserving their routes and policies and isolating CCTV catalogs by provider instance and application root. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 32072faa5..84f7d0c76 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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.** +## 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 The highest-leverage places to jump in: diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 10f616517..48ffa78b8 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,17 @@ # 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 catalog acquisition, source normalization and frame/media delivery now live diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 83b114819..f5b3b03f6 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -81,5 +81,7 @@ "server/providers/radio/constants.js", "server/providers/radio/stations.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" ] diff --git a/server/standalone/api-not-found.js b/server/standalone/api-not-found.js new file mode 100644 index 000000000..1f7332609 --- /dev/null +++ b/server/standalone/api-not-found.js @@ -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, + }; +} diff --git a/server/standalone/vite.config.js b/server/standalone/vite.config.js index ea46e4a70..5479a1c14 100644 --- a/server/standalone/vite.config.js +++ b/server/standalone/vite.config.js @@ -2,6 +2,7 @@ import { fileURLToPath } from 'node:url'; import { defineConfig, loadEnv } from 'vite'; import { createBrowserViteConfig } from '../../build/vite.js'; import { localProviderPlugins } from '../providers/local.js'; +import { apiNotFoundPlugin } from './api-not-found.js'; 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; } return createBrowserViteConfig({ - plugins: localProviderPlugins(), + plugins: [...localProviderPlugins(), apiNotFoundPlugin()], googleApiKey: process.env.GOOGLE_MAPS_API_KEY, cesiumToken: process.env.CESIUM_ION_TOKEN, host: process.env.HOST, diff --git a/src/tooling/previewServing.test.mjs b/src/tooling/previewServing.test.mjs new file mode 100644 index 000000000..65993acef --- /dev/null +++ b/src/tooling/previewServing.test.mjs @@ -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'), + 'Preview fixture

Built application

', + ); + 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(); + } + } +}); diff --git a/src/tooling/viteBuild.test.mjs b/src/tooling/viteBuild.test.mjs index 3754b5650..acfe243b5 100644 --- a/src/tooling/viteBuild.test.mjs +++ b/src/tooling/viteBuild.test.mjs @@ -63,10 +63,11 @@ test('root config retains existing named exports and standalone provider order', assert.equal(compatibility[name], value, name); const config = standaloneConfig({ mode: 'test' }); 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), ); - 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 () => {