aboutsummaryrefslogtreecommitdiffhomepage
path: root/bluebey-studio/src/urlState.js
diff options
context:
space:
mode:
authorYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-07 23:09:40 +0900
committerYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-07 23:09:40 +0900
commitbc2821adb11a30244fc4663f4fafb756877d9508 (patch)
treefea8d30d2eb1e67f895097dbf7ec4d152361243e /bluebey-studio/src/urlState.js
parent0d50c5ede0812ba7b67b775c9cd0bf52d7b65e02 (diff)
bluebey-studio: public/ の外へ移動し非公開化
Diffstat (limited to 'bluebey-studio/src/urlState.js')
-rw-r--r--bluebey-studio/src/urlState.js237
1 files changed, 237 insertions, 0 deletions
diff --git a/bluebey-studio/src/urlState.js b/bluebey-studio/src/urlState.js
new file mode 100644
index 0000000..d6fb6bd
--- /dev/null
+++ b/bluebey-studio/src/urlState.js
@@ -0,0 +1,237 @@
+/**
+ * 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;
+ }
+}