la-rose/scripts/dev-server.ts
semianiakavy 4ab8249c6a fix: cache the bouquet gallery so the flower field stops re-downloading
The dev server stamped every static response `no-store`, images included.
The field paints 40 bouquets x up to 3 shots in three identical sets, and a
client-side render rebuilds the whole view through `innerHTML` — so every
`<img>` is a new element and, with nothing cacheable, the same ~120 photos
were fetched again on each render. That is what made the slides on
/variant-b look like they kept reloading (and why a hovered cell could fade
in from blank).

- dev-server: per-kind cache policy + weak ETag. `index.html` stays
  `no-store`; `app.js` / `styles.css` (already `?v=`-busted) revalidate and
  answer 304; the committed gallery is kept for a week.
- app.js: keep one decoded image per field URL in `ffImageCache`, warmed in
  small batches when /variant-b renders, so a rebuild of the view reuses the
  bitmap instead of flashing.
- app.js: the copy of each row that is on screen at t=0 loads eagerly (first
  set for left rows, middle set for right rows); the copies drifting in later
  stay lazy.
- index.html: asset buster ?v=22 -> ?v=23.
2026-10-05 19:28:58 +00:00

359 lines
12 KiB
TypeScript

import { createServer } from 'node:http';
import { readFile, stat } 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 { CONTENT_PAGES } from '../src/db/seed';
import { BRAND, DELIVERY, STORE, STORE_CURRENCY } from '../src/store/constants';
import { ensureBrandLogo } from './fetch-logo';
import { ensureBrandFavicon, isPng } from './fetch-favicon';
/**
* 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`).
*
* At boot it also mirrors the store wordmark (scripts/fetch-logo.ts) so the header and
* footer logo is served from `public/images/brand/logo_footer.png`, not hotlinked.
*
* Routes:
* GET /api/catalog → { currency, count, priceRange, badges, delivery, categories[], products[] }
* GET /api/pages → { pages[] } (info/FAQ content from the seed)
* GET /favicon.ico → the mirrored site icon (PNG payload, served as image/png)
* GET /images/... → product gallery files from public/
* GET /<route without ext> → index.html (client-side route, SPA fallback)
* GET /<file with ext> → public/<file> or 404
*
* Static responses carry a real cache policy, not the blanket `no-store` this shell
* started with. The gallery is ~300 committed photos that never change in place, and
* serving them uncacheable meant every client-side render — which rebuilds the whole
* view via `innerHTML`, so every `<img>` is a new element — re-downloaded the same
* bouquets. That is the "the flower slides keep reloading" report on /variant-b.
* The HTML shell still goes out `no-store`, code revalidates through an ETag, images
* are kept for a week.
*/
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',
};
/**
* Content type for a file on disk. `/favicon.ico` is the one exception: the mirrored
* icon is a PNG payload under an `.ico` name (that is how the source site ships it), so
* the signature is sniffed and the file labelled `image/png` — browsers accept either
* for `<link rel="icon">`, and a truthful type beats a wrong one.
*/
function contentTypeFor(filePath: string, body: Buffer): string {
const ext = extname(filePath).toLowerCase();
if (ext === '.ico' && isPng(body)) return 'image/png';
return CONTENT_TYPES[ext] ?? 'application/octet-stream';
}
/** Extensions whose bytes never change once shipped: the committed product gallery. */
const CACHEABLE_IMAGE_EXTENSIONS = new Set([
'.jpg',
'.jpeg',
'.png',
'.webp',
'.avif',
'.gif',
'.ico',
]);
/**
* Cache policy per file kind.
*
* - the SPA shell (`index.html`) is never cached, so a plain refresh picks up a new
* build instead of a stale heuristic copy;
* - the gallery is kept for a week: the photos are committed files keyed by slug and
* position, they are the bulk of the page's bytes, and re-downloading them is what
* made the drifting rows on /variant-b flicker;
* - code (`app.js`, `styles.css`) carries a `?v=` buster and revalidates via ETag, so a
* cached copy costs a 304 rather than a full body, and can never be served stale.
*/
function cacheControlFor(ext: string): string {
if (ext === '.html') return 'no-store';
if (CACHEABLE_IMAGE_EXTENSIONS.has(ext)) return 'public, max-age=604800';
return 'public, max-age=0, must-revalidate';
}
interface ApiImage {
url: string;
alt: string;
position: number;
}
interface ApiVariant {
label: string;
priceMinor: number;
stock: number;
}
interface ApiCategory {
slug: string;
name: string;
description: string | null;
/** R13: hidden source categories stay reachable by direct URL but never in the menu. */
visible: boolean;
count: number;
}
interface ApiProduct {
n: number;
slug: string;
name: string;
description: string;
composition: string;
care: string;
priceMinor: number;
oldPriceMinor: number | null;
currency: string;
badge: 'hit' | 'new' | 'sale' | null;
stock: number;
stems: number | null;
heightCm: number;
deliveryTime: string;
occasion: string;
categories: string[];
images: ApiImage[];
variants: ApiVariant[];
}
interface ApiCatalog {
currency: string;
count: number;
priceRange: { minMinor: number; maxMinor: number };
badges: { hit: number; new: number; sale: number };
delivery: {
freeOverMinor: number;
courierFeeMinor: number;
leadTimeText: string;
outsideMkadText: string;
pickupAddress: string;
workingHours: string;
phone1: string;
phone2: string;
email: string;
whatsapp: string;
telegram: string;
};
categories: ApiCategory[];
products: ApiProduct[];
}
/** Variants seeded in src/db/seed.ts §5 (source PDP «15/19/25 роз»). */
const PRODUCT_VARIANTS: Record<string, ApiVariant[]> = {
'buket-iz-krasnykh-roz-s-evkaliptom': [
{ label: '15 роз', priceMinor: 198_00, stock: 10 },
{ label: '19 роз', priceMinor: 240_00, stock: 6 },
{ label: '25 роз', priceMinor: 325_00, stock: 4 },
],
};
/** Occasion facet, same mapping the seed writes to product_feature (§5). */
function occasionFor(categories: string[]): string {
if (categories.includes('svadebnye-bukety-nevesty')) return 'Невесте';
if (categories.includes('monobuket')) return 'Любимой';
if (
categories.includes('kompozitsii') ||
categories.includes('tsvety-v-korzine') ||
categories.includes('tsvety-v-yashchike')
) {
return 'Коллеге, Партнёру';
}
return 'Любимой, Маме, Другу';
}
/** 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) => ({
n: product.n,
slug: product.slug,
name: product.name,
description: product.description,
composition: product.composition,
care: product.care,
priceMinor: product.priceMinor,
oldPriceMinor: product.oldPriceMinor,
currency: product.currency,
badge: product.badge,
stock: product.stock,
stems: product.stems,
heightCm: product.heightCm,
deliveryTime: product.deliveryTime,
occasion: occasionFor(product.categories),
categories: product.categories,
images: (imagesBySlug.get(product.slug) ?? []).sort((a, b) => a.position - b.position),
variants: PRODUCT_VARIANTS[product.slug] ?? [],
}));
const countByCategory = new Map<string, number>();
for (const product of products) {
for (const slug of new Set(product.categories)) {
countByCategory.set(slug, (countByCategory.get(slug) ?? 0) + 1);
}
}
const prices = products.map((product) => product.priceMinor);
return {
currency: STORE_CURRENCY,
count: products.length,
priceRange: {
minMinor: prices.length ? Math.min(...prices) : 0,
maxMinor: prices.length ? Math.max(...prices) : 0,
},
badges: {
hit: products.filter((product) => product.badge === 'hit').length,
new: products.filter((product) => product.badge === 'new').length,
sale: products.filter((product) => product.badge === 'sale').length,
},
delivery: {
freeOverMinor: DELIVERY.freeOverMinor,
courierFeeMinor: DELIVERY.courierFeeMinor,
leadTimeText: DELIVERY.leadTimeText,
outsideMkadText: DELIVERY.outsideMkadText,
pickupAddress: STORE.pickupAddress,
workingHours: STORE.workingHours,
phone1: STORE.phone1,
phone2: STORE.phone2,
email: STORE.email,
whatsapp: STORE.whatsapp,
telegram: STORE.telegram,
},
categories: CATEGORIES.map((category) => ({
slug: category.slug,
name: category.name,
description: category.description,
visible: category.visible,
count: countByCategory.get(category.slug) ?? 0,
})),
products,
};
}
const catalog = buildCatalog();
const pages = CONTENT_PAGES.map(({ slug, title, body }) => ({ slug, title, body }));
// Brand assets: keep the local copies of the wordmark and the site icon in place.
// Non-fatal — if a source is unreachable we log it and keep serving (both files are
// normally committed already).
try {
const logo = await ensureBrandLogo();
if (logo === 'downloaded') console.log(`dev-server: fetched the store logo (${BRAND.logoLocalPath})`);
} catch (error) {
console.warn(`dev-server: store logo unavailable — ${(error as Error).message}`);
}
try {
const icon = await ensureBrandFavicon();
if (icon === 'downloaded') console.log(`dev-server: fetched the site icon (${BRAND.faviconLocalPath})`);
} catch (error) {
console.warn(`dev-server: site icon unavailable — ${(error as Error).message}`);
}
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,
cacheControl: string = 'no-store',
): void {
res.writeHead(status, { 'content-type': type, 'cache-control': cacheControl });
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;
}
if (requestPath === '/api/pages' || requestPath === '/api/pages/') {
sendJson(res, 200, { pages });
return;
}
const relative = normalize(requestPath === '/' ? '/index.html' : requestPath).replace(/^(\.\.[/\\])+/, '');
const filePath = join(PUBLIC_DIR, relative);
try {
const [body, info] = await Promise.all([readFile(filePath), stat(filePath)]);
const headers = {
'content-type': contentTypeFor(filePath, body),
'cache-control': cacheControlFor(extname(filePath).toLowerCase()),
/* Weak validator: size + mtime is plenty for a dev shell, and it lets a
revalidated asset answer 304 without re-sending a byte. */
etag: `W/"${info.size.toString(16)}-${Math.floor(info.mtimeMs).toString(16)}"`,
};
if (req.headers['if-none-match'] === headers.etag) {
res.writeHead(304, headers);
res.end();
return;
}
res.writeHead(200, headers);
res.end(body);
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)`);
});