1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
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;
}
}
|