clone

a browser grid puzzle game where you push words around to rewrite the rules of each level

feat(packs): add explicit install hooks and saved library

Dami committed 646db9b
commit
646db9b0ecabb280d79d87c65066f7e764859299
parent
bb177681777e08578e27434528a118d83a99c0f8
author
Dami <dami.etoile@gmail.com>
4 files changed, +429 -0
Asrc/pack-library.js143++++++++++++++++++++++++++++++
Asrc/pack-service.js108+++++++++++++++++++++++
Atests/pack-library.test.js86++++++++++++++++++
Atests/pack-service.test.js92+++++++++++++++++++
A src/pack-library.js +143 -0
@@ -0,0 +1,143 @@
1+import { CORE_PACK_ID, createCatalog } from './catalog.js';2+3+export const PACK_LIBRARY_SCHEMA = 1;4+export const DEFAULT_PACK_LIBRARY_KEY = 'miau.pack-library.v1';5+export const MAX_STORED_PACKS = 128;6+export const MAX_STORED_CHARACTERS = 2 * 1024 * 1024;7+8+const clone = value => typeof structuredClone === 'function'9+ ? structuredClone(value)10+ : JSON.parse(JSON.stringify(value));11+12+function discoverStorage(storage) {13+ if (storage !== undefined) return storage;14+ try { return globalThis.localStorage; } catch { return null; }15+}16+17+function snapshot(catalog) {18+ return catalog.getPacks()19+ .filter(pack => pack.id !== CORE_PACK_ID && pack.origin !== 'bundled')20+ .map(pack => catalog.getPack(pack.id))21+ .filter(Boolean);22+}23+24+/**25+ * Owns persistence for explicitly installed packs. The catalog remains the26+ * runtime source of truth; storage contains only validated authored data and27+ * never CommonJS source. A failing or unavailable Storage object degrades to28+ * an in-memory library for the lifetime of this instance.29+ */30+export function createPackLibrary(options = {}) {31+ const catalog = options.catalog || createCatalog();32+ const storage = discoverStorage(options.storage);33+ const storageKey = options.key || DEFAULT_PACK_LIBRARY_KEY;34+ let memorySnapshot = [];35+ let storageError = null;36+ let hydrated = false;37+38+ function rememberError(error) {39+ storageError = error instanceof Error ? error.message : String(error);40+ }41+42+ function persist() {43+ const packs = snapshot(catalog);44+ memorySnapshot = clone(packs);45+ if (!storage || typeof storage.setItem !== 'function') {46+ storageError ||= 'localStorage is unavailable';47+ return false;48+ }49+ try {50+ const data = JSON.stringify({ schema: PACK_LIBRARY_SCHEMA, packs });51+ if (packs.length > MAX_STORED_PACKS || data.length > MAX_STORED_CHARACTERS) throw new RangeError('pack library is too large for saved storage');52+ // One setItem makes each catalog mutation a single storage commit.53+ storage.setItem(storageKey, data);54+ storageError = null;55+ return true;56+ } catch (error) {57+ rememberError(error);58+ return false;59+ }60+ }61+62+ function hydrate() {63+ if (hydrated) return { loaded: memorySnapshot.length, errors: [] };64+ hydrated = true;65+ if (!storage || typeof storage.getItem !== 'function') {66+ storageError ||= 'localStorage is unavailable';67+ return { loaded: 0, errors: [storageError] };68+ }69+ let raw;70+ try { raw = storage.getItem(storageKey); }71+ catch (error) {72+ rememberError(error);73+ return { loaded: 0, errors: [storageError] };74+ }75+ if (!raw) return { loaded: 0, errors: [] };76+ if (raw.length > MAX_STORED_CHARACTERS) return { loaded: 0, errors: ['stored pack library is too large'] };77+ let data;78+ try { data = JSON.parse(raw); }79+ catch (error) {80+ rememberError(error);81+ return { loaded: 0, errors: ['stored pack library is not valid JSON'] };82+ }83+ if (!data || data.schema !== PACK_LIBRARY_SCHEMA || !Array.isArray(data.packs) || data.packs.length > MAX_STORED_PACKS) {84+ const error = 'stored pack library has an unsupported schema';85+ rememberError(error);86+ return { loaded: 0, errors: [error] };87+ }88+ const errors = [];89+ for (const [index, pack] of data.packs.entries()) {90+ try {91+ catalog.register(pack, { origin: 'local' });92+ memorySnapshot.push(clone(pack));93+ } catch (error) {94+ errors.push(`packs[${index}]: ${error instanceof Error ? error.message : String(error)}`);95+ }96+ }97+ return { loaded: memorySnapshot.length, errors };98+ }99+100+ function installPack(pack, registerOptions = {}) {101+ if (registerOptions.keepBoth && registerOptions.replace) throw new TypeError('Choose either Replace or Keep both.');102+ const origin = registerOptions.origin || 'local';103+ if (origin !== 'local' && origin !== 'url') throw new TypeError('Saved packs must come from a local file or a shared link.');104+ let candidate = pack;105+ if (registerOptions.keepBoth && catalog.getPack(pack?.manifest?.id)) {106+ const original = pack.manifest.id;107+ let suffix = 2;108+ const forkId = () => `${original.slice(0, 64 - String(suffix).length - 1)}-${suffix}`;109+ while (catalog.getPack(forkId())) suffix++;110+ candidate = clone(pack);111+ candidate.manifest.id = forkId();112+ candidate.manifest.forkOf = original;113+ }114+ const id = catalog.register(candidate, {115+ origin,116+ replace: registerOptions.replace === true,117+ });118+ persist();119+ return id;120+ }121+122+ function removePack(id) {123+ const removed = catalog.removePack(id);124+ if (removed) persist();125+ return removed;126+ }127+128+ hydrate();129+ return Object.freeze({130+ catalog,131+ hydrate,132+ installPack,133+ removePack,134+ getPacks: () => catalog.getPacks(),135+ getPack: id => catalog.getPack(id),136+ get persistedPacks() { return clone(memorySnapshot); },137+ get persistence() {138+ return { mode: storageError ? 'memory' : 'storage', error: storageError };139+ },140+ });141+}142+143+export const createPersistentPackLibrary = createPackLibrary;
A src/pack-service.js +108 -0
@@ -0,0 +1,108 @@
1+/** Presentation-independent pack actions for the stage and developer tools. */2+import { loadMod } from './mods.js';3+import { createCatalog } from './catalog.js';4+import { decodeSharedPack, encodeSharedPack, exportCommonJS } from './share.js';5+6+function errorsFrom(error) {7+ return Array.isArray(error?.errors) ? error.errors.map(String) : [error instanceof Error ? error.message : String(error)];8+}9+10+function result(pack) { return { ok: true, pack, errors: [] }; }11+function failure(error) { return { ok: false, pack: null, errors: errorsFrom(error) }; }12+function clone(value) { return typeof structuredClone === 'function' ? structuredClone(value) : JSON.parse(JSON.stringify(value)); }13+14+function viewPack(catalog, record) {15+ const data = catalog.getPack(record.id);16+ return {17+ id: record.id,18+ name: record.manifest.name,19+ author: record.manifest.author,20+ forkOf: record.manifest.forkOf,21+ origin: record.origin,22+ levels: catalog.getLevels().filter(level => level.packId === record.id).map(level => ({23+ id: level.localId,24+ globalId: level.id,25+ title: level.title,26+ hint: level.hint,27+ })),28+ palette: data.palette || {},29+ };30+}31+32+/**33+ * The returned hooks never install a pack while parsing it. In particular,34+ * a #pack fragment is data-only and cannot reach the CommonJS evaluator.35+ */36+export function createPackService(options = {}) {37+ const catalog = options.catalog || options.library?.catalog || createCatalog();38+ const library = options.library;39+ if (library && library.catalog !== catalog) throw new TypeError('Pack library and service must share one catalog.');40+ const baseUrl = options.baseUrl || (typeof location !== 'undefined' ? location.href : 'https://example.invalid/');41+42+ function listPacks() { return catalog.getPacks().map(record => viewPack(catalog, record)); }43+ function view(id) {44+ const record = catalog.getPacks().find(item => item.id === id);45+ return record ? viewPack(catalog, record) : undefined;46+ }47+ function importPackText(source, filename = 'pack.cjs') {48+ try { return result(loadMod(source, { filename })); }49+ catch (error) { return failure(error); }50+ }51+ function decodePackLink(hash) {52+ try {53+ if (typeof hash !== 'string') throw new TypeError('Pack link must be text.');54+ const fragment = hash.includes('#') ? hash.slice(hash.indexOf('#')) : hash;55+ if (!fragment.startsWith('#pack=')) throw new TypeError('This link does not contain a pack.');56+ const payload = fragment.slice('#pack='.length);57+ if (!payload || payload.includes('&')) throw new TypeError('Pack link is incomplete.');58+ return result(decodeSharedPack(payload));59+ } catch (error) { return failure(error); }60+ }61+ function installPack(pack, options = {}) {62+ try {63+ if (options.keepBoth && options.replace) throw new TypeError('Choose either Replace or Keep both.');64+ const origin = options.origin || 'local';65+ if (origin !== 'local' && origin !== 'url') throw new TypeError('Installed packs must come from a local file or a shared link.');66+ let candidate = pack;67+ if (options.keepBoth && catalog.getPack(pack?.manifest?.id)) {68+ const original = pack.manifest.id;69+ let suffix = 2;70+ const forkId = () => `${original.slice(0, 64 - String(suffix).length - 1)}-${suffix}`;71+ while (catalog.getPack(forkId())) suffix++;72+ candidate = clone(pack);73+ candidate.manifest.id = forkId();74+ candidate.manifest.forkOf = original;75+ }76+ const id = library77+ ? library.installPack(candidate, { origin, replace: options.replace === true })78+ : catalog.register(candidate, { origin, replace: options.replace === true });79+ return result(view(id));80+ } catch (error) { return failure(error); }81+ }82+ function removePack(id) { return library ? library.removePack(id) : catalog.removePack(id); }83+ function exportPack(id) {84+ const pack = catalog.getPack(id);85+ if (!pack) throw new Error(`Pack ${id} is not installed.`);86+ return new Blob([exportCommonJS(pack)], { type: 'text/javascript' });87+ }88+ function packLink(id) {89+ const pack = catalog.getPack(id);90+ if (!pack) throw new Error(`Pack ${id} is not installed.`);91+ const url = new URL(baseUrl);92+ url.search = '';93+ url.hash = `pack=${encodeSharedPack(pack)}`;94+ return url.toString();95+ }96+97+ return Object.freeze({98+ catalog,99+ library,100+ listPacks,101+ importPackText,102+ exportPack,103+ packLink,104+ decodePackLink,105+ installPack,106+ removePack,107+ });108+}
A tests/pack-library.test.js +86 -0
@@ -0,0 +1,86 @@
1+import test from 'node:test';2+import assert from 'node:assert/strict';3+import { createPackLibrary, DEFAULT_PACK_LIBRARY_KEY, PACK_LIBRARY_SCHEMA } from '../src/pack-library.js';4+import { createCatalog } from '../src/catalog.js';5+6+const level = id => ({ id, title: id, width: 1, height: 1, objects: [] });7+const pack = (id, levelId = 'intro') => ({8+ manifest: { id, name: id, version: '1.0.0' },9+ levels: [level(levelId)],10+});11+12+function storage(initial = null) {13+ let value = initial;14+ return {15+ getItem() { return value; },16+ setItem(key, next) { assert.equal(key, DEFAULT_PACK_LIBRARY_KEY); value = next; },17+ removeItem() { value = null; },18+ dump() { return value; },19+ };20+}21+22+test('explicit installs persist authored data and hydrate in order without evaluating source', () => {23+ const saved = storage();24+ const catalog = createCatalog({ coreLevels: [level('core')] });25+ catalog.register(pack('bundled'), { origin: 'bundled' });26+ const first = createPackLibrary({ catalog, storage: saved });27+ first.installPack(pack('alpha'));28+ first.installPack(pack('beta'));29+ assert.deepEqual(first.getPacks().map(item => item.id), ['core', 'bundled', 'alpha', 'beta']);30+ const encoded = saved.dump();31+ assert.deepEqual(JSON.parse(encoded), {32+ schema: PACK_LIBRARY_SCHEMA,33+ packs: [pack('alpha'), pack('beta')],34+ });35+36+ const reloaded = createPackLibrary({ catalog: createCatalog({ coreLevels: [level('core')] }), storage: saved });37+ assert.deepEqual(reloaded.getPacks().map(item => item.id), ['core', 'alpha', 'beta']);38+ assert.equal(reloaded.getPack('alpha').manifest.name, 'alpha');39+});40+41+test('replace, fork-style install, and remove each update one ordered snapshot', () => {42+ const saved = storage();43+ const library = createPackLibrary({ catalog: createCatalog({ coreLevels: [level('core')] }), storage: saved });44+ library.installPack(pack('alpha', 'old'));45+ library.installPack(pack('beta'));46+ library.installPack(pack('alpha', 'new'), { replace: true });47+ assert.deepEqual(library.getPacks().map(item => item.id), ['core', 'alpha', 'beta']);48+ assert.equal(library.getPack('alpha').levels[0].id, 'new');49+ library.installPack(pack('alpha', 'forked'), { keepBoth: true });50+ library.removePack('alpha');51+ assert.deepEqual(library.getPacks().map(item => item.id), ['core', 'beta', 'alpha-2']);52+ const reloaded = createPackLibrary({ catalog: createCatalog({ coreLevels: [level('core')] }), storage: saved });53+ assert.deepEqual(reloaded.getPacks().map(item => item.id), ['core', 'beta', 'alpha-2']);54+ assert.equal(reloaded.getPack('alpha-2').manifest.forkOf, 'alpha');55+});56+57+test('blocked storage falls back to memory without losing the active catalog', () => {58+ const blocked = {59+ getItem() { throw new Error('storage blocked'); },60+ setItem() { throw new Error('quota exceeded'); },61+ };62+ const library = createPackLibrary({ catalog: createCatalog({ coreLevels: [level('core')] }), storage: blocked });63+ library.installPack(pack('offline'));64+ assert.equal(library.persistence.mode, 'memory');65+ assert.match(library.persistence.error, /quota|blocked/);66+ assert.deepEqual(library.getPacks().map(item => item.id), ['core', 'offline']);67+ assert.deepEqual(library.persistedPacks.map(item => item.manifest.id), ['offline']);68+});69+70+test('malformed stored entries are rejected without evaluating code or poisoning later packs', () => {71+ const saved = storage(JSON.stringify({ schema: PACK_LIBRARY_SCHEMA, packs: [72+ { manifest: { id: 'bad', name: 'Bad', version: '1.0.0' }, levels: 'not-an-array' },73+ pack('good'),74+ ] }));75+ const library = createPackLibrary({ catalog: createCatalog({ coreLevels: [level('core')] }), storage: saved });76+ assert.deepEqual(library.getPacks().map(item => item.id), ['core', 'good']);77+ assert.equal(library.hydrate().errors.length, 0);78+});79+80+test('saved library rejects forged bundled origin and oversized stored snapshots', () => {81+ const saved = storage('x'.repeat(2 * 1024 * 1024 + 1));82+ const library = createPackLibrary({ catalog: createCatalog({ coreLevels: [level('core')] }), storage: saved });83+ assert.deepEqual(library.getPacks().map(item => item.id), ['core']);84+ assert.throws(() => library.installPack(pack('fake'), { origin: 'bundled' }), /local file or a shared link/);85+ assert.equal(library.getPack('fake'), undefined);86+});
A tests/pack-service.test.js +92 -0
@@ -0,0 +1,92 @@
1+import test from 'node:test';2+import assert from 'node:assert/strict';3+import { createPackService } from '../src/pack-service.js';4+import { createPackLibrary } from '../src/pack-library.js';5+import { exportCommonJS } from '../src/share.js';6+7+const example = {8+ manifest: { id: 'gift-pack', name: 'Gift Pack', author: 'Ada', version: '1.0.0' },9+ palette: { CAT: '🐈' },10+ levels: [{ id: 'hello', title: 'Hello', width: 3, height: 2, objects: [11+ { kind: 'word', text: 'CAT', x: 0, y: 0 },12+ { kind: 'word', text: 'IS', x: 1, y: 0 },13+ { kind: 'word', text: 'YOU', x: 2, y: 0 },14+ { kind: 'object', noun: 'CAT', x: 0, y: 1 },15+ ] }],16+};17+18+test('CommonJS import previews first, then installs on explicit choice', () => {19+ const service = createPackService();20+ const before = service.listPacks().length;21+ const preview = service.importPackText(exportCommonJS(example), 'gift.cjs');22+ assert.equal(preview.ok, true);23+ assert.equal(service.listPacks().length, before);24+ const installed = service.installPack(preview.pack);25+ assert.equal(installed.ok, true);26+ assert.deepEqual(installed.pack.levels.map(level => level.id), ['hello']);27+ assert.equal(installed.pack.author, 'Ada');28+ assert.equal(service.listPacks().length, before + 1);29+ assert.equal(service.catalog.getLevel('gift-pack:hello').title, 'Hello');30+});31+32+test('link decoding is data-only and does not install until confirmed', () => {33+ const sender = createPackService({ baseUrl: 'https://fcbc.n.zip/?dev#old' });34+ sender.installPack(example);35+ const link = sender.packLink('gift-pack');36+ assert.match(link, /^https:\/\/fcbc\.n\.zip\/#pack=miau1\./);37+ const receiver = createPackService();38+ const preview = receiver.decodePackLink(new URL(link).hash);39+ assert.equal(preview.ok, true);40+ assert.equal(receiver.listPacks().some(pack => pack.id === 'gift-pack'), false);41+ assert.equal(receiver.installPack(preview.pack, { origin: 'url' }).ok, true);42+ assert.equal(receiver.listPacks().find(pack => pack.id === 'gift-pack').origin, 'url');43+});44+45+test('duplicate choices replace or fork with fresh identity', () => {46+ const service = createPackService();47+ assert.equal(service.installPack(example).ok, true);48+ assert.equal(service.installPack(example).ok, false);49+ const fork = service.installPack(example, { keepBoth: true });50+ assert.equal(fork.ok, true);51+ assert.equal(fork.pack.id, 'gift-pack-2');52+ assert.equal(fork.pack.forkOf, 'gift-pack');53+ assert.equal(service.catalog.getLevel('gift-pack-2:hello').id, 'gift-pack-2:hello');54+ assert.equal(service.installPack(example, { keepBoth: true }).pack.id, 'gift-pack-3');55+ const changed = structuredClone(example);56+ changed.manifest.name = 'New Edition';57+ assert.equal(service.installPack(changed, { replace: true }).pack.name, 'New Edition');58+ assert.equal(service.listPacks().filter(pack => pack.id.startsWith('gift-pack')).length, 3);59+});60+61+test('exports installed pack as a reusable CommonJS Blob and removes local pack', async () => {62+ const service = createPackService();63+ service.installPack(example);64+ const text = await service.exportPack('gift-pack').text();65+ const preview = service.importPackText(text);66+ assert.equal(preview.ok, true);67+ assert.equal(preview.pack.manifest.id, 'gift-pack');68+ assert.equal(service.removePack('gift-pack'), true);69+ assert.equal(service.listPacks().some(pack => pack.id === 'gift-pack'), false);70+ assert.throws(() => service.exportPack('missing'), /not installed/);71+});72+73+test('bad files and fragments return error lists without state changes', () => {74+ const service = createPackService();75+ const before = service.listPacks().length;76+ assert.equal(service.importPackText('module.exports = {levels: []}').ok, false);77+ assert.equal(service.decodePackLink('#pack=broken').ok, false);78+ assert.equal(service.decodePackLink('#else=x').ok, false);79+ assert.equal(service.installPack(example, { origin: 'bundled' }).ok, false);80+ assert.equal(service.listPacks().length, before);81+});82+83+test('service installs and removals persist through its library', () => {84+ let saved = null;85+ const storage = { getItem: () => saved, setItem: (_key, value) => { saved = value; } };86+ const library = createPackLibrary({ storage });87+ const service = createPackService({ library });88+ assert.equal(service.installPack(example).ok, true);89+ assert.match(saved, /gift-pack/);90+ assert.equal(service.removePack('gift-pack'), true);91+ assert.deepEqual(JSON.parse(saved).packs, []);92+});