castle-web-sdk 0.4.5 → 0.4.6

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/src/storage.ts DELETED
@@ -1,427 +0,0 @@
1
- import {
2
- type SharedScope,
3
- type StorageBlob,
4
- type StorageUpdate,
5
- } from "./commands";
6
- import { CastleError } from "./errors";
7
- import { hostRequest } from "./transport";
8
- import type { Json } from "./types";
9
-
10
- const FLUSH_INTERVAL_MS = 2000;
11
-
12
- type EncodedValue = string | null;
13
-
14
- interface PendingRead {
15
- resolve: (value: Json | null) => void;
16
- reject: (error: unknown) => void;
17
- }
18
-
19
- interface ReadBatch {
20
- bucketKey: string;
21
- scope: SharedScope;
22
- userId?: string;
23
- reads: Map<string, PendingRead[]>;
24
- scheduled: boolean;
25
- }
26
-
27
- export interface StorageApi {
28
- get<T extends Json = Json>(key: string): Promise<T | null>;
29
- set(key: string, value: Json): void;
30
- remove(key: string): void;
31
- }
32
-
33
- export interface SharedStorageApi {
34
- get<T extends Json = Json>(scope: "deck", key: string): Promise<T | null>;
35
- get<T extends Json = Json>(scope: "user", key: string): Promise<T | null>;
36
- get<T extends Json = Json>(
37
- scope: "user",
38
- userId: string,
39
- key: string,
40
- ): Promise<T | null>;
41
- set(scope: SharedScope, key: string, value: Json): void;
42
- remove(scope: SharedScope, key: string): void;
43
- }
44
-
45
- // Per-player private storage. The host stamps deckId/sessionId, so one deck
46
- // session = one stable blob: load once, then serve reads from cache and batch
47
- // writes on a 2s flush. Dirty writes overlay the server blob so an in-flight
48
- // write isn't clobbered by the flush response.
49
- class PrivateStorageImpl implements StorageApi {
50
- private cache = new Map<string, Json>();
51
- private dirty = new Map<string, EncodedValue>();
52
- private loadPromise: Promise<void> | null = null;
53
- private flushTimer: ReturnType<typeof setTimeout> | null = null;
54
-
55
- async get<T extends Json = Json>(key: string): Promise<T | null> {
56
- await this.ensureLoaded();
57
- return (this.cache.get(key) ?? null) as T | null;
58
- }
59
-
60
- set(key: string, value: Json): void {
61
- this.cache.set(key, value);
62
- this.dirty.set(key, encodeStorageValue(value, "Storage.set"));
63
- this.scheduleFlush();
64
- }
65
-
66
- remove(key: string): void {
67
- this.cache.delete(key);
68
- this.dirty.set(key, null);
69
- this.scheduleFlush();
70
- }
71
-
72
- private ensureLoaded(): Promise<void> {
73
- if (!this.loadPromise) {
74
- this.loadPromise = this.load().catch((error: unknown) => {
75
- this.loadPromise = null;
76
- throw error;
77
- });
78
- }
79
- return this.loadPromise;
80
- }
81
-
82
- private async load(): Promise<void> {
83
- const { blob } = await hostRequest("deckStorage.load", {});
84
- this.cache = decodeStorageBlob(blob, "Storage.get");
85
- this.overlayDirty();
86
- }
87
-
88
- private async flush(): Promise<void> {
89
- if (this.dirty.size === 0) return;
90
- const snapshot = new Map(this.dirty);
91
- const { blob } = await hostRequest("deckStorage.update", {
92
- updates: updatesFromDirty(snapshot),
93
- });
94
- this.cache = decodeStorageBlob(blob, "Storage.flush");
95
- clearAcknowledgedDirty(this.dirty, snapshot);
96
- this.overlayDirty();
97
- }
98
-
99
- private overlayDirty(): void {
100
- for (const [key, encoded] of this.dirty) {
101
- if (encoded === null) this.cache.delete(key);
102
- else this.cache.set(key, decodeStorageValue(encoded, "Storage.dirty"));
103
- }
104
- }
105
-
106
- private scheduleFlush(): void {
107
- if (this.flushTimer) return;
108
- this.flushTimer = setTimeout(() => {
109
- this.flushTimer = null;
110
- void this.flush();
111
- }, FLUSH_INTERVAL_MS);
112
- }
113
- }
114
-
115
- // Cross-player storage. Writes always target the current player (the host
116
- // forces 'user'-scope writes to whoever is signed in), so dirty writes bucket
117
- // under a fixed key; reads of another player's 'user' bucket take an explicit
118
- // userId and never have pending writes. Reads coalesce per microtask into one
119
- // command with a keys[] array.
120
- class SharedStorageImpl implements SharedStorageApi {
121
- private dirtyBuckets = new Map<string, Map<string, EncodedValue>>();
122
- private readBatches = new Map<string, ReadBatch>();
123
- private flushTimer: ReturnType<typeof setTimeout> | null = null;
124
-
125
- async get<T extends Json = Json>(
126
- scope: SharedScope,
127
- userOrKey: string,
128
- maybeKey?: string,
129
- ): Promise<T | null> {
130
- assertScope(scope, "SharedStorage.get");
131
- const userId = scope === "user" && maybeKey ? userOrKey : undefined;
132
- const key = maybeKey ?? userOrKey;
133
- const dirty = this.peekDirty(scope, userId, key);
134
- if (dirty !== undefined) {
135
- return (
136
- dirty === null ? null : decodeStorageValue(dirty, "SharedStorage.get")
137
- ) as T | null;
138
- }
139
- return this.queueRead<T>(scope, userId, key);
140
- }
141
-
142
- set(scope: SharedScope, key: string, value: Json): void {
143
- assertScope(scope, "SharedStorage.set");
144
- this.write(scope, key, encodeStorageValue(value, "SharedStorage.set"));
145
- }
146
-
147
- remove(scope: SharedScope, key: string): void {
148
- assertScope(scope, "SharedStorage.remove");
149
- this.write(scope, key, null);
150
- }
151
-
152
- private write(scope: SharedScope, key: string, encoded: EncodedValue): void {
153
- const bucketKey = writeBucketKey(scope);
154
- const dirty =
155
- this.dirtyBuckets.get(bucketKey) ?? new Map<string, EncodedValue>();
156
- dirty.set(key, encoded);
157
- this.dirtyBuckets.set(bucketKey, dirty);
158
- this.scheduleFlush();
159
- }
160
-
161
- private peekDirty(
162
- scope: SharedScope,
163
- userId: string | undefined,
164
- key: string,
165
- ): EncodedValue | undefined {
166
- if (scope === "user" && userId) return undefined;
167
- return this.dirtyBuckets.get(writeBucketKey(scope))?.get(key);
168
- }
169
-
170
- private queueRead<T extends Json>(
171
- scope: SharedScope,
172
- userId: string | undefined,
173
- key: string,
174
- ): Promise<T | null> {
175
- const bucketKey = readBucketKey(scope, userId);
176
- let batch = this.readBatches.get(bucketKey);
177
- if (!batch) {
178
- batch = { bucketKey, scope, userId, reads: new Map(), scheduled: false };
179
- this.readBatches.set(bucketKey, batch);
180
- }
181
- const reads = batch.reads.get(key) ?? [];
182
- batch.reads.set(key, reads);
183
- const promise = new Promise<Json | null>((resolve, reject) => {
184
- reads.push({ resolve, reject });
185
- });
186
- if (!batch.scheduled) {
187
- batch.scheduled = true;
188
- queueMicrotask(() => void this.flushReadBatch(bucketKey));
189
- }
190
- return promise as Promise<T | null>;
191
- }
192
-
193
- private async flushReadBatch(bucketKey: string): Promise<void> {
194
- const batch = this.readBatches.get(bucketKey);
195
- if (!batch) return;
196
- this.readBatches.delete(bucketKey);
197
- const keys = Array.from(batch.reads.keys());
198
- try {
199
- const { blob } = await hostRequest("sharedDeckStorage.load", {
200
- scope: batch.scope,
201
- userId: batch.userId ?? null,
202
- keys,
203
- });
204
- this.resolveReadBatch(batch, blob);
205
- } catch (error) {
206
- rejectReadBatch(batch, error);
207
- }
208
- }
209
-
210
- private resolveReadBatch(batch: ReadBatch, blob: StorageBlob): void {
211
- for (const [key, reads] of batch.reads) {
212
- const encoded =
213
- this.peekDirty(batch.scope, batch.userId, key) ?? blob[key] ?? null;
214
- const value =
215
- encoded === null
216
- ? null
217
- : decodeStorageValue(encoded, "SharedStorage.get");
218
- for (const read of reads) read.resolve(value);
219
- }
220
- }
221
-
222
- private async flush(): Promise<void> {
223
- if (this.dirtyBuckets.size === 0) return;
224
- const tasks = Array.from(this.dirtyBuckets, ([bucketKey, dirty]) =>
225
- this.flushBucket(bucketKey, new Map(dirty)),
226
- );
227
- await Promise.all(tasks);
228
- }
229
-
230
- private async flushBucket(
231
- bucketKey: string,
232
- snapshot: Map<string, EncodedValue>,
233
- ): Promise<void> {
234
- if (snapshot.size === 0) return;
235
- await hostRequest("sharedDeckStorage.update", {
236
- scope: scopeFromWriteKey(bucketKey),
237
- updates: updatesFromDirty(snapshot),
238
- });
239
- const dirty = this.dirtyBuckets.get(bucketKey);
240
- if (!dirty) return;
241
- clearAcknowledgedDirty(dirty, snapshot);
242
- if (dirty.size === 0) this.dirtyBuckets.delete(bucketKey);
243
- }
244
-
245
- private scheduleFlush(): void {
246
- if (this.flushTimer) return;
247
- this.flushTimer = setTimeout(() => {
248
- this.flushTimer = null;
249
- void this.flush().catch(reportSharedStorageError);
250
- }, FLUSH_INTERVAL_MS);
251
- }
252
- }
253
-
254
- export const Storage: StorageApi = new PrivateStorageImpl();
255
- export const SharedStorage: SharedStorageApi = new SharedStorageImpl();
256
-
257
- // 'user'-scope writes/self-reads share one bucket (the host resolves the
258
- // current player); a 'user' read of someone else keys by their id.
259
- function writeBucketKey(scope: SharedScope): string {
260
- return scope === "deck" ? "deck" : "user:self";
261
- }
262
-
263
- function readBucketKey(scope: SharedScope, userId: string | undefined): string {
264
- if (scope === "deck") return "deck";
265
- return userId ? `user:other:${userId}` : "user:self";
266
- }
267
-
268
- function scopeFromWriteKey(bucketKey: string): SharedScope {
269
- return bucketKey === "deck" ? "deck" : "user";
270
- }
271
-
272
- function updatesFromDirty(dirty: Map<string, EncodedValue>): StorageUpdate[] {
273
- return Array.from(dirty, ([key, value]) => ({ key, value }));
274
- }
275
-
276
- function clearAcknowledgedDirty(
277
- dirty: Map<string, EncodedValue>,
278
- acknowledged: Map<string, EncodedValue>,
279
- ): void {
280
- for (const [key, value] of acknowledged) {
281
- if (dirty.get(key) === value) dirty.delete(key);
282
- }
283
- }
284
-
285
- function decodeStorageBlob(
286
- blob: StorageBlob,
287
- operation: string,
288
- ): Map<string, Json> {
289
- return new Map(
290
- Object.entries(blob).map(([key, value]) => [
291
- key,
292
- decodeStorageValue(value, operation),
293
- ]),
294
- );
295
- }
296
-
297
- function decodeStorageValue(encoded: string, operation: string): Json {
298
- try {
299
- return JSON.parse(encoded) as Json;
300
- } catch {
301
- throw storageError(
302
- "CASTLE_STORAGE_PARSE_FAILED",
303
- "Stored Castle value is not valid JSON.",
304
- operation,
305
- );
306
- }
307
- }
308
-
309
- function encodeStorageValue(value: Json, operation: string): string {
310
- try {
311
- assertJsonValue(value, new Set(), operation);
312
- return JSON.stringify(value);
313
- } catch (error) {
314
- if (error instanceof CastleError) throw error;
315
- throw storageError(
316
- "CASTLE_STORAGE_SERIALIZE_FAILED",
317
- "Castle storage values must be JSON.",
318
- operation,
319
- );
320
- }
321
- }
322
-
323
- function assertJsonValue(
324
- value: unknown,
325
- seen: Set<object>,
326
- operation: string,
327
- ): void {
328
- if (value === null || typeof value === "boolean" || typeof value === "string")
329
- return;
330
- if (typeof value === "number") {
331
- if (Number.isFinite(value)) return;
332
- throw storageError(
333
- "CASTLE_STORAGE_SERIALIZE_FAILED",
334
- "Castle storage numbers must be finite.",
335
- operation,
336
- );
337
- }
338
- if (Array.isArray(value)) {
339
- assertJsonArray(value, seen, operation);
340
- return;
341
- }
342
- if (typeof value === "object") {
343
- assertJsonObject(value, seen, operation);
344
- return;
345
- }
346
- throw storageError(
347
- "CASTLE_STORAGE_SERIALIZE_FAILED",
348
- "Castle storage values must be JSON.",
349
- operation,
350
- );
351
- }
352
-
353
- function assertJsonArray(
354
- values: unknown[],
355
- seen: Set<object>,
356
- operation: string,
357
- ): void {
358
- assertNotCyclic(values, seen, operation);
359
- seen.add(values);
360
- for (const value of values) assertJsonValue(value, seen, operation);
361
- seen.delete(values);
362
- }
363
-
364
- function assertJsonObject(
365
- value: object,
366
- seen: Set<object>,
367
- operation: string,
368
- ): void {
369
- assertNotCyclic(value, seen, operation);
370
- const prototype = Object.getPrototypeOf(value) as unknown;
371
- if (prototype !== Object.prototype && prototype !== null) {
372
- throw storageError(
373
- "CASTLE_STORAGE_SERIALIZE_FAILED",
374
- "Castle storage objects must be plain JSON.",
375
- operation,
376
- );
377
- }
378
- seen.add(value);
379
- for (const child of Object.values(value))
380
- assertJsonValue(child, seen, operation);
381
- seen.delete(value);
382
- }
383
-
384
- function assertNotCyclic(
385
- value: object,
386
- seen: Set<object>,
387
- operation: string,
388
- ): void {
389
- if (seen.has(value)) {
390
- throw storageError(
391
- "CASTLE_STORAGE_SERIALIZE_FAILED",
392
- "Castle storage values cannot be cyclic.",
393
- operation,
394
- );
395
- }
396
- }
397
-
398
- function assertScope(
399
- scope: string,
400
- operation: string,
401
- ): asserts scope is SharedScope {
402
- if (scope !== "deck" && scope !== "user") {
403
- throw storageError(
404
- "CASTLE_STORAGE_INVALID_SCOPE",
405
- 'SharedStorage scope must be "deck" or "user".',
406
- operation,
407
- );
408
- }
409
- }
410
-
411
- function rejectReadBatch(batch: ReadBatch, error: unknown): void {
412
- for (const reads of batch.reads.values()) {
413
- for (const read of reads) read.reject(error);
414
- }
415
- }
416
-
417
- function reportSharedStorageError(error: unknown): void {
418
- console.warn("Castle shared storage write failed", error);
419
- }
420
-
421
- function storageError(
422
- code: string,
423
- message: string,
424
- operation: string,
425
- ): CastleError {
426
- return new CastleError({ code, message, operation });
427
- }
package/src/time.ts DELETED
@@ -1,202 +0,0 @@
1
- import { CastleError } from "./errors";
2
- import { hostRequest } from "./transport";
3
- import type { Json } from "./types";
4
-
5
- export type CastleClockZone = "player" | "Castle";
6
-
7
- export interface CastleDateParts {
8
- sec: number;
9
- min: number;
10
- hour: number;
11
- day: number;
12
- month: number;
13
- year: number;
14
- wday: number;
15
- yday: number;
16
- daysSinceCastleEpoch: number;
17
- }
18
-
19
- export interface CastleTimeApi {
20
- getServerTime(): Promise<number>;
21
- getServerDate(timezone?: CastleClockZone): Promise<CastleDateParts>;
22
- }
23
-
24
- interface ServerTimeSnapshot {
25
- offsetSeconds: number;
26
- castleTimezoneOffsetMinutes: number;
27
- daysSinceCastleEpoch: Record<CastleClockZone, number>;
28
- }
29
-
30
- let serverTimeSnapshot: ServerTimeSnapshot | null = null;
31
- let serverTimePromise: Promise<ServerTimeSnapshot> | null = null;
32
-
33
- export const Time: CastleTimeApi = {
34
- getServerTime,
35
- getServerDate,
36
- };
37
-
38
- async function getServerTime(): Promise<number> {
39
- const snapshot = await syncServerTime();
40
- return clientUnixSeconds() + snapshot.offsetSeconds;
41
- }
42
-
43
- async function getServerDate(
44
- timezone: string = "Castle",
45
- ): Promise<CastleDateParts> {
46
- const operation = "Time.getServerDate";
47
- const zone = clockZone(timezone, operation);
48
- const snapshot = await syncServerTime();
49
- return dateParts(
50
- clientUnixSeconds() + snapshot.offsetSeconds,
51
- zone,
52
- snapshot,
53
- );
54
- }
55
-
56
- async function syncServerTime(): Promise<ServerTimeSnapshot> {
57
- if (serverTimeSnapshot) return serverTimeSnapshot;
58
- if (!serverTimePromise) {
59
- serverTimePromise = fetchServerTime().then((snapshot) => {
60
- serverTimeSnapshot = snapshot;
61
- return snapshot;
62
- });
63
- }
64
- return serverTimePromise;
65
- }
66
-
67
- async function fetchServerTime(): Promise<ServerTimeSnapshot> {
68
- const operation = "Time.getServerTime";
69
- const data = await hostRequest("time.getServerTime", {});
70
- const timestamp = numberField(
71
- data.timestamp,
72
- "serverTime.timestamp",
73
- operation,
74
- );
75
- const timezoneOffset = numberField(
76
- data.timezoneOffset,
77
- "serverTime.timezoneOffset",
78
- operation,
79
- );
80
- const epochData = jsonObject(
81
- data.castleEpochData,
82
- "serverTime.castleEpochData",
83
- operation,
84
- );
85
- return {
86
- offsetSeconds: snappedOffset(timestamp - clientUnixSeconds()),
87
- castleTimezoneOffsetMinutes: timezoneOffset,
88
- daysSinceCastleEpoch: {
89
- Castle: numberField(
90
- epochData.daysSinceServerTz,
91
- "castleEpochData.daysSinceServerTz",
92
- operation,
93
- ),
94
- player: numberField(
95
- epochData.daysSinceUserTz,
96
- "castleEpochData.daysSinceUserTz",
97
- operation,
98
- ),
99
- },
100
- };
101
- }
102
-
103
- function dateParts(
104
- unixSeconds: number,
105
- timezone: CastleClockZone,
106
- snapshot: ServerTimeSnapshot,
107
- ): CastleDateParts {
108
- const shiftedSeconds =
109
- timezone === "Castle"
110
- ? unixSeconds + snapshot.castleTimezoneOffsetMinutes * 60
111
- : unixSeconds;
112
- const date = new Date(shiftedSeconds * 1000);
113
- return timezone === "Castle"
114
- ? utcDateParts(date, snapshot.daysSinceCastleEpoch.Castle)
115
- : localDateParts(date, snapshot.daysSinceCastleEpoch.player);
116
- }
117
-
118
- function utcDateParts(
119
- date: Date,
120
- daysSinceCastleEpoch: number,
121
- ): CastleDateParts {
122
- return {
123
- sec: date.getUTCSeconds(),
124
- min: date.getUTCMinutes(),
125
- hour: date.getUTCHours(),
126
- day: date.getUTCDate(),
127
- month: date.getUTCMonth() + 1,
128
- year: date.getUTCFullYear(),
129
- wday: date.getUTCDay() + 1,
130
- yday: dayOfYear(
131
- date.getUTCFullYear(),
132
- date.getUTCMonth(),
133
- date.getUTCDate(),
134
- ),
135
- daysSinceCastleEpoch,
136
- };
137
- }
138
-
139
- function localDateParts(
140
- date: Date,
141
- daysSinceCastleEpoch: number,
142
- ): CastleDateParts {
143
- return {
144
- sec: date.getSeconds(),
145
- min: date.getMinutes(),
146
- hour: date.getHours(),
147
- day: date.getDate(),
148
- month: date.getMonth() + 1,
149
- year: date.getFullYear(),
150
- wday: date.getDay() + 1,
151
- yday: dayOfYear(date.getFullYear(), date.getMonth(), date.getDate()),
152
- daysSinceCastleEpoch,
153
- };
154
- }
155
-
156
- function dayOfYear(year: number, monthIndex: number, day: number): number {
157
- const start = Date.UTC(year, 0, 1);
158
- const current = Date.UTC(year, monthIndex, day);
159
- return Math.floor((current - start) / 86400000) + 1;
160
- }
161
-
162
- function clockZone(timezone: string, operation: string): CastleClockZone {
163
- const normalized = timezone.trim().toLowerCase();
164
- if (normalized === "castle") return "Castle";
165
- if (normalized === "player") return "player";
166
- throw new CastleError({
167
- code: "UNSUPPORTED_TIMEZONE",
168
- message: 'Time.getServerDate timezone must be "Castle" or "player".',
169
- operation,
170
- });
171
- }
172
-
173
- function clientUnixSeconds(): number {
174
- return Math.floor(Date.now() / 1000);
175
- }
176
-
177
- function snappedOffset(offsetSeconds: number): number {
178
- return Math.abs(offsetSeconds) < 10 ? 0 : offsetSeconds;
179
- }
180
-
181
- function jsonObject(
182
- value: Json,
183
- field: string,
184
- operation: string,
185
- ): Record<string, Json> {
186
- if (value !== null && typeof value === "object" && !Array.isArray(value))
187
- return value;
188
- throw new CastleError({
189
- code: "GRAPHQL_BAD_DATA",
190
- message: `Castle GraphQL field ${field} was not an object.`,
191
- operation,
192
- });
193
- }
194
-
195
- function numberField(value: Json, field: string, operation: string): number {
196
- if (typeof value === "number" && Number.isFinite(value)) return value;
197
- throw new CastleError({
198
- code: "GRAPHQL_BAD_DATA",
199
- message: `Castle GraphQL field ${field} was not a number.`,
200
- operation,
201
- });
202
- }