castle-web-sdk 0.4.24 → 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/README.md +194 -37
- package/dist/castle.d.ts +2 -0
- package/dist/castle.js +1 -0
- package/dist/chunk.d.ts +1 -0
- package/dist/chunk.js +15 -0
- package/dist/commands.d.ts +86 -0
- package/dist/multiplayer.d.ts +2 -0
- package/dist/multiplayer.js +59 -16
- package/dist/multiplayerProtocol.d.ts +3 -0
- package/dist/multiplayerProtocol.js +13 -0
- package/dist/runtime.js +21 -12
- package/dist/server/platformHandle.d.ts +13 -0
- package/dist/server/storage.d.ts +46 -0
- package/dist/server/storage.js +218 -0
- package/dist/server/wrapper.d.ts +17 -1
- package/dist/server/wrapper.js +46 -1
- package/dist/storage.d.ts +15 -0
- package/dist/storage.js +29 -86
- package/dist/storageJson.d.ts +5 -0
- package/dist/storageJson.js +69 -0
- package/dist/store.d.ts +67 -0
- package/dist/store.js +313 -0
- package/dist/unloadFlush.d.ts +3 -0
- package/dist/unloadFlush.js +30 -0
- package/package.json +1 -1
package/dist/store.d.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { Json } from "./types";
|
|
2
|
+
export interface StoreListOptions {
|
|
3
|
+
/** Only keys beginning with this. Omit for every key in the scope. */
|
|
4
|
+
prefix?: string;
|
|
5
|
+
/** Rows per page. The platform caps both the row count and the page's size. */
|
|
6
|
+
limit?: number;
|
|
7
|
+
/** The `cursor` from the previous page. Omit for the first page. */
|
|
8
|
+
cursor?: string | null;
|
|
9
|
+
}
|
|
10
|
+
export interface StoreListEntry<T extends Json = Json> {
|
|
11
|
+
key: string;
|
|
12
|
+
value: T;
|
|
13
|
+
}
|
|
14
|
+
export interface StoreListPage<T extends Json = Json> {
|
|
15
|
+
entries: StoreListEntry<T>[];
|
|
16
|
+
/**
|
|
17
|
+
* Pass to the next `list` call to continue. Null means there is nothing left,
|
|
18
|
+
* and is the only reliable end-of-range signal: a page can come back short
|
|
19
|
+
* because it hit its size cap rather than because the keys ran out.
|
|
20
|
+
*/
|
|
21
|
+
cursor: string | null;
|
|
22
|
+
}
|
|
23
|
+
export interface StoreScopeApi {
|
|
24
|
+
/** Reads keys. Keys with no value are absent from the result. */
|
|
25
|
+
get<T extends Json = Json>(keys: string[]): Promise<Record<string, T>>;
|
|
26
|
+
/**
|
|
27
|
+
* Writes keys. Resolves once the platform has the write, so a deck can tell
|
|
28
|
+
* whether saving worked; the write itself is coalesced with nearby ones.
|
|
29
|
+
*/
|
|
30
|
+
set(values: Record<string, Json>): Promise<void>;
|
|
31
|
+
remove(keys: string[]): Promise<void>;
|
|
32
|
+
/**
|
|
33
|
+
* Adds to a numeric key and returns the new total, creating the key at `delta`
|
|
34
|
+
* if it is absent. Atomic: simultaneous callers each see their own increment
|
|
35
|
+
* applied, which a read-modify-write cannot promise.
|
|
36
|
+
*/
|
|
37
|
+
increment(key: string, delta?: number): Promise<number>;
|
|
38
|
+
/** A page of keys under a prefix, ordered by key. */
|
|
39
|
+
list<T extends Json = Json>(options?: StoreListOptions): Promise<StoreListPage<T>>;
|
|
40
|
+
}
|
|
41
|
+
export interface StoreBoardScore {
|
|
42
|
+
/** The user id whose score this is. */
|
|
43
|
+
subject: string;
|
|
44
|
+
score: number;
|
|
45
|
+
updatedAt: string;
|
|
46
|
+
}
|
|
47
|
+
export interface StoreBoardApi {
|
|
48
|
+
/** Records the current player's score. Later submissions replace earlier ones. */
|
|
49
|
+
submit(score: number): Promise<StoreBoardScore>;
|
|
50
|
+
/** The highest scores, best first. */
|
|
51
|
+
top(limit?: number): Promise<StoreBoardScore[]>;
|
|
52
|
+
/** One player's score, defaulting to the current player. Null if unset. */
|
|
53
|
+
get(userId?: string): Promise<StoreBoardScore | null>;
|
|
54
|
+
}
|
|
55
|
+
export interface CastleStoreApi {
|
|
56
|
+
/** Shared by everyone playing the deck. */
|
|
57
|
+
readonly deck: StoreScopeApi;
|
|
58
|
+
/** The current player's data, which other players can read. */
|
|
59
|
+
readonly me: StoreScopeApi;
|
|
60
|
+
/** Another player's public data. Readable by anyone; writable only by them. */
|
|
61
|
+
user(userId: string): StoreScopeApi;
|
|
62
|
+
/** The current player's own data. Nobody else can read it, not even a session server. */
|
|
63
|
+
readonly private: StoreScopeApi;
|
|
64
|
+
/** A leaderboard. */
|
|
65
|
+
board(name: string): StoreBoardApi;
|
|
66
|
+
}
|
|
67
|
+
export declare const Store: CastleStoreApi;
|
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,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
|
+
}
|