openvibers/openvibe.shared release.js

release.js

main 26.7 KB · 476 lines · javascript

1'use strict';
2// ═══════════════════════════════════════════════════════════════
3// openvibe-shared/release — GET /release.json for a web surface (ADR-016, contract
4// registry.release-manifest@1). Open tabs poll it (release-watch.js) and learn when the page
5// they run is older than what the server now serves, and what changed.
6//
7// const release = require('openvibe-shared/release').createRelease({
8// service: 'community',
9// components: { // optional; see "Components" below
10// styles: { kind: 'style', assets: ['/css/app.css'] },
11// pages: { kind: 'content', files: ['server/web/pages'] },
12// shell: { kind: 'script', assets: ['/js/app.js'], files: ['server/web/layout.js'] },
13// },
14// contracts: { 'community.web-api': { version: '1.3.0', accepts: '^1.0.0' } },
15// schemaGeneration: () => migrations.generation(),
16// });
17// release.mount(app, { registry }); // GET /release.json + POST /release-metrics into /metrics
18// html += release.metaTag(); // <meta name="ov-release" …> — the page's own release
19//
20// The release is the deployed commit (git in `root`, or RELEASE_COMMIT), read at boot.
21// MIN_CLIENT_RELEASE (env) set to the running release tells every older tab it must reload
22// (as soon as that is safe); otherwise older tabs are prompted, and reload on their own once
23// the new release is older than the mixed-version window (24 h by default).
24//
25// Components (manifest 1.1.0). Each has a kind and a version, the content hash of its files:
26// style stylesheets (`assets`): open tabs swap the <link>s in place
27// content server-rendered regions marked data-ov-content="<id>": open tabs re-fetch them in place
28// script code the page runs: a reload at a safe moment
29// server code only the server runs: nothing to do in the tab
30// `shell` is the catch-all script component for every client-facing file no other component
31// lists (templates, scripts). Without a declared shell its version is the release, so every release
32// reloads, as before 1.5.0. The platform package versions are part of the shell's hash.
33// `assets` are URL paths under `publicDir`; they also make the asset map (`url` is
34// `<path>?v=<12 hex of sha256>` unless `assetUrl` says otherwise, `integrity` is sha384).
35// `files` are paths under `root`; a directory counts every file in it.
36//
37// The served manifest carries only the fields the service's installed openvibe-contracts
38// release-manifest schema declares (1.0.0 has none of the fields above), so /release.json always
39// validates against the contracts the service pins. `full()` is the manifest before that filter.
40// ═══════════════════════════════════════════════════════════════
41
42const fs = require('fs');
43const path = require('path');
44const crypto = require('crypto');
45const { execFileSync } = require('child_process');
46
47const SHA_RE = /^[0-9a-f]{7,40}$/;
48const ID_RE = /^[a-z][a-z0-9-]{0,39}$/;
49const CONTRACT_RE = /^[a-z][a-z0-9-]*(\.[a-z0-9_-]+)+$/;
50const KINDS = ['style', 'content', 'script', 'server'];
51const V1_0 = ['service', 'release', 'released_at', 'booted_at', 'contracts_version', 'packages', 'min_client_release', 'mixed_version_window_hours'];
52const SCHEMA_PATH = 'openvibe-contracts/contracts/registry/release-manifest.v1.json';
53
54// A release directory belongs to the deploying user (root under ovhost) while the service runs as its own user, and
55// git then refuses the repository ("detected dubious ownership"): released_at fell back to the boot time on every
56// release-layout service. These reads are read-only, of this release's own checkout, so the directory is named safe
57// for them on the command line (git honours safe.directory there), resolved through the `current` symlink.
58function git(root, args) {
59 let real = root;
60 try { real = fs.realpathSync(root); } catch { /* keep root */ }
61 try { return execFileSync('git', ['-c', `safe.directory=${real}`, ...args], { cwd: root, stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000 }).toString().trim(); } catch { return ''; }
62}
63
64function pkgVersion(name, root) {
65 try { return JSON.parse(fs.readFileSync(require.resolve(`${name}/package.json`, { paths: [root] }), 'utf8')).version || null; } catch { return null; }
66}
67
68// ── Versions and ranges ────────────────────────────────────────
69
70function parseVersion(s) {
71 const m = /^v?(\d+)\.(\d+)\.(\d+)$/.exec(String(s == null ? '' : s).trim());
72 return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
73}
74function compareVersions(a, b) {
75 const x = typeof a === 'string' ? parseVersion(a) : a;
76 const y = typeof b === 'string' ? parseVersion(b) : b;
77 if (!x || !y) return NaN;
78 for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] < y[i] ? -1 : 1;
79 return 0;
80}
81const bump = (v, i) => (i === 0 ? [v[0] + 1, 0, 0] : i === 1 ? [v[0], v[1] + 1, 0] : [v[0], v[1], v[2] + 1]);
82const caretTop = (v) => (v[0] > 0 ? bump(v, 0) : v[1] > 0 ? bump(v, 1) : bump(v, 2));
83
84/**
85 * A range as the manifest writes it: ">=a.b.c <x.y.z". Accepts "^1.2.0", "~1.2.0", "1.x", "1.2",
86 * "1.2.3" (that version only), ">=1.2.0", ">=1.2.0 <3.0.0" and "*"; empty means "^<version>".
87 */
88function normalizeRange(range, version) {
89 const r = String(range == null ? '' : range).trim();
90 let lo; let hi; let m;
91 if (!r) { lo = parseVersion(version); hi = lo && caretTop(lo); }
92 else if (r === '*' || /^x$/i.test(r)) { lo = [0, 0, 0]; hi = [Number.MAX_SAFE_INTEGER, 0, 0]; }
93 else if ((m = /^\^(\S+)$/.exec(r))) { lo = parseVersion(m[1]); hi = lo && caretTop(lo); }
94 else if ((m = /^~(\S+)$/.exec(r))) { lo = parseVersion(m[1]); hi = lo && bump(lo, 1); }
95 else if ((m = /^(\d+)(?:\.(\d+|x|\*))?(?:\.(x|\*))?$/i.exec(r))) {
96 const minor = m[2] === undefined || /x|\*/i.test(m[2]) ? null : Number(m[2]);
97 lo = [Number(m[1]), minor == null ? 0 : minor, 0];
98 hi = bump(lo, minor == null ? 0 : 1);
99 } else if ((m = /^>=\s*(\S+)(?:\s+<\s*(\S+))?$/.exec(r))) { lo = parseVersion(m[1]); hi = m[2] ? parseVersion(m[2]) : lo && bump(lo, 0); }
100 else if (parseVersion(r)) { lo = parseVersion(r); hi = bump(lo, 2); }
101 if (!lo || !hi || !(compareVersions(lo, hi) < 0)) throw new TypeError(`release: cannot read the range "${r}"`);
102 return `>=${lo.join('.')} <${hi.join('.')}`;
103}
104
105function satisfies(version, range) {
106 const m = /^>=(\S+) <(\S+)$/.exec(String(range || ''));
107 return !!m && compareVersions(version, m[1]) >= 0 && compareVersions(version, m[2]) < 0;
108}
109
110/** { id: '1.2.0' | { version, accepts?, role? } } → the manifest's contract_ranges. */
111function contractRanges(input) {
112 const out = {};
113 for (const [id, spec] of Object.entries(input || {})) {
114 if (!CONTRACT_RE.test(id) || id.length > 100) throw new TypeError(`release: contract id "${id}" is not <owner>.<name>`);
115 const s = typeof spec === 'string' ? { version: spec } : (spec || {});
116 const v = parseVersion(s.version);
117 if (!v) throw new TypeError(`release: ${id} needs a version x.y.z`);
118 const accepts = normalizeRange(s.accepts, s.version);
119 if (!satisfies(v.join('.'), accepts)) throw new TypeError(`release: ${id} accepts ${accepts}, which excludes its own version ${v.join('.')}`);
120 const role = s.role || 'produces';
121 if (role !== 'produces' && role !== 'consumes') throw new TypeError(`release: ${id} role must be produces or consumes`);
122 out[id] = role === 'produces' ? { version: v.join('.'), accepts } : { version: v.join('.'), accepts, role };
123 }
124 return out;
125}
126
127// ── Components and the asset map ───────────────────────────────
128
129function listFiles(abs) {
130 let st;
131 try { st = fs.statSync(abs); } catch { return null; }
132 if (st.isFile()) return [abs];
133 if (!st.isDirectory()) return null;
134 const out = [];
135 for (const e of fs.readdirSync(abs, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : 1))) {
136 if (e.name.startsWith('.') || e.name === 'node_modules') continue;
137 out.push(...(listFiles(path.join(abs, e.name)) || []));
138 }
139 return out;
140}
141
142const within = (base, p) => p === base || p.startsWith(base + path.sep);
143const hash12 = (buf) => crypto.createHash('sha256').update(buf).digest('hex').slice(0, 12);
144
145function buildComponents({ root, publicDir, spec, release, packages, assetUrl, warn }) {
146 const components = {}; const assets = {}; const watched = [];
147 const specs = { ...(spec || {}) };
148 for (const [id, c] of Object.entries(specs)) {
149 if (!ID_RE.test(id)) throw new TypeError(`release: component id "${id}" must match ${ID_RE}`);
150 if (!c || !KINDS.includes(c.kind)) throw new TypeError(`release: component ${id} needs kind ${KINDS.join('|')}`);
151 }
152 if (!specs.shell) specs.shell = { kind: 'script' };
153 else if (specs.shell.kind !== 'script') throw new TypeError('release: the shell component is kind script');
154 if (!specs.server) specs.server = { kind: 'server' };
155 for (const [id, c] of Object.entries(specs)) {
156 const h = crypto.createHash('sha256');
157 let counted = 0;
158 for (const urlPath of c.assets || []) {
159 const p = String(urlPath);
160 const file = path.resolve(publicDir, '.' + p);
161 if (!p.startsWith('/') || p.includes('..') || /[?#\s]/.test(p) || !within(publicDir, file)) throw new TypeError(`release: asset ${p} of ${id} is not a path under publicDir`);
162 let buf;
163 try { buf = fs.readFileSync(file); } catch { warn(`asset ${p} of component ${id} is missing (${file})`); h.update(`${p}\0missing\0`); continue; }
164 watched.push(file);
165 if (assets[p]) warn(`asset ${p} is listed by ${assets[p].component} and ${id}`);
166 assets[p] = { url: assetUrl(p, hash12(buf)), component: id, integrity: `sha384-${crypto.createHash('sha384').update(buf).digest('base64')}` };
167 h.update(`${p}\0`).update(buf).update('\0');
168 counted++;
169 }
170 for (const rel of c.files || []) {
171 const abs = path.resolve(root, String(rel));
172 const list = within(root, abs) ? listFiles(abs) : null;
173 if (!list) { warn(`file ${rel} of component ${id} is missing`); h.update(`${rel}\0missing\0`); continue; }
174 for (const f of list) {
175 watched.push(f);
176 h.update(`${path.relative(root, f)}\0`).update(fs.readFileSync(f)).update('\0');
177 counted++;
178 }
179 }
180 if (id === 'shell') h.update(JSON.stringify(packages));
181 const stated = c.version != null ? String(c.version) : null;
182 const version = stated || (counted ? hash12(h.digest()) : release);
183 if (!/^[0-9A-Za-z._+-]{1,64}$/.test(version)) throw new TypeError(`release: component ${id} version "${version}" is not a plain token`);
184 components[id] = { kind: c.kind, version };
185 }
186 return { components, assets, watched };
187}
188
189// ── The contract filter ────────────────────────────────────────
190
191/** The service's installed release-manifest schema, or null (then the 1.0.0 fields are served). */
192function installedSchema(root) {
193 try { return JSON.parse(fs.readFileSync(require.resolve(SCHEMA_PATH, { paths: [root] }), 'utf8')); } catch { return null; }
194}
195
196/** Keeps what `schema` allows: properties it names when additionalProperties is false, recursively. */
197function project(value, schema) {
198 if (!schema || typeof schema !== 'object' || value === null || typeof value !== 'object') return value;
199 if (Array.isArray(value)) return schema.items ? value.map((v) => project(v, schema.items)) : value;
200 const props = schema.properties || {};
201 const extra = schema.additionalProperties;
202 const out = {};
203 for (const [k, v] of Object.entries(value)) {
204 if (props[k]) out[k] = project(v, props[k]);
205 else if (extra === false) continue;
206 else out[k] = extra && typeof extra === 'object' ? project(v, extra) : v;
207 }
208 return out;
209}
210
211function deepFreeze(o) {
212 if (o && typeof o === 'object' && !Object.isFrozen(o)) { Object.freeze(o); for (const v of Object.values(o)) deepFreeze(v); }
213 return o;
214}
215
216// ── /release-metrics: what open tabs report (D46) ──────────────
217
218const OUTCOMES = {
219 prompted: /^(optional|required|window|contract)$/,
220 applied: /^(content|server|style)(\+(content|server|style)){0,2}$/,
221 reloaded: /^(user|required|window|contract)$/,
222 deferred: /^(typing|dirty|protected|media|capture|active|account)$/,
223 failed: /^(style|style-timeout|content|origin|script)$/,
224};
225const MAX_BODY = 4096;
226const MAX_PER_REASON = 50;
227const counters = new WeakMap();
228// Open tabs by generation (WS-P task 14): each tab beats every 5 minutes with a random per-tab id (never
229// stored in the browser, never tied to an account); a tab not heard from in 12 minutes, or that said it
230// ended, is gone. At most MAX_SESSIONS are tracked per registry.
231const SESSION_TTL_MS = 12 * 60 * 1000;
232const MAX_SESSIONS = 20000;
233const SESSION_RE = /^[0-9a-f]{16,32}$/;
234const sessionStores = new WeakMap();
235
236function sessionStore(registry, currentRelease) {
237 let st = sessionStores.get(registry);
238 if (!st) {
239 st = { map: new Map(), current: null };
240 registry.gauge({
241 name: 'release_client_sessions', help: 'Open tabs heard from in the last 12 minutes, by whether they run the release this process serves (current) or an older one',
242 labelNames: ['generation'],
243 collect: () => {
244 const cutoff = Date.now() - SESSION_TTL_MS;
245 const cur = typeof st.current === 'function' ? st.current() : null;
246 let current = 0; let older = 0;
247 for (const [id, v] of st.map) {
248 if (v.at < cutoff) { st.map.delete(id); continue; }
249 if (cur && v.release === cur) current++; else older++;
250 }
251 return [{ labels: { generation: 'current' }, value: current }, { labels: { generation: 'older' }, value: older }];
252 },
253 });
254 sessionStores.set(registry, st);
255 }
256 if (currentRelease) st.current = currentRelease;
257 return st;
258}
259
260function updateCounter(registry) {
261 let c = counters.get(registry);
262 if (!c) {
263 c = registry.counter({ name: 'release_client_updates_total', help: 'Release updates in open tabs, reported by release-watch: applied in place, reloaded, deferred or failed, by reason', labelNames: ['outcome', 'reason'], maxSeries: 64 });
264 counters.set(registry, c);
265 }
266 return c;
267}
268
269/** The report: a string, an object a body parser made, or null when it is over MAX_BODY. */
270function readBody(req) {
271 const b = req.body;
272 if (typeof b === 'string') return Promise.resolve(b.length > MAX_BODY ? null : b);
273 if (Buffer.isBuffer(b)) return Promise.resolve(b.length > MAX_BODY ? null : b.toString('utf8'));
274 // express.json() leaves {} (and the stream unread) for the text/plain body sendBeacon sends.
275 if (b && typeof b === 'object' && (Object.keys(b).length || req.readableEnded)) return Promise.resolve(b);
276 return new Promise((resolve) => {
277 let size = 0; const chunks = [];
278 req.on('data', (c) => { size += c.length; if (size <= MAX_BODY) chunks.push(c); });
279 req.on('end', () => resolve(size > MAX_BODY ? null : Buffer.concat(chunks).toString('utf8')));
280 req.on('error', () => resolve(''));
281 });
282}
283
284/**
285 * POST handler for release-watch beacons: { counts: { prompted|applied|reloaded|deferred|failed: { reason: n } },
286 * session, release, ended }. Counts go to release_client_updates_total{outcome,reason} in `registry`
287 * (openvibe-shared/metrics); a session beat (with or without counts) keeps that tab in
288 * release_client_sessions{generation}, current when its release is `currentRelease()`, and `ended` drops it.
289 * Unknown reasons count as "other"; each reason counts at most 50 per report; a report is at most 4 KB;
290 * one client (req.ip, which honours the app's trust proxy; or `keyOf(req)`) sends at most `perMinute`
291 * reports a minute. Sec-GPC/DNT requests are not counted.
292 */
293function collector(registry, { perMinute = 30, keyOf = null, currentRelease = null } = {}) {
294 if (!registry || typeof registry.counter !== 'function') throw new TypeError('release: collect() needs an openvibe-shared/metrics registry');
295 const counter = updateCounter(registry);
296 const sessions = sessionStore(registry, currentRelease);
297 const seen = new Map(); let windowStart = Date.now();
298 return async function releaseMetrics(req, res) {
299 const done = (code) => { res.statusCode = code; res.setHeader('Cache-Control', 'no-store'); res.end(); };
300 const h = req.headers || {};
301 if (h['sec-gpc'] === '1' || h.dnt === '1') return done(204);
302 const now = Date.now();
303 if (now - windowStart > 60000) { seen.clear(); windowStart = now; }
304 // req.ip first: behind the proxy every socket is 127.0.0.1, and one shared bucket refused everyone.
305 const ip = String((keyOf && keyOf(req)) || req.ip || (req.socket && req.socket.remoteAddress) || '');
306 const n = (seen.get(ip) || 0) + 1;
307 if (seen.size < 10000 || seen.has(ip)) seen.set(ip, n);
308 if (n > perMinute) return done(429);
309 let body = await readBody(req);
310 if (body === null) return done(413);
311 if (typeof body === 'string') { try { body = JSON.parse(body); } catch { return done(400); } }
312 if (!body || typeof body !== 'object') return done(400);
313 const counts = body.counts && typeof body.counts === 'object' ? body.counts : null;
314 const sid = typeof body.session === 'string' && SESSION_RE.test(body.session) ? body.session : null;
315 if (!counts && !sid) return done(400);
316 if (sid) {
317 if (body.ended === true) sessions.map.delete(sid);
318 else if (sessions.map.size < MAX_SESSIONS || sessions.map.has(sid)) sessions.map.set(sid, { release: typeof body.release === 'string' ? body.release.slice(0, 80) : null, at: now });
319 }
320 for (const [outcome, re] of Object.entries(OUTCOMES)) {
321 const reasons = counts && counts[outcome];
322 if (!reasons || typeof reasons !== 'object') continue;
323 for (const [reason, value] of Object.entries(reasons).slice(0, 16)) {
324 const k = Math.min(MAX_PER_REASON, Math.floor(Number(value)));
325 if (k > 0) counter.inc({ outcome, reason: re.test(reason) ? reason : 'other' }, k);
326 }
327 }
328 return done(204);
329 };
330}
331
332/**
333 * The shell (manifest 1.2.0): the named components that exist, and one version over their ids and
334 * versions (first 12 hex of SHA-256, in id order). Null when none is named or none exists.
335 */
336function shellOf(ids, components, warn) {
337 if (!Array.isArray(ids) || !ids.length) return null;
338 const have = ids.filter((id) => components && components[id]).sort();
339 const missing = ids.filter((id) => !(components && components[id]));
340 if (missing.length) warn(`shell names components that do not exist: ${missing.join(', ')}`);
341 if (!have.length) return null;
342 const version = crypto.createHash('sha256').update(have.map((id) => `${id}@${components[id].version}`).join('\n')).digest('hex').slice(0, 12);
343 return { version, components: have };
344}
345
346// ── createRelease ──────────────────────────────────────────────
347
348function createRelease({
349 service, root = process.cwd(), windowHours = 24,
350 minClientRelease = process.env.MIN_CLIENT_RELEASE || null,
351 packages = ['openvibe-shared', 'openvibe-sdk'],
352 components = null, publicDir = null, assetUrl = (p, h) => `${p}?v=${h}`,
353 contracts = null, schemaGeneration = null, schemaCompatibleFrom = null,
354 clientGeneration = null, minClientGeneration = process.env.MIN_CLIENT_GENERATION ?? null, shell = null,
355 metricsPath = null, schema, recheckMs = 0,
356 env = process.env, now = () => new Date(), logger = console,
357} = {}) {
358 if (!/^[a-z][a-z0-9-]{1,39}$/.test(String(service || ''))) throw new TypeError('createRelease: service id required');
359 root = path.resolve(root);
360 const pub = path.resolve(root, publicDir || 'public');
361 const ranges = contractRanges(contracts); // throws at boot on a malformed range
362 const contractSchema = schema === undefined ? installedSchema(root) : schema;
363 const bootedAt = now();
364 const warnings = [];
365 const warn = (msg) => { warnings.push(msg); try { logger.warn(`[release] ${service}: ${msg}`); } catch { /* */ } };
366 let metricsUrl = metricsPath || null;
367 let state = null; let body = null; let signature = ''; let checkedAt = 0;
368
369 const intOrNull = (v, name) => {
370 let x = v;
371 try { if (typeof x === 'function') x = x(); } catch (err) { warn(`${name} failed: ${err && err.message}`); return null; }
372 if (x == null) return null;
373 if (Number.isInteger(x) && x >= 0) return x;
374 warn(`${name} is not a non-negative integer`); return null;
375 };
376 const sigOf = (files) => files.map((f) => { try { const s = fs.statSync(f); return `${s.mtimeMs}:${s.size}`; } catch { return '-'; } }).join('|');
377
378 function build() {
379 warnings.length = 0;
380 const fromEnv = String(env.RELEASE_COMMIT || '').toLowerCase();
381 const sha = SHA_RE.test(fromEnv) ? fromEnv.slice(0, 12) : git(root, ['rev-parse', '--short=12', 'HEAD']);
382 const release = SHA_RE.test(sha) ? sha : '0000000';
383 const committed = env.RELEASE_AT || git(root, ['log', '-1', '--format=%cI']);
384 const releasedAt = Number.isNaN(Date.parse(committed)) ? bootedAt : new Date(committed);
385 const versions = {};
386 for (const p of packages) { const v = pkgVersion(p, root); if (v) versions[p] = v; }
387 const built = buildComponents({ root, publicDir: pub, spec: components, release, packages: versions, assetUrl, warn });
388 const generation = intOrNull(schemaGeneration, 'schemaGeneration');
389 let compatibleFrom = schemaCompatibleFrom == null ? generation : intOrNull(schemaCompatibleFrom, 'schemaCompatibleFrom');
390 if (generation != null && compatibleFrom != null && compatibleFrom > generation) { warn('schemaCompatibleFrom is above schemaGeneration'); compatibleFrom = generation; }
391 const full = deepFreeze({
392 service,
393 release,
394 released_at: releasedAt.toISOString(),
395 booted_at: bootedAt.toISOString(),
396 contracts_version: pkgVersion('openvibe-contracts', root),
397 packages: versions,
398 min_client_release: minClientRelease && SHA_RE.test(minClientRelease) ? minClientRelease : null,
399 mixed_version_window_hours: Math.max(0, Math.floor(Number(windowHours) || 0)),
400 components: built.components,
401 assets: built.assets,
402 schema_generation: generation,
403 schema_compatible_from: compatibleFrom,
404 contract_ranges: ranges,
405 // Manifest 1.2.0 (WS-P task 7): client generations and the shell.
406 client_generation: intOrNull(clientGeneration, 'clientGeneration'),
407 min_client_generation: intOrNull(typeof minClientGeneration === 'string' ? (minClientGeneration.trim() === '' ? null : Number(minClientGeneration)) : minClientGeneration, 'minClientGeneration'),
408 shell: shellOf(shell, built.components, warn),
409 metrics_url: metricsUrl,
410 });
411 const served = deepFreeze(contractSchema ? project(full, contractSchema) : Object.fromEntries(V1_0.map((k) => [k, full[k]])));
412 state = { full, served, watched: built.watched };
413 body = JSON.stringify(served);
414 signature = sigOf(built.watched);
415 checkedAt = Date.now();
416 return served;
417 }
418 build();
419
420 /** Re-reads git, env, files and the schema generation (e.g. after a static-only release switch). */
421 function refresh() { return build(); }
422 function maybeRecheck() {
423 if (!(recheckMs > 0) || Date.now() - checkedAt < recheckMs) return;
424 checkedAt = Date.now();
425 if (sigOf(state.watched) !== signature) build();
426 }
427
428 function handler(_req, res) {
429 maybeRecheck();
430 res.setHeader('Content-Type', 'application/json; charset=utf-8');
431 res.setHeader('Cache-Control', 'no-cache, max-age=0');
432 res.setHeader('Access-Control-Allow-Origin', '*');
433 res.end(body);
434 }
435 const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
436 const metaTag = (url = '/release.json') => `<meta name="ov-release" content="${esc(state.full.release)}" data-released-at="${esc(state.full.released_at)}" data-url="${esc(url)}"${metricsUrl ? ` data-metrics="${esc(metricsUrl)}"` : ''}${state.full.client_generation != null ? ` data-generation="${state.full.client_generation}"` : ''}>`;
437
438 /** GET /release.json and, with a metrics registry, POST <metricsPath> into release_client_updates_total. */
439 function mount(app, { registry = null, path: manifestPath = '/release.json', metricsPath: mp = metricsPath || '/release-metrics', perMinute } = {}) {
440 app.get(manifestPath, handler);
441 if (registry) {
442 app.post(mp, collector(registry, { perMinute, currentRelease: () => state.full.release }));
443 if (metricsUrl !== mp) { metricsUrl = mp; build(); }
444 }
445 return api;
446 }
447
448 /** Validates the served manifest with the service's openvibe-contracts (or the one passed). */
449 function validate({ contracts: lib } = {}) {
450 let c = lib;
451 if (!c) { try { c = require(require.resolve('openvibe-contracts', { paths: [root] })); } catch { return { valid: null, errors: [], reason: 'openvibe-contracts is not installed' }; } }
452 const r = c.validate('registry.release-manifest@1', state.served);
453 let version = null;
454 try { version = c.resolve('registry.release-manifest@1').version; } catch { /* */ }
455 return { valid: r.valid, errors: r.errors, contract: version };
456 }
457
458 const api = {
459 get release() { return state.full.release; },
460 manifest: () => state.served,
461 full: () => state.full,
462 handler,
463 metaTag,
464 mount,
465 collect: (registry, opts) => collector(registry, { currentRelease: () => state.full.release, ...opts }),
466 refresh,
467 validate,
468 warnings,
469 /** Which manifest fields the service's contracts let it serve. */
470 fields: () => Object.keys(state.served),
471 };
472 return api;
473}
474
475module.exports = { createRelease, normalizeRange, satisfies, compareVersions, parseVersion, contractRanges, project, collector, OUTCOMES };
476