Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Invoke the health test from the validation workflow

The checked GitHub Actions pipeline still runs npx vite build directly in .github/workflows/validate.yml:56-57, so it never executes this newly appended npm 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 run check:server explicitly.

Useful? React with 👍 / 👎.

"preview": "vite preview",
"start": "node server.mjs"
},
Expand Down
72 changes: 72 additions & 0 deletions scripts/check-server-health.mjs
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))
]);
}
}
130 changes: 88 additions & 42 deletions server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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',
Expand All @@ -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);
}

Expand All @@ -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
);
}

Expand All @@ -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;
}

Expand All @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Node.js http server headersTimeout vs requestTimeout: does assigning both as instance properties after createServer bypass the ERR_OUT_OF_RANGE validation enforced in the constructor?

💡 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);
JS

Repository: Occumed79/Map

Length of output: 1573


Keep headersTimeout below requestTimeout.

Assigning these after createServer bypasses Node’s constructor validation, but headersTimeout = 35000 still violates Node’s headersTimeout <= requestTimeout invariant with requestTimeout = 30000. The 35s header timeout can never fire before the 30s request timeout.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server.mjs` around lines 169 - 171, Update the timeout assignments near
server initialization so headersTimeout is less than or equal to requestTimeout,
preserving the intended timeout ordering and ensuring the configured values
satisfy Node’s invariant.


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.`);
});
Loading