openvibers/openvibe.shared Commits

1.3.0: openvibe-shared/metrics (Prometheus registry, golden-signal HTTP middleware by route template, process metrics, release_info, loopback-only /metrics) and openvibe-shared/ready (named required/optional checks; degraded never reads as down)

OP OpenVibers committed 23 Sept 2026 · 59fa8f9e75 · 11 files, +1626 −7


Changes

ddiff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
iindex e8c46a6..b669b98 100644
---- a/.github/workflows/ci.yml
++++ b/.github/workflows/ci.yml
@@@ -24,4 +24,4 @@ jobs:
git archive --format=tar.gz --prefix=OpenVibe.Shared-ci/ -o "$RUNNER_TEMP/openvibe-shared.tgz" HEAD
mkdir "$RUNNER_TEMP/consumer" && cd "$RUNNER_TEMP/consumer" && npm init -y >/dev/null
npm install --no-audit --no-fund "$RUNNER_TEMP/openvibe-shared.tgz"
-- node -e "require('openvibe-shared'); require('openvibe-shared/navbar'); require('openvibe-shared/legal'); const f = require('openvibe-shared/files'); for (const n of f.BROWSER) require('fs').accessSync(f.path(n)); console.log('ok', require('openvibe-shared/package.json').version)"
++ node -e "require('openvibe-shared'); require('openvibe-shared/navbar'); require('openvibe-shared/legal'); require('openvibe-shared/metrics'); require('openvibe-shared/ready'); const f = require('openvibe-shared/files'); for (const n of f.BROWSER) require('fs').accessSync(f.path(n)); console.log('ok', require('openvibe-shared/package.json').version)"
ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex be9b849..de4348e 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -4,6 +4,30 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
breaking change to any exported module, browser global or served file name is a new major.
A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
++## 1.3.0 — 2026-09-23
++
++Observability and readiness (roadmap Track O, §15.19). Additive only; both modules are Node-only
++and have no dependencies.
++
++- `openvibe-shared/metrics`: a Prometheus text-format registry (counter, gauge with optional
++ scrape-time `collect()`, histogram with fixed buckets) that caps every metric's label
++ combinations (past `maxSeries` they fold into one `_overflow` series, counted by
++ `metrics_series_overflow_total`). `instrument(app, { service, release })` mounts the golden-signal
++ middleware — `http_requests_total{method,route,status_class}`,
++ `http_request_duration_seconds{method,route}`, `http_requests_in_flight` — keyed by the route
++ TEMPLATE (`req.baseUrl` with id-like segments as `:id` + `req.route.path`, or a caller's
++ `normalize(req)`; unmatched requests are `unmatched`, never the raw URL), process metrics
++ (`process_resident_memory_bytes`, `nodejs_heap_*`, `nodejs_eventloop_lag_seconds{stat}`,
++ `process_cpu_seconds_total`, `process_uptime_seconds`), `release_info{service,release}`, and
++ `GET /metrics`, which answers only a direct loopback caller (127.0.0.1/::1 with no
++ `X-Forwarded-For`, `X-Real-IP`, `Forwarded` or `CF-Connecting-IP`) and 404s everyone else.
++- `openvibe-shared/ready`: `createReadiness({ service, release, checks, details })` → `handler` and
++ `run()`. Each named check reports `status`, `required`, `latency_ms` and `checked_at` (a cached
++ check keeps the time it really ran). `ready` is false (HTTP 503) only when a required check fails;
++ failed optional checks are listed in `degraded` and the service stays ready (`status:
++ "degraded"`). Failure reasons are one line with URL credentials, query strings and
++ `token=`/`key=` values removed.
++
## 1.2.1 — 2026-09-23
- `release-watch.js` never reloads a tab that is playing `<video>`/`<audio>` or holds a live
ddiff --git a/README.md b/README.md
iindex 2a4eff0..9935ae6 100644
---- a/README.md
++++ b/README.md
@@@ -18,7 +18,7 @@ were reconciled first.
| Kind | Files |
|---|---|
| Browser scripts, served at `/shared/<file>` (listed in `files.js`) | `navbar.js`, `nav-icons.js`, `theme-loader.js`, `footer.js`, `notification-ui.js`, `account-switcher.js`, `user-card.js`, `ov-mark.js`, `ov-icons.js`, `history.js`, `sso-client.js`, `panels.js`, `ui.js`, `island.js`, `tooltip.js`, `openvibe-sw.js` |
--| Node modules (`require('openvibe-shared/<name>')`) | `index` (`.`), `analytics`, `app-icon`, `auth-client`, `brand`, `builtin-themes`, `chrome-ssr`, `legal`, `middleware`, `notifications`, `seo`, `theme-sync`, `url-resolver`, `files`; `footer` and `icons` (= `ov-icons.js`) work on both sides |
++| Node modules (`require('openvibe-shared/<name>')`) | `index` (`.`), `analytics`, `app-icon`, `auth-client`, `brand`, `builtin-themes`, `chrome-ssr`, `legal`, `middleware`, `notifications`, `seo`, `theme-sync`, `url-resolver`, `files`, `release`, `metrics`, `ready`; `footer` and `icons` (= `ov-icons.js`) work on both sides |
| Generators | `scripts/build-nav-icons.py` (Font Awesome glyphs → `nav-icons.js`, `ov-icons.js`), `scripts/build-navbar-icons.js` (navbar.js's built-in glyphs), `scripts/build-theme-loader.js`, `scripts/build-app-icons.js` |
Every module file has an `exports` entry, so `require('openvibe-shared/navbar')`,
@@@ -44,6 +44,29 @@ const seo = require('openvibe-shared/seo');
const { AnalyticsTracker } = require('openvibe-shared/analytics');
```
++### Metrics and readiness (server)
++
++```js
++const release = require('openvibe-shared/release').createRelease({ service: 'community' });
++const m = require('openvibe-shared/metrics').instrument(app, { service: 'community', release: release.release });
++// before any route: HTTP golden signals by route template, process metrics, release_info,
++// and GET /metrics for direct loopback callers only (404 through a proxy)
++m.registry.gauge({ name: 'jobs', help: 'Jobs by state', labelNames: ['state'], collect: () => rows });
++
++const ready = require('openvibe-shared/ready').createReadiness({
++ service: 'community', release: release.release,
++ checks: [
++ { name: 'db', required: true, check: () => db.prepare('SELECT 1 AS ok').get().ok === 1 },
++ { name: 'media', required: false, cacheMs: 30000, check: async () => (await ping()) || 'unreachable' },
++ ],
++});
++app.get('/api/ready', ready.handler); // 503 only when a required check fails; optional → degraded
++```
++
++A check fails when it throws, times out, returns `false`, a string (the reason) or
++`{ ok: false, error }`. Name a dependency required only when the service really cannot serve
++without it.
++
### Browser side: two options
1. **From the Network** (what most pages do today):
ddiff --git a/STATUS.json b/STATUS.json
iindex 1c37671..0874d86 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "1.0.0",
++ "version": "1.3.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"updated": "2026-09-22"
}
ddiff --git a/metrics.js b/metrics.js
nnew file mode 100644
iindex 0000000..40a1cd2
---- /dev/null
++++ b/metrics.js
@@@ -0,0 +1,364 @@
++'use strict';
++// ═══════════════════════════════════════════════════════════════
++// openvibe-shared/metrics — Prometheus text-format metrics with no dependencies (roadmap Track O,
++// §15.19): a registry (counter, gauge, histogram), golden-signal HTTP middleware keyed by ROUTE
++// TEMPLATE, process metrics, release_info, and a /metrics handler only loopback callers reach.
++//
++// const metrics = require('openvibe-shared/metrics');
++// const m = metrics.instrument(app, { service: 'community', release: release.release });
++// // mounted: the HTTP middleware (call this before any route) and GET /metrics (loopback only)
++// const depth = m.registry.gauge({ name: 'jobs', help: 'Jobs by state', labelNames: ['state'],
++// collect: () => db.prepare('SELECT state, COUNT(*) n FROM jobs GROUP BY state').all()
++// .map(r => ({ labels: { state: r.state }, value: r.n })) });
++//
++// Route labels are templates, never raw URLs: `req.baseUrl` (with id-like segments replaced by
++// `:id`) + `req.route.path`, or what `normalize(req)` returns. A request no route matched is
++// `unmatched`. Every metric caps its label combinations (`maxSeries`); past the cap new
++// combinations collapse into one `_overflow` series and metrics_series_overflow_total counts them.
++// ═══════════════════════════════════════════════════════════════
++
++const NAME_RE = /^[a-zA-Z_:][a-zA-Z0-9_:]*$/;
++const LABEL_RE = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
++const DEFAULT_BUCKETS = Object.freeze([0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10]);
++const DEFAULT_MAX_SERIES = 500;
++const OVERFLOW = '_overflow';
++const MAX_LABEL_VALUE = 120;
++
++const escHelp = (s) => String(s).replace(/\\/g, '\\\\').replace(/\n/g, '\\n');
++const escValue = (s) => String(s).replace(/\\/g, '\\\\').replace(/\n/g, '\\n').replace(/"/g, '\\"');
++function fmt(n) {
++ if (n === Infinity) return '+Inf';
++ if (n === -Infinity) return '-Inf';
++ if (Number.isNaN(n)) return 'NaN';
++ return String(n);
++}
++const labelText = (names, values) => (names.length ? `{${names.map((n, i) => `${n}="${escValue(values[i])}"`).join(',')}}` : '');
++
++function createRegistry({ maxSeries = DEFAULT_MAX_SERIES } = {}) {
++ const metrics = new Map();
++ let overflow = null; // counter, created on first use so an untroubled registry stays quiet
++
++ function noteOverflow(name) {
++ if (!overflow) overflow = counter({ name: 'metrics_series_overflow_total', help: 'Label combinations collapsed into the _overflow series because a metric reached its series cap', labelNames: ['metric'] });
++ overflow.inc({ metric: name });
++ }
++
++ function base(kind, { name, help, labelNames = [], maxSeries: cap = maxSeries }) {
++ if (!NAME_RE.test(String(name || ''))) throw new TypeError(`metrics: invalid metric name ${name}`);
++ if (metrics.has(name)) throw new Error(`metrics: ${name} is already registered`);
++ if (!help) throw new TypeError(`metrics: ${name} needs help text`);
++ for (const l of labelNames) {
++ if (!LABEL_RE.test(l) || l.startsWith('__') || (kind === 'histogram' && l === 'le')) throw new TypeError(`metrics: invalid label ${l} on ${name}`);
++ }
++ const series = new Map(); // key -> { values: [..], ...state }
++ function key(labels = {}) {
++ const values = labelNames.map((l) => {
++ const v = labels[l];
++ return v === undefined || v === null ? '' : String(v).slice(0, MAX_LABEL_VALUE);
++ });
++ return { k: values.join('\u0000'), values };
++ }
++ function get(labels, init) {
++ let { k, values } = key(labels);
++ let s = series.get(k);
++ if (s) return s;
++ if (series.size >= cap && labelNames.length) {
++ noteOverflow(name);
++ values = labelNames.map(() => OVERFLOW);
++ k = values.join('\u0000');
++ s = series.get(k);
++ if (s) return s;
++ }
++ s = { values, ...init() };
++ series.set(k, s);
++ return s;
++ }
++ return { name, help, labelNames, series, get, kind };
++ }
++
++ function counter(opts) {
++ const m = base('counter', opts);
++ const api = {
++ name: m.name,
++ inc(labels, value = 1) {
++ if (typeof labels === 'number') { value = labels; labels = {}; }
++ if (!(value >= 0)) throw new RangeError(`metrics: ${m.name} can only increase`);
++ m.get(labels, () => ({ value: 0 })).value += value;
++ },
++ get: (labels) => (m.series.get(labelsKey(m.labelNames, labels)) || { value: 0 }).value,
++ reset: () => m.series.clear(),
++ render() {
++ const out = [`# HELP ${m.name} ${escHelp(m.help)}`, `# TYPE ${m.name} counter`];
++ for (const s of m.series.values()) out.push(`${m.name}${labelText(m.labelNames, s.values)} ${fmt(s.value)}`);
++ return out;
++ },
++ };
++ metrics.set(m.name, api);
++ return api;
++ }
++
++ // collect(): a number (no labels) or [{ labels, value }] read at scrape time. A throwing collect
++ // leaves the gauge out of that scrape rather than reporting a stale or invented value.
++ function gauge(opts) {
++ const m = base('gauge', opts);
++ const collect = typeof opts.collect === 'function' ? opts.collect : null;
++ const api = {
++ name: m.name,
++ set(labels, value) {
++ if (typeof labels === 'number') { value = labels; labels = {}; }
++ m.get(labels, () => ({ value: 0 })).value = Number(value);
++ },
++ inc(labels, value = 1) {
++ if (typeof labels === 'number') { value = labels; labels = {}; }
++ m.get(labels, () => ({ value: 0 })).value += value;
++ },
++ dec(labels, value = 1) {
++ if (typeof labels === 'number') { value = labels; labels = {}; }
++ m.get(labels, () => ({ value: 0 })).value -= value;
++ },
++ get: (labels) => (m.series.get(labelsKey(m.labelNames, labels)) || { value: undefined }).value,
++ reset: () => m.series.clear(),
++ render() {
++ if (collect) {
++ let got;
++ try { got = collect(); } catch { return []; }
++ if (got === undefined || got === null) return [];
++ m.series.clear();
++ if (typeof got === 'number') api.set({}, got);
++ else for (const row of got) api.set(row.labels || {}, row.value);
++ }
++ const out = [`# HELP ${m.name} ${escHelp(m.help)}`, `# TYPE ${m.name} gauge`];
++ for (const s of m.series.values()) out.push(`${m.name}${labelText(m.labelNames, s.values)} ${fmt(s.value)}`);
++ return out;
++ },
++ };
++ metrics.set(m.name, api);
++ return api;
++ }
++
++ function histogram(opts) {
++ const m = base('histogram', opts);
++ const buckets = [...(opts.buckets || DEFAULT_BUCKETS)].map(Number).sort((a, b) => a - b);
++ if (!buckets.length || buckets.some((b) => !Number.isFinite(b))) throw new TypeError(`metrics: ${m.name} needs finite buckets`);
++ const init = () => ({ counts: new Array(buckets.length).fill(0), sum: 0, count: 0 });
++ const api = {
++ name: m.name,
++ buckets,
++ observe(labels, value) {
++ if (typeof labels === 'number') { value = labels; labels = {}; }
++ if (!Number.isFinite(value)) return;
++ const s = m.get(labels, init);
++ for (let i = 0; i < buckets.length; i++) if (value <= buckets[i]) s.counts[i]++;
++ s.sum += value;
++ s.count++;
++ },
++ startTimer(labels = {}) {
++ const t0 = process.hrtime.bigint();
++ return (more = {}) => {
++ const seconds = Number(process.hrtime.bigint() - t0) / 1e9;
++ api.observe({ ...labels, ...more }, seconds);
++ return seconds;
++ };
++ },
++ get: (labels) => {
++ const s = m.series.get(labelsKey(m.labelNames, labels));
++ return s ? { count: s.count, sum: s.sum, buckets: Object.fromEntries(buckets.map((b, i) => [b, s.counts[i]])) } : null;
++ },
++ reset: () => m.series.clear(),
++ render() {
++ const out = [`# HELP ${m.name} ${escHelp(m.help)}`, `# TYPE ${m.name} histogram`];
++ const names = [...m.labelNames, 'le'];
++ for (const s of m.series.values()) {
++ buckets.forEach((b, i) => out.push(`${m.name}_bucket${labelText(names, [...s.values, fmt(b)])} ${s.counts[i]}`));
++ out.push(`${m.name}_bucket${labelText(names, [...s.values, '+Inf'])} ${s.count}`);
++ out.push(`${m.name}_sum${labelText(m.labelNames, s.values)} ${fmt(s.sum)}`);
++ out.push(`${m.name}_count${labelText(m.labelNames, s.values)} ${s.count}`);
++ }
++ return out;
++ },
++ };
++ metrics.set(m.name, api);
++ return api;
++ }
++
++ // Callbacks run just before a scrape renders (process metrics sample themselves here).
++ const beforeScrape = [];
++ function metricsText() {
++ for (const f of beforeScrape) { try { f(); } catch { /* a sampler never breaks the scrape */ } }
++ const lines = [];
++ for (const m of metrics.values()) lines.push(...m.render());
++ return lines.join('\n') + '\n';
++ }
++
++ return {
++ counter, gauge, histogram,
++ getMetric: (name) => metrics.get(name) || null,
++ onScrape: (f) => { beforeScrape.push(f); },
++ metrics: metricsText,
++ contentType: 'text/plain; version=0.0.4; charset=utf-8',
++ };
++}
++
++function labelsKey(names, labels = {}) {
++ return names.map((l) => (labels[l] === undefined || labels[l] === null ? '' : String(labels[l]).slice(0, MAX_LABEL_VALUE))).join('\u0000');
++}
++
++// ── Route templates ─────────────────────────────────────────────
++
++// A path segment that is an identifier rather than a route word: numbers, UUIDs, ULIDs, prefixed
++// subject ids (usr_…), hex digests, and long mixed tokens.
++const ID_SEGMENT = /^(?:\d+|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9A-HJKMNP-TV-Z]{26}|[a-z]{2,5}_[0-9A-Za-z]{8,}|[0-9a-f]{12,}|(?=[^/]*\d)[A-Za-z0-9_-]{16,})$/i;
++
++/** Replace id-like segments of a concrete path with `:id` (query strings are dropped). */
++function templatePath(p) {
++ const clean = String(p || '').split('?')[0];
++ return clean.split('/').map((seg) => (seg && ID_SEGMENT.test(seg) ? ':id' : seg)).join('/') || '/';
++}
++
++/** The route label of a finished request: the matched route's template, or 'unmatched'. */
++function routeLabel(req) {
++ if (!req.route || req.route.path === undefined) return 'unmatched';
++ const rp = req.route.path;
++ const tail = rp instanceof RegExp ? '(regex)' : Array.isArray(rp) ? String(rp[0]) : String(rp);
++ const base = req.baseUrl ? templatePath(req.baseUrl) : '';
++ const joined = `${base}${tail === '/' && base ? '' : tail}`;
++ return (joined || '/').slice(0, MAX_LABEL_VALUE);
++}
++
++// ── HTTP golden signals ─────────────────────────────────────────
++
++// skip(req): requests never recorded (long-lived streams such as SSE, whose "duration" is a session).
++function httpMetrics(registry, { buckets = DEFAULT_BUCKETS, normalize = null, skip = null, maxRoutes = 300 } = {}) {
++ const requests = registry.counter({ name: 'http_requests_total', help: 'HTTP requests by method, route template and status class', labelNames: ['method', 'route', 'status_class'], maxSeries: maxRoutes * 4 });
++ const duration = registry.histogram({ name: 'http_request_duration_seconds', help: 'HTTP request duration by method and route template', labelNames: ['method', 'route'], buckets, maxSeries: maxRoutes });
++ const inFlight = registry.gauge({ name: 'http_requests_in_flight', help: 'HTTP requests being served' });
++ inFlight.set(0);
++ const METHODS = new Set(['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']);
++
++ function middleware(req, res, next) {
++ try { if (skip && skip(req)) return next(); } catch { /* record it */ }
++ const t0 = process.hrtime.bigint();
++ inFlight.inc();
++ let done = false;
++ const finish = () => {
++ if (done) return;
++ done = true;
++ inFlight.dec();
++ let route;
++ try { route = normalize ? normalize(req, res) : null; } catch { route = null; }
++ if (!route) route = routeLabel(req);
++ const method = METHODS.has(req.method) ? req.method : 'OTHER';
++ // Closed before the response finished: the client went away (or a stream was cut).
++ const status = res.writableFinished || res.finished ? `${Math.floor((res.statusCode || 0) / 100)}xx` : 'aborted';
++ requests.inc({ method, route, status_class: status });
++ duration.observe({ method, route }, Number(process.hrtime.bigint() - t0) / 1e9);
++ };
++ res.once('finish', finish);
++ res.once('close', finish);
++ next();
++ }
++ return { middleware, requests, duration, inFlight };
++}
++
++// ── Process metrics ─────────────────────────────────────────────
++
++function processMetrics(registry, { eventLoopResolutionMs = 20 } = {}) {
++ let loop = null;
++ try {
++ loop = require('perf_hooks').monitorEventLoopDelay({ resolution: eventLoopResolutionMs });
++ loop.enable();
++ } catch { loop = null; }
++ const startSeconds = Math.round((Date.now() - process.uptime() * 1000) / 1000);
++ const rss = registry.gauge({ name: 'process_resident_memory_bytes', help: 'Resident set size in bytes' });
++ const heapUsed = registry.gauge({ name: 'nodejs_heap_used_bytes', help: 'V8 heap in use in bytes' });
++ const heapTotal = registry.gauge({ name: 'nodejs_heap_total_bytes', help: 'V8 heap allocated in bytes' });
++ const cpu = registry.gauge({ name: 'process_cpu_seconds_total', help: 'User and system CPU time spent, in seconds' });
++ const uptime = registry.gauge({ name: 'process_uptime_seconds', help: 'Seconds since the process started' });
++ registry.gauge({ name: 'process_start_time_seconds', help: 'Start time of the process since the Unix epoch, in seconds' }).set(startSeconds);
++ const lag = loop && registry.gauge({ name: 'nodejs_eventloop_lag_seconds', help: 'Event-loop delay since the previous scrape', labelNames: ['stat'] });
++ registry.onScrape(() => {
++ const mem = process.memoryUsage();
++ rss.set(mem.rss);
++ heapUsed.set(mem.heapUsed);
++ heapTotal.set(mem.heapTotal);
++ const c = process.cpuUsage();
++ cpu.set((c.user + c.system) / 1e6);
++ uptime.set(Math.round(process.uptime()));
++ if (loop && lag) {
++ // The histogram is in nanoseconds; with no samples yet it reports NaN/huge values.
++ const ok = loop.count > 0 || loop.max > 0;
++ lag.set({ stat: 'mean' }, ok ? loop.mean / 1e9 : 0);
++ lag.set({ stat: 'p99' }, ok ? loop.percentile(99) / 1e9 : 0);
++ lag.set({ stat: 'max' }, ok ? loop.max / 1e9 : 0);
++ loop.reset();
++ }
++ });
++ return { stop: () => { if (loop) loop.disable(); } };
++}
++
++/** release_info{service,release} 1 — the deployed release, from openvibe-shared/release. */
++function releaseInfo(registry, { service, release }) {
++ const g = registry.gauge({ name: 'release_info', help: 'The release this process runs (value is always 1)', labelNames: ['service', 'release'] });
++ g.set({ service: String(service || 'unknown'), release: String(release || 'unknown') }, 1);
++ return g;
++}
++
++// ── /metrics ───────────────────────────────────────────────────
++
++const LOOPBACK = /^(?:127\.\d+\.\d+\.\d+|::1|::ffff:127\.\d+\.\d+\.\d+)$/;
++const PROXY_HEADERS = ['x-forwarded-for', 'x-real-ip', 'forwarded', 'cf-connecting-ip', 'true-client-ip'];
++
++/** True only for a direct loopback connection that no proxy relayed. */
++function isLoopbackDirect(req) {
++ const addr = String((req.socket && req.socket.remoteAddress) || (req.connection && req.connection.remoteAddress) || '');
++ if (!LOOPBACK.test(addr)) return false;
++ const h = req.headers || {};
++ return !PROXY_HEADERS.some((name) => h[name] !== undefined);
++}
++
++function metricsHandler(registry) {
++ return (req, res) => {
++ if (!isLoopbackDirect(req)) {
++ res.statusCode = 404;
++ res.setHeader('Content-Type', 'application/json; charset=utf-8');
++ return res.end('{"error":"Not found"}');
++ }
++ let body;
++ try { body = registry.metrics(); } catch (err) {
++ res.statusCode = 500;
++ res.setHeader('Content-Type', 'text/plain; charset=utf-8');
++ return res.end(`# metrics failed: ${String(err && err.message).slice(0, 200)}\n`);
++ }
++ res.statusCode = 200;
++ res.setHeader('Content-Type', registry.contentType);
++ res.setHeader('Cache-Control', 'no-store');
++ res.end(body);
++ };
++}
++
++/**
++ * Everything at once, for an Express app: a registry with process metrics and release_info, the HTTP
++ * middleware mounted first, and GET <path> (default /metrics) for loopback callers.
++ * Call it before any route or body parser is mounted.
++ */
++function instrument(app, { service, release, path = '/metrics', normalize = null, skip = null, buckets, maxRoutes, registry = createRegistry() } = {}) {
++ const http = httpMetrics(registry, { normalize, skip, buckets, maxRoutes });
++ const proc = processMetrics(registry);
++ releaseInfo(registry, { service, release });
++ app.use(http.middleware);
++ app.get(path, metricsHandler(registry));
++ return { registry, http, stop: proc.stop };
++}
++
++module.exports = {
++ createRegistry,
++ httpMetrics,
++ processMetrics,
++ releaseInfo,
++ metricsHandler,
++ isLoopbackDirect,
++ instrument,
++ routeLabel,
++ templatePath,
++ DEFAULT_BUCKETS,
++};
ddiff --git a/package-lock.json b/package-lock.json
iindex e7fc91d..dea5860 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,15 +1,64 @@
{
"name": "openvibe-shared",
-- "version": "1.0.0",
++ "version": "1.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-shared",
-- "version": "1.0.0",
++ "version": "1.3.0",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0"
++ },
++ "devDependencies": {
++ "express": "^4.22.3"
++ }
++ },
++ "node_modules/accepts": {
++ "version": "1.3.8",
++ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
++ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
++ "dev": true,
++ "license": "MIT",
++ "dependencies": {
++ "mime-types": "~2.1.34",
++ "negotiator": "0.6.3"
++ },
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/array-flatten": {
++ "version": "1.1.1",
++ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
++ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
++ "dev": true,
++ "license": "MIT"
++ },
++ "node_modules/body-parser": {
++ "version": "1.20.8",
++ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz",
++ "integrity": "sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==",
++ "dev": true,
++ "license": "MIT",
++ "dependencies": {
++ "bytes": "~3.1.2",
++ "content-type": "~1.0.5",
++ "debug": "2.6.9",
++ "depd": "2.0.0",
++ "destroy": "~1.2.0",
++ "http-errors": "~2.0.1",
++ "iconv-lite": "~0.4.24",
++ "on-finished": "~2.4.1",
++ "qs": "~6.16.0",
++ "raw-body": "~2.5.3",
++ "type-is": "~1.6.18",
++ "unpipe": "~1.0.0"
++ },
++ "engines": {
++ "node": ">= 0.8",
++ "npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/buffer-equal-constant-time": {
@@@ -18,6 +67,140 @@
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
++ "node_modules/bytes": {
++ "version": "3.1.2",
++ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
++ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
++ "dev": true,
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/call-bind-apply-helpers": {
++ "version": "1.0.2",
++ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
++ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
++ "dev": true,
++ "license": "MIT",
++ "dependencies": {
++ "es-errors": "^1.3.0",
++ "function-bind": "^1.1.2"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/call-bound": {
++ "version": "1.0.4",
++ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
++ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
++ "dev": true,
++ "license": "MIT",
++ "dependencies": {
++ "call-bind-apply-helpers": "^1.0.2",
++ "get-intrinsic": "^1.3.0"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/content-disposition": {
++ "version": "0.5.4",
++ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
++ "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
++ "dev": true,
++ "license": "MIT",
++ "dependencies": {
++ "safe-buffer": "5.2.1"
++ },
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/content-type": {
++ "version": "1.0.5",
++ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
++ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
++ "dev": true,
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/cookie": {
++ "version": "0.7.2",
++ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
++ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
++ "dev": true,
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/cookie-signature": {
++ "version": "1.0.7",
++ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
++ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
++ "dev": true,
++ "license": "MIT"
++ },
++ "node_modules/debug": {
++ "version": "2.6.9",
++ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
++ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
++ "dev": true,
++ "license": "MIT",
++ "dependencies": {
++ "ms": "2.0.0"
++ }
++ },
++ "node_modules/debug/node_modules/ms": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
++ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
++ "dev": true,
++ "license": "MIT"
++ },
++ "node_modules/depd": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
++ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
++ "dev": true,
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/destroy": {
++ "version": "1.2.0",
++ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
++ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
++ "dev": true,
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8",
++ "npm": "1.2.8000 || >= 1.4.16"
++ }
++ },
++ "node_modules/dunder-proto": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
++ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
++ "dev": true,
++ "license": "MIT",
++ "dependencies": {
++ "call-bind-apply-helpers": "^1.0.1",
++ "es-errors": "^1.3.0",
++ "gopd": "^1.2.0"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
@@@ -27,6 +210,298 @@
"safe-buffer": "^5.0.1"
}
},
++ "node_modules/ee-first": {
++ "version": "1.1.1",
++ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
++ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
++ "dev": true,
++ "license": "MIT"
++ },
++ "node_modules/encodeurl": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
++ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
++ "dev": true,
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/es-define-property": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
++ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
++ "dev": true,
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/es-errors": {
++ "version": "1.3.0",
++ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
++ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
++ "dev": true,
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/es-object-atoms": {
++ "version": "1.1.2",
++ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
++ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
++ "dev": true,
++ "license": "MIT",
++ "dependencies": {
++ "es-errors": "^1.3.0"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/escape-html": {
++ "version": "1.0.3",
++ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
++ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
++ "dev": true,
++ "license": "MIT"
++ },
++ "node_modules/etag": {
++ "version": "1.8.1",
++ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
++ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
++ "dev": true,
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/express": {
++ "version": "4.22.3",
++ "resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz",
++ "integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==",
++ "dev": true,
++ "license": "MIT",
++ "dependencies": {
++ "accepts": "~1.3.8",
++ "array-flatten": "1.1.1",
++ "body-parser": "~1.20.5",
++ "content-disposition": "~0.5.4",
++ "content-type": "~1.0.4",
++ "cookie": "~0.7.1",
++ "cookie-signature": "~1.0.6",
++ "debug": "2.6.9",
++ "depd": "2.0.0",
++ "encodeurl": "~2.0.0",
++ "escape-html": "~1.0.3",
++ "etag": "~1.8.1",
++ "finalhandler": "~1.3.1",
++ "fresh": "~0.5.2",
++ "http-errors": "~2.0.0",
++ "merge-descriptors": "1.0.3",
++ "methods": "~1.1.2",
++ "on-finished": "~2.4.1",
++ "parseurl": "~1.3.3",
++ "path-to-regexp": "~0.1.13",
++ "proxy-addr": "~2.0.7",
++ "qs": "~6.16.0",
++ "range-parser": "~1.2.1",
++ "safe-buffer": "5.2.1",
++ "send": "~0.19.0",
++ "serve-static": "~1.16.2",
++ "setprototypeof": "1.2.0",
++ "statuses": "~2.0.1",
++ "type-is": "~1.6.18",
++ "utils-merge": "1.0.1",
++ "vary": "~1.1.2"
++ },
++ "engines": {
++ "node": ">= 0.10.0"
++ },
++ "funding": {
++ "type": "opencollective",
++ "url": "https://opencollective.com/express"
++ }
++ },
++ "node_modules/finalhandler": {
++ "version": "1.3.2",
++ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
++ "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
++ "dev": true,
++ "license": "MIT",
++ "dependencies": {
++ "debug": "2.6.9",
++ "encodeurl": "~2.0.0",
++ "escape-html": "~1.0.3",
++ "on-finished": "~2.4.1",
++ "parseurl": "~1.3.3",
++ "statuses": "~2.0.2",
++ "unpipe": "~1.0.0"
++ },
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/forwarded": {
++ "version": "0.2.0",
++ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
++ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
++ "dev": true,
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/fresh": {
++ "version": "0.5.2",
++ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
++ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
++ "dev": true,
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/function-bind": {
++ "version": "1.1.2",
++ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
++ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
++ "dev": true,
++ "license": "MIT",
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/get-intrinsic": {
++ "version": "1.3.0",
++ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
++ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
++ "dev": true,
++ "license": "MIT",
++ "dependencies": {
++ "call-bind-apply-helpers": "^1.0.2",
++ "es-define-property": "^1.0.1",
++ "es-errors": "^1.3.0",
++ "es-object-atoms": "^1.1.1",
++ "function-bind": "^1.1.2",
++ "get-proto": "^1.0.1",
++ "gopd": "^1.2.0",
++ "has-symbols": "^1.1.0",
++ "hasown": "^2.0.2",
++ "math-intrinsics": "^1.1.0"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/get-proto": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
++ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
++ "dev": true,
++ "license": "MIT",
++ "dependencies": {
++ "dunder-proto": "^1.0.1",
++ "es-object-atoms": "^1.0.0"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/gopd": {
++ "version": "1.2.0",
++ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
++ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
++ "dev": true,
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/has-symbols": {
++ "version": "1.1.0",
++ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
++ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
++ "dev": true,
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/hasown": {
++ "version": "2.0.4",
++ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
++ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
++ "dev": true,
++ "license": "MIT",
++ "dependencies": {
++ "function-bind": "^1.1.2"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/http-errors": {
++ "version": "2.0.1",
++ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
++ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
++ "dev": true,
++ "license": "MIT",
++ "dependencies": {
++ "depd": "~2.0.0",
++ "inherits": "~2.0.4",
++ "setprototypeof": "~1.2.0",
++ "statuses": "~2.0.2",
++ "toidentifier": "~1.0.1"
++ },
++ "engines": {
++ "node": ">= 0.8"
++ },
++ "funding": {
++ "type": "opencollective",
++ "url": "https://opencollective.com/express"
++ }
++ },
++ "node_modules/iconv-lite": {
++ "version": "0.4.24",
++ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
++ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
++ "dev": true,
++ "license": "MIT",
++ "dependencies": {
++ "safer-buffer": ">= 2.1.2 < 3"
++ },
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
++ "node_modules/inherits": {
++ "version": "2.0.4",
++ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
++ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
++ "dev": true,
++ "license": "ISC"
++ },
++ "node_modules/ipaddr.js": {
++ "version": "1.9.1",
++ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
++ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
++ "dev": true,
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.10"
++ }
++ },
"node_modules/jsonwebtoken": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
@@@ -112,12 +587,202 @@
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"license": "MIT"
},
++ "node_modules/math-intrinsics": {
++ "version": "1.1.0",
++ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
++ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
++ "dev": true,
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/media-typer": {
++ "version": "0.3.0",
++ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
++ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
++ "dev": true,
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/merge-descriptors": {
++ "version": "1.0.3",
++ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
++ "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
++ "dev": true,
++ "license": "MIT",
++ "funding": {
++ "url": "https://github.com/sponsors/sindresorhus"
++ }
++ },
++ "node_modules/methods": {
++ "version": "1.1.2",
++ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
++ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
++ "dev": true,
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/mime": {
++ "version": "1.6.0",
++ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
++ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
++ "dev": true,
++ "license": "MIT",
++ "bin": {
++ "mime": "cli.js"
++ },
++ "engines": {
++ "node": ">=4"
++ }
++ },
++ "node_modules/mime-db": {
++ "version": "1.52.0",
++ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
++ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
++ "dev": true,
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/mime-types": {
++ "version": "2.1.35",
++ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
++ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
++ "dev": true,
++ "license": "MIT",
++ "dependencies": {
++ "mime-db": "1.52.0"
++ },
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
++ "node_modules/negotiator": {
++ "version": "0.6.3",
++ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
++ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
++ "dev": true,
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/object-inspect": {
++ "version": "1.13.4",
++ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
++ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
++ "dev": true,
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/on-finished": {
++ "version": "2.4.1",
++ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
++ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
++ "dev": true,
++ "license": "MIT",
++ "dependencies": {
++ "ee-first": "1.1.1"
++ },
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/parseurl": {
++ "version": "1.3.3",
++ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
++ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
++ "dev": true,
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/path-to-regexp": {
++ "version": "0.1.13",
++ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
++ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
++ "dev": true,
++ "license": "MIT"
++ },
++ "node_modules/proxy-addr": {
++ "version": "2.0.8",
++ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
++ "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",
++ "dev": true,
++ "license": "MIT",
++ "dependencies": {
++ "forwarded": "0.2.0",
++ "ipaddr.js": "1.9.1"
++ },
++ "engines": {
++ "node": ">= 0.10"
++ },
++ "funding": {
++ "type": "opencollective",
++ "url": "https://opencollective.com/express"
++ }
++ },
++ "node_modules/qs": {
++ "version": "6.16.0",
++ "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
++ "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
++ "dev": true,
++ "license": "BSD-3-Clause",
++ "dependencies": {
++ "es-define-property": "^1.0.1",
++ "side-channel": "^1.1.1"
++ },
++ "engines": {
++ "node": ">=0.6"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/range-parser": {
++ "version": "1.2.1",
++ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
++ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
++ "dev": true,
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/raw-body": {
++ "version": "2.5.3",
++ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
++ "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
++ "dev": true,
++ "license": "MIT",
++ "dependencies": {
++ "bytes": "~3.1.2",
++ "http-errors": "~2.0.1",
++ "iconv-lite": "~0.4.24",
++ "unpipe": "~1.0.0"
++ },
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@@ -138,6 +803,13 @@
],
"license": "MIT"
},
++ "node_modules/safer-buffer": {
++ "version": "2.1.2",
++ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
++ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
++ "dev": true,
++ "license": "MIT"
++ },
"node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
@@@ -149,6 +821,194 @@
"engines": {
"node": ">=10"
}
++ },
++ "node_modules/send": {
++ "version": "0.19.2",
++ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
++ "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
++ "dev": true,
++ "license": "MIT",
++ "dependencies": {
++ "debug": "2.6.9",
++ "depd": "2.0.0",
++ "destroy": "1.2.0",
++ "encodeurl": "~2.0.0",
++ "escape-html": "~1.0.3",
++ "etag": "~1.8.1",
++ "fresh": "~0.5.2",
++ "http-errors": "~2.0.1",
++ "mime": "1.6.0",
++ "ms": "2.1.3",
++ "on-finished": "~2.4.1",
++ "range-parser": "~1.2.1",
++ "statuses": "~2.0.2"
++ },
++ "engines": {
++ "node": ">= 0.8.0"
++ }
++ },
++ "node_modules/serve-static": {
++ "version": "1.16.3",
++ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
++ "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
++ "dev": true,
++ "license": "MIT",
++ "dependencies": {
++ "encodeurl": "~2.0.0",
++ "escape-html": "~1.0.3",
++ "parseurl": "~1.3.3",
++ "send": "~0.19.1"
++ },
++ "engines": {
++ "node": ">= 0.8.0"
++ }
++ },
++ "node_modules/setprototypeof": {
++ "version": "1.2.0",
++ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
++ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
++ "dev": true,
++ "license": "ISC"
++ },
++ "node_modules/side-channel": {
++ "version": "1.1.1",
++ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
++ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
++ "dev": true,
++ "license": "MIT",
++ "dependencies": {
++ "es-errors": "^1.3.0",
++ "object-inspect": "^1.13.4",
++ "side-channel-list": "^1.0.1",
++ "side-channel-map": "^1.0.1",
++ "side-channel-weakmap": "^1.0.2"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/side-channel-list": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
++ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
++ "dev": true,
++ "license": "MIT",
++ "dependencies": {
++ "es-errors": "^1.3.0",
++ "object-inspect": "^1.13.4"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/side-channel-map": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
++ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
++ "dev": true,
++ "license": "MIT",
++ "dependencies": {
++ "call-bound": "^1.0.2",
++ "es-errors": "^1.3.0",
++ "get-intrinsic": "^1.2.5",
++ "object-inspect": "^1.13.3"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/side-channel-weakmap": {
++ "version": "1.0.2",
++ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
++ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
++ "dev": true,
++ "license": "MIT",
++ "dependencies": {
++ "call-bound": "^1.0.2",
++ "es-errors": "^1.3.0",
++ "get-intrinsic": "^1.2.5",
++ "object-inspect": "^1.13.3",
++ "side-channel-map": "^1.0.1"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/statuses": {
++ "version": "2.0.2",
++ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
++ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
++ "dev": true,
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/toidentifier": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
++ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
++ "dev": true,
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.6"
++ }
++ },
++ "node_modules/type-is": {
++ "version": "1.6.18",
++ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
++ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
++ "dev": true,
++ "license": "MIT",
++ "dependencies": {
++ "media-typer": "0.3.0",
++ "mime-types": "~2.1.24"
++ },
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/unpipe": {
++ "version": "1.0.0",
++ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
++ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
++ "dev": true,
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/utils-merge": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
++ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
++ "dev": true,
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4.0"
++ }
++ },
++ "node_modules/vary": {
++ "version": "1.1.2",
++ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
++ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
++ "dev": true,
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
}
}
}
ddiff --git a/package.json b/package.json
iindex d56d5c7..bd2e37f 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "1.2.1",
++ "version": "1.3.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"exports": {
@@@ -19,6 +19,7 @@
"./icons": "./ov-icons.js",
"./island": "./island.js",
"./legal": "./legal.js",
++ "./metrics": "./metrics.js",
"./middleware": "./middleware.js",
"./nav-icons": "./nav-icons.js",
"./navbar": "./navbar.js",
@@@ -28,6 +29,7 @@
"./ov-icons": "./ov-icons.js",
"./ov-mark": "./ov-mark.js",
"./panels": "./panels.js",
++ "./ready": "./ready.js",
"./release": "./release.js",
"./release-watch": "./release-watch.js",
"./seo": "./seo.js",
@@@ -58,5 +60,8 @@
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0"
++ },
++ "devDependencies": {
++ "express": "^4.22.3"
}
}
ddiff --git a/ready.js b/ready.js
nnew file mode 100644
iindex 0000000..8b562dc
---- /dev/null
++++ b/ready.js
@@@ -0,0 +1,126 @@
++'use strict';
++// ═══════════════════════════════════════════════════════════════
++// openvibe-shared/ready — a truthful readiness response (roadmap Track O, §15.19: "readiness must
++// report the capability actually served").
++//
++// const ready = require('openvibe-shared/ready').createReadiness({
++// service: 'community', release: release.release,
++// checks: [
++// { name: 'db', required: true, check: () => db.prepare('SELECT 1 AS ok').get().ok === 1 },
++// { name: 'network_jwks', required: true, check: () => keys.loaded() || 'not loaded yet' },
++// { name: 'media', required: false, cacheMs: 30000, check: async () => { … } },
++// ],
++// details: () => ({ queue_depth: store.depth() }), // optional extra fields
++// });
++// app.get('/api/ready', ready.handler);
++//
++// Every check runs on each request (or from its cache, keeping the time it really ran) and reports
++// status, latency_ms and checked_at. A check passes when it returns anything but `false` or a string
++// (`false`/a string is a failure; the string is the reason) or `{ ok: false, detail }`, and does not
++// throw or time out. Overall `ready` is false only when a REQUIRED check fails (HTTP 503); a failed
++// optional check keeps the service ready (HTTP 200) and is listed in `degraded`, so a missing optional
++// dependency never reads as "healthy" or as "down".
++// ═══════════════════════════════════════════════════════════════
++
++const NAME_RE = /^[a-z][a-z0-9_.-]{0,63}$/;
++
++/** A failure reason safe to show publicly: one line, no URL query strings or credentials, short. */
++function safeReason(v) {
++ let s = v instanceof Error ? (v.name === 'TimeoutError' || v.name === 'AbortError' ? 'timeout' : v.message) : String(v);
++ s = s.split('\n')[0]
++ .replace(/(\b[a-z][a-z0-9+.-]*:\/\/)[^/@\s]*@/gi, '$1') // user:pass@
++ .replace(/(\b[a-z][a-z0-9+.-]*:\/\/[^\s?#]*)[?#][^\s]*/gi, '$1') // ?query / #fragment
++ .replace(/\b(?:key|token|secret|password|signature)=[^\s&]+/gi, (m) => `${m.split('=')[0]}=…`);
++ return s.slice(0, 200) || 'failed';
++}
++
++function withTimeout(promise, ms) {
++ let t;
++ const timer = new Promise((_, reject) => { t = setTimeout(() => reject(Object.assign(new Error('timeout'), { name: 'TimeoutError' })), ms); });
++ return Promise.race([promise, timer]).finally(() => clearTimeout(t));
++}
++
++function createReadiness({ service, release = null, checks = [], details = null, timeoutMs = 2000, now = () => new Date() } = {}) {
++ if (!service) throw new TypeError('createReadiness: service required');
++ const list = checks.map((c) => {
++ if (!NAME_RE.test(String(c.name || ''))) throw new TypeError(`createReadiness: bad check name ${c.name}`);
++ if (typeof c.check !== 'function') throw new TypeError(`createReadiness: check ${c.name} needs a function`);
++ 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 };
++ });
++ const names = new Set();
++ for (const c of list) { if (names.has(c.name)) throw new Error(`createReadiness: duplicate check ${c.name}`); names.add(c.name); }
++ const cache = new Map(); // name -> { at: ms, result }
++ const inflight = new Map();
++
++ async function runOne(c) {
++ const hit = cache.get(c.name);
++ if (hit && c.cacheMs && Date.now() - hit.at < c.cacheMs) return hit.result;
++ if (inflight.has(c.name)) return inflight.get(c.name);
++ const p = (async () => {
++ const t0 = process.hrtime.bigint();
++ const out = { status: 'ok', required: c.required };
++ try {
++ const v = await withTimeout(Promise.resolve().then(() => c.check()), c.timeoutMs);
++ if (v === false) { out.status = 'fail'; out.error = 'check returned false'; }
++ else if (typeof v === 'string') { out.status = 'fail'; out.error = safeReason(v); }
++ else if (v && typeof v === 'object' && !Array.isArray(v)) {
++ if (v.ok === false) { out.status = 'fail'; out.error = safeReason(v.error || v.detail || 'failed'); }
++ if (v.detail !== undefined && (v.ok !== false || typeof v.detail === 'object')) out.detail = v.detail;
++ }
++ } catch (err) {
++ out.status = 'fail';
++ out.error = safeReason(err);
++ }
++ out.latency_ms = Math.round(Number(process.hrtime.bigint() - t0) / 1e5) / 10;
++ out.checked_at = now().toISOString();
++ if (c.description) out.description = c.description;
++ cache.set(c.name, { at: Date.now(), result: out });
++ return out;
++ })().finally(() => inflight.delete(c.name));
++ inflight.set(c.name, p);
++ return p;
++ }
++
++ async function run() {
++ const results = await Promise.all(list.map(runOne));
++ const out = {};
++ const failed = []; const degraded = [];
++ list.forEach((c, i) => {
++ out[c.name] = results[i];
++ if (results[i].status !== 'ok') (c.required ? failed : degraded).push(c.name);
++ });
++ const ready = failed.length === 0;
++ const body = {
++ ready,
++ status: !ready ? 'not_ready' : degraded.length ? 'degraded' : 'ready',
++ service,
++ release: typeof release === 'function' ? release() : release,
++ checked_at: now().toISOString(),
++ failed,
++ degraded,
++ checks: out,
++ };
++ if (details) {
++ try {
++ const extra = await withTimeout(Promise.resolve().then(() => details(body)), timeoutMs);
++ if (extra && typeof extra === 'object') for (const [k, v] of Object.entries(extra)) if (!(k in body)) body[k] = v;
++ } catch (err) { body.details_error = safeReason(err); }
++ }
++ return body;
++ }
++
++ async function handler(_req, res) {
++ let body;
++ try { body = await run(); } catch (err) {
++ body = { ready: false, status: 'not_ready', service, checked_at: now().toISOString(), failed: ['readiness'], degraded: [], checks: {}, error: safeReason(err) };
++ }
++ res.statusCode = body.ready ? 200 : 503;
++ res.setHeader('Content-Type', 'application/json; charset=utf-8');
++ res.setHeader('Cache-Control', 'no-store');
++ res.end(JSON.stringify(body));
++ }
++
++ return { run, handler, checks: list.map((c) => ({ name: c.name, required: c.required })) };
++}
++
++module.exports = { createReadiness, safeReason };
ddiff --git a/test/metrics.test.js b/test/metrics.test.js
nnew file mode 100644
iindex 0000000..3261920
---- /dev/null
++++ b/test/metrics.test.js
@@@ -0,0 +1,132 @@
++'use strict';
++// metrics.js: Prometheus text format, cardinality guard, route templates (never raw URLs), the
++// loopback-only /metrics handler, and the golden-signal middleware on a real Express-shaped app.
++const assert = require('assert');
++const http = require('http');
++const metrics = require('../metrics');
++
++(async () => {
++ // ── Registry and text format ──
++ const r = metrics.createRegistry({ maxSeries: 3 });
++ const c = r.counter({ name: 'jobs_total', help: 'Jobs\nrun', labelNames: ['state'] });
++ c.inc({ state: 'done' });
++ c.inc({ state: 'done' }, 2);
++ c.inc({ state: 'a"b\\c\nd' });
++ assert.strictEqual(c.get({ state: 'done' }), 3);
++ assert.throws(() => c.inc({ state: 'done' }, -1), /only increase/);
++ assert.throws(() => r.counter({ name: 'jobs_total', help: 'x' }), /already registered/);
++ assert.throws(() => r.counter({ name: 'bad-name', help: 'x' }), /invalid metric name/);
++ assert.throws(() => r.histogram({ name: 'h_bad', help: 'x', labelNames: ['le'] }), /invalid label/);
++ assert.throws(() => r.gauge({ name: 'no_help' }), /help/);
++
++ const g = r.gauge({ name: 'queue_depth', help: 'Depth', labelNames: ['status'] });
++ g.set({ status: 'pending' }, 4); g.inc({ status: 'pending' }); g.dec({ status: 'pending' }, 2);
++ assert.strictEqual(g.get({ status: 'pending' }), 3);
++ const collected = r.gauge({ name: 'objects', help: 'Objects by state', labelNames: ['state'], collect: () => [{ labels: { state: 'local' }, value: 7 }, { labels: { state: 'b2' }, value: 2 }] });
++ r.gauge({ name: 'broken', help: 'Collect throws', collect: () => { throw new Error('db gone'); } });
++ r.gauge({ name: 'scalar', help: 'A plain number', collect: () => 5 });
++ assert.ok(collected);
++
++ const h = r.histogram({ name: 'lat_seconds', help: 'Latency', labelNames: ['op'], buckets: [0.1, 1] });
++ h.observe({ op: 'x' }, 0.05); h.observe({ op: 'x' }, 0.5); h.observe({ op: 'x' }, 3); h.observe({ op: 'x' }, NaN);
++ assert.deepStrictEqual(h.get({ op: 'x' }), { count: 3, sum: 3.55, buckets: { 0.1: 1, 1: 2 } });
++ const end = h.startTimer({ op: 'y' });
++ assert.ok(end() >= 0);
++
++ let text = r.metrics();
++ assert.ok(text.endsWith('\n'));
++ assert.ok(text.includes('# HELP jobs_total Jobs\\nrun\n# TYPE jobs_total counter\n'));
++ assert.ok(text.includes('jobs_total{state="done"} 3\n'));
++ assert.ok(text.includes('jobs_total{state="a\\"b\\\\c\\nd"} 1\n'), 'label values are escaped');
++ assert.ok(text.includes('queue_depth{status="pending"} 3\n'));
++ assert.ok(text.includes('objects{state="local"} 7\nobjects{state="b2"} 2\n'));
++ assert.ok(text.includes('scalar 5\n'));
++ assert.ok(!text.includes('broken'), 'a throwing collect is left out, never reported stale');
++ assert.ok(text.includes('lat_seconds_bucket{op="x",le="0.1"} 1\nlat_seconds_bucket{op="x",le="1"} 2\nlat_seconds_bucket{op="x",le="+Inf"} 3\nlat_seconds_sum{op="x"} 3.55\nlat_seconds_count{op="x"} 3\n'));
++
++ // Cardinality guard: past maxSeries, new combinations fold into one _overflow series.
++ for (let i = 0; i < 50; i++) c.inc({ state: `s${i}` });
++ text = r.metrics();
++ assert.strictEqual((text.match(/^jobs_total\{/gm) || []).length, 4, 'three series plus _overflow');
++ assert.ok(text.includes('jobs_total{state="_overflow"} 49\n'));
++ assert.ok(text.includes('metrics_series_overflow_total{metric="jobs_total"} 49\n'));
++
++ // ── Route templates ──
++ assert.strictEqual(metrics.templatePath('/api/v1/pastes/12345?x=1'), '/api/v1/pastes/:id');
++ assert.strictEqual(metrics.templatePath('/u/usr_01J9ZX4YQ0ABCDEF/x'), '/u/:id/x');
++ assert.strictEqual(metrics.templatePath('/o/0f8fad5b-d9cb-469f-a165-70867728950e'), '/o/:id');
++ assert.strictEqual(metrics.templatePath('/o/01J9ZX4YQ0ABCDEFGHJKMNPQRS'), '/o/:id');
++ assert.strictEqual(metrics.templatePath('/api/v1/live/vods'), '/api/v1/live/vods', 'route words stay');
++ assert.strictEqual(metrics.routeLabel({ route: { path: '/:id' }, baseUrl: '/api/v1/live/vods' }), '/api/v1/live/vods/:id');
++ assert.strictEqual(metrics.routeLabel({ route: { path: '/' }, baseUrl: '/api/things' }), '/api/things');
++ assert.strictEqual(metrics.routeLabel({ route: { path: '/' }, baseUrl: '' }), '/');
++ assert.strictEqual(metrics.routeLabel({ route: { path: /^\/x/ }, baseUrl: '' }), '(regex)');
++ assert.strictEqual(metrics.routeLabel({ route: { path: ['/a', '/b'] }, baseUrl: '' }), '/a');
++ assert.strictEqual(metrics.routeLabel({ baseUrl: '/secret/123', originalUrl: '/secret/123?q=1' }), 'unmatched');
++
++ // ── Loopback-only guard ──
++ const req = (addr, headers = {}) => ({ socket: { remoteAddress: addr }, headers });
++ assert.ok(metrics.isLoopbackDirect(req('127.0.0.1')));
++ assert.ok(metrics.isLoopbackDirect(req('::1')));
++ assert.ok(metrics.isLoopbackDirect(req('::ffff:127.0.0.1')));
++ assert.ok(!metrics.isLoopbackDirect(req('10.0.0.2')));
++ assert.ok(!metrics.isLoopbackDirect(req('::ffff:10.0.0.2')));
++ assert.ok(!metrics.isLoopbackDirect(req('127.0.0.1', { 'x-forwarded-for': '1.2.3.4' })), 'nginx on loopback is not a local caller');
++ assert.ok(!metrics.isLoopbackDirect(req('127.0.0.1', { 'x-real-ip': '1.2.3.4' })));
++ assert.ok(!metrics.isLoopbackDirect(req('127.0.0.1', { forwarded: 'for=1.2.3.4' })));
++
++ // ── The whole thing on a real server (a minimal Express-compatible router stand-in is not
++ // enough for req.route; use express when it is installed, else a hand-rolled app) ──
++ let express = null;
++ try { express = require('express'); } catch { /* not a dependency of this package */ }
++ if (express) {
++ const app = express();
++ const m = metrics.instrument(app, { service: 'shared-test', release: 'abc1234', skip: (q) => q.path === '/sse' });
++ const router = express.Router();
++ router.get('/:id', (q, s) => s.status(q.params.id === '0' ? 404 : 200).json({ id: q.params.id }));
++ app.use('/api/items', router);
++ app.get('/boom', () => { throw new Error('x'); });
++ app.get('/sse', (_q, s) => s.end('ok'));
++ const server = await new Promise((resolve) => { const s = app.listen(0, '127.0.0.1', () => resolve(s)); });
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const get = (p, headers = {}) => new Promise((resolve, reject) => http.get(base + p, { headers }, (res) => { let b = ''; res.on('data', (d) => { b += d; }); res.on('end', () => resolve({ status: res.statusCode, body: b, type: res.headers['content-type'] })); }).on('error', reject));
++ await get('/api/items/42?token=secret');
++ await get('/api/items/43');
++ await get('/api/items/0');
++ await get('/boom');
++ await get('/sse');
++ await get('/nope/9999/secret-path');
++ const out = await get('/metrics');
++ assert.strictEqual(out.status, 200);
++ assert.match(out.type, /^text\/plain; version=0\.0\.4/);
++ assert.ok(out.body.includes('http_requests_total{method="GET",route="/api/items/:id",status_class="2xx"} 2\n'), out.body);
++ assert.ok(out.body.includes('http_requests_total{method="GET",route="/api/items/:id",status_class="4xx"} 1\n'));
++ assert.ok(out.body.includes('http_requests_total{method="GET",route="/boom",status_class="5xx"} 1\n'));
++ assert.ok(out.body.includes('http_requests_total{method="GET",route="unmatched",status_class="4xx"} 1\n'));
++ const labelSets = out.body.split('\n').filter((l) => l.startsWith('http_')).map((l) => (l.match(/\{[^}]*\}/) || [''])[0]).join('\n');
++ assert.ok(!/42|43|secret|9999|nope/.test(labelSets), 'no ids, queries or raw paths in labels');
++ assert.ok(!out.body.includes('route="/sse"'), 'skipped requests are not recorded');
++ assert.ok(out.body.includes('http_request_duration_seconds_count{method="GET",route="/api/items/:id"} 3\n'));
++ assert.ok(/http_requests_in_flight 1\n/.test(out.body), 'the scrape itself is in flight');
++ assert.ok(out.body.includes('release_info{service="shared-test",release="abc1234"} 1\n'));
++ for (const n of ['process_resident_memory_bytes', 'nodejs_heap_used_bytes', 'process_uptime_seconds', 'process_cpu_seconds_total', 'nodejs_eventloop_lag_seconds{stat="p99"}']) assert.ok(out.body.includes(`\n${n} `), n);
++ const proxied = await get('/metrics', { 'X-Forwarded-For': '203.0.113.9' });
++ assert.strictEqual(proxied.status, 404, 'a proxied caller gets 404');
++ m.stop();
++ server.close();
++ } else {
++ console.log('metrics: express not installed; middleware checked through the handler only');
++ }
++
++ // Handler without express: 404 to non-loopback, text to loopback.
++ const reg = metrics.createRegistry();
++ metrics.releaseInfo(reg, { service: 's', release: 'r' });
++ const handler = metrics.metricsHandler(reg);
++ const fakeRes = () => { const o = { headers: {}, statusCode: 0, body: '' }; o.setHeader = (k, v) => { o.headers[k] = v; }; o.end = (b) => { o.body = b; }; return o; };
++ let res = fakeRes(); handler(req('192.0.2.1'), res);
++ assert.strictEqual(res.statusCode, 404);
++ res = fakeRes(); handler(req('127.0.0.1'), res);
++ assert.strictEqual(res.statusCode, 200);
++ assert.ok(res.body.includes('release_info{service="s",release="r"} 1'));
++ console.log('metrics: all checks passed');
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/package.test.js b/test/package.test.js
iindex 807b382..863b79e 100644
---- a/test/package.test.js
++++ b/test/package.test.js
@@@ -19,7 +19,7 @@ for (const sub of ['analytics', 'app-icon', 'auth-client', 'brand', 'chrome-ssr'
assert.doesNotThrow(() => require.resolve(`openvibe-shared/${sub}`), `openvibe-shared/${sub}`);
}
// Server modules load in Node; the navbar module loads too (UMD, no DOM touched at require time).
--for (const sub of ['analytics', 'app-icon', 'brand', 'chrome-ssr', 'footer', 'icons', 'legal', 'notifications', 'seo', 'url-resolver', 'files', 'nav-icons']) require(`openvibe-shared/${sub}`);
++for (const sub of ['analytics', 'app-icon', 'brand', 'chrome-ssr', 'footer', 'icons', 'legal', 'notifications', 'seo', 'url-resolver', 'files', 'nav-icons', 'metrics', 'ready']) require(`openvibe-shared/${sub}`);
assert.strictEqual(typeof require('openvibe-shared/navbar').init, 'function');
assert.ok(/^\d+\.\d+\.\d+$/.test(pkg.version), 'semver version');
assert.ok(fs.readFileSync(path.join(ROOT, 'CHANGELOG.md'), 'utf8').includes(`## ${pkg.version}`), 'CHANGELOG has an entry for this version');
ddiff --git a/test/ready.test.js b/test/ready.test.js
nnew file mode 100644
iindex 0000000..d7a5170
---- /dev/null
++++ b/test/ready.test.js
@@@ -0,0 +1,85 @@
++'use strict';
++// ready.js: required vs optional checks, status/latency/checked_at on every check, timeouts,
++// cached checks keep the time they really ran, and failure reasons never leak secrets.
++const assert = require('assert');
++const { createReadiness, safeReason } = require('../ready');
++
++(async () => {
++ assert.throws(() => createReadiness({}), /service/);
++ assert.throws(() => createReadiness({ service: 's', checks: [{ name: 'Bad Name', check: () => true }] }), /bad check name/);
++ assert.throws(() => createReadiness({ service: 's', checks: [{ name: 'db', check: () => true }, { name: 'db', check: () => true }] }), /duplicate/);
++
++ let dbUp = true; let mediaUp = true; let remoteCalls = 0;
++ const ready = createReadiness({
++ service: 'community',
++ release: 'abc1234',
++ checks: [
++ { name: 'db', required: true, check: () => { if (!dbUp) throw new Error('SQLITE_CANTOPEN: unable to open database file'); return true; } },
++ { name: 'network_jwks', required: true, check: () => ({ ok: true, detail: { kid: 'ov-network-1' } }) },
++ { name: 'media', required: false, check: () => (mediaUp ? true : 'Media answered 502') },
++ { name: 'remote_tier', required: false, cacheMs: 60000, check: async () => { remoteCalls++; return { ok: false, error: 'GET https://user:[email protected]/bucket?X-Amz-Signature=abc failed' }; } },
++ { name: 'slow', required: false, timeoutMs: 30, check: () => new Promise((resolve) => setTimeout(resolve, 500)) },
++ ],
++ details: () => ({ queue: 3, ready: 'ignored: never overrides a computed field' }),
++ });
++
++ let body = await ready.run();
++ assert.strictEqual(body.ready, true, 'optional failures keep the service ready');
++ assert.strictEqual(body.status, 'degraded');
++ assert.deepStrictEqual(body.failed, []);
++ assert.deepStrictEqual(body.degraded, ['remote_tier', 'slow']);
++ assert.strictEqual(body.service, 'community');
++ assert.strictEqual(body.release, 'abc1234');
++ assert.strictEqual(body.queue, 3);
++ for (const [name, c] of Object.entries(body.checks)) {
++ assert.ok(['ok', 'fail'].includes(c.status), name);
++ assert.strictEqual(typeof c.latency_ms, 'number', `${name} latency`);
++ assert.ok(!Number.isNaN(Date.parse(c.checked_at)), `${name} checked_at`);
++ assert.strictEqual(typeof c.required, 'boolean');
++ }
++ assert.deepStrictEqual(body.checks.network_jwks.detail, { kid: 'ov-network-1' });
++ assert.strictEqual(body.checks.slow.error, 'timeout');
++ const leaked = body.checks.remote_tier.error;
++ assert.ok(!/pw@|Signature=abc|\?/.test(leaked), `no credentials or query in reasons: ${leaked}`);
++ assert.ok(leaked.includes('https://b2.example/bucket'));
++
++ // Cached optional check: not re-run, and still reports when it actually ran.
++ const firstAt = body.checks.remote_tier.checked_at;
++ await new Promise((r) => setTimeout(r, 5));
++ body = await ready.run();
++ assert.strictEqual(remoteCalls, 1);
++ assert.strictEqual(body.checks.remote_tier.checked_at, firstAt);
++
++ // All healthy apart from the cached remote tier.
++ mediaUp = false;
++ body = await ready.run();
++ assert.ok(body.degraded.includes('media'));
++ assert.strictEqual(body.checks.media.error, 'Media answered 502');
++
++ // A required failure is not ready, with the reason.
++ dbUp = false;
++ const res = { headers: {}, setHeader(k, v) { this.headers[k] = v; }, end(b) { this.body = b; } };
++ await ready.handler({}, res);
++ assert.strictEqual(res.statusCode, 503);
++ assert.strictEqual(res.headers['Cache-Control'], 'no-store');
++ body = JSON.parse(res.body);
++ assert.strictEqual(body.ready, false);
++ assert.strictEqual(body.status, 'not_ready');
++ assert.deepStrictEqual(body.failed, ['db']);
++ assert.match(body.checks.db.error, /SQLITE_CANTOPEN/);
++
++ dbUp = true; mediaUp = true;
++ const ok = createReadiness({ service: 'x', checks: [{ name: 'db', check: () => 1 }, { name: 'falsey', required: false, check: () => false }] });
++ const res2 = { setHeader() {}, end(b) { this.body = b; } };
++ await ok.handler({}, res2);
++ assert.strictEqual(res2.statusCode, 200);
++ assert.strictEqual(JSON.parse(res2.body).checks.falsey.error, 'check returned false');
++
++ const none = await createReadiness({ service: 'x' }).run();
++ assert.strictEqual(none.ready, true);
++ assert.strictEqual(none.status, 'ready');
++
++ assert.strictEqual(safeReason(Object.assign(new Error('x'), { name: 'AbortError' })), 'timeout');
++ assert.strictEqual(safeReason('bad token=abc123&x=1'), 'bad token=…&x=1');
++ console.log('ready: all checks passed');
++})().catch((err) => { console.error(err); process.exit(1); });