remora/web/server.mjs

226 lines
6.7 KiB
JavaScript

import { createReadStream, statSync } from 'node:fs'
import { createServer } from 'node:http'
import { extname, resolve, sep } from 'node:path'
const root = resolve(process.env.REMORA_WEB_ROOT ?? '/app/dist')
const port = numberEnvironment('REMORA_WEB_PORT', 8080)
const runtimeConfig = {
apiUrl: environment('REMORA_WEB_API_URL', '/v1/'),
eventsUrl: environment('REMORA_WEB_EVENTS_URL', ''),
oidcAuthority: environment('REMORA_WEB_OIDC_AUTHORITY', '/oidc'),
oidcClientId: environment('REMORA_WEB_OIDC_CLIENT_ID', 'remora-web'),
oidcRedirectUri: environment('REMORA_WEB_OIDC_REDIRECT_URI', '/'),
oidcPostLogoutRedirectUri: environment(
'REMORA_WEB_OIDC_POST_LOGOUT_REDIRECT_URI',
'/',
),
oidcScope: environment('REMORA_WEB_OIDC_SCOPE', 'openid profile'),
}
const startedAt = Date.now()
let requests = 0
createServer((request, response) => {
requests += 1
const traceId = traceIdentifier(request.headers.traceparent)
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', contentSecurityPolicy())
response.setHeader('Traceparent', `00-${traceId}-0000000000000001-01`)
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`,
)
})
route(request, response)
}).listen(port, '0.0.0.0', () => {
process.stdout.write(
`${JSON.stringify({
timestamp: new Date().toISOString(),
level: 'info',
component: 'remora-web',
message: 'web application ready',
port,
})}\n`,
)
})
function route(request, response) {
if (request.method !== 'GET' && request.method !== 'HEAD') {
response.writeHead(405, { Allow: 'GET, HEAD' })
response.end()
return
}
const url = new URL(request.url ?? '/', 'http://localhost')
if (url.pathname === '/health/live' || url.pathname === '/health/ready') {
json(response, 200, { status: 'ready' })
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(
normalizedRuntimeConfig(),
)};\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
}
const candidate = safeFile(url.pathname)
const file = candidate ?? resolve(root, 'index.html')
const body = regularFile(file) ? file : resolve(root, 'index.html')
if (!regularFile(body)) {
response.writeHead(404)
response.end()
return
}
const size = statSync(body).size
response.writeHead(200, {
'Cache-Control': body.endsWith('index.html')
? 'no-cache'
: 'public, max-age=31536000, immutable',
'Content-Type': contentType(body),
'Content-Length': size,
})
if (request.method === 'HEAD') {
response.end()
} else {
createReadStream(body).pipe(response)
}
}
function requestPath(value) {
try {
return new URL(value ?? '/', 'http://localhost').pathname
} catch {
return '/'
}
}
function normalizedRuntimeConfig() {
const origin = requiredEnvironment('REMORA_WEB_PUBLIC_ORIGIN')
return {
...runtimeConfig,
eventsUrl:
runtimeConfig.eventsUrl ||
absolute(runtimeConfig.apiUrl.replace(/\/?$/, '/events'), origin, true),
oidcAuthority: absolute(runtimeConfig.oidcAuthority, origin, false),
oidcRedirectUri: absolute(runtimeConfig.oidcRedirectUri, origin, false),
oidcPostLogoutRedirectUri: absolute(
runtimeConfig.oidcPostLogoutRedirectUri,
origin,
false,
),
}
}
function absolute(value, origin, websocket) {
if (/^[a-z][a-z0-9+.-]*:/i.test(value)) return value
if (!origin) return value
const url = new URL(value, origin)
if (websocket) url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
return url.toString()
}
function safeFile(pathname) {
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) {
const body = JSON.stringify(value)
response.writeHead(status, {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
})
response.end(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 contentSecurityPolicy() {
const connect = environment('REMORA_WEB_CSP_CONNECT_SRC', "'self'")
return `default-src 'self'; base-uri 'none'; frame-ancestors 'none'; object-src 'none'; connect-src ${connect}`
}
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('-', '')
}
function environment(name, fallback) {
const value = process.env[name]
return value === undefined || value === '' ? fallback : value
}
function requiredEnvironment(name) {
const value = process.env[name]
if (value === undefined || value === '') {
throw new Error(`${name} is required`)
}
return value
}
function numberEnvironment(name, fallback) {
const value = Number(environment(name, String(fallback)))
if (!Number.isSafeInteger(value) || value < 1 || value > 65535) {
throw new Error(`${name} must be a valid port`)
}
return value
}