import type { Connect, Plugin } from 'vite'; import type { IncomingMessage, ServerResponse } from 'node:http'; import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { fakePrediction } from './handlers/prediction'; const __dirname = dirname(fileURLToPath(import.meta.url)); const DATA_DIR = join(__dirname, 'data'); /** * Dev-only mock for the Django REST API. Enable by setting * `VITE_USE_MOCK_API=true` when running `vite dev`. * * Covered endpoints (everything under /api/): * - GET/POST/PUT/DELETE /saved-points/… * - GET/POST/PUT/DELETE /saved-templates/… * - GET/POST/PUT/DELETE /saved-profiles/… * - POST /predictions/ * - GET /session/, /whoami/, /csrf/ * - POST /login/, /logout/ * * State lives on disk so edits survive dev-server restarts. */ interface MockSession { isAuthenticated: boolean; username: string | null; } let session: MockSession = { isAuthenticated: true, username: 'demo-user' }; function loadArray(file: string, fallback: T[]): T[] { const path = join(DATA_DIR, file); if (!existsSync(path)) return fallback; try { return JSON.parse(readFileSync(path, 'utf-8')) as T[]; } catch { return fallback; } } function saveArray(file: string, items: T[]): void { if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true }); writeFileSync(join(DATA_DIR, file), JSON.stringify(items, null, '\t')); } function send(res: ServerResponse, status: number, body: unknown) { res.statusCode = status; res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify(body)); } function cors(res: ServerResponse, req: IncomingMessage) { res.setHeader('Access-Control-Allow-Origin', req.headers.origin ?? '*'); res.setHeader('Access-Control-Allow-Credentials', 'true'); res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,PATCH,OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-CSRFToken'); } async function readBody(req: IncomingMessage): Promise { return await new Promise((resolve) => { const chunks: Uint8Array[] = []; req.on('data', (chunk: Uint8Array) => chunks.push(chunk)); req.on('end', () => { const raw = Buffer.concat(chunks).toString('utf-8'); if (!raw) return resolve({}); try { resolve(JSON.parse(raw)); } catch { resolve({}); } }); }); } function crudEndpoint( file: string, path: string, ): (req: IncomingMessage, res: ServerResponse) => Promise { return async (req, res) => { const url = req.url ?? ''; if (!url.startsWith(path)) return false; const remainder = url.slice(path.length); const [idPart] = remainder.replace(/\/$/, '').split('?'); const id = idPart ? Number(idPart) : null; let items = loadArray(file, []); switch (req.method) { case 'GET': if (id == null || Number.isNaN(id)) { send(res, 200, items); } else { const item = items.find((i) => i.id === id); item ? send(res, 200, item) : send(res, 404, { detail: 'not found' }); } return true; case 'POST': { const body = (await readBody(req)) as Partial; const nextId = items.reduce((m, i) => Math.max(m, i.id), 0) + 1; const created = { ...body, id: nextId } as T; items = [...items, created]; saveArray(file, items); send(res, 201, created); return true; } case 'PUT': { const body = (await readBody(req)) as T; items = items.map((i) => (i.id === (id ?? body.id) ? body : i)); saveArray(file, items); send(res, 200, body); return true; } case 'DELETE': items = items.filter((i) => i.id !== id); saveArray(file, items); res.statusCode = 204; res.end(); return true; default: return false; } }; } export function mockApiPlugin(): Plugin { const handlers: Array<(req: IncomingMessage, res: ServerResponse) => Promise> = [ crudEndpoint('points.json', '/api/saved-points/'), crudEndpoint('scenarios.json', '/api/saved-templates/'), crudEndpoint('profiles.json', '/api/saved-profiles/'), ]; const middleware: Connect.NextHandleFunction = async (req, res, next) => { const url = req.url ?? ''; if (!url.startsWith('/api/')) return next(); cors(res, req); if (req.method === 'OPTIONS') { res.statusCode = 204; res.end(); return; } if (url.startsWith('/api/csrf/')) { res.setHeader('Set-Cookie', 'csrftoken=mock-csrf; Path=/'); send(res, 200, { detail: 'ok' }); return; } if (url.startsWith('/api/session/')) { send(res, 200, { isAuthenticated: session.isAuthenticated }); return; } if (url.startsWith('/api/whoami/')) { if (!session.username) return send(res, 401, { detail: 'not authenticated' }); send(res, 200, { username: session.username }); return; } if (url.startsWith('/api/login/')) { const body = (await readBody(req)) as { username: string; password: string }; if (!body.username || !body.password) { return send(res, 400, { detail: 'missing credentials' }); } session = { isAuthenticated: true, username: body.username }; send(res, 200, { detail: 'ok', username: body.username }); return; } if (url.startsWith('/api/logout/')) { session = { isAuthenticated: false, username: null }; send(res, 200, { detail: 'ok' }); return; } if (url.startsWith('/api/predictions/')) { const body = (await readBody(req)) as Parameters[0]; send(res, 200, fakePrediction(body)); return; } for (const h of handlers) { if (await h(req, res)) return; } send(res, 404, { detail: 'mock: endpoint not implemented' }); }; return { name: 'lsv-mock-api', apply: 'serve', configureServer(server) { server.middlewares.use(middleware); }, }; }