castle-web-sdk 0.4.4 → 0.4.5
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/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 +67 -1
- 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 +8 -4
- package/src/castle.ts +6 -0
- package/src/commands.ts +136 -0
- package/src/context.ts +4 -98
- package/src/leaderboard.ts +78 -186
- package/src/passes.ts +95 -0
- package/src/runtime.ts +80 -1
- package/src/storage.ts +106 -315
- package/src/time.ts +5 -29
- package/src/transport.ts +169 -0
- package/src/user.ts +6 -39
- 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/graphql.ts +0 -182
package/dist/runtime.js
CHANGED
|
@@ -1,9 +1,17 @@
|
|
|
1
|
+
import { CASTLE_SDK_PROTOCOL, } from "./commands";
|
|
1
2
|
import { getCastleEmbed, isEdit } from "./context";
|
|
2
3
|
export const CARD_RATIO = 5 / 7;
|
|
3
4
|
let ws = null;
|
|
4
5
|
let logBuffer = [];
|
|
5
6
|
let nextRequestId = 1;
|
|
6
7
|
const pendingRequests = new Map();
|
|
8
|
+
// Local-dev command channel: the `castle-web serve` dev server is the host, so
|
|
9
|
+
// SDK commands ride the same websocket runtime.ts already uses for
|
|
10
|
+
// logs/screenshots/restart. Correlated by requestId, separate from the
|
|
11
|
+
// screenshot/write_file request map above.
|
|
12
|
+
const COMMAND_TIMEOUT_MS = 15000;
|
|
13
|
+
const SOCKET_WAIT_TIMEOUT_MS = 10000;
|
|
14
|
+
const pendingCommands = new Map();
|
|
7
15
|
const origLog = console.log;
|
|
8
16
|
const origWarn = console.warn;
|
|
9
17
|
const origError = console.error;
|
|
@@ -126,6 +134,61 @@ function sendLocalRequest(msg) {
|
|
|
126
134
|
ws.send(JSON.stringify(request));
|
|
127
135
|
});
|
|
128
136
|
}
|
|
137
|
+
// Send an SDK command to the dev server and resolve with the raw response
|
|
138
|
+
// envelope (ok/data/error). transport.ts interprets it — error reconstruction
|
|
139
|
+
// stays uniform across all three channels there. Waits for the socket to open
|
|
140
|
+
// so a command issued during startup isn't dropped.
|
|
141
|
+
export function sendLocalCommand(command, params) {
|
|
142
|
+
const requestId = `cmd_${nextRequestId++}`;
|
|
143
|
+
return new Promise((resolve, reject) => {
|
|
144
|
+
const timeout = setTimeout(() => {
|
|
145
|
+
pendingCommands.delete(requestId);
|
|
146
|
+
reject(new Error(`Timed out waiting for command ${command}.`));
|
|
147
|
+
}, COMMAND_TIMEOUT_MS);
|
|
148
|
+
pendingCommands.set(requestId, (env) => {
|
|
149
|
+
clearTimeout(timeout);
|
|
150
|
+
pendingCommands.delete(requestId);
|
|
151
|
+
resolve(env);
|
|
152
|
+
});
|
|
153
|
+
waitForSocket()
|
|
154
|
+
.then((socket) => {
|
|
155
|
+
socket.send(JSON.stringify({
|
|
156
|
+
type: "castle_command",
|
|
157
|
+
castleSdk: CASTLE_SDK_PROTOCOL,
|
|
158
|
+
requestId,
|
|
159
|
+
command,
|
|
160
|
+
params,
|
|
161
|
+
}));
|
|
162
|
+
})
|
|
163
|
+
.catch((error) => {
|
|
164
|
+
clearTimeout(timeout);
|
|
165
|
+
pendingCommands.delete(requestId);
|
|
166
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
function waitForSocket() {
|
|
171
|
+
if (ws && ws.readyState === WebSocket.OPEN)
|
|
172
|
+
return Promise.resolve(ws);
|
|
173
|
+
return new Promise((resolve, reject) => {
|
|
174
|
+
const start = Date.now();
|
|
175
|
+
const poll = setInterval(() => {
|
|
176
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
177
|
+
clearInterval(poll);
|
|
178
|
+
resolve(ws);
|
|
179
|
+
}
|
|
180
|
+
else if (Date.now() - start > SOCKET_WAIT_TIMEOUT_MS) {
|
|
181
|
+
clearInterval(poll);
|
|
182
|
+
reject(new Error("Castle dev server is not connected."));
|
|
183
|
+
}
|
|
184
|
+
}, 100);
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
function resolveLocalCommand(msg) {
|
|
188
|
+
const pending = pendingCommands.get(msg.requestId);
|
|
189
|
+
if (pending)
|
|
190
|
+
pending(msg);
|
|
191
|
+
}
|
|
129
192
|
function resolveLocalRequest(msg) {
|
|
130
193
|
if (!msg.requestId)
|
|
131
194
|
return false;
|
|
@@ -280,6 +343,9 @@ function handleLocalMessage(msg) {
|
|
|
280
343
|
else if (msg.type === "write_file_response") {
|
|
281
344
|
resolveLocalRequest(msg);
|
|
282
345
|
}
|
|
346
|
+
else if (msg.type === "castle_command_response") {
|
|
347
|
+
resolveLocalCommand(msg);
|
|
348
|
+
}
|
|
283
349
|
}
|
|
284
350
|
// Restart (from `castle-web restart` / task agents) is debounced so a burst
|
|
285
351
|
// of reload requests -- several tasks finishing close together -- produces
|
|
@@ -298,7 +364,7 @@ function scheduleRestart() {
|
|
|
298
364
|
restartTimer = setTimeout(() => {
|
|
299
365
|
void (async () => {
|
|
300
366
|
try {
|
|
301
|
-
await Promise.all([...beforeRestartHooks].map((hook) => hook()));
|
|
367
|
+
await Promise.all([...beforeRestartHooks].map(async (hook) => hook()));
|
|
302
368
|
}
|
|
303
369
|
catch {
|
|
304
370
|
// a failed flush shouldn't block the reload
|
package/dist/storage.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
+
import { type SharedScope } from "./commands";
|
|
1
2
|
import type { Json } from "./types";
|
|
2
|
-
type SharedScope = "deck" | "user";
|
|
3
3
|
export interface StorageApi {
|
|
4
4
|
get<T extends Json = Json>(key: string): Promise<T | null>;
|
|
5
5
|
set(key: string, value: Json): void;
|
|
@@ -14,4 +14,3 @@ export interface SharedStorageApi {
|
|
|
14
14
|
}
|
|
15
15
|
export declare const Storage: StorageApi;
|
|
16
16
|
export declare const SharedStorage: SharedStorageApi;
|
|
17
|
-
export {};
|
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 {};
|