openvibers/openvibe.shared release.js
release.js
| 1 | ; |
| 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 | |
| 42 | const fs = require('fs'); |
| 43 | const path = require('path'); |
| 44 | const crypto = require('crypto'); |
| 45 | const { execFileSync } = require('child_process'); |
| 46 | |
| 47 | const SHA_RE = /^[0-9a-f]{7,40}$/; |
| 48 | const ID_RE = /^[a-z][a-z0-9-]{0,39}$/; |
| 49 | const CONTRACT_RE = /^[a-z][a-z0-9-]*(\.[a-z0-9_-]+)+$/; |
| 50 | const KINDS = ['style', 'content', 'script', 'server']; |
| 51 | const V1_0 = ['service', 'release', 'released_at', 'booted_at', 'contracts_version', 'packages', 'min_client_release', 'mixed_version_window_hours']; |
| 52 | const 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. |
| 58 | function 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 | |
| 64 | function 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 | |
| 70 | function 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 | } |
| 74 | function 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 | } |
| 81 | const 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]); |
| 82 | const 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 | */ |
| 88 | function 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 | |
| 105 | function 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. */ |
| 111 | function 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 | |
| 129 | function 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 | |
| 142 | const within = (base, p) => p === base || p.startsWith(base + path.sep); |
| 143 | const hash12 = (buf) => crypto.createHash('sha256').update(buf).digest('hex').slice(0, 12); |
| 144 | |
| 145 | function 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). */ |
| 192 | function 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. */ |
| 197 | function 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 | |
| 211 | function 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 | |
| 218 | const 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 | }; |
| 225 | const MAX_BODY = 4096; |
| 226 | const MAX_PER_REASON = 50; |
| 227 | const 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. |
| 231 | const SESSION_TTL_MS = 12 * 60 * 1000; |
| 232 | const MAX_SESSIONS = 20000; |
| 233 | const SESSION_RE = /^[0-9a-f]{16,32}$/; |
| 234 | const sessionStores = new WeakMap(); |
| 235 | |
| 236 | function 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 | |
| 260 | function 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. */ |
| 270 | function 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 | */ |
| 293 | function 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 | */ |
| 336 | function 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 | |
| 348 | function 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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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 | |
| 475 | module.exports = { createRelease, normalizeRange, satisfies, compareVersions, parseVersion, contractRanges, project, collector, OUTCOMES }; |
| 476 |