openvibers/openvibe.shared ready.js

ready.js

main 8.1 KB · 148 lines · javascript

1'use strict';
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
32const 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. */
35function 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
44function 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
50function 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`. */
143function skip(reason = 'not checked', detail) {
144 return detail === undefined ? { skipped: String(reason || 'not checked') } : { skipped: String(reason || 'not checked'), detail };
145}
146
147module.exports = { createReadiness, safeReason, skip };
148