f1262fd9 2026-04-12 Pavel Safronov | 1 | import { defineConfig, type Plugin } from 'vite' |
44eb5832 2026-04-12 Pavel Safronov | 2 | import { qrcode } from 'vite-plugin-qrcode' |
f1262fd9 2026-04-12 Pavel Safronov | 3 | import basicSsl from '@vitejs/plugin-basic-ssl' |
| 4 | import type { IncomingMessage, ServerResponse } from 'node:http' |
aa6e5a99 2026-04-13 Pavel Safronov | 5 | import { execFileSync } from 'node:child_process' |
| 6 | import { readdirSync, readFileSync, existsSync, mkdirSync } from 'node:fs' |
| 7 | import { join, dirname } from 'node:path' |
| 8 | import { createRequire } from 'node:module' |
| 9 | import { tmpdir } from 'node:os' |
cf658d5b 2026-10-27 Pavel Safronov | 10 | import fuzzyworld from 'fuzzyworld-tools/vite' |
aa6e5a99 2026-04-13 Pavel Safronov | 11 | |
| 12 | const require = createRequire(import.meta.url) |
| 13 | // eslint-disable-next-line @typescript-eslint/no-require-imports |
| 14 | const ffmpeg: string = require('ffmpeg-static') |
| 15 | |
| 16 | const SOUNDS_SRC = new URL('./src/sounds', import.meta.url).pathname |
| 17 | |
| 18 | // The specific files sound.ts references (category prefix only, 000–002 appended) |
| 19 | const SOUND_CATEGORIES = [ |
| 20 | 'impactWood_medium', |
| 21 | 'impactGlass_medium', |
| 22 | 'impactMetal_heavy', |
| 23 | 'impactMining', |
| 24 | 'impactSoft_heavy', |
| 25 | 'impactSoft_medium', |
| 26 | 'impactPunch_heavy', |
| 27 | 'impactTin_medium', |
| 28 | ] |
| 29 | |
| 30 | function oggToMp3(src: string, dest: string): void { |
| 31 | mkdirSync(dirname(dest), { recursive: true }) |
| 32 | execFileSync(ffmpeg, ['-i', src, '-q:a', '4', '-y', dest], { stdio: 'pipe' }) |
| 33 | } |
| 34 | |
| 35 | function soundFiles(): string[] { |
| 36 | return SOUND_CATEGORIES.flatMap(cat => |
| 37 | [0, 1, 2].map(v => `${cat}_${String(v).padStart(3, '0')}`) |
| 38 | ) |
| 39 | } |
| 40 | |
| 41 | function sounds(): Plugin { |
| 42 | // Temp dir for dev-server cache |
| 43 | const cache = join(tmpdir(), 'nuts-sounds') |
| 44 | mkdirSync(cache, { recursive: true }) |
| 45 | |
| 46 | return { |
| 47 | name: 'sounds', |
| 48 | |
| 49 | // Build: convert + emit each MP3 as an asset |
| 50 | async generateBundle() { |
| 51 | for (const name of soundFiles()) { |
| 52 | const src = join(SOUNDS_SRC, `${name}.ogg`) |
| 53 | if (!existsSync(src)) continue |
| 54 | const tmp = join(cache, `${name}.mp3`) |
| 55 | if (!existsSync(tmp)) oggToMp3(src, tmp) |
| 56 | this.emitFile({ |
| 57 | type: 'asset', |
| 58 | fileName: `sounds/${name}.mp3`, |
| 59 | source: readFileSync(tmp), |
| 60 | }) |
| 61 | } |
| 62 | }, |
| 63 | |
| 64 | // Dev: convert on first request, then serve from cache |
| 65 | configureServer(server) { |
| 66 | server.middlewares.use('/sounds/', (req: IncomingMessage, res: ServerResponse) => { |
| 67 | const mp3Name = (req.url ?? '').replace(/^\//, '') |
| 68 | if (!mp3Name.endsWith('.mp3')) { res.writeHead(404).end(); return } |
| 69 | const oggName = mp3Name.replace(/\.mp3$/, '.ogg') |
| 70 | const src = join(SOUNDS_SRC, oggName) |
| 71 | const dest = join(cache, mp3Name) |
| 72 | if (!existsSync(src)) { res.writeHead(404).end(); return } |
| 73 | try { |
| 74 | if (!existsSync(dest)) oggToMp3(src, dest) |
| 75 | const data = readFileSync(dest) |
| 76 | res.writeHead(200, { 'Content-Type': 'audio/mpeg', 'Content-Length': data.length }) |
| 77 | res.end(data) |
| 78 | } catch (e) { |
| 79 | res.writeHead(500).end(String(e)) |
| 80 | } |
| 81 | }) |
| 82 | }, |
| 83 | } |
| 84 | } |
f1262fd9 2026-04-12 Pavel Safronov | 85 | |
| 86 | function remoteLog(): Plugin { |
| 87 | return { |
| 88 | name: 'remote-log', |
| 89 | configureServer(server) { |
| 90 | server.middlewares.use( |
| 91 | '/__log', |
| 92 | (req: IncomingMessage, res: ServerResponse) => { |
| 93 | if (req.method !== 'POST') { |
| 94 | res.writeHead(405).end() |
| 95 | return |
| 96 | } |
| 97 | let body = '' |
| 98 | req.setEncoding('utf8') |
| 99 | req.on('data', (chunk: string) => { body += chunk }) |
| 100 | req.on('end', () => { |
| 101 | try { |
| 102 | const { level, msg } = JSON.parse(body) as { level: string; msg: string } |
| 103 | const prefix = '\x1b[36m[web]\x1b[0m' |
| 104 | if (level === 'error') { |
| 105 | server.config.logger.error(`${prefix} ${msg}`) |
| 106 | } else { |
| 107 | server.config.logger.info(`${prefix} ${msg}`) |
| 108 | } |
| 109 | } catch { /* malformed – ignore */ } |
| 110 | res.writeHead(204).end() |
| 111 | }) |
| 112 | } |
| 113 | ) |
| 114 | }, |
| 115 | } |
| 116 | } |
44eb5832 2026-04-12 Pavel Safronov | 117 | |
| 118 | export default defineConfig({ |
| 119 | build: { |
| 120 | outDir: 'dist', |
| 121 | }, |
| 122 | server: { |
| 123 | host: true, // listen on 0.0.0.0 so the phone can reach it |
| 124 | }, |
cf658d5b 2026-10-27 Pavel Safronov | 125 | plugins: [fuzzyworld(), sounds(), remoteLog(), basicSsl(), qrcode()], |
44eb5832 2026-04-12 Pavel Safronov | 126 | }) |