remora/web/server.mjs
ms2k 414b380516 Harden WebBFF and deployment release behavior
Model fail-closed cross-replica session renewal and implement owner-fenced refresh, transient observer handling, logout fencing, and graceful shutdown. Remove chart-owned Ingress while retaining qualification-owned external routing.
2026-07-20 17:09:26 +09:00

408 lines
11 KiB
JavaScript

import { createReadStream, statSync } from 'node:fs'
import { createServer } from 'node:http'
import { extname, resolve, sep } from 'node:path'
import { pathToFileURL } from 'node:url'
import { loadConfig } from './server/config.mjs'
import { OAuthClient, SessionRenewalPendingError } from './server/oauth.mjs'
import { ApiProxy } from './server/proxy.mjs'
import { SessionStore } from './server/sessions.mjs'
const SHUTDOWN_TIMEOUT_MILLISECONDS = 25_000
export function createWebServer(config, dependencies = {}) {
const store = dependencies.store ?? new SessionStore(config)
const oauth =
dependencies.oauth ??
new OAuthClient(config, store, dependencies.fetchImplementation)
const proxy =
dependencies.proxy ??
new ApiProxy(config, oauth, dependencies.fetchImplementation)
const startedAt = Date.now()
const runtime = { accepting: true }
let requests = 0
let shutdownPromise
const server = createServer(async (request, response) => {
requests += 1
const traceId = traceIdentifier(request.headers.traceparent)
secureHeaders(response, traceId)
response.once('finish', () => {
process.stdout.write(
`${JSON.stringify({
timestamp: new Date().toISOString(),
level: 'info',
component: 'remora-web',
trace_id: traceId,
method: request.method,
path: requestPath(request.url),
status: response.statusCode,
})}\n`,
)
if (!runtime.accepting) server.closeIdleConnections()
})
try {
await route(
request,
response,
config,
store,
oauth,
proxy,
startedAt,
requests,
runtime,
)
} catch (error) {
if (!response.headersSent)
response.writeHead(
error instanceof SessionRenewalPendingError ? 503 : 502,
{ 'Cache-Control': 'no-store' },
)
response.end()
}
})
server.on('upgrade', async (request, socket, head) => {
try {
if (!(await proxy.upgrade(request, socket, head))) socket.destroy()
} catch (error) {
if (error instanceof SessionRenewalPendingError)
socket.end(
'HTTP/1.1 503 Service Unavailable\r\nCache-Control: no-store\r\nConnection: close\r\nContent-Length: 0\r\n\r\n',
)
else socket.destroy()
}
})
const shutdown = () => {
shutdownPromise ??= shutdownWebServer(
server,
store,
proxy,
runtime,
dependencies.shutdownTimeoutMilliseconds ?? SHUTDOWN_TIMEOUT_MILLISECONDS,
)
return shutdownPromise
}
return { server, store, shutdown }
}
async function route(
request,
response,
config,
store,
oauth,
proxy,
startedAt,
requests,
runtime,
) {
const url = new URL(request.url ?? '/', config.publicOrigin)
if (request.method === 'POST' && url.pathname === '/client-errors') {
await recordClientError(request, response, config)
return
}
if (await oauth.route(request, response, url)) return
if (await proxy.route(request, response, url)) return
if (request.method !== 'GET' && request.method !== 'HEAD') {
response.writeHead(405, { Allow: 'GET, HEAD' })
response.end()
return
}
if (url.pathname === '/health/live') {
json(response, 200, { status: 'live' }, request.method)
return
}
if (url.pathname === '/health/ready') {
json(
response,
runtime.accepting && store.ready ? 200 : 503,
{
status: runtime.accepting && store.ready ? 'ready' : 'unavailable',
},
request.method,
)
return
}
if (url.pathname === '/metrics') {
const body =
'# TYPE remora_web_build_info gauge\n' +
'remora_web_build_info 1\n' +
'# TYPE remora_web_requests_total counter\n' +
`remora_web_requests_total ${requests}\n` +
'# TYPE remora_web_uptime_seconds gauge\n' +
`remora_web_uptime_seconds ${Math.floor((Date.now() - startedAt) / 1000)}\n`
response.writeHead(200, {
'Content-Type': 'text/plain; version=0.0.4',
'Content-Length': Buffer.byteLength(body),
})
response.end(request.method === 'HEAD' ? undefined : body)
return
}
if (url.pathname === '/runtime-config.js') {
const body = `window.remoraRuntimeConfig=${JSON.stringify({
apiUrl: '/bff/api/',
eventsUrl: `${config.publicOrigin.replace(/^http/, 'ws')}/bff/events`,
})};\n`
response.writeHead(200, {
'Cache-Control': 'no-store',
'Content-Type': 'text/javascript; charset=utf-8',
'Content-Length': Buffer.byteLength(body),
})
response.end(request.method === 'HEAD' ? undefined : body)
return
}
serveStatic(request, response, url, config.root)
}
async function recordClientError(request, response, config) {
if (
request.headers.origin !== config.publicOrigin ||
request.headers['sec-fetch-site'] === 'cross-site'
) {
response.writeHead(403, { 'Cache-Control': 'no-store' })
response.end()
return
}
const chunks = []
let size = 0
for await (const chunk of request) {
size += chunk.length
if (size > 4096) {
response.writeHead(413, { 'Cache-Control': 'no-store' })
response.end()
return
}
chunks.push(chunk)
}
let body
try {
body = JSON.parse(Buffer.concat(chunks).toString())
} catch {
response.writeHead(400, { 'Cache-Control': 'no-store' })
response.end()
return
}
const validKind =
body?.kind === 'error' || body?.kind === 'unhandled_rejection'
if (
!validKind ||
typeof body.path !== 'string' ||
body.path.length > 1024 ||
!body.path.startsWith('/')
) {
response.writeHead(400, { 'Cache-Control': 'no-store' })
response.end()
return
}
process.stdout.write(
`${JSON.stringify({
timestamp: new Date().toISOString(),
level: 'error',
component: 'remora-web',
event: 'browser_error',
kind: body.kind,
path: body.path,
})}\n`,
)
response.writeHead(204, { 'Cache-Control': 'no-store' })
response.end()
}
function serveStatic(request, response, url, root) {
const candidate = safeFile(url.pathname, root)
let file = candidate && regularFile(candidate) ? candidate : null
if (
file === null &&
request.headers.accept?.includes('text/html') &&
!url.pathname.includes('.')
)
file = resolve(root, 'index.html')
if (file === null || !regularFile(file)) {
response.writeHead(404)
response.end()
return
}
const compressed = compressedFile(file, request.headers['accept-encoding'])
const served = compressed?.path ?? file
const size = statSync(served).size
const headers = {
'Cache-Control': file.endsWith('index.html')
? 'no-cache'
: 'public, max-age=31536000, immutable',
'Content-Type': contentType(file),
'Content-Length': size,
}
if (compressed !== null) {
headers['Content-Encoding'] = compressed.encoding
headers.Vary = 'Accept-Encoding'
}
response.writeHead(200, headers)
if (request.method === 'HEAD') response.end()
else createReadStream(served).pipe(response)
}
function compressedFile(path, accepted = '') {
for (const [encoding, suffix] of [
['br', '.br'],
['gzip', '.gz'],
])
if (accepted.includes(encoding) && regularFile(`${path}${suffix}`))
return { encoding, path: `${path}${suffix}` }
return null
}
function secureHeaders(response, traceId) {
response.setHeader('X-Content-Type-Options', 'nosniff')
response.setHeader('Referrer-Policy', 'no-referrer')
response.setHeader('X-Frame-Options', 'DENY')
response.setHeader('Cross-Origin-Opener-Policy', 'same-origin')
response.setHeader(
'Content-Security-Policy',
"default-src 'self'; base-uri 'none'; frame-ancestors 'none'; object-src 'none'; connect-src 'self'",
)
response.setHeader('Traceparent', `00-${traceId}-0000000000000001-01`)
}
function requestPath(value) {
try {
return new URL(value ?? '/', 'http://localhost').pathname
} catch {
return '/'
}
}
function safeFile(pathname, root) {
let decoded
try {
decoded = decodeURIComponent(pathname)
} catch {
return null
}
const candidate = resolve(root, `.${decoded}`)
return candidate === root || candidate.startsWith(`${root}${sep}`)
? candidate
: null
}
function regularFile(path) {
try {
return statSync(path).isFile()
} catch {
return false
}
}
function json(response, status, value, method) {
const body = JSON.stringify(value)
response.writeHead(status, {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
})
response.end(method === 'HEAD' ? undefined : body)
}
function contentType(path) {
return (
{
'.css': 'text/css; charset=utf-8',
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.json': 'application/json',
'.svg': 'image/svg+xml',
'.woff2': 'font/woff2',
}[extname(path)] ?? 'application/octet-stream'
)
}
function traceIdentifier(traceparent) {
const match =
typeof traceparent === 'string'
? /^00-([0-9a-f]{32})-[0-9a-f]{16}-[0-9a-f]{2}$/.exec(traceparent)
: null
return match?.[1] ?? crypto.randomUUID().replaceAll('-', '')
}
async function main() {
const config = loadConfig()
const { server, store, shutdown } = createWebServer(config)
const stopping = terminationSignal()
await store.connect()
await listen(server, config.port)
process.stdout.write(
`${JSON.stringify({
timestamp: new Date().toISOString(),
level: 'info',
component: 'remora-web',
message: 'web application ready',
port: config.port,
})}\n`,
)
const signal = await stopping
process.stdout.write(
`${JSON.stringify({
timestamp: new Date().toISOString(),
level: 'info',
component: 'remora-web',
message: 'web application stopping',
signal,
})}\n`,
)
await shutdown()
}
if (import.meta.url === pathToFileURL(process.argv[1] || '').href)
main().catch((error) => {
process.stderr.write(
`${JSON.stringify({
timestamp: new Date().toISOString(),
level: 'error',
component: 'remora-web',
message: error.message,
})}\n`,
)
process.exitCode = 1
})
async function shutdownWebServer(
server,
store,
proxy,
runtime,
timeoutMilliseconds,
) {
runtime.accepting = false
proxy.close?.()
server.closeIdleConnections()
await new Promise((resolve, reject) => {
const timeout = setTimeout(
() => server.closeAllConnections(),
timeoutMilliseconds,
)
timeout.unref()
server.close((error) => {
clearTimeout(timeout)
if (error && error.code !== 'ERR_SERVER_NOT_RUNNING') reject(error)
else resolve()
})
})
await store.close?.()
}
function listen(server, port) {
return new Promise((resolve, reject) => {
const failed = (error) => reject(error)
server.once('error', failed)
server.listen(port, '0.0.0.0', () => {
server.off('error', failed)
resolve()
})
})
}
function terminationSignal() {
return new Promise((resolve) => {
process.once('SIGINT', () => resolve('SIGINT'))
process.once('SIGTERM', () => resolve('SIGTERM'))
})
}