openvibers/openvibe.shared Commits
Track R (ADR-016, D42-D46): release manifests with components, open tabs updated in place, update metrics, and an N/N-1 mixed-version harness
Changes
@@@ -24,7 +24,7 @@ jobs:git archive --format=tar.gz --prefix=OpenVibe.Shared-ci/ -o "$RUNNER_TEMP/openvibe-shared.tgz" HEADmkdir "$RUNNER_TEMP/consumer" && cd "$RUNNER_TEMP/consumer" && npm init -y >/dev/nullnpm install --no-audit --no-fund "$RUNNER_TEMP/openvibe-shared.tgz"-- node -e "require('openvibe-shared'); require('openvibe-shared/navbar'); require('openvibe-shared/legal'); require('openvibe-shared/metrics'); require('openvibe-shared/ready'); require('openvibe-shared/analytics'); require('openvibe-shared/analytics/prune-cli'); require('openvibe-shared/analytics/event.v1.json'); 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'); require('openvibe-shared/release'); require('openvibe-shared/release-compat'); require('openvibe-shared/analytics'); require('openvibe-shared/analytics/prune-cli'); require('openvibe-shared/analytics/event.v1.json'); 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)"# Secret scan (gitleaks) and dependency audit, the workflow every OpenVibers repo calls (Track Q).security:uses: ./.github/workflows/security.yml@@@ -2,8 +2,9 @@> Versioned UI, chrome, SEO, legal and release-client packages every OpenVibe site renders.--**Status:** alpha. The `openvibe-shared` package lives here; the latest tag is v1.3.0 (Wave 2, metrics--and readiness from Track O). Every deployed consumer installs a tagged release and none keeps a++**Status:** alpha. The `openvibe-shared` package lives here; the latest tag is v1.4.0, and 1.5.0 (Track R:++release manifests, in-place updates in open tabs, update metrics, the mixed-version harness) is on++`main`, untagged. Every deployed consumer installs a tagged release and none keeps avendored copy ([docs/migration-plan.md](docs/migration-plan.md) is done): v1.3.0 in Network, Live,Media, Community, Tools, Events, Codes, Wiki, Blog, News, Reviews, Deals, Coupons, Trade, VIP andHost; v1.2.1 in Tips and OpenRe.Stream; v1.0.0 in Sites. CI failed on the v1.3.0 tag (`1173562`,@@@ -22,8 +23,8 @@ 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` (+ `analytics/{privacy,tracker,retention,schema,event,prune-cli}`), `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 |++| 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`, `release-watch.js`, `release-update.js`, `openvibe-sw.js` |++| Node modules (`require('openvibe-shared/<name>')`) | `index` (`.`), `analytics` (+ `analytics/{privacy,tracker,retention,schema,event,prune-cli}`), `app-icon`, `auth-client`, `brand`, `builtin-themes`, `chrome-ssr`, `legal`, `middleware`, `notifications`, `seo`, `theme-sync`, `url-resolver`, `files`, `release`, `release-compat` (tests), `metrics`, `ready`; `footer` and `icons` (= `ov-icons.js`) work on both sides, and `release-update` gives Node its pure `plan()` || Schemas | `docs/schemas/analytics-event.v1.json` (`analytics/event.v1`, exported as `openvibe-shared/analytics/event.v1.json`) || 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` |@@@ -75,6 +76,67 @@ A check fails when it throws, times out, returns `false`, a string (the reason)`{ ok: false, error }`. Name a dependency required only when the service really cannot servewithout it.++### Releases: `/release.json`, open tabs and the mixed-version test (ADR-016, Track R)++++```js++const release = require('openvibe-shared/release').createRelease({++ service: 'community',++ root: path.join(__dirname, '..'),++ components: {++ styles: { kind: 'style', assets: ['/css/app.css', '/css/pages.css'] }, // swapped in place++ pages: { kind: 'content', files: ['server/web/pages'] }, // regions re-fetched in place++ shell: { kind: 'script', assets: ['/js/app.js'], files: ['server/web/layout.js', 'public/index.html'] },++ },++ contracts: { 'community.web-api': { version: '1.4.0', accepts: '^1.0.0' } }, // what the pages call++ schemaGeneration: () => migrations.generation(), // optional++});++const m = require('openvibe-shared/metrics').instrument(app, { service: 'community', release: release.release });++release.mount(app, { registry: m.registry }); // GET /release.json, POST /release-metrics → /metrics++```++++- **Components.** `style` (stylesheets, `assets` are URL paths under `publicDir`, default++ `root/public`), `content` (server-rendered regions), `script` (code the page runs) and `server`++ (code only the server runs). A component's version is the hash of its files. `shell` is the++ catch-all `script` component: list in it **every client-facing file no other component lists**++ (layout templates, page scripts). An undeclared shell is versioned by the release, so every++ release reloads, which is the 1.4.0 behaviour. The asset map's URLs are `<path>?v=<12 hex of++ sha256>`, which matches Live's `server/web/assets.js`; pass `assetUrl(path, hash)` for another++ scheme.++- **Contract ranges.** `version` is what this release speaks. `accepts` is the range it still++ serves from the other side (`^1.0.0` → `>=1.0.0 <2.0.0`); keep it wide enough for the previous++ release, which is the 24-hour window. `role: 'consumes'` marks another service's contract that++ this one calls.++- **What is served** is limited to the fields your installed openvibe-contracts++ `registry.release-manifest` schema declares: 1.0.0 until you pin openvibe-contracts ≥ v0.31.0.++ `release.validate()` checks the served manifest with your contracts (put it in a test), and++ `release.full()` is everything. A static-only release switch without a restart: pass++ `recheckMs`, or call `release.refresh()`.++- **Markup for in-place content**: `<main data-ov-content="pages" data-ov-rev="<revision>">…</main>`.++ The tab re-fetches the page URL (or `data-ov-src`) and takes the element with the same++ `data-ov-content`. Scripts and inline handlers are dropped, so rebind on `ov:content-updated`.++ A stylesheet whose URL path is not the asset's logical path needs `data-ov-asset="/css/app.css"`.++ Mark anything that must not be replaced with `data-ov-protected`.++- **Metrics (D46)**: `release_client_updates_total{outcome,reason}`, where outcome is `applied`,++ `reloaded`, `deferred` or `failed`. The tab finds the endpoint in the manifest's `metrics_url`++ (set by `mount`), the meta tag's `data-metrics`, or `OpenVibeNavbar.init({ releaseWatch:++ { metricsUrl } })`.++++Mixed-version test: capture the previous release's `/release.json` (its `full()` form) and the++calls its pages make as fixtures, then:++++```js++const compat = require('openvibe-shared/release-compat');++await compat.assertMixedVersion({ releases: [++ { name: 'N-1', manifest: require('./fixtures/release-prev.json'), client: compat.replay(require('./fixtures/calls-prev.json')) },++ { name: 'N', manifest: release.full(), server: async () => ({ url, close }), client: compat.replay(calls) },++] });++```++++It fails when adjacent releases' ranges don't overlap both ways, when a pair the manifests call++compatible fails against the real server, and when an incompatible pair would not make the tab++reload. `compat.openPage({ html, serve })` runs release-watch in a linkedom page (install++`linkedom` as a devDependency) for in-place tests of your own markup.++### Analytics (server, ADR-021)```js@@@ -151,7 +213,12 @@ node scripts/build-theme-loader.jsTests are plain Node with stubbed browser globals. Some tests guard the release:--- **navbar.js size budget**: 29 KB brotli (`test/nav-icons.test.js`).++- **navbar.js size budget**: 29 KB brotli (`test/nav-icons.test.js`). release-watch.js and++ release-update.js: 3.5 KB each (`test/release-update.test.js`).++- **Releases (ADR-016)**: the manifest validates against the installed openvibe-contracts (and++ the 1.1.0 schema), in-place updates are transactional and never replace a region in use, the++ reload rules hold, and the mixed-version matrix runs against real servers++ (`test/release*.test.js`).- **Built-in icons**: every icon the default navbar renders must be built in and drawn with nofetch.- **Generated blocks**: the generated navbar and theme-loader blocks must be up to date.@@@ -29,6 +29,7 @@ const BROWSER = Object.freeze(['island.js', // activity island'tooltip.js','release-watch.js', // keeps open tabs on a supported release (ADR-016), loaded after first paint++ 'release-update.js', // plans a new release and applies style/content in place; release-watch loads it on demand'openvibe-sw.js', // Web Push service worker]);@@@ -408,11 +408,14 @@function upgradeIconsWhenFontsReady() { /* icons no longer depend on webfonts */ }// Release watch (ADR-016): a few seconds after the page settles, load release-watch.js from the-- // same place as this navbar, so every site that serves /release.json gets update prompts with-- // no page changes. Opt out with init({ releaseWatch: false }).++ // same place as this navbar, so every site that serves /release.json keeps open tabs current with++ // no page changes. Opt out with init({ releaseWatch: false }); an object is its OVReleaseConfig++ // ({ metricsUrl, url, inPlace }).function loadReleaseWatch() {try {-- if (_config.releaseWatch === false || root.OVRelease || document.querySelector('script[src*="release-watch.js"]')) return;++ const rw = _config.releaseWatch;++ if (rw === false || root.OVRelease || document.querySelector('script[src*="release-watch.js"]')) return;++ if (rw && typeof rw === 'object' && !root.OVReleaseConfig) root.OVReleaseConfig = rw;const m = /^(https?:\/\/[^?#]*\/)navbar\.js(?:[?#]|$)/.exec(_selfSrc);const sc = document.createElement('script'); sc.src = `${m ? m[1] : 'https://openvibe.network/shared/'}release-watch.js`; sc.async = true;try { sc.fetchPriority = 'low'; } catch { /* */ }@@@ -13,7 +13,9 @@},"devDependencies": {"better-sqlite3": "^11.10.0",-- "express": "^4.22.3"++ "express": "^4.22.3",++ "linkedom": "^0.18.13",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.30.2"}},"node_modules/accepts": {@@@ -30,6 +32,41 @@"node": ">= 0.6"}},++ "node_modules/ajv": {++ "version": "8.20.0",++ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",++ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",++ "dev": true,++ "license": "MIT",++ "dependencies": {++ "fast-deep-equal": "^3.1.3",++ "fast-uri": "^3.0.1",++ "json-schema-traverse": "^1.0.0",++ "require-from-string": "^2.0.2"++ },++ "funding": {++ "type": "github",++ "url": "https://github.com/sponsors/epoberezkin"++ }++ },++ "node_modules/ajv-formats": {++ "version": "3.0.1",++ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",++ "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",++ "dev": true,++ "license": "MIT",++ "dependencies": {++ "ajv": "^8.0.0"++ },++ "peerDependencies": {++ "ajv": "^8.0.0"++ },++ "peerDependenciesMeta": {++ "ajv": {++ "optional": true++ }++ }++ },"node_modules/array-flatten": {"version": "1.1.1","resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",@@@ -117,6 +154,20 @@"npm": "1.2.8000 || >= 1.4.16"}},++ "node_modules/boolbase": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-2.0.0.tgz",++ "integrity": "sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA==",++ "dev": true,++ "license": "ISC",++ "engines": {++ "node": ">=20.19.0"++ },++ "funding": {++ "type": "github",++ "url": "https://github.com/sponsors/fb55"++ }++ },"node_modules/buffer": {"version": "5.7.1","resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",@@@ -236,6 +287,48 @@"dev": true,"license": "MIT"},++ "node_modules/css-select": {++ "version": "7.0.0",++ "resolved": "https://registry.npmjs.org/css-select/-/css-select-7.0.0.tgz",++ "integrity": "sha512-snmjEVXy+1LnwXdxhYvTMj1d9tOh4HxkA1YmoayVBeeyR2C14Pum7fcxJIm4SswYspVy866eYNwlH6xC3/VH5g==",++ "dev": true,++ "license": "BSD-2-Clause",++ "dependencies": {++ "boolbase": "^2.0.0",++ "css-what": "^8.0.0",++ "domhandler": "^6.0.1",++ "domutils": "^4.0.2",++ "nth-check": "^3.0.1"++ },++ "engines": {++ "node": ">=20.19.0"++ },++ "funding": {++ "type": "github",++ "url": "https://github.com/sponsors/fb55"++ }++ },++ "node_modules/css-what": {++ "version": "8.0.0",++ "resolved": "https://registry.npmjs.org/css-what/-/css-what-8.0.0.tgz",++ "integrity": "sha512-DH0Bqq3DNp5tdOReuNyAA+Ev4Y2GS5FMbZpeTLP6C4CDi0h5nL0BmUPChXw3o/qbHLDWHl49sbNqQVY7bMSDdw==",++ "dev": true,++ "license": "BSD-2-Clause",++ "engines": {++ "node": ">=20.19.0"++ },++ "funding": {++ "type": "github",++ "url": "https://github.com/sponsors/fb55"++ }++ },++ "node_modules/cssom": {++ "version": "0.5.0",++ "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz",++ "integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==",++ "dev": true,++ "license": "MIT"++ },"node_modules/debug": {"version": "2.6.9","resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",@@@ -310,6 +403,77 @@"node": ">=8"}},++ "node_modules/dom-serializer": {++ "version": "3.1.1",++ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz",++ "integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==",++ "dev": true,++ "license": "MIT",++ "dependencies": {++ "domelementtype": "^3.0.0",++ "domhandler": "^6.0.0",++ "entities": "^8.0.0"++ },++ "engines": {++ "node": ">=20.19.0"++ },++ "funding": {++ "type": "github",++ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"++ }++ },++ "node_modules/domelementtype": {++ "version": "3.0.0",++ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz",++ "integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==",++ "dev": true,++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/fb55"++ }++ ],++ "license": "BSD-2-Clause",++ "engines": {++ "node": ">=20.19.0"++ }++ },++ "node_modules/domhandler": {++ "version": "6.0.1",++ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-6.0.1.tgz",++ "integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==",++ "dev": true,++ "license": "BSD-2-Clause",++ "dependencies": {++ "domelementtype": "^3.0.0"++ },++ "engines": {++ "node": ">=20.19.0"++ },++ "funding": {++ "type": "github",++ "url": "https://github.com/fb55/domhandler?sponsor=1"++ }++ },++ "node_modules/domutils": {++ "version": "4.0.2",++ "resolved": "https://registry.npmjs.org/domutils/-/domutils-4.0.2.tgz",++ "integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==",++ "dev": true,++ "license": "BSD-2-Clause",++ "dependencies": {++ "dom-serializer": "^3.0.0",++ "domelementtype": "^3.0.0",++ "domhandler": "^6.0.0"++ },++ "engines": {++ "node": ">=20.19.0"++ },++ "funding": {++ "type": "github",++ "url": "https://github.com/fb55/domutils?sponsor=1"++ }++ },"node_modules/dunder-proto": {"version": "1.0.1","resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",@@@ -361,6 +525,19 @@"once": "^1.4.0"}},++ "node_modules/entities": {++ "version": "8.1.0",++ "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz",++ "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==",++ "dev": true,++ "license": "BSD-2-Clause",++ "engines": {++ "node": ">=20.19.0"++ },++ "funding": {++ "url": "https://github.com/fb55/entities?sponsor=1"++ }++ },"node_modules/es-define-property": {"version": "1.0.1","resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",@@@ -468,6 +645,30 @@"url": "https://opencollective.com/express"}},++ "node_modules/fast-deep-equal": {++ "version": "3.1.3",++ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",++ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",++ "dev": true,++ "license": "MIT"++ },++ "node_modules/fast-uri": {++ "version": "3.1.8",++ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",++ "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",++ "dev": true,++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/fastify"++ },++ {++ "type": "opencollective",++ "url": "https://opencollective.com/fastify"++ }++ ],++ "license": "BSD-3-Clause"++ },"node_modules/file-uri-to-path": {"version": "1.0.0","resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",@@@ -616,6 +817,118 @@"node": ">= 0.4"}},++ "node_modules/html-escaper": {++ "version": "3.0.3",++ "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz",++ "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==",++ "dev": true,++ "license": "MIT"++ },++ "node_modules/htmlparser2": {++ "version": "10.1.0",++ "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",++ "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",++ "dev": true,++ "funding": [++ "https://github.com/fb55/htmlparser2?sponsor=1",++ {++ "type": "github",++ "url": "https://github.com/sponsors/fb55"++ }++ ],++ "license": "MIT",++ "dependencies": {++ "domelementtype": "^2.3.0",++ "domhandler": "^5.0.3",++ "domutils": "^3.2.2",++ "entities": "^7.0.1"++ }++ },++ "node_modules/htmlparser2/node_modules/dom-serializer": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",++ "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",++ "dev": true,++ "license": "MIT",++ "dependencies": {++ "domelementtype": "^2.3.0",++ "domhandler": "^5.0.2",++ "entities": "^4.2.0"++ },++ "funding": {++ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"++ }++ },++ "node_modules/htmlparser2/node_modules/dom-serializer/node_modules/entities": {++ "version": "4.5.0",++ "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",++ "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",++ "dev": true,++ "license": "BSD-2-Clause",++ "engines": {++ "node": ">=0.12"++ },++ "funding": {++ "url": "https://github.com/fb55/entities?sponsor=1"++ }++ },++ "node_modules/htmlparser2/node_modules/domelementtype": {++ "version": "2.3.0",++ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",++ "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",++ "dev": true,++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/fb55"++ }++ ],++ "license": "BSD-2-Clause"++ },++ "node_modules/htmlparser2/node_modules/domhandler": {++ "version": "5.0.3",++ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",++ "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",++ "dev": true,++ "license": "BSD-2-Clause",++ "dependencies": {++ "domelementtype": "^2.3.0"++ },++ "engines": {++ "node": ">= 4"++ },++ "funding": {++ "url": "https://github.com/fb55/domhandler?sponsor=1"++ }++ },++ "node_modules/htmlparser2/node_modules/domutils": {++ "version": "3.2.2",++ "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",++ "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",++ "dev": true,++ "license": "BSD-2-Clause",++ "dependencies": {++ "dom-serializer": "^2.0.0",++ "domelementtype": "^2.3.0",++ "domhandler": "^5.0.3"++ },++ "funding": {++ "url": "https://github.com/fb55/domutils?sponsor=1"++ }++ },++ "node_modules/htmlparser2/node_modules/entities": {++ "version": "7.0.1",++ "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",++ "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",++ "dev": true,++ "license": "BSD-2-Clause",++ "engines": {++ "node": ">=0.12"++ },++ "funding": {++ "url": "https://github.com/fb55/entities?sponsor=1"++ }++ },"node_modules/http-errors": {"version": "2.0.1","resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",@@@ -695,6 +1008,13 @@"node": ">= 0.10"}},++ "node_modules/json-schema-traverse": {++ "version": "1.0.0",++ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",++ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",++ "dev": true,++ "license": "MIT"++ },"node_modules/jsonwebtoken": {"version": "9.0.3","resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",@@@ -738,6 +1058,31 @@"safe-buffer": "^5.0.1"}},++ "node_modules/linkedom": {++ "version": "0.18.13",++ "resolved": "https://registry.npmjs.org/linkedom/-/linkedom-0.18.13.tgz",++ "integrity": "sha512-ES/o9qotMpzpN2MHs+Iq/JcVoOj8Fa5wiQYrTdFpvAnwXL0g66XHHUc9WUMk6nAlBtGsFQ24ne+SYnvnaQ2FSw==",++ "dev": true,++ "license": "ISC",++ "dependencies": {++ "css-select": "^7.0.0",++ "cssom": "^0.5.0",++ "html-escaper": "^3.0.3",++ "htmlparser2": "^10.1.0",++ "uhyphen": "^0.2.0"++ },++ "engines": {++ "node": ">=16"++ },++ "peerDependencies": {++ "canvas": ">= 2"++ },++ "peerDependenciesMeta": {++ "canvas": {++ "optional": true++ }++ }++ },"node_modules/lodash.includes": {"version": "4.3.0","resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",@@@ -922,6 +1267,23 @@"node": ">=10"}},++ "node_modules/nth-check": {++ "version": "3.0.1",++ "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-3.0.1.tgz",++ "integrity": "sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ==",++ "dev": true,++ "license": "BSD-2-Clause",++ "dependencies": {++ "boolbase": "^2.0.0"++ },++ "engines": {++ "node": ">=20.19.0"++ },++ "funding": {++ "type": "github",++ "url": "https://github.com/fb55/nth-check?sponsor=1"++ }++ },"node_modules/object-inspect": {"version": "1.13.4","resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",@@@ -958,6 +1320,23 @@"wrappy": "1"}},++ "node_modules/openvibe-contracts": {++ "version": "0.30.2",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.30.2",++ "integrity": "sha512-R01NgRY5V/GEYWkWUyxfl1MifSQb4dfJy11VS9RP+wsWedh2Crr9/IL7yGSn1PytwTKKL+tQl/r4ppGfH3TwLg==",++ "dev": true,++ "license": "AGPL-3.0-only",++ "dependencies": {++ "ajv": "^8.17.1",++ "ajv-formats": "^3.0.1"++ },++ "bin": {++ "openvibe-contracts-check": "bin/check-service.js"++ },++ "engines": {++ "node": ">=20"++ }++ },"node_modules/parseurl": {"version": "1.3.3","resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",@@@ -1106,6 +1485,16 @@"node": ">= 6"}},++ "node_modules/require-from-string": {++ "version": "2.0.2",++ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",++ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",++ "dev": true,++ "license": "MIT",++ "engines": {++ "node": ">=0.10.0"++ }++ },"node_modules/safe-buffer": {"version": "5.2.1","resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",@@@ -1413,6 +1802,13 @@"node": ">= 0.6"}},++ "node_modules/uhyphen": {++ "version": "0.2.0",++ "resolved": "https://registry.npmjs.org/uhyphen/-/uhyphen-0.2.0.tgz",++ "integrity": "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==",++ "dev": true,++ "license": "ISC"++ },"node_modules/unpipe": {"version": "1.0.0","resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",@@@ -38,7 +38,9 @@"./panels": "./panels.js","./ready": "./ready.js","./release": "./release.js",++ "./release-compat": "./release-compat.js","./release-watch": "./release-watch.js",++ "./release-update": "./release-update.js","./seo": "./seo.js","./sso-client": "./sso-client.js","./theme-loader": "./theme-loader.js",@@@ -72,6 +74,8 @@},"devDependencies": {"better-sqlite3": "^11.10.0",-- "express": "^4.22.3"++ "express": "^4.22.3",++ "linkedom": "^0.18.13",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.30.2"}}nnew file mode 100644@@@ -0,0 +1,259 @@++'use strict';++// ═══════════════════════════════════════════════════════════════++// openvibe-shared/release-compat — the mixed-version (N/N-1) test harness (ADR-016, Track R, D45).++// Node only; services use it in their own tests.++//++// const compat = require('openvibe-shared/release-compat');++// await compat.assertMixedVersion({ releases: [++// { name: 'N-1', manifest: require('./fixtures/release-prev.json'), client: compat.replay(prevCalls) },++// { name: 'N', manifest: release.full(), server: startServer, client: compat.replay(calls) },++// ] });++//++// For every page release P and server release S it checks what the release manifests promise:++// - compatible(P, S): S still accepts every contract P produces and P accepts S's versions++// (contract_ranges); the same rule release-watch applies in a tab;++// - when they say compatible and S has a server, P's client runs against it and must succeed. A failure++// means the manifest claims a compatibility the server does not honour;++// - when they say incompatible, the tab's plan must be a reload (reason "contract"), never a silent++// in-place update or nothing;++// - releases next to each other in `releases` must be compatible both ways (the supported window of++// ADR-016: the current and the previous release), unless requireAdjacent is false.++// openPage() runs release-watch.js and release-update.js against an HTML page in linkedom (a++// devDependency the service installs) with a scripted /release.json, for in-place update tests.++// ═══════════════════════════════════════════════════════════════++++const fs = require('fs');++const path = require('path');++const vm = require('vm');++const { plan, compatible, satisfies } = require('./release-update');++const { normalizeRange, compareVersions, contractRanges } = require('./release');++++/** Service to service: every contract `consumer` consumes is produced by `producer`, both ranges holding. */++function consumerCompatible(consumer, producer) {++ const problems = [];++ const mine = (consumer && consumer.contract_ranges) || {};++ const theirs = (producer && producer.contract_ranges) || {};++ for (const [id, a] of Object.entries(mine)) {++ if (a.role !== 'consumes') continue;++ const b = theirs[id];++ if (!b || b.role === 'consumes') problems.push(`${id}: ${producer && producer.service} does not produce it`);++ else if (!satisfies(a.version, b.accepts)) problems.push(`${id}: ${producer.service} accepts ${b.accepts}, ${consumer.service} speaks ${a.version}`);++ else if (!satisfies(b.version, a.accepts)) problems.push(`${id}: ${consumer.service} accepts ${a.accepts}, ${producer.service} speaks ${b.version}`);++ }++ return { ok: !problems.length, problems };++}++++/**++ * Rolling back from release `from` to release `to` keeps data safe when `to`'s code expects a schema++ * generation `from` still supports: to.schema_generation >= from.schema_compatible_from.++ */++function rollbackSafe(from, to) {++ const need = from && from.schema_compatible_from;++ const have = to && to.schema_generation;++ if (need == null || have == null) return { ok: true, unknown: true };++ return have >= need ? { ok: true } : { ok: false, problem: `${to.release} expects schema generation ${have}; ${from.release} is only readable from ${need}` };++}++++/**++ * A client made of recorded calls: [{ method?, path, body?, headers?, status?: n | [n…] | (n) => bool,++ * check?: (json, res) => void }]. Each call must answer 2xx (or `status`) and pass `check`.++ */++function replay(calls, { fetchImpl = globalThis.fetch } = {}) {++ return async (baseUrl) => {++ for (const c of calls) {++ const method = (c.method || 'GET').toUpperCase();++ const headers = { accept: 'application/json', ...(c.body !== undefined ? { 'content-type': 'application/json' } : {}), ...(c.headers || {}) };++ const res = await fetchImpl(new URL(c.path, baseUrl), { method, headers, body: c.body !== undefined ? JSON.stringify(c.body) : undefined });++ const want = c.status;++ const ok = want == null ? res.status >= 200 && res.status < 300++ : typeof want === 'function' ? want(res.status) : [].concat(want).includes(res.status);++ const text = await res.text();++ if (!ok) throw new Error(`${method} ${c.path} answered ${res.status}: ${text.slice(0, 200)}`);++ if (c.check) {++ let json = null;++ try { json = text ? JSON.parse(text) : null; } catch { throw new Error(`${method} ${c.path} did not answer JSON`); }++ await c.check(json, res);++ }++ }++ };++}++++/** A manifest for a test release (registry.release-manifest 1.1.0 fields), no git needed. */++function manifestFor({ service = 'test', release, releasedAt = new Date().toISOString(), windowHours = 24, minClientRelease = null, components = null, assets = null, contracts = null, schemaGeneration = null, schemaCompatibleFrom = null, metricsUrl = null } = {}) {++ if (!/^[0-9a-f]{7,40}$/.test(String(release || ''))) throw new TypeError('manifestFor: release must be a hex sha');++ const m = {++ service, release, released_at: releasedAt, booted_at: releasedAt, contracts_version: null, packages: {},++ min_client_release: minClientRelease, mixed_version_window_hours: windowHours,++ };++ if (components) m.components = components;++ if (assets) m.assets = assets;++ if (schemaGeneration != null) { m.schema_generation = schemaGeneration; m.schema_compatible_from = schemaCompatibleFrom == null ? schemaGeneration : schemaCompatibleFrom; }++ if (contracts) m.contract_ranges = contractRanges(contracts);++ if (metricsUrl) m.metrics_url = metricsUrl;++ return m;++}++++/**++ * Runs the page × server matrix. releases: [{ name, manifest, server?: async () => ({ url, close? }),++ * client?: async (url) => void }], oldest first. Returns rows { page, server, adjacent, compatible,++ * problems, action, reason, ran, ok, error }.++ */++async function runMixedVersion({ releases, now = Date.now(), requireAdjacent = true } = {}) {++ if (!Array.isArray(releases) || releases.length < 2) throw new TypeError('runMixedVersion: at least two releases');++ const started = new Map();++ const serverOf = async (r) => {++ if (!r.server) return null;++ if (!started.has(r.name)) started.set(r.name, await r.server());++ return started.get(r.name);++ };++ const rows = [];++ try {++ for (let i = 0; i < releases.length; i++) {++ for (let j = 0; j < releases.length; j++) {++ if (i === j) continue;++ const P = releases[i]; const S = releases[j];++ const c = compatible(P.manifest, S.manifest);++ const p = plan(P.manifest, S.manifest, now);++ const row = { page: P.name, server: S.name, adjacent: Math.abs(i - j) === 1, compatible: c.ok, problems: c.problems, action: p.action, reason: p.reason || null, ran: false, ok: true, error: null };++ if (!c.ok) {++ if (p.action !== 'reload' || p.reason !== 'contract') { row.ok = false; row.error = `incompatible, but the tab would ${p.action}`; }++ if (row.adjacent && requireAdjacent) { row.ok = false; row.error = `adjacent releases must be compatible: ${c.problems.join('; ')}`; }++ } else if (P.client) {++ const s = await serverOf(S);++ if (s) {++ row.ran = true;++ try { await P.client(s.url); } catch (err) { row.ok = false; row.error = `declared compatible, but the ${P.name} client failed against the ${S.name} server: ${err && err.message}`; }++ }++ }++ rows.push(row);++ }++ }++ } finally {++ for (const s of started.values()) { try { if (s && s.close) await s.close(); } catch { /* */ } }++ }++ return rows;++}++++function formatMatrix(rows) {++ const head = ['page', 'server', 'compatible', 'tab', 'ran', 'ok'];++ const body = rows.map((r) => [r.page, r.server, r.compatible ? 'yes' : 'no', r.action + (r.reason ? ` (${r.reason})` : ''), r.ran ? 'yes' : 'no', r.ok ? 'ok' : `FAIL: ${r.error}`]);++ const w = head.map((h, i) => Math.max(h.length, ...body.map((b) => String(b[i]).length)));++ return [head, ...body].map((r) => r.map((c, i) => (i === r.length - 1 ? String(c) : String(c).padEnd(w[i]))).join(' ')).join('\n');++}++++async function assertMixedVersion(opts) {++ const rows = await runMixedVersion(opts);++ if (rows.some((r) => !r.ok)) {++ const err = new Error(`mixed-version check failed\n${formatMatrix(rows)}`);++ err.rows = rows;++ throw err;++ }++ return rows;++}++++// ── openPage: release-watch in a linkedom page ────────────────++++const WATCH_SRC = () => fs.readFileSync(path.join(__dirname, 'release-watch.js'), 'utf8');++const UPDATE_SRC = () => fs.readFileSync(path.join(__dirname, 'release-update.js'), 'utf8');++++function toResponse(out) {++ if (out && typeof out.status === 'number' && typeof out.text === 'function') return out; // a Response++ const o = out || { status: 404 };++ const body = o.json !== undefined ? JSON.stringify(o.json) : o.body != null ? String(o.body) : o.text != null ? String(o.text) : '';++ return new Response(o.status === 204 ? null : body, { status: o.status || 200, headers: { 'content-type': o.json !== undefined ? 'application/json' : 'text/html' } });++}++++/**++ * A page at `url` rendered from `html`, running release-watch.js. `serve(url, init)` answers every fetch++ * the scripts make (return { json } | { body } | { status } | a Response). Options: config++ * (OVReleaseConfig), updateScript: 'lazy' (default: loaded when release-watch asks) | 'fail', hidden,++ * protectedFn (window.OVProtected).++ * Returns controls to drive it: settle(), settleStyles(ok), focus(el), setHidden(bool), tick(), and what++ * it did: reloads, beacons, toasts, requests.++ */++async function openPage({ url = 'https://site.test/', html, serve, config = null, updateScript = 'lazy', protectedFn = null, hidden: startHidden = false } = {}) {++ let linkedom;++ try { linkedom = require('linkedom'); } catch { throw new Error('openPage needs linkedom (npm i -D linkedom)'); }++ const { window: lw, document } = linkedom.parseHTML(html || '<!doctype html><html><head></head><body></body></html>');++ const origin = new URL(url).origin;++ const out = { reloads: 0, beacons: [], toasts: [], requests: [], intervals: [] };++ const timers = new Map(); let tid = 0;++ let focused = null; let hidden = !!startHidden;++ Object.defineProperty(document, 'activeElement', { configurable: true, get: () => focused || document.body });++ Object.defineProperty(document, 'hidden', { configurable: true, get: () => hidden });++ Object.defineProperty(document, 'currentScript', { configurable: true, get: () => ({ src: `${origin}/shared/release-watch.js` }) });++ const listeners = {};++ const ctx = {++ document, console, URL, JSON, Promise,++ DOMParser: linkedom.DOMParser, CustomEvent: lw.CustomEvent || linkedom.CustomEvent, Event: lw.Event || linkedom.Event,++ location: { href: url, origin, reload: () => { out.reloads++; } },++ navigator: { sendBeacon: (to, body) => { out.beacons.push({ to, body: JSON.parse(body) }); return true; } },++ OpenVibeUI: { toast: (msg, o) => out.toasts.push({ msg, o }) },++ setInterval: (f) => { out.intervals.push(f); return out.intervals.length; },++ clearInterval: () => {},++ setTimeout: (f) => { timers.set(++tid, f); return tid; },++ clearTimeout: (id) => { timers.delete(id); },++ addEventListener: (t, f) => { (listeners[t] = listeners[t] || []).push(f); },++ removeEventListener: () => {},++ dispatchEvent: (e) => { for (const f of listeners[e.type] || []) f(e); return true; },++ scrollBy: () => {},++ fetch: async (u, init = {}) => {++ const abs = new URL(String(u), url).href;++ out.requests.push({ url: abs, method: (init.method || 'GET').toUpperCase() });++ return toResponse(await serve(abs, init));++ },++ };++ if (config) ctx.OVReleaseConfig = config;++ if (protectedFn) ctx.OVProtected = protectedFn;++ ctx.window = ctx; ctx.globalThis = ctx;++ vm.createContext(ctx);++ // A script element release-watch appends is "loaded": release-update.js runs, then onload fires.++ const append = document.head.appendChild.bind(document.head);++ document.head.appendChild = (node) => {++ const r = append(node);++ const src = node && node.tagName === 'SCRIPT' ? String(node.src || node.getAttribute('src') || '') : '';++ if (src) {++ setImmediate(() => {++ if (updateScript === 'fail' || !/release-update\.js/.test(src)) { if (node.onerror) node.onerror(new ctx.Event('error')); return; }++ vm.runInContext(UPDATE_SRC(), ctx, { filename: 'release-update.js' });++ if (node.onload) node.onload(new ctx.Event('load'));++ });++ }++ return r;++ };++ const initialLinks = new Set(document.querySelectorAll('link'));++ const settled = new Set();++ vm.runInContext(WATCH_SRC(), ctx, { filename: 'release-watch.js' });++ const page = {++ window: ctx, document, ...out,++ get reloads() { return out.reloads; },++ get release() { return ctx.OVRelease; },++ /** The tab's outcome counts so far (a plain copy). */++ metrics() { return JSON.parse(JSON.stringify(ctx.OVRelease ? ctx.OVRelease.state().metrics : {})); },++ async settle(turns = 8) { for (let i = 0; i < turns; i++) await new Promise((r) => setImmediate(r)); },++ /** Fires load (or error) on every stylesheet release-update inserted and that has not loaded yet. */++ settleStyles(ok = true) {++ let n = 0;++ for (const l of document.querySelectorAll('link')) {++ if (initialLinks.has(l) || settled.has(l)) continue;++ settled.add(l); n++;++ l.dispatchEvent(new ctx.Event(ok ? 'load' : 'error'));++ }++ return n;++ },++ /** Runs the timeouts still pending (release-update's 15 s stylesheet timeout). */++ fireTimeouts() { const t = [...timers.values()]; timers.clear(); t.forEach((f) => f()); return t.length; },++ focus(el) { focused = el; },++ blur() { focused = null; },++ setHidden(h) { hidden = !!h; document.dispatchEvent(new ctx.Event('visibilitychange')); },++ tick() { out.intervals.forEach((f) => f()); },++ stop() { if (ctx.OVRelease) ctx.OVRelease.stop(); },++ };++ return page;++}++++module.exports = {++ plan, compatible, satisfies, normalizeRange, compareVersions,++ consumerCompatible, rollbackSafe, replay, manifestFor,++ runMixedVersion, assertMixedVersion, formatMatrix, openPage,++};nnew file mode 100644@@@ -0,0 +1,153 @@++/*++ * openvibe-shared/release-update.js — plans a new release for an open tab and applies it in place++ * (ADR-016, Track R). release-watch.js loads it from its own directory when /release.json reports a++ * release with components or contract ranges; pages never load it themselves. In Node, require() returns++ * the pure plan(), compatible() and satisfies() (openvibe-shared/release-compat builds on them).++ *++ * In place is transactional: every new stylesheet loads and every region is fetched before anything old++ * is removed; a rejection (style, style-timeout, content, origin) leaves the page as it was.++ * styles(ids, next) new <link>s for the changed style components' assets, loaded beside the old ones++ * regions(ids) fresh [data-ov-content="<component>"] HTML (from data-ov-src, default this URL),++ * without scripts, frames or inline handlers; unchanged data-ov-rev is skipped++ * commit(list, next, busy, record) replaces the regions busy() allows, returns those still waiting++ * apply(plan, next, busy, record) all of the above for an in-place plan++ */++(function (root) {++ 'use strict';++ const IN_PLACE = { style: 1, content: 1, server: 1 };++ const ver = (v) => { const m = /^(\d+)\.(\d+)\.(\d+)$/.exec(String(v || '')); return m ? [+m[1], +m[2], +m[3]] : null; };++ const cmp = (a, b) => { for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1; return 0; };++ /** version inside ">=a.b.c <x.y.z", the only form a manifest writes. */++ function satisfies(version, range) {++ const v = ver(version); const m = /^>=(\S+) <(\S+)$/.exec(String(range || ''));++ const lo = m && ver(m[1]); const hi = m && ver(m[2]);++ return !!(v && lo && hi && cmp(v, lo) >= 0 && cmp(v, hi) < 0);++ }++ /** Every contract the page's release produces is still accepted by the server, and the reverse. */++ function compatible(page, server) {++ const problems = []; const mine = (page && page.contract_ranges) || {}; const theirs = (server && server.contract_ranges) || {};++ for (const id of Object.keys(mine)) {++ const a = mine[id]; const b = theirs[id];++ if (!a || a.role === 'consumes') continue;++ if (!b || b.role === 'consumes') problems.push(`${id}: the server no longer serves it`);++ else if (!satisfies(a.version, b.accepts)) problems.push(`${id}: the server accepts ${b.accepts}, the page speaks ${a.version}`);++ else if (!satisfies(b.version, a.accepts)) problems.push(`${id}: the page accepts ${a.accepts}, the server speaks ${b.version}`);++ }++ return { ok: !problems.length, problems };++ }++ /**++ * What a tab on `page` (the manifest of the release it runs) does about `server`:++ * { action: none | in-place | prompt | reload, reason?: contract | required | window, changed, problems? }.++ * In place only when every changed component is style, content (same kind on both sides) or server.++ */++ function plan(page, server, now) {++ if (!server || !page || server.release === page.release) return { action: 'none', changed: [] };++ const c = compatible(page, server);++ if (!c.ok) return { action: 'reload', reason: 'contract', changed: [], problems: c.problems };++ const a = page.components; const b = server.components; let changed = null;++ if (a && b) {++ let ok = true; changed = [];++ for (const id of Object.keys(Object.assign({}, a, b))) {++ const x = a[id]; const y = b[id];++ if (x && y && x.kind === y.kind && x.version === y.version) continue;++ const kind = (y || x).kind;++ changed.push({ id, kind });++ if (!IN_PLACE[kind] || (x && y ? x.kind !== y.kind : kind !== 'server')) ok = false;++ }++ if (ok) return { action: 'in-place', changed };++ }++ const windowMs = (Number(server.mixed_version_window_hours) || 0) * 3600e3;++ if (server.min_client_release === server.release) return { action: 'reload', reason: 'required', changed };++ if (windowMs > 0 && (now == null ? Date.now() : +now) - Date.parse(server.released_at) > windowMs) return { action: 'reload', reason: 'window', changed };++ return { action: 'prompt', changed };++ }++ if (typeof module !== 'undefined' && module.exports) module.exports = { plan, compatible, satisfies };++ if (typeof document === 'undefined' || root.OVReleaseUpdate) return;++++ const loc = () => root.location.href;++ const sameOrigin = (u) => { try { return new URL(u, loc()).origin === new URL(loc()).origin; } catch { return false; } };++++ function styles(ids, next) {++ const jobs = [];++ for (const p of Object.keys(next.assets || {})) {++ const a = next.assets[p];++ if (!ids[a.component]) continue;++ const want = new URL(a.url, loc());++ for (const l of document.querySelectorAll('link[rel~="stylesheet"]')) {++ const have = new URL(l.getAttribute('href') || '', loc());++ if (l.getAttribute('data-ov-asset') !== p && (have.origin !== want.origin || (have.pathname !== want.pathname && have.pathname !== p))) continue;++ if (have.href === want.href) continue;++ if (have.origin !== want.origin || !/^https?:$/.test(want.protocol)) return Promise.reject('origin');++ const n = l.cloneNode(false);++ n.setAttribute('href', want.href);++ if (a.integrity) n.setAttribute('integrity', a.integrity); else n.removeAttribute('integrity');++ jobs.push({ old: l, n });++ }++ }++ return Promise.all(jobs.map((j) => new Promise((ok, no) => {++ const timer = root.setTimeout(() => no('style-timeout'), 15000);++ j.n.addEventListener('load', () => { root.clearTimeout(timer); ok(); });++ j.n.addEventListener('error', () => { root.clearTimeout(timer); no('style'); });++ j.old.parentNode.insertBefore(j.n, j.old.nextSibling);++ }))).then(() => jobs, (why) => { jobs.forEach((j) => j.n.remove()); throw why; });++ }++++ async function regions(ids) {++ const out = []; const docs = {};++ for (const el of document.querySelectorAll('[data-ov-content]')) {++ const id = el.getAttribute('data-ov-content');++ if (!ids[id] || !/^[a-z][a-z0-9-]{0,39}$/.test(id)) continue;++ const src = new URL(el.getAttribute('data-ov-src') || loc(), loc()); src.hash = '';++ if (!sameOrigin(src.href)) throw 'origin';++ if (!docs[src.href]) {++ const r = await root.fetch(src.href, { cache: 'no-store', credentials: 'same-origin', headers: { Accept: 'text/html' } });++ if (!r.ok) throw 'content';++ docs[src.href] = new root.DOMParser().parseFromString(await r.text(), 'text/html');++ }++ const doc = docs[src.href];++ const fresh = doc.querySelector(`[data-ov-content="${id}"]`) || (el.hasAttribute('data-ov-src') ? doc.body : null);++ if (!fresh) throw 'content';++ for (const x of fresh.querySelectorAll('script, iframe, object, embed, base, frame, meta')) x.remove();++ for (const x of [fresh, ...fresh.querySelectorAll('*')]) {++ for (const at of Array.from(x.attributes)) {++ if (/^on/i.test(at.name) || (/^(href|src|action|formaction|xlink:href|srcdoc)$/i.test(at.name) && /^\s*(javascript|vbscript|data):/i.test(at.value) && !/^\s*data:image\//i.test(at.value))) x.removeAttribute(at.name);++ }++ }++ const rev = fresh.getAttribute('data-ov-rev');++ if (rev && rev === el.getAttribute('data-ov-rev')) continue;++ out.push({ el, fresh, id });++ }++ return out;++ }++++ function commit(list, next, busy, record) {++ return list.filter((w) => {++ if (!w.el.isConnected) return false;++ const why = busy(w.el);++ if (why) { record('deferred', why, true); return true; }++ const r = w.el.getBoundingClientRect();++ const st = w.el.scrollTop;++ w.el.replaceChildren(...Array.from(w.fresh.childNodes).map((n) => document.importNode(n, true)));++ const rev = w.fresh.getAttribute('data-ov-rev');++ if (rev) w.el.setAttribute('data-ov-rev', rev);++ w.el.scrollTop = st;++ // A region above the viewport that changed height must not move what the reader is looking at.++ if (r.bottom <= 0 && root.scrollBy) root.scrollBy(0, w.el.getBoundingClientRect().bottom - r.bottom);++ try { w.el.dispatchEvent(new root.CustomEvent('ov:content-updated', { bubbles: true, detail: { component: w.id, release: next.release } })); } catch { /* */ }++ return false;++ });++ }++++ /** Applies an in-place plan; resolves with the regions still waiting for commit(), rejects with a reason. */++ async function apply(p, next, busy, record) {++ const ids = {}; const k = {};++ for (const ch of p.changed) { ids[ch.id] = 1; k[ch.kind] = 1; }++ const list = k.content ? await regions(ids) : [];++ const jobs = k.style ? await styles(ids, next) : [];++ jobs.forEach((j) => j.old.remove());++ if (jobs.length) try { root.dispatchEvent(new root.CustomEvent('ov:styles-updated', { detail: { release: next.release } })); } catch { /* */ }++ return commit(list, next, busy, record);++ }++++ root.OVReleaseUpdate = { plan, compatible, satisfies, styles, regions, commit, apply };++})(typeof window !== 'undefined' ? window : globalThis);@@@ -1,62 +1,147 @@/*-- * openvibe-shared/release-watch.js — keeps open tabs on a supported release (ADR-016).-- *-- * The page's release comes from <meta name="ov-release" content="<sha>" data-url="/release.json">-- * (openvibe-shared/release metaTag()) or, without it, from /release.json when the script loads. On focus, when the tab becomes visible, when the network-- * comes back and every 10 minutes, this reads /release.json:-- * - same release: nothing;-- * - newer release: one quiet "new version" toast with a Reload button, never an automatic reload;-- * - the server requires it (min_client_release is the running release) or the new release is older-- * than the mixed-version window: reload automatically, but ONLY when it is safe — the tab is-- * hidden or idle for 2 minutes, no text field has focus, and nothing on the page is protected-- * (a form marked data-dirty="true", an element with data-ov-protected, a playing <video>/<audio>-- * or live camera/mic stream, or window.OVProtected() returning true for uploads and calls).++ * openvibe-shared/release-watch.js — keeps open tabs on a supported release (ADR-016, Track R).++ * Reads /release.json on focus, visibility, reconnect and every 10 minutes. A new release with components++ * or contract ranges is planned by release-update.js (loaded then, from this directory): style, content and++ * server changes are applied in place; anything else prompts. It reloads by itself only when it must++ * (min_client_release, the mixed-version window, contracts out of range) and only when safe: hidden or idle++ * 2 minutes, no focused field, nothing protected (form[data-dirty="true"], [data-ov-protected], playing++ * media, a live camera/mic, window.OVProtected()). Outcomes are beaconed to the metrics URL (D46).++ * OVReleaseConfig: { url, metricsUrl, updateUrl, inPlace: false }. See README "Releases".*/(function (root) {if (typeof document === 'undefined' || root.OVRelease) return;-- // The page's release: from <meta name="ov-release"> when the server rendered one, otherwise the-- // release /release.json reports when this script first runs (a site without the route stops).++ const cfg = root.OVReleaseConfig || {};++ const self = (document.currentScript && document.currentScript.src) || '';++ const SELF_RE = /release-watch\.js(?:[?#].*)?$/;++ const updateUrl = cfg.updateUrl || `${SELF_RE.test(self) ? self.replace(SELF_RE, '') : '/shared/'}release-update.js`;const meta = document.querySelector('meta[name="ov-release"]');-- let current = meta && meta.content ? meta.content : null;-- const url = (meta && meta.getAttribute('data-url')) || '/release.json';++ let current = (meta && meta.content) || null;++ const url = cfg.url || (meta && meta.getAttribute('data-url')) || '/release.json';const IDLE_MS = 2 * 60 * 1000;const MIN_GAP_MS = 60 * 1000;++ let base = null; // the manifest of the release this page runs++ let latest = null;let lastCheck = 0;let lastInput = Date.now();let prompted = false;-- let mustReload = false;-- let latest = null;++ let mustReload = false; let reloadWhy = null;++ let busyWith = null; // the plan/apply in flight++ let pending = null; let waiting = []; let kinds = ''; let failedRelease = null; let lib = null;++ let counts = {}; const totals = {}; const noted = {};['keydown', 'pointerdown', 'input', 'wheel', 'touchstart'].forEach((t) => root.addEventListener(t, () => { lastInput = Date.now(); }, { passive: true, capture: true }));-- function busy() {++ // ── Update metrics (D46): counted here, sent on hide, after an update and before a reload ──++ function record(outcome, reason, once) {++ const k = `${latest && latest.release}|${outcome}|${reason}`;++ if (once) { if (noted[k]) return; noted[k] = 1; }++ for (const o of [counts, totals]) { const r = o[outcome] || (o[outcome] = {}); r[reason] = (r[reason] || 0) + 1; }++ }++ function flush() {++ const to = cfg.metricsUrl || (meta && meta.getAttribute('data-metrics')) || (latest && latest.metrics_url);++ if (!to || !Object.keys(counts).length) return;++ try {++ if (new URL(to, root.location.href).origin !== new URL(root.location.href).origin) return;++ const body = JSON.stringify({ service: latest && latest.service, release: current, to: latest && latest.release, counts });++ counts = {};++ const n = root.navigator;++ if (!(n && n.sendBeacon && n.sendBeacon(to, body))) root.fetch(to, { method: 'POST', body, keepalive: true, credentials: 'same-origin', headers: { 'Content-Type': 'text/plain' } }).catch(() => {});++ } catch { /* best effort */ }++ }++++ /** Why acting now is not safe, or null. With `scope`, only what is inside that element counts. */++ function busy(scope) {++ const d = scope || document;const a = document.activeElement;-- if (a && (a.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(a.tagName || ''))) return true;-- if (document.querySelector('form[data-dirty="true"], [data-ov-protected]')) return true;++ const typing = a && (a.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(a.tagName || ''));++ if (scope ? a && a !== document.body && scope.contains(a) : typing) return typing ? 'typing' : 'active';++ if (d.querySelector('form[data-dirty="true"]') || (scope && scope.matches('form[data-dirty="true"]'))) return 'dirty';++ if (d.querySelector('[data-ov-protected]') || (scope && scope.hasAttribute('data-ov-protected'))) return 'protected';// Someone watching or broadcasting: a playing <video>/<audio>, or a live camera/mic stream.try {-- for (const m of document.querySelectorAll('video, audio')) {-- if (!m.paused && !m.ended) return true;++ for (const m of d.querySelectorAll('video, audio')) {++ if (!m.paused && !m.ended) return 'media';const so = m.srcObject;-- if (so && typeof so.getTracks === 'function' && so.getTracks().some((t) => t.readyState === 'live')) return true;++ if (so && typeof so.getTracks === 'function' && so.getTracks().some((t) => t.readyState === 'live')) return 'capture';}-- } catch { return true; }-- try { if (typeof root.OVProtected === 'function' && root.OVProtected()) return true; } catch { return true; }-- return !document.hidden && Date.now() - lastInput < IDLE_MS;++ } catch { return 'media'; }++ if (scope) return null;++ try { if (typeof root.OVProtected === 'function' && root.OVProtected()) return 'protected'; } catch { return 'protected'; }++ return !document.hidden && Date.now() - lastInput < IDLE_MS ? 'active' : null;}++ function reload(why) { record('reloaded', why); flush(); root.location.reload(); }function prompt() {if (prompted) return;prompted = true;-- const text = 'A new version of this page is available.';-- const reload = () => root.location.reload();if (root.OpenVibeUI && typeof root.OpenVibeUI.toast === 'function') {-- root.OpenVibeUI.toast(text, { type: 'info', title: 'Update ready', ttl: 0, action: { label: 'Reload', onClick: reload } });++ root.OpenVibeUI.toast('A new version of this page is available.', { type: 'info', title: 'Update ready', ttl: 0, action: { label: 'Reload', onClick: () => reload('user') } });}}--function maybeReload() {-- if (mustReload && !busy()) root.location.reload();++ if (!mustReload) return;++ const why = busy();++ if (why) record('deferred', why, true); else reload(reloadWhy);++ }++ function emit(name, release) { try { root.dispatchEvent(new root.CustomEvent(name, { detail: { release } })); } catch { /* */ } }++++ function loadUpdate() {++ if (root.OVReleaseUpdate) return Promise.resolve(root.OVReleaseUpdate);++ return lib || (lib = new Promise((ok, no) => {++ const sc = document.createElement('script');++ sc.src = updateUrl; sc.async = true;++ sc.onload = () => (root.OVReleaseUpdate ? ok(root.OVReleaseUpdate) : no('script'));++ sc.onerror = () => { lib = null; no('script'); };++ document.head.appendChild(sc);++ }));++ }++ /** Without release-update.js: the release id, min_client_release and the window only. */++ function legacy(m) {++ const w = (Number(m.mixed_version_window_hours) || 0) * 3600e3;++ if (m.min_client_release === m.release) return { action: 'reload', reason: 'required' };++ return w > 0 && Date.now() - Date.parse(m.released_at) > w ? { action: 'reload', reason: 'window' } : { action: 'prompt' };++ }++ function commitRegions(next) {++ if (waiting.length) waiting = root.OVReleaseUpdate.commit(waiting, next, busy, record);++ return !waiting.length;++ }++ function adopt(next) {++ base = next; current = next.release; pending = null; mustReload = false;++ record('applied', kinds || 'server');++ flush();++ emit('ov:release-applied', next.release);++ }++++ async function decide(m) {++ let x = null;++ if (m.components || m.contract_ranges) { try { x = await loadUpdate(); } catch { record('failed', 'script'); flush(); } }++ const page = base || { release: current };++ let p = x ? x.plan(page, m, Date.now()) : legacy(m);++ if (p.action === 'in-place' && (cfg.inPlace === false || failedRelease === m.release)) p = x.plan({ release: current, contract_ranges: page.contract_ranges }, m, Date.now());++ if (p.action === 'none') return;++ if (p.action !== 'in-place') {++ if (p.action === 'reload') { mustReload = true; reloadWhy = p.reason; }++ prompt();++ maybeReload();++ return;++ }++ try {++ kinds = Array.from(new Set(p.changed.map((c) => c.kind))).sort().join('+');++ waiting = await x.apply(p, m, busy, record); pending = m;++ if (!waiting.length) adopt(m);++ } catch (why) {++ waiting = []; pending = null;++ record('failed', typeof why === 'string' ? why : 'content');++ failedRelease = m.release;++ flush();++ await decide(m);++ }++ }++ function consider(m) {++ latest = m;++ if (m.release === current || busyWith || (pending && pending.release === m.release)) return;++ waiting = []; pending = null; // a newer release supersedes regions still waiting for the last one++ busyWith = decide(m).catch(() => {}).then(() => { busyWith = null; });}async function check(force) {@@@ -70,30 +155,42 @@m = await r.json();} catch { return latest; }if (!m || typeof m.release !== 'string') return latest;-- latest = m;-- if (!current) { current = m.release; root.OVRelease.current = current; return m; }-- if (m.release === current) return m;-- const age = now - Date.parse(m.released_at);-- const windowMs = (Number(m.mixed_version_window_hours) || 0) * 3600 * 1000;-- mustReload = m.min_client_release === m.release || (windowMs > 0 && age > windowMs);-- prompt();-- maybeReload();++ if (!current) current = m.release;++ if (!base && m.release === current) base = m;++ consider(m);++ if (busyWith) await busyWith;return m;}let stopped = false;root.addEventListener('focus', () => { if (!stopped) check(false); });root.addEventListener('online', () => { if (!stopped) check(true); });-- document.addEventListener('visibilitychange', () => { if (stopped) return; if (document.hidden) maybeReload(); else check(false); });-- const tick = root.setInterval(() => { if (!stopped) { check(false); maybeReload(); } }, 30 * 1000);++ root.addEventListener('pagehide', flush);++ document.addEventListener('visibilitychange', () => { if (stopped) return; if (document.hidden) { flush(); maybeReload(); } else check(false); });++ const tick = root.setInterval(() => {++ if (stopped) return;++ if (pending && commitRegions(pending)) adopt(pending);++ check(false); maybeReload();++ }, 30 * 1000);const poll = root.setInterval(() => { if (!stopped) check(true); }, 10 * 60 * 1000);function stop() { stopped = true; root.clearInterval(tick); root.clearInterval(poll); }-- root.OVRelease = { current, check: () => check(true), state: () => ({ current, latest, mustReload }), stop };-- // No server-rendered release: learn it now; a site that serves no /release.json is left alone.-- if (!current) {-- root.fetch(url, { cache: 'no-store', credentials: 'omit' })-- .then((r) => (r.ok ? r.json() : null))-- .then((m) => { if (m && typeof m.release === 'string') { current = m.release; root.OVRelease.current = current; latest = m; lastCheck = Date.now(); } else stop(); })-- .catch(stop);-- }++ root.OVRelease = {++ get current() { return current; },++ check: () => check(true),++ state: () => ({ current, latest, mustReload, waiting: waiting.length, metrics: totals }),++ flush,++ stop,++ };++ // The page's own manifest, if /release.json still serves the release the page was rendered from. A site++ // that serves no /release.json and rendered no meta tag is left alone.++ lastCheck = Date.now();++ root.fetch(url, { cache: 'no-store', credentials: 'omit' })++ .then((r) => (r.ok ? r.json() : null))++ .then((m) => {++ if (!m || typeof m.release !== 'string') { if (!current) stop(); return; }++ if (!current) current = m.release;++ if (m.release === current) base = m;++ consider(m);++ })++ .catch(() => { if (!current) stop(); });})(typeof window !== 'undefined' ? window : globalThis);@@@ -2,22 +2,54 @@// ═══════════════════════════════════════════════════════════════// openvibe-shared/release — GET /release.json for a web surface (ADR-016, contract// registry.release-manifest@1). Open tabs poll it (release-watch.js) and learn when the page--// they run is older than what the server now serves.++// they run is older than what the server now serves, and what changed.//--// const release = require('openvibe-shared/release').createRelease({ service: 'community' });--// app.get('/release.json', release.handler);--// html += release.metaTag(); // <meta name="ov-release" …> — the page's own release++// const release = require('openvibe-shared/release').createRelease({++// service: 'community',++// components: { // optional; see "Components" below++// styles: { kind: 'style', assets: ['/css/app.css'] },++// pages: { kind: 'content', files: ['server/web/pages'] },++// shell: { kind: 'script', assets: ['/js/app.js'], files: ['server/web/layout.js'] },++// },++// contracts: { 'community.web-api': { version: '1.3.0', accepts: '^1.0.0' } },++// schemaGeneration: () => migrations.generation(),++// });++// release.mount(app, { registry }); // GET /release.json + POST /release-metrics into /metrics++// html += release.metaTag(); // <meta name="ov-release" …> — the page's own release//--// The release is the deployed commit (git in `root`, or RELEASE_COMMIT), read once at boot.++// The release is the deployed commit (git in `root`, or RELEASE_COMMIT), read at boot.// MIN_CLIENT_RELEASE (env) set to the running release tells every older tab it must reload--// (as soon as that is safe); otherwise older tabs are only prompted, and reload on their own once++// (as soon as that is safe); otherwise older tabs are prompted, and reload on their own once// the new release is older than the mixed-version window (24 h by default).++//++// Components (manifest 1.1.0). Each has a kind and a version, the content hash of its files:++// style stylesheets (`assets`): open tabs swap the <link>s in place++// content server-rendered regions marked data-ov-content="<id>": open tabs re-fetch them in place++// script code the page runs: a reload at a safe moment++// server code only the server runs: nothing to do in the tab++// `shell` is the catch-all script component for every client-facing file no other component++// lists (templates, scripts). Without a declared shell its version is the release, so every release++// reloads, as before 1.5.0. The platform package versions are part of the shell's hash.++// `assets` are URL paths under `publicDir`; they also make the asset map (`url` is++// `<path>?v=<12 hex of sha256>` unless `assetUrl` says otherwise, `integrity` is sha384).++// `files` are paths under `root`; a directory counts every file in it.++//++// The served manifest carries only the fields the service's installed openvibe-contracts++// release-manifest schema declares (1.0.0 has none of the fields above), so /release.json always++// validates against the contracts the service pins. `full()` is the manifest before that filter.// ═══════════════════════════════════════════════════════════════const fs = require('fs');++const path = require('path');++const crypto = require('crypto');const { execFileSync } = require('child_process');const SHA_RE = /^[0-9a-f]{7,40}$/;++const ID_RE = /^[a-z][a-z0-9-]{0,39}$/;++const CONTRACT_RE = /^[a-z][a-z0-9-]*(\.[a-z0-9_-]+)+$/;++const KINDS = ['style', 'content', 'script', 'server'];++const V1_0 = ['service', 'release', 'released_at', 'booted_at', 'contracts_version', 'packages', 'min_client_release', 'mixed_version_window_hours'];++const SCHEMA_PATH = 'openvibe-contracts/contracts/registry/release-manifest.v1.json';function git(root, args) {try { return execFileSync('git', args, { cwd: root, stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000 }).toString().trim(); } catch { return ''; }@@@ -27,43 +59,349 @@ function pkgVersion(name, root) {try { return JSON.parse(fs.readFileSync(require.resolve(`${name}/package.json`, { paths: [root] }), 'utf8')).version || null; } catch { return null; }}++// ── Versions and ranges ────────────────────────────────────────++++function parseVersion(s) {++ const m = /^v?(\d+)\.(\d+)\.(\d+)$/.exec(String(s == null ? '' : s).trim());++ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;++}++function compareVersions(a, b) {++ const x = typeof a === 'string' ? parseVersion(a) : a;++ const y = typeof b === 'string' ? parseVersion(b) : b;++ if (!x || !y) return NaN;++ for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] < y[i] ? -1 : 1;++ return 0;++}++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]);++const caretTop = (v) => (v[0] > 0 ? bump(v, 0) : v[1] > 0 ? bump(v, 1) : bump(v, 2));++++/**++ * A range as the manifest writes it: ">=a.b.c <x.y.z". Accepts "^1.2.0", "~1.2.0", "1.x", "1.2",++ * "1.2.3" (that version only), ">=1.2.0", ">=1.2.0 <3.0.0" and "*"; empty means "^<version>".++ */++function normalizeRange(range, version) {++ const r = String(range == null ? '' : range).trim();++ let lo; let hi; let m;++ if (!r) { lo = parseVersion(version); hi = lo && caretTop(lo); }++ else if (r === '*' || /^x$/i.test(r)) { lo = [0, 0, 0]; hi = [Number.MAX_SAFE_INTEGER, 0, 0]; }++ else if ((m = /^\^(\S+)$/.exec(r))) { lo = parseVersion(m[1]); hi = lo && caretTop(lo); }++ else if ((m = /^~(\S+)$/.exec(r))) { lo = parseVersion(m[1]); hi = lo && bump(lo, 1); }++ else if ((m = /^(\d+)(?:\.(\d+|x|\*))?(?:\.(x|\*))?$/i.exec(r))) {++ const minor = m[2] === undefined || /x|\*/i.test(m[2]) ? null : Number(m[2]);++ lo = [Number(m[1]), minor == null ? 0 : minor, 0];++ hi = bump(lo, minor == null ? 0 : 1);++ } else if ((m = /^>=\s*(\S+)(?:\s+<\s*(\S+))?$/.exec(r))) { lo = parseVersion(m[1]); hi = m[2] ? parseVersion(m[2]) : lo && bump(lo, 0); }++ else if (parseVersion(r)) { lo = parseVersion(r); hi = bump(lo, 2); }++ if (!lo || !hi || !(compareVersions(lo, hi) < 0)) throw new TypeError(`release: cannot read the range "${r}"`);++ return `>=${lo.join('.')} <${hi.join('.')}`;++}++++function satisfies(version, range) {++ const m = /^>=(\S+) <(\S+)$/.exec(String(range || ''));++ return !!m && compareVersions(version, m[1]) >= 0 && compareVersions(version, m[2]) < 0;++}++++/** { id: '1.2.0' | { version, accepts?, role? } } → the manifest's contract_ranges. */++function contractRanges(input) {++ const out = {};++ for (const [id, spec] of Object.entries(input || {})) {++ if (!CONTRACT_RE.test(id) || id.length > 100) throw new TypeError(`release: contract id "${id}" is not <owner>.<name>`);++ const s = typeof spec === 'string' ? { version: spec } : (spec || {});++ const v = parseVersion(s.version);++ if (!v) throw new TypeError(`release: ${id} needs a version x.y.z`);++ const accepts = normalizeRange(s.accepts, s.version);++ if (!satisfies(v.join('.'), accepts)) throw new TypeError(`release: ${id} accepts ${accepts}, which excludes its own version ${v.join('.')}`);++ const role = s.role || 'produces';++ if (role !== 'produces' && role !== 'consumes') throw new TypeError(`release: ${id} role must be produces or consumes`);++ out[id] = role === 'produces' ? { version: v.join('.'), accepts } : { version: v.join('.'), accepts, role };++ }++ return out;++}++++// ── Components and the asset map ───────────────────────────────++++function listFiles(abs) {++ let st;++ try { st = fs.statSync(abs); } catch { return null; }++ if (st.isFile()) return [abs];++ if (!st.isDirectory()) return null;++ const out = [];++ for (const e of fs.readdirSync(abs, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : 1))) {++ if (e.name.startsWith('.') || e.name === 'node_modules') continue;++ out.push(...(listFiles(path.join(abs, e.name)) || []));++ }++ return out;++}++++const within = (base, p) => p === base || p.startsWith(base + path.sep);++const hash12 = (buf) => crypto.createHash('sha256').update(buf).digest('hex').slice(0, 12);++++function buildComponents({ root, publicDir, spec, release, packages, assetUrl, warn }) {++ const components = {}; const assets = {}; const watched = [];++ const specs = { ...(spec || {}) };++ for (const [id, c] of Object.entries(specs)) {++ if (!ID_RE.test(id)) throw new TypeError(`release: component id "${id}" must match ${ID_RE}`);++ if (!c || !KINDS.includes(c.kind)) throw new TypeError(`release: component ${id} needs kind ${KINDS.join('|')}`);++ }++ if (!specs.shell) specs.shell = { kind: 'script' };++ else if (specs.shell.kind !== 'script') throw new TypeError('release: the shell component is kind script');++ if (!specs.server) specs.server = { kind: 'server' };++ for (const [id, c] of Object.entries(specs)) {++ const h = crypto.createHash('sha256');++ let counted = 0;++ for (const urlPath of c.assets || []) {++ const p = String(urlPath);++ const file = path.resolve(publicDir, '.' + p);++ 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`);++ let buf;++ try { buf = fs.readFileSync(file); } catch { warn(`asset ${p} of component ${id} is missing (${file})`); h.update(`${p}\0missing\0`); continue; }++ watched.push(file);++ if (assets[p]) warn(`asset ${p} is listed by ${assets[p].component} and ${id}`);++ assets[p] = { url: assetUrl(p, hash12(buf)), component: id, integrity: `sha384-${crypto.createHash('sha384').update(buf).digest('base64')}` };++ h.update(`${p}\0`).update(buf).update('\0');++ counted++;++ }++ for (const rel of c.files || []) {++ const abs = path.resolve(root, String(rel));++ const list = within(root, abs) ? listFiles(abs) : null;++ if (!list) { warn(`file ${rel} of component ${id} is missing`); h.update(`${rel}\0missing\0`); continue; }++ for (const f of list) {++ watched.push(f);++ h.update(`${path.relative(root, f)}\0`).update(fs.readFileSync(f)).update('\0');++ counted++;++ }++ }++ if (id === 'shell') h.update(JSON.stringify(packages));++ const stated = c.version != null ? String(c.version) : null;++ const version = stated || (counted ? hash12(h.digest()) : release);++ if (!/^[0-9A-Za-z._+-]{1,64}$/.test(version)) throw new TypeError(`release: component ${id} version "${version}" is not a plain token`);++ components[id] = { kind: c.kind, version };++ }++ return { components, assets, watched };++}++++// ── The contract filter ────────────────────────────────────────++++/** The service's installed release-manifest schema, or null (then the 1.0.0 fields are served). */++function installedSchema(root) {++ try { return JSON.parse(fs.readFileSync(require.resolve(SCHEMA_PATH, { paths: [root] }), 'utf8')); } catch { return null; }++}++++/** Keeps what `schema` allows: properties it names when additionalProperties is false, recursively. */++function project(value, schema) {++ if (!schema || typeof schema !== 'object' || value === null || typeof value !== 'object') return value;++ if (Array.isArray(value)) return schema.items ? value.map((v) => project(v, schema.items)) : value;++ const props = schema.properties || {};++ const extra = schema.additionalProperties;++ const out = {};++ for (const [k, v] of Object.entries(value)) {++ if (props[k]) out[k] = project(v, props[k]);++ else if (extra === false) continue;++ else out[k] = extra && typeof extra === 'object' ? project(v, extra) : v;++ }++ return out;++}++++function deepFreeze(o) {++ if (o && typeof o === 'object' && !Object.isFrozen(o)) { Object.freeze(o); for (const v of Object.values(o)) deepFreeze(v); }++ return o;++}++++// ── /release-metrics: what open tabs report (D46) ──────────────++++const OUTCOMES = {++ applied: /^(content|server|style)(\+(content|server|style)){0,2}$/,++ reloaded: /^(user|required|window|contract)$/,++ deferred: /^(typing|dirty|protected|media|capture|active)$/,++ failed: /^(style|style-timeout|content|origin|script)$/,++};++const MAX_BODY = 4096;++const MAX_PER_REASON = 50;++const counters = new WeakMap();++++function updateCounter(registry) {++ let c = counters.get(registry);++ if (!c) {++ 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 });++ counters.set(registry, c);++ }++ return c;++}++++/** The report: a string, an object a body parser made, or null when it is over MAX_BODY. */++function readBody(req) {++ const b = req.body;++ if (typeof b === 'string') return Promise.resolve(b.length > MAX_BODY ? null : b);++ if (Buffer.isBuffer(b)) return Promise.resolve(b.length > MAX_BODY ? null : b.toString('utf8'));++ // express.json() leaves {} (and the stream unread) for the text/plain body sendBeacon sends.++ if (b && typeof b === 'object' && (Object.keys(b).length || req.readableEnded)) return Promise.resolve(b);++ return new Promise((resolve) => {++ let size = 0; const chunks = [];++ req.on('data', (c) => { size += c.length; if (size <= MAX_BODY) chunks.push(c); });++ req.on('end', () => resolve(size > MAX_BODY ? null : Buffer.concat(chunks).toString('utf8')));++ req.on('error', () => resolve(''));++ });++}++++/**++ * POST handler for release-watch beacons: { counts: { applied|reloaded|deferred|failed: { reason: n } } }.++ * Counts go to release_client_updates_total{outcome,reason} in `registry` (openvibe-shared/metrics).++ * Unknown reasons count as "other"; each reason counts at most 50 per report; a report is at most 4 KB;++ * one address sends at most `perMinute` reports a minute. Sec-GPC/DNT requests are not counted.++ */++function collector(registry, { perMinute = 30 } = {}) {++ if (!registry || typeof registry.counter !== 'function') throw new TypeError('release: collect() needs an openvibe-shared/metrics registry');++ const counter = updateCounter(registry);++ const seen = new Map(); let windowStart = Date.now();++ return async function releaseMetrics(req, res) {++ const done = (code) => { res.statusCode = code; res.setHeader('Cache-Control', 'no-store'); res.end(); };++ const h = req.headers || {};++ if (h['sec-gpc'] === '1' || h.dnt === '1') return done(204);++ const now = Date.now();++ if (now - windowStart > 60000) { seen.clear(); windowStart = now; }++ const ip = String((req.socket && req.socket.remoteAddress) || req.ip || '');++ const n = (seen.get(ip) || 0) + 1;++ if (seen.size < 10000 || seen.has(ip)) seen.set(ip, n);++ if (n > perMinute) return done(429);++ let body = await readBody(req);++ if (body === null) return done(413);++ if (typeof body === 'string') { try { body = JSON.parse(body); } catch { return done(400); } }++ const counts = body && typeof body === 'object' ? body.counts : null;++ if (!counts || typeof counts !== 'object') return done(400);++ for (const [outcome, re] of Object.entries(OUTCOMES)) {++ const reasons = counts[outcome];++ if (!reasons || typeof reasons !== 'object') continue;++ for (const [reason, value] of Object.entries(reasons).slice(0, 16)) {++ const k = Math.min(MAX_PER_REASON, Math.floor(Number(value)));++ if (k > 0) counter.inc({ outcome, reason: re.test(reason) ? reason : 'other' }, k);++ }++ }++ return done(204);++ };++}++++// ── createRelease ──────────────────────────────────────────────++function createRelease({service, root = process.cwd(), windowHours = 24,minClientRelease = process.env.MIN_CLIENT_RELEASE || null,packages = ['openvibe-shared', 'openvibe-sdk'],-- env = process.env, now = () => new Date(),++ components = null, publicDir = null, assetUrl = (p, h) => `${p}?v=${h}`,++ contracts = null, schemaGeneration = null, schemaCompatibleFrom = null,++ metricsPath = null, schema, recheckMs = 0,++ env = process.env, now = () => new Date(), logger = console,} = {}) {if (!/^[a-z][a-z0-9-]{1,39}$/.test(String(service || ''))) throw new TypeError('createRelease: service id required');-- const fromEnv = String(env.RELEASE_COMMIT || '').toLowerCase();-- const sha = SHA_RE.test(fromEnv) ? fromEnv.slice(0, 12) : git(root, ['rev-parse', '--short=12', 'HEAD']);-- const release = SHA_RE.test(sha) ? sha : '0000000';-- const committed = env.RELEASE_AT || git(root, ['log', '-1', '--format=%cI']);++ root = path.resolve(root);++ const pub = path.resolve(root, publicDir || 'public');++ const ranges = contractRanges(contracts); // throws at boot on a malformed range++ const contractSchema = schema === undefined ? installedSchema(root) : schema;const bootedAt = now();-- const releasedAt = Number.isNaN(Date.parse(committed)) ? bootedAt : new Date(committed);-- const versions = {};-- for (const p of packages) { const v = pkgVersion(p, root); if (v) versions[p] = v; }-- const manifest = Object.freeze({-- service,-- release,-- released_at: releasedAt.toISOString(),-- booted_at: bootedAt.toISOString(),-- contracts_version: pkgVersion('openvibe-contracts', root),-- packages: Object.freeze(versions),-- min_client_release: minClientRelease && SHA_RE.test(minClientRelease) ? minClientRelease : null,-- mixed_version_window_hours: Math.max(0, Math.floor(Number(windowHours) || 0)),-- });-- const body = JSON.stringify(manifest);++ const warnings = [];++ const warn = (msg) => { warnings.push(msg); try { logger.warn(`[release] ${service}: ${msg}`); } catch { /* */ } };++ let metricsUrl = metricsPath || null;++ let state = null; let body = null; let signature = ''; let checkedAt = 0;++++ const intOrNull = (v, name) => {++ let x = v;++ try { if (typeof x === 'function') x = x(); } catch (err) { warn(`${name} failed: ${err && err.message}`); return null; }++ if (x == null) return null;++ if (Number.isInteger(x) && x >= 0) return x;++ warn(`${name} is not a non-negative integer`); return null;++ };++ const sigOf = (files) => files.map((f) => { try { const s = fs.statSync(f); return `${s.mtimeMs}:${s.size}`; } catch { return '-'; } }).join('|');++++ function build() {++ warnings.length = 0;++ const fromEnv = String(env.RELEASE_COMMIT || '').toLowerCase();++ const sha = SHA_RE.test(fromEnv) ? fromEnv.slice(0, 12) : git(root, ['rev-parse', '--short=12', 'HEAD']);++ const release = SHA_RE.test(sha) ? sha : '0000000';++ const committed = env.RELEASE_AT || git(root, ['log', '-1', '--format=%cI']);++ const releasedAt = Number.isNaN(Date.parse(committed)) ? bootedAt : new Date(committed);++ const versions = {};++ for (const p of packages) { const v = pkgVersion(p, root); if (v) versions[p] = v; }++ const built = buildComponents({ root, publicDir: pub, spec: components, release, packages: versions, assetUrl, warn });++ const generation = intOrNull(schemaGeneration, 'schemaGeneration');++ let compatibleFrom = schemaCompatibleFrom == null ? generation : intOrNull(schemaCompatibleFrom, 'schemaCompatibleFrom');++ if (generation != null && compatibleFrom != null && compatibleFrom > generation) { warn('schemaCompatibleFrom is above schemaGeneration'); compatibleFrom = generation; }++ const full = deepFreeze({++ service,++ release,++ released_at: releasedAt.toISOString(),++ booted_at: bootedAt.toISOString(),++ contracts_version: pkgVersion('openvibe-contracts', root),++ packages: versions,++ min_client_release: minClientRelease && SHA_RE.test(minClientRelease) ? minClientRelease : null,++ mixed_version_window_hours: Math.max(0, Math.floor(Number(windowHours) || 0)),++ components: built.components,++ assets: built.assets,++ schema_generation: generation,++ schema_compatible_from: compatibleFrom,++ contract_ranges: ranges,++ metrics_url: metricsUrl,++ });++ const served = deepFreeze(contractSchema ? project(full, contractSchema) : Object.fromEntries(V1_0.map((k) => [k, full[k]])));++ state = { full, served, watched: built.watched };++ body = JSON.stringify(served);++ signature = sigOf(built.watched);++ checkedAt = Date.now();++ return served;++ }++ build();++++ /** Re-reads git, env, files and the schema generation (e.g. after a static-only release switch). */++ function refresh() { return build(); }++ function maybeRecheck() {++ if (!(recheckMs > 0) || Date.now() - checkedAt < recheckMs) return;++ checkedAt = Date.now();++ if (sigOf(state.watched) !== signature) build();++ }function handler(_req, res) {++ maybeRecheck();res.setHeader('Content-Type', 'application/json; charset=utf-8');res.setHeader('Cache-Control', 'no-cache, max-age=0');res.setHeader('Access-Control-Allow-Origin', '*');res.end(body);}const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));-- const metaTag = (url = '/release.json') => `<meta name="ov-release" content="${esc(release)}" data-released-at="${esc(manifest.released_at)}" data-url="${esc(url)}">`;++ 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)}"` : ''}>`;++++ /** GET /release.json and, with a metrics registry, POST <metricsPath> into release_client_updates_total. */++ function mount(app, { registry = null, path: manifestPath = '/release.json', metricsPath: mp = metricsPath || '/release-metrics', perMinute } = {}) {++ app.get(manifestPath, handler);++ if (registry) {++ app.post(mp, collector(registry, { perMinute }));++ if (metricsUrl !== mp) { metricsUrl = mp; build(); }++ }++ return api;++ }++++ /** Validates the served manifest with the service's openvibe-contracts (or the one passed). */++ function validate({ contracts: lib } = {}) {++ let c = lib;++ if (!c) { try { c = require(require.resolve('openvibe-contracts', { paths: [root] })); } catch { return { valid: null, errors: [], reason: 'openvibe-contracts is not installed' }; } }++ const r = c.validate('registry.release-manifest@1', state.served);++ let version = null;++ try { version = c.resolve('registry.release-manifest@1').version; } catch { /* */ }++ return { valid: r.valid, errors: r.errors, contract: version };++ }-- return { manifest: () => manifest, release, handler, metaTag };++ const api = {++ get release() { return state.full.release; },++ manifest: () => state.served,++ full: () => state.full,++ handler,++ metaTag,++ mount,++ collect: (registry, opts) => collector(registry, opts),++ refresh,++ validate,++ warnings,++ /** Which manifest fields the service's contracts let it serve. */++ fields: () => Object.keys(state.served),++ };++ return api;}--module.exports = { createRelease };++module.exports = { createRelease, normalizeRange, satisfies, compareVersions, parseVersion, contractRanges, project, collector, OUTCOMES };nnew file mode 100644@@@ -0,0 +1,85 @@++{++ "$schema": "https://json-schema.org/draft/2020-12/schema",++ "$id": "https://openvibe.network/contracts/registry/release-manifest.v1.json",++ "title": "ReleaseManifest",++ "description": "What a running web surface is serving (GET /release.json, ADR-016). Open clients compare it on focus and reconnect. A client outside the supported window is prompted and reloads only when that is safe. Since 1.1.0 the manifest can also say which components changed, so a client can apply style and content changes in place, and which contract versions the server still accepts, so a mixed-version population can be checked.",++ "type": "object",++ "required": ["service", "release", "released_at", "contracts_version", "min_client_release", "mixed_version_window_hours"],++ "additionalProperties": false,++ "properties": {++ "service": { "type": "string", "pattern": "^[a-z][a-z0-9-]{1,39}$" },++ "release": { "type": "string", "pattern": "^[0-9a-f]{7,40}$", "description": "The deployed commit (short sha)." },++ "released_at": { "type": "string", "format": "date-time", "description": "Commit time of the release." },++ "booted_at": { "type": "string", "format": "date-time" },++ "contracts_version": { "type": ["string", "null"], "description": "openvibe-contracts version the service runs against." },++ "packages": {++ "type": "object",++ "description": "Versions of the platform packages the page loads (openvibe-shared, openvibe-sdk, ...).",++ "additionalProperties": { "type": "string" }++ },++ "min_client_release": { "type": ["string", "null"], "description": "Oldest client release still supported; null while every client in the window is." },++ "mixed_version_window_hours": { "type": "integer", "minimum": 0 },++ "components": {++ "type": "object",++ "description": "Since 1.1.0. Per-component versions. A client applies a new release in place only when every component that changed is a style, content or server component and the contract ranges still match. Any other change is applied by a reload at a safe moment. The shell component covers every client-facing file that no other component lists.",++ "propertyNames": { "pattern": "^[a-z][a-z0-9-]{0,39}$" },++ "additionalProperties": {++ "type": "object",++ "required": ["kind", "version"],++ "additionalProperties": false,++ "properties": {++ "kind": {++ "enum": ["style", "content", "script", "server"],++ "description": "style: stylesheets, swapped in place. content: server-rendered regions (data-ov-content), re-fetched in place. script: code the page runs, applied by a reload. server: code only the server runs, no client action."++ },++ "version": { "type": "string", "pattern": "^[0-9A-Za-z._+-]{1,64}$", "description": "Content hash of the component's files, or a version the service states." }++ }++ }++ },++ "assets": {++ "type": "object",++ "description": "Since 1.1.0. Asset map: each logical URL path the release serves, mapped to its content-addressed URL. HTML from this release references only these URLs.",++ "propertyNames": { "pattern": "^/[^?#\\s]{0,255}$" },++ "additionalProperties": {++ "type": "object",++ "required": ["url", "component"],++ "additionalProperties": false,++ "properties": {++ "url": { "type": "string", "minLength": 1, "maxLength": 512 },++ "component": { "type": "string", "pattern": "^[a-z][a-z0-9-]{0,39}$" },++ "integrity": { "type": "string", "pattern": "^sha(256|384|512)-[A-Za-z0-9+/]+={0,2}$" }++ }++ }++ },++ "schema_generation": {++ "type": ["integer", "null"],++ "minimum": 0,++ "description": "Since 1.1.0. The database schema generation this release migrates to and runs against; null when the service does not track one."++ },++ "schema_compatible_from": {++ "type": ["integer", "null"],++ "minimum": 0,++ "description": "Since 1.1.0. The oldest schema generation whose code still runs against this release's schema, because every migration since then only expanded it. Rolling back to a release whose schema_generation is lower is not data-safe."++ },++ "contract_ranges": {++ "type": "object",++ "description": "Since 1.1.0. For each contract the service produces (its API, as served to its own pages and clients) or consumes: the version this release speaks and the range of versions it accepts from the other side. A page from release A works against a server on release B when, for every contract A produces, A's version is in B's accepts and B's version is in A's accepts.",++ "propertyNames": { "pattern": "^[a-z][a-z0-9-]*(\\.[a-z0-9_-]+)+$", "maxLength": 100 },++ "additionalProperties": {++ "type": "object",++ "required": ["version", "accepts"],++ "additionalProperties": false,++ "properties": {++ "version": { "type": "string", "pattern": "^\\d+\\.\\d+\\.\\d+$" },++ "accepts": { "type": "string", "pattern": "^>=\\d+\\.\\d+\\.\\d+ <\\d+\\.\\d+\\.\\d+$", "description": "A half-open range, always written \">=a.b.c <x.y.z\"." },++ "role": { "enum": ["produces", "consumes"], "description": "produces when absent." }++ }++ }++ },++ "metrics_url": {++ "type": ["string", "null"],++ "maxLength": 512,++ "description": "Since 1.1.0. Same-origin URL where clients POST their update outcome counts (applied, reloaded, deferred, failed); null or absent when the service does not collect them."++ }++ }++}nnew file mode 100644@@@ -0,0 +1,94 @@++'use strict';++// openvibe-shared/release-compat: the N/N-1 mixed-version matrix, driven by the manifests' contract ranges++// and checked against real servers. Three releases of a small service:++// R1 items API 1.0.0: GET /api/items -> [{ id, name }]++// R2 items API 1.1.0 (additive): items gain `tags`; POST /api/items takes an optional `tags`++// R3 items API 2.0.0 (breaking): `name` is renamed `title`++// and a dishonest R2x that claims 1.1.0 (^1) but ships R3's breaking response.++const assert = require('assert');++const express = require('express');++const { createRelease } = require('../release');++const compat = require('../release-compat');++++function service(shape) {++ const items = [{ id: 1, name: 'first', tags: ['a'] }];++ const app = express();++ app.use(express.json());++ app.get('/api/items', (_req, res) => res.json(items.map((i) => shape(i))));++ app.post('/api/items', (req, res) => {++ const name = req.body.name || req.body.title;++ if (typeof name !== 'string') return res.status(400).json({ error: 'name required' });++ const item = { id: items.length + 1, name, tags: Array.isArray(req.body.tags) ? req.body.tags : [] };++ items.push(item);++ return res.status(201).json(shape(item));++ });++ return async () => {++ const server = await new Promise((resolve) => { const s = app.listen(0, '127.0.0.1', () => resolve(s)); });++ return { url: `http://127.0.0.1:${server.address().port}/`, close: () => new Promise((r) => server.close(r)) };++ };++}++const v1 = (i) => ({ id: i.id, name: i.name });++const v11 = (i) => ({ id: i.id, name: i.name, tags: i.tags });++const v2 = (i) => ({ id: i.id, title: i.name, tags: i.tags });++++// What each release's page does against whatever server it reaches.++const hasName = (list) => { for (const i of list) assert.strictEqual(typeof i.name, 'string', 'item.name'); };++const hasTitle = (list) => { for (const i of list) assert.strictEqual(typeof i.title, 'string', 'item.title'); };++const client1 = compat.replay([{ path: '/api/items', check: hasName }, { method: 'POST', path: '/api/items', body: { name: 'from R1' }, status: 201, check: (i) => assert.strictEqual(i.name, 'from R1') }]);++const client2 = compat.replay([{ path: '/api/items', check: hasName }, { method: 'POST', path: '/api/items', body: { name: 'from R2', tags: ['x'] }, status: 201, check: (i) => assert.strictEqual(i.name, 'from R2') }]);++const client3 = compat.replay([{ path: '/api/items', check: hasTitle }]);++++// Manifests the way a service makes them (createRelease), and one the way a fixture from production looks.++const manifest = (sha, version, accepts, extra = {}) => createRelease({++ service: 'items', root: __dirname, env: { RELEASE_COMMIT: sha, RELEASE_AT: '2026-09-23T10:00:00Z' }, schema: require('./fixtures/release-manifest.v1.1.0.json'),++ contracts: { 'items.web-api': { version, accepts } }, logger: { warn() {} }, ...extra,++}).full();++const R1 = { name: 'R1', manifest: manifest('1111111', '1.0.0', '^1.0.0', { schemaGeneration: 3 }), server: service(v1), client: client1 };++const R2 = { name: 'R2', manifest: manifest('2222222', '1.1.0', '^1.0.0', { schemaGeneration: 4, schemaCompatibleFrom: 3 }), server: service(v11), client: client2 };++const R3 = { name: 'R3', manifest: manifest('3333333', '2.0.0', '^2.0.0', { schemaGeneration: 6 }), server: service(v2), client: client3 };++const R2x = { name: 'R2x', manifest: manifest('2222333', '1.1.0', '^1.0.0'), server: service(v2), client: client2 };++++(async () => {++ // N-1 and N, both ways: an R1 page on the R2 server and an R2 page on the R1 server (rollback).++ const rows = await compat.assertMixedVersion({ releases: [R1, R2] });++ assert.deepStrictEqual(rows.map((r) => [r.page, r.server, r.compatible, r.ran, r.ok]), [['R1', 'R2', true, true, true], ['R2', 'R1', true, true, true]]);++ assert.ok(rows.every((r) => r.action === 'prompt'), 'no declared shell (so it changes every release): a compatible release prompts, never forces a reload');++++ // A breaking release next to the one before it is refused (ADR-016: N-1 must keep working) …++ await assert.rejects(compat.assertMixedVersion({ releases: [R2, R3] }), (err) => {++ assert.match(err.message, /adjacent releases must be compatible/);++ assert.match(err.message, /items\.web-api: the server accepts >=2\.0\.0 <3\.0\.0, the page speaks 1\.1\.0/);++ return true;++ });++ // … and outside the window (not adjacent) the tab is told to reload before it can break.++ const wide = await compat.runMixedVersion({ releases: [R1, R2, R3] });++ const r1r3 = wide.find((r) => r.page === 'R1' && r.server === 'R3');++ assert.deepStrictEqual([r1r3.compatible, r1r3.action, r1r3.reason, r1r3.ran, r1r3.ok], [false, 'reload', 'contract', false, true]);++ assert.ok(wide.filter((r) => r.adjacent && !r.compatible).every((r) => !r.ok), 'R2 <-> R3 fail as adjacent');++ const off = await compat.runMixedVersion({ releases: [R2, R3], requireAdjacent: false });++ assert.ok(off.every((r) => r.ok && r.action === 'reload'), 'requireAdjacent: false accepts a reload-only boundary');++++ // A manifest that claims compatibility its server does not honour is caught by the real calls.++ await assert.rejects(compat.assertMixedVersion({ releases: [R1, R2x] }), (err) => {++ assert.match(err.message, /declared compatible, but the R1 client failed against the R2x server: item\.name/);++ const bad = err.rows.find((r) => !r.ok);++ assert.deepStrictEqual([bad.page, bad.server, bad.ran], ['R1', 'R2x', true]);++ return true;++ });++ console.log(compat.formatMatrix(wide).split('\n').map((l) => ` ${l}`).join('\n'));++++ // Rollback safety from the schema generations: R2 only expanded R1's schema (compatible from 3), R3 did not.++ assert.deepStrictEqual(compat.rollbackSafe(R2.manifest, R1.manifest), { ok: true });++ assert.strictEqual(compat.rollbackSafe(R3.manifest, R2.manifest).ok, false);++ assert.strictEqual(compat.rollbackSafe({}, R1.manifest).unknown, true);++++ // Service to service: a consumer's range against a producer's.++ const media = compat.manifestFor({ service: 'media', release: 'abcdef0', contracts: { 'media.object': { version: '1.3.0', accepts: '^1.0.0' } } });++ const live = compat.manifestFor({ service: 'live', release: 'abcdef1', contracts: { 'media.object': { version: '1.1.0', accepts: '^1.0.0', role: 'consumes' } } });++ assert.deepStrictEqual(compat.consumerCompatible(live, media), { ok: true, problems: [] });++ const media2 = compat.manifestFor({ service: 'media', release: 'abcdef2', contracts: { 'media.object': { version: '2.0.0', accepts: '^2.0.0' } } });++ assert.strictEqual(compat.consumerCompatible(live, media2).ok, false);++ assert.deepStrictEqual(compat.compatible(live, media2), { ok: true, problems: [] }, 'consumed contracts are not the page\'s concern');++++ console.log('release-mixed-version: all checks passed');++})().catch((e) => { console.error(e); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,227 @@++'use strict';++// release-update.js through release-watch.js in a linkedom page (openvibe-shared/release-compat openPage):++// style and content changes applied in place, transactionally; deferral around focus and media; the++// fallback to a prompt; contract ranges forcing a reload only when safe; the update metrics (D46).++const assert = require('assert');++const { openPage, manifestFor, plan } = require('../release-compat');++++const URL_ = 'https://site.test/docs/a';++const A = 'aaaaaaa'; const B = 'bbbbbbb';++const comps = (o) => ({++ styles: { kind: 'style', version: 'css1' }, extra: { kind: 'style', version: 'x1' }, docs: { kind: 'content', version: 'd1' },++ side: { kind: 'content', version: 's1' }, shell: { kind: 'script', version: 'sh1' }, server: { kind: 'server', version: 'r1' }, ...o,++});++const assets = (css) => ({++ '/css/app.css': { url: `/css/app.css?v=${css}`, component: 'styles', integrity: 'sha384-AAAA' },++ '/css/other.css': { url: '/css/other.css?v=222222222222', component: 'extra' },++ '/js/app.js': { url: '/js/app.js?v=999999999999', component: 'shell' },++});++const api = (version, accepts) => ({ 'test.web-api': { version, accepts } });++const M1 = manifestFor({ service: 'test', release: A, components: comps(), assets: assets('111111111111'), contracts: api('1.0.0', '^1.0.0'), metricsUrl: '/release-metrics' });++const M2 = manifestFor({ service: 'test', release: B, components: comps({ styles: { kind: 'style', version: 'css2' }, docs: { kind: 'content', version: 'd2' }, server: { kind: 'server', version: 'r2' } }), assets: assets('333333333333'), contracts: api('1.1.0', '^1.0.0'), metricsUrl: '/release-metrics' });++++const PAGE = (region = '<p>old text</p>', rev = '1') => `<!doctype html><html><head>++<meta name="ov-release" content="${A}" data-url="/release.json">++<link rel="stylesheet" href="/css/app.css?v=111111111111">++<link rel="stylesheet" href="/css/other.css?v=222222222222">++<link rel="stylesheet" href="https://openvibe.network/shared/app.css">++</head><body>++<main data-ov-content="docs" data-ov-rev="${rev}">${region}</main>++<aside data-ov-content="side"><p>side</p></aside>++<section data-ov-content="side-2"><p>stays</p></section>++</body></html>`;++const NEW_PAGE = `<!doctype html><html><body>++<main data-ov-content="docs" data-ov-rev="2"><p onclick="evil()">new text</p><script>evil()</script><a href="javascript:evil()">x</a><meta http-equiv="refresh" content="0;url=/elsewhere"><img src="data:image/png;base64,AAAA"><iframe src="/x"></iframe></main>++<aside data-ov-content="side"><p>side, fresh</p></aside>++</body></html>`;++++async function open({ served = M1, html = PAGE(), page = NEW_PAGE, config, hidden = true, updateScript } = {}) {++ const state = { served, page, pageStatus: 200 };++ const p = await openPage({++ url: URL_, html, hidden, config, updateScript,++ serve: (u) => {++ const { pathname } = new URL(u);++ if (pathname === '/release.json') return { json: state.served };++ if (pathname === '/docs/a') return { status: state.pageStatus, body: state.page };++ return { status: 404 };++ },++ });++ await p.settle();++ p.state = state;++ return p;++}++/** Serves `next` and runs a check through to the end, loading (or failing) the new stylesheets. */++async function update(p, next, { styles = true } = {}) {++ p.state.served = next;++ const done = p.release.check();++ await p.settle();++ if (styles === true) p.settleStyles(true);++ else if (styles === false) p.settleStyles(false);++ else if (styles === 'timeout') p.fireTimeouts();++ await done; await p.settle();++}++const hrefs = (p) => Array.from(p.document.querySelectorAll('link[rel~="stylesheet"]')).map((l) => l.getAttribute('href'));++const counts = (p) => p.beacons.reduce((acc, b) => { for (const [o, rs] of Object.entries(b.body.counts)) for (const [r, n] of Object.entries(rs)) { acc[o] = acc[o] || {}; acc[o][r] = (acc[o][r] || 0) + n; } return acc; }, {});++++// Size: release-watch.js loads on every page after first paint; release-update.js only when a release++// can change something in place. Measured 2026-09-23: 3.2 KB and 3.0 KB brotli (release-watch was 1.7 KB).++{++ const fs = require('fs'); const path = require('path'); const { brotli } = require('../scripts/size-report');++ const size = (f) => brotli(fs.readFileSync(path.join(__dirname, '..', f)));++ assert.ok(size('release-watch.js') <= 3.5 * 1024, `release-watch.js is ${(size('release-watch.js') / 1024).toFixed(1)} KB brotli, budget 3.5 KB`);++ assert.ok(size('release-update.js') <= 3.5 * 1024, `release-update.js is ${(size('release-update.js') / 1024).toFixed(1)} KB brotli, budget 3.5 KB`);++}++++(async () => {++ // ── The plan, as a pure function ──++ assert.deepStrictEqual(plan(M1, M2).action, 'in-place');++ assert.deepStrictEqual(plan(M1, M2).changed.map((c) => c.id).sort(), ['docs', 'server', 'styles']);++ assert.strictEqual(plan(M1, { ...M2, components: comps({ shell: { kind: 'script', version: 'sh2' } }) }).action, 'prompt', 'a script change prompts');++ assert.strictEqual(plan(M1, { ...M2, components: comps({ nav: { kind: 'style', version: 'n1' } }) }).action, 'prompt', 'a new style component is not in place (the page has no <link> for it)');++ assert.strictEqual(plan(M1, { ...M2, components: comps({ styles: { kind: 'script', version: 'css1' } }) }).action, 'prompt', 'a kind change is not in place');++ assert.strictEqual(plan(M1, { ...M2, components: comps({ jobs: { kind: 'server', version: 'j1' } }) }).action, 'in-place', 'a new server component needs nothing from the tab');++ assert.deepStrictEqual(plan(M1, { ...M2, contract_ranges: api('2.0.0', '^2.0.0') }).reason, 'contract');++ assert.strictEqual(plan(M1, M1).action, 'none');++++ // ── Style and content in place ──++ {++ const p = await open();++ let applied = 0; p.window.addEventListener('ov:release-applied', () => { applied++; });++ let updated = null; p.document.querySelector('main').addEventListener('ov:content-updated', (e) => { updated = e.detail.component; });++ assert.strictEqual(p.release.current, A);++ assert.strictEqual(p.requests.length, 1, 'at load: only /release.json');++ await update(p, M2);++ assert.strictEqual(p.release.current, B, 'the tab is on the new release');++ assert.deepStrictEqual(hrefs(p), ['https://site.test/css/app.css?v=333333333333', '/css/other.css?v=222222222222', 'https://openvibe.network/shared/app.css'], 'only the changed stylesheet moved, in its place');++ assert.strictEqual(p.document.querySelector('link[href*="app.css?v=333"]').getAttribute('integrity'), 'sha384-AAAA');++ const main = p.document.querySelector('main');++ assert.match(main.textContent, /new text/);++ assert.strictEqual(main.getAttribute('data-ov-rev'), '2');++ assert.ok(!main.querySelector('script, iframe, meta, [onclick]'), 'no scripts, frames, meta refreshes or inline handlers come along');++ assert.strictEqual(main.querySelector('a').getAttribute('href'), null, 'javascript: links are dropped');++ assert.strictEqual(main.querySelector('img').getAttribute('src'), 'data:image/png;base64,AAAA', 'data: images stay');++ assert.match(p.document.querySelector('aside').textContent, /^side$/, 'a region whose component did not change is left alone');++ assert.strictEqual(updated, 'docs');++ assert.strictEqual(applied, 1);++ assert.deepStrictEqual([p.reloads, p.toasts.length], [0, 0], 'no reload, no prompt');++ assert.ok(p.document.querySelector('script[src="https://site.test/shared/release-update.js"]'), 'release-update.js was loaded from next to release-watch.js, on demand');++ assert.deepStrictEqual(p.beacons.map((b) => [b.to, b.body.release, b.body.to, b.body.counts]), [['/release-metrics', B, B, { applied: { 'content+server+style': 1 } }]]);++ // The same release again is nothing: no refetch, no new <link>, no remount.++ const before = p.requests.length;++ await update(p, M2);++ assert.strictEqual(p.requests.length, before + 1, 'only /release.json was read');++ assert.strictEqual(hrefs(p).length, 3);++ // A rollback to the previous release is applied in place too (the server is the authority).++ await update(p, { ...M1, contract_ranges: M2.contract_ranges });++ assert.strictEqual(p.release.current, A);++ assert.ok(hrefs(p).includes('https://site.test/css/app.css?v=111111111111'));++ }++++ // ── Server-only change: nothing to fetch, adopted quietly ──++ {++ const p = await open();++ await update(p, { ...M1, release: B, components: comps({ server: { kind: 'server', version: 'r9' } }) });++ assert.deepStrictEqual([p.release.current, p.toasts.length, p.requests.length], [B, 0, 2]);++ assert.deepStrictEqual(counts(p), { applied: { server: 1 } });++ }++++ // ── Focus inside a region: styles apply, the region waits, then commits ──++ {++ const p = await open({ html: PAGE('<p>old text</p><input>') });++ p.focus(p.document.querySelector('main input'));++ await update(p, M2);++ assert.ok(hrefs(p).includes('https://site.test/css/app.css?v=333333333333'), 'the stylesheet does not wait for the region');++ assert.match(p.document.querySelector('main').textContent, /old text/, 'the focused region is not replaced');++ assert.strictEqual(p.release.state().waiting, 1);++ assert.strictEqual(p.release.current, A, 'not adopted while a region waits');++ p.tick(); await p.settle();++ assert.deepStrictEqual(p.metrics(), { deferred: { typing: 1 } }, 'deferred once, however many ticks');++ const fetches = p.requests.filter((r) => r.url === URL_).length;++ await p.release.check(); await p.settle();++ assert.strictEqual(p.requests.filter((r) => r.url === URL_).length, fetches, 'a waiting region is not fetched again');++ p.blur(); p.tick(); await p.settle();++ assert.match(p.document.querySelector('main').textContent, /new text/);++ assert.strictEqual(p.release.current, B);++ assert.deepStrictEqual(counts(p), { deferred: { typing: 1 }, applied: { 'content+server+style': 1 } });++ }++ // Media playing inside a region, or a protected region: it waits (reason media / protected).++ {++ const p = await open({ html: PAGE('<p>old text</p><video></video>') });++ await update(p, M2);++ assert.deepStrictEqual([p.release.state().waiting, p.metrics()], [1, { deferred: { media: 1 } }]);++ const q = await open({ html: PAGE('<p data-ov-protected>editing</p>') });++ await update(q, M2);++ assert.deepStrictEqual([q.release.state().waiting, q.metrics()], [1, { deferred: { protected: 1 } }]);++ }++ // An unchanged data-ov-rev is not replaced (no remount).++ {++ const p = await open({ html: PAGE('<p>old text</p>', '2') });++ await update(p, M2);++ assert.match(p.document.querySelector('main').textContent, /old text/);++ assert.strictEqual(p.release.current, B);++ }++++ // ── Failures keep the old page and fall back to the prompt ──++ {++ const p = await open();++ await update(p, M2, { styles: false });++ assert.deepStrictEqual(hrefs(p), ['/css/app.css?v=111111111111', '/css/other.css?v=222222222222', 'https://openvibe.network/shared/app.css'], 'a stylesheet that fails to load is removed; the old one stays');++ assert.match(p.document.querySelector('main').textContent, /old text/, 'nothing commits when a stylesheet fails');++ assert.deepStrictEqual([p.release.current, p.toasts.length, p.reloads], [A, 1, 0], 'prompted instead');++ assert.deepStrictEqual(counts(p), { failed: { style: 1 } });++ await update(p, M2);++ assert.strictEqual(p.document.querySelectorAll('link[href*="?v=333"]').length, 0, 'a release that failed in place is not retried in place');++ }++ {++ const p = await open();++ await update(p, M2, { styles: 'timeout' });++ assert.deepStrictEqual(counts(p), { failed: { 'style-timeout': 1 } });++ assert.strictEqual(hrefs(p)[0], '/css/app.css?v=111111111111');++ }++ {++ const p = await open();++ p.state.pageStatus = 500;++ await update(p, M2);++ assert.deepStrictEqual([counts(p), hrefs(p)[0], p.toasts.length], [{ failed: { content: 1 } }, '/css/app.css?v=111111111111', 1], 'a region that cannot be fetched: nothing changes, prompt');++ }++ {++ const p = await open({ updateScript: 'fail' });++ await update(p, M2);++ assert.deepStrictEqual([counts(p), p.toasts.length, p.release.current], [{ failed: { script: 1 } }, 1, A], 'release-update.js unavailable: the old prompt');++ }++ {++ const p = await open({ config: { inPlace: false } });++ await update(p, M2);++ assert.deepStrictEqual([p.toasts.length, hrefs(p)[0], p.release.current], [1, '/css/app.css?v=111111111111', A], 'inPlace: false prompts');++ }++ {++ const cross = { ...M2, assets: { ...M2.assets, '/css/app.css': { url: 'https://evil.test/css/app.css?v=3', component: 'styles' } } };++ const p = await open({ html: PAGE().replace('<link rel="stylesheet" href="/css/app.css?v=111111111111">', '<link rel="stylesheet" data-ov-asset="/css/app.css" href="/css/app.1111.css">') });++ await update(p, cross);++ assert.deepStrictEqual([counts(p), p.release.current], [{ failed: { origin: 1 } }, A], 'an asset on another origin is refused');++ }++++ // ── A script change prompts; the contract ranges force a reload, only when safe ──++ {++ const p = await open();++ await update(p, { ...M2, components: comps({ shell: { kind: 'script', version: 'sh2' } }) });++ assert.deepStrictEqual([p.toasts.length, p.reloads, hrefs(p)[0]], [1, 0, '/css/app.css?v=111111111111']);++ }++ {++ const p = await open({ hidden: false });++ await update(p, { ...M2, contract_ranges: api('2.0.0', '^2.0.0') });++ assert.deepStrictEqual([p.reloads, p.metrics()], [0, { deferred: { active: 1 } }], 'incompatible contracts: a reload, but not under the user');++ assert.strictEqual(hrefs(p)[0], '/css/app.css?v=111111111111', 'and nothing in place');++ p.setHidden(true);++ assert.strictEqual(p.reloads, 1);++ assert.deepStrictEqual(counts(p), { deferred: { active: 1 }, reloaded: { contract: 1 } });++ }++ {++ // A page one release behind whose server kept accepting it: in place (the N-1 → N window).++ const p = await open();++ await update(p, { ...M2, contract_ranges: api('1.4.0', '>=1.0.0 <2.0.0') });++ assert.strictEqual(p.release.current, B);++ }++++ console.log('release-update: all checks passed');++})().catch((e) => { console.error(e); process.exit(1); });@@@ -1,13 +1,47 @@'use strict';--// release.js (GET /release.json) and release-watch.js (prompt, and reload only when safe).--const assert = require('assert'); const vm = require('vm'); const fs = require('fs'); const path = require('path'); const os = require('os');--const { createRelease } = require('../release');++// release.js (GET /release.json, the manifest fields, the contract filter, POST /release-metrics) and++// release-watch.js on a manifest without components (prompt, and reload only when safe).++const assert = require('assert'); const fs = require('fs'); const path = require('path'); const os = require('os'); const crypto = require('crypto');++const { createRelease, normalizeRange, satisfies, project } = require('../release');++const { createRegistry } = require('../metrics');++const { openPage } = require('../release-compat');++++const ROOT = path.join(__dirname, '..');++const FIXTURE = require('./fixtures/release-manifest.v1.1.0.json');++const Ajv2020 = require(require.resolve('ajv/dist/2020', { paths: [path.dirname(require.resolve('openvibe-contracts/package.json'))] }));++const addFormats = require(require.resolve('ajv-formats', { paths: [path.dirname(require.resolve('openvibe-contracts/package.json'))] }));++const contracts = require('openvibe-contracts');++++// The schema a full manifest is checked against: the installed openvibe-contracts once it has 1.1.0,++// else the copy of Contracts' proposed 1.1.0 in test/fixtures (drop it when the devDependency moves on).++const installed = contracts.schema('registry.release-manifest@1');++const v11 = installed.properties.components ? installed : FIXTURE;++for (const k of Object.keys(installed.properties)) assert.ok(FIXTURE.properties[k], `the 1.1.0 fixture keeps ${k}`);++const ajv = new Ajv2020({ allErrors: true, strict: true, strictRequired: false, allowUnionTypes: true }); addFormats(ajv);++const validate11 = ajv.compile(v11);++const valid11 = (m) => { const ok = validate11(m); return ok || JSON.stringify(validate11.errors); };(async () => {-- // Server side: manifest from env (no git), metaTag, handler headers.-- const r = createRelease({ service: 'community', root: os.tmpdir(), env: { RELEASE_COMMIT: 'ABCDEF1234567890', RELEASE_AT: '2026-09-23T01:00:00Z' }, minClientRelease: 'nope' });++ // ── Ranges ─────────────────────────────────────────────────++ assert.strictEqual(normalizeRange('^1.2.0'), '>=1.2.0 <2.0.0');++ assert.strictEqual(normalizeRange('^0.4.1'), '>=0.4.1 <0.5.0');++ assert.strictEqual(normalizeRange('~1.2.3'), '>=1.2.3 <1.3.0');++ assert.strictEqual(normalizeRange('1.x'), '>=1.0.0 <2.0.0');++ assert.strictEqual(normalizeRange('1.2'), '>=1.2.0 <1.3.0');++ assert.strictEqual(normalizeRange('1.2.3'), '>=1.2.3 <1.2.4');++ assert.strictEqual(normalizeRange('>=1.1.0'), '>=1.1.0 <2.0.0');++ assert.strictEqual(normalizeRange('>= 1.1.0 < 3.0.0'), '>=1.1.0 <3.0.0');++ assert.strictEqual(normalizeRange('', '2.3.4'), '>=2.3.4 <3.0.0', 'empty is ^version');++ assert.throws(() => normalizeRange('>=2.0.0 <1.0.0'));++ assert.throws(() => normalizeRange('latest'));++ assert.ok(satisfies('1.9.9', '>=1.0.0 <2.0.0') && !satisfies('2.0.0', '>=1.0.0 <2.0.0') && !satisfies('0.9.0', '>=1.0.0 <2.0.0'));++++ // ── A service with no contracts installed: the 1.0.0 manifest, as before ──++ const r = createRelease({ service: 'community', root: os.tmpdir(), env: { RELEASE_COMMIT: 'ABCDEF1234567890', RELEASE_AT: '2026-09-23T01:00:00Z' }, minClientRelease: 'nope', schema: null });const m = r.manifest();++ assert.deepStrictEqual(Object.keys(m), ['service', 'release', 'released_at', 'booted_at', 'contracts_version', 'packages', 'min_client_release', 'mixed_version_window_hours']);assert.strictEqual(m.release, 'abcdef123456');++ assert.strictEqual(r.release, 'abcdef123456');assert.strictEqual(m.released_at, '2026-09-23T01:00:00.000Z');assert.strictEqual(m.min_client_release, null, 'a malformed MIN_CLIENT_RELEASE is ignored');assert.strictEqual(m.mixed_version_window_hours, 24);@@@ -17,112 +51,230 @@ const { createRelease } = require('../release');assert.strictEqual(headers['Cache-Control'], 'no-cache, max-age=0');assert.deepStrictEqual(JSON.parse(body), JSON.parse(JSON.stringify(m)));assert.throws(() => createRelease({ service: 'Bad Name' }));-- const fromGit = createRelease({ service: 'shared', root: path.join(__dirname, '..'), env: {} });-- assert.match(fromGit.release, /^[0-9a-f]{7,12}$/);---- // Browser side, in a vm with a fake DOM and clock.-- function page({ serverRelease, releasedAt, min = null, active = null, protectedFn = null, hidden = false }) {-- const listeners = {}; const docListeners = {};-- let reloads = 0; const toasts = [];-- const ctx = {-- console, Date, JSON, Promise,-- setInterval: () => 1, clearInterval: () => {},-- location: { reload: () => { reloads++; } },-- addEventListener: (t, f) => { (listeners[t] = listeners[t] || []).push(f); },-- OpenVibeUI: { toast: (msg, o) => toasts.push({ msg, o }) },-- fetch: async () => ({ ok: true, json: async () => ({ release: serverRelease, released_at: releasedAt, min_client_release: min, mixed_version_window_hours: 24 }) }),-- document: {-- hidden,-- activeElement: active,-- querySelector: (sel) => (sel.startsWith('meta') ? { content: 'aaaaaaa', getAttribute: () => '/release.json' } : null),-- querySelectorAll: () => [],-- addEventListener: (t, f) => { docListeners[t] = f; },-- },-- };-- if (protectedFn) ctx.OVProtected = protectedFn;-- ctx.window = ctx; ctx.globalThis = ctx;-- vm.createContext(ctx);-- vm.runInContext(fs.readFileSync(path.join(__dirname, '..', 'release-watch.js'), 'utf8'), ctx);-- return { ctx, toasts, reloads: () => reloads, docListeners };++ assert.strictEqual(r.full().components.shell.version, 'abcdef123456', 'no declared shell: its version is the release, so every release reloads');++ assert.strictEqual(r.full().components.server.kind, 'server');++++ // ── Components, the asset map, schema generation and contract ranges ──++ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-release-'));++ const write = (rel, text) => { fs.mkdirSync(path.dirname(path.join(tmp, rel)), { recursive: true }); fs.writeFileSync(path.join(tmp, rel), text); };++ write('public/css/app.css', 'body{color:red}');++ write('public/js/app.js', 'console.log(1)');++ write('server/web/layout.js', 'module.exports = 1;');++ write('docs/a.md', '# A');++ write('docs/b.md', '# B');++ const sha12 = (t) => crypto.createHash('sha256').update(t).digest('hex').slice(0, 12);++ const spec = {++ service: 'test', root: tmp, env: { RELEASE_COMMIT: '1234567abcdef', RELEASE_AT: '2026-09-23T02:00:00Z' }, schema: v11,++ components: {++ styles: { kind: 'style', assets: ['/css/app.css'] },++ docs: { kind: 'content', files: ['docs'] },++ shell: { kind: 'script', assets: ['/js/app.js'], files: ['server/web/layout.js'] },++ },++ contracts: { 'test.web-api': { version: '1.2.0', accepts: '^1.0.0' }, 'media.object': { version: '1.0.0', role: 'consumes' } },++ schemaGeneration: () => 7, schemaCompatibleFrom: 5, logger: { warn() {} },++ };++ let rel = createRelease(spec);++ let full = rel.full();++ assert.strictEqual(valid11(full), true, 'the full manifest validates against registry.release-manifest 1.1.0');++ assert.deepStrictEqual(rel.manifest(), full, 'a 1.1.0 schema serves every field');++ assert.deepStrictEqual(Object.keys(full.components).sort(), ['docs', 'server', 'shell', 'styles']);++ assert.strictEqual(full.components.styles.kind, 'style');++ assert.match(full.components.styles.version, /^[0-9a-f]{12}$/);++ assert.strictEqual(full.components.server.version, '1234567abcde');++ assert.deepStrictEqual(full.assets['/css/app.css'], { url: `/css/app.css?v=${sha12('body{color:red}')}`, component: 'styles', integrity: `sha384-${crypto.createHash('sha384').update('body{color:red}').digest('base64')}` });++ assert.strictEqual(full.assets['/js/app.js'].component, 'shell');++ assert.strictEqual(full.schema_generation, 7);++ assert.strictEqual(full.schema_compatible_from, 5);++ assert.deepStrictEqual(full.contract_ranges, { 'test.web-api': { version: '1.2.0', accepts: '>=1.0.0 <2.0.0' }, 'media.object': { version: '1.0.0', accepts: '>=1.0.0 <2.0.0', role: 'consumes' } });++ assert.strictEqual(full.metrics_url, null);++ assert.deepStrictEqual(rel.warnings, []);++ const before = full.components;++ // A CSS-only change moves only the style component; a doc edit only the content one.++ write('public/css/app.css', 'body{color:blue}');++ full = rel.refresh() && rel.full();++ assert.notStrictEqual(full.components.styles.version, before.styles.version);++ assert.strictEqual(full.components.docs.version, before.docs.version);++ assert.strictEqual(full.components.shell.version, before.shell.version);++ write('docs/b.md', '# B, edited');++ full = rel.refresh() && rel.full();++ assert.notStrictEqual(full.components.docs.version, before.docs.version);++ assert.strictEqual(full.components.shell.version, before.shell.version);++ // A platform package bump is a shell change (the navbar is code the page runs).++ fs.mkdirSync(path.join(tmp, 'node_modules/openvibe-sdk'), { recursive: true });++ fs.writeFileSync(path.join(tmp, 'node_modules/openvibe-sdk/package.json'), '{"name":"openvibe-sdk","version":"9.9.9"}');++ full = rel.refresh() && rel.full();++ assert.strictEqual(full.packages['openvibe-sdk'], '9.9.9');++ assert.notStrictEqual(full.components.shell.version, before.shell.version);++ // recheckMs: a static-only switch is noticed without a restart.++ rel = createRelease({ ...spec, recheckMs: 1 });++ const v1 = rel.full().components.styles.version;++ write('public/css/app.css', 'body{color:green}');++ await new Promise((res) => setTimeout(res, 5));++ let served = '';++ rel.handler({}, { setHeader() {}, end: (b) => { served = b; } });++ assert.notStrictEqual(JSON.parse(served).components.styles.version, v1, 'the handler re-reads changed files after recheckMs');++ // Mistakes: a missing file warns (the manifest is advisory); a bad declaration throws at boot.++ const warned = createRelease({ ...spec, components: { styles: { kind: 'style', assets: ['/css/gone.css'] } } });++ assert.match(warned.warnings[0], /gone\.css/);++ assert.throws(() => createRelease({ ...spec, components: { styles: { kind: 'layout' } } }), /kind/);++ assert.throws(() => createRelease({ ...spec, components: { styles: { kind: 'style', assets: ['/../etc/passwd'] } } }), /publicDir/);++ assert.throws(() => createRelease({ ...spec, components: { shell: { kind: 'style' } } }), /shell/);++ assert.throws(() => createRelease({ ...spec, contracts: { 'test.web-api': { version: '1.2.0', accepts: '>=2.0.0' } } }), /excludes its own version/);++ assert.throws(() => createRelease({ ...spec, contracts: { nodot: '1.0.0' } }), /owner/);++ fs.rmSync(tmp, { recursive: true, force: true });++++ // ── The contract filter: /release.json validates against the contracts the service pins ──++ const pinned = createRelease({ service: 'shared', root: ROOT, env: {}, components: spec.components, contracts: spec.contracts, logger: { warn() {} } });++ const v = pinned.validate();++ assert.strictEqual(v.contract, installed.properties.components ? v.contract : '1.0.0');++ assert.strictEqual(v.valid, true, `the served manifest validates against the installed openvibe-contracts: ${JSON.stringify(v.errors)}`);++ if (!installed.properties.components) {++ assert.deepStrictEqual(pinned.fields(), ['service', 'release', 'released_at', 'booted_at', 'contracts_version', 'packages', 'min_client_release', 'mixed_version_window_hours'], 'contracts 0.30.x: only the 1.0.0 fields are served');++ assert.strictEqual(contracts.validate('registry.release-manifest@1', pinned.full()).valid, false, 'the unfiltered manifest would fail 1.0.0');}++ assert.strictEqual(valid11(pinned.full()), true, 'and the full manifest validates against 1.1.0');++ assert.deepStrictEqual(project({ a: 1, b: { c: 2, d: 3 }, e: { x: { y: 1, z: 2 } } }, { additionalProperties: false, properties: { a: {}, b: { additionalProperties: false, properties: { c: {} } }, e: { additionalProperties: { additionalProperties: false, properties: { y: {} } } } } }), { a: 1, b: { c: 2 }, e: { x: { y: 1 } } });++++ // ── POST /release-metrics into release_client_updates_total ──++ const registry = createRegistry();++ const collect = r.collect(registry, { perMinute: 3 });++ const { Readable } = require('stream');++ const post = async (payload, hdrs = {}, ip = '127.0.0.1') => {++ const req = Readable.from([Buffer.from(typeof payload === 'string' ? payload : JSON.stringify(payload))]);++ req.headers = hdrs; req.socket = { remoteAddress: ip };++ const res = { statusCode: 0, setHeader() {}, end() {} };++ await collect(req, res);++ return res.statusCode;++ };++ const counter = (outcome, reason) => { const line = registry.metrics().split('\n').find((l) => l.startsWith(`release_client_updates_total{outcome="${outcome}",reason="${reason}"}`)); return line ? Number(line.split(' ').pop()) : 0; };++ assert.strictEqual(await post({ counts: { applied: { 'content+style': 1 }, deferred: { typing: 2, bogus: 1 }, failed: { style: 500 } } }), 204);++ assert.strictEqual(counter('applied', 'content+style'), 1);++ assert.strictEqual(counter('deferred', 'typing'), 2);++ assert.strictEqual(counter('deferred', 'other'), 1, 'an unknown reason counts as other');++ assert.strictEqual(counter('failed', 'style'), 50, 'one report counts at most 50 per reason');++ assert.strictEqual(await post('not json', {}, '10.0.0.2'), 400);++ assert.strictEqual(await post({ counts: { reloaded: { user: 1 } } }, { 'sec-gpc': '1' }, '10.0.0.3'), 204);++ assert.strictEqual(counter('reloaded', 'user'), 0, 'Sec-GPC reports are not counted');++ assert.strictEqual(await post('x'.repeat(5000), {}, '10.0.0.4'), 413);++ assert.strictEqual(await post({ counts: {} }), 204);++ assert.strictEqual(await post({ counts: {} }), 204);++ assert.strictEqual(await post({ counts: {} }), 429, 'an address sends at most perMinute reports a minute');++++ // mount(): GET /release.json and POST /release-metrics on a real app, behind express.json(), fed by++ // the text/plain body navigator.sendBeacon sends; the manifest then says where to report.++ {++ const express = require('express');++ const app = express();++ app.use(express.json());++ const reg = createRegistry();++ const svc = createRelease({ service: 'test', root: ROOT, env: {}, schema: v11, logger: { warn() {} } }).mount(app, { registry: reg });++ assert.strictEqual(svc.full().metrics_url, '/release-metrics');++ assert.match(svc.metaTag(), /data-metrics="\/release-metrics"/);++ const server = await new Promise((res) => { const s = app.listen(0, '127.0.0.1', () => res(s)); });++ const base = `http://127.0.0.1:${server.address().port}`;++ const got = await (await fetch(`${base}/release.json`)).json();++ assert.strictEqual(got.metrics_url, '/release-metrics');++ const beacon = await fetch(`${base}/release-metrics`, { method: 'POST', headers: { 'content-type': 'text/plain;charset=UTF-8' }, body: JSON.stringify({ counts: { applied: { style: 1 }, reloaded: { window: 1 } } }) });++ assert.strictEqual(beacon.status, 204);++ const json = await fetch(`${base}/release-metrics`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ counts: { applied: { style: 1 } } }) });++ assert.strictEqual(json.status, 204);++ assert.match(reg.metrics(), /release_client_updates_total\{outcome="applied",reason="style"\} 2/);++ assert.match(reg.metrics(), /release_client_updates_total\{outcome="reloaded",reason="window"\} 1/);++ server.close();++ }++++ // ── The tab, on manifests without components (every service before 1.5.0 + contracts 0.31) ──const fresh = new Date(Date.now() - 3600e3).toISOString();const stale = new Date(Date.now() - 30 * 3600e3).toISOString();---- let p = page({ serverRelease: 'aaaaaaa', releasedAt: fresh });-- await p.ctx.OVRelease.check();++ const HTML = (extra = '', body = '') => `<!doctype html><html><head><meta name="ov-release" content="aaaaaaa" data-url="/release.json">${extra}</head><body>${body}</body></html>`;++ async function tab({ server, releasedAt = fresh, min = null, hidden = true, html = HTML(), protectedFn = null, focus = null, config = { metricsUrl: '/release-metrics' } }) {++ const manifest = { service: 'test', release: server, released_at: releasedAt, min_client_release: min, mixed_version_window_hours: 24 };++ const page = await openPage({ url: 'https://site.test/', html, hidden, protectedFn, config, serve: (u) => (u.endsWith('/release.json') ? { json: manifest } : { status: 404 }) });++ if (focus) page.focus(page.document.querySelector(focus));++ await page.settle();++ return page;++ }++ let p = await tab({ server: 'aaaaaaa' });++ await p.release.check(); await p.settle();assert.strictEqual(p.toasts.length, 0, 'same release: nothing');-- p = page({ serverRelease: 'bbbbbbb', releasedAt: fresh, hidden: true });-- await p.ctx.OVRelease.check();++ p = await tab({ server: 'bbbbbbb' });assert.strictEqual(p.toasts.length, 1, 'newer release: one prompt');assert.strictEqual(p.toasts[0].o.ttl, 0, 'the prompt stays until dismissed');-- assert.strictEqual(p.reloads(), 0, 'inside the window: never an automatic reload');-- await p.ctx.OVRelease.check();++ assert.strictEqual(p.reloads, 0, 'inside the window: never an automatic reload');++ await p.release.check(); await p.settle();assert.strictEqual(p.toasts.length, 1, 'prompted once');++ assert.ok(!p.document.querySelector('script[src*="release-update.js"]'), 'a manifest without components needs no release-update.js');++ p.toasts[0].o.action.onClick();++ assert.strictEqual(p.reloads, 1, 'Reload reloads');++ assert.deepStrictEqual(p.beacons.at(-1).body.counts, { reloaded: { user: 1 } }, 'and is counted before the page goes');-- p = page({ serverRelease: 'bbbbbbb', releasedAt: stale, hidden: true });-- await p.ctx.OVRelease.check();-- assert.strictEqual(p.reloads(), 1, 'outside the window and hidden: reloads');++ p = await tab({ server: 'bbbbbbb', releasedAt: stale });++ assert.strictEqual(p.reloads, 1, 'outside the window and hidden: reloads');++ assert.deepStrictEqual(p.beacons[0].body.counts, { reloaded: { window: 1 } });++ assert.strictEqual(p.beacons[0].to, '/release-metrics');-- p = page({ serverRelease: 'bbbbbbb', releasedAt: fresh, min: 'bbbbbbb', hidden: true });-- await p.ctx.OVRelease.check();-- assert.strictEqual(p.reloads(), 1, 'server requires the new release: reloads when safe');++ p = await tab({ server: 'bbbbbbb', min: 'bbbbbbb' });++ assert.strictEqual(p.reloads, 1, 'server requires the new release: reloads when safe');++ assert.deepStrictEqual(p.metrics(), { reloaded: { required: 1 } });-- p = page({ serverRelease: 'bbbbbbb', releasedAt: stale, hidden: true, active: { tagName: 'TEXTAREA' } });-- await p.ctx.OVRelease.check();-- assert.strictEqual(p.reloads(), 0, 'never while a text field has focus');++ p = await tab({ server: 'bbbbbbb', releasedAt: stale, html: HTML('', '<textarea></textarea>'), focus: 'textarea' });++ assert.strictEqual(p.reloads, 0, 'never while a text field has focus');++ p.tick(); p.tick();++ assert.deepStrictEqual(p.metrics(), { deferred: { typing: 1 } }, 'a deferral is counted once per release and reason');++ p.blur(); p.tick();++ assert.strictEqual(p.reloads, 1, 'and reloads once the field lets go');-- p = page({ serverRelease: 'bbbbbbb', releasedAt: stale, hidden: true, protectedFn: () => true });-- await p.ctx.OVRelease.check();-- assert.strictEqual(p.reloads(), 0, 'never during a protected session (upload, call, broadcast)');++ p = await tab({ server: 'bbbbbbb', releasedAt: stale, protectedFn: () => true });++ assert.strictEqual(p.reloads, 0, 'never during a protected session (upload, call, broadcast)');++ assert.deepStrictEqual(p.metrics(), { deferred: { protected: 1 } });++ p = await tab({ server: 'bbbbbbb', releasedAt: stale, html: HTML('', '<form data-dirty="true"></form>') });++ assert.deepStrictEqual([p.reloads, p.metrics()], [0, { deferred: { dirty: 1 } }], 'never with unsent form input');++++ p = await tab({ server: 'bbbbbbb', releasedAt: stale, html: HTML('', '<video></video>') });++ assert.deepStrictEqual([p.reloads, p.metrics()], [0, { deferred: { media: 1 } }], 'never while a stream is playing (a linkedom <video> is not paused)');{-- const playing = page({ serverRelease: 'bbbbbbb', releasedAt: stale, hidden: true });-- playing.ctx.document.querySelectorAll = (sel) => (sel === 'video, audio' ? [{ paused: false, ended: false }] : []);-- await playing.ctx.OVRelease.check();-- assert.strictEqual(playing.reloads(), 0, 'never while a stream is playing');-- const capture = page({ serverRelease: 'bbbbbbb', releasedAt: stale, hidden: true });-- capture.ctx.document.querySelectorAll = () => [{ paused: true, ended: false, srcObject: { getTracks: () => [{ readyState: 'live' }] } }];-- await capture.ctx.OVRelease.check();-- assert.strictEqual(capture.reloads(), 0, 'never while the camera/mic is live');++ // A paused <video> with a live camera track is a capture: served stale, it waits.++ const html = HTML('', '<video></video>');++ const manifest = { service: 'test', release: 'bbbbbbb', released_at: stale, min_client_release: null, mixed_version_window_hours: 24 };++ const page = await openPage({ url: 'https://site.test/', html, hidden: true, serve: () => ({ json: manifest }) });++ const vid = page.document.querySelector('video');++ vid.paused = true; vid.srcObject = { getTracks: () => [{ readyState: 'live' }] };++ await page.settle();++ assert.deepStrictEqual([page.reloads, page.metrics()], [0, { deferred: { capture: 1 } }], 'never while the camera/mic is live');}-- p = page({ serverRelease: 'bbbbbbb', releasedAt: stale, hidden: false });-- await p.ctx.OVRelease.check();-- assert.strictEqual(p.reloads(), 0, 'a visible tab someone just used is not reloaded');++ p = await tab({ server: 'bbbbbbb', releasedAt: stale, hidden: false });++ assert.deepStrictEqual([p.reloads, p.metrics()], [0, { deferred: { active: 1 } }], 'a visible tab someone just used is not reloaded');++ p.setHidden(true);++ assert.strictEqual(p.reloads, 1, 'hiding it is the safe moment');++ assert.deepStrictEqual(p.beacons.map((b) => b.body.counts), [{ deferred: { active: 1 } }, { reloaded: { window: 1 } }], 'hiding sends the deferral, the reload its own count');// Without a meta tag the first /release.json read is the baseline; a later release prompts.{let served = 'ccccccc';-- const listeners = {}; const toasts = [];-- const ctx = {-- console, Date, JSON, Promise, setInterval: () => 1, clearInterval: () => {},-- location: { reload: () => {} }, addEventListener: (t, f) => { listeners[t] = f; },-- OpenVibeUI: { toast: (msg, o) => toasts.push({ msg, o }) },-- fetch: async () => ({ ok: true, json: async () => ({ release: served, released_at: fresh, min_client_release: null, mixed_version_window_hours: 24 }) }),-- document: { hidden: true, activeElement: null, querySelector: () => null, querySelectorAll: () => [], addEventListener: () => {} },-- };-- ctx.window = ctx; ctx.globalThis = ctx; vm.createContext(ctx);-- vm.runInContext(fs.readFileSync(path.join(__dirname, '..', 'release-watch.js'), 'utf8'), ctx);-- await new Promise((r) => setImmediate(r));-- assert.strictEqual(ctx.OVRelease.current, 'ccccccc', 'baseline learned from /release.json');-- await ctx.OVRelease.check();-- assert.strictEqual(toasts.length, 0);++ const page = await openPage({ url: 'https://site.test/', html: '<html><head></head><body></body></html>', hidden: true, serve: () => ({ json: { service: 'test', release: served, released_at: fresh, min_client_release: null, mixed_version_window_hours: 24 } }) });++ await page.settle();++ assert.strictEqual(page.release.current, 'ccccccc', 'baseline learned from /release.json');++ await page.release.check(); await page.settle();++ assert.strictEqual(page.toasts.length, 0);served = 'ddddddd';-- await ctx.OVRelease.check();-- assert.strictEqual(toasts.length, 1, 'a later release prompts');++ await page.release.check(); await page.settle();++ assert.strictEqual(page.toasts.length, 1, 'a later release prompts');++ assert.strictEqual(page.beacons.length, 0, 'no metrics URL configured: nothing is sent');++ }++ {++ const page = await openPage({ url: 'https://site.test/', html: '<html><head></head><body></body></html>', hidden: true, serve: () => ({ status: 404 }) });++ await page.settle();++ page.tick(); await page.settle();++ assert.strictEqual(page.requests.length, 1, 'a site without /release.json is asked once, then left alone');}{-- let calls = 0;-- const ctx = {-- console, Date, JSON, Promise, setInterval: () => 1, clearInterval: () => {}, location: { reload: () => {} }, addEventListener: () => {},-- fetch: async () => { calls++; return { ok: false, json: async () => null }; },-- document: { hidden: true, activeElement: null, querySelector: () => null, querySelectorAll: () => [], addEventListener: () => {} },-- };-- ctx.window = ctx; ctx.globalThis = ctx; vm.createContext(ctx);-- vm.runInContext(fs.readFileSync(path.join(__dirname, '..', 'release-watch.js'), 'utf8'), ctx);-- await new Promise((r) => setImmediate(r));-- assert.strictEqual(calls, 1, 'a site without /release.json is asked once, then left alone');++ // A metrics URL on another origin is never used.++ const page = await tab({ server: 'bbbbbbb', releasedAt: stale, config: { metricsUrl: 'https://elsewhere.test/collect' } });++ assert.strictEqual(page.reloads, 1);++ assert.strictEqual(page.beacons.length, 0);}console.log('release: all checks passed');
@@ -24,7 +24,7 @@ jobs:
@@ -24,7 +24,7 @@ jobs:
git archive --format=tar.gz --prefix=OpenVibe.Shared-ci/ -o "$RUNNER_TEMP/openvibe-shared.tgz" HEAD
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
mkdir "$RUNNER_TEMP/consumer" && cd "$RUNNER_TEMP/consumer" && npm init -y >/dev/null
npm install --no-audit --no-fund "$RUNNER_TEMP/openvibe-shared.tgz"
npm install --no-audit --no-fund "$RUNNER_TEMP/openvibe-shared.tgz"
- node -e "require('openvibe-shared'); require('openvibe-shared/navbar'); require('openvibe-shared/legal'); require('openvibe-shared/metrics'); require('openvibe-shared/ready'); require('openvibe-shared/analytics'); require('openvibe-shared/analytics/prune-cli'); require('openvibe-shared/analytics/event.v1.json'); 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'); require('openvibe-shared/release'); require('openvibe-shared/release-compat'); require('openvibe-shared/analytics'); require('openvibe-shared/analytics/prune-cli'); require('openvibe-shared/analytics/event.v1.json'); 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)"
# Secret scan (gitleaks) and dependency audit, the workflow every OpenVibers repo calls (Track Q).
# Secret scan (gitleaks) and dependency audit, the workflow every OpenVibers repo calls (Track Q).
security:
security:
uses: ./.github/workflows/security.yml
uses: ./.github/workflows/security.yml
@@ -2,8 +2,9 @@
@@ -2,8 +2,9 @@
> Versioned UI, chrome, SEO, legal and release-client packages every OpenVibe site renders.
> Versioned UI, chrome, SEO, legal and release-client packages every OpenVibe site renders.
-**Status:** alpha. The `openvibe-shared` package lives here; the latest tag is v1.3.0 (Wave 2, metrics
-and readiness from Track O). Every deployed consumer installs a tagged release and none keeps a
+**Status:** alpha. The `openvibe-shared` package lives here; the latest tag is v1.4.0, and 1.5.0 (Track R:
+release manifests, in-place updates in open tabs, update metrics, the mixed-version harness) is on
+`main`, untagged. Every deployed consumer installs a tagged release and none keeps a
vendored copy ([docs/migration-plan.md](docs/migration-plan.md) is done): v1.3.0 in Network, Live,
vendored copy ([docs/migration-plan.md](docs/migration-plan.md) is done): v1.3.0 in Network, Live,
Media, Community, Tools, Events, Codes, Wiki, Blog, News, Reviews, Deals, Coupons, Trade, VIP and
Media, Community, Tools, Events, Codes, Wiki, Blog, News, Reviews, Deals, Coupons, Trade, VIP and
Host; v1.2.1 in Tips and OpenRe.Stream; v1.0.0 in Sites. CI failed on the v1.3.0 tag (`1173562`,
Host; v1.2.1 in Tips and OpenRe.Stream; v1.0.0 in Sites. CI failed on the v1.3.0 tag (`1173562`,
@@ -22,8 +23,8 @@ were reconciled first.
@@ -22,8 +23,8 @@ were reconciled first.
| Kind | Files |
| 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` (+ `analytics/{privacy,tracker,retention,schema,event,prune-cli}`), `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 |
+| 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`, `release-watch.js`, `release-update.js`, `openvibe-sw.js` |
+| Node modules (`require('openvibe-shared/<name>')`) | `index` (`.`), `analytics` (+ `analytics/{privacy,tracker,retention,schema,event,prune-cli}`), `app-icon`, `auth-client`, `brand`, `builtin-themes`, `chrome-ssr`, `legal`, `middleware`, `notifications`, `seo`, `theme-sync`, `url-resolver`, `files`, `release`, `release-compat` (tests), `metrics`, `ready`; `footer` and `icons` (= `ov-icons.js`) work on both sides, and `release-update` gives Node its pure `plan()` |
| Schemas | `docs/schemas/analytics-event.v1.json` (`analytics/event.v1`, exported as `openvibe-shared/analytics/event.v1.json`) |
| Schemas | `docs/schemas/analytics-event.v1.json` (`analytics/event.v1`, exported as `openvibe-shared/analytics/event.v1.json`) |
| 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` |
| 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` |
@@ -75,6 +76,67 @@ A check fails when it throws, times out, returns `false`, a string (the reason)
@@ -75,6 +76,67 @@ A check fails when it throws, times out, returns `false`, a string (the reason)
`{ ok: false, error }`. Name a dependency required only when the service really cannot serve
`{ ok: false, error }`. Name a dependency required only when the service really cannot serve
without it.
without it.
+### Releases: `/release.json`, open tabs and the mixed-version test (ADR-016, Track R)
+
+```js
+const release = require('openvibe-shared/release').createRelease({
+ service: 'community',
+ root: path.join(__dirname, '..'),
+ components: {
+ styles: { kind: 'style', assets: ['/css/app.css', '/css/pages.css'] }, // swapped in place
+ pages: { kind: 'content', files: ['server/web/pages'] }, // regions re-fetched in place
+ shell: { kind: 'script', assets: ['/js/app.js'], files: ['server/web/layout.js', 'public/index.html'] },
+ },
+ contracts: { 'community.web-api': { version: '1.4.0', accepts: '^1.0.0' } }, // what the pages call
+ schemaGeneration: () => migrations.generation(), // optional
+});
+const m = require('openvibe-shared/metrics').instrument(app, { service: 'community', release: release.release });
+release.mount(app, { registry: m.registry }); // GET /release.json, POST /release-metrics → /metrics
+```
+
+- **Components.** `style` (stylesheets, `assets` are URL paths under `publicDir`, default
+ `root/public`), `content` (server-rendered regions), `script` (code the page runs) and `server`
+ (code only the server runs). A component's version is the hash of its files. `shell` is the
+ catch-all `script` component: list in it **every client-facing file no other component lists**
+ (layout templates, page scripts). An undeclared shell is versioned by the release, so every
+ release reloads, which is the 1.4.0 behaviour. The asset map's URLs are `<path>?v=<12 hex of
+ sha256>`, which matches Live's `server/web/assets.js`; pass `assetUrl(path, hash)` for another
+ scheme.
+- **Contract ranges.** `version` is what this release speaks. `accepts` is the range it still
+ serves from the other side (`^1.0.0` → `>=1.0.0 <2.0.0`); keep it wide enough for the previous
+ release, which is the 24-hour window. `role: 'consumes'` marks another service's contract that
+ this one calls.
+- **What is served** is limited to the fields your installed openvibe-contracts
+ `registry.release-manifest` schema declares: 1.0.0 until you pin openvibe-contracts ≥ v0.31.0.
+ `release.validate()` checks the served manifest with your contracts (put it in a test), and
+ `release.full()` is everything. A static-only release switch without a restart: pass
+ `recheckMs`, or call `release.refresh()`.
+- **Markup for in-place content**: `<main data-ov-content="pages" data-ov-rev="<revision>">…</main>`.
+ The tab re-fetches the page URL (or `data-ov-src`) and takes the element with the same
+ `data-ov-content`. Scripts and inline handlers are dropped, so rebind on `ov:content-updated`.
+ A stylesheet whose URL path is not the asset's logical path needs `data-ov-asset="/css/app.css"`.
+ Mark anything that must not be replaced with `data-ov-protected`.
+- **Metrics (D46)**: `release_client_updates_total{outcome,reason}`, where outcome is `applied`,
+ `reloaded`, `deferred` or `failed`. The tab finds the endpoint in the manifest's `metrics_url`
+ (set by `mount`), the meta tag's `data-metrics`, or `OpenVibeNavbar.init({ releaseWatch:
+ { metricsUrl } })`.
+
+Mixed-version test: capture the previous release's `/release.json` (its `full()` form) and the
+calls its pages make as fixtures, then:
+
+```js
+const compat = require('openvibe-shared/release-compat');
+await compat.assertMixedVersion({ releases: [
+ { name: 'N-1', manifest: require('./fixtures/release-prev.json'), client: compat.replay(require('./fixtures/calls-prev.json')) },
+ { name: 'N', manifest: release.full(), server: async () => ({ url, close }), client: compat.replay(calls) },
+] });
+```
+
+It fails when adjacent releases' ranges don't overlap both ways, when a pair the manifests call
+compatible fails against the real server, and when an incompatible pair would not make the tab
+reload. `compat.openPage({ html, serve })` runs release-watch in a linkedom page (install
+`linkedom` as a devDependency) for in-place tests of your own markup.
+
### Analytics (server, ADR-021)
### Analytics (server, ADR-021)
```js
```js
@@ -151,7 +213,12 @@ node scripts/build-theme-loader.js
@@ -151,7 +213,12 @@ node scripts/build-theme-loader.js
Tests are plain Node with stubbed browser globals. Some tests guard the release:
Tests are plain Node with stubbed browser globals. Some tests guard the release:
-- **navbar.js size budget**: 29 KB brotli (`test/nav-icons.test.js`).
+- **navbar.js size budget**: 29 KB brotli (`test/nav-icons.test.js`). release-watch.js and
+ release-update.js: 3.5 KB each (`test/release-update.test.js`).
+- **Releases (ADR-016)**: the manifest validates against the installed openvibe-contracts (and
+ the 1.1.0 schema), in-place updates are transactional and never replace a region in use, the
+ reload rules hold, and the mixed-version matrix runs against real servers
+ (`test/release*.test.js`).
- **Built-in icons**: every icon the default navbar renders must be built in and drawn with no
- **Built-in icons**: every icon the default navbar renders must be built in and drawn with no
fetch.
fetch.
- **Generated blocks**: the generated navbar and theme-loader blocks must be up to date.
- **Generated blocks**: the generated navbar and theme-loader blocks must be up to date.
@@ -29,6 +29,7 @@ const BROWSER = Object.freeze([
@@ -29,6 +29,7 @@ const BROWSER = Object.freeze([
'island.js', // activity island
'island.js', // activity island
'tooltip.js',
'tooltip.js',
'release-watch.js', // keeps open tabs on a supported release (ADR-016), loaded after first paint
'release-watch.js', // keeps open tabs on a supported release (ADR-016), loaded after first paint
+ 'release-update.js', // plans a new release and applies style/content in place; release-watch loads it on demand
'openvibe-sw.js', // Web Push service worker
'openvibe-sw.js', // Web Push service worker
]);
]);
@@ -408,11 +408,14 @@
@@ -408,11 +408,14 @@
function upgradeIconsWhenFontsReady() { /* icons no longer depend on webfonts */ }
function upgradeIconsWhenFontsReady() { /* icons no longer depend on webfonts */ }
// Release watch (ADR-016): a few seconds after the page settles, load release-watch.js from the
// Release watch (ADR-016): a few seconds after the page settles, load release-watch.js from the
- // same place as this navbar, so every site that serves /release.json gets update prompts with
- // no page changes. Opt out with init({ releaseWatch: false }).
+ // same place as this navbar, so every site that serves /release.json keeps open tabs current with
+ // no page changes. Opt out with init({ releaseWatch: false }); an object is its OVReleaseConfig
+ // ({ metricsUrl, url, inPlace }).
function loadReleaseWatch() {
function loadReleaseWatch() {
try {
try {
- if (_config.releaseWatch === false || root.OVRelease || document.querySelector('script[src*="release-watch.js"]')) return;
+ const rw = _config.releaseWatch;
+ if (rw === false || root.OVRelease || document.querySelector('script[src*="release-watch.js"]')) return;
+ if (rw && typeof rw === 'object' && !root.OVReleaseConfig) root.OVReleaseConfig = rw;
const m = /^(https?:\/\/[^?#]*\/)navbar\.js(?:[?#]|$)/.exec(_selfSrc);
const m = /^(https?:\/\/[^?#]*\/)navbar\.js(?:[?#]|$)/.exec(_selfSrc);
const sc = document.createElement('script'); sc.src = `${m ? m[1] : 'https://openvibe.network/shared/'}release-watch.js`; sc.async = true;
const sc = document.createElement('script'); sc.src = `${m ? m[1] : 'https://openvibe.network/shared/'}release-watch.js`; sc.async = true;
try { sc.fetchPriority = 'low'; } catch { /* */ }
try { sc.fetchPriority = 'low'; } catch { /* */ }
@@ -13,7 +13,9 @@
@@ -13,7 +13,9 @@
},
},
"devDependencies": {
"devDependencies": {
"better-sqlite3": "^11.10.0",
"better-sqlite3": "^11.10.0",
- "express": "^4.22.3"
+ "express": "^4.22.3",
+ "linkedom": "^0.18.13",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.30.2"
}
}
},
},
"node_modules/accepts": {
"node_modules/accepts": {
@@ -30,6 +32,41 @@
@@ -30,6 +32,41 @@
"node": ">= 0.6"
"node": ">= 0.6"
}
}
},
},
+ "node_modules/ajv": {
+ "version": "8.20.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
+ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.3",
+ "fast-uri": "^3.0.1",
+ "json-schema-traverse": "^1.0.0",
+ "require-from-string": "^2.0.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/ajv-formats": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
+ "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ajv": "^8.0.0"
+ },
+ "peerDependencies": {
+ "ajv": "^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "ajv": {
+ "optional": true
+ }
+ }
+ },
"node_modules/array-flatten": {
"node_modules/array-flatten": {
"version": "1.1.1",
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
@@ -117,6 +154,20 @@
@@ -117,6 +154,20 @@
"npm": "1.2.8000 || >= 1.4.16"
"npm": "1.2.8000 || >= 1.4.16"
}
}
},
},
+ "node_modules/boolbase": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-2.0.0.tgz",
+ "integrity": "sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ },
"node_modules/buffer": {
"node_modules/buffer": {
"version": "5.7.1",
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
@@ -236,6 +287,48 @@
@@ -236,6 +287,48 @@
"dev": true,
"dev": true,
"license": "MIT"
"license": "MIT"
},
},
+ "node_modules/css-select": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/css-select/-/css-select-7.0.0.tgz",
+ "integrity": "sha512-snmjEVXy+1LnwXdxhYvTMj1d9tOh4HxkA1YmoayVBeeyR2C14Pum7fcxJIm4SswYspVy866eYNwlH6xC3/VH5g==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "boolbase": "^2.0.0",
+ "css-what": "^8.0.0",
+ "domhandler": "^6.0.1",
+ "domutils": "^4.0.2",
+ "nth-check": "^3.0.1"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ },
+ "node_modules/css-what": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/css-what/-/css-what-8.0.0.tgz",
+ "integrity": "sha512-DH0Bqq3DNp5tdOReuNyAA+Ev4Y2GS5FMbZpeTLP6C4CDi0h5nL0BmUPChXw3o/qbHLDWHl49sbNqQVY7bMSDdw==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ },
+ "node_modules/cssom": {
+ "version": "0.5.0",
+ "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz",
+ "integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/debug": {
"node_modules/debug": {
"version": "2.6.9",
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@@ -310,6 +403,77 @@
@@ -310,6 +403,77 @@
"node": ">=8"
"node": ">=8"
}
}
},
},
+ "node_modules/dom-serializer": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz",
+ "integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "^3.0.0",
+ "domhandler": "^6.0.0",
+ "entities": "^8.0.0"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
+ }
+ },
+ "node_modules/domelementtype": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz",
+ "integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
+ "node_modules/domhandler": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-6.0.1.tgz",
+ "integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "domelementtype": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/fb55/domhandler?sponsor=1"
+ }
+ },
+ "node_modules/domutils": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/domutils/-/domutils-4.0.2.tgz",
+ "integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "dom-serializer": "^3.0.0",
+ "domelementtype": "^3.0.0",
+ "domhandler": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/fb55/domutils?sponsor=1"
+ }
+ },
"node_modules/dunder-proto": {
"node_modules/dunder-proto": {
"version": "1.0.1",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -361,6 +525,19 @@
@@ -361,6 +525,19 @@
"once": "^1.4.0"
"once": "^1.4.0"
}
}
},
},
+ "node_modules/entities": {
+ "version": "8.1.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz",
+ "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
"node_modules/es-define-property": {
"node_modules/es-define-property": {
"version": "1.0.1",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@@ -468,6 +645,30 @@
@@ -468,6 +645,30 @@
"url": "https://opencollective.com/express"
"url": "https://opencollective.com/express"
}
}
},
},
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-uri": {
+ "version": "3.1.8",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
+ "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
"node_modules/file-uri-to-path": {
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
@@ -616,6 +817,118 @@
@@ -616,6 +817,118 @@
"node": ">= 0.4"
"node": ">= 0.4"
}
}
},
},
+ "node_modules/html-escaper": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz",
+ "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/htmlparser2": {
+ "version": "10.1.0",
+ "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
+ "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
+ "dev": true,
+ "funding": [
+ "https://github.com/fb55/htmlparser2?sponsor=1",
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3",
+ "domutils": "^3.2.2",
+ "entities": "^7.0.1"
+ }
+ },
+ "node_modules/htmlparser2/node_modules/dom-serializer": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
+ "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.2",
+ "entities": "^4.2.0"
+ },
+ "funding": {
+ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
+ }
+ },
+ "node_modules/htmlparser2/node_modules/dom-serializer/node_modules/entities": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
+ "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/htmlparser2/node_modules/domelementtype": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
+ "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/htmlparser2/node_modules/domhandler": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
+ "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "domelementtype": "^2.3.0"
+ },
+ "engines": {
+ "node": ">= 4"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domhandler?sponsor=1"
+ }
+ },
+ "node_modules/htmlparser2/node_modules/domutils": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
+ "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "dom-serializer": "^2.0.0",
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domutils?sponsor=1"
+ }
+ },
+ "node_modules/htmlparser2/node_modules/entities": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
+ "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
"node_modules/http-errors": {
"node_modules/http-errors": {
"version": "2.0.1",
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -695,6 +1008,13 @@
@@ -695,6 +1008,13 @@
"node": ">= 0.10"
"node": ">= 0.10"
}
}
},
},
+ "node_modules/json-schema-traverse": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/jsonwebtoken": {
"node_modules/jsonwebtoken": {
"version": "9.0.3",
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
@@ -738,6 +1058,31 @@
@@ -738,6 +1058,31 @@
"safe-buffer": "^5.0.1"
"safe-buffer": "^5.0.1"
}
}
},
},
+ "node_modules/linkedom": {
+ "version": "0.18.13",
+ "resolved": "https://registry.npmjs.org/linkedom/-/linkedom-0.18.13.tgz",
+ "integrity": "sha512-ES/o9qotMpzpN2MHs+Iq/JcVoOj8Fa5wiQYrTdFpvAnwXL0g66XHHUc9WUMk6nAlBtGsFQ24ne+SYnvnaQ2FSw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "css-select": "^7.0.0",
+ "cssom": "^0.5.0",
+ "html-escaper": "^3.0.3",
+ "htmlparser2": "^10.1.0",
+ "uhyphen": "^0.2.0"
+ },
+ "engines": {
+ "node": ">=16"
+ },
+ "peerDependencies": {
+ "canvas": ">= 2"
+ },
+ "peerDependenciesMeta": {
+ "canvas": {
+ "optional": true
+ }
+ }
+ },
"node_modules/lodash.includes": {
"node_modules/lodash.includes": {
"version": "4.3.0",
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
@@ -922,6 +1267,23 @@
@@ -922,6 +1267,23 @@
"node": ">=10"
"node": ">=10"
}
}
},
},
+ "node_modules/nth-check": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-3.0.1.tgz",
+ "integrity": "sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "boolbase": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/fb55/nth-check?sponsor=1"
+ }
+ },
"node_modules/object-inspect": {
"node_modules/object-inspect": {
"version": "1.13.4",
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@ -958,6 +1320,23 @@
@@ -958,6 +1320,23 @@
"wrappy": "1"
"wrappy": "1"
}
}
},
},
+ "node_modules/openvibe-contracts": {
+ "version": "0.30.2",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.30.2",
+ "integrity": "sha512-R01NgRY5V/GEYWkWUyxfl1MifSQb4dfJy11VS9RP+wsWedh2Crr9/IL7yGSn1PytwTKKL+tQl/r4ppGfH3TwLg==",
+ "dev": true,
+ "license": "AGPL-3.0-only",
+ "dependencies": {
+ "ajv": "^8.17.1",
+ "ajv-formats": "^3.0.1"
+ },
+ "bin": {
+ "openvibe-contracts-check": "bin/check-service.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/parseurl": {
"node_modules/parseurl": {
"version": "1.3.3",
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -1106,6 +1485,16 @@
@@ -1106,6 +1485,16 @@
"node": ">= 6"
"node": ">= 6"
}
}
},
},
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/safe-buffer": {
"node_modules/safe-buffer": {
"version": "5.2.1",
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -1413,6 +1802,13 @@
@@ -1413,6 +1802,13 @@
"node": ">= 0.6"
"node": ">= 0.6"
}
}
},
},
+ "node_modules/uhyphen": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/uhyphen/-/uhyphen-0.2.0.tgz",
+ "integrity": "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/unpipe": {
"node_modules/unpipe": {
"version": "1.0.0",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
@@ -38,7 +38,9 @@
@@ -38,7 +38,9 @@
"./panels": "./panels.js",
"./panels": "./panels.js",
"./ready": "./ready.js",
"./ready": "./ready.js",
"./release": "./release.js",
"./release": "./release.js",
+ "./release-compat": "./release-compat.js",
"./release-watch": "./release-watch.js",
"./release-watch": "./release-watch.js",
+ "./release-update": "./release-update.js",
"./seo": "./seo.js",
"./seo": "./seo.js",
"./sso-client": "./sso-client.js",
"./sso-client": "./sso-client.js",
"./theme-loader": "./theme-loader.js",
"./theme-loader": "./theme-loader.js",
@@ -72,6 +74,8 @@
@@ -72,6 +74,8 @@
},
},
"devDependencies": {
"devDependencies": {
"better-sqlite3": "^11.10.0",
"better-sqlite3": "^11.10.0",
- "express": "^4.22.3"
+ "express": "^4.22.3",
+ "linkedom": "^0.18.13",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.30.2"
}
}
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,259 @@
@@ -0,0 +1,259 @@
+'use strict';
+// ═══════════════════════════════════════════════════════════════
+// openvibe-shared/release-compat — the mixed-version (N/N-1) test harness (ADR-016, Track R, D45).
+// Node only; services use it in their own tests.
+//
+// const compat = require('openvibe-shared/release-compat');
+// await compat.assertMixedVersion({ releases: [
+// { name: 'N-1', manifest: require('./fixtures/release-prev.json'), client: compat.replay(prevCalls) },
+// { name: 'N', manifest: release.full(), server: startServer, client: compat.replay(calls) },
+// ] });
+//
+// For every page release P and server release S it checks what the release manifests promise:
+// - compatible(P, S): S still accepts every contract P produces and P accepts S's versions
+// (contract_ranges); the same rule release-watch applies in a tab;
+// - when they say compatible and S has a server, P's client runs against it and must succeed. A failure
+// means the manifest claims a compatibility the server does not honour;
+// - when they say incompatible, the tab's plan must be a reload (reason "contract"), never a silent
+// in-place update or nothing;
+// - releases next to each other in `releases` must be compatible both ways (the supported window of
+// ADR-016: the current and the previous release), unless requireAdjacent is false.
+// openPage() runs release-watch.js and release-update.js against an HTML page in linkedom (a
+// devDependency the service installs) with a scripted /release.json, for in-place update tests.
+// ═══════════════════════════════════════════════════════════════
+
+const fs = require('fs');
+const path = require('path');
+const vm = require('vm');
+const { plan, compatible, satisfies } = require('./release-update');
+const { normalizeRange, compareVersions, contractRanges } = require('./release');
+
+/** Service to service: every contract `consumer` consumes is produced by `producer`, both ranges holding. */
+function consumerCompatible(consumer, producer) {
+ const problems = [];
+ const mine = (consumer && consumer.contract_ranges) || {};
+ const theirs = (producer && producer.contract_ranges) || {};
+ for (const [id, a] of Object.entries(mine)) {
+ if (a.role !== 'consumes') continue;
+ const b = theirs[id];
+ if (!b || b.role === 'consumes') problems.push(`${id}: ${producer && producer.service} does not produce it`);
+ else if (!satisfies(a.version, b.accepts)) problems.push(`${id}: ${producer.service} accepts ${b.accepts}, ${consumer.service} speaks ${a.version}`);
+ else if (!satisfies(b.version, a.accepts)) problems.push(`${id}: ${consumer.service} accepts ${a.accepts}, ${producer.service} speaks ${b.version}`);
+ }
+ return { ok: !problems.length, problems };
+}
+
+/**
+ * Rolling back from release `from` to release `to` keeps data safe when `to`'s code expects a schema
+ * generation `from` still supports: to.schema_generation >= from.schema_compatible_from.
+ */
+function rollbackSafe(from, to) {
+ const need = from && from.schema_compatible_from;
+ const have = to && to.schema_generation;
+ if (need == null || have == null) return { ok: true, unknown: true };
+ return have >= need ? { ok: true } : { ok: false, problem: `${to.release} expects schema generation ${have}; ${from.release} is only readable from ${need}` };
+}
+
+/**
+ * A client made of recorded calls: [{ method?, path, body?, headers?, status?: n | [n…] | (n) => bool,
+ * check?: (json, res) => void }]. Each call must answer 2xx (or `status`) and pass `check`.
+ */
+function replay(calls, { fetchImpl = globalThis.fetch } = {}) {
+ return async (baseUrl) => {
+ for (const c of calls) {
+ const method = (c.method || 'GET').toUpperCase();
+ const headers = { accept: 'application/json', ...(c.body !== undefined ? { 'content-type': 'application/json' } : {}), ...(c.headers || {}) };
+ const res = await fetchImpl(new URL(c.path, baseUrl), { method, headers, body: c.body !== undefined ? JSON.stringify(c.body) : undefined });
+ const want = c.status;
+ const ok = want == null ? res.status >= 200 && res.status < 300
+ : typeof want === 'function' ? want(res.status) : [].concat(want).includes(res.status);
+ const text = await res.text();
+ if (!ok) throw new Error(`${method} ${c.path} answered ${res.status}: ${text.slice(0, 200)}`);
+ if (c.check) {
+ let json = null;
+ try { json = text ? JSON.parse(text) : null; } catch { throw new Error(`${method} ${c.path} did not answer JSON`); }
+ await c.check(json, res);
+ }
+ }
+ };
+}
+
+/** A manifest for a test release (registry.release-manifest 1.1.0 fields), no git needed. */
+function manifestFor({ service = 'test', release, releasedAt = new Date().toISOString(), windowHours = 24, minClientRelease = null, components = null, assets = null, contracts = null, schemaGeneration = null, schemaCompatibleFrom = null, metricsUrl = null } = {}) {
+ if (!/^[0-9a-f]{7,40}$/.test(String(release || ''))) throw new TypeError('manifestFor: release must be a hex sha');
+ const m = {
+ service, release, released_at: releasedAt, booted_at: releasedAt, contracts_version: null, packages: {},
+ min_client_release: minClientRelease, mixed_version_window_hours: windowHours,
+ };
+ if (components) m.components = components;
+ if (assets) m.assets = assets;
+ if (schemaGeneration != null) { m.schema_generation = schemaGeneration; m.schema_compatible_from = schemaCompatibleFrom == null ? schemaGeneration : schemaCompatibleFrom; }
+ if (contracts) m.contract_ranges = contractRanges(contracts);
+ if (metricsUrl) m.metrics_url = metricsUrl;
+ return m;
+}
+
+/**
+ * Runs the page × server matrix. releases: [{ name, manifest, server?: async () => ({ url, close? }),
+ * client?: async (url) => void }], oldest first. Returns rows { page, server, adjacent, compatible,
+ * problems, action, reason, ran, ok, error }.
+ */
+async function runMixedVersion({ releases, now = Date.now(), requireAdjacent = true } = {}) {
+ if (!Array.isArray(releases) || releases.length < 2) throw new TypeError('runMixedVersion: at least two releases');
+ const started = new Map();
+ const serverOf = async (r) => {
+ if (!r.server) return null;
+ if (!started.has(r.name)) started.set(r.name, await r.server());
+ return started.get(r.name);
+ };
+ const rows = [];
+ try {
+ for (let i = 0; i < releases.length; i++) {
+ for (let j = 0; j < releases.length; j++) {
+ if (i === j) continue;
+ const P = releases[i]; const S = releases[j];
+ const c = compatible(P.manifest, S.manifest);
+ const p = plan(P.manifest, S.manifest, now);
+ const row = { page: P.name, server: S.name, adjacent: Math.abs(i - j) === 1, compatible: c.ok, problems: c.problems, action: p.action, reason: p.reason || null, ran: false, ok: true, error: null };
+ if (!c.ok) {
+ if (p.action !== 'reload' || p.reason !== 'contract') { row.ok = false; row.error = `incompatible, but the tab would ${p.action}`; }
+ if (row.adjacent && requireAdjacent) { row.ok = false; row.error = `adjacent releases must be compatible: ${c.problems.join('; ')}`; }
+ } else if (P.client) {
+ const s = await serverOf(S);
+ if (s) {
+ row.ran = true;
+ try { await P.client(s.url); } catch (err) { row.ok = false; row.error = `declared compatible, but the ${P.name} client failed against the ${S.name} server: ${err && err.message}`; }
+ }
+ }
+ rows.push(row);
+ }
+ }
+ } finally {
+ for (const s of started.values()) { try { if (s && s.close) await s.close(); } catch { /* */ } }
+ }
+ return rows;
+}
+
+function formatMatrix(rows) {
+ const head = ['page', 'server', 'compatible', 'tab', 'ran', 'ok'];
+ const body = rows.map((r) => [r.page, r.server, r.compatible ? 'yes' : 'no', r.action + (r.reason ? ` (${r.reason})` : ''), r.ran ? 'yes' : 'no', r.ok ? 'ok' : `FAIL: ${r.error}`]);
+ const w = head.map((h, i) => Math.max(h.length, ...body.map((b) => String(b[i]).length)));
+ return [head, ...body].map((r) => r.map((c, i) => (i === r.length - 1 ? String(c) : String(c).padEnd(w[i]))).join(' ')).join('\n');
+}
+
+async function assertMixedVersion(opts) {
+ const rows = await runMixedVersion(opts);
+ if (rows.some((r) => !r.ok)) {
+ const err = new Error(`mixed-version check failed\n${formatMatrix(rows)}`);
+ err.rows = rows;
+ throw err;
+ }
+ return rows;
+}
+
+// ── openPage: release-watch in a linkedom page ────────────────
+
+const WATCH_SRC = () => fs.readFileSync(path.join(__dirname, 'release-watch.js'), 'utf8');
+const UPDATE_SRC = () => fs.readFileSync(path.join(__dirname, 'release-update.js'), 'utf8');
+
+function toResponse(out) {
+ if (out && typeof out.status === 'number' && typeof out.text === 'function') return out; // a Response
+ const o = out || { status: 404 };
+ const body = o.json !== undefined ? JSON.stringify(o.json) : o.body != null ? String(o.body) : o.text != null ? String(o.text) : '';
+ return new Response(o.status === 204 ? null : body, { status: o.status || 200, headers: { 'content-type': o.json !== undefined ? 'application/json' : 'text/html' } });
+}
+
+/**
+ * A page at `url` rendered from `html`, running release-watch.js. `serve(url, init)` answers every fetch
+ * the scripts make (return { json } | { body } | { status } | a Response). Options: config
+ * (OVReleaseConfig), updateScript: 'lazy' (default: loaded when release-watch asks) | 'fail', hidden,
+ * protectedFn (window.OVProtected).
+ * Returns controls to drive it: settle(), settleStyles(ok), focus(el), setHidden(bool), tick(), and what
+ * it did: reloads, beacons, toasts, requests.
+ */
+async function openPage({ url = 'https://site.test/', html, serve, config = null, updateScript = 'lazy', protectedFn = null, hidden: startHidden = false } = {}) {
+ let linkedom;
+ try { linkedom = require('linkedom'); } catch { throw new Error('openPage needs linkedom (npm i -D linkedom)'); }
+ const { window: lw, document } = linkedom.parseHTML(html || '<!doctype html><html><head></head><body></body></html>');
+ const origin = new URL(url).origin;
+ const out = { reloads: 0, beacons: [], toasts: [], requests: [], intervals: [] };
+ const timers = new Map(); let tid = 0;
+ let focused = null; let hidden = !!startHidden;
+ Object.defineProperty(document, 'activeElement', { configurable: true, get: () => focused || document.body });
+ Object.defineProperty(document, 'hidden', { configurable: true, get: () => hidden });
+ Object.defineProperty(document, 'currentScript', { configurable: true, get: () => ({ src: `${origin}/shared/release-watch.js` }) });
+ const listeners = {};
+ const ctx = {
+ document, console, URL, JSON, Promise,
+ DOMParser: linkedom.DOMParser, CustomEvent: lw.CustomEvent || linkedom.CustomEvent, Event: lw.Event || linkedom.Event,
+ location: { href: url, origin, reload: () => { out.reloads++; } },
+ navigator: { sendBeacon: (to, body) => { out.beacons.push({ to, body: JSON.parse(body) }); return true; } },
+ OpenVibeUI: { toast: (msg, o) => out.toasts.push({ msg, o }) },
+ setInterval: (f) => { out.intervals.push(f); return out.intervals.length; },
+ clearInterval: () => {},
+ setTimeout: (f) => { timers.set(++tid, f); return tid; },
+ clearTimeout: (id) => { timers.delete(id); },
+ addEventListener: (t, f) => { (listeners[t] = listeners[t] || []).push(f); },
+ removeEventListener: () => {},
+ dispatchEvent: (e) => { for (const f of listeners[e.type] || []) f(e); return true; },
+ scrollBy: () => {},
+ fetch: async (u, init = {}) => {
+ const abs = new URL(String(u), url).href;
+ out.requests.push({ url: abs, method: (init.method || 'GET').toUpperCase() });
+ return toResponse(await serve(abs, init));
+ },
+ };
+ if (config) ctx.OVReleaseConfig = config;
+ if (protectedFn) ctx.OVProtected = protectedFn;
+ ctx.window = ctx; ctx.globalThis = ctx;
+ vm.createContext(ctx);
+ // A script element release-watch appends is "loaded": release-update.js runs, then onload fires.
+ const append = document.head.appendChild.bind(document.head);
+ document.head.appendChild = (node) => {
+ const r = append(node);
+ const src = node && node.tagName === 'SCRIPT' ? String(node.src || node.getAttribute('src') || '') : '';
+ if (src) {
+ setImmediate(() => {
+ if (updateScript === 'fail' || !/release-update\.js/.test(src)) { if (node.onerror) node.onerror(new ctx.Event('error')); return; }
+ vm.runInContext(UPDATE_SRC(), ctx, { filename: 'release-update.js' });
+ if (node.onload) node.onload(new ctx.Event('load'));
+ });
+ }
+ return r;
+ };
+ const initialLinks = new Set(document.querySelectorAll('link'));
+ const settled = new Set();
+ vm.runInContext(WATCH_SRC(), ctx, { filename: 'release-watch.js' });
+ const page = {
+ window: ctx, document, ...out,
+ get reloads() { return out.reloads; },
+ get release() { return ctx.OVRelease; },
+ /** The tab's outcome counts so far (a plain copy). */
+ metrics() { return JSON.parse(JSON.stringify(ctx.OVRelease ? ctx.OVRelease.state().metrics : {})); },
+ async settle(turns = 8) { for (let i = 0; i < turns; i++) await new Promise((r) => setImmediate(r)); },
+ /** Fires load (or error) on every stylesheet release-update inserted and that has not loaded yet. */
+ settleStyles(ok = true) {
+ let n = 0;
+ for (const l of document.querySelectorAll('link')) {
+ if (initialLinks.has(l) || settled.has(l)) continue;
+ settled.add(l); n++;
+ l.dispatchEvent(new ctx.Event(ok ? 'load' : 'error'));
+ }
+ return n;
+ },
+ /** Runs the timeouts still pending (release-update's 15 s stylesheet timeout). */
+ fireTimeouts() { const t = [...timers.values()]; timers.clear(); t.forEach((f) => f()); return t.length; },
+ focus(el) { focused = el; },
+ blur() { focused = null; },
+ setHidden(h) { hidden = !!h; document.dispatchEvent(new ctx.Event('visibilitychange')); },
+ tick() { out.intervals.forEach((f) => f()); },
+ stop() { if (ctx.OVRelease) ctx.OVRelease.stop(); },
+ };
+ return page;
+}
+
+module.exports = {
+ plan, compatible, satisfies, normalizeRange, compareVersions,
+ consumerCompatible, rollbackSafe, replay, manifestFor,
+ runMixedVersion, assertMixedVersion, formatMatrix, openPage,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,153 @@
@@ -0,0 +1,153 @@
+/*
+ * openvibe-shared/release-update.js — plans a new release for an open tab and applies it in place
+ * (ADR-016, Track R). release-watch.js loads it from its own directory when /release.json reports a
+ * release with components or contract ranges; pages never load it themselves. In Node, require() returns
+ * the pure plan(), compatible() and satisfies() (openvibe-shared/release-compat builds on them).
+ *
+ * In place is transactional: every new stylesheet loads and every region is fetched before anything old
+ * is removed; a rejection (style, style-timeout, content, origin) leaves the page as it was.
+ * styles(ids, next) new <link>s for the changed style components' assets, loaded beside the old ones
+ * regions(ids) fresh [data-ov-content="<component>"] HTML (from data-ov-src, default this URL),
+ * without scripts, frames or inline handlers; unchanged data-ov-rev is skipped
+ * commit(list, next, busy, record) replaces the regions busy() allows, returns those still waiting
+ * apply(plan, next, busy, record) all of the above for an in-place plan
+ */
+(function (root) {
+ 'use strict';
+ const IN_PLACE = { style: 1, content: 1, server: 1 };
+ const ver = (v) => { const m = /^(\d+)\.(\d+)\.(\d+)$/.exec(String(v || '')); return m ? [+m[1], +m[2], +m[3]] : null; };
+ const cmp = (a, b) => { for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1; return 0; };
+ /** version inside ">=a.b.c <x.y.z", the only form a manifest writes. */
+ function satisfies(version, range) {
+ const v = ver(version); const m = /^>=(\S+) <(\S+)$/.exec(String(range || ''));
+ const lo = m && ver(m[1]); const hi = m && ver(m[2]);
+ return !!(v && lo && hi && cmp(v, lo) >= 0 && cmp(v, hi) < 0);
+ }
+ /** Every contract the page's release produces is still accepted by the server, and the reverse. */
+ function compatible(page, server) {
+ const problems = []; const mine = (page && page.contract_ranges) || {}; const theirs = (server && server.contract_ranges) || {};
+ for (const id of Object.keys(mine)) {
+ const a = mine[id]; const b = theirs[id];
+ if (!a || a.role === 'consumes') continue;
+ if (!b || b.role === 'consumes') problems.push(`${id}: the server no longer serves it`);
+ else if (!satisfies(a.version, b.accepts)) problems.push(`${id}: the server accepts ${b.accepts}, the page speaks ${a.version}`);
+ else if (!satisfies(b.version, a.accepts)) problems.push(`${id}: the page accepts ${a.accepts}, the server speaks ${b.version}`);
+ }
+ return { ok: !problems.length, problems };
+ }
+ /**
+ * What a tab on `page` (the manifest of the release it runs) does about `server`:
+ * { action: none | in-place | prompt | reload, reason?: contract | required | window, changed, problems? }.
+ * In place only when every changed component is style, content (same kind on both sides) or server.
+ */
+ function plan(page, server, now) {
+ if (!server || !page || server.release === page.release) return { action: 'none', changed: [] };
+ const c = compatible(page, server);
+ if (!c.ok) return { action: 'reload', reason: 'contract', changed: [], problems: c.problems };
+ const a = page.components; const b = server.components; let changed = null;
+ if (a && b) {
+ let ok = true; changed = [];
+ for (const id of Object.keys(Object.assign({}, a, b))) {
+ const x = a[id]; const y = b[id];
+ if (x && y && x.kind === y.kind && x.version === y.version) continue;
+ const kind = (y || x).kind;
+ changed.push({ id, kind });
+ if (!IN_PLACE[kind] || (x && y ? x.kind !== y.kind : kind !== 'server')) ok = false;
+ }
+ if (ok) return { action: 'in-place', changed };
+ }
+ const windowMs = (Number(server.mixed_version_window_hours) || 0) * 3600e3;
+ if (server.min_client_release === server.release) return { action: 'reload', reason: 'required', changed };
+ if (windowMs > 0 && (now == null ? Date.now() : +now) - Date.parse(server.released_at) > windowMs) return { action: 'reload', reason: 'window', changed };
+ return { action: 'prompt', changed };
+ }
+ if (typeof module !== 'undefined' && module.exports) module.exports = { plan, compatible, satisfies };
+ if (typeof document === 'undefined' || root.OVReleaseUpdate) return;
+
+ const loc = () => root.location.href;
+ const sameOrigin = (u) => { try { return new URL(u, loc()).origin === new URL(loc()).origin; } catch { return false; } };
+
+ function styles(ids, next) {
+ const jobs = [];
+ for (const p of Object.keys(next.assets || {})) {
+ const a = next.assets[p];
+ if (!ids[a.component]) continue;
+ const want = new URL(a.url, loc());
+ for (const l of document.querySelectorAll('link[rel~="stylesheet"]')) {
+ const have = new URL(l.getAttribute('href') || '', loc());
+ if (l.getAttribute('data-ov-asset') !== p && (have.origin !== want.origin || (have.pathname !== want.pathname && have.pathname !== p))) continue;
+ if (have.href === want.href) continue;
+ if (have.origin !== want.origin || !/^https?:$/.test(want.protocol)) return Promise.reject('origin');
+ const n = l.cloneNode(false);
+ n.setAttribute('href', want.href);
+ if (a.integrity) n.setAttribute('integrity', a.integrity); else n.removeAttribute('integrity');
+ jobs.push({ old: l, n });
+ }
+ }
+ return Promise.all(jobs.map((j) => new Promise((ok, no) => {
+ const timer = root.setTimeout(() => no('style-timeout'), 15000);
+ j.n.addEventListener('load', () => { root.clearTimeout(timer); ok(); });
+ j.n.addEventListener('error', () => { root.clearTimeout(timer); no('style'); });
+ j.old.parentNode.insertBefore(j.n, j.old.nextSibling);
+ }))).then(() => jobs, (why) => { jobs.forEach((j) => j.n.remove()); throw why; });
+ }
+
+ async function regions(ids) {
+ const out = []; const docs = {};
+ for (const el of document.querySelectorAll('[data-ov-content]')) {
+ const id = el.getAttribute('data-ov-content');
+ if (!ids[id] || !/^[a-z][a-z0-9-]{0,39}$/.test(id)) continue;
+ const src = new URL(el.getAttribute('data-ov-src') || loc(), loc()); src.hash = '';
+ if (!sameOrigin(src.href)) throw 'origin';
+ if (!docs[src.href]) {
+ const r = await root.fetch(src.href, { cache: 'no-store', credentials: 'same-origin', headers: { Accept: 'text/html' } });
+ if (!r.ok) throw 'content';
+ docs[src.href] = new root.DOMParser().parseFromString(await r.text(), 'text/html');
+ }
+ const doc = docs[src.href];
+ const fresh = doc.querySelector(`[data-ov-content="${id}"]`) || (el.hasAttribute('data-ov-src') ? doc.body : null);
+ if (!fresh) throw 'content';
+ for (const x of fresh.querySelectorAll('script, iframe, object, embed, base, frame, meta')) x.remove();
+ for (const x of [fresh, ...fresh.querySelectorAll('*')]) {
+ for (const at of Array.from(x.attributes)) {
+ if (/^on/i.test(at.name) || (/^(href|src|action|formaction|xlink:href|srcdoc)$/i.test(at.name) && /^\s*(javascript|vbscript|data):/i.test(at.value) && !/^\s*data:image\//i.test(at.value))) x.removeAttribute(at.name);
+ }
+ }
+ const rev = fresh.getAttribute('data-ov-rev');
+ if (rev && rev === el.getAttribute('data-ov-rev')) continue;
+ out.push({ el, fresh, id });
+ }
+ return out;
+ }
+
+ function commit(list, next, busy, record) {
+ return list.filter((w) => {
+ if (!w.el.isConnected) return false;
+ const why = busy(w.el);
+ if (why) { record('deferred', why, true); return true; }
+ const r = w.el.getBoundingClientRect();
+ const st = w.el.scrollTop;
+ w.el.replaceChildren(...Array.from(w.fresh.childNodes).map((n) => document.importNode(n, true)));
+ const rev = w.fresh.getAttribute('data-ov-rev');
+ if (rev) w.el.setAttribute('data-ov-rev', rev);
+ w.el.scrollTop = st;
+ // A region above the viewport that changed height must not move what the reader is looking at.
+ if (r.bottom <= 0 && root.scrollBy) root.scrollBy(0, w.el.getBoundingClientRect().bottom - r.bottom);
+ try { w.el.dispatchEvent(new root.CustomEvent('ov:content-updated', { bubbles: true, detail: { component: w.id, release: next.release } })); } catch { /* */ }
+ return false;
+ });
+ }
+
+ /** Applies an in-place plan; resolves with the regions still waiting for commit(), rejects with a reason. */
+ async function apply(p, next, busy, record) {
+ const ids = {}; const k = {};
+ for (const ch of p.changed) { ids[ch.id] = 1; k[ch.kind] = 1; }
+ const list = k.content ? await regions(ids) : [];
+ const jobs = k.style ? await styles(ids, next) : [];
+ jobs.forEach((j) => j.old.remove());
+ if (jobs.length) try { root.dispatchEvent(new root.CustomEvent('ov:styles-updated', { detail: { release: next.release } })); } catch { /* */ }
+ return commit(list, next, busy, record);
+ }
+
+ root.OVReleaseUpdate = { plan, compatible, satisfies, styles, regions, commit, apply };
+})(typeof window !== 'undefined' ? window : globalThis);
@@ -1,62 +1,147 @@
@@ -1,62 +1,147 @@
/*
/*
- * openvibe-shared/release-watch.js — keeps open tabs on a supported release (ADR-016).
- *
- * The page's release comes from <meta name="ov-release" content="<sha>" data-url="/release.json">
- * (openvibe-shared/release metaTag()) or, without it, from /release.json when the script loads. On focus, when the tab becomes visible, when the network
- * comes back and every 10 minutes, this reads /release.json:
- * - same release: nothing;
- * - newer release: one quiet "new version" toast with a Reload button, never an automatic reload;
- * - the server requires it (min_client_release is the running release) or the new release is older
- * than the mixed-version window: reload automatically, but ONLY when it is safe — the tab is
- * hidden or idle for 2 minutes, no text field has focus, and nothing on the page is protected
- * (a form marked data-dirty="true", an element with data-ov-protected, a playing <video>/<audio>
- * or live camera/mic stream, or window.OVProtected() returning true for uploads and calls).
+ * openvibe-shared/release-watch.js — keeps open tabs on a supported release (ADR-016, Track R).
+ * Reads /release.json on focus, visibility, reconnect and every 10 minutes. A new release with components
+ * or contract ranges is planned by release-update.js (loaded then, from this directory): style, content and
+ * server changes are applied in place; anything else prompts. It reloads by itself only when it must
+ * (min_client_release, the mixed-version window, contracts out of range) and only when safe: hidden or idle
+ * 2 minutes, no focused field, nothing protected (form[data-dirty="true"], [data-ov-protected], playing
+ * media, a live camera/mic, window.OVProtected()). Outcomes are beaconed to the metrics URL (D46).
+ * OVReleaseConfig: { url, metricsUrl, updateUrl, inPlace: false }. See README "Releases".
*/
*/
(function (root) {
(function (root) {
if (typeof document === 'undefined' || root.OVRelease) return;
if (typeof document === 'undefined' || root.OVRelease) return;
- // The page's release: from <meta name="ov-release"> when the server rendered one, otherwise the
- // release /release.json reports when this script first runs (a site without the route stops).
+ const cfg = root.OVReleaseConfig || {};
+ const self = (document.currentScript && document.currentScript.src) || '';
+ const SELF_RE = /release-watch\.js(?:[?#].*)?$/;
+ const updateUrl = cfg.updateUrl || `${SELF_RE.test(self) ? self.replace(SELF_RE, '') : '/shared/'}release-update.js`;
const meta = document.querySelector('meta[name="ov-release"]');
const meta = document.querySelector('meta[name="ov-release"]');
- let current = meta && meta.content ? meta.content : null;
- const url = (meta && meta.getAttribute('data-url')) || '/release.json';
+ let current = (meta && meta.content) || null;
+ const url = cfg.url || (meta && meta.getAttribute('data-url')) || '/release.json';
const IDLE_MS = 2 * 60 * 1000;
const IDLE_MS = 2 * 60 * 1000;
const MIN_GAP_MS = 60 * 1000;
const MIN_GAP_MS = 60 * 1000;
+ let base = null; // the manifest of the release this page runs
+ let latest = null;
let lastCheck = 0;
let lastCheck = 0;
let lastInput = Date.now();
let lastInput = Date.now();
let prompted = false;
let prompted = false;
- let mustReload = false;
- let latest = null;
+ let mustReload = false; let reloadWhy = null;
+ let busyWith = null; // the plan/apply in flight
+ let pending = null; let waiting = []; let kinds = ''; let failedRelease = null; let lib = null;
+ let counts = {}; const totals = {}; const noted = {};
['keydown', 'pointerdown', 'input', 'wheel', 'touchstart'].forEach((t) => root.addEventListener(t, () => { lastInput = Date.now(); }, { passive: true, capture: true }));
['keydown', 'pointerdown', 'input', 'wheel', 'touchstart'].forEach((t) => root.addEventListener(t, () => { lastInput = Date.now(); }, { passive: true, capture: true }));
- function busy() {
+ // ── Update metrics (D46): counted here, sent on hide, after an update and before a reload ──
+ function record(outcome, reason, once) {
+ const k = `${latest && latest.release}|${outcome}|${reason}`;
+ if (once) { if (noted[k]) return; noted[k] = 1; }
+ for (const o of [counts, totals]) { const r = o[outcome] || (o[outcome] = {}); r[reason] = (r[reason] || 0) + 1; }
+ }
+ function flush() {
+ const to = cfg.metricsUrl || (meta && meta.getAttribute('data-metrics')) || (latest && latest.metrics_url);
+ if (!to || !Object.keys(counts).length) return;
+ try {
+ if (new URL(to, root.location.href).origin !== new URL(root.location.href).origin) return;
+ const body = JSON.stringify({ service: latest && latest.service, release: current, to: latest && latest.release, counts });
+ counts = {};
+ const n = root.navigator;
+ if (!(n && n.sendBeacon && n.sendBeacon(to, body))) root.fetch(to, { method: 'POST', body, keepalive: true, credentials: 'same-origin', headers: { 'Content-Type': 'text/plain' } }).catch(() => {});
+ } catch { /* best effort */ }
+ }
+
+ /** Why acting now is not safe, or null. With `scope`, only what is inside that element counts. */
+ function busy(scope) {
+ const d = scope || document;
const a = document.activeElement;
const a = document.activeElement;
- if (a && (a.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(a.tagName || ''))) return true;
- if (document.querySelector('form[data-dirty="true"], [data-ov-protected]')) return true;
+ const typing = a && (a.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(a.tagName || ''));
+ if (scope ? a && a !== document.body && scope.contains(a) : typing) return typing ? 'typing' : 'active';
+ if (d.querySelector('form[data-dirty="true"]') || (scope && scope.matches('form[data-dirty="true"]'))) return 'dirty';
+ if (d.querySelector('[data-ov-protected]') || (scope && scope.hasAttribute('data-ov-protected'))) return 'protected';
// Someone watching or broadcasting: a playing <video>/<audio>, or a live camera/mic stream.
// Someone watching or broadcasting: a playing <video>/<audio>, or a live camera/mic stream.
try {
try {
- for (const m of document.querySelectorAll('video, audio')) {
- if (!m.paused && !m.ended) return true;
+ for (const m of d.querySelectorAll('video, audio')) {
+ if (!m.paused && !m.ended) return 'media';
const so = m.srcObject;
const so = m.srcObject;
- if (so && typeof so.getTracks === 'function' && so.getTracks().some((t) => t.readyState === 'live')) return true;
+ if (so && typeof so.getTracks === 'function' && so.getTracks().some((t) => t.readyState === 'live')) return 'capture';
}
}
- } catch { return true; }
- try { if (typeof root.OVProtected === 'function' && root.OVProtected()) return true; } catch { return true; }
- return !document.hidden && Date.now() - lastInput < IDLE_MS;
+ } catch { return 'media'; }
+ if (scope) return null;
+ try { if (typeof root.OVProtected === 'function' && root.OVProtected()) return 'protected'; } catch { return 'protected'; }
+ return !document.hidden && Date.now() - lastInput < IDLE_MS ? 'active' : null;
}
}
+ function reload(why) { record('reloaded', why); flush(); root.location.reload(); }
function prompt() {
function prompt() {
if (prompted) return;
if (prompted) return;
prompted = true;
prompted = true;
- const text = 'A new version of this page is available.';
- const reload = () => root.location.reload();
if (root.OpenVibeUI && typeof root.OpenVibeUI.toast === 'function') {
if (root.OpenVibeUI && typeof root.OpenVibeUI.toast === 'function') {
- root.OpenVibeUI.toast(text, { type: 'info', title: 'Update ready', ttl: 0, action: { label: 'Reload', onClick: reload } });
+ root.OpenVibeUI.toast('A new version of this page is available.', { type: 'info', title: 'Update ready', ttl: 0, action: { label: 'Reload', onClick: () => reload('user') } });
}
}
}
}
-
function maybeReload() {
function maybeReload() {
- if (mustReload && !busy()) root.location.reload();
+ if (!mustReload) return;
+ const why = busy();
+ if (why) record('deferred', why, true); else reload(reloadWhy);
+ }
+ function emit(name, release) { try { root.dispatchEvent(new root.CustomEvent(name, { detail: { release } })); } catch { /* */ } }
+
+ function loadUpdate() {
+ if (root.OVReleaseUpdate) return Promise.resolve(root.OVReleaseUpdate);
+ return lib || (lib = new Promise((ok, no) => {
+ const sc = document.createElement('script');
+ sc.src = updateUrl; sc.async = true;
+ sc.onload = () => (root.OVReleaseUpdate ? ok(root.OVReleaseUpdate) : no('script'));
+ sc.onerror = () => { lib = null; no('script'); };
+ document.head.appendChild(sc);
+ }));
+ }
+ /** Without release-update.js: the release id, min_client_release and the window only. */
+ function legacy(m) {
+ const w = (Number(m.mixed_version_window_hours) || 0) * 3600e3;
+ if (m.min_client_release === m.release) return { action: 'reload', reason: 'required' };
+ return w > 0 && Date.now() - Date.parse(m.released_at) > w ? { action: 'reload', reason: 'window' } : { action: 'prompt' };
+ }
+ function commitRegions(next) {
+ if (waiting.length) waiting = root.OVReleaseUpdate.commit(waiting, next, busy, record);
+ return !waiting.length;
+ }
+ function adopt(next) {
+ base = next; current = next.release; pending = null; mustReload = false;
+ record('applied', kinds || 'server');
+ flush();
+ emit('ov:release-applied', next.release);
+ }
+
+ async function decide(m) {
+ let x = null;
+ if (m.components || m.contract_ranges) { try { x = await loadUpdate(); } catch { record('failed', 'script'); flush(); } }
+ const page = base || { release: current };
+ let p = x ? x.plan(page, m, Date.now()) : legacy(m);
+ if (p.action === 'in-place' && (cfg.inPlace === false || failedRelease === m.release)) p = x.plan({ release: current, contract_ranges: page.contract_ranges }, m, Date.now());
+ if (p.action === 'none') return;
+ if (p.action !== 'in-place') {
+ if (p.action === 'reload') { mustReload = true; reloadWhy = p.reason; }
+ prompt();
+ maybeReload();
+ return;
+ }
+ try {
+ kinds = Array.from(new Set(p.changed.map((c) => c.kind))).sort().join('+');
+ waiting = await x.apply(p, m, busy, record); pending = m;
+ if (!waiting.length) adopt(m);
+ } catch (why) {
+ waiting = []; pending = null;
+ record('failed', typeof why === 'string' ? why : 'content');
+ failedRelease = m.release;
+ flush();
+ await decide(m);
+ }
+ }
+ function consider(m) {
+ latest = m;
+ if (m.release === current || busyWith || (pending && pending.release === m.release)) return;
+ waiting = []; pending = null; // a newer release supersedes regions still waiting for the last one
+ busyWith = decide(m).catch(() => {}).then(() => { busyWith = null; });
}
}
async function check(force) {
async function check(force) {
@@ -70,30 +155,42 @@
@@ -70,30 +155,42 @@
m = await r.json();
m = await r.json();
} catch { return latest; }
} catch { return latest; }
if (!m || typeof m.release !== 'string') return latest;
if (!m || typeof m.release !== 'string') return latest;
- latest = m;
- if (!current) { current = m.release; root.OVRelease.current = current; return m; }
- if (m.release === current) return m;
- const age = now - Date.parse(m.released_at);
- const windowMs = (Number(m.mixed_version_window_hours) || 0) * 3600 * 1000;
- mustReload = m.min_client_release === m.release || (windowMs > 0 && age > windowMs);
- prompt();
- maybeReload();
+ if (!current) current = m.release;
+ if (!base && m.release === current) base = m;
+ consider(m);
+ if (busyWith) await busyWith;
return m;
return m;
}
}
let stopped = false;
let stopped = false;
root.addEventListener('focus', () => { if (!stopped) check(false); });
root.addEventListener('focus', () => { if (!stopped) check(false); });
root.addEventListener('online', () => { if (!stopped) check(true); });
root.addEventListener('online', () => { if (!stopped) check(true); });
- document.addEventListener('visibilitychange', () => { if (stopped) return; if (document.hidden) maybeReload(); else check(false); });
- const tick = root.setInterval(() => { if (!stopped) { check(false); maybeReload(); } }, 30 * 1000);
+ root.addEventListener('pagehide', flush);
+ document.addEventListener('visibilitychange', () => { if (stopped) return; if (document.hidden) { flush(); maybeReload(); } else check(false); });
+ const tick = root.setInterval(() => {
+ if (stopped) return;
+ if (pending && commitRegions(pending)) adopt(pending);
+ check(false); maybeReload();
+ }, 30 * 1000);
const poll = root.setInterval(() => { if (!stopped) check(true); }, 10 * 60 * 1000);
const poll = root.setInterval(() => { if (!stopped) check(true); }, 10 * 60 * 1000);
function stop() { stopped = true; root.clearInterval(tick); root.clearInterval(poll); }
function stop() { stopped = true; root.clearInterval(tick); root.clearInterval(poll); }
- root.OVRelease = { current, check: () => check(true), state: () => ({ current, latest, mustReload }), stop };
- // No server-rendered release: learn it now; a site that serves no /release.json is left alone.
- if (!current) {
- root.fetch(url, { cache: 'no-store', credentials: 'omit' })
- .then((r) => (r.ok ? r.json() : null))
- .then((m) => { if (m && typeof m.release === 'string') { current = m.release; root.OVRelease.current = current; latest = m; lastCheck = Date.now(); } else stop(); })
- .catch(stop);
- }
+ root.OVRelease = {
+ get current() { return current; },
+ check: () => check(true),
+ state: () => ({ current, latest, mustReload, waiting: waiting.length, metrics: totals }),
+ flush,
+ stop,
+ };
+ // The page's own manifest, if /release.json still serves the release the page was rendered from. A site
+ // that serves no /release.json and rendered no meta tag is left alone.
+ lastCheck = Date.now();
+ root.fetch(url, { cache: 'no-store', credentials: 'omit' })
+ .then((r) => (r.ok ? r.json() : null))
+ .then((m) => {
+ if (!m || typeof m.release !== 'string') { if (!current) stop(); return; }
+ if (!current) current = m.release;
+ if (m.release === current) base = m;
+ consider(m);
+ })
+ .catch(() => { if (!current) stop(); });
})(typeof window !== 'undefined' ? window : globalThis);
})(typeof window !== 'undefined' ? window : globalThis);
@@ -2,22 +2,54 @@
@@ -2,22 +2,54 @@
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
// openvibe-shared/release — GET /release.json for a web surface (ADR-016, contract
// openvibe-shared/release — GET /release.json for a web surface (ADR-016, contract
// registry.release-manifest@1). Open tabs poll it (release-watch.js) and learn when the page
// registry.release-manifest@1). Open tabs poll it (release-watch.js) and learn when the page
-// they run is older than what the server now serves.
+// they run is older than what the server now serves, and what changed.
//
//
-// const release = require('openvibe-shared/release').createRelease({ service: 'community' });
-// app.get('/release.json', release.handler);
-// html += release.metaTag(); // <meta name="ov-release" …> — the page's own release
+// const release = require('openvibe-shared/release').createRelease({
+// service: 'community',
+// components: { // optional; see "Components" below
+// styles: { kind: 'style', assets: ['/css/app.css'] },
+// pages: { kind: 'content', files: ['server/web/pages'] },
+// shell: { kind: 'script', assets: ['/js/app.js'], files: ['server/web/layout.js'] },
+// },
+// contracts: { 'community.web-api': { version: '1.3.0', accepts: '^1.0.0' } },
+// schemaGeneration: () => migrations.generation(),
+// });
+// release.mount(app, { registry }); // GET /release.json + POST /release-metrics into /metrics
+// html += release.metaTag(); // <meta name="ov-release" …> — the page's own release
//
//
-// The release is the deployed commit (git in `root`, or RELEASE_COMMIT), read once at boot.
+// The release is the deployed commit (git in `root`, or RELEASE_COMMIT), read at boot.
// MIN_CLIENT_RELEASE (env) set to the running release tells every older tab it must reload
// MIN_CLIENT_RELEASE (env) set to the running release tells every older tab it must reload
-// (as soon as that is safe); otherwise older tabs are only prompted, and reload on their own once
+// (as soon as that is safe); otherwise older tabs are prompted, and reload on their own once
// the new release is older than the mixed-version window (24 h by default).
// the new release is older than the mixed-version window (24 h by default).
+//
+// Components (manifest 1.1.0). Each has a kind and a version, the content hash of its files:
+// style stylesheets (`assets`): open tabs swap the <link>s in place
+// content server-rendered regions marked data-ov-content="<id>": open tabs re-fetch them in place
+// script code the page runs: a reload at a safe moment
+// server code only the server runs: nothing to do in the tab
+// `shell` is the catch-all script component for every client-facing file no other component
+// lists (templates, scripts). Without a declared shell its version is the release, so every release
+// reloads, as before 1.5.0. The platform package versions are part of the shell's hash.
+// `assets` are URL paths under `publicDir`; they also make the asset map (`url` is
+// `<path>?v=<12 hex of sha256>` unless `assetUrl` says otherwise, `integrity` is sha384).
+// `files` are paths under `root`; a directory counts every file in it.
+//
+// The served manifest carries only the fields the service's installed openvibe-contracts
+// release-manifest schema declares (1.0.0 has none of the fields above), so /release.json always
+// validates against the contracts the service pins. `full()` is the manifest before that filter.
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
const fs = require('fs');
const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
const { execFileSync } = require('child_process');
const { execFileSync } = require('child_process');
const SHA_RE = /^[0-9a-f]{7,40}$/;
const SHA_RE = /^[0-9a-f]{7,40}$/;
+const ID_RE = /^[a-z][a-z0-9-]{0,39}$/;
+const CONTRACT_RE = /^[a-z][a-z0-9-]*(\.[a-z0-9_-]+)+$/;
+const KINDS = ['style', 'content', 'script', 'server'];
+const V1_0 = ['service', 'release', 'released_at', 'booted_at', 'contracts_version', 'packages', 'min_client_release', 'mixed_version_window_hours'];
+const SCHEMA_PATH = 'openvibe-contracts/contracts/registry/release-manifest.v1.json';
function git(root, args) {
function git(root, args) {
try { return execFileSync('git', args, { cwd: root, stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000 }).toString().trim(); } catch { return ''; }
try { return execFileSync('git', args, { cwd: root, stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000 }).toString().trim(); } catch { return ''; }
@@ -27,43 +59,349 @@ function pkgVersion(name, root) {
@@ -27,43 +59,349 @@ function pkgVersion(name, root) {
try { return JSON.parse(fs.readFileSync(require.resolve(`${name}/package.json`, { paths: [root] }), 'utf8')).version || null; } catch { return null; }
try { return JSON.parse(fs.readFileSync(require.resolve(`${name}/package.json`, { paths: [root] }), 'utf8')).version || null; } catch { return null; }
}
}
+// ── Versions and ranges ────────────────────────────────────────
+
+function parseVersion(s) {
+ const m = /^v?(\d+)\.(\d+)\.(\d+)$/.exec(String(s == null ? '' : s).trim());
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
+}
+function compareVersions(a, b) {
+ const x = typeof a === 'string' ? parseVersion(a) : a;
+ const y = typeof b === 'string' ? parseVersion(b) : b;
+ if (!x || !y) return NaN;
+ for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] < y[i] ? -1 : 1;
+ return 0;
+}
+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]);
+const caretTop = (v) => (v[0] > 0 ? bump(v, 0) : v[1] > 0 ? bump(v, 1) : bump(v, 2));
+
+/**
+ * A range as the manifest writes it: ">=a.b.c <x.y.z". Accepts "^1.2.0", "~1.2.0", "1.x", "1.2",
+ * "1.2.3" (that version only), ">=1.2.0", ">=1.2.0 <3.0.0" and "*"; empty means "^<version>".
+ */
+function normalizeRange(range, version) {
+ const r = String(range == null ? '' : range).trim();
+ let lo; let hi; let m;
+ if (!r) { lo = parseVersion(version); hi = lo && caretTop(lo); }
+ else if (r === '*' || /^x$/i.test(r)) { lo = [0, 0, 0]; hi = [Number.MAX_SAFE_INTEGER, 0, 0]; }
+ else if ((m = /^\^(\S+)$/.exec(r))) { lo = parseVersion(m[1]); hi = lo && caretTop(lo); }
+ else if ((m = /^~(\S+)$/.exec(r))) { lo = parseVersion(m[1]); hi = lo && bump(lo, 1); }
+ else if ((m = /^(\d+)(?:\.(\d+|x|\*))?(?:\.(x|\*))?$/i.exec(r))) {
+ const minor = m[2] === undefined || /x|\*/i.test(m[2]) ? null : Number(m[2]);
+ lo = [Number(m[1]), minor == null ? 0 : minor, 0];
+ hi = bump(lo, minor == null ? 0 : 1);
+ } else if ((m = /^>=\s*(\S+)(?:\s+<\s*(\S+))?$/.exec(r))) { lo = parseVersion(m[1]); hi = m[2] ? parseVersion(m[2]) : lo && bump(lo, 0); }
+ else if (parseVersion(r)) { lo = parseVersion(r); hi = bump(lo, 2); }
+ if (!lo || !hi || !(compareVersions(lo, hi) < 0)) throw new TypeError(`release: cannot read the range "${r}"`);
+ return `>=${lo.join('.')} <${hi.join('.')}`;
+}
+
+function satisfies(version, range) {
+ const m = /^>=(\S+) <(\S+)$/.exec(String(range || ''));
+ return !!m && compareVersions(version, m[1]) >= 0 && compareVersions(version, m[2]) < 0;
+}
+
+/** { id: '1.2.0' | { version, accepts?, role? } } → the manifest's contract_ranges. */
+function contractRanges(input) {
+ const out = {};
+ for (const [id, spec] of Object.entries(input || {})) {
+ if (!CONTRACT_RE.test(id) || id.length > 100) throw new TypeError(`release: contract id "${id}" is not <owner>.<name>`);
+ const s = typeof spec === 'string' ? { version: spec } : (spec || {});
+ const v = parseVersion(s.version);
+ if (!v) throw new TypeError(`release: ${id} needs a version x.y.z`);
+ const accepts = normalizeRange(s.accepts, s.version);
+ if (!satisfies(v.join('.'), accepts)) throw new TypeError(`release: ${id} accepts ${accepts}, which excludes its own version ${v.join('.')}`);
+ const role = s.role || 'produces';
+ if (role !== 'produces' && role !== 'consumes') throw new TypeError(`release: ${id} role must be produces or consumes`);
+ out[id] = role === 'produces' ? { version: v.join('.'), accepts } : { version: v.join('.'), accepts, role };
+ }
+ return out;
+}
+
+// ── Components and the asset map ───────────────────────────────
+
+function listFiles(abs) {
+ let st;
+ try { st = fs.statSync(abs); } catch { return null; }
+ if (st.isFile()) return [abs];
+ if (!st.isDirectory()) return null;
+ const out = [];
+ for (const e of fs.readdirSync(abs, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : 1))) {
+ if (e.name.startsWith('.') || e.name === 'node_modules') continue;
+ out.push(...(listFiles(path.join(abs, e.name)) || []));
+ }
+ return out;
+}
+
+const within = (base, p) => p === base || p.startsWith(base + path.sep);
+const hash12 = (buf) => crypto.createHash('sha256').update(buf).digest('hex').slice(0, 12);
+
+function buildComponents({ root, publicDir, spec, release, packages, assetUrl, warn }) {
+ const components = {}; const assets = {}; const watched = [];
+ const specs = { ...(spec || {}) };
+ for (const [id, c] of Object.entries(specs)) {
+ if (!ID_RE.test(id)) throw new TypeError(`release: component id "${id}" must match ${ID_RE}`);
+ if (!c || !KINDS.includes(c.kind)) throw new TypeError(`release: component ${id} needs kind ${KINDS.join('|')}`);
+ }
+ if (!specs.shell) specs.shell = { kind: 'script' };
+ else if (specs.shell.kind !== 'script') throw new TypeError('release: the shell component is kind script');
+ if (!specs.server) specs.server = { kind: 'server' };
+ for (const [id, c] of Object.entries(specs)) {
+ const h = crypto.createHash('sha256');
+ let counted = 0;
+ for (const urlPath of c.assets || []) {
+ const p = String(urlPath);
+ const file = path.resolve(publicDir, '.' + p);
+ 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`);
+ let buf;
+ try { buf = fs.readFileSync(file); } catch { warn(`asset ${p} of component ${id} is missing (${file})`); h.update(`${p}\0missing\0`); continue; }
+ watched.push(file);
+ if (assets[p]) warn(`asset ${p} is listed by ${assets[p].component} and ${id}`);
+ assets[p] = { url: assetUrl(p, hash12(buf)), component: id, integrity: `sha384-${crypto.createHash('sha384').update(buf).digest('base64')}` };
+ h.update(`${p}\0`).update(buf).update('\0');
+ counted++;
+ }
+ for (const rel of c.files || []) {
+ const abs = path.resolve(root, String(rel));
+ const list = within(root, abs) ? listFiles(abs) : null;
+ if (!list) { warn(`file ${rel} of component ${id} is missing`); h.update(`${rel}\0missing\0`); continue; }
+ for (const f of list) {
+ watched.push(f);
+ h.update(`${path.relative(root, f)}\0`).update(fs.readFileSync(f)).update('\0');
+ counted++;
+ }
+ }
+ if (id === 'shell') h.update(JSON.stringify(packages));
+ const stated = c.version != null ? String(c.version) : null;
+ const version = stated || (counted ? hash12(h.digest()) : release);
+ if (!/^[0-9A-Za-z._+-]{1,64}$/.test(version)) throw new TypeError(`release: component ${id} version "${version}" is not a plain token`);
+ components[id] = { kind: c.kind, version };
+ }
+ return { components, assets, watched };
+}
+
+// ── The contract filter ────────────────────────────────────────
+
+/** The service's installed release-manifest schema, or null (then the 1.0.0 fields are served). */
+function installedSchema(root) {
+ try { return JSON.parse(fs.readFileSync(require.resolve(SCHEMA_PATH, { paths: [root] }), 'utf8')); } catch { return null; }
+}
+
+/** Keeps what `schema` allows: properties it names when additionalProperties is false, recursively. */
+function project(value, schema) {
+ if (!schema || typeof schema !== 'object' || value === null || typeof value !== 'object') return value;
+ if (Array.isArray(value)) return schema.items ? value.map((v) => project(v, schema.items)) : value;
+ const props = schema.properties || {};
+ const extra = schema.additionalProperties;
+ const out = {};
+ for (const [k, v] of Object.entries(value)) {
+ if (props[k]) out[k] = project(v, props[k]);
+ else if (extra === false) continue;
+ else out[k] = extra && typeof extra === 'object' ? project(v, extra) : v;
+ }
+ return out;
+}
+
+function deepFreeze(o) {
+ if (o && typeof o === 'object' && !Object.isFrozen(o)) { Object.freeze(o); for (const v of Object.values(o)) deepFreeze(v); }
+ return o;
+}
+
+// ── /release-metrics: what open tabs report (D46) ──────────────
+
+const OUTCOMES = {
+ applied: /^(content|server|style)(\+(content|server|style)){0,2}$/,
+ reloaded: /^(user|required|window|contract)$/,
+ deferred: /^(typing|dirty|protected|media|capture|active)$/,
+ failed: /^(style|style-timeout|content|origin|script)$/,
+};
+const MAX_BODY = 4096;
+const MAX_PER_REASON = 50;
+const counters = new WeakMap();
+
+function updateCounter(registry) {
+ let c = counters.get(registry);
+ if (!c) {
+ 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 });
+ counters.set(registry, c);
+ }
+ return c;
+}
+
+/** The report: a string, an object a body parser made, or null when it is over MAX_BODY. */
+function readBody(req) {
+ const b = req.body;
+ if (typeof b === 'string') return Promise.resolve(b.length > MAX_BODY ? null : b);
+ if (Buffer.isBuffer(b)) return Promise.resolve(b.length > MAX_BODY ? null : b.toString('utf8'));
+ // express.json() leaves {} (and the stream unread) for the text/plain body sendBeacon sends.
+ if (b && typeof b === 'object' && (Object.keys(b).length || req.readableEnded)) return Promise.resolve(b);
+ return new Promise((resolve) => {
+ let size = 0; const chunks = [];
+ req.on('data', (c) => { size += c.length; if (size <= MAX_BODY) chunks.push(c); });
+ req.on('end', () => resolve(size > MAX_BODY ? null : Buffer.concat(chunks).toString('utf8')));
+ req.on('error', () => resolve(''));
+ });
+}
+
+/**
+ * POST handler for release-watch beacons: { counts: { applied|reloaded|deferred|failed: { reason: n } } }.
+ * Counts go to release_client_updates_total{outcome,reason} in `registry` (openvibe-shared/metrics).
+ * Unknown reasons count as "other"; each reason counts at most 50 per report; a report is at most 4 KB;
+ * one address sends at most `perMinute` reports a minute. Sec-GPC/DNT requests are not counted.
+ */
+function collector(registry, { perMinute = 30 } = {}) {
+ if (!registry || typeof registry.counter !== 'function') throw new TypeError('release: collect() needs an openvibe-shared/metrics registry');
+ const counter = updateCounter(registry);
+ const seen = new Map(); let windowStart = Date.now();
+ return async function releaseMetrics(req, res) {
+ const done = (code) => { res.statusCode = code; res.setHeader('Cache-Control', 'no-store'); res.end(); };
+ const h = req.headers || {};
+ if (h['sec-gpc'] === '1' || h.dnt === '1') return done(204);
+ const now = Date.now();
+ if (now - windowStart > 60000) { seen.clear(); windowStart = now; }
+ const ip = String((req.socket && req.socket.remoteAddress) || req.ip || '');
+ const n = (seen.get(ip) || 0) + 1;
+ if (seen.size < 10000 || seen.has(ip)) seen.set(ip, n);
+ if (n > perMinute) return done(429);
+ let body = await readBody(req);
+ if (body === null) return done(413);
+ if (typeof body === 'string') { try { body = JSON.parse(body); } catch { return done(400); } }
+ const counts = body && typeof body === 'object' ? body.counts : null;
+ if (!counts || typeof counts !== 'object') return done(400);
+ for (const [outcome, re] of Object.entries(OUTCOMES)) {
+ const reasons = counts[outcome];
+ if (!reasons || typeof reasons !== 'object') continue;
+ for (const [reason, value] of Object.entries(reasons).slice(0, 16)) {
+ const k = Math.min(MAX_PER_REASON, Math.floor(Number(value)));
+ if (k > 0) counter.inc({ outcome, reason: re.test(reason) ? reason : 'other' }, k);
+ }
+ }
+ return done(204);
+ };
+}
+
+// ── createRelease ──────────────────────────────────────────────
+
function createRelease({
function createRelease({
service, root = process.cwd(), windowHours = 24,
service, root = process.cwd(), windowHours = 24,
minClientRelease = process.env.MIN_CLIENT_RELEASE || null,
minClientRelease = process.env.MIN_CLIENT_RELEASE || null,
packages = ['openvibe-shared', 'openvibe-sdk'],
packages = ['openvibe-shared', 'openvibe-sdk'],
- env = process.env, now = () => new Date(),
+ components = null, publicDir = null, assetUrl = (p, h) => `${p}?v=${h}`,
+ contracts = null, schemaGeneration = null, schemaCompatibleFrom = null,
+ metricsPath = null, schema, recheckMs = 0,
+ env = process.env, now = () => new Date(), logger = console,
} = {}) {
} = {}) {
if (!/^[a-z][a-z0-9-]{1,39}$/.test(String(service || ''))) throw new TypeError('createRelease: service id required');
if (!/^[a-z][a-z0-9-]{1,39}$/.test(String(service || ''))) throw new TypeError('createRelease: service id required');
- const fromEnv = String(env.RELEASE_COMMIT || '').toLowerCase();
- const sha = SHA_RE.test(fromEnv) ? fromEnv.slice(0, 12) : git(root, ['rev-parse', '--short=12', 'HEAD']);
- const release = SHA_RE.test(sha) ? sha : '0000000';
- const committed = env.RELEASE_AT || git(root, ['log', '-1', '--format=%cI']);
+ root = path.resolve(root);
+ const pub = path.resolve(root, publicDir || 'public');
+ const ranges = contractRanges(contracts); // throws at boot on a malformed range
+ const contractSchema = schema === undefined ? installedSchema(root) : schema;
const bootedAt = now();
const bootedAt = now();
- const releasedAt = Number.isNaN(Date.parse(committed)) ? bootedAt : new Date(committed);
- const versions = {};
- for (const p of packages) { const v = pkgVersion(p, root); if (v) versions[p] = v; }
- const manifest = Object.freeze({
- service,
- release,
- released_at: releasedAt.toISOString(),
- booted_at: bootedAt.toISOString(),
- contracts_version: pkgVersion('openvibe-contracts', root),
- packages: Object.freeze(versions),
- min_client_release: minClientRelease && SHA_RE.test(minClientRelease) ? minClientRelease : null,
- mixed_version_window_hours: Math.max(0, Math.floor(Number(windowHours) || 0)),
- });
- const body = JSON.stringify(manifest);
+ const warnings = [];
+ const warn = (msg) => { warnings.push(msg); try { logger.warn(`[release] ${service}: ${msg}`); } catch { /* */ } };
+ let metricsUrl = metricsPath || null;
+ let state = null; let body = null; let signature = ''; let checkedAt = 0;
+
+ const intOrNull = (v, name) => {
+ let x = v;
+ try { if (typeof x === 'function') x = x(); } catch (err) { warn(`${name} failed: ${err && err.message}`); return null; }
+ if (x == null) return null;
+ if (Number.isInteger(x) && x >= 0) return x;
+ warn(`${name} is not a non-negative integer`); return null;
+ };
+ const sigOf = (files) => files.map((f) => { try { const s = fs.statSync(f); return `${s.mtimeMs}:${s.size}`; } catch { return '-'; } }).join('|');
+
+ function build() {
+ warnings.length = 0;
+ const fromEnv = String(env.RELEASE_COMMIT || '').toLowerCase();
+ const sha = SHA_RE.test(fromEnv) ? fromEnv.slice(0, 12) : git(root, ['rev-parse', '--short=12', 'HEAD']);
+ const release = SHA_RE.test(sha) ? sha : '0000000';
+ const committed = env.RELEASE_AT || git(root, ['log', '-1', '--format=%cI']);
+ const releasedAt = Number.isNaN(Date.parse(committed)) ? bootedAt : new Date(committed);
+ const versions = {};
+ for (const p of packages) { const v = pkgVersion(p, root); if (v) versions[p] = v; }
+ const built = buildComponents({ root, publicDir: pub, spec: components, release, packages: versions, assetUrl, warn });
+ const generation = intOrNull(schemaGeneration, 'schemaGeneration');
+ let compatibleFrom = schemaCompatibleFrom == null ? generation : intOrNull(schemaCompatibleFrom, 'schemaCompatibleFrom');
+ if (generation != null && compatibleFrom != null && compatibleFrom > generation) { warn('schemaCompatibleFrom is above schemaGeneration'); compatibleFrom = generation; }
+ const full = deepFreeze({
+ service,
+ release,
+ released_at: releasedAt.toISOString(),
+ booted_at: bootedAt.toISOString(),
+ contracts_version: pkgVersion('openvibe-contracts', root),
+ packages: versions,
+ min_client_release: minClientRelease && SHA_RE.test(minClientRelease) ? minClientRelease : null,
+ mixed_version_window_hours: Math.max(0, Math.floor(Number(windowHours) || 0)),
+ components: built.components,
+ assets: built.assets,
+ schema_generation: generation,
+ schema_compatible_from: compatibleFrom,
+ contract_ranges: ranges,
+ metrics_url: metricsUrl,
+ });
+ const served = deepFreeze(contractSchema ? project(full, contractSchema) : Object.fromEntries(V1_0.map((k) => [k, full[k]])));
+ state = { full, served, watched: built.watched };
+ body = JSON.stringify(served);
+ signature = sigOf(built.watched);
+ checkedAt = Date.now();
+ return served;
+ }
+ build();
+
+ /** Re-reads git, env, files and the schema generation (e.g. after a static-only release switch). */
+ function refresh() { return build(); }
+ function maybeRecheck() {
+ if (!(recheckMs > 0) || Date.now() - checkedAt < recheckMs) return;
+ checkedAt = Date.now();
+ if (sigOf(state.watched) !== signature) build();
+ }
function handler(_req, res) {
function handler(_req, res) {
+ maybeRecheck();
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Cache-Control', 'no-cache, max-age=0');
res.setHeader('Cache-Control', 'no-cache, max-age=0');
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Origin', '*');
res.end(body);
res.end(body);
}
}
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
- const metaTag = (url = '/release.json') => `<meta name="ov-release" content="${esc(release)}" data-released-at="${esc(manifest.released_at)}" data-url="${esc(url)}">`;
+ 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)}"` : ''}>`;
+
+ /** GET /release.json and, with a metrics registry, POST <metricsPath> into release_client_updates_total. */
+ function mount(app, { registry = null, path: manifestPath = '/release.json', metricsPath: mp = metricsPath || '/release-metrics', perMinute } = {}) {
+ app.get(manifestPath, handler);
+ if (registry) {
+ app.post(mp, collector(registry, { perMinute }));
+ if (metricsUrl !== mp) { metricsUrl = mp; build(); }
+ }
+ return api;
+ }
+
+ /** Validates the served manifest with the service's openvibe-contracts (or the one passed). */
+ function validate({ contracts: lib } = {}) {
+ let c = lib;
+ if (!c) { try { c = require(require.resolve('openvibe-contracts', { paths: [root] })); } catch { return { valid: null, errors: [], reason: 'openvibe-contracts is not installed' }; } }
+ const r = c.validate('registry.release-manifest@1', state.served);
+ let version = null;
+ try { version = c.resolve('registry.release-manifest@1').version; } catch { /* */ }
+ return { valid: r.valid, errors: r.errors, contract: version };
+ }
- return { manifest: () => manifest, release, handler, metaTag };
+ const api = {
+ get release() { return state.full.release; },
+ manifest: () => state.served,
+ full: () => state.full,
+ handler,
+ metaTag,
+ mount,
+ collect: (registry, opts) => collector(registry, opts),
+ refresh,
+ validate,
+ warnings,
+ /** Which manifest fields the service's contracts let it serve. */
+ fields: () => Object.keys(state.served),
+ };
+ return api;
}
}
-module.exports = { createRelease };
+module.exports = { createRelease, normalizeRange, satisfies, compareVersions, parseVersion, contractRanges, project, collector, OUTCOMES };
new file mode 100644
new file mode 100644
@@ -0,0 +1,85 @@
@@ -0,0 +1,85 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://openvibe.network/contracts/registry/release-manifest.v1.json",
+ "title": "ReleaseManifest",
+ "description": "What a running web surface is serving (GET /release.json, ADR-016). Open clients compare it on focus and reconnect. A client outside the supported window is prompted and reloads only when that is safe. Since 1.1.0 the manifest can also say which components changed, so a client can apply style and content changes in place, and which contract versions the server still accepts, so a mixed-version population can be checked.",
+ "type": "object",
+ "required": ["service", "release", "released_at", "contracts_version", "min_client_release", "mixed_version_window_hours"],
+ "additionalProperties": false,
+ "properties": {
+ "service": { "type": "string", "pattern": "^[a-z][a-z0-9-]{1,39}$" },
+ "release": { "type": "string", "pattern": "^[0-9a-f]{7,40}$", "description": "The deployed commit (short sha)." },
+ "released_at": { "type": "string", "format": "date-time", "description": "Commit time of the release." },
+ "booted_at": { "type": "string", "format": "date-time" },
+ "contracts_version": { "type": ["string", "null"], "description": "openvibe-contracts version the service runs against." },
+ "packages": {
+ "type": "object",
+ "description": "Versions of the platform packages the page loads (openvibe-shared, openvibe-sdk, ...).",
+ "additionalProperties": { "type": "string" }
+ },
+ "min_client_release": { "type": ["string", "null"], "description": "Oldest client release still supported; null while every client in the window is." },
+ "mixed_version_window_hours": { "type": "integer", "minimum": 0 },
+ "components": {
+ "type": "object",
+ "description": "Since 1.1.0. Per-component versions. A client applies a new release in place only when every component that changed is a style, content or server component and the contract ranges still match. Any other change is applied by a reload at a safe moment. The shell component covers every client-facing file that no other component lists.",
+ "propertyNames": { "pattern": "^[a-z][a-z0-9-]{0,39}$" },
+ "additionalProperties": {
+ "type": "object",
+ "required": ["kind", "version"],
+ "additionalProperties": false,
+ "properties": {
+ "kind": {
+ "enum": ["style", "content", "script", "server"],
+ "description": "style: stylesheets, swapped in place. content: server-rendered regions (data-ov-content), re-fetched in place. script: code the page runs, applied by a reload. server: code only the server runs, no client action."
+ },
+ "version": { "type": "string", "pattern": "^[0-9A-Za-z._+-]{1,64}$", "description": "Content hash of the component's files, or a version the service states." }
+ }
+ }
+ },
+ "assets": {
+ "type": "object",
+ "description": "Since 1.1.0. Asset map: each logical URL path the release serves, mapped to its content-addressed URL. HTML from this release references only these URLs.",
+ "propertyNames": { "pattern": "^/[^?#\\s]{0,255}$" },
+ "additionalProperties": {
+ "type": "object",
+ "required": ["url", "component"],
+ "additionalProperties": false,
+ "properties": {
+ "url": { "type": "string", "minLength": 1, "maxLength": 512 },
+ "component": { "type": "string", "pattern": "^[a-z][a-z0-9-]{0,39}$" },
+ "integrity": { "type": "string", "pattern": "^sha(256|384|512)-[A-Za-z0-9+/]+={0,2}$" }
+ }
+ }
+ },
+ "schema_generation": {
+ "type": ["integer", "null"],
+ "minimum": 0,
+ "description": "Since 1.1.0. The database schema generation this release migrates to and runs against; null when the service does not track one."
+ },
+ "schema_compatible_from": {
+ "type": ["integer", "null"],
+ "minimum": 0,
+ "description": "Since 1.1.0. The oldest schema generation whose code still runs against this release's schema, because every migration since then only expanded it. Rolling back to a release whose schema_generation is lower is not data-safe."
+ },
+ "contract_ranges": {
+ "type": "object",
+ "description": "Since 1.1.0. For each contract the service produces (its API, as served to its own pages and clients) or consumes: the version this release speaks and the range of versions it accepts from the other side. A page from release A works against a server on release B when, for every contract A produces, A's version is in B's accepts and B's version is in A's accepts.",
+ "propertyNames": { "pattern": "^[a-z][a-z0-9-]*(\\.[a-z0-9_-]+)+$", "maxLength": 100 },
+ "additionalProperties": {
+ "type": "object",
+ "required": ["version", "accepts"],
+ "additionalProperties": false,
+ "properties": {
+ "version": { "type": "string", "pattern": "^\\d+\\.\\d+\\.\\d+$" },
+ "accepts": { "type": "string", "pattern": "^>=\\d+\\.\\d+\\.\\d+ <\\d+\\.\\d+\\.\\d+$", "description": "A half-open range, always written \">=a.b.c <x.y.z\"." },
+ "role": { "enum": ["produces", "consumes"], "description": "produces when absent." }
+ }
+ }
+ },
+ "metrics_url": {
+ "type": ["string", "null"],
+ "maxLength": 512,
+ "description": "Since 1.1.0. Same-origin URL where clients POST their update outcome counts (applied, reloaded, deferred, failed); null or absent when the service does not collect them."
+ }
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,94 @@
@@ -0,0 +1,94 @@
+'use strict';
+// openvibe-shared/release-compat: the N/N-1 mixed-version matrix, driven by the manifests' contract ranges
+// and checked against real servers. Three releases of a small service:
+// R1 items API 1.0.0: GET /api/items -> [{ id, name }]
+// R2 items API 1.1.0 (additive): items gain `tags`; POST /api/items takes an optional `tags`
+// R3 items API 2.0.0 (breaking): `name` is renamed `title`
+// and a dishonest R2x that claims 1.1.0 (^1) but ships R3's breaking response.
+const assert = require('assert');
+const express = require('express');
+const { createRelease } = require('../release');
+const compat = require('../release-compat');
+
+function service(shape) {
+ const items = [{ id: 1, name: 'first', tags: ['a'] }];
+ const app = express();
+ app.use(express.json());
+ app.get('/api/items', (_req, res) => res.json(items.map((i) => shape(i))));
+ app.post('/api/items', (req, res) => {
+ const name = req.body.name || req.body.title;
+ if (typeof name !== 'string') return res.status(400).json({ error: 'name required' });
+ const item = { id: items.length + 1, name, tags: Array.isArray(req.body.tags) ? req.body.tags : [] };
+ items.push(item);
+ return res.status(201).json(shape(item));
+ });
+ return async () => {
+ const server = await new Promise((resolve) => { const s = app.listen(0, '127.0.0.1', () => resolve(s)); });
+ return { url: `http://127.0.0.1:${server.address().port}/`, close: () => new Promise((r) => server.close(r)) };
+ };
+}
+const v1 = (i) => ({ id: i.id, name: i.name });
+const v11 = (i) => ({ id: i.id, name: i.name, tags: i.tags });
+const v2 = (i) => ({ id: i.id, title: i.name, tags: i.tags });
+
+// What each release's page does against whatever server it reaches.
+const hasName = (list) => { for (const i of list) assert.strictEqual(typeof i.name, 'string', 'item.name'); };
+const hasTitle = (list) => { for (const i of list) assert.strictEqual(typeof i.title, 'string', 'item.title'); };
+const client1 = compat.replay([{ path: '/api/items', check: hasName }, { method: 'POST', path: '/api/items', body: { name: 'from R1' }, status: 201, check: (i) => assert.strictEqual(i.name, 'from R1') }]);
+const client2 = compat.replay([{ path: '/api/items', check: hasName }, { method: 'POST', path: '/api/items', body: { name: 'from R2', tags: ['x'] }, status: 201, check: (i) => assert.strictEqual(i.name, 'from R2') }]);
+const client3 = compat.replay([{ path: '/api/items', check: hasTitle }]);
+
+// Manifests the way a service makes them (createRelease), and one the way a fixture from production looks.
+const manifest = (sha, version, accepts, extra = {}) => createRelease({
+ service: 'items', root: __dirname, env: { RELEASE_COMMIT: sha, RELEASE_AT: '2026-09-23T10:00:00Z' }, schema: require('./fixtures/release-manifest.v1.1.0.json'),
+ contracts: { 'items.web-api': { version, accepts } }, logger: { warn() {} }, ...extra,
+}).full();
+const R1 = { name: 'R1', manifest: manifest('1111111', '1.0.0', '^1.0.0', { schemaGeneration: 3 }), server: service(v1), client: client1 };
+const R2 = { name: 'R2', manifest: manifest('2222222', '1.1.0', '^1.0.0', { schemaGeneration: 4, schemaCompatibleFrom: 3 }), server: service(v11), client: client2 };
+const R3 = { name: 'R3', manifest: manifest('3333333', '2.0.0', '^2.0.0', { schemaGeneration: 6 }), server: service(v2), client: client3 };
+const R2x = { name: 'R2x', manifest: manifest('2222333', '1.1.0', '^1.0.0'), server: service(v2), client: client2 };
+
+(async () => {
+ // N-1 and N, both ways: an R1 page on the R2 server and an R2 page on the R1 server (rollback).
+ const rows = await compat.assertMixedVersion({ releases: [R1, R2] });
+ assert.deepStrictEqual(rows.map((r) => [r.page, r.server, r.compatible, r.ran, r.ok]), [['R1', 'R2', true, true, true], ['R2', 'R1', true, true, true]]);
+ assert.ok(rows.every((r) => r.action === 'prompt'), 'no declared shell (so it changes every release): a compatible release prompts, never forces a reload');
+
+ // A breaking release next to the one before it is refused (ADR-016: N-1 must keep working) …
+ await assert.rejects(compat.assertMixedVersion({ releases: [R2, R3] }), (err) => {
+ assert.match(err.message, /adjacent releases must be compatible/);
+ assert.match(err.message, /items\.web-api: the server accepts >=2\.0\.0 <3\.0\.0, the page speaks 1\.1\.0/);
+ return true;
+ });
+ // … and outside the window (not adjacent) the tab is told to reload before it can break.
+ const wide = await compat.runMixedVersion({ releases: [R1, R2, R3] });
+ const r1r3 = wide.find((r) => r.page === 'R1' && r.server === 'R3');
+ assert.deepStrictEqual([r1r3.compatible, r1r3.action, r1r3.reason, r1r3.ran, r1r3.ok], [false, 'reload', 'contract', false, true]);
+ assert.ok(wide.filter((r) => r.adjacent && !r.compatible).every((r) => !r.ok), 'R2 <-> R3 fail as adjacent');
+ const off = await compat.runMixedVersion({ releases: [R2, R3], requireAdjacent: false });
+ assert.ok(off.every((r) => r.ok && r.action === 'reload'), 'requireAdjacent: false accepts a reload-only boundary');
+
+ // A manifest that claims compatibility its server does not honour is caught by the real calls.
+ await assert.rejects(compat.assertMixedVersion({ releases: [R1, R2x] }), (err) => {
+ assert.match(err.message, /declared compatible, but the R1 client failed against the R2x server: item\.name/);
+ const bad = err.rows.find((r) => !r.ok);
+ assert.deepStrictEqual([bad.page, bad.server, bad.ran], ['R1', 'R2x', true]);
+ return true;
+ });
+ console.log(compat.formatMatrix(wide).split('\n').map((l) => ` ${l}`).join('\n'));
+
+ // Rollback safety from the schema generations: R2 only expanded R1's schema (compatible from 3), R3 did not.
+ assert.deepStrictEqual(compat.rollbackSafe(R2.manifest, R1.manifest), { ok: true });
+ assert.strictEqual(compat.rollbackSafe(R3.manifest, R2.manifest).ok, false);
+ assert.strictEqual(compat.rollbackSafe({}, R1.manifest).unknown, true);
+
+ // Service to service: a consumer's range against a producer's.
+ const media = compat.manifestFor({ service: 'media', release: 'abcdef0', contracts: { 'media.object': { version: '1.3.0', accepts: '^1.0.0' } } });
+ const live = compat.manifestFor({ service: 'live', release: 'abcdef1', contracts: { 'media.object': { version: '1.1.0', accepts: '^1.0.0', role: 'consumes' } } });
+ assert.deepStrictEqual(compat.consumerCompatible(live, media), { ok: true, problems: [] });
+ const media2 = compat.manifestFor({ service: 'media', release: 'abcdef2', contracts: { 'media.object': { version: '2.0.0', accepts: '^2.0.0' } } });
+ assert.strictEqual(compat.consumerCompatible(live, media2).ok, false);
+ assert.deepStrictEqual(compat.compatible(live, media2), { ok: true, problems: [] }, 'consumed contracts are not the page\'s concern');
+
+ console.log('release-mixed-version: all checks passed');
+})().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,227 @@
@@ -0,0 +1,227 @@
+'use strict';
+// release-update.js through release-watch.js in a linkedom page (openvibe-shared/release-compat openPage):
+// style and content changes applied in place, transactionally; deferral around focus and media; the
+// fallback to a prompt; contract ranges forcing a reload only when safe; the update metrics (D46).
+const assert = require('assert');
+const { openPage, manifestFor, plan } = require('../release-compat');
+
+const URL_ = 'https://site.test/docs/a';
+const A = 'aaaaaaa'; const B = 'bbbbbbb';
+const comps = (o) => ({
+ styles: { kind: 'style', version: 'css1' }, extra: { kind: 'style', version: 'x1' }, docs: { kind: 'content', version: 'd1' },
+ side: { kind: 'content', version: 's1' }, shell: { kind: 'script', version: 'sh1' }, server: { kind: 'server', version: 'r1' }, ...o,
+});
+const assets = (css) => ({
+ '/css/app.css': { url: `/css/app.css?v=${css}`, component: 'styles', integrity: 'sha384-AAAA' },
+ '/css/other.css': { url: '/css/other.css?v=222222222222', component: 'extra' },
+ '/js/app.js': { url: '/js/app.js?v=999999999999', component: 'shell' },
+});
+const api = (version, accepts) => ({ 'test.web-api': { version, accepts } });
+const M1 = manifestFor({ service: 'test', release: A, components: comps(), assets: assets('111111111111'), contracts: api('1.0.0', '^1.0.0'), metricsUrl: '/release-metrics' });
+const M2 = manifestFor({ service: 'test', release: B, components: comps({ styles: { kind: 'style', version: 'css2' }, docs: { kind: 'content', version: 'd2' }, server: { kind: 'server', version: 'r2' } }), assets: assets('333333333333'), contracts: api('1.1.0', '^1.0.0'), metricsUrl: '/release-metrics' });
+
+const PAGE = (region = '<p>old text</p>', rev = '1') => `<!doctype html><html><head>
+<meta name="ov-release" content="${A}" data-url="/release.json">
+<link rel="stylesheet" href="/css/app.css?v=111111111111">
+<link rel="stylesheet" href="/css/other.css?v=222222222222">
+<link rel="stylesheet" href="https://openvibe.network/shared/app.css">
+</head><body>
+<main data-ov-content="docs" data-ov-rev="${rev}">${region}</main>
+<aside data-ov-content="side"><p>side</p></aside>
+<section data-ov-content="side-2"><p>stays</p></section>
+</body></html>`;
+const NEW_PAGE = `<!doctype html><html><body>
+<main data-ov-content="docs" data-ov-rev="2"><p onclick="evil()">new text</p><script>evil()</script><a href="javascript:evil()">x</a><meta http-equiv="refresh" content="0;url=/elsewhere"><img src="data:image/png;base64,AAAA"><iframe src="/x"></iframe></main>
+<aside data-ov-content="side"><p>side, fresh</p></aside>
+</body></html>`;
+
+async function open({ served = M1, html = PAGE(), page = NEW_PAGE, config, hidden = true, updateScript } = {}) {
+ const state = { served, page, pageStatus: 200 };
+ const p = await openPage({
+ url: URL_, html, hidden, config, updateScript,
+ serve: (u) => {
+ const { pathname } = new URL(u);
+ if (pathname === '/release.json') return { json: state.served };
+ if (pathname === '/docs/a') return { status: state.pageStatus, body: state.page };
+ return { status: 404 };
+ },
+ });
+ await p.settle();
+ p.state = state;
+ return p;
+}
+/** Serves `next` and runs a check through to the end, loading (or failing) the new stylesheets. */
+async function update(p, next, { styles = true } = {}) {
+ p.state.served = next;
+ const done = p.release.check();
+ await p.settle();
+ if (styles === true) p.settleStyles(true);
+ else if (styles === false) p.settleStyles(false);
+ else if (styles === 'timeout') p.fireTimeouts();
+ await done; await p.settle();
+}
+const hrefs = (p) => Array.from(p.document.querySelectorAll('link[rel~="stylesheet"]')).map((l) => l.getAttribute('href'));
+const counts = (p) => p.beacons.reduce((acc, b) => { for (const [o, rs] of Object.entries(b.body.counts)) for (const [r, n] of Object.entries(rs)) { acc[o] = acc[o] || {}; acc[o][r] = (acc[o][r] || 0) + n; } return acc; }, {});
+
+// Size: release-watch.js loads on every page after first paint; release-update.js only when a release
+// can change something in place. Measured 2026-09-23: 3.2 KB and 3.0 KB brotli (release-watch was 1.7 KB).
+{
+ const fs = require('fs'); const path = require('path'); const { brotli } = require('../scripts/size-report');
+ const size = (f) => brotli(fs.readFileSync(path.join(__dirname, '..', f)));
+ assert.ok(size('release-watch.js') <= 3.5 * 1024, `release-watch.js is ${(size('release-watch.js') / 1024).toFixed(1)} KB brotli, budget 3.5 KB`);
+ assert.ok(size('release-update.js') <= 3.5 * 1024, `release-update.js is ${(size('release-update.js') / 1024).toFixed(1)} KB brotli, budget 3.5 KB`);
+}
+
+(async () => {
+ // ── The plan, as a pure function ──
+ assert.deepStrictEqual(plan(M1, M2).action, 'in-place');
+ assert.deepStrictEqual(plan(M1, M2).changed.map((c) => c.id).sort(), ['docs', 'server', 'styles']);
+ assert.strictEqual(plan(M1, { ...M2, components: comps({ shell: { kind: 'script', version: 'sh2' } }) }).action, 'prompt', 'a script change prompts');
+ assert.strictEqual(plan(M1, { ...M2, components: comps({ nav: { kind: 'style', version: 'n1' } }) }).action, 'prompt', 'a new style component is not in place (the page has no <link> for it)');
+ assert.strictEqual(plan(M1, { ...M2, components: comps({ styles: { kind: 'script', version: 'css1' } }) }).action, 'prompt', 'a kind change is not in place');
+ assert.strictEqual(plan(M1, { ...M2, components: comps({ jobs: { kind: 'server', version: 'j1' } }) }).action, 'in-place', 'a new server component needs nothing from the tab');
+ assert.deepStrictEqual(plan(M1, { ...M2, contract_ranges: api('2.0.0', '^2.0.0') }).reason, 'contract');
+ assert.strictEqual(plan(M1, M1).action, 'none');
+
+ // ── Style and content in place ──
+ {
+ const p = await open();
+ let applied = 0; p.window.addEventListener('ov:release-applied', () => { applied++; });
+ let updated = null; p.document.querySelector('main').addEventListener('ov:content-updated', (e) => { updated = e.detail.component; });
+ assert.strictEqual(p.release.current, A);
+ assert.strictEqual(p.requests.length, 1, 'at load: only /release.json');
+ await update(p, M2);
+ assert.strictEqual(p.release.current, B, 'the tab is on the new release');
+ assert.deepStrictEqual(hrefs(p), ['https://site.test/css/app.css?v=333333333333', '/css/other.css?v=222222222222', 'https://openvibe.network/shared/app.css'], 'only the changed stylesheet moved, in its place');
+ assert.strictEqual(p.document.querySelector('link[href*="app.css?v=333"]').getAttribute('integrity'), 'sha384-AAAA');
+ const main = p.document.querySelector('main');
+ assert.match(main.textContent, /new text/);
+ assert.strictEqual(main.getAttribute('data-ov-rev'), '2');
+ assert.ok(!main.querySelector('script, iframe, meta, [onclick]'), 'no scripts, frames, meta refreshes or inline handlers come along');
+ assert.strictEqual(main.querySelector('a').getAttribute('href'), null, 'javascript: links are dropped');
+ assert.strictEqual(main.querySelector('img').getAttribute('src'), 'data:image/png;base64,AAAA', 'data: images stay');
+ assert.match(p.document.querySelector('aside').textContent, /^side$/, 'a region whose component did not change is left alone');
+ assert.strictEqual(updated, 'docs');
+ assert.strictEqual(applied, 1);
+ assert.deepStrictEqual([p.reloads, p.toasts.length], [0, 0], 'no reload, no prompt');
+ assert.ok(p.document.querySelector('script[src="https://site.test/shared/release-update.js"]'), 'release-update.js was loaded from next to release-watch.js, on demand');
+ assert.deepStrictEqual(p.beacons.map((b) => [b.to, b.body.release, b.body.to, b.body.counts]), [['/release-metrics', B, B, { applied: { 'content+server+style': 1 } }]]);
+ // The same release again is nothing: no refetch, no new <link>, no remount.
+ const before = p.requests.length;
+ await update(p, M2);
+ assert.strictEqual(p.requests.length, before + 1, 'only /release.json was read');
+ assert.strictEqual(hrefs(p).length, 3);
+ // A rollback to the previous release is applied in place too (the server is the authority).
+ await update(p, { ...M1, contract_ranges: M2.contract_ranges });
+ assert.strictEqual(p.release.current, A);
+ assert.ok(hrefs(p).includes('https://site.test/css/app.css?v=111111111111'));
+ }
+
+ // ── Server-only change: nothing to fetch, adopted quietly ──
+ {
+ const p = await open();
+ await update(p, { ...M1, release: B, components: comps({ server: { kind: 'server', version: 'r9' } }) });
+ assert.deepStrictEqual([p.release.current, p.toasts.length, p.requests.length], [B, 0, 2]);
+ assert.deepStrictEqual(counts(p), { applied: { server: 1 } });
+ }
+
+ // ── Focus inside a region: styles apply, the region waits, then commits ──
+ {
+ const p = await open({ html: PAGE('<p>old text</p><input>') });
+ p.focus(p.document.querySelector('main input'));
+ await update(p, M2);
+ assert.ok(hrefs(p).includes('https://site.test/css/app.css?v=333333333333'), 'the stylesheet does not wait for the region');
+ assert.match(p.document.querySelector('main').textContent, /old text/, 'the focused region is not replaced');
+ assert.strictEqual(p.release.state().waiting, 1);
+ assert.strictEqual(p.release.current, A, 'not adopted while a region waits');
+ p.tick(); await p.settle();
+ assert.deepStrictEqual(p.metrics(), { deferred: { typing: 1 } }, 'deferred once, however many ticks');
+ const fetches = p.requests.filter((r) => r.url === URL_).length;
+ await p.release.check(); await p.settle();
+ assert.strictEqual(p.requests.filter((r) => r.url === URL_).length, fetches, 'a waiting region is not fetched again');
+ p.blur(); p.tick(); await p.settle();
+ assert.match(p.document.querySelector('main').textContent, /new text/);
+ assert.strictEqual(p.release.current, B);
+ assert.deepStrictEqual(counts(p), { deferred: { typing: 1 }, applied: { 'content+server+style': 1 } });
+ }
+ // Media playing inside a region, or a protected region: it waits (reason media / protected).
+ {
+ const p = await open({ html: PAGE('<p>old text</p><video></video>') });
+ await update(p, M2);
+ assert.deepStrictEqual([p.release.state().waiting, p.metrics()], [1, { deferred: { media: 1 } }]);
+ const q = await open({ html: PAGE('<p data-ov-protected>editing</p>') });
+ await update(q, M2);
+ assert.deepStrictEqual([q.release.state().waiting, q.metrics()], [1, { deferred: { protected: 1 } }]);
+ }
+ // An unchanged data-ov-rev is not replaced (no remount).
+ {
+ const p = await open({ html: PAGE('<p>old text</p>', '2') });
+ await update(p, M2);
+ assert.match(p.document.querySelector('main').textContent, /old text/);
+ assert.strictEqual(p.release.current, B);
+ }
+
+ // ── Failures keep the old page and fall back to the prompt ──
+ {
+ const p = await open();
+ await update(p, M2, { styles: false });
+ assert.deepStrictEqual(hrefs(p), ['/css/app.css?v=111111111111', '/css/other.css?v=222222222222', 'https://openvibe.network/shared/app.css'], 'a stylesheet that fails to load is removed; the old one stays');
+ assert.match(p.document.querySelector('main').textContent, /old text/, 'nothing commits when a stylesheet fails');
+ assert.deepStrictEqual([p.release.current, p.toasts.length, p.reloads], [A, 1, 0], 'prompted instead');
+ assert.deepStrictEqual(counts(p), { failed: { style: 1 } });
+ await update(p, M2);
+ assert.strictEqual(p.document.querySelectorAll('link[href*="?v=333"]').length, 0, 'a release that failed in place is not retried in place');
+ }
+ {
+ const p = await open();
+ await update(p, M2, { styles: 'timeout' });
+ assert.deepStrictEqual(counts(p), { failed: { 'style-timeout': 1 } });
+ assert.strictEqual(hrefs(p)[0], '/css/app.css?v=111111111111');
+ }
+ {
+ const p = await open();
+ p.state.pageStatus = 500;
+ await update(p, M2);
+ assert.deepStrictEqual([counts(p), hrefs(p)[0], p.toasts.length], [{ failed: { content: 1 } }, '/css/app.css?v=111111111111', 1], 'a region that cannot be fetched: nothing changes, prompt');
+ }
+ {
+ const p = await open({ updateScript: 'fail' });
+ await update(p, M2);
+ assert.deepStrictEqual([counts(p), p.toasts.length, p.release.current], [{ failed: { script: 1 } }, 1, A], 'release-update.js unavailable: the old prompt');
+ }
+ {
+ const p = await open({ config: { inPlace: false } });
+ await update(p, M2);
+ assert.deepStrictEqual([p.toasts.length, hrefs(p)[0], p.release.current], [1, '/css/app.css?v=111111111111', A], 'inPlace: false prompts');
+ }
+ {
+ const cross = { ...M2, assets: { ...M2.assets, '/css/app.css': { url: 'https://evil.test/css/app.css?v=3', component: 'styles' } } };
+ const p = await open({ html: PAGE().replace('<link rel="stylesheet" href="/css/app.css?v=111111111111">', '<link rel="stylesheet" data-ov-asset="/css/app.css" href="/css/app.1111.css">') });
+ await update(p, cross);
+ assert.deepStrictEqual([counts(p), p.release.current], [{ failed: { origin: 1 } }, A], 'an asset on another origin is refused');
+ }
+
+ // ── A script change prompts; the contract ranges force a reload, only when safe ──
+ {
+ const p = await open();
+ await update(p, { ...M2, components: comps({ shell: { kind: 'script', version: 'sh2' } }) });
+ assert.deepStrictEqual([p.toasts.length, p.reloads, hrefs(p)[0]], [1, 0, '/css/app.css?v=111111111111']);
+ }
+ {
+ const p = await open({ hidden: false });
+ await update(p, { ...M2, contract_ranges: api('2.0.0', '^2.0.0') });
+ assert.deepStrictEqual([p.reloads, p.metrics()], [0, { deferred: { active: 1 } }], 'incompatible contracts: a reload, but not under the user');
+ assert.strictEqual(hrefs(p)[0], '/css/app.css?v=111111111111', 'and nothing in place');
+ p.setHidden(true);
+ assert.strictEqual(p.reloads, 1);
+ assert.deepStrictEqual(counts(p), { deferred: { active: 1 }, reloaded: { contract: 1 } });
+ }
+ {
+ // A page one release behind whose server kept accepting it: in place (the N-1 → N window).
+ const p = await open();
+ await update(p, { ...M2, contract_ranges: api('1.4.0', '>=1.0.0 <2.0.0') });
+ assert.strictEqual(p.release.current, B);
+ }
+
+ console.log('release-update: all checks passed');
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -1,13 +1,47 @@
@@ -1,13 +1,47 @@
'use strict';
'use strict';
-// release.js (GET /release.json) and release-watch.js (prompt, and reload only when safe).
-const assert = require('assert'); const vm = require('vm'); const fs = require('fs'); const path = require('path'); const os = require('os');
-const { createRelease } = require('../release');
+// release.js (GET /release.json, the manifest fields, the contract filter, POST /release-metrics) and
+// release-watch.js on a manifest without components (prompt, and reload only when safe).
+const assert = require('assert'); const fs = require('fs'); const path = require('path'); const os = require('os'); const crypto = require('crypto');
+const { createRelease, normalizeRange, satisfies, project } = require('../release');
+const { createRegistry } = require('../metrics');
+const { openPage } = require('../release-compat');
+
+const ROOT = path.join(__dirname, '..');
+const FIXTURE = require('./fixtures/release-manifest.v1.1.0.json');
+const Ajv2020 = require(require.resolve('ajv/dist/2020', { paths: [path.dirname(require.resolve('openvibe-contracts/package.json'))] }));
+const addFormats = require(require.resolve('ajv-formats', { paths: [path.dirname(require.resolve('openvibe-contracts/package.json'))] }));
+const contracts = require('openvibe-contracts');
+
+// The schema a full manifest is checked against: the installed openvibe-contracts once it has 1.1.0,
+// else the copy of Contracts' proposed 1.1.0 in test/fixtures (drop it when the devDependency moves on).
+const installed = contracts.schema('registry.release-manifest@1');
+const v11 = installed.properties.components ? installed : FIXTURE;
+for (const k of Object.keys(installed.properties)) assert.ok(FIXTURE.properties[k], `the 1.1.0 fixture keeps ${k}`);
+const ajv = new Ajv2020({ allErrors: true, strict: true, strictRequired: false, allowUnionTypes: true }); addFormats(ajv);
+const validate11 = ajv.compile(v11);
+const valid11 = (m) => { const ok = validate11(m); return ok || JSON.stringify(validate11.errors); };
(async () => {
(async () => {
- // Server side: manifest from env (no git), metaTag, handler headers.
- const r = createRelease({ service: 'community', root: os.tmpdir(), env: { RELEASE_COMMIT: 'ABCDEF1234567890', RELEASE_AT: '2026-09-23T01:00:00Z' }, minClientRelease: 'nope' });
+ // ── Ranges ─────────────────────────────────────────────────
+ assert.strictEqual(normalizeRange('^1.2.0'), '>=1.2.0 <2.0.0');
+ assert.strictEqual(normalizeRange('^0.4.1'), '>=0.4.1 <0.5.0');
+ assert.strictEqual(normalizeRange('~1.2.3'), '>=1.2.3 <1.3.0');
+ assert.strictEqual(normalizeRange('1.x'), '>=1.0.0 <2.0.0');
+ assert.strictEqual(normalizeRange('1.2'), '>=1.2.0 <1.3.0');
+ assert.strictEqual(normalizeRange('1.2.3'), '>=1.2.3 <1.2.4');
+ assert.strictEqual(normalizeRange('>=1.1.0'), '>=1.1.0 <2.0.0');
+ assert.strictEqual(normalizeRange('>= 1.1.0 < 3.0.0'), '>=1.1.0 <3.0.0');
+ assert.strictEqual(normalizeRange('', '2.3.4'), '>=2.3.4 <3.0.0', 'empty is ^version');
+ assert.throws(() => normalizeRange('>=2.0.0 <1.0.0'));
+ assert.throws(() => normalizeRange('latest'));
+ assert.ok(satisfies('1.9.9', '>=1.0.0 <2.0.0') && !satisfies('2.0.0', '>=1.0.0 <2.0.0') && !satisfies('0.9.0', '>=1.0.0 <2.0.0'));
+
+ // ── A service with no contracts installed: the 1.0.0 manifest, as before ──
+ const r = createRelease({ service: 'community', root: os.tmpdir(), env: { RELEASE_COMMIT: 'ABCDEF1234567890', RELEASE_AT: '2026-09-23T01:00:00Z' }, minClientRelease: 'nope', schema: null });
const m = r.manifest();
const m = r.manifest();
+ assert.deepStrictEqual(Object.keys(m), ['service', 'release', 'released_at', 'booted_at', 'contracts_version', 'packages', 'min_client_release', 'mixed_version_window_hours']);
assert.strictEqual(m.release, 'abcdef123456');
assert.strictEqual(m.release, 'abcdef123456');
+ assert.strictEqual(r.release, 'abcdef123456');
assert.strictEqual(m.released_at, '2026-09-23T01:00:00.000Z');
assert.strictEqual(m.released_at, '2026-09-23T01:00:00.000Z');
assert.strictEqual(m.min_client_release, null, 'a malformed MIN_CLIENT_RELEASE is ignored');
assert.strictEqual(m.min_client_release, null, 'a malformed MIN_CLIENT_RELEASE is ignored');
assert.strictEqual(m.mixed_version_window_hours, 24);
assert.strictEqual(m.mixed_version_window_hours, 24);
@@ -17,112 +51,230 @@ const { createRelease } = require('../release');
@@ -17,112 +51,230 @@ const { createRelease } = require('../release');
assert.strictEqual(headers['Cache-Control'], 'no-cache, max-age=0');
assert.strictEqual(headers['Cache-Control'], 'no-cache, max-age=0');
assert.deepStrictEqual(JSON.parse(body), JSON.parse(JSON.stringify(m)));
assert.deepStrictEqual(JSON.parse(body), JSON.parse(JSON.stringify(m)));
assert.throws(() => createRelease({ service: 'Bad Name' }));
assert.throws(() => createRelease({ service: 'Bad Name' }));
- const fromGit = createRelease({ service: 'shared', root: path.join(__dirname, '..'), env: {} });
- assert.match(fromGit.release, /^[0-9a-f]{7,12}$/);
-
- // Browser side, in a vm with a fake DOM and clock.
- function page({ serverRelease, releasedAt, min = null, active = null, protectedFn = null, hidden = false }) {
- const listeners = {}; const docListeners = {};
- let reloads = 0; const toasts = [];
- const ctx = {
- console, Date, JSON, Promise,
- setInterval: () => 1, clearInterval: () => {},
- location: { reload: () => { reloads++; } },
- addEventListener: (t, f) => { (listeners[t] = listeners[t] || []).push(f); },
- OpenVibeUI: { toast: (msg, o) => toasts.push({ msg, o }) },
- fetch: async () => ({ ok: true, json: async () => ({ release: serverRelease, released_at: releasedAt, min_client_release: min, mixed_version_window_hours: 24 }) }),
- document: {
- hidden,
- activeElement: active,
- querySelector: (sel) => (sel.startsWith('meta') ? { content: 'aaaaaaa', getAttribute: () => '/release.json' } : null),
- querySelectorAll: () => [],
- addEventListener: (t, f) => { docListeners[t] = f; },
- },
- };
- if (protectedFn) ctx.OVProtected = protectedFn;
- ctx.window = ctx; ctx.globalThis = ctx;
- vm.createContext(ctx);
- vm.runInContext(fs.readFileSync(path.join(__dirname, '..', 'release-watch.js'), 'utf8'), ctx);
- return { ctx, toasts, reloads: () => reloads, docListeners };
+ assert.strictEqual(r.full().components.shell.version, 'abcdef123456', 'no declared shell: its version is the release, so every release reloads');
+ assert.strictEqual(r.full().components.server.kind, 'server');
+
+ // ── Components, the asset map, schema generation and contract ranges ──
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-release-'));
+ const write = (rel, text) => { fs.mkdirSync(path.dirname(path.join(tmp, rel)), { recursive: true }); fs.writeFileSync(path.join(tmp, rel), text); };
+ write('public/css/app.css', 'body{color:red}');
+ write('public/js/app.js', 'console.log(1)');
+ write('server/web/layout.js', 'module.exports = 1;');
+ write('docs/a.md', '# A');
+ write('docs/b.md', '# B');
+ const sha12 = (t) => crypto.createHash('sha256').update(t).digest('hex').slice(0, 12);
+ const spec = {
+ service: 'test', root: tmp, env: { RELEASE_COMMIT: '1234567abcdef', RELEASE_AT: '2026-09-23T02:00:00Z' }, schema: v11,
+ components: {
+ styles: { kind: 'style', assets: ['/css/app.css'] },
+ docs: { kind: 'content', files: ['docs'] },
+ shell: { kind: 'script', assets: ['/js/app.js'], files: ['server/web/layout.js'] },
+ },
+ contracts: { 'test.web-api': { version: '1.2.0', accepts: '^1.0.0' }, 'media.object': { version: '1.0.0', role: 'consumes' } },
+ schemaGeneration: () => 7, schemaCompatibleFrom: 5, logger: { warn() {} },
+ };
+ let rel = createRelease(spec);
+ let full = rel.full();
+ assert.strictEqual(valid11(full), true, 'the full manifest validates against registry.release-manifest 1.1.0');
+ assert.deepStrictEqual(rel.manifest(), full, 'a 1.1.0 schema serves every field');
+ assert.deepStrictEqual(Object.keys(full.components).sort(), ['docs', 'server', 'shell', 'styles']);
+ assert.strictEqual(full.components.styles.kind, 'style');
+ assert.match(full.components.styles.version, /^[0-9a-f]{12}$/);
+ assert.strictEqual(full.components.server.version, '1234567abcde');
+ assert.deepStrictEqual(full.assets['/css/app.css'], { url: `/css/app.css?v=${sha12('body{color:red}')}`, component: 'styles', integrity: `sha384-${crypto.createHash('sha384').update('body{color:red}').digest('base64')}` });
+ assert.strictEqual(full.assets['/js/app.js'].component, 'shell');
+ assert.strictEqual(full.schema_generation, 7);
+ assert.strictEqual(full.schema_compatible_from, 5);
+ assert.deepStrictEqual(full.contract_ranges, { 'test.web-api': { version: '1.2.0', accepts: '>=1.0.0 <2.0.0' }, 'media.object': { version: '1.0.0', accepts: '>=1.0.0 <2.0.0', role: 'consumes' } });
+ assert.strictEqual(full.metrics_url, null);
+ assert.deepStrictEqual(rel.warnings, []);
+ const before = full.components;
+ // A CSS-only change moves only the style component; a doc edit only the content one.
+ write('public/css/app.css', 'body{color:blue}');
+ full = rel.refresh() && rel.full();
+ assert.notStrictEqual(full.components.styles.version, before.styles.version);
+ assert.strictEqual(full.components.docs.version, before.docs.version);
+ assert.strictEqual(full.components.shell.version, before.shell.version);
+ write('docs/b.md', '# B, edited');
+ full = rel.refresh() && rel.full();
+ assert.notStrictEqual(full.components.docs.version, before.docs.version);
+ assert.strictEqual(full.components.shell.version, before.shell.version);
+ // A platform package bump is a shell change (the navbar is code the page runs).
+ fs.mkdirSync(path.join(tmp, 'node_modules/openvibe-sdk'), { recursive: true });
+ fs.writeFileSync(path.join(tmp, 'node_modules/openvibe-sdk/package.json'), '{"name":"openvibe-sdk","version":"9.9.9"}');
+ full = rel.refresh() && rel.full();
+ assert.strictEqual(full.packages['openvibe-sdk'], '9.9.9');
+ assert.notStrictEqual(full.components.shell.version, before.shell.version);
+ // recheckMs: a static-only switch is noticed without a restart.
+ rel = createRelease({ ...spec, recheckMs: 1 });
+ const v1 = rel.full().components.styles.version;
+ write('public/css/app.css', 'body{color:green}');
+ await new Promise((res) => setTimeout(res, 5));
+ let served = '';
+ rel.handler({}, { setHeader() {}, end: (b) => { served = b; } });
+ assert.notStrictEqual(JSON.parse(served).components.styles.version, v1, 'the handler re-reads changed files after recheckMs');
+ // Mistakes: a missing file warns (the manifest is advisory); a bad declaration throws at boot.
+ const warned = createRelease({ ...spec, components: { styles: { kind: 'style', assets: ['/css/gone.css'] } } });
+ assert.match(warned.warnings[0], /gone\.css/);
+ assert.throws(() => createRelease({ ...spec, components: { styles: { kind: 'layout' } } }), /kind/);
+ assert.throws(() => createRelease({ ...spec, components: { styles: { kind: 'style', assets: ['/../etc/passwd'] } } }), /publicDir/);
+ assert.throws(() => createRelease({ ...spec, components: { shell: { kind: 'style' } } }), /shell/);
+ assert.throws(() => createRelease({ ...spec, contracts: { 'test.web-api': { version: '1.2.0', accepts: '>=2.0.0' } } }), /excludes its own version/);
+ assert.throws(() => createRelease({ ...spec, contracts: { nodot: '1.0.0' } }), /owner/);
+ fs.rmSync(tmp, { recursive: true, force: true });
+
+ // ── The contract filter: /release.json validates against the contracts the service pins ──
+ const pinned = createRelease({ service: 'shared', root: ROOT, env: {}, components: spec.components, contracts: spec.contracts, logger: { warn() {} } });
+ const v = pinned.validate();
+ assert.strictEqual(v.contract, installed.properties.components ? v.contract : '1.0.0');
+ assert.strictEqual(v.valid, true, `the served manifest validates against the installed openvibe-contracts: ${JSON.stringify(v.errors)}`);
+ if (!installed.properties.components) {
+ assert.deepStrictEqual(pinned.fields(), ['service', 'release', 'released_at', 'booted_at', 'contracts_version', 'packages', 'min_client_release', 'mixed_version_window_hours'], 'contracts 0.30.x: only the 1.0.0 fields are served');
+ assert.strictEqual(contracts.validate('registry.release-manifest@1', pinned.full()).valid, false, 'the unfiltered manifest would fail 1.0.0');
}
}
+ assert.strictEqual(valid11(pinned.full()), true, 'and the full manifest validates against 1.1.0');
+ assert.deepStrictEqual(project({ a: 1, b: { c: 2, d: 3 }, e: { x: { y: 1, z: 2 } } }, { additionalProperties: false, properties: { a: {}, b: { additionalProperties: false, properties: { c: {} } }, e: { additionalProperties: { additionalProperties: false, properties: { y: {} } } } } }), { a: 1, b: { c: 2 }, e: { x: { y: 1 } } });
+
+ // ── POST /release-metrics into release_client_updates_total ──
+ const registry = createRegistry();
+ const collect = r.collect(registry, { perMinute: 3 });
+ const { Readable } = require('stream');
+ const post = async (payload, hdrs = {}, ip = '127.0.0.1') => {
+ const req = Readable.from([Buffer.from(typeof payload === 'string' ? payload : JSON.stringify(payload))]);
+ req.headers = hdrs; req.socket = { remoteAddress: ip };
+ const res = { statusCode: 0, setHeader() {}, end() {} };
+ await collect(req, res);
+ return res.statusCode;
+ };
+ const counter = (outcome, reason) => { const line = registry.metrics().split('\n').find((l) => l.startsWith(`release_client_updates_total{outcome="${outcome}",reason="${reason}"}`)); return line ? Number(line.split(' ').pop()) : 0; };
+ assert.strictEqual(await post({ counts: { applied: { 'content+style': 1 }, deferred: { typing: 2, bogus: 1 }, failed: { style: 500 } } }), 204);
+ assert.strictEqual(counter('applied', 'content+style'), 1);
+ assert.strictEqual(counter('deferred', 'typing'), 2);
+ assert.strictEqual(counter('deferred', 'other'), 1, 'an unknown reason counts as other');
+ assert.strictEqual(counter('failed', 'style'), 50, 'one report counts at most 50 per reason');
+ assert.strictEqual(await post('not json', {}, '10.0.0.2'), 400);
+ assert.strictEqual(await post({ counts: { reloaded: { user: 1 } } }, { 'sec-gpc': '1' }, '10.0.0.3'), 204);
+ assert.strictEqual(counter('reloaded', 'user'), 0, 'Sec-GPC reports are not counted');
+ assert.strictEqual(await post('x'.repeat(5000), {}, '10.0.0.4'), 413);
+ assert.strictEqual(await post({ counts: {} }), 204);
+ assert.strictEqual(await post({ counts: {} }), 204);
+ assert.strictEqual(await post({ counts: {} }), 429, 'an address sends at most perMinute reports a minute');
+
+ // mount(): GET /release.json and POST /release-metrics on a real app, behind express.json(), fed by
+ // the text/plain body navigator.sendBeacon sends; the manifest then says where to report.
+ {
+ const express = require('express');
+ const app = express();
+ app.use(express.json());
+ const reg = createRegistry();
+ const svc = createRelease({ service: 'test', root: ROOT, env: {}, schema: v11, logger: { warn() {} } }).mount(app, { registry: reg });
+ assert.strictEqual(svc.full().metrics_url, '/release-metrics');
+ assert.match(svc.metaTag(), /data-metrics="\/release-metrics"/);
+ const server = await new Promise((res) => { const s = app.listen(0, '127.0.0.1', () => res(s)); });
+ const base = `http://127.0.0.1:${server.address().port}`;
+ const got = await (await fetch(`${base}/release.json`)).json();
+ assert.strictEqual(got.metrics_url, '/release-metrics');
+ const beacon = await fetch(`${base}/release-metrics`, { method: 'POST', headers: { 'content-type': 'text/plain;charset=UTF-8' }, body: JSON.stringify({ counts: { applied: { style: 1 }, reloaded: { window: 1 } } }) });
+ assert.strictEqual(beacon.status, 204);
+ const json = await fetch(`${base}/release-metrics`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ counts: { applied: { style: 1 } } }) });
+ assert.strictEqual(json.status, 204);
+ assert.match(reg.metrics(), /release_client_updates_total\{outcome="applied",reason="style"\} 2/);
+ assert.match(reg.metrics(), /release_client_updates_total\{outcome="reloaded",reason="window"\} 1/);
+ server.close();
+ }
+
+ // ── The tab, on manifests without components (every service before 1.5.0 + contracts 0.31) ──
const fresh = new Date(Date.now() - 3600e3).toISOString();
const fresh = new Date(Date.now() - 3600e3).toISOString();
const stale = new Date(Date.now() - 30 * 3600e3).toISOString();
const stale = new Date(Date.now() - 30 * 3600e3).toISOString();
-
- let p = page({ serverRelease: 'aaaaaaa', releasedAt: fresh });
- await p.ctx.OVRelease.check();
+ const HTML = (extra = '', body = '') => `<!doctype html><html><head><meta name="ov-release" content="aaaaaaa" data-url="/release.json">${extra}</head><body>${body}</body></html>`;
+ async function tab({ server, releasedAt = fresh, min = null, hidden = true, html = HTML(), protectedFn = null, focus = null, config = { metricsUrl: '/release-metrics' } }) {
+ const manifest = { service: 'test', release: server, released_at: releasedAt, min_client_release: min, mixed_version_window_hours: 24 };
+ const page = await openPage({ url: 'https://site.test/', html, hidden, protectedFn, config, serve: (u) => (u.endsWith('/release.json') ? { json: manifest } : { status: 404 }) });
+ if (focus) page.focus(page.document.querySelector(focus));
+ await page.settle();
+ return page;
+ }
+ let p = await tab({ server: 'aaaaaaa' });
+ await p.release.check(); await p.settle();
assert.strictEqual(p.toasts.length, 0, 'same release: nothing');
assert.strictEqual(p.toasts.length, 0, 'same release: nothing');
- p = page({ serverRelease: 'bbbbbbb', releasedAt: fresh, hidden: true });
- await p.ctx.OVRelease.check();
+ p = await tab({ server: 'bbbbbbb' });
assert.strictEqual(p.toasts.length, 1, 'newer release: one prompt');
assert.strictEqual(p.toasts.length, 1, 'newer release: one prompt');
assert.strictEqual(p.toasts[0].o.ttl, 0, 'the prompt stays until dismissed');
assert.strictEqual(p.toasts[0].o.ttl, 0, 'the prompt stays until dismissed');
- assert.strictEqual(p.reloads(), 0, 'inside the window: never an automatic reload');
- await p.ctx.OVRelease.check();
+ assert.strictEqual(p.reloads, 0, 'inside the window: never an automatic reload');
+ await p.release.check(); await p.settle();
assert.strictEqual(p.toasts.length, 1, 'prompted once');
assert.strictEqual(p.toasts.length, 1, 'prompted once');
+ assert.ok(!p.document.querySelector('script[src*="release-update.js"]'), 'a manifest without components needs no release-update.js');
+ p.toasts[0].o.action.onClick();
+ assert.strictEqual(p.reloads, 1, 'Reload reloads');
+ assert.deepStrictEqual(p.beacons.at(-1).body.counts, { reloaded: { user: 1 } }, 'and is counted before the page goes');
- p = page({ serverRelease: 'bbbbbbb', releasedAt: stale, hidden: true });
- await p.ctx.OVRelease.check();
- assert.strictEqual(p.reloads(), 1, 'outside the window and hidden: reloads');
+ p = await tab({ server: 'bbbbbbb', releasedAt: stale });
+ assert.strictEqual(p.reloads, 1, 'outside the window and hidden: reloads');
+ assert.deepStrictEqual(p.beacons[0].body.counts, { reloaded: { window: 1 } });
+ assert.strictEqual(p.beacons[0].to, '/release-metrics');
- p = page({ serverRelease: 'bbbbbbb', releasedAt: fresh, min: 'bbbbbbb', hidden: true });
- await p.ctx.OVRelease.check();
- assert.strictEqual(p.reloads(), 1, 'server requires the new release: reloads when safe');
+ p = await tab({ server: 'bbbbbbb', min: 'bbbbbbb' });
+ assert.strictEqual(p.reloads, 1, 'server requires the new release: reloads when safe');
+ assert.deepStrictEqual(p.metrics(), { reloaded: { required: 1 } });
- p = page({ serverRelease: 'bbbbbbb', releasedAt: stale, hidden: true, active: { tagName: 'TEXTAREA' } });
- await p.ctx.OVRelease.check();
- assert.strictEqual(p.reloads(), 0, 'never while a text field has focus');
+ p = await tab({ server: 'bbbbbbb', releasedAt: stale, html: HTML('', '<textarea></textarea>'), focus: 'textarea' });
+ assert.strictEqual(p.reloads, 0, 'never while a text field has focus');
+ p.tick(); p.tick();
+ assert.deepStrictEqual(p.metrics(), { deferred: { typing: 1 } }, 'a deferral is counted once per release and reason');
+ p.blur(); p.tick();
+ assert.strictEqual(p.reloads, 1, 'and reloads once the field lets go');
- p = page({ serverRelease: 'bbbbbbb', releasedAt: stale, hidden: true, protectedFn: () => true });
- await p.ctx.OVRelease.check();
- assert.strictEqual(p.reloads(), 0, 'never during a protected session (upload, call, broadcast)');
+ p = await tab({ server: 'bbbbbbb', releasedAt: stale, protectedFn: () => true });
+ assert.strictEqual(p.reloads, 0, 'never during a protected session (upload, call, broadcast)');
+ assert.deepStrictEqual(p.metrics(), { deferred: { protected: 1 } });
+ p = await tab({ server: 'bbbbbbb', releasedAt: stale, html: HTML('', '<form data-dirty="true"></form>') });
+ assert.deepStrictEqual([p.reloads, p.metrics()], [0, { deferred: { dirty: 1 } }], 'never with unsent form input');
+
+ p = await tab({ server: 'bbbbbbb', releasedAt: stale, html: HTML('', '<video></video>') });
+ assert.deepStrictEqual([p.reloads, p.metrics()], [0, { deferred: { media: 1 } }], 'never while a stream is playing (a linkedom <video> is not paused)');
{
{
- const playing = page({ serverRelease: 'bbbbbbb', releasedAt: stale, hidden: true });
- playing.ctx.document.querySelectorAll = (sel) => (sel === 'video, audio' ? [{ paused: false, ended: false }] : []);
- await playing.ctx.OVRelease.check();
- assert.strictEqual(playing.reloads(), 0, 'never while a stream is playing');
- const capture = page({ serverRelease: 'bbbbbbb', releasedAt: stale, hidden: true });
- capture.ctx.document.querySelectorAll = () => [{ paused: true, ended: false, srcObject: { getTracks: () => [{ readyState: 'live' }] } }];
- await capture.ctx.OVRelease.check();
- assert.strictEqual(capture.reloads(), 0, 'never while the camera/mic is live');
+ // A paused <video> with a live camera track is a capture: served stale, it waits.
+ const html = HTML('', '<video></video>');
+ const manifest = { service: 'test', release: 'bbbbbbb', released_at: stale, min_client_release: null, mixed_version_window_hours: 24 };
+ const page = await openPage({ url: 'https://site.test/', html, hidden: true, serve: () => ({ json: manifest }) });
+ const vid = page.document.querySelector('video');
+ vid.paused = true; vid.srcObject = { getTracks: () => [{ readyState: 'live' }] };
+ await page.settle();
+ assert.deepStrictEqual([page.reloads, page.metrics()], [0, { deferred: { capture: 1 } }], 'never while the camera/mic is live');
}
}
- p = page({ serverRelease: 'bbbbbbb', releasedAt: stale, hidden: false });
- await p.ctx.OVRelease.check();
- assert.strictEqual(p.reloads(), 0, 'a visible tab someone just used is not reloaded');
+ p = await tab({ server: 'bbbbbbb', releasedAt: stale, hidden: false });
+ assert.deepStrictEqual([p.reloads, p.metrics()], [0, { deferred: { active: 1 } }], 'a visible tab someone just used is not reloaded');
+ p.setHidden(true);
+ assert.strictEqual(p.reloads, 1, 'hiding it is the safe moment');
+ assert.deepStrictEqual(p.beacons.map((b) => b.body.counts), [{ deferred: { active: 1 } }, { reloaded: { window: 1 } }], 'hiding sends the deferral, the reload its own count');
// Without a meta tag the first /release.json read is the baseline; a later release prompts.
// Without a meta tag the first /release.json read is the baseline; a later release prompts.
{
{
let served = 'ccccccc';
let served = 'ccccccc';
- const listeners = {}; const toasts = [];
- const ctx = {
- console, Date, JSON, Promise, setInterval: () => 1, clearInterval: () => {},
- location: { reload: () => {} }, addEventListener: (t, f) => { listeners[t] = f; },
- OpenVibeUI: { toast: (msg, o) => toasts.push({ msg, o }) },
- fetch: async () => ({ ok: true, json: async () => ({ release: served, released_at: fresh, min_client_release: null, mixed_version_window_hours: 24 }) }),
- document: { hidden: true, activeElement: null, querySelector: () => null, querySelectorAll: () => [], addEventListener: () => {} },
- };
- ctx.window = ctx; ctx.globalThis = ctx; vm.createContext(ctx);
- vm.runInContext(fs.readFileSync(path.join(__dirname, '..', 'release-watch.js'), 'utf8'), ctx);
- await new Promise((r) => setImmediate(r));
- assert.strictEqual(ctx.OVRelease.current, 'ccccccc', 'baseline learned from /release.json');
- await ctx.OVRelease.check();
- assert.strictEqual(toasts.length, 0);
+ const page = await openPage({ url: 'https://site.test/', html: '<html><head></head><body></body></html>', hidden: true, serve: () => ({ json: { service: 'test', release: served, released_at: fresh, min_client_release: null, mixed_version_window_hours: 24 } }) });
+ await page.settle();
+ assert.strictEqual(page.release.current, 'ccccccc', 'baseline learned from /release.json');
+ await page.release.check(); await page.settle();
+ assert.strictEqual(page.toasts.length, 0);
served = 'ddddddd';
served = 'ddddddd';
- await ctx.OVRelease.check();
- assert.strictEqual(toasts.length, 1, 'a later release prompts');
+ await page.release.check(); await page.settle();
+ assert.strictEqual(page.toasts.length, 1, 'a later release prompts');
+ assert.strictEqual(page.beacons.length, 0, 'no metrics URL configured: nothing is sent');
+ }
+ {
+ const page = await openPage({ url: 'https://site.test/', html: '<html><head></head><body></body></html>', hidden: true, serve: () => ({ status: 404 }) });
+ await page.settle();
+ page.tick(); await page.settle();
+ assert.strictEqual(page.requests.length, 1, 'a site without /release.json is asked once, then left alone');
}
}
{
{
- let calls = 0;
- const ctx = {
- console, Date, JSON, Promise, setInterval: () => 1, clearInterval: () => {}, location: { reload: () => {} }, addEventListener: () => {},
- fetch: async () => { calls++; return { ok: false, json: async () => null }; },
- document: { hidden: true, activeElement: null, querySelector: () => null, querySelectorAll: () => [], addEventListener: () => {} },
- };
- ctx.window = ctx; ctx.globalThis = ctx; vm.createContext(ctx);
- vm.runInContext(fs.readFileSync(path.join(__dirname, '..', 'release-watch.js'), 'utf8'), ctx);
- await new Promise((r) => setImmediate(r));
- assert.strictEqual(calls, 1, 'a site without /release.json is asked once, then left alone');
+ // A metrics URL on another origin is never used.
+ const page = await tab({ server: 'bbbbbbb', releasedAt: stale, config: { metricsUrl: 'https://elsewhere.test/collect' } });
+ assert.strictEqual(page.reloads, 1);
+ assert.strictEqual(page.beacons.length, 0);
}
}
console.log('release: all checks passed');
console.log('release: all checks passed');