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.js162
1 files changed, 162 insertions, 0 deletions
diff --git a/public/bluebey-studio/src/urlState.js b/public/bluebey-studio/src/urlState.js
new file mode 100644
index 0000000..a3a7e48
--- /dev/null
+++ b/public/bluebey-studio/src/urlState.js
@@ -0,0 +1,162 @@
+/**
+ * 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.
+ */
+
+/** 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;
+}
+
+/** 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(stripForUrl(state) ?? null);
+ 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: parsed };
+ } catch {
+ return null;
+ }
+}