castle-web-sdk 0.4.25 → 0.4.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/store.js ADDED
@@ -0,0 +1,313 @@
1
+ import { chunked } from "./chunk";
2
+ import { decodeStorageValue, encodeStorageValue, storageError } from "./storageJson";
3
+ import { hostRequest } from "./transport";
4
+ import { onUnloadFlush } from "./unloadFlush";
5
+ // Per-key deck storage for Cauldron decks.
6
+ //
7
+ // The older Storage/SharedStorage keep one document per bucket and rewrite the
8
+ // whole thing on every change, which loses concurrent writes and stops at 2 MB.
9
+ // This keeps one row per key instead: two players writing different keys no
10
+ // longer overwrite each other, a counter can be incremented atomically, and a
11
+ // deck can hold far more than one document's worth and read back a slice of it.
12
+ //
13
+ // Every operation here is a bounded request. A deck never supplies a filter or
14
+ // an ordering, which is what lets the platform promise that no deck can make the
15
+ // database do expensive work.
16
+ // Writes coalesce by key for this long before being sent, so a deck that saves
17
+ // on every change costs one request per burst rather than one per change.
18
+ const FLUSH_INTERVAL_MS = 200;
19
+ // The platform takes at most this many keys per call. A buffer that coalesced
20
+ // past it goes out as several requests rather than as one the server rejects,
21
+ // so a deck that saves many small keys in a burst never has to know the limit.
22
+ const MAX_KEYS_PER_REQUEST = 32;
23
+ const buckets = new Map();
24
+ function bucketKey(scope, subject) {
25
+ return `${scope}|${subject ?? "self"}`;
26
+ }
27
+ function pendingBucket(scope, subject) {
28
+ const id = bucketKey(scope, subject);
29
+ let bucket = buckets.get(id);
30
+ if (!bucket) {
31
+ bucket = {
32
+ scope,
33
+ subject,
34
+ values: new Map(),
35
+ waiters: [],
36
+ timer: null,
37
+ inFlight: Promise.resolve(),
38
+ pending: 0,
39
+ };
40
+ buckets.set(id, bucket);
41
+ }
42
+ return bucket;
43
+ }
44
+ function queueWrite(scope, subject, values, unload = false) {
45
+ const bucket = pendingBucket(scope, subject);
46
+ for (const [key, value] of values)
47
+ bucket.values.set(key, value);
48
+ const settled = new Promise((resolve, reject) => {
49
+ bucket.waiters.push({ keys: Array.from(values.keys()), resolve, reject });
50
+ });
51
+ // A stable reference, so registering on every write leaves one handler.
52
+ onUnloadFlush(flushAllNow);
53
+ if (unload) {
54
+ void flushBucket(bucket, true);
55
+ }
56
+ else if (!bucket.timer) {
57
+ bucket.timer = setTimeout(() => {
58
+ bucket.timer = null;
59
+ void flushBucket(bucket, false);
60
+ }, FLUSH_INTERVAL_MS);
61
+ }
62
+ return settled;
63
+ }
64
+ // Never rejects: a failed request is reported to the waiters whose keys it
65
+ // carried, which is what the deck is holding. Callers can `void` it safely.
66
+ function flushBucket(bucket, unload) {
67
+ bucket.pending += 1;
68
+ // An unload flush does not wait its turn. Queueing it behind a request that
69
+ // may not come back before the frame does would lose the last save outright,
70
+ // and the host finishes an `unload` write with fetch keepalive, so what
71
+ // matters is that the message reaches it at all. The cost is that this one
72
+ // write can race the request already in flight: if they touch the same key
73
+ // and land out of order, the older value stays stored. Ordering that
74
+ // correctly needs a revision token the server checks, which does not exist
75
+ // yet -- a stale key beats a lost one until it does.
76
+ const run = unload
77
+ ? guarded(sendBucket(bucket, true))
78
+ : guarded(bucket.inFlight.then(() => sendBucket(bucket, false)));
79
+ // Later flushes queue behind this one either way, so only the unload write
80
+ // itself skips the queue. Counting it in `pending` matters as much: a bucket
81
+ // dropped while its request is still out would let the next write build a
82
+ // second bucket for the same scope, and the two would flush concurrently.
83
+ bucket.inFlight = Promise.all([bucket.inFlight, run]).then(() => {
84
+ bucket.pending -= 1;
85
+ pruneBucket(bucket);
86
+ });
87
+ return run;
88
+ }
89
+ // A rejection must never reach `inFlight`. The chain is built with `then`, so a
90
+ // rejected link makes every later flush for this bucket skip its send and
91
+ // reject in turn: the scope stops writing, permanently and silently. Failed
92
+ // requests are already reported to their waiters, so anything arriving here is
93
+ // a bug in this file rather than a write that did not land.
94
+ function guarded(run) {
95
+ return run.catch(reportWriteError);
96
+ }
97
+ function reportWriteError(error) {
98
+ console.warn("Castle store flush failed unexpectedly", error);
99
+ }
100
+ async function sendBucket(bucket, unload) {
101
+ // Take the buffer before awaiting: writes made while this request is in flight
102
+ // belong to the next one, not to this one's waiters.
103
+ const values = bucket.values;
104
+ const waiters = bucket.waiters;
105
+ bucket.values = new Map();
106
+ bucket.waiters = [];
107
+ if (bucket.timer) {
108
+ clearTimeout(bucket.timer);
109
+ bucket.timer = null;
110
+ }
111
+ if (values.size === 0 && waiters.length === 0)
112
+ return;
113
+ const entries = [];
114
+ const removals = [];
115
+ for (const [key, value] of values) {
116
+ if (value === null)
117
+ removals.push(key);
118
+ else
119
+ entries.push({ key, value });
120
+ }
121
+ // Which keys failed to store, and why. Set and delete never share a key here:
122
+ // a key written and then removed inside one window is a single null in
123
+ // `values`, so the two lists are disjoint and their outcomes independent.
124
+ const failures = new Map();
125
+ // Anything escaping the per-request catch is a bug, not a write that failed.
126
+ // It still has to reach the waiters: an unsettled one is a deck awaiting a
127
+ // promise that will never resolve.
128
+ let unexpected = null;
129
+ try {
130
+ for (const request of buildRequests(bucket, entries, removals, unload)) {
131
+ try {
132
+ await request.send();
133
+ }
134
+ catch (error) {
135
+ for (const key of request.keys)
136
+ failures.set(key, error);
137
+ }
138
+ }
139
+ }
140
+ catch (error) {
141
+ unexpected = error;
142
+ }
143
+ for (const waiter of waiters) {
144
+ if (unexpected !== null) {
145
+ waiter.reject(unexpected);
146
+ continue;
147
+ }
148
+ // `find`, not `some`: the first failing key supplies the error the deck sees.
149
+ const failed = waiter.keys.find((key) => failures.has(key));
150
+ // A caller that contributed no keys -- `set({})`, `remove([])` -- has nothing
151
+ // that can fail, and must still be settled rather than left hanging.
152
+ if (failed === undefined)
153
+ waiter.resolve();
154
+ else
155
+ waiter.reject(failures.get(failed));
156
+ }
157
+ }
158
+ function buildRequests(bucket, entries, removals, unload) {
159
+ const requests = [];
160
+ // Removals go first: within one window a key that was set and then removed
161
+ // should end up removed, and sending the set afterwards would resurrect it.
162
+ for (const keys of chunked(removals, MAX_KEYS_PER_REQUEST)) {
163
+ requests.push({
164
+ keys,
165
+ send: () => hostRequest("cauldronStorage.delete", {
166
+ scope: bucket.scope,
167
+ subject: bucket.subject,
168
+ keys,
169
+ unload,
170
+ }),
171
+ });
172
+ }
173
+ for (const chunk of chunked(entries, MAX_KEYS_PER_REQUEST)) {
174
+ requests.push({
175
+ keys: chunk.map((entry) => entry.key),
176
+ send: () => hostRequest("cauldronStorage.set", {
177
+ scope: bucket.scope,
178
+ subject: bucket.subject,
179
+ entries: chunk,
180
+ unload,
181
+ }),
182
+ });
183
+ }
184
+ return requests;
185
+ }
186
+ // A deck that reads many players' buckets would otherwise leave one empty entry
187
+ // per user id behind for the life of the page.
188
+ function pruneBucket(bucket) {
189
+ if (bucket.pending > 0 ||
190
+ bucket.values.size > 0 ||
191
+ bucket.waiters.length > 0 ||
192
+ bucket.timer) {
193
+ return;
194
+ }
195
+ const id = bucketKey(bucket.scope, bucket.subject);
196
+ if (buckets.get(id) === bucket)
197
+ buckets.delete(id);
198
+ }
199
+ function flushAllNow() {
200
+ for (const bucket of buckets.values()) {
201
+ // Nothing awaits these: the page is going away. The host is what finishes
202
+ // the request, and it outlives the deck's frame.
203
+ void flushBucket(bucket, true);
204
+ }
205
+ }
206
+ // ---------------------------------------------------------------------------
207
+ function assertKeys(keys, operation) {
208
+ if (!Array.isArray(keys) || keys.some((key) => typeof key !== "string")) {
209
+ throw storageError("CASTLE_STORE_INVALID_KEYS", "Store keys must be an array of strings.", operation);
210
+ }
211
+ return keys;
212
+ }
213
+ function createScope(scope, subject) {
214
+ return {
215
+ async get(keys) {
216
+ const { entries } = await hostRequest("cauldronStorage.get", {
217
+ scope,
218
+ subject,
219
+ keys: assertKeys(keys, "Store.get"),
220
+ });
221
+ const result = {};
222
+ for (const entry of entries) {
223
+ // defineProperty rather than assignment: a stored key named "__proto__"
224
+ // would otherwise reach the prototype setter instead of becoming an own
225
+ // property, corrupting the object handed back to the deck.
226
+ Object.defineProperty(result, entry.key, {
227
+ value: decodeStorageValue(entry.value, "Store.get"),
228
+ writable: true,
229
+ enumerable: true,
230
+ configurable: true,
231
+ });
232
+ }
233
+ return result;
234
+ },
235
+ set(values) {
236
+ const pending = new Map();
237
+ for (const [key, value] of Object.entries(values)) {
238
+ pending.set(key, encodeStorageValue(value, "Store.set"));
239
+ }
240
+ return queueWrite(scope, subject, pending);
241
+ },
242
+ remove(keys) {
243
+ const pending = new Map();
244
+ for (const key of assertKeys(keys, "Store.remove"))
245
+ pending.set(key, null);
246
+ return queueWrite(scope, subject, pending);
247
+ },
248
+ async increment(key, delta = 1) {
249
+ // Not coalesced. Merging increments locally would mean reporting a total
250
+ // the server has not agreed to, and the point of this verb is that the
251
+ // server is the one doing the adding.
252
+ const { value } = await hostRequest("cauldronStorage.increment", {
253
+ scope,
254
+ subject,
255
+ key,
256
+ delta,
257
+ });
258
+ return Number(value);
259
+ },
260
+ async list(options = {}) {
261
+ const { entries, cursor } = await hostRequest("cauldronStorage.list", {
262
+ scope,
263
+ subject,
264
+ prefix: options.prefix ?? null,
265
+ limit: options.limit ?? null,
266
+ cursor: options.cursor ?? null,
267
+ });
268
+ return {
269
+ entries: entries.map((entry) => ({
270
+ key: entry.key,
271
+ value: decodeStorageValue(entry.value, "Store.list"),
272
+ })),
273
+ cursor,
274
+ };
275
+ },
276
+ };
277
+ }
278
+ function createBoard(name) {
279
+ return {
280
+ async submit(score) {
281
+ const { entry } = await hostRequest("cauldronStorage.boardSubmit", {
282
+ board: name,
283
+ score,
284
+ });
285
+ return entry;
286
+ },
287
+ async top(limit) {
288
+ const { entries } = await hostRequest("cauldronStorage.boardTop", {
289
+ board: name,
290
+ limit: limit ?? null,
291
+ });
292
+ return entries;
293
+ },
294
+ async get(userId) {
295
+ const { entry } = await hostRequest("cauldronStorage.boardGet", {
296
+ board: name,
297
+ subject: userId ?? null,
298
+ });
299
+ return entry;
300
+ },
301
+ };
302
+ }
303
+ export const Store = {
304
+ deck: createScope("deck", null),
305
+ me: createScope("user", null),
306
+ user(userId) {
307
+ return createScope("user", userId);
308
+ },
309
+ private: createScope("private", null),
310
+ board(name) {
311
+ return createBoard(name);
312
+ },
313
+ };
@@ -0,0 +1,3 @@
1
+ type FlushHandler = () => void;
2
+ export declare function onUnloadFlush(handler: FlushHandler): void;
3
+ export {};
@@ -0,0 +1,30 @@
1
+ // Somewhere to hang "flush before the page goes away".
2
+ //
3
+ // Writes batch on a timer, so leaving the page inside that window would lose
4
+ // them, and a deck saving progress as someone quits is exactly when it matters.
5
+ // `pagehide` is the reliable signal on iOS, where unload often never fires, and
6
+ // visibilitychange covers backgrounding without a navigation.
7
+ //
8
+ // Listeners are registered on the first write, so a deck that never stores
9
+ // anything hooks nothing.
10
+ //
11
+ // Handlers are held in a Set, so registering on every write is fine as long as
12
+ // the caller passes a STABLE function reference. A fresh closure per call would
13
+ // accumulate, and every one of them would run at unload.
14
+ const handlers = new Set();
15
+ let hooked = false;
16
+ export function onUnloadFlush(handler) {
17
+ handlers.add(handler);
18
+ if (hooked || typeof window === "undefined")
19
+ return;
20
+ hooked = true;
21
+ const flushAll = () => {
22
+ for (const flush of handlers)
23
+ flush();
24
+ };
25
+ window.addEventListener("pagehide", flushAll);
26
+ document.addEventListener("visibilitychange", () => {
27
+ if (document.visibilityState === "hidden")
28
+ flushAll();
29
+ });
30
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "castle-web-sdk",
3
- "version": "0.4.25",
3
+ "version": "0.4.26",
4
4
  "type": "module",
5
5
  "main": "dist/castle.js",
6
6
  "types": "dist/castle.d.ts",