la-rose/scripts/dev-server.ts

169 lines
5.4 KiB
TypeScript

import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
import { extname, join, normalize } from 'node:path';
import { fileURLToPath } from 'node:url';
import { CATEGORIES, readCatalog } from '../src/db/catalog';
import { imageUrl, readImages } from '../src/db/images';
import { STORE_CURRENCY } from '../src/store/constants';
/**
* Minimal storefront server for the basic frontend stage.
*
* The full Next.js application shell is a later layer (docs/PLAN.md §8, card #57).
* Until it lands this keeps the dev container green AND lets the storefront show the
* real seeded catalog: it serves `public/` plus one read-only JSON endpoint built from
* the same seed sources as the database (`db/seed/catalog.tsv` + `db/seed/images.tsv`).
*
* Routes:
* GET /api/catalog → { currency, count, categories, products[] }
* GET /images/... → product gallery files from public/
* GET /product/<slug> → index.html (client-side route, SPA fallback)
* GET /<file with ext> → public/<file> or 404
*/
const PUBLIC_DIR = fileURLToPath(new URL('../public', import.meta.url));
const INDEX_HTML = join(PUBLIC_DIR, 'index.html');
function readPort(): number {
const inline = process.argv.find((arg) => arg.startsWith('--port='));
if (inline) return Number(inline.split('=')[1]);
const idx = process.argv.indexOf('--port');
if (idx >= 0 && process.argv[idx + 1]) return Number(process.argv[idx + 1]);
return Number(process.env.PORT ?? 3000);
}
const CONTENT_TYPES: Record<string, string> = {
'.html': 'text/html; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.map': 'application/json; charset=utf-8',
'.txt': 'text/plain; charset=utf-8',
'.svg': 'image/svg+xml',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.webp': 'image/webp',
'.gif': 'image/gif',
'.avif': 'image/avif',
'.ico': 'image/x-icon',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
};
interface ApiImage {
url: string;
alt: string;
position: number;
}
interface ApiCategory {
slug: string;
name: string;
}
interface ApiProduct {
slug: string;
name: string;
description: string;
composition: string;
priceMinor: number;
oldPriceMinor: number | null;
currency: string;
badge: 'hit' | 'new' | 'sale' | null;
stock: number;
stems: number | null;
heightCm: number;
deliveryTime: string;
categories: string[];
images: ApiImage[];
}
interface ApiCatalog {
currency: string;
count: number;
categories: ApiCategory[];
products: ApiProduct[];
}
/** Build the storefront payload from the same seed files the database is seeded from. */
function buildCatalog(): ApiCatalog {
const imagesBySlug = new Map<string, ApiImage[]>();
for (const image of readImages()) {
const list = imagesBySlug.get(image.slug) ?? [];
list.push({ url: imageUrl(image.slug, image.position), alt: image.alt, position: image.position });
imagesBySlug.set(image.slug, list);
}
const products: ApiProduct[] = readCatalog().map((product) => ({
slug: product.slug,
name: product.name,
description: product.description,
composition: product.composition,
priceMinor: product.priceMinor,
oldPriceMinor: product.oldPriceMinor,
currency: product.currency,
badge: product.badge,
stock: product.stock,
stems: product.stems,
heightCm: product.heightCm,
deliveryTime: product.deliveryTime,
categories: product.categories,
images: (imagesBySlug.get(product.slug) ?? []).sort((a, b) => a.position - b.position),
}));
return {
currency: STORE_CURRENCY,
count: products.length,
categories: CATEGORIES.filter((category) => category.visible).map(({ slug, name }) => ({ slug, name })),
products,
};
}
const catalog = buildCatalog();
function sendJson(res: import('node:http').ServerResponse, status: number, body: unknown): void {
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
res.end(JSON.stringify(body));
}
function sendBody(res: import('node:http').ServerResponse, status: number, body: Buffer | string, type: string): void {
res.writeHead(status, { 'content-type': type });
res.end(body);
}
const server = createServer(async (req, res) => {
const requestPath = decodeURIComponent((req.url ?? '/').split('?')[0]);
if (requestPath === '/api/catalog' || requestPath === '/api/catalog/') {
sendJson(res, 200, catalog);
return;
}
const relative = normalize(requestPath === '/' ? '/index.html' : requestPath).replace(/^(\.\.[/\\])+/, '');
const filePath = join(PUBLIC_DIR, relative);
try {
const body = await readFile(filePath);
sendBody(res, 200, body, CONTENT_TYPES[extname(filePath).toLowerCase()] ?? 'application/octet-stream');
return;
} catch {
// Client-side routes (e.g. /product/<slug>) have no file extension: serve the shell.
if (!extname(requestPath)) {
try {
const shell = await readFile(INDEX_HTML);
sendBody(res, 200, shell, CONTENT_TYPES['.html']);
return;
} catch {
// fall through to 404
}
}
sendBody(res, 404, 'Not found', 'text/plain; charset=utf-8');
}
});
const port = readPort();
server.listen(port, () => {
console.log(`La Rose storefront listening on http://localhost:${port} (${catalog.count} products)`);
});