aboutsummaryrefslogtreecommitdiffhomepage
path: root/public/bluebey-studio/src/urlState.js
diff options
context:
space:
mode:
Diffstat (limited to 'public/bluebey-studio/src/urlState.js')
-rw-r--r--public/bluebey-studio/src/urlState.js237
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;
- }
-}