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

OP OpenVibers committed 23 Sept 2026 · 4b9e5d72d8 · 14 files, +2053 −177

/release.json can now say what changed and what the server still accepts: the registry.release-manifest 1.1.0 fields (openvibe-contracts v0.31.0). The served manifest keeps only the fields the service's installed contracts schema declares, so every service that pins openvibe-contracts <= 0.30.x keeps serving (and validating) exactly its 1.0.0 manifest.

- release.js: createRelease() takes components ({ id: { kind: style|content|script|server, assets (URL paths under publicDir), files (paths under root, directories recursed), version? } }). A component's version is the hash of its files, and the asset map gives each asset's ?v=<sha256/12> URL (assetUrl overrides it) plus its sha384 integrity. An undeclared shell is versioned by the release, so every release still reloads. The package versions are part of the shell's hash. It also takes contracts ({ id: version | { version, accepts, role } }), with ^, ~, x, >= ranges normalised to ">=a.b.c <x.y.z" and a range that excludes its own version refused at boot, plus schemaGeneration / schemaCompatibleFrom (numbers or functions), recheckMs (a static-only switch is noticed) and metricsPath. New methods: full(), fields(), validate() (with the service's openvibe-contracts), refresh(), mount(app, { registry }) (GET /release.json + POST /release-metrics) and collect(registry). A missing file warns: the manifest is advisory.
- POST /release-metrics feeds release_client_updates_total{outcome,reason} in the service's openvibe-shared/metrics registry, so it appears in /metrics. Outcomes are applied, reloaded, deferred and failed, each with a fixed set of reasons (an unknown one counts as other). A report is at most 4 KB, counts at most 50 per reason, and one address sends at most 30 a minute. Sec-GPC/DNT reports are dropped. It reads the text/plain body sendBeacon sends even behind express.json().
- release-watch.js (every page, after first paint) now reads the page's own manifest at load and counts outcomes, beaconing them (same origin only) on hide, after an update and before a reload. A release with components or contract ranges goes to the new release-update.js, loaded on demand from the same directory. It applies the release in place when only style, content or server components changed and the contract ranges hold both ways. New <link>s load beside the old ones, which go only once every new one has loaded. [data-ov-content] regions are re-fetched (data-ov-src, default the page URL) without scripts, frames, meta or inline handlers, and each is replaced only when nothing in it has focus, is protected or plays; otherwise it waits (counted once). Any failure keeps the old page and prompts. Contracts out of range force a reload, still only when safe. The old rules are unchanged: one prompt, and no reload while media plays, a capture is live, a field has focus, a form is dirty or OVProtected() says so. OVReleaseConfig.inPlace: false opts out.
- navbar.js: init({ releaseWatch: { ... } }) becomes OVReleaseConfig.
- release-compat.js (Node, for service tests): runMixedVersion/assertMixedVersion run the page x server matrix from the manifests' contract ranges. They call the real server where the manifests say compatible, require a reload where they say incompatible, and require adjacent releases to be compatible both ways. Also replay(), manifestFor(), consumerCompatible(), rollbackSafe(), formatMatrix() and openPage() (release-watch in a linkedom page).
- Sizes (brotli q11): release-watch.js 1.7 -> 3.1 KB. release-update.js 2.9 KB, loaded only when needed. navbar.js +21 bytes. Both release files have a 3.5 KB budget test.
- Tests: release (ranges, components, asset map, the contract filter against the installed openvibe-contracts v0.30.2 and the 1.1.0 schema, the collector through a real express app, every reload rule through openPage), release-update (in place, transactional, deferral, failures, contract reloads, beacons), release-mixed-version (three releases of a real service plus one whose manifest lies). linkedom and openvibe-contracts v0.30.2 are devDependencies. test/fixtures/release-manifest.v1.1.0.json is a copy of Contracts' 1.1.0 schema, used until that devDependency moves to v0.31.0. The CI tarball check also loads release and release-compat.

Changes

ddiff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
iindex 3399bf3..4c745c1 100644
---- a/.github/workflows/ci.yml
++++ b/.github/workflows/ci.yml
@@@ -24,7 +24,7 @@ jobs:
git archive --format=tar.gz --prefix=OpenVibe.Shared-ci/ -o "$RUNNER_TEMP/openvibe-shared.tgz" HEAD
mkdir "$RUNNER_TEMP/consumer" && cd "$RUNNER_TEMP/consumer" && npm init -y >/dev/null
npm install --no-audit --no-fund "$RUNNER_TEMP/openvibe-shared.tgz"
-- node -e "require('openvibe-shared'); require('openvibe-shared/navbar'); require('openvibe-shared/legal'); 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
ddiff --git a/README.md b/README.md
iindex 4f2ea67..1022e5b 100644
---- a/README.md
++++ b/README.md
@@@ -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 a
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
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.
| 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 serve
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)
```js
@@@ -151,7 +213,12 @@ node scripts/build-theme-loader.js
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
fetch.
- **Generated blocks**: the generated navbar and theme-loader blocks must be up to date.
ddiff --git a/files.js b/files.js
iindex 3bb9fb8..2c38c73 100644
---- a/files.js
++++ b/files.js
@@@ -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
]);
ddiff --git a/navbar.js b/navbar.js
iindex 9eb593a..a4a3873 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -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 { /* */ }
ddiff --git a/package-lock.json b/package-lock.json
iindex 34043c0..f1ef5af 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex 0188a97..6a7bd0d 100644
---- a/package.json
++++ b/package.json
@@@ -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"
}
}
ddiff --git a/release-compat.js b/release-compat.js
nnew file mode 100644
iindex 0000000..326abfb
---- /dev/null
++++ b/release-compat.js
@@@ -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,
++};
ddiff --git a/release-update.js b/release-update.js
nnew file mode 100644
iindex 0000000..dd05b4a
---- /dev/null
++++ b/release-update.js
@@@ -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);
ddiff --git a/release-watch.js b/release-watch.js
iindex 2894e43..4805d60 100644
---- a/release-watch.js
++++ b/release-watch.js
@@@ -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);
ddiff --git a/release.js b/release.js
iindex 40b6c74..02517c6 100644
---- a/release.js
++++ b/release.js
@@@ -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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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 };
ddiff --git a/test/fixtures/release-manifest.v1.1.0.json b/test/fixtures/release-manifest.v1.1.0.json
nnew file mode 100644
iindex 0000000..52c9449
---- /dev/null
++++ b/test/fixtures/release-manifest.v1.1.0.json
@@@ -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."
++ }
++ }
++}
ddiff --git a/test/release-mixed-version.test.js b/test/release-mixed-version.test.js
nnew file mode 100644
iindex 0000000..cff1802
---- /dev/null
++++ b/test/release-mixed-version.test.js
@@@ -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); });
ddiff --git a/test/release-update.test.js b/test/release-update.test.js
nnew file mode 100644
iindex 0000000..489490e
---- /dev/null
++++ b/test/release-update.test.js
@@@ -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); });
ddiff --git a/test/release.test.js b/test/release.test.js
iindex 7db4b6b..80b1c31 100644
---- a/test/release.test.js
++++ b/test/release.test.js
@@@ -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');