gridloc / server.mjs
lassanmonster's picture
Fix CSS fallback: match base name not hash (Vite hashes contain hyphens)
c074826
Raw
History Blame Contribute Delete
5.27 kB
// Minimal production host for the TanStack Start build.
//
// `vite build` emits a static client (dist/client) and a fetch-style SSR
// handler (dist/server/server.js default export). Neither listens on a port,
// so this tiny Node server does two jobs: serve files out of dist/client, and
// pass everything else through the SSR handler. Listens on PORT (Hugging Face
// Spaces routes to 7860). No framework, no extra dependencies.
import { createServer } from 'node:http'
import { Readable } from 'node:stream'
import { stat } from 'node:fs/promises'
import { createReadStream, readdirSync } from 'node:fs'
import { join, normalize, extname } from 'node:path'
import { fileURLToPath } from 'node:url'
import handler from './dist/server/server.js'
const ROOT = fileURLToPath(new URL('.', import.meta.url))
const CLIENT_DIR = join(ROOT, 'dist', 'client')
const ASSETS_DIR = join(CLIENT_DIR, 'assets')
const PORT = Number(process.env.PORT) || 3000
const HOST = process.env.HOST || '0.0.0.0'
// Map "name-prefix" -> emitted CSS filename. Tailwind v4 + the TanStack Start
// SSR build can hash the stylesheet differently between the client and server
// passes, so the SSR HTML injects a CSS href whose hash does not match the file
// the client actually emitted. Since there is exactly one file per prefix
// (styles-*, mail-*), we resolve any missing hashed CSS to the real one. The
// hash is only a cache key, so serving the emitted stylesheet is correct.
// Key by the base NAME (the part before the first hyphen), not the hash. Vite's
// base64url hashes can themselves contain hyphens, so splitting on the last
// hyphen is unreliable; the first token (styles, tetris-loader -> "tetris",
// mail) is stable and unique enough across the emitted stylesheets.
const cssByToken = new Map()
try {
for (const f of readdirSync(ASSETS_DIR)) {
if (f.endsWith('.css')) cssByToken.set(f.split('-')[0], f)
}
} catch (err) {
console.warn('[server] could not index CSS assets:', err.message)
}
const MIME = {
'.js': 'text/javascript',
'.mjs': 'text/javascript',
'.css': 'text/css',
'.html': 'text/html; charset=utf-8',
'.json': 'application/json',
'.geojson': 'application/json',
'.map': 'application/json',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.ico': 'image/x-icon',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.ttf': 'font/ttf',
'.wasm': 'application/wasm',
}
// Serve a file from dist/client if it exists. Returns true if it handled the
// request. Guards against path traversal outside the client directory.
async function tryStatic(res, pathname) {
const safe = normalize(decodeURIComponent(pathname)).replace(/^([/\\])+/, '')
const filePath = join(CLIENT_DIR, safe)
if (!filePath.startsWith(CLIENT_DIR)) return false
try {
const info = await stat(filePath)
if (!info.isFile()) return false
res.writeHead(200, {
'content-type': MIME[extname(filePath)] || 'application/octet-stream',
'content-length': info.size,
'cache-control': 'public, max-age=3600',
})
createReadStream(filePath).pipe(res)
return true
} catch {
return false
}
}
// Build a Web Request from the incoming Node request so the SSR handler (which
// speaks the Fetch API) can process it.
function toWebRequest(req) {
const url = `http://${req.headers.host || 'localhost'}${req.url}`
const headers = new Headers()
for (const [key, value] of Object.entries(req.headers)) {
if (value !== undefined) headers.set(key, Array.isArray(value) ? value.join(', ') : value)
}
const method = req.method || 'GET'
const hasBody = method !== 'GET' && method !== 'HEAD'
return new Request(url, {
method,
headers,
body: hasBody ? Readable.toWeb(req) : undefined,
duplex: hasBody ? 'half' : undefined,
})
}
const server = createServer(async (req, res) => {
try {
const pathname = (req.url || '/').split('?')[0]
// Static assets first (skip the bare root so it always renders via SSR).
if ((req.method === 'GET' || req.method === 'HEAD') && pathname !== '/') {
if (await tryStatic(res, pathname)) return
// CSS fallback: the SSR build can inject a stylesheet hash that does not
// match the emitted file. Resolve it to the real one by name prefix so the
// page is never served unstyled.
if (pathname.startsWith('/assets/') && pathname.endsWith('.css')) {
const token = pathname.slice('/assets/'.length).split('-')[0]
const actual = cssByToken.get(token)
if (actual && (await tryStatic(res, `/assets/${actual}`))) return
}
}
const response = await handler.fetch(toWebRequest(req))
res.statusCode = response.status
response.headers.forEach((value, key) => res.setHeader(key, value))
if (response.body) {
Readable.fromWeb(response.body).pipe(res)
} else {
res.end()
}
} catch (err) {
console.error('[server] request failed:', err)
if (!res.headersSent) res.writeHead(500, { 'content-type': 'text/plain' })
res.end('Internal Server Error')
}
})
server.listen(PORT, HOST, () => {
console.log(`GridLoc is listening on http://${HOST}:${PORT}`)
})