2026-07-18 04:22:14 +09:00
|
|
|
import { createReadStream, statSync } from 'node:fs'
|
|
|
|
|
import { createServer } from 'node:http'
|
|
|
|
|
import { extname, resolve, sep } from 'node:path'
|
2026-07-19 18:21:17 +09:00
|
|
|
import { pathToFileURL } from 'node:url'
|
2026-07-18 04:22:14 +09:00
|
|
|
|
2026-07-19 18:21:17 +09:00
|
|
|
import { loadConfig } from './server/config.mjs'
|
|
|
|
|
import { OAuthClient } from './server/oauth.mjs'
|
|
|
|
|
import { ApiProxy } from './server/proxy.mjs'
|
|
|
|
|
import { SessionStore } from './server/sessions.mjs'
|
2026-07-18 04:22:14 +09:00
|
|
|
|
2026-07-19 18:21:17 +09:00
|
|
|
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()
|
|
|
|
|
let requests = 0
|
|
|
|
|
|
|
|
|
|
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`,
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
try {
|
|
|
|
|
await route(
|
|
|
|
|
request,
|
|
|
|
|
response,
|
|
|
|
|
config,
|
|
|
|
|
store,
|
|
|
|
|
oauth,
|
|
|
|
|
proxy,
|
|
|
|
|
startedAt,
|
|
|
|
|
requests,
|
|
|
|
|
)
|
|
|
|
|
} catch {
|
|
|
|
|
if (!response.headersSent)
|
|
|
|
|
response.writeHead(502, { 'Cache-Control': 'no-store' })
|
|
|
|
|
response.end()
|
|
|
|
|
}
|
2026-07-18 04:22:14 +09:00
|
|
|
})
|
2026-07-19 18:21:17 +09:00
|
|
|
server.on('upgrade', async (request, socket, head) => {
|
|
|
|
|
try {
|
|
|
|
|
if (!(await proxy.upgrade(request, socket, head))) socket.destroy()
|
|
|
|
|
} catch {
|
|
|
|
|
socket.destroy()
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
return { server, store }
|
|
|
|
|
}
|
2026-07-18 04:22:14 +09:00
|
|
|
|
2026-07-19 18:21:17 +09:00
|
|
|
async function route(
|
|
|
|
|
request,
|
|
|
|
|
response,
|
|
|
|
|
config,
|
|
|
|
|
store,
|
|
|
|
|
oauth,
|
|
|
|
|
proxy,
|
|
|
|
|
startedAt,
|
|
|
|
|
requests,
|
|
|
|
|
) {
|
|
|
|
|
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
|
2026-07-18 04:22:14 +09:00
|
|
|
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
|
|
|
|
response.writeHead(405, { Allow: 'GET, HEAD' })
|
|
|
|
|
response.end()
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-07-19 18:21:17 +09:00
|
|
|
if (url.pathname === '/health/live') {
|
|
|
|
|
json(response, 200, { status: 'live' }, request.method)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if (url.pathname === '/health/ready') {
|
|
|
|
|
json(
|
|
|
|
|
response,
|
|
|
|
|
store.ready ? 200 : 503,
|
|
|
|
|
{ status: store.ready ? 'ready' : 'unavailable' },
|
|
|
|
|
request.method,
|
|
|
|
|
)
|
2026-07-18 04:22:14 +09:00
|
|
|
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') {
|
2026-07-19 18:21:17 +09:00
|
|
|
const body = `window.remoraRuntimeConfig=${JSON.stringify({
|
|
|
|
|
apiUrl: '/bff/api/',
|
|
|
|
|
eventsUrl: `${config.publicOrigin.replace(/^http/, 'ws')}/bff/events`,
|
|
|
|
|
})};\n`
|
2026-07-18 04:22:14 +09:00
|
|
|
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
|
|
|
|
|
}
|
2026-07-19 18:21:17 +09:00
|
|
|
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)) {
|
2026-07-18 04:22:14 +09:00
|
|
|
response.writeHead(404)
|
|
|
|
|
response.end()
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-07-19 18:21:17 +09:00
|
|
|
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')
|
2026-07-18 04:22:14 +09:00
|
|
|
? 'no-cache'
|
|
|
|
|
: 'public, max-age=31536000, immutable',
|
2026-07-19 18:21:17 +09:00
|
|
|
'Content-Type': contentType(file),
|
2026-07-18 04:22:14 +09:00
|
|
|
'Content-Length': size,
|
|
|
|
|
}
|
2026-07-19 18:21:17 +09:00
|
|
|
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`)
|
2026-07-18 04:22:14 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function requestPath(value) {
|
|
|
|
|
try {
|
|
|
|
|
return new URL(value ?? '/', 'http://localhost').pathname
|
|
|
|
|
} catch {
|
|
|
|
|
return '/'
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-19 18:21:17 +09:00
|
|
|
function safeFile(pathname, root) {
|
2026-07-18 04:22:14 +09:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-19 18:21:17 +09:00
|
|
|
function json(response, status, value, method) {
|
2026-07-18 04:22:14 +09:00
|
|
|
const body = JSON.stringify(value)
|
|
|
|
|
response.writeHead(status, {
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
'Content-Length': Buffer.byteLength(body),
|
|
|
|
|
})
|
2026-07-19 18:21:17 +09:00
|
|
|
response.end(method === 'HEAD' ? undefined : body)
|
2026-07-18 04:22:14 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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('-', '')
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-19 18:21:17 +09:00
|
|
|
async function main() {
|
|
|
|
|
const config = loadConfig()
|
|
|
|
|
const { server, store } = createWebServer(config)
|
|
|
|
|
await store.connect()
|
|
|
|
|
server.listen(config.port, '0.0.0.0', () => {
|
|
|
|
|
process.stdout.write(
|
|
|
|
|
`${JSON.stringify({
|
|
|
|
|
timestamp: new Date().toISOString(),
|
|
|
|
|
level: 'info',
|
|
|
|
|
component: 'remora-web',
|
|
|
|
|
message: 'web application ready',
|
|
|
|
|
port: config.port,
|
|
|
|
|
})}\n`,
|
|
|
|
|
)
|
|
|
|
|
})
|
2026-07-18 04:22:14 +09:00
|
|
|
}
|
|
|
|
|
|
2026-07-19 18:21:17 +09:00
|
|
|
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
|
|
|
|
|
})
|