-
Notifications
You must be signed in to change notification settings - Fork 0
Make Render health checks immediate and deterministic #19
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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)) | ||
| ]); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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; | ||
|
Comment on lines
+169
to
+171
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win 🧩 Analysis chain🌐 Web query:
💡 Result: No, assigning headersTimeout and requestTimeout as instance properties after the http.Server instance has been created does not trigger the ERR_OUT_OF_RANGE validation enforced in the constructor [1]. The ERR_OUT_OF_RANGE validation for these timeouts is part of the internal server option storage logic, which executes specifically during the construction of the http.Server instance (via http.createServer or new http.Server) [1][2][3]. Once the instance is initialized, these properties are exposed on the object, and you can manually overwrite them [4]. Node.js does not re-validate these instance properties when they are modified directly at runtime [1]. While it is technically possible to bypass the constructor's validation by assigning values after initialization, doing so is discouraged as it may lead to inconsistent server behavior or unexpected connection timeouts [4][5]. Node.js relies on these values to manage connection lifetimes and mitigate potential Denial-of-Service attacks, and the internal logic expects that headersTimeout is less than or equal to requestTimeout [4][6][1]. If you set them to invalid values, the server's internal connection-tracking mechanisms may not operate as intended [6][5]. Citations:
🏁 Script executed: #!/bin/bash
set -euo pipefail
printf 'Node runtime availability:\n'
node --version
printf '\nInspect relevant server.mjs lines:\n'
if [ -f server.mjs ]; then
nl -ba server.mjs | sed -n '150,180p'
else
fd -i '^server\.mjs$' -x sh -c 'echo "--- $1"; nl -ba "$1" | sed -n "150,180p"' sh {}
fi
printf '\nCheck Node http.Server behavior for constructor validation and post-init property assignment:\n'
node - <<'JS'
const http = require('http');
console.log('--- constructor with requestTimeout=30000, headersTimeout=35000 ---');
try {
const bad = http.createServer({ requestTimeout: 30000, headersTimeout: 35000 });
console.log('created:', bad.headersTimeout, bad.requestTimeout);
} catch (err) {
console.log(err.name, err.code, err.message);
}
console.log('\n--- post-init assignment with same values ---');
const good = http.createServer({ requestTimeout: 30000, headersTimeout: 35000 });
good.headersTimeout = 35000;
good.requestTimeout = 30000;
console.log('created with same values:', good.headersTimeout, good.requestTimeout);
good.headersTimeout = 35000;
console.log('after post-init headersTimeout=', good.headersTimeout);
good.requestTimeout = 30000;
console.log('after post-init requestTimeout=', good.requestTimeout);Repository: Occumed79/Map Length of output: 263 🏁 Script executed: #!/bin/bash
set -u
printf 'Inspect relevant server.mjs lines:\n'
if [ -f server.mjs ]; then
sed -n '150,180p' server.mjs | cat -n
else
for f in $(fd -i '^server\.mjs$' . 2>/dev/null); do
echo "--- $f"
sed -n '150,180p' "$f" | cat -n
done
fi
printf '\nCheck Node http.Server behavior for constructor validation and post-init property assignment:\n'
node - <<'JS'
const http = require('http');
console.log('--- constructor with requestTimeout=30000, headersTimeout=35000 ---');
try {
const bad = http.createServer({ requestTimeout: 30000, headersTimeout: 35000 });
console.log('created:', bad.headersTimeout, bad.requestTimeout);
} catch (err) {
console.log(err.name, err.code, err.message);
}
console.log('\n--- post-init assignment with same values ---');
const good = http.createServer({ requestTimeout: 30000, headersTimeout: 10000 });
good.headersTimeout = 35000;
good.requestTimeout = 30000;
console.log('initial headersTimeout=', good.headersTimeout, 'requestTimeout=', good.requestTimeout);
console.log('after post-init headersTimeout=', good.headersTimeout, 'requestTimeout=', good.requestTimeout);
JSRepository: Occumed79/Map Length of output: 1573 Keep Assigning these after 🤖 Prompt for AI Agents |
||
|
|
||
| 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.`); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The checked GitHub Actions pipeline still runs
npx vite builddirectly in.github/workflows/validate.yml:56-57, so it never executes this newly appendednpm run check:server. Consequently, pull requests can pass CI even when the health endpoint test fails, contrary to the intended CI readiness validation; update that workflow to call this build script or runcheck:serverexplicitly.Useful? React with 👍 / 👎.