123 行
5.0 KiB
JavaScript
123 行
5.0 KiB
JavaScript
import { createReadStream } from 'node:fs'
|
|
import { readFile, stat } from 'node:fs/promises'
|
|
import { createServer, request as requestHTTP } from 'node:http'
|
|
import { request as requestHTTPS } from 'node:https'
|
|
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 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 === '/api' || request.url.startsWith ('/api/'))
|
|
{
|
|
const address = new URL (request.url, origin)
|
|
const target = new URL (apiBaseURL)
|
|
target.pathname = target.pathname.replace (/\/$/, '') + address.pathname.slice (4)
|
|
target.search = address.search
|
|
const send = target.protocol === 'https:' ? requestHTTPS : requestHTTP
|
|
const upstream = send (target, {
|
|
method: request.method,
|
|
headers: { ...request.headers, host: target.host,
|
|
'x-forwarded-host': new URL (origin).host,
|
|
'x-forwarded-proto': new URL (origin).protocol.replace (':', ''),
|
|
'x-forwarded-for': forwardedFor } }, result => {
|
|
response.writeHead (result.statusCode ?? 502, result.headers)
|
|
result.on ('error', () => response.destroy ())
|
|
result.pipe (response)
|
|
})
|
|
upstream.setTimeout (10_000, () => upstream.destroy (new Error ('API timeout')))
|
|
upstream.on ('error', () => {
|
|
if (response.headersSent)
|
|
response.destroy ()
|
|
else
|
|
response.writeHead (503).end ()
|
|
})
|
|
request.on ('aborted', () => upstream.destroy ())
|
|
request.pipe (upstream)
|
|
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, {
|
|
cookie: request.headers.cookie, forwardedFor })
|
|
if (result?.setCookies?.length > 0)
|
|
response.setHeader ('Set-Cookie', result.setCookies)
|
|
if (result?.location != null)
|
|
{
|
|
response.writeHead (result.status, { Location: result.location }).end ()
|
|
return
|
|
}
|
|
const html = result?.html == null
|
|
? template.replace ('<!-- helmet -->', result?.status === 404
|
|
? '<meta name="robots" content="noindex" data-rh="true">' : '')
|
|
: template
|
|
.replace ('lang="ja"', `lang="${result.locale}"`)
|
|
.replace (/<title>[\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)
|
|
})
|