castle-web-sdk 0.4.3 → 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 +3 -1
- package/dist/castle.js +2 -1
- 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 +3 -0
- package/dist/runtime.js +93 -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 +7 -1
- 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 +107 -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/src/leaderboard.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
type RawLeaderboard,
|
|
3
|
+
type RawLeaderboardEntry,
|
|
4
|
+
} from "./commands";
|
|
5
|
+
import { isEdit } from "./context";
|
|
3
6
|
import { CastleError } from "./errors";
|
|
4
|
-
import {
|
|
7
|
+
import { hostRequest } from "./transport";
|
|
5
8
|
|
|
6
9
|
export type LeaderboardSort = "high" | "low";
|
|
7
10
|
export type LeaderboardScope = string;
|
|
@@ -23,17 +26,7 @@ export interface LeaderboardData {
|
|
|
23
26
|
playerValue?: number;
|
|
24
27
|
}
|
|
25
28
|
|
|
26
|
-
interface LeaderboardRequest {
|
|
27
|
-
deckId: string;
|
|
28
|
-
variable: string;
|
|
29
|
-
type: LeaderboardSort;
|
|
30
|
-
scope: string | null;
|
|
31
|
-
token: string;
|
|
32
|
-
userId: string | null;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
29
|
interface PendingLeaderboardWrite {
|
|
36
|
-
deckId: string;
|
|
37
30
|
variable: string;
|
|
38
31
|
scope: string | null;
|
|
39
32
|
highScore: number;
|
|
@@ -48,32 +41,10 @@ interface LeaderboardWriteJob {
|
|
|
48
41
|
score: number;
|
|
49
42
|
}
|
|
50
43
|
|
|
51
|
-
interface GraphqlLeaderboardUser {
|
|
52
|
-
userId?: string | null;
|
|
53
|
-
username?: string | null;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
interface GraphqlLeaderboardEntry {
|
|
57
|
-
place?: string | number | null;
|
|
58
|
-
score?: string | number | null;
|
|
59
|
-
user?: GraphqlLeaderboardUser | null;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
interface GraphqlLeaderboard {
|
|
63
|
-
list?: GraphqlLeaderboardEntry[] | null;
|
|
64
|
-
yourScore?: GraphqlLeaderboardEntry | null;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
interface LeaderboardQueryData {
|
|
68
|
-
leaderboard: GraphqlLeaderboard;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
interface SaveLeaderboardData {
|
|
72
|
-
saveVariableToLeaderboard: null;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
44
|
const LEADERBOARD_FLUSH_INTERVAL_MS = 5000;
|
|
76
45
|
|
|
46
|
+
// Host stamps deckId, so one deck session = one set of leaderboards; key by
|
|
47
|
+
// variable+scope only and keep the best high/low score per key until flush.
|
|
77
48
|
const leaderboardWrites = new Map<string, PendingLeaderboardWrite>();
|
|
78
49
|
let leaderboardFlushTimer: ReturnType<typeof setTimeout> | null = null;
|
|
79
50
|
let leaderboardFlushPromise: Promise<void> | null = null;
|
|
@@ -103,9 +74,11 @@ function writeLeaderboard(
|
|
|
103
74
|
options: LeaderboardOptions,
|
|
104
75
|
): void {
|
|
105
76
|
if (isEdit()) return;
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
)
|
|
77
|
+
try {
|
|
78
|
+
bufferLeaderboardWrite(variable, score, options);
|
|
79
|
+
} catch (error) {
|
|
80
|
+
reportLeaderboardError(error);
|
|
81
|
+
}
|
|
109
82
|
}
|
|
110
83
|
|
|
111
84
|
async function fetchLeaderboardData(
|
|
@@ -113,47 +86,81 @@ async function fetchLeaderboardData(
|
|
|
113
86
|
type: LeaderboardSort,
|
|
114
87
|
options: LeaderboardOptions,
|
|
115
88
|
): Promise<LeaderboardData> {
|
|
116
|
-
|
|
117
|
-
|
|
89
|
+
assertLeaderboardVariable(variable, "Leaderboard.fetch");
|
|
90
|
+
assertLeaderboardType(type, "Leaderboard.fetch");
|
|
91
|
+
const scope = leaderboardScope(options);
|
|
92
|
+
// If the deck has written a score for this variable+scope this session, send
|
|
93
|
+
// it so the host writes-and-reads via leaderboardV2 and the player's own
|
|
94
|
+
// score shows up immediately (mirrors getLeaderboard in
|
|
95
|
+
// core/src/leaderboards.cpp — presence of a buffered score, not dirtiness,
|
|
96
|
+
// gates the write-through). Otherwise a plain read of the settled board.
|
|
97
|
+
const record = leaderboardWrites.get(leaderboardWriteKey(variable, scope));
|
|
98
|
+
const score = record
|
|
99
|
+
? type === "high"
|
|
100
|
+
? record.highScore
|
|
101
|
+
: record.lowScore
|
|
102
|
+
: null;
|
|
103
|
+
const { leaderboard, currentUserId } = await hostRequest("leaderboard.fetch", {
|
|
118
104
|
variable,
|
|
119
105
|
type,
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
106
|
+
scope,
|
|
107
|
+
...(score === null ? {} : { score }),
|
|
108
|
+
});
|
|
109
|
+
if (record && score !== null) {
|
|
110
|
+
clearLeaderboardDirtyAfterFetch(record, type, score);
|
|
111
|
+
}
|
|
112
|
+
return normalizeLeaderboard(leaderboard, currentUserId);
|
|
123
113
|
}
|
|
124
114
|
|
|
125
|
-
|
|
115
|
+
// After a write-through fetch, clear the dirty flag for the side we just sent
|
|
116
|
+
// (so the periodic flush won't re-send it via saveVariableToLeaderboard). If
|
|
117
|
+
// the other side's buffered value matches what we sent (the common single-score
|
|
118
|
+
// case where high == low), clear it too. The equality guards skip clearing if a
|
|
119
|
+
// concurrent write bumped the buffered score while the fetch was in flight —
|
|
120
|
+
// that newer score still needs flushing. Mirrors leaderboards.cpp.
|
|
121
|
+
function clearLeaderboardDirtyAfterFetch(
|
|
122
|
+
record: PendingLeaderboardWrite,
|
|
123
|
+
type: LeaderboardSort,
|
|
124
|
+
sentScore: number,
|
|
125
|
+
): void {
|
|
126
|
+
if (type === "high") {
|
|
127
|
+
if (record.highScore === sentScore) {
|
|
128
|
+
record.isHighDirty = false;
|
|
129
|
+
if (record.lowScore === sentScore) record.isLowDirty = false;
|
|
130
|
+
}
|
|
131
|
+
} else {
|
|
132
|
+
if (record.lowScore === sentScore) {
|
|
133
|
+
record.isLowDirty = false;
|
|
134
|
+
if (record.highScore === sentScore) record.isHighDirty = false;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function bufferLeaderboardWrite(
|
|
126
140
|
variable: string,
|
|
127
141
|
score: number,
|
|
128
142
|
options: LeaderboardOptions,
|
|
129
|
-
):
|
|
143
|
+
): void {
|
|
130
144
|
assertLeaderboardVariable(variable, "Leaderboard.write");
|
|
131
145
|
assertLeaderboardScore(score, "Leaderboard.write");
|
|
132
|
-
const deckId = await requireDeckId("Leaderboard.write");
|
|
133
|
-
await requireAuthToken("Leaderboard.write");
|
|
134
146
|
const scope = leaderboardScope(options);
|
|
135
|
-
const key = leaderboardWriteKey(
|
|
147
|
+
const key = leaderboardWriteKey(variable, scope);
|
|
136
148
|
const record = leaderboardWrites.get(key);
|
|
137
149
|
if (record) {
|
|
138
150
|
updatePendingLeaderboardWrite(record, score);
|
|
139
151
|
} else {
|
|
140
|
-
leaderboardWrites.set(
|
|
141
|
-
key,
|
|
142
|
-
newPendingLeaderboardWrite(deckId, variable, scope, score),
|
|
143
|
-
);
|
|
152
|
+
leaderboardWrites.set(key, newPendingLeaderboardWrite(variable, scope, score));
|
|
144
153
|
}
|
|
145
154
|
ensureLeaderboardUnloadFlush();
|
|
146
155
|
scheduleLeaderboardFlush();
|
|
147
156
|
}
|
|
148
157
|
|
|
149
158
|
function newPendingLeaderboardWrite(
|
|
150
|
-
deckId: string,
|
|
151
159
|
variable: string,
|
|
152
160
|
scope: string | null,
|
|
153
161
|
score: number,
|
|
154
162
|
): PendingLeaderboardWrite {
|
|
155
163
|
return {
|
|
156
|
-
deckId,
|
|
157
164
|
variable,
|
|
158
165
|
scope,
|
|
159
166
|
highScore: score,
|
|
@@ -203,11 +210,8 @@ async function flushLeaderboardWrites(): Promise<void> {
|
|
|
203
210
|
}
|
|
204
211
|
|
|
205
212
|
async function flushLeaderboardWritesOnce(): Promise<void> {
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
const token = await requireAuthToken("Leaderboard.write");
|
|
209
|
-
for (const job of jobs) {
|
|
210
|
-
await saveLeaderboardScore(job.record, job.score, token);
|
|
213
|
+
for (const job of leaderboardWriteJobs()) {
|
|
214
|
+
await saveLeaderboardScore(job.record, job.score);
|
|
211
215
|
markLeaderboardWriteClean(job);
|
|
212
216
|
}
|
|
213
217
|
}
|
|
@@ -248,131 +252,19 @@ function hasDirtyLeaderboardWrites(): boolean {
|
|
|
248
252
|
return false;
|
|
249
253
|
}
|
|
250
254
|
|
|
251
|
-
async function leaderboardRequest(
|
|
252
|
-
operation: string,
|
|
253
|
-
variable: string,
|
|
254
|
-
type: LeaderboardSort,
|
|
255
|
-
options: LeaderboardOptions,
|
|
256
|
-
): Promise<LeaderboardRequest> {
|
|
257
|
-
assertLeaderboardVariable(variable, operation);
|
|
258
|
-
assertLeaderboardType(type, operation);
|
|
259
|
-
const [deckId, token, auth] = await Promise.all([
|
|
260
|
-
requireDeckId(operation),
|
|
261
|
-
requireAuthToken(operation),
|
|
262
|
-
getAuth(),
|
|
263
|
-
]);
|
|
264
|
-
return {
|
|
265
|
-
deckId,
|
|
266
|
-
variable,
|
|
267
|
-
type,
|
|
268
|
-
scope: leaderboardScope(options),
|
|
269
|
-
token,
|
|
270
|
-
userId: auth.userId ?? null,
|
|
271
|
-
};
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
async function fetchLeaderboard(
|
|
275
|
-
request: LeaderboardRequest,
|
|
276
|
-
): Promise<GraphqlLeaderboard> {
|
|
277
|
-
const data = await graphqlRequest<LeaderboardQueryData>(
|
|
278
|
-
`
|
|
279
|
-
query CastleLeaderboard(
|
|
280
|
-
$deckId: ID!
|
|
281
|
-
$variable: String!
|
|
282
|
-
$type: LeaderboardType!
|
|
283
|
-
$filter: LeaderboardFilter!
|
|
284
|
-
$includeFollowList: Boolean
|
|
285
|
-
$includeParties: Boolean
|
|
286
|
-
$scope: String
|
|
287
|
-
) {
|
|
288
|
-
leaderboard(
|
|
289
|
-
deckId: $deckId
|
|
290
|
-
variable: $variable
|
|
291
|
-
type: $type
|
|
292
|
-
filter: $filter
|
|
293
|
-
includeFollowList: $includeFollowList
|
|
294
|
-
includeParties: $includeParties
|
|
295
|
-
scope: $scope
|
|
296
|
-
) ${leaderboardFields()}
|
|
297
|
-
}
|
|
298
|
-
`,
|
|
299
|
-
leaderboardVariables(request),
|
|
300
|
-
{
|
|
301
|
-
operation: "CastleLeaderboard",
|
|
302
|
-
token: request.token,
|
|
303
|
-
requireAuth: true,
|
|
304
|
-
},
|
|
305
|
-
);
|
|
306
|
-
return data.leaderboard;
|
|
307
|
-
}
|
|
308
|
-
|
|
309
255
|
async function saveLeaderboardScore(
|
|
310
256
|
record: PendingLeaderboardWrite,
|
|
311
257
|
score: number,
|
|
312
|
-
token: string,
|
|
313
258
|
): Promise<void> {
|
|
314
|
-
await
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
$score: Float!
|
|
320
|
-
$scope: String
|
|
321
|
-
) {
|
|
322
|
-
saveVariableToLeaderboard(
|
|
323
|
-
deckId: $deckId
|
|
324
|
-
variable: $variable
|
|
325
|
-
score: $score
|
|
326
|
-
scope: $scope
|
|
327
|
-
)
|
|
328
|
-
}
|
|
329
|
-
`,
|
|
330
|
-
{
|
|
331
|
-
deckId: record.deckId,
|
|
332
|
-
variable: record.variable,
|
|
333
|
-
score,
|
|
334
|
-
scope: record.scope,
|
|
335
|
-
},
|
|
336
|
-
{
|
|
337
|
-
operation: "CastleSaveVariableToLeaderboard",
|
|
338
|
-
token,
|
|
339
|
-
requireAuth: true,
|
|
340
|
-
},
|
|
341
|
-
);
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
function leaderboardVariables(request: LeaderboardRequest): GraphqlVariables {
|
|
345
|
-
return {
|
|
346
|
-
deckId: request.deckId,
|
|
347
|
-
variable: request.variable,
|
|
348
|
-
type: request.type,
|
|
349
|
-
filter: "dedupUsers",
|
|
350
|
-
includeFollowList: false,
|
|
351
|
-
includeParties: false,
|
|
352
|
-
scope: request.scope,
|
|
353
|
-
};
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
function leaderboardFields(): string {
|
|
357
|
-
return `{
|
|
358
|
-
yourScore { score }
|
|
359
|
-
list {
|
|
360
|
-
place
|
|
361
|
-
score
|
|
362
|
-
user {
|
|
363
|
-
userId
|
|
364
|
-
username
|
|
365
|
-
}
|
|
366
|
-
}
|
|
367
|
-
}`;
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
function leaderboardScope(options: LeaderboardOptions): string | null {
|
|
371
|
-
return options.scope ?? null;
|
|
259
|
+
await hostRequest("leaderboard.save", {
|
|
260
|
+
variable: record.variable,
|
|
261
|
+
score,
|
|
262
|
+
scope: record.scope,
|
|
263
|
+
});
|
|
372
264
|
}
|
|
373
265
|
|
|
374
266
|
function normalizeLeaderboard(
|
|
375
|
-
leaderboard:
|
|
267
|
+
leaderboard: RawLeaderboard,
|
|
376
268
|
userId: string | null,
|
|
377
269
|
): LeaderboardData {
|
|
378
270
|
const list = (leaderboard.list ?? []).map(normalizeLeaderboardEntry);
|
|
@@ -386,7 +278,7 @@ function normalizeLeaderboard(
|
|
|
386
278
|
}
|
|
387
279
|
|
|
388
280
|
function normalizeLeaderboardEntry(
|
|
389
|
-
entry:
|
|
281
|
+
entry: RawLeaderboardEntry,
|
|
390
282
|
): LeaderboardEntry {
|
|
391
283
|
const userId = entry.user?.userId ?? undefined;
|
|
392
284
|
return {
|
|
@@ -416,12 +308,12 @@ function scoreNumber(
|
|
|
416
308
|
return Number.isFinite(parsed) ? parsed : undefined;
|
|
417
309
|
}
|
|
418
310
|
|
|
419
|
-
function leaderboardWriteKey(
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
): string {
|
|
424
|
-
return
|
|
311
|
+
function leaderboardWriteKey(variable: string, scope: string | null): string {
|
|
312
|
+
return `${variable}${scope ? `::${scope}` : ""}`;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function leaderboardScope(options: LeaderboardOptions): string | null {
|
|
316
|
+
return options.scope ?? null;
|
|
425
317
|
}
|
|
426
318
|
|
|
427
319
|
function assertLeaderboardVariable(variable: string, operation: string): void {
|
package/src/passes.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Pass — a deck-facing capability for selling a creator "pass" to the player.
|
|
2
|
+
//
|
|
3
|
+
// The deck stays capability-AGNOSTIC: it just offers a pass to the player and
|
|
4
|
+
// gets back one normalized outcome regardless of platform. The host decides what
|
|
5
|
+
// UI to show and whether a real transaction can happen:
|
|
6
|
+
// - mobile app : renders the native bricks purchase sheet over the deck
|
|
7
|
+
// - web player : shows an "open in the app" upsell, returns `unavailable`
|
|
8
|
+
// - dev CLI : no host UI surface, so the SDK shows a minimal in-page
|
|
9
|
+
// notice itself (kept deliberately tiny), returns `unavailable`
|
|
10
|
+
// There is no capability check for the deck to make — every platform returns a
|
|
11
|
+
// PassOfferResult, so a single code path handles them all.
|
|
12
|
+
|
|
13
|
+
import type { PassOfferResult, PassOfferStatus } from "./commands";
|
|
14
|
+
import { CastleError } from "./errors";
|
|
15
|
+
import { getCommandChannel, hostRequest } from "./transport";
|
|
16
|
+
|
|
17
|
+
export type { PassOfferResult, PassOfferStatus } from "./commands";
|
|
18
|
+
|
|
19
|
+
export interface CastlePassApi {
|
|
20
|
+
has(passId: string): Promise<boolean>;
|
|
21
|
+
offer(passId: string): Promise<PassOfferResult>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const Pass: CastlePassApi = {
|
|
25
|
+
has,
|
|
26
|
+
offer,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// Pure read — does the current player already own this pass? No UI, every
|
|
30
|
+
// platform answers it the same way (a GraphQL query). Use it to gate content or
|
|
31
|
+
// to decide whether to bother calling `offer`.
|
|
32
|
+
async function has(passId: string): Promise<boolean> {
|
|
33
|
+
if (typeof passId !== "string" || passId.length === 0) {
|
|
34
|
+
throw new CastleError({
|
|
35
|
+
code: "INVALID_ARGUMENT",
|
|
36
|
+
message: "Pass.has requires a passId.",
|
|
37
|
+
operation: "Pass.has",
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
const { hasPass } = await hostRequest("pass.has", { passId });
|
|
41
|
+
return hasPass;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function offer(passId: string): Promise<PassOfferResult> {
|
|
45
|
+
if (typeof passId !== "string" || passId.length === 0) {
|
|
46
|
+
throw new CastleError({
|
|
47
|
+
code: "INVALID_ARGUMENT",
|
|
48
|
+
message: "Pass.offer requires a passId.",
|
|
49
|
+
operation: "Pass.offer",
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
const result = await hostRequest("pass.offer", { passId });
|
|
53
|
+
// On the dev server there's no host chrome to explain why nothing happened,
|
|
54
|
+
// so surface a small built-in notice. The mobile/web hosts render their own
|
|
55
|
+
// UI, so the SDK stays silent there.
|
|
56
|
+
if (result.status === "unavailable" && getCommandChannel() === "local") {
|
|
57
|
+
showDevUnavailableNotice();
|
|
58
|
+
}
|
|
59
|
+
return result;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let devNoticeEl: HTMLDivElement | null = null;
|
|
63
|
+
let devNoticeTimer: ReturnType<typeof setTimeout> | null = null;
|
|
64
|
+
|
|
65
|
+
// Minimal, dependency-free toast. Dev-only affordance — not the place for a
|
|
66
|
+
// designed purchase UI.
|
|
67
|
+
function showDevUnavailableNotice(): void {
|
|
68
|
+
if (typeof document === "undefined") return;
|
|
69
|
+
if (!devNoticeEl) {
|
|
70
|
+
devNoticeEl = document.createElement("div");
|
|
71
|
+
devNoticeEl.textContent = "Passes can only be purchased in the Castle app.";
|
|
72
|
+
devNoticeEl.style.cssText = [
|
|
73
|
+
"position:fixed",
|
|
74
|
+
"left:50%",
|
|
75
|
+
"bottom:24px",
|
|
76
|
+
"transform:translateX(-50%)",
|
|
77
|
+
"max-width:80vw",
|
|
78
|
+
"padding:10px 16px",
|
|
79
|
+
"border-radius:8px",
|
|
80
|
+
"background:rgba(0,0,0,0.82)",
|
|
81
|
+
"color:#fff",
|
|
82
|
+
"font:500 13px/1.4 system-ui,sans-serif",
|
|
83
|
+
"text-align:center",
|
|
84
|
+
"z-index:2147483647",
|
|
85
|
+
"pointer-events:none",
|
|
86
|
+
"transition:opacity 0.3s ease",
|
|
87
|
+
].join(";");
|
|
88
|
+
document.body.appendChild(devNoticeEl);
|
|
89
|
+
}
|
|
90
|
+
devNoticeEl.style.opacity = "1";
|
|
91
|
+
if (devNoticeTimer) clearTimeout(devNoticeTimer);
|
|
92
|
+
devNoticeTimer = setTimeout(() => {
|
|
93
|
+
if (devNoticeEl) devNoticeEl.style.opacity = "0";
|
|
94
|
+
}, 3200);
|
|
95
|
+
}
|
package/src/runtime.ts
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CASTLE_SDK_PROTOCOL,
|
|
3
|
+
type CommandName,
|
|
4
|
+
type CommandParams,
|
|
5
|
+
type CommandResponseEnvelope,
|
|
6
|
+
} from "./commands";
|
|
1
7
|
import { getCastleEmbed, isEdit } from "./context";
|
|
2
8
|
|
|
3
9
|
export const CARD_RATIO = 5 / 7;
|
|
@@ -37,6 +43,17 @@ let logBuffer: OutgoingMessage[] = [];
|
|
|
37
43
|
let nextRequestId = 1;
|
|
38
44
|
const pendingRequests = new Map<string, PendingRequest>();
|
|
39
45
|
|
|
46
|
+
// Local-dev command channel: the `castle-web serve` dev server is the host, so
|
|
47
|
+
// SDK commands ride the same websocket runtime.ts already uses for
|
|
48
|
+
// logs/screenshots/restart. Correlated by requestId, separate from the
|
|
49
|
+
// screenshot/write_file request map above.
|
|
50
|
+
const COMMAND_TIMEOUT_MS = 15000;
|
|
51
|
+
const SOCKET_WAIT_TIMEOUT_MS = 10000;
|
|
52
|
+
const pendingCommands = new Map<
|
|
53
|
+
string,
|
|
54
|
+
(env: CommandResponseEnvelope) => void
|
|
55
|
+
>();
|
|
56
|
+
|
|
40
57
|
const origLog = console.log;
|
|
41
58
|
const origWarn = console.warn;
|
|
42
59
|
const origError = console.error;
|
|
@@ -175,6 +192,66 @@ function sendLocalRequest(msg: OutgoingMessage): Promise<LocalResponse> {
|
|
|
175
192
|
});
|
|
176
193
|
}
|
|
177
194
|
|
|
195
|
+
// Send an SDK command to the dev server and resolve with the raw response
|
|
196
|
+
// envelope (ok/data/error). transport.ts interprets it — error reconstruction
|
|
197
|
+
// stays uniform across all three channels there. Waits for the socket to open
|
|
198
|
+
// so a command issued during startup isn't dropped.
|
|
199
|
+
export function sendLocalCommand<C extends CommandName>(
|
|
200
|
+
command: C,
|
|
201
|
+
params: CommandParams[C],
|
|
202
|
+
): Promise<CommandResponseEnvelope> {
|
|
203
|
+
const requestId = `cmd_${nextRequestId++}`;
|
|
204
|
+
return new Promise<CommandResponseEnvelope>((resolve, reject) => {
|
|
205
|
+
const timeout = setTimeout(() => {
|
|
206
|
+
pendingCommands.delete(requestId);
|
|
207
|
+
reject(new Error(`Timed out waiting for command ${command}.`));
|
|
208
|
+
}, COMMAND_TIMEOUT_MS);
|
|
209
|
+
pendingCommands.set(requestId, (env) => {
|
|
210
|
+
clearTimeout(timeout);
|
|
211
|
+
pendingCommands.delete(requestId);
|
|
212
|
+
resolve(env);
|
|
213
|
+
});
|
|
214
|
+
waitForSocket()
|
|
215
|
+
.then((socket) => {
|
|
216
|
+
socket.send(
|
|
217
|
+
JSON.stringify({
|
|
218
|
+
type: "castle_command",
|
|
219
|
+
castleSdk: CASTLE_SDK_PROTOCOL,
|
|
220
|
+
requestId,
|
|
221
|
+
command,
|
|
222
|
+
params,
|
|
223
|
+
}),
|
|
224
|
+
);
|
|
225
|
+
})
|
|
226
|
+
.catch((error: unknown) => {
|
|
227
|
+
clearTimeout(timeout);
|
|
228
|
+
pendingCommands.delete(requestId);
|
|
229
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function waitForSocket(): Promise<WebSocket> {
|
|
235
|
+
if (ws && ws.readyState === WebSocket.OPEN) return Promise.resolve(ws);
|
|
236
|
+
return new Promise<WebSocket>((resolve, reject) => {
|
|
237
|
+
const start = Date.now();
|
|
238
|
+
const poll = setInterval(() => {
|
|
239
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
240
|
+
clearInterval(poll);
|
|
241
|
+
resolve(ws);
|
|
242
|
+
} else if (Date.now() - start > SOCKET_WAIT_TIMEOUT_MS) {
|
|
243
|
+
clearInterval(poll);
|
|
244
|
+
reject(new Error("Castle dev server is not connected."));
|
|
245
|
+
}
|
|
246
|
+
}, 100);
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function resolveLocalCommand(msg: CommandResponseEnvelope): void {
|
|
251
|
+
const pending = pendingCommands.get(msg.requestId);
|
|
252
|
+
if (pending) pending(msg);
|
|
253
|
+
}
|
|
254
|
+
|
|
178
255
|
function resolveLocalRequest(msg: LocalResponse): boolean {
|
|
179
256
|
if (!msg.requestId) return false;
|
|
180
257
|
const pending = pendingRequests.get(msg.requestId);
|
|
@@ -332,12 +409,41 @@ function handleLocalMessage(msg: IncomingMessage): void {
|
|
|
332
409
|
});
|
|
333
410
|
});
|
|
334
411
|
} else if (msg.type === "restart") {
|
|
335
|
-
|
|
412
|
+
scheduleRestart();
|
|
336
413
|
} else if (msg.type === "write_file_response") {
|
|
337
414
|
resolveLocalRequest(msg);
|
|
415
|
+
} else if (msg.type === "castle_command_response") {
|
|
416
|
+
resolveLocalCommand(msg as unknown as CommandResponseEnvelope);
|
|
338
417
|
}
|
|
339
418
|
}
|
|
340
419
|
|
|
420
|
+
// Restart (from `castle-web restart` / task agents) is debounced so a burst
|
|
421
|
+
// of reload requests -- several tasks finishing close together -- produces
|
|
422
|
+
// one reload. Before reloading, registered hooks run (the kit editor flushes
|
|
423
|
+
// its debounced unsaved edits there) so in-flight work isn't lost.
|
|
424
|
+
const RESTART_DEBOUNCE_MS = 1500;
|
|
425
|
+
let restartTimer: ReturnType<typeof setTimeout> | null = null;
|
|
426
|
+
const beforeRestartHooks = new Set<() => void | Promise<void>>();
|
|
427
|
+
|
|
428
|
+
export function onBeforeRestart(hook: () => void | Promise<void>): () => void {
|
|
429
|
+
beforeRestartHooks.add(hook);
|
|
430
|
+
return () => beforeRestartHooks.delete(hook);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function scheduleRestart(): void {
|
|
434
|
+
if (restartTimer !== null) clearTimeout(restartTimer);
|
|
435
|
+
restartTimer = setTimeout(() => {
|
|
436
|
+
void (async () => {
|
|
437
|
+
try {
|
|
438
|
+
await Promise.all([...beforeRestartHooks].map(async (hook) => hook()));
|
|
439
|
+
} catch {
|
|
440
|
+
// a failed flush shouldn't block the reload
|
|
441
|
+
}
|
|
442
|
+
location.reload();
|
|
443
|
+
})();
|
|
444
|
+
}, RESTART_DEBOUNCE_MS);
|
|
445
|
+
}
|
|
446
|
+
|
|
341
447
|
function localWsUrl(path: string): string {
|
|
342
448
|
const url = new URL(path, location.href);
|
|
343
449
|
url.protocol = location.protocol === "https:" ? "wss:" : "ws:";
|