import { createReadStream } from 'node:fs' import { readFile, stat } from 'node:fs/promises' import { createServer } from 'node:http' import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto' import path from 'node:path' import { fileURLToPath } from 'node:url' import { render } from './dist/server/entry-server.js' const root = fileURLToPath (new URL ('./dist/client/', import.meta.url)) const template = await readFile (path.join (root, 'index.html'), 'utf8') const origin = new URL (process.env.PUBLIC_ORIGIN ?? 'http://localhost:5173').origin const apiBaseURL = process.env.SSR_API_BASE_URL ?? 'http://127.0.0.1:3002' const sessionSecret = process.env.SSR_COOKIE_SECRET if (process.env.NODE_ENV === 'production' && (!sessionSecret || sessionSecret.length < 32)) throw new Error ('SSR_COOKIE_SECRET must contain at least 32 characters in production') const sessionKey = createHash ('sha256').update (sessionSecret ?? randomBytes (32)).digest () const cookieName = '__Host-btrc-session' const sessionSeconds = 7 * 24 * 60 * 60 const encodeSession = code => { const iv = randomBytes (12) const cipher = createCipheriv ('aes-256-gcm', sessionKey, iv) const payload = JSON.stringify ({ code, expires: Date.now () + sessionSeconds * 1000 }) const encrypted = Buffer.concat ([cipher.update (payload, 'utf8'), cipher.final ()]) return Buffer.concat ([iv, cipher.getAuthTag (), encrypted]).toString ('base64url') } const decodeSession = cookie => { try { const token = cookie?.split (';').map (part => part.trim ()) .find (part => part.startsWith (`${cookieName}=`))?.slice (cookieName.length + 1) if (!token || token.length > 2048) return undefined const payload = Buffer.from (token, 'base64url') const decipher = createDecipheriv ('aes-256-gcm', sessionKey, payload.subarray (0, 12)) decipher.setAuthTag (payload.subarray (12, 28)) const data = JSON.parse (Buffer.concat ([ decipher.update (payload.subarray (28)), decipher.final ()]).toString ('utf8')) return typeof data.code === 'string' && data.expires > Date.now () ? data.code : undefined } catch { return undefined } } const types = { '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.png': 'image/png', '.gif': 'image/gif', '.jpg': 'image/jpeg', '.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.webp': 'image/webp', '.woff': 'font/woff', '.woff2': 'font/woff2', '.otf': 'font/otf', '.mp4': 'video/mp4', '.mp3': 'audio/mpeg', '.txt': 'text/plain' } if (process.env.NODE_ENV === 'production' && !process.env.PUBLIC_ORIGIN) throw new Error ('PUBLIC_ORIGIN is required in production') const server = createServer (async (request, response) => { response.setHeader ('Cache-Control', 'private, no-store') const forwardedFor = request.headers['x-forwarded-for'] ?? request.socket.remoteAddress if (request.url === '/_ssr/session') { if (request.method !== 'POST' || request.headers.origin !== origin) { response.writeHead (403).end () return } const code = request.headers['x-transfer-code'] if (typeof code !== 'string' || code.length > 512 || code.length === 0) { response.writeHead (400).end () return } try { const verified = await fetch (new URL ('users/session', apiBaseURL.replace (/\/?$/, '/')), { headers: { 'X-Transfer-Code': code, 'X-Forwarded-For': forwardedFor }, signal: AbortSignal.timeout (10_000) }) if (!verified.ok) { response.writeHead (verified.status === 401 || verified.status === 403 ? verified.status : 503).end () return } response.setHeader ('Set-Cookie', `${cookieName}=${encodeSession (code)}; ` + `Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${sessionSeconds}`) response.writeHead (204).end () } catch { response.writeHead (503).end () } return } if (!['GET', 'HEAD'].includes (request.method)) { response.writeHead (405, { Allow: 'GET, HEAD' }).end () return } try { const url = new URL (request.url, origin) const pathname = decodeURIComponent (url.pathname) const file = path.resolve (root, '.' + pathname) const allowed = file.startsWith (root) && !pathname.split ('/').some (segment => segment.startsWith ('.')) && pathname !== '/index.html' const info = allowed ? await stat (file).catch (() => null) : null if (info?.isFile ()) { response.writeHead (200, { 'Content-Type': types[path.extname (file)] ?? 'application/octet-stream', 'Content-Length': info.size, 'Cache-Control': /\/[^/]+-[\w-]{8}\.[^.]+$/.test (pathname) ? 'public, max-age=31536000, immutable' : 'public, max-age=3600' }) if (request.method === 'HEAD') response.end () else createReadStream (file).on ('error', () => response.destroy ()).pipe (response) return } if (pathname.startsWith ('/assets/')) { response.writeHead (404).end () return } const result = await render (url.pathname + url.search, origin, apiBaseURL, { transferCode: decodeSession (request.headers.cookie), forwardedFor }) if (result?.location != null) { response.writeHead (result.status, { Location: result.location }).end () return } const html = result?.html == null ? template.replace ('', result?.status === 404 ? '' : '') : template .replace ('lang="ja"', `lang="${result.locale}"`) .replace (/[\s\S]*?<\/title>/, '') .replace (/<meta name="description"[^>]*>/, '') .replace ('<!-- helmet -->', () => result.head) .replace ('<!-- outlet -->', () => result.html) .replace ('<!-- ssr-state -->', () => `<script id="ssr-state" type="application/json">${result.bootstrap}</script>`) response.writeHead (result?.status ?? 200, { 'Content-Type': 'text/html; charset=utf-8' }) response.end (request.method === 'HEAD' ? undefined : html) } catch (error) { console.error ('[SSR]', error instanceof Error ? error.message : 'Rendering failed') const denied = error?.response?.status === 401 || error?.response?.status === 403 response.writeHead (error instanceof URIError ? 400 : (denied ? error.response.status : 503)).end () } }) server.listen (Number (process.env.PORT ?? 5173), process.env.HOST ?? '127.0.0.1', () => { console.info ('[SSR] listening on', server.address ().port) })