fuzzyworld

tower / files / vite.config.ts

vite.config.ts

5.3 KB · history · blame

1import { existsSync, readFileSync, writeFileSync } from 'node:fs'
2import path from 'node:path'
3import { type Plugin, defineConfig } from 'vite'
4import { qrcode } from 'vite-plugin-qrcode'
5import { parsePhoto } from './src/world/photos.js'
6import { parseWorld, toPublicWorld } from './src/world/schema.js'
7
8/**
9 * `content/world.json` is the world, and the only copy of it (D-072). Readers get it
10 * less its notes and its unpublished features, and that file is made from it here —
11 * served at `world.json` in dev, emitted into the build — rather than committed beside
12 * it, so the two can never disagree. A change to the file reloads an open dev page.
13 */
14const WORLD = path.resolve('content/world.json')
15const publicWorld = (): string =>
16 JSON.stringify(toPublicWorld(parseWorld(JSON.parse(readFileSync(WORLD, 'utf8')))), null, 2)
17
18function world(): Plugin {
19 return {
20 name: 'tower-world',
21 configureServer(server) {
22 server.watcher.add(WORLD)
23 server.watcher.on('change', (file) => {
24 if (path.resolve(file) === WORLD) server.ws.send({ type: 'full-reload' })
25 })
26 server.middlewares.use((req, res, next) => {
27 if (req.url?.split('?')[0] !== '/world.json') return next()
28 res.setHeader('Content-Type', 'application/json')
29 res.setHeader('Cache-Control', 'no-store')
30 res.end(publicWorld())
31 })
32 },
33 generateBundle() {
34 this.emitFile({ type: 'asset', fileName: 'world.json', source: publicWorld() })
35 },
36 }
37}
38
39/**
40 * `content/photos.json` is the list of saved shots (src/world/photos.ts, D-099), and the dev
41 * server is what keeps it: the app posts a shot here when the author presses P, and reads
42 * the list back to go to one. Dev only — there is no such thing in the build — and only
43 * from this machine, because `npm run dev` is open to the whole network and this writes a
44 * file. Every field is checked first.
45 */
46const PHOTOS = path.resolve('content/photos.json')
47
48function photos(): Plugin {
49 return {
50 name: 'tower-photos',
51 configureServer(server) {
52 server.middlewares.use((req, res, next) => {
53 if (req.url?.split('?')[0] !== '/__photos') return next()
54 const say = (status: number, text: string): void => {
55 res.statusCode = status
56 res.end(text)
57 }
58 const from = req.socket.remoteAddress ?? ''
59 if (!['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(from)) return say(403, 'shots are saved from the machine the dev server runs on')
60 if (req.method === 'GET') {
61 res.setHeader('Content-Type', 'application/json')
62 res.setHeader('Cache-Control', 'no-store')
63 return say(200, existsSync(PHOTOS) ? readFileSync(PHOTOS, 'utf8') : '[]')
64 }
65 if (req.method !== 'POST') return say(405, 'GET the list, or POST a shot')
66 let body = ''
67 req.on('data', (chunk: Buffer) => { body += chunk.toString() })
68 req.on('end', () => {
69 try {
70 const { photo, replace } = JSON.parse(body) as { photo: unknown; replace?: boolean }
71 const shot = parsePhoto(photo)
72 const list = existsSync(PHOTOS) ? (JSON.parse(readFileSync(PHOTOS, 'utf8')) as { id?: string; note?: string }[]) : []
73 const at = list.findIndex((p) => p.id === shot.id)
74 if (at >= 0 && !replace) return say(409, `there is already a shot called ${shot.id}`)
75 // A re-framed shot keeps its place in the list, and whatever note was written on it.
76 if (at >= 0) list[at] = { ...shot, ...(list[at]!.note === undefined ? {} : { note: list[at]!.note }) }
77 else list.push(shot)
78 writeFileSync(PHOTOS, `${JSON.stringify(list, null, 2)}\n`)
79 say(200, shot.id)
80 } catch (e) {
81 say(400, e instanceof Error ? e.message : String(e))
82 }
83 })
84 })
85 },
86 }
87}
88
89/**
90 * Relative asset paths by default, so the same build works wherever Pages puts it.
91 *
92 * GitLab serves project pages either at https://<user>.gitlab.io/<project>/ or, if
93 * unique domains are on, at the root of a generated domain. A hard-coded `/tower/`
94 * base 404s every asset in the second case, and there is no way to know which one
95 * applies until the first deploy. Relative paths are correct for both.
96 *
97 * Set BASE to override — an absolute base is needed if this ever gains client-side
98 * routing, since relative URLs resolve against the current path rather than the app
99 * root. `??` would not catch BASE="", so the check is explicit.
100 */
101const base = process.env.BASE || './'
102
103export default defineConfig({
104 base,
105 // `npm run dev` binds to every interface (--host) so a phone on the same Wi-Fi
106 // can open the app; this prints a scannable QR of that network URL alongside it.
107 plugins: [qrcode(), world(), photos()],
108 // Vite's static directory is `static/`, not the default `public/`, because
109 // GitLab Pages requires its artifact to be a directory named `public` — and
110 // `mv dist public` does not rename when `public/` already exists, it moves dist
111 // *inside* it. The collision is silent and produces a 404 at the site root.
112 publicDir: 'static',
113 build: {
114 target: 'es2022',
115 // Three.js is ~515 kB and is deliberately split into its own chunk, loaded
116 // only when the exterior view is opened. Raise the threshold past it so the
117 // warning stays meaningful for chunks that are large by accident.
118 chunkSizeWarningLimit: 600,
119 },
120})