clone

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

main
miau-is-true / src / share.js
share.js 5.6K
1/**2 * Portable data-only sharing helpers.3 *4 * encodeSharedPack(pack) -> URL-safe checksummed payload5 * decodeSharedPack(payload) -> validated, normalized mod data (never executes JS)6 * exportCommonJS(pack) -> deterministic CommonJS source containing only data7 */8import { validateMod, LEGACY_RULES_VERSION, MOD_SCHEMA_VERSION } from './mods.js';9import { cloneJsonData } from './data/clone.js';10import { LevelSchemaError, normalizeLevel } from './level-schema.js';1112export const SHARE_SCHEMA_VERSION = 1;13export const MAX_SHARED_JSON_BYTES = 48 * 1024;14// Keep shared links practical for browser address bars and copy/paste surfaces.15export const MAX_SHARED_PAYLOAD_LENGTH = 16 * 1024;16const PREFIX = `miau${SHARE_SCHEMA_VERSION}.`;1718function isObject(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); }1920function stableStringify(value) {21 if (value === null || typeof value === 'string' || typeof value === 'boolean') return JSON.stringify(value);22 if (typeof value === 'number') {23 if (!Number.isFinite(value)) throw new TypeError('shared pack contains a non-finite number');24 return JSON.stringify(value);25 }26 if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;27 if (isObject(value)) return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(',')}}`;28 throw new TypeError(`shared pack contains unsupported ${typeof value}`);29}3031function byteLength(text) { return new TextEncoder().encode(text).byteLength; }3233function normalizePack(pack) {34 const copy = cloneJsonData(pack, 'shared pack');35 if (!isObject(copy)) throw new TypeError('shared pack must be an object');36 copy.manifest = {37 ...copy.manifest,38 engine: copy.manifest?.engine ?? MOD_SCHEMA_VERSION,39 rules: copy.manifest?.rules ?? LEGACY_RULES_VERSION,40 };41 if (Array.isArray(copy.levels)) {42 const levelIssues = [];43 copy.levels = copy.levels.map((level, index) => {44 try {45 return normalizeLevel(level, { profile: 'pack', path: `levels[${index}]` });46 } catch (error) {47 if (error instanceof LevelSchemaError) levelIssues.push(...error.issues);48 return level;49 }50 });51 if (levelIssues.length) {52 const errors = levelIssues.map(entry => `${entry.path}: ${entry.message}`);53 const failure = new TypeError(`Invalid shared pack:\n- ${errors.join('\n- ')}`);54 failure.errors = errors;55 failure.issues = levelIssues;56 throw failure;57 }58 }59 if (Array.isArray(copy.vocabulary)) copy.vocabulary = copy.vocabulary.map(word => String(word).toUpperCase());60 if (isObject(copy.palette)) {61 const palette = {};62 for (const [noun, glyph] of Object.entries(copy.palette)) palette[noun.toUpperCase()] = glyph;63 copy.palette = palette;64 }65 const errors = validateMod(copy);66 if (errors.length) {67 const failure = new TypeError(`Invalid shared pack:\n- ${errors.join('\n- ')}`);68 failure.errors = errors;69 throw failure;70 }71 return copy;72}7374function fnv1a(text) {75 let hash = 0x811c9dc5;76 for (const byte of new TextEncoder().encode(text)) {77 hash ^= byte;78 hash = Math.imul(hash, 0x01000193) >>> 0;79 }80 return hash.toString(16).padStart(8, '0');81}8283function encodeBase64Url(text) {84 const bytes = new TextEncoder().encode(text);85 let binary = '';86 for (const byte of bytes) binary += String.fromCharCode(byte);87 return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');88}8990function decodeBase64Url(payload) {91 const base64 = payload.replaceAll('-', '+').replaceAll('_', '/') + '='.repeat((4 - payload.length % 4) % 4);92 const binary = atob(base64);93 const bytes = Uint8Array.from(binary, character => character.charCodeAt(0));94 return new TextDecoder('utf-8', { fatal: true }).decode(bytes);95}9697export function encodeSharedPack(pack) {98 const normalized = normalizePack(pack);99 const json = stableStringify({ pack: normalized, v: SHARE_SCHEMA_VERSION });100 if (byteLength(json) > MAX_SHARED_JSON_BYTES) throw new RangeError(`shared pack exceeds ${MAX_SHARED_JSON_BYTES} bytes`);101 const encoded = `${PREFIX}${encodeBase64Url(json)}.${fnv1a(json)}`;102 if (encoded.length > MAX_SHARED_PAYLOAD_LENGTH) throw new RangeError(`shared payload exceeds ${MAX_SHARED_PAYLOAD_LENGTH} characters`);103 return encoded;104}105106export function decodeSharedPack(payload) {107 if (typeof payload !== 'string' || payload.length === 0 || payload.length > MAX_SHARED_PAYLOAD_LENGTH) throw new TypeError('invalid shared payload length');108 if (!payload.startsWith(PREFIX)) throw new TypeError('unsupported shared payload schema');109 const body = payload.slice(PREFIX.length);110 const split = body.lastIndexOf('.');111 if (split < 1 || !/^[0-9a-f]{8}$/.test(body.slice(split + 1))) throw new TypeError('malformed shared payload');112 if (!/^[A-Za-z0-9_-]+$/.test(body.slice(0, split))) throw new TypeError('malformed shared payload encoding');113 let json;114 try { json = decodeBase64Url(body.slice(0, split)); } catch { throw new TypeError('malformed shared payload encoding'); }115 if (byteLength(json) > MAX_SHARED_JSON_BYTES || fnv1a(json) !== body.slice(split + 1)) throw new TypeError('shared payload failed integrity check');116 let envelope;117 try { envelope = JSON.parse(json); } catch { throw new TypeError('shared payload is not JSON'); }118 if (!isObject(envelope) || envelope.v !== SHARE_SCHEMA_VERSION || !isObject(envelope.pack)) throw new TypeError('invalid shared payload envelope');119 return normalizePack(envelope.pack);120}121122export function exportCommonJS(pack) {123 const normalized = normalizePack(pack);124 return `'use strict';\n\nmodule.exports = ${stableStringify(normalized)};\n`;125}