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.
@@ -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 { graphqlRequest } from "./graphql";
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 token = await requireAuthToken(operation);
29
- const data = await graphqlRequest(CURRENT_USER_QUERY, undefined, {
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, "me.userId", operation),
46
- username: requiredString(user.username, "me.username", operation),
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 GraphQL response did not include ${field}.`,
38
+ message: `Castle response did not include ${field}.`,
56
39
  operation,
57
40
  });
58
41
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "castle-web-sdk",
3
- "version": "0.4.3",
3
+ "version": "0.4.5",
4
4
  "type": "module",
5
5
  "main": "dist/castle.js",
6
6
  "types": "dist/castle.d.ts",
@@ -10,13 +10,17 @@
10
10
  "default": "./dist/castle.js"
11
11
  }
12
12
  },
13
+ "//host": "host.{js,d.ts} is the host-side executor — deliberately NOT exported and NOT packaged. It is vendored into host repos via scripts/copy-host-module.mjs; decks must never receive it.",
13
14
  "files": [
14
15
  "dist",
15
- "src"
16
+ "src",
17
+ "!dist/host.js",
18
+ "!dist/host.d.ts",
19
+ "!src/host.ts"
16
20
  ],
17
21
  "scripts": {
18
- "build": "tsc",
19
- "check": "eslint . && jscpd && tsc --noEmit && tsc"
22
+ "build": "rm -rf dist && tsc",
23
+ "check": "eslint . && jscpd && tsc --noEmit && npm run build"
20
24
  },
21
25
  "jscpd": {
22
26
  "path": [
package/src/castle.ts CHANGED
@@ -10,7 +10,13 @@ export type {
10
10
  LeaderboardScope,
11
11
  LeaderboardSort,
12
12
  } from "./leaderboard";
13
- export { CARD_RATIO, initCard, setup, writeFile } from "./runtime";
13
+ export { Pass } from "./passes";
14
+ export type {
15
+ CastlePassApi,
16
+ PassOfferResult,
17
+ PassOfferStatus,
18
+ } from "./passes";
19
+ export { CARD_RATIO, initCard, onBeforeRestart, setup, writeFile } from "./runtime";
14
20
  export { SharedStorage, Storage } from "./storage";
15
21
  export { Time } from "./time";
16
22
  export type { CastleClockZone, CastleDateParts, CastleTimeApi } from "./time";
@@ -0,0 +1,136 @@
1
+ // The SDK↔host command contract. This is the single source of truth for the
2
+ // wire protocol shared by the deck-side command-poster (`transport.ts`) and the
3
+ // host-side executor (`host.ts`). It carries ONLY names and types — no GraphQL
4
+ // query strings and no auth — so importing it into a deck bundle reveals
5
+ // nothing privileged.
6
+
7
+ import type { Json } from "./types";
8
+
9
+ // Marker + protocol version. Doubles as a discriminator so host messages can't
10
+ // be confused with embed.js `castlexyz:` strings or RN console messages.
11
+ export const CASTLE_SDK_PROTOCOL = 1;
12
+
13
+ export type StorageBlob = Record<string, string>;
14
+ export type SharedScope = "deck" | "user";
15
+
16
+ export interface StorageUpdate {
17
+ key: string;
18
+ value: string | null;
19
+ }
20
+
21
+ // The raw GraphQL leaderboard shape the host returns; the SDK normalizes it
22
+ // (and computes playerRank from `currentUserId`) into the public LeaderboardData.
23
+ export interface RawLeaderboardEntry {
24
+ place?: string | number | null;
25
+ score?: string | number | null;
26
+ user?: { userId?: string | null; username?: string | null } | null;
27
+ }
28
+
29
+ export interface RawLeaderboard {
30
+ list?: RawLeaderboardEntry[] | null;
31
+ yourScore?: { score?: string | number | null } | null;
32
+ }
33
+
34
+ export type PassOfferStatus =
35
+ | "purchased"
36
+ | "alreadyOwned"
37
+ | "cancelled"
38
+ | "unavailable";
39
+
40
+ export interface PassOfferResult {
41
+ status: PassOfferStatus;
42
+ }
43
+
44
+ export interface CommandParams {
45
+ "deckStorage.load": Record<string, never>;
46
+ "deckStorage.update": { updates: StorageUpdate[] };
47
+ "sharedDeckStorage.load": {
48
+ scope: SharedScope;
49
+ userId?: string | null;
50
+ keys: string[];
51
+ };
52
+ "sharedDeckStorage.update": { scope: SharedScope; updates: StorageUpdate[] };
53
+ "leaderboard.fetch": {
54
+ variable: string;
55
+ type: "high" | "low";
56
+ scope?: string | null;
57
+ // When present, the deck has a freshly-written score for this
58
+ // variable+scope that hasn't settled server-side yet. The host routes
59
+ // through the leaderboardV2 mutation, which writes this score and returns
60
+ // the post-write leaderboard atomically, so the player's own score shows
61
+ // up immediately. Absent → a plain read of the settled leaderboard.
62
+ score?: number | null;
63
+ };
64
+ "leaderboard.save": { variable: string; score: number; scope?: string | null };
65
+ "user.getCurrent": Record<string, never>;
66
+ "time.getServerTime": Record<string, never>;
67
+ "pass.has": { passId: string };
68
+ // Platform/interactive command — dispatched to the host's platformHandler,
69
+ // not graphqlFetch. Unlike the data commands above, this one can stay open
70
+ // for a long time (the player interacting with a native sheet), so the
71
+ // deck-side transport gives it no timeout.
72
+ "pass.offer": { passId: string };
73
+ }
74
+
75
+ export interface CommandResult {
76
+ "deckStorage.load": { blob: StorageBlob };
77
+ "deckStorage.update": { blob: StorageBlob };
78
+ "sharedDeckStorage.load": { blob: StorageBlob };
79
+ "sharedDeckStorage.update": { ok: true };
80
+ "leaderboard.fetch": {
81
+ leaderboard: RawLeaderboard;
82
+ currentUserId: string | null;
83
+ };
84
+ "leaderboard.save": { ok: true };
85
+ "user.getCurrent": { user: { userId: string; username: string } | null };
86
+ "time.getServerTime": {
87
+ timestamp: number;
88
+ timezoneOffset: number;
89
+ castleEpochData: Json;
90
+ };
91
+ "pass.has": { hasPass: boolean };
92
+ "pass.offer": PassOfferResult;
93
+ }
94
+
95
+ export type CommandName = keyof CommandParams;
96
+
97
+ // NB: the runtime command allowlist (COMMAND_NAMES / isCommandName) lives in
98
+ // host.ts, not here, so that host.ts can keep ALL of its imports type-only and
99
+ // compile to a single self-contained file (zero runtime imports) that vendors
100
+ // cleanly into Node-ESM / webpack / Metro hosts in other repos.
101
+
102
+ // Serializable error that survives the postMessage boundary. The deck-side
103
+ // rebuilds a real CastleError from it, preserving `code` so decks can branch.
104
+ export interface SerializedCommandError {
105
+ code: string;
106
+ message: string;
107
+ command?: string;
108
+ extensions?: Record<string, unknown>;
109
+ }
110
+
111
+ export interface CommandRequestEnvelope {
112
+ castleSdk: typeof CASTLE_SDK_PROTOCOL;
113
+ requestId: string;
114
+ command: CommandName;
115
+ params: unknown;
116
+ }
117
+
118
+ export interface CommandResponseEnvelope {
119
+ castleSdk: typeof CASTLE_SDK_PROTOCOL;
120
+ requestId: string;
121
+ ok: boolean;
122
+ data?: unknown;
123
+ error?: SerializedCommandError;
124
+ }
125
+
126
+ export function isResponseEnvelope(
127
+ value: unknown,
128
+ ): value is CommandResponseEnvelope {
129
+ if (typeof value !== "object" || value === null) return false;
130
+ const record = value as Record<string, unknown>;
131
+ return (
132
+ record.castleSdk === CASTLE_SDK_PROTOCOL &&
133
+ typeof record.requestId === "string" &&
134
+ typeof record.ok === "boolean"
135
+ );
136
+ }
package/src/context.ts CHANGED
@@ -1,25 +1,11 @@
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
- }
1
+ // Which outer runtime is hosting the deck. Set by each host alongside the
2
+ // (now non-secret) CastleEmbed flags; used by transport.ts to pick a channel.
3
+ export type CastleHost = "web" | "mobile" | "dev";
13
4
 
14
5
  export interface CastleEmbed {
15
6
  edit?: boolean;
16
7
  feed?: boolean;
17
- auth?: CastleEmbedAuth;
18
- deck?: CastleDeckContext;
19
- deckId?: string;
20
- cardId?: string;
21
- sessionId?: string | null;
22
- graphqlEndpoint?: string;
8
+ host?: CastleHost;
23
9
  }
24
10
 
25
11
  declare global {
@@ -28,10 +14,6 @@ declare global {
28
14
  }
29
15
  }
30
16
 
31
- let configuredDeckContext: CastleDeckContext = {};
32
- let cachedDeckContext: CastleDeckContext | null = null;
33
- let cachedDeckContextPromise: Promise<CastleDeckContext> | null = null;
34
-
35
17
  export function getCastleEmbed(): CastleEmbed | undefined {
36
18
  return typeof window === "undefined" ? undefined : window.CastleEmbed;
37
19
  }
@@ -46,79 +28,3 @@ export function isEdit(): boolean {
46
28
  }
47
29
  return !!getCastleEmbed()?.edit;
48
30
  }
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
- }