openvibers/openvibe.shared ready.js
ready.js
| 1 | ; |
| 2 | // ═══════════════════════════════════════════════════════════════ |
| 3 | // openvibe-shared/ready — a truthful readiness response (roadmap Track O, §15.19: "readiness must |
| 4 | // report the capability actually served"). |
| 5 | // |
| 6 | // const ready = require('openvibe-shared/ready').createReadiness({ |
| 7 | // service: 'community', release: release.release, |
| 8 | // checks: [ |
| 9 | // { name: 'db', required: true, check: () => db.prepare('SELECT 1 AS ok').get().ok === 1 }, |
| 10 | // { name: 'network_jwks', required: true, check: () => keys.loaded() || 'not loaded yet' }, |
| 11 | // { name: 'media', required: false, cacheMs: 30000, check: async () => { … } }, |
| 12 | // ], |
| 13 | // details: () => ({ queue_depth: store.depth() }), // optional extra fields |
| 14 | // }); |
| 15 | // app.get('/api/ready', ready.handler); |
| 16 | // |
| 17 | // Every check runs on each request (or from its cache, keeping the time it really ran) and reports |
| 18 | // status, latency_ms and checked_at. A check passes when it returns anything but `false` or a string |
| 19 | // (`false`/a string is a failure; the string is the reason) or `{ ok: false, detail }`, and does not |
| 20 | // throw or time out. Overall `ready` is false only when a REQUIRED check fails (HTTP 503); a failed |
| 21 | // optional check keeps the service ready (HTTP 200) and is listed in `degraded`, so a missing optional |
| 22 | // dependency never reads as "healthy" or as "down". |
| 23 | // |
| 24 | // No fake green (roadmap WS-Q task 7): a check that did not verify anything (its feature is not |
| 25 | // configured, its dependency is switched off) returns `skip('not configured')` ({ skipped: reason }) |
| 26 | // and reports status `skipped` with the reason, never `ok`. Skipped checks are listed in `skipped`. |
| 27 | // A skipped REQUIRED check is also `degraded` (yellow: the service cannot say it serves that), and a |
| 28 | // service is `ready` (green) only when at least one check really passed: none, or all skipped, is |
| 29 | // `degraded`. |
| 30 | // ═══════════════════════════════════════════════════════════════ |
| 31 | |
| 32 | const NAME_RE = /^[a-z][a-z0-9_.-]{0,63}$/; |
| 33 | |
| 34 | /** A failure reason safe to show publicly: one line, no URL query strings or credentials, short. */ |
| 35 | function safeReason(v) { |
| 36 | let s = v instanceof Error ? (v.name === 'TimeoutError' || v.name === 'AbortError' ? 'timeout' : v.message) : String(v); |
| 37 | s = s.split('\n')[0] |
| 38 | .replace(/(\b[a-z][a-z0-9+.-]*:\/\/)[^/@\s]*@/gi, '$1') // user:pass@ |
| 39 | .replace(/(\b[a-z][a-z0-9+.-]*:\/\/[^\s?#]*)[?#][^\s]*/gi, '$1') // ?query / #fragment |
| 40 | .replace(/\b(?:key|token|secret|password|signature)=[^\s&]+/gi, (m) => `${m.split('=')[0]}=…`); |
| 41 | return s.slice(0, 200) || 'failed'; |
| 42 | } |
| 43 | |
| 44 | function withTimeout(promise, ms) { |
| 45 | let t; |
| 46 | const timer = new Promise((_, reject) => { t = setTimeout(() => reject(Object.assign(new Error('timeout'), { name: 'TimeoutError' })), ms); }); |
| 47 | return Promise.race([promise, timer]).finally(() => clearTimeout(t)); |
| 48 | } |
| 49 | |
| 50 | function createReadiness({ service, release = null, checks = [], details = null, timeoutMs = 2000, now = () => new Date() } = {}) { |
| 51 | if (!service) throw new TypeError('createReadiness: service required'); |
| 52 | const list = checks.map((c) => { |
| 53 | if (!NAME_RE.test(String(c.name || ''))) throw new TypeError(`createReadiness: bad check name ${c.name}`); |
| 54 | if (typeof c.check !== 'function') throw new TypeError(`createReadiness: check ${c.name} needs a function`); |
| 55 | return { name: c.name, required: c.required !== false, check: c.check, timeoutMs: c.timeoutMs || timeoutMs, cacheMs: Math.max(0, c.cacheMs || 0), description: c.description || null }; |
| 56 | }); |
| 57 | const names = new Set(); |
| 58 | for (const c of list) { if (names.has(c.name)) throw new Error(`createReadiness: duplicate check ${c.name}`); names.add(c.name); } |
| 59 | const cache = new Map(); // name -> { at: ms, result } |
| 60 | const inflight = new Map(); |
| 61 | |
| 62 | async function runOne(c) { |
| 63 | const hit = cache.get(c.name); |
| 64 | if (hit && c.cacheMs && Date.now() - hit.at < c.cacheMs) return hit.result; |
| 65 | if (inflight.has(c.name)) return inflight.get(c.name); |
| 66 | const p = (async () => { |
| 67 | const t0 = process.hrtime.bigint(); |
| 68 | const out = { status: 'ok', required: c.required }; |
| 69 | try { |
| 70 | const v = await withTimeout(Promise.resolve().then(() => c.check()), c.timeoutMs); |
| 71 | if (v === false) { out.status = 'fail'; out.error = 'check returned false'; } |
| 72 | else if (typeof v === 'string') { out.status = 'fail'; out.error = safeReason(v); } |
| 73 | else if (v && typeof v === 'object' && !Array.isArray(v) && v.skipped) { |
| 74 | out.status = 'skipped'; |
| 75 | out.reason = safeReason(v.skipped === true ? 'not checked' : v.skipped); |
| 76 | if (v.detail !== undefined) out.detail = v.detail; |
| 77 | } |
| 78 | else if (v && typeof v === 'object' && !Array.isArray(v)) { |
| 79 | if (v.ok === false) { out.status = 'fail'; out.error = safeReason(v.error || v.detail || 'failed'); } |
| 80 | if (v.detail !== undefined && (v.ok !== false || typeof v.detail === 'object')) out.detail = v.detail; |
| 81 | } |
| 82 | } catch (err) { |
| 83 | out.status = 'fail'; |
| 84 | out.error = safeReason(err); |
| 85 | } |
| 86 | out.latency_ms = Math.round(Number(process.hrtime.bigint() - t0) / 1e5) / 10; |
| 87 | out.checked_at = now().toISOString(); |
| 88 | if (c.description) out.description = c.description; |
| 89 | cache.set(c.name, { at: Date.now(), result: out }); |
| 90 | return out; |
| 91 | })().finally(() => inflight.delete(c.name)); |
| 92 | inflight.set(c.name, p); |
| 93 | return p; |
| 94 | } |
| 95 | |
| 96 | async function run() { |
| 97 | const results = await Promise.all(list.map(runOne)); |
| 98 | const out = {}; |
| 99 | const failed = []; const degraded = []; const skipped = []; |
| 100 | list.forEach((c, i) => { |
| 101 | out[c.name] = results[i]; |
| 102 | if (results[i].status === 'skipped') { skipped.push(c.name); if (c.required) degraded.push(c.name); } |
| 103 | else if (results[i].status !== 'ok') (c.required ? failed : degraded).push(c.name); |
| 104 | }); |
| 105 | const ready = failed.length === 0; |
| 106 | // Green is built from checks that passed, never from skipped ones or from none. |
| 107 | const verified = results.some((r) => r.status === 'ok'); |
| 108 | const body = { |
| 109 | ready, |
| 110 | status: !ready ? 'not_ready' : degraded.length || !verified ? 'degraded' : 'ready', |
| 111 | service, |
| 112 | release: typeof release === 'function' ? release() : release, |
| 113 | checked_at: now().toISOString(), |
| 114 | failed, |
| 115 | degraded, |
| 116 | skipped, |
| 117 | checks: out, |
| 118 | }; |
| 119 | if (details) { |
| 120 | try { |
| 121 | const extra = await withTimeout(Promise.resolve().then(() => details(body)), timeoutMs); |
| 122 | if (extra && typeof extra === 'object') for (const [k, v] of Object.entries(extra)) if (!(k in body)) body[k] = v; |
| 123 | } catch (err) { body.details_error = safeReason(err); } |
| 124 | } |
| 125 | return body; |
| 126 | } |
| 127 | |
| 128 | async function handler(_req, res) { |
| 129 | let body; |
| 130 | try { body = await run(); } catch (err) { |
| 131 | body = { ready: false, status: 'not_ready', service, checked_at: now().toISOString(), failed: ['readiness'], degraded: [], skipped: [], checks: {}, error: safeReason(err) }; |
| 132 | } |
| 133 | res.statusCode = body.ready ? 200 : 503; |
| 134 | res.setHeader('Content-Type', 'application/json; charset=utf-8'); |
| 135 | res.setHeader('Cache-Control', 'no-store'); |
| 136 | res.end(JSON.stringify(body)); |
| 137 | } |
| 138 | |
| 139 | return { run, handler, checks: list.map((c) => ({ name: c.name, required: c.required })) }; |
| 140 | } |
| 141 | |
| 142 | /** What a check returns when it verified nothing: status `skipped` with this reason, never `ok`. */ |
| 143 | function skip(reason = 'not checked', detail) { |
| 144 | return detail === undefined ? { skipped: String(reason || 'not checked') } : { skipped: String(reason || 'not checked'), detail }; |
| 145 | } |
| 146 | |
| 147 | module.exports = { createReadiness, safeReason, skip }; |
| 148 |