Skip to content

Commit 79fd3e8

Browse files
committed
feat(updates): a bundle says which core it needs, and is refused when it is below
The core, the Admin UI and the Player are three artefacts on three release cadences, each behind its own button, pressed in whatever order somebody chooses. Nothing related them. A console built against endpoints this core does not have installed onto it without a word, and the result is not an error anyone sees — a page that renders and then quietly 404s. Two mechanisms, with different jobs. The channel decides the ordinary case. Web bundles had none: both the fetch scripts and the in-place update used `releases/latest/download/...`, and that endpoint never resolves to a prerelease — so a beta bundle could not reach a beta install at all, and every UI change had to ship as a stable release that then also landed on installs still running 3.1.0. Bundles now follow the channel of the core serving them, with stables still eligible behind the prereleases, because these repos may publish none. Within the channel the newest release whose `minCore` this core satisfies wins, so an install on an older core receives the last bundle built for it rather than a dead button. Release manifests are published beside the tarball so that choice costs one small JSON per candidate instead of a download. `minCore` is the backstop. Each bundle states the oldest core that can serve it, and the update preflight reads it out of the staging directory before the swap — nothing has moved yet, so a refusal costs nothing and rolls nothing back. 409 rather than 500: the install was declined, the previous bundle is untouched, and the fix is to update the core first. Both fields are open on unknowns. A bundle that states no minimum is every bundle released before today, and a core reporting `dev` is a working copy; neither may be gated. Alongside: - `shared/semver.ts` replaces a comparator that split on `[.-]` and so read `4.0.0` as older than its own `4.0.0-beta.21`. Harmless while it only ordered speaker builds; wrong the moment it decides an install, and this project has spent its whole 4.0 cycle on prereleases. - `/info` reports both bundles with whether they fit, added beside `player` rather than replacing it: an Admin UI older than this server has to keep reading what it always read. It also reports what this core asks of its bundles, which cannot be enforced — a console too old to serve is already the one rendering the page — only said. - The prerelease lookup swallowed nothing, so a repo answering 404 would have rejected under the shared `Promise.all` and nulled every version in the batch. It now propagates only a refusal, which is what tells the cache to keep what it knew. - One listing per repo answers both "newest release" and "newest prerelease", so covering three repos instead of one costs no more of the hourly budget than before.
1 parent 131082c commit 79fd3e8

11 files changed

Lines changed: 975 additions & 73 deletions

File tree

package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@
88
"url": "git+https://github.com/sonn-audio/core.git"
99
},
1010
"main": "dist/server.js",
11+
"sonn": {
12+
"minAdminUi": null,
13+
"minPlayer": null
14+
},
1115
"_moduleAliases": {
1216
"@": "dist"
1317
},

scripts/bundleCompat.mjs

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
/**
2+
* The build-time half of the bundle compatibility check.
3+
*
4+
* `fetch:admin` and `fetch:player` pull whatever release the bundle repos call latest, into
5+
* a core checked out at whatever version it happens to be. Nothing compared the two, so a
6+
* console built against endpoints this core does not have could be baked straight into a
7+
* release image — the one place a mismatch is fully preventable, and the one where it is
8+
* hardest to notice afterwards.
9+
*
10+
* Deliberately a hard failure rather than a warning. The asymmetry is what makes that safe:
11+
* it can only fire when the *bundle* is ahead of the core being built, never when the core
12+
* is ahead of the bundle, so an ordinary release — core moves first, bundles follow — never
13+
* sees it. `SONN_SKIP_BUNDLE_COMPAT=1` is there for the deliberate exception.
14+
*
15+
* A plain `.mjs` duplicate of `src/shared/semver.ts` on purpose: these scripts run before
16+
* `tsc` has produced `dist/`, so they cannot import the compiled module they mirror. The
17+
* behaviour is pinned on the TypeScript side by `tests/bundleCompat.test.ts`.
18+
*/
19+
import { promises as fs } from 'node:fs';
20+
import { join } from 'node:path';
21+
22+
function parseVersion(input) {
23+
const trimmed = String(input ?? '').trim().replace(/^v/i, '');
24+
if (!trimmed) return null;
25+
const [withoutBuild] = trimmed.split('+', 1);
26+
const [core, pre] = (withoutBuild ?? '').split('-', 2);
27+
const parts = (core ?? '').split('.').map((part) => Number.parseInt(part.replace(/\D+.*$/, ''), 10));
28+
if (parts.length === 0 || parts.some((part) => Number.isNaN(part))) return null;
29+
while (parts.length < 3) parts.push(0);
30+
return { parts, prerelease: pre ? pre.trim() : null };
31+
}
32+
33+
function comparePrerelease(a, b) {
34+
if (!a && !b) return 0;
35+
if (!a) return 1;
36+
if (!b) return -1;
37+
const left = a.split('.');
38+
const right = b.split('.');
39+
for (let i = 0; i < Math.max(left.length, right.length); i += 1) {
40+
const l = left[i];
41+
const r = right[i];
42+
if (l === undefined) return -1;
43+
if (r === undefined) return 1;
44+
if (l === r) continue;
45+
const lNum = /^\d+$/.test(l);
46+
const rNum = /^\d+$/.test(r);
47+
if (lNum && rNum) {
48+
if (Number(l) < Number(r)) return -1;
49+
if (Number(l) > Number(r)) return 1;
50+
continue;
51+
}
52+
if (lNum && !rNum) return -1;
53+
if (!lNum && rNum) return 1;
54+
return l < r ? -1 : 1;
55+
}
56+
return 0;
57+
}
58+
59+
export function compareVersions(a, b) {
60+
const left = parseVersion(a);
61+
const right = parseVersion(b);
62+
if (!left || !right) return 0;
63+
for (let i = 0; i < Math.max(left.parts.length, right.parts.length); i += 1) {
64+
const l = left.parts[i] ?? 0;
65+
const r = right.parts[i] ?? 0;
66+
if (l < r) return -1;
67+
if (l > r) return 1;
68+
}
69+
return comparePrerelease(left.prerelease, right.prerelease);
70+
}
71+
72+
/** Open on both unknowns: a bundle that states nothing, and a core that cannot be read. */
73+
export function satisfiesMin(running, minimum) {
74+
const min = String(minimum ?? '').trim();
75+
const have = String(running ?? '').trim();
76+
if (!min || !have) return true;
77+
if (!parseVersion(min) || !parseVersion(have)) return true;
78+
return compareVersions(have, min) >= 0;
79+
}
80+
81+
export async function readCoreVersion(cwd) {
82+
try {
83+
const pkg = JSON.parse(await fs.readFile(join(cwd, 'package.json'), 'utf8'));
84+
return typeof pkg.version === 'string' ? pkg.version : null;
85+
} catch {
86+
return null;
87+
}
88+
}
89+
90+
/**
91+
* Throws when the bundle now sitting in `dir` needs a newer core than this one.
92+
*
93+
* Reads the manifest the bundle build emits; a directory without one is a release from
94+
* before manifests existed and passes, which is what keeps this from breaking every
95+
* existing install the day it lands.
96+
*/
97+
export async function assertBundleFitsCore(dir, coreVersion, label) {
98+
if ((process.env.SONN_SKIP_BUNDLE_COMPAT ?? '').trim() === '1') return;
99+
let manifest;
100+
try {
101+
manifest = JSON.parse(await fs.readFile(join(dir, 'version.json'), 'utf8'));
102+
} catch {
103+
return;
104+
}
105+
const minCore = typeof manifest?.minCore === 'string' ? manifest.minCore.trim() : '';
106+
if (!minCore || satisfiesMin(coreVersion, minCore)) return;
107+
108+
const version = typeof manifest?.version === 'string' ? manifest.version : 'unknown';
109+
throw new Error(
110+
`${label} ${version} requires server core ${minCore}, but this checkout is ${coreVersion}.\n` +
111+
` Pin an older bundle (e.g. ADMINUI_RELEASE / PLAYER_RELEASE=vX.Y.Z), bump the core,\n` +
112+
` or set SONN_SKIP_BUNDLE_COMPAT=1 if you know this pairing is fine.`,
113+
);
114+
}

scripts/fetch-admin-dist.mjs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { join } from 'node:path';
55
import { pipeline } from 'node:stream/promises';
66
import { spawn } from 'node:child_process';
77
import https from 'node:https';
8+
import { assertBundleFitsCore, readCoreVersion } from './bundleCompat.mjs';
89

910
const repo = 'sonn-audio/adminui';
1011
const assetName = 'admin-dist.tgz';
@@ -103,3 +104,7 @@ if (await hasLocalAdminUi()) {
103104
await extract(archivePath, targetDir);
104105
await fs.rm(archivePath, { force: true });
105106
}
107+
108+
// After both paths, because a checked-in UI can outrun this core exactly as a downloaded
109+
// one can — and the local build is the case where it happens during development.
110+
await assertBundleFitsCore(targetDir, await readCoreVersion(process.cwd()), 'admin ui');

scripts/fetch-player-dist.mjs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { fileURLToPath } from 'node:url';
66
import { pipeline } from 'node:stream/promises';
77
import { spawn } from 'node:child_process';
88
import https from 'node:https';
9+
import { assertBundleFitsCore, readCoreVersion } from './bundleCompat.mjs';
910

1011
const comingSoonHtml = join(dirname(fileURLToPath(import.meta.url)), 'player-coming-soon.html');
1112

@@ -114,3 +115,9 @@ if (await hasLocalPlayer()) {
114115
await fs.copyFile(comingSoonHtml, join(targetDir, 'index.html'));
115116
}
116117
}
118+
119+
// Outside the catch above on purpose. That fallback answers "there is no player to install",
120+
// and an incompatible one is a different thing: quietly swapping it for a placeholder would
121+
// turn a fixable version mismatch into a build that ships without a player and says so once,
122+
// in a warning nobody reads. The placeholder itself carries no manifest, so it passes here.
123+
await assertBundleFitsCore(targetDir, await readCoreVersion(process.cwd()), 'player');

0 commit comments

Comments
 (0)