diff options
Diffstat (limited to 'public/bluebey-studio/src/urlState.js')
| -rw-r--r-- | public/bluebey-studio/src/urlState.js | 237 |
1 files changed, 0 insertions, 237 deletions
diff --git a/public/bluebey-studio/src/urlState.js b/public/bluebey-studio/src/urlState.js deleted file mode 100644 index d6fb6bd..0000000 --- a/public/bluebey-studio/src/urlState.js +++ /dev/null @@ -1,237 +0,0 @@ -/** - * Shareable links: the whole look, packed into the URL fragment. - * - * A studio session is a lot of state, and "save a JSON file and send it" is a - * poor answer to "how do I show you what I made" - one link you can paste into - * chat is much better. So the state is JSON-encoded, deflated and - * base64url-encoded into something short enough to sit in a `#` fragment. - * - * Three details make it survive the trip: - * - * - `view.backgroundImage` can be a multi-megabyte data URL (a photo the user - * loaded). No link can carry that, so `stripForUrl` replaces just that one - * field with `null` and keeps everything else - caption text, story panels, - * props. Nothing else is ever dropped. - * - * - The payload starts with a version tag ('1' = raw deflate, '0' = plain - * bytes) so the decoder knows whether to inflate. A browser without - * `CompressionStream` falls back to the uncompressed form rather than - * failing to produce a link at all. - * - * - base64url uses `-` and `_` and carries no `=` padding, so the fragment is - * safe to paste into chat, Markdown or HTML without being mangled or escaped. - * - * `decodeState` is deliberately total: any malformed, truncated or hostile input - * returns `null` rather than throwing, because it is fed whatever came out of - * the address bar. - * - * Most of a session is still at its default, and a default costs bytes on every - * link. `pruneDefaults` drops exactly the fields that equal `defaultState()`, and - * `restoreDefaults` merges what is left back onto a fresh default, so the link - * carries only what the user actually changed. The pruning is lossless and the - * decoder still accepts the older, full-state links. - */ - -import { applyPatch, defaultState } from './presets.js'; - -/** Above this length a `data:` background image is a photo, not a link. */ -const MAX_INLINE_IMAGE = 2048; - -/** Version tags. '1' is the deflated body, '0' the fallback plain body. */ -const TAG_DEFLATE = '1'; -const TAG_PLAIN = '0'; - -const BASE64URL = /^[A-Za-z0-9_-]+$/; - -/** - * A copy of `state` that is safe to put in a URL. Only the inline background - * image is dropped (replaced with `null`), and only when it is a `data:` URL - * long enough to blow up the link. - */ -export function stripForUrl(state) { - // A JSON round-trip gives the copy for free and drops anything that could not - // be serialised anyway, so the link and the live state cannot diverge. - const copy = JSON.parse(JSON.stringify(state ?? null)); - if (!copy || typeof copy !== 'object' || Array.isArray(copy)) return copy; - - const image = copy.view && copy.view.backgroundImage; - if (typeof image === 'string' && image.startsWith('data:') && image.length > MAX_INLINE_IMAGE) { - copy.view.backgroundImage = null; - } - return copy; -} - -/** Plain objects only: not `null`, not an array. */ -function isPlainObject(value) { - return value !== null && typeof value === 'object' && !Array.isArray(value); -} - -/** Structural equality for the JSON-shaped values this module deals in. */ -function deepEqual(a, b) { - if (a === b) return true; - if (typeof a !== typeof b || a === null || b === null) return false; - if (typeof a !== 'object') return Number.isNaN(a) && Number.isNaN(b); - if (Array.isArray(a) !== Array.isArray(b)) return false; - if (Array.isArray(a)) { - return a.length === b.length && a.every((item, i) => deepEqual(item, b[i])); - } - const aKeys = Object.keys(a); - const bKeys = Object.keys(b); - if (aKeys.length !== bKeys.length) return false; - return aKeys.every((key) => Object.prototype.hasOwnProperty.call(b, key) && deepEqual(a[key], b[key])); -} - -/** A detached copy of a JSON-shaped value. */ -function cloneValue(value) { - return value === undefined ? undefined : JSON.parse(JSON.stringify(value)); -} - -/** Marks a value that equals its default, so its key can be left out entirely. */ -const OMIT = Symbol('urlState.omit'); - -/** - * `value` with every sub-tree that deep-equals the matching `def` removed. - * Arrays are kept whole - never pruned element by element - because that is how - * `applyPatch` replaces them; objects are pruned key by key. `OMIT` means the - * value matched its default and can be dropped from the parent. - */ -function pruneValue(value, def) { - if (deepEqual(value, def)) return OMIT; - if (isPlainObject(value)) { - if (!isPlainObject(def)) return cloneValue(value); - const out = {}; - for (const [key, child] of Object.entries(value)) { - const pruned = pruneValue(child, def[key]); - if (pruned !== OMIT) out[key] = pruned; - } - return Object.keys(out).length === 0 ? OMIT : out; - } - return Array.isArray(value) ? cloneValue(value) : value; -} - -/** - * A copy of `state` with every value equal to `defaultState()` omitted. Lossless: - * `restoreDefaults` merges the result onto a fresh default. Arrays that differ - * from the default are carried whole so `applyPatch` can replace them. - */ -export function pruneDefaults(state) { - const pruned = pruneValue(state, defaultState()); - return pruned === OMIT ? {} : pruned; -} - -/** - * The inverse of `pruneDefaults`: a full state, with every field the partial does - * not mention left at its default. Merging a previously full state is idempotent, - * so links made by the older encoder still decode. - */ -export function restoreDefaults(partial) { - return applyPatch(defaultState(), partial); -} - -/** True when the encoded fragment is bigger than a URL can comfortably hold. */ -export function isTooLong(text, limit = 1800) { - return typeof text === 'string' && text.length > limit; -} - -/** - * base64url, without padding. Chunked so a large payload does not blow the - * argument limit of `String.fromCharCode`. - */ -function toBase64Url(bytes) { - let binary = ''; - const chunk = 0x8000; - for (let i = 0; i < bytes.length; i += chunk) { - binary += String.fromCharCode(...bytes.subarray(i, i + chunk)); - } - return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); -} - -/** The inverse of `toBase64Url`. Throws on text that is not valid base64. */ -function fromBase64Url(text) { - const base64 = text.replace(/-/g, '+').replace(/_/g, '/'); - const pad = (4 - (base64.length % 4)) % 4; - const binary = atob(base64 + '='.repeat(pad)); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i); - return bytes; -} - -/** Read a whole ReadableStream into one Uint8Array. */ -async function drain(stream) { - const chunks = []; - let length = 0; - for await (const chunk of stream) { - const bytes = chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk); - chunks.push(bytes); - length += bytes.length; - } - const out = new Uint8Array(length); - let offset = 0; - for (const bytes of chunks) { - out.set(bytes, offset); - offset += bytes.length; - } - return out; -} - -async function deflateRaw(bytes) { - const stream = new Blob([bytes]).stream().pipeThrough(new CompressionStream('deflate-raw')); - return drain(stream); -} - -async function inflateRaw(bytes) { - const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream('deflate-raw')); - return drain(stream); -} - -/** - * Serialise, deflate and base64url-encode a state. The result is a fragment - * payload: it survives a URL, a chat message and an HTML attribute. - * - * @param {object} state the studio state - * @returns {Promise<string>} a URL-safe string (tag + base64url) - */ -export async function encodeState(state) { - const json = JSON.stringify(pruneDefaults(stripForUrl(state))); - const bytes = new TextEncoder().encode(json); - - if (typeof CompressionStream === 'function') { - try { - return TAG_DEFLATE + toBase64Url(await deflateRaw(bytes)); - } catch { - // Fall through: an unusable compressing stream should not cost the link. - } - } - return TAG_PLAIN + toBase64Url(bytes); -} - -/** - * The inverse of `encodeState`. Never throws: a bad tag, bad base64, a failed - * inflate or JSON that is not a plain object all come back as `null`. - * - * @param {string} text the fragment payload - * @returns {Promise<{ state: object } | null>} - */ -export async function decodeState(text) { - try { - if (typeof text !== 'string' || text.length < 2) return null; - - const tag = text[0]; - if (tag !== TAG_DEFLATE && tag !== TAG_PLAIN) return null; - - const body = text.slice(1); - if (!BASE64URL.test(body)) return null; - - let bytes = fromBase64Url(body); - if (tag === TAG_DEFLATE) { - if (typeof DecompressionStream !== 'function') return null; - bytes = await inflateRaw(bytes); - } - - const parsed = JSON.parse(new TextDecoder().decode(bytes)); - if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return null; - return { state: restoreDefaults(parsed) }; - } catch { - return null; - } -} |
