#!/usr/bin/env node /** * Mirror vai-avatar's live product sources into this Space, same-origin. * * Why a mirror and not a runtime cross-origin fetch: * huggingface.co serves dataset files with * `Access-Control-Allow-Origin: https://huggingface.co`, and `/resolve` then * 302s to a CDN host that sets its own CORS policy. A page on * *.static.hf.space is neither origin, so the browser hides the response * body and the read fails with an opaque "Failed to fetch". Same-origin * cannot be blocked, so the bytes are copied into the Space instead. * * Freshness therefore depends on this script running, not on a rebuild of any * baked data: re-run it (or let the scheduled job run it) and the Space serves * whatever vai-avatar has written at that moment. * * node scripts/sync-catalogue.mjs * * Writes public/catalogue/products.json and public/catalogue/promos.json. * `public/` is what Vite copies into dist/, so both end up served with the app. */ import { mkdir, writeFile } from 'node:fs/promises'; import { realpathSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const OUT_DIR = resolve(ROOT, 'public/catalogue'); const REVISION = 'main'; const TIMEOUT_MS = 30_000; /** Kept byte-identical to the sources declared in src/catalogue.js. */ export const SOURCES = { 'products.json': { repo: 'bep40/grob-products-updated', file: 'products_url_added.json', }, 'promos.json': { repo: 'bep40/vaistudio-data', file: 'promos.json', }, }; function urlFor({ repo, file }) { return `https://huggingface.co/datasets/${repo}/resolve/${REVISION}/${file}?cts=${Date.now()}`; } async function fetchSource(name, source) { const url = urlFor(source); process.stdout.write(`[sync] ${name} <- ${source.repo}/${source.file} ... `); const response = await fetch(url, { cache: 'no-store', signal: AbortSignal.timeout(TIMEOUT_MS), headers: process.env.HF_TOKEN ? { Authorization: `Bearer ${process.env.HF_TOKEN}` } : {}, }); if (!response.ok) throw new Error(`HTTP ${response.status} for ${url}`); const text = await response.text(); // Validate before writing: a truncated or HTML error body would otherwise be // served to the app as if it were a catalogue. const parsed = JSON.parse(text); const count = Array.isArray(parsed) ? `${parsed.length} products` : `${Object.keys(parsed?.productEdits ?? {}).length} productEdits`; process.stdout.write(`ok (${count})\n`); return text; } /** * Was this file the process entrypoint? * * Comparing basenames with `endsWith` looks equivalent and is not: any file whose * name merely ENDS WITH this one (`my-sync-catalogue.mjs`) also matches, so * loading such a file would run main() as a side effect of importing it. Both * paths are resolved before comparing. */ function isDirectRun() { const entry = process.argv[1]; if (!entry) return false; try { return realpathSync(entry) === realpathSync(fileURLToPath(import.meta.url)); } catch { return false; } } async function main() { await mkdir(OUT_DIR, { recursive: true }); let failures = 0; for (const [name, source] of Object.entries(SOURCES)) { try { const text = await fetchSource(name, source); await writeFile(resolve(OUT_DIR, name), text, 'utf8'); } catch (error) { // The good file is still written, so one transient error does not throw // away a successful fetch. Whether that mixture may be PUBLISHED is // decided below. failures++; console.error(`[sync] FAILED ${name}: ${error.message}`); } } /** * ANY failure fails the run, not only a total failure. * * This matters from the second successful run onward, once public/catalogue is * committed: a file that failed to fetch is then still on disk as its previous * version, so requireRealFile() and recordCount() in refresh-catalogue.mjs both * pass on that stale copy. The caller would then build, commit and print * REFRESH_OK, and the Space would serve a fresh products.json beside an old * promos.json -- so every Sửa and every Xóa from vai-avatar silently stopped * applying, with no error anywhere. * * On the very first run the mirror is not committed yet, so the failed file is * simply absent and the caller's guard already catches it. Failing here makes * that behaviour unconditional instead of depending on which run it is. * * A stale overlay is not a degraded catalogue, it is a wrong one: catalogue.js * merges the two files, so old edits resurrect old prices and old tombstones * fight the new file. Aborting here is what makes the caller leave the * previously committed mirror intact, which is the guarantee both scripts * document. */ if (failures > 0) { console.error( `[sync] ${failures}/${Object.keys(SOURCES).length} source(s) failed — the mirror on disk ` + 'is now MIXED (fresh file beside last-commit file). Refusing to report success: ' + 'the caller must abort so the last-good mirror stays live.', ); process.exitCode = 1; return; } console.log(`[sync] wrote ${OUT_DIR}`); } if (isDirectRun()) { main().catch(error => { console.error(`[sync] ${error.stack || error}`); process.exitCode = 1; }); }