/** * prerender.mjs — Genera HTML statico per le route pubbliche. * * Uso: * pnpm build:static → quasar build + node scripts/prerender.mjs * * Output: * dist/spa/index.html (route /) * dist/spa/someroute/index.html (any additional public route) * * ─── Handler Go (esempio GoFiber) ──────────────────────────────────────────── * * app.Static("/", "./dist/spa", fiber.Static{ * Index: "index.html", * Browse: false, * MaxAge: 0, // nessuna cache per l'HTML * }) * * // Fallback: serve index.html per qualsiasi percorso sconosciuto (routing client SPA) * app.Use(func(c *fiber.Ctx) error { * // Serve l'HTML prerenderizzato se esiste, altrimenti ripiega sulla shell SPA * candidate := "./dist/spa" + c.Path() + "/index.html" * if _, err := os.Stat(candidate); err == nil { * return c.SendFile(candidate) * } * return c.SendFile("./dist/spa/index.html") * }) * * ─── Esempio net/http ───────────────────────────────────────────────────────── * * fs := http.FileServer(http.Dir("./dist/spa")) * http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { * candidate := "./dist/spa" + path.Clean(r.URL.Path) + "/index.html" * if _, err := os.Stat(candidate); err == nil { * http.ServeFile(w, r, candidate) * return * } * // Serve la root prerenderizzata o la shell SPA * r2 := r.Clone(r.Context()) * r2.URL.Path = "/" * fs.ServeHTTP(w, r2) * }) * * ───────────────────────────────────────────────────────────────────────────── */ import { copyFileSync, cpSync, createReadStream, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync, } from 'node:fs'; import { createServer } from 'node:http'; import { extname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import puppeteer from 'puppeteer'; const __dirname = fileURLToPath(new URL('.', import.meta.url)); const DIST_DIR = resolve(__dirname, '../dist/spa'); const ENV_FILE = resolve(__dirname, '../.env'); const PORT = 4173; /** * Route pubbliche da prerenderizzare. * Ogni voce produrra dist/spa{route}/index.html */ export const PUBLIC_ROUTES = [ '/', // '/about', // '/terms', // '/privacy', ]; const MIME_TYPES = { '.html': 'text/html; charset=utf-8', '.js': 'application/javascript', '.mjs': 'application/javascript', '.css': 'text/css', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.json': 'application/json', '.woff2': 'font/woff2', '.woff': 'font/woff', '.ttf': 'font/ttf', }; function startServer() { const server = createServer((req, res) => { const url = req.url?.split('?')[0] ?? '/'; let filePath = join(DIST_DIR, url === '/' ? 'index.html' : url); // Fallback SPA: percorsi sconosciuti o senza estensione -> index.html if (!existsSync(filePath) || !extname(filePath)) { filePath = join(DIST_DIR, 'index.html'); } const mime = MIME_TYPES[extname(filePath)] ?? 'application/octet-stream'; res.setHeader('Content-Type', mime); res.setHeader('Cache-Control', 'no-store'); createReadStream(filePath) .on('error', () => { res.writeHead(404); res.end('Not found'); }) .pipe(res); }); return new Promise((resolve) => server.listen(PORT, () => resolve(server))); } function parseEnvFile(filePath) { if (!existsSync(filePath)) { return {}; } const data = readFileSync(filePath, 'utf-8'); const env = {}; for (const line of data.split('\n')) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith('#')) { continue; } const idx = trimmed.indexOf('='); if (idx <= 0) { continue; } const key = trimmed.slice(0, idx).trim(); let value = trimmed.slice(idx + 1).trim(); if ( (value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")) ) { value = value.slice(1, -1); } env[key] = value; } return env; } function copyDirectoryContent(srcDir, destDir) { mkdirSync(destDir, { recursive: true }); for (const entry of readdirSync(srcDir)) { const srcPath = join(srcDir, entry); const destPath = join(destDir, entry); const stat = statSync(srcPath); if (stat.isDirectory()) { cpSync(srcPath, destPath, { recursive: true, force: true }); } else { copyFileSync(srcPath, destPath); } } } function copySpaToGoTarget() { const envFromFile = parseEnvFile(ENV_FILE); const goProjectDir = process.env.GO_PROJECT_DIR ?? envFromFile.GO_PROJECT_DIR; const goSpaTargetDir = process.env.GO_SPA_TARGET_DIR ?? envFromFile.GO_SPA_TARGET_DIR ?? (goProjectDir ? join(goProjectDir, 'spa') : null); if (!goSpaTargetDir) { console.log('│ copy : skipped (set GO_PROJECT_DIR or GO_SPA_TARGET_DIR in .env)'); return; } const resolvedTarget = resolve(goSpaTargetDir); rmSync(resolvedTarget, { recursive: true, force: true }); mkdirSync(resolvedTarget, { recursive: true }); copyDirectoryContent(DIST_DIR, resolvedTarget); console.log(`│ copy : ${DIST_DIR} -> ${resolvedTarget}`); } async function prerender() { console.log('┌─ Static prerender ────────────────────────────────────────────'); console.log(`│ dist dir : ${DIST_DIR}`); console.log(`│ routes : ${PUBLIC_ROUTES.join(', ')}`); console.log('│'); if (!existsSync(DIST_DIR)) { console.error('│ ERROR: dist/spa not found — run `pnpm build` first.'); process.exit(1); } const server = await startServer(); const browser = await puppeteer.launch({ headless: true, args: ['--no-sandbox', '--disable-setuid-sandbox'], }); for (const route of PUBLIC_ROUTES) { process.stdout.write(`│ ${route.padEnd(30)} → `); const page = await browser.newPage(); // Silenzia il rumore della console del browser page.on('console', () => {}); await page.goto(`http://localhost:${PORT}${route}`, { waitUntil: 'networkidle0', timeout: 30_000, }); const html = await page.content(); const outDir = route === '/' ? DIST_DIR : join(DIST_DIR, route); mkdirSync(outDir, { recursive: true }); writeFileSync(join(outDir, 'index.html'), html, 'utf-8'); console.log(`${outDir}/index.html`); await page.close(); } await browser.close(); server.close(); copySpaToGoTarget(); console.log('│'); console.log('└─ Done ✓'); } prerender().catch((err) => { console.error(err); process.exit(1); });