leaflet_svelte/mocks/vitePlugin.ts
2026-04-22 01:27:38 +09:00

200 lines
5.7 KiB
TypeScript

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<T>(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<T>(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<unknown> {
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<T extends { id: number }>(
file: string,
path: string,
): (req: IncomingMessage, res: ServerResponse) => Promise<boolean> {
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<T>(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<T>;
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<boolean>> = [
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<typeof fakePrediction>[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);
},
};
}