diff --git a/package.json b/package.json index db12cc4c..984e8b76 100644 --- a/package.json +++ b/package.json @@ -9,9 +9,10 @@ "prepare:assets": "npm run prepare:sprites && npm run prepare:style", "check:export": "node scripts/check-export.mjs", "check:runtime": "node scripts/check-runtime.mjs && node scripts/validate-maplibre-style.mjs && node scripts/check-globe-parity.mjs && node scripts/check-cartography-parity.mjs && node scripts/check-viewer-quality.mjs && node scripts/check-photo-reference.mjs", + "check:server": "node scripts/check-server-health.mjs", "check": "npm run check:export && npm run check:runtime", "dev": "npm run prepare:assets && vite", - "build": "npm run prepare:assets && npm run check && vite build", + "build": "npm run prepare:assets && npm run check && vite build && npm run check:server", "preview": "vite preview", "start": "node server.mjs" }, diff --git a/scripts/check-server-health.mjs b/scripts/check-server-health.mjs new file mode 100644 index 00000000..b4d487f5 --- /dev/null +++ b/scripts/check-server-health.mjs @@ -0,0 +1,72 @@ +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import http from 'node:http'; + +const port = 43173; +const child = spawn(process.execPath, ['server.mjs'], { + env: { ...process.env, PORT: String(port), HOST: '0.0.0.0' }, + stdio: ['ignore', 'pipe', 'pipe'] +}); + +let output = ''; +child.stdout.on('data', (chunk) => { + output += chunk.toString(); +}); +child.stderr.on('data', (chunk) => { + output += chunk.toString(); +}); + +async function request(method) { + return new Promise((resolve, reject) => { + const req = http.request( + { + hostname: '127.0.0.1', + port, + path: '/health?render-probe=1', + method, + timeout: 3000, + headers: { Host: `map-yxjb.onrender.com:${port}` } + }, + (response) => { + let body = ''; + response.setEncoding('utf8'); + response.on('data', (chunk) => { + body += chunk; + }); + response.on('end', () => resolve({ status: response.statusCode, body })); + } + ); + req.on('timeout', () => req.destroy(new Error(`${method} /health timed out`))); + req.on('error', reject); + req.end(); + }); +} + +try { + const deadline = Date.now() + 8000; + while (!output.includes('Health endpoint ready')) { + if (child.exitCode !== null) throw new Error(`Server exited early with code ${child.exitCode}.\n${output}`); + if (Date.now() > deadline) throw new Error(`Server did not become ready.\n${output}`); + await new Promise((resolve) => setTimeout(resolve, 100)); + } + + const get = await request('GET'); + if (get.status !== 200 || get.body !== 'ok') { + throw new Error(`GET /health returned ${get.status} with body ${JSON.stringify(get.body)}`); + } + + const head = await request('HEAD'); + if (head.status !== 200 || head.body !== '') { + throw new Error(`HEAD /health returned ${head.status} with body ${JSON.stringify(head.body)}`); + } + + console.log('Server health check passed for GET and HEAD with a Render-style Host header.'); +} finally { + child.kill('SIGTERM'); + if (child.exitCode === null) { + await Promise.race([ + once(child, 'exit'), + new Promise((resolve) => setTimeout(resolve, 2000)) + ]); + } +} diff --git a/server.mjs b/server.mjs index 26e04ff3..5e3c6bcc 100644 --- a/server.mjs +++ b/server.mjs @@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url'; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'dist'); const port = Number(process.env.PORT || 4173); +const host = process.env.HOST?.trim() || '0.0.0.0'; const contentTypes = { '.css': 'text/css; charset=utf-8', @@ -25,11 +26,11 @@ function requestOrigin(request) { const forwardedProtocol = String(request.headers['x-forwarded-proto'] || '').split(',')[0].trim(); const forwardedHost = String(request.headers['x-forwarded-host'] || '').split(',')[0].trim(); const protocol = forwardedProtocol || 'http'; - const host = forwardedHost || request.headers.host || `localhost:${port}`; - return `${protocol}://${host}`; + const requestHost = forwardedHost || request.headers.host || `localhost:${port}`; + return `${protocol}://${requestHost}`; } -function send(response, status, body, contentType, cacheControl = 'no-store') { +function send(response, status, body, contentType, cacheControl = 'no-store', method = 'GET') { response.writeHead(status, { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS', @@ -41,7 +42,20 @@ function send(response, status, body, contentType, cacheControl = 'no-store') { 'Cross-Origin-Resource-Policy': 'cross-origin', 'X-Content-Type-Options': 'nosniff' }); - if (response.req.method === 'HEAD') response.end(); + if (method === 'HEAD') response.end(); + else response.end(body); +} + +function sendHealth(request, response) { + const body = 'ok'; + response.statusCode = 200; + response.shouldKeepAlive = false; + response.setHeader('Content-Type', 'text/plain; charset=utf-8'); + response.setHeader('Content-Length', Buffer.byteLength(body)); + response.setHeader('Cache-Control', 'no-store'); + response.setHeader('Connection', 'close'); + response.setHeader('X-Content-Type-Options', 'nosniff'); + if (request.method === 'HEAD') response.end(); else response.end(body); } @@ -54,7 +68,8 @@ async function serveStyle(request, response) { 200, resolved, contentTypes['.json'], - 'no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0' + 'no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0', + request.method ); } @@ -64,7 +79,7 @@ async function serveStatic(request, response, pathname) { const absolute = path.resolve(root, `.${decoded}`); if (!absolute.startsWith(`${root}${path.sep}`) && absolute !== path.join(root, 'index.html')) { - send(response, 403, 'Forbidden', 'text/plain; charset=utf-8'); + send(response, 403, 'Forbidden', 'text/plain; charset=utf-8', 'no-store', request.method); return; } @@ -79,57 +94,88 @@ async function serveStatic(request, response, pathname) { 200, body, contentTypes[extension] || 'application/octet-stream', - longLived ? 'public, max-age=31536000, immutable' : 'no-store, no-cache, must-revalidate, max-age=0' + longLived ? 'public, max-age=31536000, immutable' : 'no-store, no-cache, must-revalidate, max-age=0', + request.method ); } catch { const index = await fs.readFile(path.join(root, 'index.html')); - send(response, 200, index, contentTypes['.html'], 'no-store, no-cache, must-revalidate, max-age=0'); + send( + response, + 200, + index, + contentTypes['.html'], + 'no-store, no-cache, must-revalidate, max-age=0', + request.method + ); } } -const server = http.createServer(async (request, response) => { - try { - if (request.method === 'OPTIONS') { - response.writeHead(204, { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type, Cache-Control, Pragma', - 'Cache-Control': 'no-store' - }); - response.end(); - return; - } +async function handleRequest(request, response) { + const method = request.method || 'GET'; + + if (method === 'OPTIONS') { + response.writeHead(204, { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Cache-Control, Pragma', + 'Cache-Control': 'no-store' + }); + response.end(); + return; + } - if (!['GET', 'HEAD'].includes(request.method || '')) { - send(response, 405, 'Method not allowed', 'text/plain; charset=utf-8'); - return; - } + if (!['GET', 'HEAD'].includes(method)) { + send(response, 405, 'Method not allowed', 'text/plain; charset=utf-8', 'no-store', method); + return; + } - const url = new URL(request.url || '/', `http://${request.headers.host || 'localhost'}`); + const url = new URL(request.url || '/', `http://${request.headers.host || 'localhost'}`); - if (url.pathname === '/health') { + if (url.pathname === '/style/occumed-open.json') { + await serveStyle(request, response); + return; + } + + await serveStatic(request, response, url.pathname); +} + +const server = http.createServer((request, response) => { + const rawPath = (request.url || '/').split('?', 1)[0]; + + // Keep Render's deployment probe completely independent from URL parsing, + // filesystem access, the built map assets, and all application routing. + if (rawPath === '/health' || rawPath === '/healthz') { + sendHealth(request, response); + return; + } + + void handleRequest(request, response).catch((error) => { + console.error(error); + if (!response.headersSent) { send( response, - 200, - JSON.stringify({ ok: true, service: 'occumed-map' }), - contentTypes['.json'], - 'no-store' + 500, + 'Internal server error', + 'text/plain; charset=utf-8', + 'no-store', + request.method ); - return; + } else { + response.destroy(); } + }); +}); - if (url.pathname === '/style/occumed-open.json') { - await serveStyle(request, response); - return; - } +server.requestTimeout = 30_000; +server.headersTimeout = 35_000; +server.keepAliveTimeout = 5_000; - await serveStatic(request, response, url.pathname); - } catch (error) { - console.error(error); - send(response, 500, 'Internal server error', 'text/plain; charset=utf-8'); - } +server.on('error', (error) => { + console.error('Occu-Med Map server error:', error); + process.exitCode = 1; }); -server.listen(port, '0.0.0.0', () => { - console.log(`Occu-Med Map listening on port ${port}.`); +server.listen(port, host, () => { + console.log(`Occu-Med Map listening on ${host}:${port}.`); + console.log(`Health endpoint ready at http://127.0.0.1:${port}/health.`); });