From 2ffacd1cfff06aaa33af0dd62fd4d99f8cf27aee Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sun, 20 Sep 2026 12:24:35 +0300 Subject: [PATCH] [fix] content refs: scenes@format-2 and packs@format-1, never a semver-looking name (#230) - SCENES_BASE default moves scenes@v2 -> scenes@format-2 and PACKS_BASE packs@v1 -> packs@format-1; contentBase() is untouched, so the deploy-time VITE_* override still wins (production keeps VITE_SCENES_BASE=...scenes@main until this release ships) - WHY: jsDelivr parses `v2` as a SEMVER VERSION (`x-jsd-version-type: version`, `cache-control: immutable` for a year), so `git tag -f v2` was a no-op forever and four purges changed nothing -- the Games tab shipped three games while the feed had six. Measured today: a plain TAG named `format-2` (scenes, a5ebe8f) and `format-1` (packs, 03b9568) are reported `x-jsd-version-type: branch` with `s-maxage=43200`, and scenes@format-2/index.json lists all six games (towers, stars-room, football, jam-room, dungeon-realms, untangle). packs@v1 was the identical trap, unexploded only because it had never been retagged, so it moves in the same change - the three games suites' feed fallback follows (game-untangle / game-football / game-dungeon-realms), plus the one packs@v1 asset URL in scripts/author-templates.cjs (sha256-pinned, same bytes at format-1) - tests/unit/contentBase.test.js (4): the override wins with a trailing slash trimmed; an absent override ships the fallback byte-identically; the two consumers' SOURCE literals are the format-N refs; and no shipped fallback names a ref jsDelivr would parse as a version. Counterfactual: scenes@v2 restored in sceneTemplates.js -> 2 of 4 red ("the scenes default is scenes@format-2" and "no shipped fallback names a ref jsDelivr would parse as a semver version"); restored byte-identically -> 4/4 - docs: the SCENES_BASE/PACKS_BASE JSDoc carries the ritual and the measurement; PACKS.md, CLAUDE.md (games-suite line + the #230 gotcha closed) and the e2e skill name the new refs. The scenes/packs READMEs and cloud's .env.deploy.example move in their own PRs (scenes#7, packs#1, cloud#28) - gates: svelte-check 336/47 with byte-identical message lists; vitest 182 (base 178); battery held (see the handover table); build green server-down Co-Authored-By: Claude Opus 5 (1M context) --- .claude/skills/e2e-verify/SKILL.md | 2 +- CLAUDE.md | 16 ++++- PACKS.md | 7 ++- scripts/author-templates.cjs | 2 +- src/lib/contentBase.js | 10 ++-- src/lib/packs.js | 14 +++-- src/lib/sceneTemplates.js | 24 ++++++-- tests/e2e/game-dungeon-realms.test.cjs | 4 +- tests/e2e/game-football.test.cjs | 4 +- tests/e2e/game-untangle.test.cjs | 2 +- tests/unit/contentBase.test.js | 81 ++++++++++++++++++++++++++ 11 files changed, 141 insertions(+), 25 deletions(-) create mode 100644 tests/unit/contentBase.test.js diff --git a/.claude/skills/e2e-verify/SKILL.md b/.claude/skills/e2e-verify/SKILL.md index ba77100a..8f592cfb 100644 --- a/.claude/skills/e2e-verify/SKILL.md +++ b/.claude/skills/e2e-verify/SKILL.md @@ -136,7 +136,7 @@ Rules: never run suites in parallel AGAINST THE SAME dev server, never edit sour while one runs (HMR reloads the pages mid-test — see "HMR churn makes runs LIE"). -The GAMES-TAB line (real scene from the scenes feed @v2 + real module zips from a packed +The GAMES-TAB line (real scene from the scenes feed @format-2 + real module zips from a packed sibling modules checkout, every one SKIPS-never-fails when a source is missing): `game-towers` (20), `game-stars-room` (36), `game-football` (102, two peers + late joiner; FOOTBALL_TPSCENE / FOOTBALL_ZIP), `game-dungeon-realms` (64, two peers + late joiner, TWO zips: diff --git a/CLAUDE.md b/CLAUDE.md index 19748088..15e1b4f0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2129,7 +2129,7 @@ loadable play content. Everything a user does must be visible to connected peers Towers in eight numbers and Football's HUD is authored by its module). Suites `game-dungeon-realms` (64, two peers + late joiner; env DUNGEON_REALMS_TPSCENE / DUNGEON_KIT_ZIP / DUNGEON_REALMS_ZIP / MODULES_REPO), `game-untangle` (46; UNTANGLE_TPSCENE / - UNTANGLE_ZIP), `game-football` (102; scene from the scenes feed @v2, zip from a packed + UNTANGLE_ZIP), `game-football` (102; scene from the scenes feed @format-2, zip from a packed sibling modules checkout — skips, never fails, when every source misses). - `playMode.js` `embedMode` / `embedSceneId` / `embedOpenUrl()` (R29 fork 4): the additive `?embed=1` boot flag, read ONCE at module evaluation (before the cloud plugin clears the @@ -2243,6 +2243,20 @@ loadable play content. Everything a user does must be visible to connected peers purgeable). So a moving ref on jsDelivr must be a branch or a tag name that is not a version (`format-2`); `packs@v1` has the same trap waiting. Core ticket #230; the deploy-time unblock is `VITE_SCENES_BASE=…scenes@main` (what `contentBase()` exists for). + **29f CLOSED IT BY MEASUREMENT**: a plain TAG named `format-2` (scenes, at a5ebe8f) and + `format-1` (packs, at 03b9568) are reported `x-jsd-version-type: branch` with + `s-maxage=43200`, while `@v2`/`@v1` carry `cache-control: immutable` for a year — so the + tag form works and no branch was needed. `SCENES_BASE`/`PACKS_BASE` default to the + `format-N` refs now, the three games suites' feed fallback with them, and + `tests/unit/contentBase.test.js` reads both source files and refuses any fallback whose ref + matches jsDelivr's version rule (an optional v, then dotted digits, nothing else). `v1`/`v2` + are DEAD refs, left where jsDelivr first resolved them for the builds that shipped against + them. Production keeps the cloud `.env.deploy` `VITE_SCENES_BASE=…scenes@main` override until + the core release carrying this ships; the scenes/packs READMEs and cloud's + `.env.deploy.example` teach the new ritual. THE EDITING TRAP THIS FOUND: a JS + `String.replace(from, to)` with a STRING `to` expands `$\``, `$'`, `$&` — a doc edit whose + replacement text quoted a regex ending in `$` followed by a backtick pasted 2255 lines of + this file into itself. Use `split(from).join(to)` for literal replacement. - **flowbite-svelte's `Button` FREEZES its class string at mount.** `Button.svelte:34` reads the theme through a DESTRUCTURING `$derived` declaration, which evaluates its object ONCE — so a button BORN disabled wears `cursor-not-allowed opacity-50` forever, even after its diff --git a/PACKS.md b/PACKS.md index 07f9df7e..7152b904 100644 --- a/PACKS.md +++ b/PACKS.md @@ -10,9 +10,10 @@ There are two kinds of pack: - **Default packs** come from the pack repo's `index.json`, fetched from `PACKS_BASE` (`src/lib/packs.js`) — the jsDelivr CDN over [theprototype-app/packs](https://github.com/theprototype-app/packs), pinned to a - tag (`@v1`). If the CDN is unreachable, the app falls back to the MINIMAL starter - bundled at `static/library/libraryList.json` (offline / fresh clones are never - empty). + ref (`@format-1` — never a semver-looking name: jsDelivr resolves a version once + and a retag of it is a no-op, core #230). If the CDN is unreachable, the app falls + back to the MINIMAL starter bundled at `static/library/libraryList.json` (offline / + fresh clones are never empty). - **Remote / imported packs** are self-describing repos or `.zip` files using the `manifest.json` format below — drag a `.zip` in with **+ Import pack**. diff --git a/scripts/author-templates.cjs b/scripts/author-templates.cjs index d5b4d8d6..11b1e8f5 100644 --- a/scripts/author-templates.cjs +++ b/scripts/author-templates.cjs @@ -424,7 +424,7 @@ const STARS_HUD_PANEL = { const STARS_CHIME = { key: 'chime', name: 'impact-glass.ogg', - url: 'https://cdn.jsdelivr.net/gh/theprototype-app/packs@v1/audio-essentials/assets/impact-glass.ogg', + url: 'https://cdn.jsdelivr.net/gh/theprototype-app/packs@format-1/audio-essentials/assets/impact-glass.ogg', sha256: '9252d50bfb85edb17d6073c4a7806e10cdb9de56d3dbfc93a4b9727146d2df6d', credit: { what: 'Impact Glass', author: 'Kenney', license: 'CC0-1.0', source: 'https://kenney.nl/assets/impact-sounds' } }; diff --git a/src/lib/contentBase.js b/src/lib/contentBase.js index f07c6c00..752e3424 100644 --- a/src/lib/contentBase.js +++ b/src/lib/contentBase.js @@ -1,9 +1,11 @@ // The off-bundle CONTENT BASES, overridable at build time. // -// Three content repos are read over jsDelivr at pinned refs — `scenes@v2` -// (templates/examples/games), `modules@main` (the module gallery) and `packs@v1` -// (Explorer packs). Every one of them was a hardcoded const, which makes them the -// only build-time configuration in the app that CANNOT be pointed anywhere else: +// Three content repos are read over jsDelivr at pinned refs — `scenes@format-2` +// (templates/examples/games), `modules@main` (the module gallery) and `packs@format-1` +// (Explorer packs; 29f/#230: a ref must never look like a semver version, because +// jsDelivr resolves a version ONCE and a retag of it is a no-op forever). Every one +// of them was a hardcoded const, which makes them the only build-time configuration +// in the app that CANNOT be pointed anywhere else: // the ref a build reads is the ref production reads, so there was no way to try // unpublished content without publishing it to the ref real users are on. // diff --git a/src/lib/packs.js b/src/lib/packs.js index 2c0fda65..2ea82efe 100644 --- a/src/lib/packs.js +++ b/src/lib/packs.js @@ -15,10 +15,16 @@ import { safeStorage } from './safeStorage'; // // The pack repo/manifest structure is documented in PACKS.md. -/** Off-bundle base for remote packs (RP): the tagged jsDelivr mirror of - * github.com/theprototype-app/packs. Bump the tag when pack content changes — - * jsDelivr caches tags aggressively, so released builds stay stable. */ -export const PACKS_BASE = contentBase(import.meta.env.VITE_PACKS_BASE, 'https://cdn.jsdelivr.net/gh/theprototype-app/packs@v1'); +/** Off-bundle base for remote packs (RP): the ref-pinned jsDelivr mirror of + * github.com/theprototype-app/packs. A content release re-points the ref there + * (`git tag -f format-1 && git push -f origin format-1`, then purge) — jsDelivr caches + * a ref for up to 12 hours, so released builds stay stable. + * + * 29f (#230): the ref must NOT look like a version. `packs@v1` was the same trap as + * `scenes@v2` (jsDelivr parses `v1` as a semver VERSION and caches it immutably, so a + * retag would have been a no-op the first time it was tried) and moved in the same + * change; see the SCENES_BASE note in sceneTemplates.js for the measurement. */ +export const PACKS_BASE = contentBase(import.meta.env.VITE_PACKS_BASE, 'https://cdn.jsdelivr.net/gh/theprototype-app/packs@format-1'); const INSTALLED_KEY = 'installedPacks'; diff --git a/src/lib/sceneTemplates.js b/src/lib/sceneTemplates.js index 252cfc5f..293e2f12 100644 --- a/src/lib/sceneTemplates.js +++ b/src/lib/sceneTemplates.js @@ -22,13 +22,25 @@ import { communityProvider } from './cloudHooks'; // requestLoadSession): format confirm, "Backup before " stash, replicated // clear+rebuild, and the sessionproposal peer-consent flow all come for free. -/** Off-bundle base for curated templates/examples/games. Bump the tag when content - * changes — jsDelivr caches tags aggressively, so released builds stay stable. +/** Off-bundle base for curated templates/examples/games. A content release re-points + * the ref in the scenes repo (`git tag -f format-2 && git push -f origin format-2`, + * then purge) — jsDelivr caches a ref for up to 12 hours, so released builds stay + * stable and the app picks the change up without a redeploy. * - * C5.2: @v2 is a NEW tag, never a reused one, so a deployed older build cannot be - * handed an index whose `games` section it has no tab for. (Reusing @v1 would push - * v2 content at every build already in the wild.) */ -export const SCENES_BASE = contentBase(import.meta.env.VITE_SCENES_BASE, 'https://cdn.jsdelivr.net/gh/theprototype-app/scenes@v2'); + * C5.2: the ref tracks the INDEX FORMAT and a format bump takes a NEW ref, never a + * reused one, so a deployed older build cannot be handed an index whose `games` + * section it has no tab for. (Reusing the previous ref would push new-format content + * at every build already in the wild.) + * + * 29f (#230): THE REF MUST NOT LOOK LIKE A VERSION. jsDelivr parses `v2` as a SEMVER + * VERSION (`x-jsd-version-type: version`, `cache-control: immutable` for a year), so a + * retag of `v2` was a no-op forever and four purges changed nothing — the Games tab + * shipped three games while the feed had six. A ref jsDelivr cannot parse as a version + * (`format-2`, tag or branch) is reported as type `branch` with a 12-hour s-maxage, + * which is what makes the retag-and-purge ritual work. `scenes@v2` is a dead ref, left + * where jsDelivr first resolved it for the builds that shipped against it; + * `tests/unit/contentBase.test.js` asserts no semver-looking ref comes back. */ +export const SCENES_BASE = contentBase(import.meta.env.VITE_SCENES_BASE, 'https://cdn.jsdelivr.net/gh/theprototype-app/scenes@format-2'); /** Community manifest (raw = fresh + CORS; see header note). */ export const GALLERY_JSON_URL = 'https://raw.githubusercontent.com/theprototype-app/community-gallery/main/gallery.json'; diff --git a/tests/e2e/game-dungeon-realms.test.cjs b/tests/e2e/game-dungeon-realms.test.cjs index 9bf08554..cbb17627 100644 --- a/tests/e2e/game-dungeon-realms.test.cjs +++ b/tests/e2e/game-dungeon-realms.test.cjs @@ -2,7 +2,7 @@ // `dungeon` Kit generates + renders the world from the graph's Dungeon node; the // `dungeon-realms` module plays it) on the REAL artefacts and nothing authored in-test: // the scene — games/dungeon-realms/scene.tpscene from the scenes FEED (SCENES_BASE, -// tag v2), or DUNGEON_REALMS_TPSCENE=, or a sibling scenes checkout +// ref format-2), or DUNGEON_REALMS_TPSCENE=, or a sibling scenes checkout // the zips — dungeon.zip + dungeon-realms.zip: DUNGEON_KIT_ZIP / DUNGEON_REALMS_ZIP, // the MODULES_REPO checkout, a packed sibling modules checkout, or the CDN // installed on TWO peers plus a LATE JOINER. Skip-never-fail: when the scene or a zip @@ -29,7 +29,7 @@ const h = require('./helpers.cjs'); const fs = require('fs'); const path = require('path'); -const SCENES_BASE = (process.env.DUNGEON_REALMS_SCENES_BASE || 'https://cdn.jsdelivr.net/gh/theprototype-app/scenes@v2').replace(/\/$/, ''); +const SCENES_BASE = (process.env.DUNGEON_REALMS_SCENES_BASE || 'https://cdn.jsdelivr.net/gh/theprototype-app/scenes@format-2').replace(/\/$/, ''); const MODULES_BASE = (process.env.DUNGEON_REALMS_MODULES_BASE || 'https://cdn.jsdelivr.net/gh/theprototype-app/modules@main').replace(/\/$/, ''); const ROOT = path.resolve(__dirname, '../../..'); const SEED = 1337; diff --git a/tests/e2e/game-football.test.cjs b/tests/e2e/game-football.test.cjs index d78a5171..03728113 100644 --- a/tests/e2e/game-football.test.cjs +++ b/tests/e2e/game-football.test.cjs @@ -1,7 +1,7 @@ // 24-B B4 ACCEPTANCE — the Football game (VR football on the knock; the RULES are the // `football` module, the physics is the template's data). Driven through the REAL // artefacts and nothing authored in-test: -// the scene — games/football/scene.tpscene from the scenes FEED (SCENES_BASE, tag v2), +// the scene — games/football/scene.tpscene from the scenes FEED (SCENES_BASE, ref format-2), // or FOOTBALL_TPSCENE=, or a sibling scenes checkout as the fallback // the module — football.zip: FOOTBALL_ZIP=, a sibling modules checkout's packed zip // (`npm run pack -- football` there), or the modules CDN @@ -31,7 +31,7 @@ const h = require('./helpers.cjs'); const fs = require('fs'); const path = require('path'); -const SCENES_BASE = (process.env.FOOTBALL_SCENES_BASE || 'https://cdn.jsdelivr.net/gh/theprototype-app/scenes@v2').replace(/\/$/, ''); +const SCENES_BASE = (process.env.FOOTBALL_SCENES_BASE || 'https://cdn.jsdelivr.net/gh/theprototype-app/scenes@format-2').replace(/\/$/, ''); const MODULES_BASE = (process.env.FOOTBALL_MODULES_BASE || 'https://cdn.jsdelivr.net/gh/theprototype-app/modules@main').replace(/\/$/, ''); const ROOT = path.resolve(__dirname, '../../..'); diff --git a/tests/e2e/game-untangle.test.cjs b/tests/e2e/game-untangle.test.cjs index 6ea1a78d..e45c2f8c 100644 --- a/tests/e2e/game-untangle.test.cjs +++ b/tests/e2e/game-untangle.test.cjs @@ -27,7 +27,7 @@ const h = require('./helpers.cjs'); const fs = require('fs'); const path = require('path'); -const SCENES_BASE = (process.env.UNTANGLE_SCENES_BASE || 'https://cdn.jsdelivr.net/gh/theprototype-app/scenes@v2').replace(/\/$/, ''); +const SCENES_BASE = (process.env.UNTANGLE_SCENES_BASE || 'https://cdn.jsdelivr.net/gh/theprototype-app/scenes@format-2').replace(/\/$/, ''); const MODULES_BASE = (process.env.UNTANGLE_MODULES_BASE || 'https://cdn.jsdelivr.net/gh/theprototype-app/modules@main').replace(/\/$/, ''); const ROOT = path.resolve(__dirname, '../../..'); const TEMPLATE_LEVEL = 2; diff --git a/tests/unit/contentBase.test.js b/tests/unit/contentBase.test.js new file mode 100644 index 00000000..8c44c12f --- /dev/null +++ b/tests/unit/contentBase.test.js @@ -0,0 +1,81 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { contentBase } from '../../src/lib/contentBase.js'; + +// 29f (core #230). Two things this module has to keep true, and neither is visible +// from a build that merely runs: the deploy-time override must WIN (it is how +// production was unblocked the day the feed went stale), and the default a build +// ships with NONE of the VITE_* vars set must be a ref jsDelivr will re-resolve. +// +// The second is the lesson of #230: jsDelivr parses a ref like `v2` as a SEMVER +// VERSION (`x-jsd-version-type: version`, `cache-control: immutable` for a year), so a +// retag of it is a no-op forever and a purge reports finished without re-resolving — +// the Games tab shipped three games while the feed had six. The refs are `format-N` +// now, and this test reads the two consumers' SOURCE so the literal they pass as the +// fallback can never drift back to a version-shaped name. The consumers themselves +// import svelte/store and the app's stores, which is why they are read as text here +// rather than imported (the unit layer's entry rule: modules that import nothing). + +const SRC = resolve(__dirname, '../../src/lib'); +/** what jsDelivr treats as a version: an optional v, then dotted digits and nothing else */ +const SEMVER_LIKE = /^v?\d+(\.\d+)*$/; +const SCENES_DEFAULT = 'https://cdn.jsdelivr.net/gh/theprototype-app/scenes@format-2'; +const PACKS_DEFAULT = 'https://cdn.jsdelivr.net/gh/theprototype-app/packs@format-1'; + +/** the string literal a consumer hands `contentBase()` as its fallback + * @param {string} file @param {string} constName */ +function fallbackOf(file, constName) { + const src = readFileSync(resolve(SRC, file), 'utf8'); + const m = src.match(new RegExp('export const ' + constName + " = contentBase\\([^,]+, '([^']+)'\\)")); + if (!m) throw new Error(constName + ' fallback literal not found in ' + file); + return m[1]; +} +/** the ref after the `@` of a jsDelivr gh url @param {string} url */ +function refOf(url) { + const m = url.match(/@([^/]+)$/); + return m ? m[1] : ''; +} + +describe('contentBase', () => { + it('an override wins, with a trailing slash trimmed so `${BASE}/index.json` never doubles it', () => { + expect(contentBase('https://cdn.jsdelivr.net/gh/theprototype-app/scenes@main', SCENES_DEFAULT)).toBe( + 'https://cdn.jsdelivr.net/gh/theprototype-app/scenes@main' + ); + expect(contentBase('https://example.test/scenes@dev/', SCENES_DEFAULT)).toBe('https://example.test/scenes@dev'); + expect(contentBase('https://example.test/scenes@dev///', SCENES_DEFAULT)).toBe('https://example.test/scenes@dev'); + }); + + it('an absent override ships the pinned fallback byte-identically', () => { + expect(contentBase(undefined, SCENES_DEFAULT)).toBe(SCENES_DEFAULT); + expect(contentBase('', SCENES_DEFAULT)).toBe(SCENES_DEFAULT); + expect(contentBase(null, PACKS_DEFAULT)).toBe(PACKS_DEFAULT); + // vite substitutes an unset VITE_* with undefined, never with a non-string; a + // non-string is still "absent" rather than a crash + expect(contentBase(42, PACKS_DEFAULT)).toBe(PACKS_DEFAULT); + }); + + it('the scenes default is scenes@format-2 and the packs default is packs@format-1', () => { + expect(fallbackOf('sceneTemplates.js', 'SCENES_BASE')).toBe(SCENES_DEFAULT); + expect(fallbackOf('packs.js', 'PACKS_BASE')).toBe(PACKS_DEFAULT); + }); + + it('no shipped fallback names a ref jsDelivr would parse as a semver version (#230)', () => { + for (const [file, name] of [ + ['sceneTemplates.js', 'SCENES_BASE'], + ['packs.js', 'PACKS_BASE'] + ]) { + const ref = refOf(fallbackOf(file, name)); + expect(ref, name + ' has a ref').not.toBe(''); + expect(SEMVER_LIKE.test(ref), name + ' ref "' + ref + '" must not look like a version').toBe(false); + } + // the rule itself, pinned against the two names that bit: this is what the + // assertion above would have refused before 29f + expect(SEMVER_LIKE.test('v2')).toBe(true); + expect(SEMVER_LIKE.test('v1')).toBe(true); + expect(SEMVER_LIKE.test('2')).toBe(true); + expect(SEMVER_LIKE.test('1.2.3')).toBe(true); + expect(SEMVER_LIKE.test('format-2')).toBe(false); + expect(SEMVER_LIKE.test('main')).toBe(false); + }); +});