diff options
| author | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-02 23:51:34 +0900 |
|---|---|---|
| committer | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-02 23:51:34 +0900 |
| commit | e332019acb312ec64893c26cf3d797d5ce472f26 (patch) | |
| tree | d97eaca75ad6d1d41658854d6b80519f6cd1ab79 /public/bluebey-studio/src/urlState.js | |
| parent | 99204ebe327657ed4aaaa92d7f2d6e0cb04a6b3b (diff) | |
bluebey: ぶるべー スタジオのページを公開
Diffstat (limited to 'public/bluebey-studio/src/urlState.js')
| -rw-r--r-- | public/bluebey-studio/src/urlState.js | 162 |
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; + } +} |
