Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/skills/e2e-verify/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
16 changes: 15 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions PACKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**.

Expand Down
2 changes: 1 addition & 1 deletion scripts/author-templates.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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' }
};
Expand Down
10 changes: 6 additions & 4 deletions src/lib/contentBase.js
Original file line number Diff line number Diff line change
@@ -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.
//
Expand Down
14 changes: 10 additions & 4 deletions src/lib/packs.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
24 changes: 18 additions & 6 deletions src/lib/sceneTemplates.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,25 @@ import { communityProvider } from './cloudHooks';
// requestLoadSession): format confirm, "Backup before <name>" 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';
Expand Down
4 changes: 2 additions & 2 deletions tests/e2e/game-dungeon-realms.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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=<path>, or a sibling scenes checkout
// ref format-2), or DUNGEON_REALMS_TPSCENE=<path>, 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
Expand All @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions tests/e2e/game-football.test.cjs
Original file line number Diff line number Diff line change
@@ -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=<path>, or a sibling scenes checkout as the fallback
// the module — football.zip: FOOTBALL_ZIP=<path>, a sibling modules checkout's packed zip
// (`npm run pack -- football` there), or the modules CDN
Expand Down Expand Up @@ -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, '../../..');

Expand Down
2 changes: 1 addition & 1 deletion tests/e2e/game-untangle.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
81 changes: 81 additions & 0 deletions tests/unit/contentBase.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading