castle-web-sdk 0.4.4 → 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/README.md +321 -0
- package/dist/castle.d.ts +2 -0
- package/dist/castle.js +1 -0
- package/dist/commands.d.ts +117 -0
- package/dist/commands.js +16 -0
- package/dist/context.d.ts +2 -19
- package/dist/context.js +0 -71
- package/dist/leaderboard.js +69 -114
- package/dist/passes.d.ts +7 -0
- package/dist/passes.js +84 -0
- package/dist/runtime.d.ts +2 -0
- package/dist/runtime.js +89 -15
- package/dist/storage.d.ts +1 -2
- package/dist/storage.js +82 -198
- package/dist/time.js +5 -16
- package/dist/transport.d.ts +16 -0
- package/dist/transport.js +126 -0
- package/dist/user.js +6 -23
- package/package.json +7 -4
- package/dist/auth.d.ts +0 -8
- package/dist/auth.js +0 -52
- package/dist/graphql.d.ts +0 -15
- package/dist/graphql.js +0 -120
- package/src/auth.ts +0 -64
- package/src/castle.ts +0 -19
- package/src/context.ts +0 -124
- package/src/errors.ts +0 -32
- package/src/graphql.ts +0 -182
- package/src/leaderboard.ts +0 -456
- package/src/runtime.ts +0 -372
- package/src/storage.ts +0 -636
- package/src/time.ts +0 -226
- package/src/types.ts +0 -7
- package/src/user.ts +0 -91
package/dist/storage.js
CHANGED
|
@@ -1,22 +1,22 @@
|
|
|
1
|
-
import { getAuth, requireAuthToken } from "./auth";
|
|
2
|
-
import { getDeckContext, requireDeckId } from "./context";
|
|
3
1
|
import { CastleError } from "./errors";
|
|
4
|
-
import {
|
|
2
|
+
import { hostRequest } from "./transport";
|
|
5
3
|
const FLUSH_INTERVAL_MS = 2000;
|
|
4
|
+
// Per-player private storage. The host stamps deckId/sessionId, so one deck
|
|
5
|
+
// session = one stable blob: load once, then serve reads from cache and batch
|
|
6
|
+
// writes on a 2s flush. Dirty writes overlay the server blob so an in-flight
|
|
7
|
+
// write isn't clobbered by the flush response.
|
|
6
8
|
class PrivateStorageImpl {
|
|
7
9
|
cache = new Map();
|
|
8
10
|
dirty = new Map();
|
|
9
11
|
loadPromise = null;
|
|
10
|
-
loadedContextKey = null;
|
|
11
12
|
flushTimer = null;
|
|
12
13
|
async get(key) {
|
|
13
|
-
await this.ensureLoaded(
|
|
14
|
+
await this.ensureLoaded();
|
|
14
15
|
return (this.cache.get(key) ?? null);
|
|
15
16
|
}
|
|
16
17
|
set(key, value) {
|
|
17
|
-
const encoded = encodeStorageValue(value, "Storage.set");
|
|
18
18
|
this.cache.set(key, value);
|
|
19
|
-
this.dirty.set(key,
|
|
19
|
+
this.dirty.set(key, encodeStorageValue(value, "Storage.set"));
|
|
20
20
|
this.scheduleFlush();
|
|
21
21
|
}
|
|
22
22
|
remove(key) {
|
|
@@ -24,51 +24,37 @@ class PrivateStorageImpl {
|
|
|
24
24
|
this.dirty.set(key, null);
|
|
25
25
|
this.scheduleFlush();
|
|
26
26
|
}
|
|
27
|
-
|
|
28
|
-
if (this.dirty.size === 0)
|
|
29
|
-
return;
|
|
30
|
-
const context = await currentContext("Storage.flush");
|
|
31
|
-
const snapshot = new Map(this.dirty);
|
|
32
|
-
const data = await storageGraphql(UPDATE_DECK_STORAGE_MUTATION, {
|
|
33
|
-
deckId: context.deckId,
|
|
34
|
-
sessionId: context.sessionId,
|
|
35
|
-
updates: updatesFromDirty(snapshot),
|
|
36
|
-
}, "updateDeckStorage");
|
|
37
|
-
this.applyServerBlob(data.updateDeckStorage, contextKey(context.deckId, context.sessionId));
|
|
38
|
-
clearAcknowledgedDirty(this.dirty, snapshot);
|
|
39
|
-
this.overlayDirty();
|
|
40
|
-
}
|
|
41
|
-
ensureLoaded(operation) {
|
|
27
|
+
ensureLoaded() {
|
|
42
28
|
if (!this.loadPromise) {
|
|
43
|
-
this.loadPromise = this.load(
|
|
29
|
+
this.loadPromise = this.load().catch((error) => {
|
|
44
30
|
this.loadPromise = null;
|
|
45
31
|
throw error;
|
|
46
32
|
});
|
|
47
33
|
}
|
|
48
34
|
return this.loadPromise;
|
|
49
35
|
}
|
|
50
|
-
async load(
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
if (this.loadedContextKey === key)
|
|
54
|
-
return;
|
|
55
|
-
const data = await storageGraphql(DECK_STORAGE_QUERY, { deckId: context.deckId, sessionId: context.sessionId }, "deckStorage");
|
|
56
|
-
this.cache = decodeStorageBlob(data.deckStorage, operation);
|
|
57
|
-
this.loadedContextKey = key;
|
|
36
|
+
async load() {
|
|
37
|
+
const { blob } = await hostRequest("deckStorage.load", {});
|
|
38
|
+
this.cache = decodeStorageBlob(blob, "Storage.get");
|
|
58
39
|
this.overlayDirty();
|
|
59
40
|
}
|
|
60
|
-
|
|
41
|
+
async flush() {
|
|
42
|
+
if (this.dirty.size === 0)
|
|
43
|
+
return;
|
|
44
|
+
const snapshot = new Map(this.dirty);
|
|
45
|
+
const { blob } = await hostRequest("deckStorage.update", {
|
|
46
|
+
updates: updatesFromDirty(snapshot),
|
|
47
|
+
});
|
|
61
48
|
this.cache = decodeStorageBlob(blob, "Storage.flush");
|
|
62
|
-
this.
|
|
49
|
+
clearAcknowledgedDirty(this.dirty, snapshot);
|
|
50
|
+
this.overlayDirty();
|
|
63
51
|
}
|
|
64
52
|
overlayDirty() {
|
|
65
53
|
for (const [key, encoded] of this.dirty) {
|
|
66
|
-
if (encoded === null)
|
|
54
|
+
if (encoded === null)
|
|
67
55
|
this.cache.delete(key);
|
|
68
|
-
|
|
69
|
-
else {
|
|
56
|
+
else
|
|
70
57
|
this.cache.set(key, decodeStorageValue(encoded, "Storage.dirty"));
|
|
71
|
-
}
|
|
72
58
|
}
|
|
73
59
|
}
|
|
74
60
|
scheduleFlush() {
|
|
@@ -80,71 +66,50 @@ class PrivateStorageImpl {
|
|
|
80
66
|
}, FLUSH_INTERVAL_MS);
|
|
81
67
|
}
|
|
82
68
|
}
|
|
69
|
+
// Cross-player storage. Writes always target the current player (the host
|
|
70
|
+
// forces 'user'-scope writes to whoever is signed in), so dirty writes bucket
|
|
71
|
+
// under a fixed key; reads of another player's 'user' bucket take an explicit
|
|
72
|
+
// userId and never have pending writes. Reads coalesce per microtask into one
|
|
73
|
+
// command with a keys[] array.
|
|
83
74
|
class SharedStorageImpl {
|
|
84
75
|
dirtyBuckets = new Map();
|
|
85
|
-
buckets = new Map();
|
|
86
76
|
readBatches = new Map();
|
|
87
|
-
pendingWrites = new Set();
|
|
88
|
-
pendingWriteErrors = [];
|
|
89
77
|
flushTimer = null;
|
|
90
78
|
async get(scope, userOrKey, maybeKey) {
|
|
91
|
-
|
|
79
|
+
assertScope(scope, "SharedStorage.get");
|
|
80
|
+
const userId = scope === "user" && maybeKey ? userOrKey : undefined;
|
|
92
81
|
const key = maybeKey ?? userOrKey;
|
|
93
|
-
|
|
94
|
-
const dirty = this.peekDirty(bucket, key);
|
|
82
|
+
const dirty = this.peekDirty(scope, userId, key);
|
|
95
83
|
if (dirty !== undefined) {
|
|
96
84
|
return (dirty === null ? null : decodeStorageValue(dirty, "SharedStorage.get"));
|
|
97
85
|
}
|
|
98
|
-
return this.queueRead(
|
|
86
|
+
return this.queueRead(scope, userId, key);
|
|
99
87
|
}
|
|
100
88
|
set(scope, key, value) {
|
|
101
|
-
|
|
89
|
+
assertScope(scope, "SharedStorage.set");
|
|
90
|
+
this.write(scope, key, encodeStorageValue(value, "SharedStorage.set"));
|
|
102
91
|
}
|
|
103
92
|
remove(scope, key) {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
async flush() {
|
|
107
|
-
await this.awaitPendingWrites("SharedStorage.flush");
|
|
108
|
-
if (this.dirtyBuckets.size === 0)
|
|
109
|
-
return;
|
|
110
|
-
const tasks = Array.from(this.dirtyBuckets, ([bucketKey, dirty]) => this.flushBucket(bucketKey, new Map(dirty)));
|
|
111
|
-
await Promise.all(tasks);
|
|
93
|
+
assertScope(scope, "SharedStorage.remove");
|
|
94
|
+
this.write(scope, key, null);
|
|
112
95
|
}
|
|
113
|
-
|
|
114
|
-
const
|
|
115
|
-
const bucketKey = makeBucketKey(bucket);
|
|
116
|
-
this.buckets.set(bucketKey, bucket);
|
|
96
|
+
write(scope, key, encoded) {
|
|
97
|
+
const bucketKey = writeBucketKey(scope);
|
|
117
98
|
const dirty = this.dirtyBuckets.get(bucketKey) ?? new Map();
|
|
118
99
|
dirty.set(key, encoded);
|
|
119
100
|
this.dirtyBuckets.set(bucketKey, dirty);
|
|
120
101
|
this.scheduleFlush();
|
|
121
102
|
}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
})
|
|
127
|
-
.finally(() => {
|
|
128
|
-
this.pendingWrites.delete(pending);
|
|
129
|
-
});
|
|
130
|
-
this.pendingWrites.add(pending);
|
|
103
|
+
peekDirty(scope, userId, key) {
|
|
104
|
+
if (scope === "user" && userId)
|
|
105
|
+
return undefined;
|
|
106
|
+
return this.dirtyBuckets.get(writeBucketKey(scope))?.get(key);
|
|
131
107
|
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
await Promise.all(this.pendingWrites);
|
|
135
|
-
}
|
|
136
|
-
const error = this.pendingWriteErrors.shift();
|
|
137
|
-
if (error instanceof Error)
|
|
138
|
-
throw error;
|
|
139
|
-
if (error) {
|
|
140
|
-
throw storageError("CASTLE_STORAGE_WRITE_FAILED", "Queued shared storage write failed.", operation);
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
async queueRead(bucket, key) {
|
|
144
|
-
const bucketKey = makeBucketKey(bucket);
|
|
108
|
+
queueRead(scope, userId, key) {
|
|
109
|
+
const bucketKey = readBucketKey(scope, userId);
|
|
145
110
|
let batch = this.readBatches.get(bucketKey);
|
|
146
111
|
if (!batch) {
|
|
147
|
-
batch = {
|
|
112
|
+
batch = { bucketKey, scope, userId, reads: new Map(), scheduled: false };
|
|
148
113
|
this.readBatches.set(bucketKey, batch);
|
|
149
114
|
}
|
|
150
115
|
const reads = batch.reads.get(key) ?? [];
|
|
@@ -154,11 +119,9 @@ class SharedStorageImpl {
|
|
|
154
119
|
});
|
|
155
120
|
if (!batch.scheduled) {
|
|
156
121
|
batch.scheduled = true;
|
|
157
|
-
queueMicrotask(() =>
|
|
158
|
-
void this.flushReadBatch(bucketKey);
|
|
159
|
-
});
|
|
122
|
+
queueMicrotask(() => void this.flushReadBatch(bucketKey));
|
|
160
123
|
}
|
|
161
|
-
return
|
|
124
|
+
return promise;
|
|
162
125
|
}
|
|
163
126
|
async flushReadBatch(bucketKey) {
|
|
164
127
|
const batch = this.readBatches.get(bucketKey);
|
|
@@ -167,9 +130,12 @@ class SharedStorageImpl {
|
|
|
167
130
|
this.readBatches.delete(bucketKey);
|
|
168
131
|
const keys = Array.from(batch.reads.keys());
|
|
169
132
|
try {
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
133
|
+
const { blob } = await hostRequest("sharedDeckStorage.load", {
|
|
134
|
+
scope: batch.scope,
|
|
135
|
+
userId: batch.userId ?? null,
|
|
136
|
+
keys,
|
|
137
|
+
});
|
|
138
|
+
this.resolveReadBatch(batch, blob);
|
|
173
139
|
}
|
|
174
140
|
catch (error) {
|
|
175
141
|
rejectReadBatch(batch, error);
|
|
@@ -177,7 +143,7 @@ class SharedStorageImpl {
|
|
|
177
143
|
}
|
|
178
144
|
resolveReadBatch(batch, blob) {
|
|
179
145
|
for (const [key, reads] of batch.reads) {
|
|
180
|
-
const encoded = this.peekDirty(batch.
|
|
146
|
+
const encoded = this.peekDirty(batch.scope, batch.userId, key) ?? blob[key] ?? null;
|
|
181
147
|
const value = encoded === null
|
|
182
148
|
? null
|
|
183
149
|
: decodeStorageValue(encoded, "SharedStorage.get");
|
|
@@ -185,16 +151,19 @@ class SharedStorageImpl {
|
|
|
185
151
|
read.resolve(value);
|
|
186
152
|
}
|
|
187
153
|
}
|
|
154
|
+
async flush() {
|
|
155
|
+
if (this.dirtyBuckets.size === 0)
|
|
156
|
+
return;
|
|
157
|
+
const tasks = Array.from(this.dirtyBuckets, ([bucketKey, dirty]) => this.flushBucket(bucketKey, new Map(dirty)));
|
|
158
|
+
await Promise.all(tasks);
|
|
159
|
+
}
|
|
188
160
|
async flushBucket(bucketKey, snapshot) {
|
|
189
161
|
if (snapshot.size === 0)
|
|
190
162
|
return;
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
return;
|
|
194
|
-
const context = await currentContext("SharedStorage.flush");
|
|
195
|
-
await storageGraphql(UPDATE_SHARED_DECK_STORAGE_MUTATION, sharedVariables(context.deckId, bucket, {
|
|
163
|
+
await hostRequest("sharedDeckStorage.update", {
|
|
164
|
+
scope: scopeFromWriteKey(bucketKey),
|
|
196
165
|
updates: updatesFromDirty(snapshot),
|
|
197
|
-
})
|
|
166
|
+
});
|
|
198
167
|
const dirty = this.dirtyBuckets.get(bucketKey);
|
|
199
168
|
if (!dirty)
|
|
200
169
|
return;
|
|
@@ -202,112 +171,29 @@ class SharedStorageImpl {
|
|
|
202
171
|
if (dirty.size === 0)
|
|
203
172
|
this.dirtyBuckets.delete(bucketKey);
|
|
204
173
|
}
|
|
205
|
-
peekDirty(bucket, key) {
|
|
206
|
-
return this.dirtyBuckets.get(makeBucketKey(bucket))?.get(key);
|
|
207
|
-
}
|
|
208
174
|
scheduleFlush() {
|
|
209
175
|
if (this.flushTimer)
|
|
210
176
|
return;
|
|
211
177
|
this.flushTimer = setTimeout(() => {
|
|
212
178
|
this.flushTimer = null;
|
|
213
|
-
void this.flush();
|
|
179
|
+
void this.flush().catch(reportSharedStorageError);
|
|
214
180
|
}, FLUSH_INTERVAL_MS);
|
|
215
181
|
}
|
|
216
182
|
}
|
|
217
183
|
export const Storage = new PrivateStorageImpl();
|
|
218
184
|
export const SharedStorage = new SharedStorageImpl();
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
`;
|
|
224
|
-
const UPDATE_DECK_STORAGE_MUTATION = `
|
|
225
|
-
mutation CastleUpdateDeckStorage(
|
|
226
|
-
$deckId: ID!,
|
|
227
|
-
$updates: [DeckStorageUpdateInput!]!,
|
|
228
|
-
$sessionId: ID
|
|
229
|
-
) {
|
|
230
|
-
updateDeckStorage(deckId: $deckId, updates: $updates, sessionId: $sessionId)
|
|
231
|
-
}
|
|
232
|
-
`;
|
|
233
|
-
const SHARED_DECK_STORAGE_QUERY = `
|
|
234
|
-
query CastleSharedDeckStorage(
|
|
235
|
-
$deckId: ID!,
|
|
236
|
-
$keys: [String!]!,
|
|
237
|
-
$sessionId: ID,
|
|
238
|
-
$userId: ID
|
|
239
|
-
) {
|
|
240
|
-
sharedDeckStorage(deckId: $deckId, keys: $keys, sessionId: $sessionId, userId: $userId)
|
|
241
|
-
}
|
|
242
|
-
`;
|
|
243
|
-
const UPDATE_SHARED_DECK_STORAGE_MUTATION = `
|
|
244
|
-
mutation CastleUpdateSharedDeckStorage(
|
|
245
|
-
$deckId: ID!,
|
|
246
|
-
$updates: [SharedDeckStorageUpdateInput!]!,
|
|
247
|
-
$sessionId: ID,
|
|
248
|
-
$userId: ID
|
|
249
|
-
) {
|
|
250
|
-
updateSharedDeckStorage(
|
|
251
|
-
deckId: $deckId,
|
|
252
|
-
updates: $updates,
|
|
253
|
-
sessionId: $sessionId,
|
|
254
|
-
userId: $userId
|
|
255
|
-
)
|
|
256
|
-
}
|
|
257
|
-
`;
|
|
258
|
-
async function currentContext(operation) {
|
|
259
|
-
const deckId = await storageDeckId(operation);
|
|
260
|
-
const context = await getDeckContext();
|
|
261
|
-
return { deckId, sessionId: context.sessionId ?? undefined };
|
|
262
|
-
}
|
|
263
|
-
async function storageDeckId(operation) {
|
|
264
|
-
try {
|
|
265
|
-
return await requireDeckId(operation);
|
|
266
|
-
}
|
|
267
|
-
catch (error) {
|
|
268
|
-
if (error instanceof CastleError && error.code === "MISSING_DECK_ID") {
|
|
269
|
-
throw storageError("CASTLE_STORAGE_MISSING_DECK_ID", "Save this deck before using Castle storage.", operation);
|
|
270
|
-
}
|
|
271
|
-
throw error;
|
|
272
|
-
}
|
|
185
|
+
// 'user'-scope writes/self-reads share one bucket (the host resolves the
|
|
186
|
+
// current player); a 'user' read of someone else keys by their id.
|
|
187
|
+
function writeBucketKey(scope) {
|
|
188
|
+
return scope === "deck" ? "deck" : "user:self";
|
|
273
189
|
}
|
|
274
|
-
|
|
275
|
-
const token = await requireAuthToken(operation);
|
|
276
|
-
return graphqlRequest(query, variables, {
|
|
277
|
-
operation,
|
|
278
|
-
requireAuth: true,
|
|
279
|
-
token,
|
|
280
|
-
});
|
|
281
|
-
}
|
|
282
|
-
async function readBucket(scope, userOrKey, maybeKey, operation) {
|
|
283
|
-
assertScope(scope, operation);
|
|
284
|
-
if (scope === "deck")
|
|
285
|
-
return withSession({ scope });
|
|
286
|
-
if (maybeKey)
|
|
287
|
-
return withSession({ scope, userId: userOrKey });
|
|
288
|
-
return writeBucket(scope, operation);
|
|
289
|
-
}
|
|
290
|
-
async function writeBucket(scope, operation) {
|
|
291
|
-
assertScope(scope, operation);
|
|
190
|
+
function readBucketKey(scope, userId) {
|
|
292
191
|
if (scope === "deck")
|
|
293
|
-
return
|
|
294
|
-
|
|
295
|
-
if (!auth.userId) {
|
|
296
|
-
throw storageError("CASTLE_STORAGE_MISSING_USER_ID", "Castle user id is required for user-scoped shared storage.", operation);
|
|
297
|
-
}
|
|
298
|
-
return withSession({ scope, userId: auth.userId });
|
|
192
|
+
return "deck";
|
|
193
|
+
return userId ? `user:other:${userId}` : "user:self";
|
|
299
194
|
}
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
return { ...bucket, sessionId: context.sessionId ?? undefined };
|
|
303
|
-
}
|
|
304
|
-
function sharedVariables(deckId, bucket, extra) {
|
|
305
|
-
return {
|
|
306
|
-
deckId,
|
|
307
|
-
sessionId: bucket.sessionId,
|
|
308
|
-
userId: bucket.userId,
|
|
309
|
-
...extra,
|
|
310
|
-
};
|
|
195
|
+
function scopeFromWriteKey(bucketKey) {
|
|
196
|
+
return bucketKey === "deck" ? "deck" : "user";
|
|
311
197
|
}
|
|
312
198
|
function updatesFromDirty(dirty) {
|
|
313
199
|
return Array.from(dirty, ([key, value]) => ({ key, value }));
|
|
@@ -362,18 +248,14 @@ function assertJsonValue(value, seen, operation) {
|
|
|
362
248
|
throw storageError("CASTLE_STORAGE_SERIALIZE_FAILED", "Castle storage values must be JSON.", operation);
|
|
363
249
|
}
|
|
364
250
|
function assertJsonArray(values, seen, operation) {
|
|
365
|
-
|
|
366
|
-
throw storageError("CASTLE_STORAGE_SERIALIZE_FAILED", "Castle storage values cannot be cyclic.", operation);
|
|
367
|
-
}
|
|
251
|
+
assertNotCyclic(values, seen, operation);
|
|
368
252
|
seen.add(values);
|
|
369
253
|
for (const value of values)
|
|
370
254
|
assertJsonValue(value, seen, operation);
|
|
371
255
|
seen.delete(values);
|
|
372
256
|
}
|
|
373
257
|
function assertJsonObject(value, seen, operation) {
|
|
374
|
-
|
|
375
|
-
throw storageError("CASTLE_STORAGE_SERIALIZE_FAILED", "Castle storage values cannot be cyclic.", operation);
|
|
376
|
-
}
|
|
258
|
+
assertNotCyclic(value, seen, operation);
|
|
377
259
|
const prototype = Object.getPrototypeOf(value);
|
|
378
260
|
if (prototype !== Object.prototype && prototype !== null) {
|
|
379
261
|
throw storageError("CASTLE_STORAGE_SERIALIZE_FAILED", "Castle storage objects must be plain JSON.", operation);
|
|
@@ -383,11 +265,10 @@ function assertJsonObject(value, seen, operation) {
|
|
|
383
265
|
assertJsonValue(child, seen, operation);
|
|
384
266
|
seen.delete(value);
|
|
385
267
|
}
|
|
386
|
-
function
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
return `${deckId}:${sessionId ?? ""}`;
|
|
268
|
+
function assertNotCyclic(value, seen, operation) {
|
|
269
|
+
if (seen.has(value)) {
|
|
270
|
+
throw storageError("CASTLE_STORAGE_SERIALIZE_FAILED", "Castle storage values cannot be cyclic.", operation);
|
|
271
|
+
}
|
|
391
272
|
}
|
|
392
273
|
function assertScope(scope, operation) {
|
|
393
274
|
if (scope !== "deck" && scope !== "user") {
|
|
@@ -400,6 +281,9 @@ function rejectReadBatch(batch, error) {
|
|
|
400
281
|
read.reject(error);
|
|
401
282
|
}
|
|
402
283
|
}
|
|
284
|
+
function reportSharedStorageError(error) {
|
|
285
|
+
console.warn("Castle shared storage write failed", error);
|
|
286
|
+
}
|
|
403
287
|
function storageError(code, message, operation) {
|
|
404
288
|
return new CastleError({ code, message, operation });
|
|
405
289
|
}
|
package/dist/time.js
CHANGED
|
@@ -1,14 +1,5 @@
|
|
|
1
1
|
import { CastleError } from "./errors";
|
|
2
|
-
import {
|
|
3
|
-
const SERVER_TIME_QUERY = `
|
|
4
|
-
query CastleServerTime {
|
|
5
|
-
serverTime {
|
|
6
|
-
timestamp
|
|
7
|
-
timezoneOffset
|
|
8
|
-
castleEpochData
|
|
9
|
-
}
|
|
10
|
-
}
|
|
11
|
-
`;
|
|
2
|
+
import { hostRequest } from "./transport";
|
|
12
3
|
let serverTimeSnapshot = null;
|
|
13
4
|
let serverTimePromise = null;
|
|
14
5
|
export const Time = {
|
|
@@ -38,12 +29,10 @@ async function syncServerTime() {
|
|
|
38
29
|
}
|
|
39
30
|
async function fetchServerTime() {
|
|
40
31
|
const operation = "Time.getServerTime";
|
|
41
|
-
const data = await
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const
|
|
45
|
-
const timezoneOffset = numberField(data.serverTime.timezoneOffset, "serverTime.timezoneOffset", operation);
|
|
46
|
-
const epochData = jsonObject(data.serverTime.castleEpochData, "serverTime.castleEpochData", operation);
|
|
32
|
+
const data = await hostRequest("time.getServerTime", {});
|
|
33
|
+
const timestamp = numberField(data.timestamp, "serverTime.timestamp", operation);
|
|
34
|
+
const timezoneOffset = numberField(data.timezoneOffset, "serverTime.timezoneOffset", operation);
|
|
35
|
+
const epochData = jsonObject(data.castleEpochData, "serverTime.castleEpochData", operation);
|
|
47
36
|
return {
|
|
48
37
|
offsetSeconds: snappedOffset(timestamp - clientUnixSeconds()),
|
|
49
38
|
castleTimezoneOffsetMinutes: timezoneOffset,
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type CommandName, type CommandParams, type CommandResult } from "./commands";
|
|
2
|
+
type PostChannel = "mobile" | "web";
|
|
3
|
+
interface ReactNativeWebViewBridge {
|
|
4
|
+
postMessage: (message: string) => void;
|
|
5
|
+
}
|
|
6
|
+
declare global {
|
|
7
|
+
interface Window {
|
|
8
|
+
ReactNativeWebView?: ReactNativeWebViewBridge;
|
|
9
|
+
__castleSdkHost?: {
|
|
10
|
+
receive: (message: unknown) => void;
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export declare function hostRequest<C extends CommandName>(command: C, params: CommandParams[C]): Promise<CommandResult[C]>;
|
|
15
|
+
export declare function getCommandChannel(): PostChannel | "local";
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// Deck-side command-poster. The SDK never makes API calls or holds the auth
|
|
2
|
+
// token; every privileged operation becomes a `command` posted to the outer
|
|
3
|
+
// runtime (host), which validates it, stamps trusted context, runs the call
|
|
4
|
+
// with its own auth, and replies. Three host channels:
|
|
5
|
+
// - mobile → window.ReactNativeWebView.postMessage; host pushes responses
|
|
6
|
+
// back by calling window.__castleSdkHost.receive(...)
|
|
7
|
+
// - web → window.parent.postMessage; responses via 'message' events
|
|
8
|
+
// - local → the castle-web serve dev server, over runtime.ts's websocket
|
|
9
|
+
// Error reconstruction is uniform here so callers always get a CastleError.
|
|
10
|
+
import { CASTLE_SDK_PROTOCOL, isResponseEnvelope, } from "./commands";
|
|
11
|
+
import { getCastleEmbed } from "./context";
|
|
12
|
+
import { CastleError } from "./errors";
|
|
13
|
+
import { sendLocalCommand } from "./runtime";
|
|
14
|
+
const REQUEST_TIMEOUT_MS = 15000;
|
|
15
|
+
// Interactive platform commands hold the screen while the player interacts with
|
|
16
|
+
// a host-native sheet (e.g. a pass purchase), so the flat data-command timeout
|
|
17
|
+
// is wrong for them: they get NO timeout and resolve only when the host replies.
|
|
18
|
+
const INTERACTIVE_COMMANDS = new Set([
|
|
19
|
+
"pass.offer",
|
|
20
|
+
]);
|
|
21
|
+
let nextRequestId = 1;
|
|
22
|
+
const pending = new Map();
|
|
23
|
+
let listenersInstalled = false;
|
|
24
|
+
export async function hostRequest(command, params) {
|
|
25
|
+
try {
|
|
26
|
+
const channel = resolveChannel();
|
|
27
|
+
const env = channel === "local"
|
|
28
|
+
? await sendLocalCommand(command, params)
|
|
29
|
+
: await postCommand(channel, command, params);
|
|
30
|
+
return interpretResponse(command, env);
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
// Honor the SDK contract that every thrown error is a CastleError. Errors
|
|
34
|
+
// surfaced by the host (interpretResponse) are already CastleErrors; this
|
|
35
|
+
// wraps transport-level failures (host timeout / unreachable / dev server
|
|
36
|
+
// disconnected) that would otherwise be plain Errors.
|
|
37
|
+
if (error instanceof CastleError)
|
|
38
|
+
throw error;
|
|
39
|
+
throw new CastleError({
|
|
40
|
+
code: "CASTLE_HOST_UNAVAILABLE",
|
|
41
|
+
message: error instanceof Error
|
|
42
|
+
? error.message
|
|
43
|
+
: `Castle host did not handle ${command}.`,
|
|
44
|
+
operation: command,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
// Exposed so capability modules (e.g. passes) can tell whether the current host
|
|
49
|
+
// has its own UI surface over the deck. "mobile"/"web" hosts render their own
|
|
50
|
+
// purchase/upsell UI; the "local" dev server has none, so the SDK itself shows
|
|
51
|
+
// a minimal in-page notice there.
|
|
52
|
+
export function getCommandChannel() {
|
|
53
|
+
return resolveChannel();
|
|
54
|
+
}
|
|
55
|
+
function resolveChannel() {
|
|
56
|
+
if (typeof window === "undefined")
|
|
57
|
+
return "local";
|
|
58
|
+
if (window.ReactNativeWebView)
|
|
59
|
+
return "mobile";
|
|
60
|
+
const host = getCastleEmbed()?.host;
|
|
61
|
+
if (host === "web")
|
|
62
|
+
return "web";
|
|
63
|
+
if (host === "dev")
|
|
64
|
+
return "local";
|
|
65
|
+
// Fallback: an iframe with a parent is the web player; otherwise assume the
|
|
66
|
+
// local dev server (top-level page served by `castle-web serve`).
|
|
67
|
+
return window.parent && window.parent !== window ? "web" : "local";
|
|
68
|
+
}
|
|
69
|
+
function postCommand(channel, command, params) {
|
|
70
|
+
installResponseListener();
|
|
71
|
+
const requestId = `csdk_${nextRequestId++}`;
|
|
72
|
+
return new Promise((resolve, reject) => {
|
|
73
|
+
const timeout = INTERACTIVE_COMMANDS.has(command)
|
|
74
|
+
? undefined
|
|
75
|
+
: setTimeout(() => {
|
|
76
|
+
pending.delete(requestId);
|
|
77
|
+
reject(new Error(`Castle host did not respond to ${command}.`));
|
|
78
|
+
}, REQUEST_TIMEOUT_MS);
|
|
79
|
+
pending.set(requestId, { resolve, reject, timeout });
|
|
80
|
+
sendEnvelope(channel, { castleSdk: CASTLE_SDK_PROTOCOL, requestId, command, params });
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
function sendEnvelope(channel, envelope) {
|
|
84
|
+
const json = JSON.stringify(envelope);
|
|
85
|
+
if (channel === "mobile") {
|
|
86
|
+
window.ReactNativeWebView?.postMessage(json);
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
window.parent.postMessage(envelope, "*");
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
// The mobile host can't dispatch a DOM 'message' event, so it calls this global
|
|
93
|
+
// directly with the parsed envelope. The web host posts a 'message' event.
|
|
94
|
+
function installResponseListener() {
|
|
95
|
+
if (listenersInstalled || typeof window === "undefined")
|
|
96
|
+
return;
|
|
97
|
+
listenersInstalled = true;
|
|
98
|
+
window.__castleSdkHost = { receive: (message) => settle(message) };
|
|
99
|
+
window.addEventListener("message", (event) => {
|
|
100
|
+
settle(event.data);
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
function settle(message) {
|
|
104
|
+
if (!isResponseEnvelope(message))
|
|
105
|
+
return;
|
|
106
|
+
const entry = pending.get(message.requestId);
|
|
107
|
+
if (!entry)
|
|
108
|
+
return;
|
|
109
|
+
if (entry.timeout)
|
|
110
|
+
clearTimeout(entry.timeout);
|
|
111
|
+
pending.delete(message.requestId);
|
|
112
|
+
entry.resolve(message);
|
|
113
|
+
}
|
|
114
|
+
function interpretResponse(command, env) {
|
|
115
|
+
if (env.ok)
|
|
116
|
+
return env.data;
|
|
117
|
+
throw fromSerializedError(env.error, command);
|
|
118
|
+
}
|
|
119
|
+
function fromSerializedError(error, command) {
|
|
120
|
+
return new CastleError({
|
|
121
|
+
code: error?.code ?? "CASTLE_HOST_ERROR",
|
|
122
|
+
message: error?.message ?? `Castle command ${command} failed.`,
|
|
123
|
+
operation: error?.command ?? command,
|
|
124
|
+
extensions: error?.extensions,
|
|
125
|
+
});
|
|
126
|
+
}
|
package/dist/user.js
CHANGED
|
@@ -1,14 +1,5 @@
|
|
|
1
|
-
import { requireAuthToken } from "./auth";
|
|
2
1
|
import { CastleError } from "./errors";
|
|
3
|
-
import {
|
|
4
|
-
const CURRENT_USER_QUERY = `
|
|
5
|
-
query CastleCurrentUser {
|
|
6
|
-
me {
|
|
7
|
-
userId
|
|
8
|
-
username
|
|
9
|
-
}
|
|
10
|
-
}
|
|
11
|
-
`;
|
|
2
|
+
import { hostRequest } from "./transport";
|
|
12
3
|
let currentUser = null;
|
|
13
4
|
let currentUserPromise = null;
|
|
14
5
|
export const User = {
|
|
@@ -25,25 +16,17 @@ async function getCurrent() {
|
|
|
25
16
|
}
|
|
26
17
|
async function fetchCurrentUser() {
|
|
27
18
|
const operation = "User.getCurrent";
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
operation,
|
|
31
|
-
requireAuth: true,
|
|
32
|
-
token,
|
|
33
|
-
});
|
|
34
|
-
if (!data.me) {
|
|
19
|
+
const { user } = await hostRequest("user.getCurrent", {});
|
|
20
|
+
if (!user) {
|
|
35
21
|
throw new CastleError({
|
|
36
22
|
code: "LOGIN_REQUIRED",
|
|
37
23
|
message: "Log in to Castle before using User.getCurrent().",
|
|
38
24
|
operation,
|
|
39
25
|
});
|
|
40
26
|
}
|
|
41
|
-
return normalizeCurrentUser(data.me, operation);
|
|
42
|
-
}
|
|
43
|
-
function normalizeCurrentUser(user, operation) {
|
|
44
27
|
return {
|
|
45
|
-
userId: requiredString(user.userId, "
|
|
46
|
-
username: requiredString(user.username, "
|
|
28
|
+
userId: requiredString(user.userId, "user.userId", operation),
|
|
29
|
+
username: requiredString(user.username, "user.username", operation),
|
|
47
30
|
isActive: true,
|
|
48
31
|
};
|
|
49
32
|
}
|
|
@@ -52,7 +35,7 @@ function requiredString(value, field, operation) {
|
|
|
52
35
|
return value;
|
|
53
36
|
throw new CastleError({
|
|
54
37
|
code: "GRAPHQL_BAD_DATA",
|
|
55
|
-
message: `Castle
|
|
38
|
+
message: `Castle response did not include ${field}.`,
|
|
56
39
|
operation,
|
|
57
40
|
});
|
|
58
41
|
}
|