castle-web-sdk 0.4.1 → 0.4.2
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/auth.d.ts +8 -0
- package/dist/auth.js +52 -0
- package/dist/castle.d.ts +11 -0
- package/dist/castle.js +8 -0
- package/dist/context.d.ts +30 -0
- package/dist/context.js +86 -0
- package/dist/errors.d.ts +22 -0
- package/dist/errors.js +16 -0
- package/dist/graphql.d.ts +15 -0
- package/dist/graphql.js +120 -0
- package/dist/leaderboard.d.ts +20 -0
- package/dist/leaderboard.js +296 -0
- package/dist/runtime.d.ts +12 -0
- package/dist/runtime.js +288 -0
- package/dist/storage.d.ts +17 -0
- package/dist/storage.js +405 -0
- package/dist/time.d.ts +17 -0
- package/dist/time.js +131 -0
- package/dist/types.d.ts +3 -0
- package/dist/types.js +1 -0
- package/dist/user.d.ts +9 -0
- package/dist/user.js +58 -0
- package/package.json +30 -3
- package/src/auth.ts +64 -0
- package/src/castle.ts +19 -0
- package/src/context.ts +124 -0
- package/src/errors.ts +32 -0
- package/src/graphql.ts +182 -0
- package/src/leaderboard.ts +456 -0
- package/src/runtime.ts +345 -0
- package/src/storage.ts +636 -0
- package/src/time.ts +226 -0
- package/src/types.ts +7 -0
- package/src/user.ts +91 -0
- package/AGENTS.md +0 -27
- package/castle.js +0 -130
package/src/auth.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { getCastleEmbed } from "./context";
|
|
2
|
+
import { CastleError } from "./errors";
|
|
3
|
+
|
|
4
|
+
export interface CastleAuth {
|
|
5
|
+
token?: string | null;
|
|
6
|
+
userId?: string | null;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
let cachedAuth: CastleAuth | null = null;
|
|
10
|
+
let cachedAuthPromise: Promise<CastleAuth> | null = null;
|
|
11
|
+
|
|
12
|
+
export async function getAuth(): Promise<CastleAuth> {
|
|
13
|
+
return getCachedAuth();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function getAuthToken(): Promise<string | null> {
|
|
17
|
+
const auth = await getCachedAuth();
|
|
18
|
+
return auth.token ?? null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function getUserId(): Promise<string | null> {
|
|
22
|
+
const auth = await getCachedAuth();
|
|
23
|
+
return auth.userId ?? null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function requireAuthToken(
|
|
27
|
+
operation = "Castle API request",
|
|
28
|
+
): Promise<string> {
|
|
29
|
+
const token = await getAuthToken();
|
|
30
|
+
if (!token) {
|
|
31
|
+
throw new CastleError({
|
|
32
|
+
code: "LOGIN_REQUIRED",
|
|
33
|
+
message: "Log in to Castle before using this API.",
|
|
34
|
+
operation,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
return token;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function getCachedAuth(): Promise<CastleAuth> {
|
|
41
|
+
cachedAuthPromise ??= resolveAuth();
|
|
42
|
+
return cachedAuthPromise;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function resolveAuth(): Promise<CastleAuth> {
|
|
46
|
+
if (cachedAuth) return cachedAuth;
|
|
47
|
+
const embedded = getCastleEmbed()?.auth;
|
|
48
|
+
if (embedded && (embedded.token || embedded.userId)) {
|
|
49
|
+
cachedAuth = { token: embedded.token, userId: embedded.userId };
|
|
50
|
+
return cachedAuth;
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
const res = await fetch("/__castle/auth");
|
|
54
|
+
if (res.ok) {
|
|
55
|
+
const json = (await res.json()) as CastleAuth;
|
|
56
|
+
cachedAuth = { token: json.token, userId: json.userId };
|
|
57
|
+
return cachedAuth;
|
|
58
|
+
}
|
|
59
|
+
} catch {
|
|
60
|
+
// endpoint missing or offline -- treat as unauthenticated
|
|
61
|
+
}
|
|
62
|
+
cachedAuth = {};
|
|
63
|
+
return cachedAuth;
|
|
64
|
+
}
|
package/src/castle.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Castle Web SDK
|
|
2
|
+
|
|
3
|
+
export { isEdit } from "./context";
|
|
4
|
+
export { CastleError } from "./errors";
|
|
5
|
+
export { Leaderboard } from "./leaderboard";
|
|
6
|
+
export type {
|
|
7
|
+
LeaderboardData,
|
|
8
|
+
LeaderboardEntry,
|
|
9
|
+
LeaderboardOptions,
|
|
10
|
+
LeaderboardScope,
|
|
11
|
+
LeaderboardSort,
|
|
12
|
+
} from "./leaderboard";
|
|
13
|
+
export { CARD_RATIO, initCard, setup, writeFile } from "./runtime";
|
|
14
|
+
export { SharedStorage, Storage } from "./storage";
|
|
15
|
+
export { Time } from "./time";
|
|
16
|
+
export type { CastleClockZone, CastleDateParts, CastleTimeApi } from "./time";
|
|
17
|
+
export type { Json } from "./types";
|
|
18
|
+
export { User } from "./user";
|
|
19
|
+
export type { CastleUser, CastleUserApi } from "./user";
|
package/src/context.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { CastleError } from "./errors";
|
|
2
|
+
|
|
3
|
+
export interface CastleDeckContext {
|
|
4
|
+
deckId?: string | null;
|
|
5
|
+
cardId?: string | null;
|
|
6
|
+
sessionId?: string | null;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface CastleEmbedAuth {
|
|
10
|
+
token?: string | null;
|
|
11
|
+
userId?: string | null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface CastleEmbed {
|
|
15
|
+
edit?: boolean;
|
|
16
|
+
feed?: boolean;
|
|
17
|
+
auth?: CastleEmbedAuth;
|
|
18
|
+
deck?: CastleDeckContext;
|
|
19
|
+
deckId?: string;
|
|
20
|
+
cardId?: string;
|
|
21
|
+
sessionId?: string | null;
|
|
22
|
+
graphqlEndpoint?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
declare global {
|
|
26
|
+
interface Window {
|
|
27
|
+
CastleEmbed?: CastleEmbed;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let configuredDeckContext: CastleDeckContext = {};
|
|
32
|
+
let cachedDeckContext: CastleDeckContext | null = null;
|
|
33
|
+
let cachedDeckContextPromise: Promise<CastleDeckContext> | null = null;
|
|
34
|
+
|
|
35
|
+
export function getCastleEmbed(): CastleEmbed | undefined {
|
|
36
|
+
return typeof window === "undefined" ? undefined : window.CastleEmbed;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function isEdit(): boolean {
|
|
40
|
+
try {
|
|
41
|
+
const params = new URLSearchParams(window.location.search);
|
|
42
|
+
const override = params.get("edit");
|
|
43
|
+
if (override === "0" || override === "false") return false;
|
|
44
|
+
} catch {
|
|
45
|
+
// ignore -- window.location may be unavailable
|
|
46
|
+
}
|
|
47
|
+
return !!getCastleEmbed()?.edit;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function configureDeckContext(context: CastleDeckContext): void {
|
|
51
|
+
configuredDeckContext = cleanDeckContext(context);
|
|
52
|
+
cachedDeckContext = null;
|
|
53
|
+
cachedDeckContextPromise = null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function getDeckContext(): Promise<CastleDeckContext> {
|
|
57
|
+
if (cachedDeckContext) return cachedDeckContext;
|
|
58
|
+
cachedDeckContextPromise ??= resolveDeckContext();
|
|
59
|
+
return cachedDeckContextPromise;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function requireDeckId(
|
|
63
|
+
operation = "Castle API request",
|
|
64
|
+
): Promise<string> {
|
|
65
|
+
const context = await getDeckContext();
|
|
66
|
+
if (!context.deckId) {
|
|
67
|
+
throw new CastleError({
|
|
68
|
+
code: "MISSING_DECK_ID",
|
|
69
|
+
message: "This Castle API needs a deck id.",
|
|
70
|
+
operation,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
return context.deckId;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function resolveDeckContext(): Promise<CastleDeckContext> {
|
|
77
|
+
const embedded = embeddedDeckContext();
|
|
78
|
+
const local = await localDeckContext();
|
|
79
|
+
const context = mergeDeckContexts(configuredDeckContext, embedded, local);
|
|
80
|
+
cachedDeckContext = context;
|
|
81
|
+
return context;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function embeddedDeckContext(): CastleDeckContext {
|
|
85
|
+
const embed = getCastleEmbed();
|
|
86
|
+
return cleanDeckContext({
|
|
87
|
+
deckId: embed?.deck?.deckId ?? embed?.deckId,
|
|
88
|
+
cardId: embed?.deck?.cardId ?? embed?.cardId,
|
|
89
|
+
sessionId: embed?.deck?.sessionId ?? embed?.sessionId,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function localDeckContext(): Promise<CastleDeckContext> {
|
|
94
|
+
try {
|
|
95
|
+
const res = await fetch("/__castle/context");
|
|
96
|
+
if (!res.ok) return {};
|
|
97
|
+
return cleanDeckContext((await res.json()) as CastleDeckContext);
|
|
98
|
+
} catch {
|
|
99
|
+
return {};
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function cleanDeckContext(context: CastleDeckContext): CastleDeckContext {
|
|
104
|
+
return {
|
|
105
|
+
deckId: stringOrNull(context.deckId),
|
|
106
|
+
cardId: stringOrNull(context.cardId),
|
|
107
|
+
sessionId: stringOrNull(context.sessionId),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function mergeDeckContexts(
|
|
112
|
+
...contexts: CastleDeckContext[]
|
|
113
|
+
): CastleDeckContext {
|
|
114
|
+
return cleanDeckContext({
|
|
115
|
+
deckId: contexts.find((context) => context.deckId)?.deckId,
|
|
116
|
+
cardId: contexts.find((context) => context.cardId)?.cardId,
|
|
117
|
+
sessionId: contexts.find((context) => context.sessionId)?.sessionId,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function stringOrNull(value: string | null | undefined): string | null {
|
|
122
|
+
if (typeof value !== "string") return null;
|
|
123
|
+
return value.length > 0 ? value : null;
|
|
124
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export interface GraphqlErrorPayload {
|
|
2
|
+
message?: string;
|
|
3
|
+
extensions?: Record<string, unknown>;
|
|
4
|
+
path?: Array<string | number>;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
interface CastleErrorInput {
|
|
8
|
+
code: string;
|
|
9
|
+
message: string;
|
|
10
|
+
operation?: string;
|
|
11
|
+
status?: number;
|
|
12
|
+
extensions?: Record<string, unknown>;
|
|
13
|
+
errors?: GraphqlErrorPayload[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class CastleError extends Error {
|
|
17
|
+
code: string;
|
|
18
|
+
operation?: string;
|
|
19
|
+
status?: number;
|
|
20
|
+
extensions?: Record<string, unknown>;
|
|
21
|
+
errors?: GraphqlErrorPayload[];
|
|
22
|
+
|
|
23
|
+
constructor(input: CastleErrorInput) {
|
|
24
|
+
super(input.message);
|
|
25
|
+
this.name = "CastleError";
|
|
26
|
+
this.code = input.code;
|
|
27
|
+
this.operation = input.operation;
|
|
28
|
+
this.status = input.status;
|
|
29
|
+
this.extensions = input.extensions;
|
|
30
|
+
this.errors = input.errors;
|
|
31
|
+
}
|
|
32
|
+
}
|
package/src/graphql.ts
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { getAuthToken } from "./auth";
|
|
2
|
+
import { getCastleEmbed } from "./context";
|
|
3
|
+
import { CastleError, type GraphqlErrorPayload } from "./errors";
|
|
4
|
+
import type { Json } from "./types";
|
|
5
|
+
|
|
6
|
+
export const GRAPHQL_ENDPOINT = "https://api.castle.xyz/graphql";
|
|
7
|
+
|
|
8
|
+
export type GraphqlVariables = Record<string, Json | undefined>;
|
|
9
|
+
export type { GraphqlErrorPayload } from "./errors";
|
|
10
|
+
|
|
11
|
+
export interface GraphqlRequestOptions {
|
|
12
|
+
endpoint?: string;
|
|
13
|
+
headers?: Record<string, string>;
|
|
14
|
+
operation?: string;
|
|
15
|
+
operationName?: string;
|
|
16
|
+
requireAuth?: boolean;
|
|
17
|
+
signal?: AbortSignal;
|
|
18
|
+
timeoutMs?: number;
|
|
19
|
+
token?: string | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface GraphqlResponse<TData> {
|
|
23
|
+
data?: TData;
|
|
24
|
+
errors?: GraphqlErrorPayload[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function graphqlRequest<
|
|
28
|
+
TData = unknown,
|
|
29
|
+
TVariables extends GraphqlVariables = GraphqlVariables,
|
|
30
|
+
>(
|
|
31
|
+
query: string,
|
|
32
|
+
variables?: TVariables,
|
|
33
|
+
options: GraphqlRequestOptions = {},
|
|
34
|
+
): Promise<TData> {
|
|
35
|
+
const operation = options.operation ?? guessGraphqlOperation(query);
|
|
36
|
+
const token = options.token ?? (await getAuthToken());
|
|
37
|
+
if (options.requireAuth && !token) {
|
|
38
|
+
throw new CastleError({
|
|
39
|
+
code: "LOGIN_REQUIRED",
|
|
40
|
+
message: "Log in to Castle before using this API.",
|
|
41
|
+
operation,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
const response = await fetchGraphql<TData>(query, variables, {
|
|
45
|
+
...options,
|
|
46
|
+
operation,
|
|
47
|
+
token,
|
|
48
|
+
});
|
|
49
|
+
if (response.errors?.length) {
|
|
50
|
+
throw graphqlError(response.errors, operation);
|
|
51
|
+
}
|
|
52
|
+
if (response.data === null || response.data === undefined) {
|
|
53
|
+
throw new CastleError({
|
|
54
|
+
code: "GRAPHQL_NO_DATA",
|
|
55
|
+
message: "Castle GraphQL response did not include data.",
|
|
56
|
+
operation,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
return response.data;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function fetchGraphql<TData>(
|
|
63
|
+
query: string,
|
|
64
|
+
variables: GraphqlVariables | undefined,
|
|
65
|
+
options: Required<Pick<GraphqlRequestOptions, "operation">> &
|
|
66
|
+
GraphqlRequestOptions,
|
|
67
|
+
): Promise<GraphqlResponse<TData>> {
|
|
68
|
+
const headers = graphqlHeaders(options);
|
|
69
|
+
const abort = requestAbort(options);
|
|
70
|
+
try {
|
|
71
|
+
const response = await fetch(graphqlEndpoint(options), {
|
|
72
|
+
method: "POST",
|
|
73
|
+
headers,
|
|
74
|
+
body: JSON.stringify(
|
|
75
|
+
graphqlBody(query, variables, options.operationName),
|
|
76
|
+
),
|
|
77
|
+
signal: abort.signal,
|
|
78
|
+
});
|
|
79
|
+
const json = (await readGraphqlJson(response, options.operation)) as
|
|
80
|
+
| GraphqlResponse<TData>
|
|
81
|
+
| undefined;
|
|
82
|
+
if (!response.ok) {
|
|
83
|
+
throw new CastleError({
|
|
84
|
+
code: "GRAPHQL_HTTP_ERROR",
|
|
85
|
+
message: `Castle GraphQL request failed with HTTP ${response.status}.`,
|
|
86
|
+
operation: options.operation,
|
|
87
|
+
status: response.status,
|
|
88
|
+
errors: json?.errors,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
return json ?? {};
|
|
92
|
+
} finally {
|
|
93
|
+
abort.cleanup();
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function requestAbort(options: GraphqlRequestOptions): {
|
|
98
|
+
signal?: AbortSignal;
|
|
99
|
+
cleanup: () => void;
|
|
100
|
+
} {
|
|
101
|
+
if (options.signal) return { signal: options.signal, cleanup: () => {} };
|
|
102
|
+
const controller = new AbortController();
|
|
103
|
+
const timeout = setTimeout(
|
|
104
|
+
() => controller.abort(),
|
|
105
|
+
options.timeoutMs ?? 10000,
|
|
106
|
+
);
|
|
107
|
+
return { signal: controller.signal, cleanup: () => clearTimeout(timeout) };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function graphqlEndpoint(options: GraphqlRequestOptions): string {
|
|
111
|
+
return (
|
|
112
|
+
options.endpoint ?? getCastleEmbed()?.graphqlEndpoint ?? GRAPHQL_ENDPOINT
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function graphqlHeaders(
|
|
117
|
+
options: GraphqlRequestOptions,
|
|
118
|
+
): Record<string, string> {
|
|
119
|
+
const headers: Record<string, string> = {
|
|
120
|
+
Accept: "application/json",
|
|
121
|
+
"Content-Type": "application/json",
|
|
122
|
+
"X-OS": "web",
|
|
123
|
+
"X-TimeZone": Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
124
|
+
...options.headers,
|
|
125
|
+
};
|
|
126
|
+
if (options.token) headers["X-Auth-Token"] = options.token;
|
|
127
|
+
return headers;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function graphqlBody(
|
|
131
|
+
query: string,
|
|
132
|
+
variables: GraphqlVariables | undefined,
|
|
133
|
+
operationName: string | undefined,
|
|
134
|
+
): Record<string, unknown> {
|
|
135
|
+
const body: Record<string, unknown> = { query };
|
|
136
|
+
if (variables) body.variables = variables;
|
|
137
|
+
if (operationName) body.operationName = operationName;
|
|
138
|
+
return body;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function readGraphqlJson(
|
|
142
|
+
response: Response,
|
|
143
|
+
operation: string,
|
|
144
|
+
): Promise<unknown> {
|
|
145
|
+
try {
|
|
146
|
+
return await response.json();
|
|
147
|
+
} catch {
|
|
148
|
+
if (response.ok) return {};
|
|
149
|
+
throw new CastleError({
|
|
150
|
+
code: "GRAPHQL_BAD_RESPONSE",
|
|
151
|
+
message: "Castle GraphQL response was not valid JSON.",
|
|
152
|
+
operation,
|
|
153
|
+
status: response.status,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function graphqlError(
|
|
159
|
+
errors: GraphqlErrorPayload[],
|
|
160
|
+
operation: string,
|
|
161
|
+
): CastleError {
|
|
162
|
+
const first = errors[0];
|
|
163
|
+
return new CastleError({
|
|
164
|
+
code: graphqlErrorCode(first),
|
|
165
|
+
message: first?.message ?? "Castle GraphQL request failed.",
|
|
166
|
+
operation,
|
|
167
|
+
extensions: first?.extensions,
|
|
168
|
+
errors,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function graphqlErrorCode(error: GraphqlErrorPayload | undefined): string {
|
|
173
|
+
const code = error?.extensions?.code;
|
|
174
|
+
return typeof code === "string" ? code : "GRAPHQL_ERROR";
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function guessGraphqlOperation(query: string): string {
|
|
178
|
+
const match = /\b(?:query|mutation|subscription)\s+([A-Za-z0-9_]+)/.exec(
|
|
179
|
+
query,
|
|
180
|
+
);
|
|
181
|
+
return match?.[1] ?? "Castle GraphQL request";
|
|
182
|
+
}
|