castle-web-sdk 0.4.26 → 0.4.28

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 CHANGED
@@ -55,7 +55,9 @@ metadata distinguishes a server `bye`, a replaced server version, an explicit
55
55
 
56
56
  When Castle selects solo fallback, the returned connection has `isSolo === true`,
57
57
  a one-player roster, and no server process. Calls to `send` are accepted but do
58
- nothing. Client→server frames are limited to 16 KB on the wire.
58
+ nothing. `soloReason` says why: `private_deck` or `no_server_bundle` are the
59
+ creator's to fix (change the deck's visibility, or publish a deck that has a
60
+ server entry); anything else reports `unavailable`. Client→server frames are limited to 16 KB on the wire.
59
61
 
60
62
  ### Binary messages
61
63
 
@@ -414,7 +416,10 @@ const dailyPuzzle = (date.daysSinceCastleEpoch % 30) + 1;
414
416
  Returns the signed-in player. Throws `CastleError`
415
417
  (`LOGIN_REQUIRED`) when nobody is signed in.
416
418
 
417
- The returned `CastleUser` has `userId`, `username`, `isAnonymous`, and `isActive`.
419
+ A `CastleUser` has `userId`, `username`, `isAnonymous`, `isActive`, `photoUrl`
420
+ (their 256px avatar), `frameUrl` (the 96px frame drawn around it) and `color`
421
+ (the hex their username is shown in); the last three are `null` when unset. The
422
+ urls are `data:` urls, so they go straight into an `<img>` or a texture.
418
423
  Anonymous accounts are real logins with generated `anonymous-user-...` names;
419
424
  check `isAnonymous` before accepting writes into anything other players see,
420
425
  such as a shared gallery.
@@ -424,6 +429,19 @@ const me = await User.getCurrent();
424
429
  greet(me.username);
425
430
  ```
426
431
 
432
+ ### `User.get(userId): Promise<CastleUser | null>`
433
+
434
+ Another player as anyone may see them, or `null` when no such user exists.
435
+ Results are cached per id for the life of the page. The ids come from the
436
+ session's player list or from `Store.user(id)` rows.
437
+
438
+ ```js
439
+ const them = await User.get(player.userId);
440
+ if (them?.photoUrl) {
441
+ avatar.src = them.photoUrl;
442
+ }
443
+ ```
444
+
427
445
  ## Pass
428
446
 
429
447
  A pass is something a creator sells to players for Castle bricks (the
@@ -545,7 +563,8 @@ be coalesced, so it's safe to call on frequent events.
545
563
 
546
564
  `Lifecycle` tells the host when the deck has painted its first frame, so
547
565
  the Castle feed can reveal it right away instead of waiting out a fixed
548
- delay.
566
+ delay — and carries state across an unload, so a deck the player leaves
567
+ mid-play and comes back to picks up where it was.
549
568
 
550
569
  ### `Lifecycle.ready()`
551
570
 
@@ -565,6 +584,70 @@ createRoot(root).render(<App />);
565
584
  requestAnimationFrame(() => requestAnimationFrame(() => Lifecycle.ready()));
566
585
  ```
567
586
 
587
+ ### Resuming across an unload
588
+
589
+ The host can unload a deck's page mid-play and mount it again later, and
590
+ by default the deck starts over when it does. Resume state closes that:
591
+ the host asks the deck for a snapshot of its current state, keeps it in
592
+ memory, and offers it back the next time the deck mounts.
593
+
594
+ It is **pause, not save**. The host holds the snapshot briefly and in
595
+ memory only: it does not outlive the app, and the player restarting the
596
+ deck clears it. Anything that should outlive the app goes in
597
+ [`Store`](#store) or [`Storage`](#storage).
598
+
599
+ Reach for resume state when:
600
+
601
+ - the state must NOT persist by design — a daily-challenge attempt in
602
+ progress, a roguelike run, an anti-farm timer;
603
+ - the state is too big or too churny for `Store`;
604
+ - the state only needs to survive the player leaving and coming back.
605
+
606
+ Don't substitute `localStorage`: it outlives the app and the host cannot
607
+ clear it, so the deck could never be restarted cleanly.
608
+
609
+ Not every host keeps resume state. Where one doesn't, `restoreState()`
610
+ resolves `null` and the provider is never called, so a deck written for
611
+ it works unchanged.
612
+
613
+ ### `Lifecycle.provideState(provide): () => void`
614
+
615
+ Register a function returning the deck's current state, for the host to
616
+ hand back if it unloads and remounts this deck. Returns a function that
617
+ unregisters it; registering again replaces the provider.
618
+
619
+ The host calls it whenever it might need a snapshot: at arbitrary
620
+ moments while the deck plays on, and often. A call does not mean the deck
621
+ is about to unload, so treat it as a read of your state and nothing more
622
+ — it must be **synchronous, cheap and side-effect-free**. Return
623
+ `undefined` to decline; the deck then starts fresh the next time it
624
+ mounts.
625
+
626
+ Whatever you return is `JSON.stringify`'d, so keep it small — tens of
627
+ kilobytes, not megabytes. A value over 1 MB is dropped with a warning.
628
+
629
+ ```js
630
+ import { Lifecycle } from "castle-web-sdk";
631
+
632
+ Lifecycle.provideState(() => ({ level, score, elapsedMs: clock.elapsed() }));
633
+ ```
634
+
635
+ ### `Lifecycle.restoreState<T>(): Promise<T | null>`
636
+
637
+ The state this deck last provided, or `null` — nothing kept, the player
638
+ restarted the deck, or the host doesn't keep resume state. It never
639
+ throws for any of those, so there is nothing to catch. Reading doesn't
640
+ consume it.
641
+
642
+ Call it before your first paint and before `ready()`, so a deck that is
643
+ resuming doesn't flash its fresh state first:
644
+
645
+ ```js
646
+ const saved = await Lifecycle.restoreState();
647
+ startGame(saved ?? newGame());
648
+ Lifecycle.ready();
649
+ ```
650
+
568
651
  ## Setup
569
652
 
570
653
  Startup, editor-mode check, and a file-write call for editor UI.
package/dist/castle.d.ts CHANGED
@@ -9,6 +9,7 @@ export { Lifecycle } from "./lifecycle";
9
9
  export type { CastleLifecycleApi } from "./lifecycle";
10
10
  export { Multiplayer } from "./multiplayer";
11
11
  export type { CastleMultiplayerApi, MultiplayerConnection, MultiplayerConnectionState, MultiplayerJoinOptions, MultiplayerRoster, MultiplayerStateMetadata, } from "./multiplayer";
12
+ export type { MultiplayerSoloReason } from "./commands";
12
13
  export { MAX_CLIENT_FRAME_BYTES, MAX_PLAYERS_CAP } from "./multiplayerProtocol";
13
14
  export type { ClientByeFrame, ClientHelloFrame, ClientMessageFrame, ClientPingFrame, ClientPongFrame, ClientReplacedFrame, ClientRosterFrame, ClientToPlatformFrame, ClientToPlatformMessageFrame, MultiplayerPlayer, PlayerIdentity, PlatformToClientFrame, SessionPlayer, SessionType, } from "./multiplayerProtocol";
14
15
  export { Pass } from "./passes";
@@ -61,16 +61,29 @@ export type HapticsStatus = "triggered" | "unavailable";
61
61
  export interface HapticsResult {
62
62
  status: HapticsStatus;
63
63
  }
64
+ export type LifecycleRestoreStateStatus = "restored" | "none" | "unavailable";
65
+ export interface LifecycleRestoreStateResult {
66
+ status: LifecycleRestoreStateStatus;
67
+ /** The deck-serialized JSON string it saved. Present iff status is "restored". */
68
+ state?: string;
69
+ }
64
70
  export type MultiplayerMode = "named" | "party" | "public";
65
71
  export interface MultiplayerGetSessionParams {
66
72
  mode: MultiplayerMode;
67
73
  key?: string | null;
68
74
  }
75
+ /**
76
+ * Why a join fell back to solo. `private_deck` and `no_server_bundle` are the
77
+ * creator's to fix; anything else the platform reports as `unavailable`.
78
+ */
79
+ export type MultiplayerSoloReason = "private_deck" | "no_server_bundle" | "unavailable";
69
80
  export interface MultiplayerGetSessionResult {
70
81
  status: "ok" | "solo" | "unavailable";
71
82
  url: string | null;
72
83
  nonce: string | null;
73
84
  sessionId: string | null;
85
+ /** Set when `status` is "solo"; null otherwise. */
86
+ soloReason: MultiplayerSoloReason | null;
74
87
  }
75
88
  export interface CommandParams {
76
89
  "deckStorage.load": Record<string, never>;
@@ -107,6 +120,9 @@ export interface CommandParams {
107
120
  score?: number | null;
108
121
  };
109
122
  "user.getCurrent": Record<string, never>;
123
+ "user.get": {
124
+ userId: string;
125
+ };
110
126
  "time.getServerTime": Record<string, never>;
111
127
  "pass.has": {
112
128
  passId: string;
@@ -171,6 +187,7 @@ export interface CommandParams {
171
187
  board: string;
172
188
  subject?: string | null;
173
189
  };
190
+ "lifecycle.restoreState": Record<string, never>;
174
191
  }
175
192
  export interface CommandResult {
176
193
  "deckStorage.load": {
@@ -200,6 +217,9 @@ export interface CommandResult {
200
217
  isAnonymous?: boolean;
201
218
  } | null;
202
219
  };
220
+ "user.get": {
221
+ user: UserPayload | null;
222
+ };
203
223
  "time.getServerTime": {
204
224
  timestamp: number;
205
225
  timezoneOffset: number;
@@ -238,6 +258,15 @@ export interface CommandResult {
238
258
  "cauldronStorage.boardGet": {
239
259
  entry: StoreBoardEntry | null;
240
260
  };
261
+ "lifecycle.restoreState": LifecycleRestoreStateResult;
262
+ }
263
+ export interface UserPayload {
264
+ userId: string;
265
+ username: string;
266
+ isAnonymous: boolean;
267
+ photoUrl: string | null;
268
+ frameUrl: string | null;
269
+ color: string | null;
241
270
  }
242
271
  export type CommandName = keyof CommandParams;
243
272
  export interface SerializedCommandError {
@@ -260,8 +289,22 @@ export interface CommandResponseEnvelope {
260
289
  error?: SerializedCommandError;
261
290
  }
262
291
  export declare function isResponseEnvelope(value: unknown): value is CommandResponseEnvelope;
263
- export type LifecycleEvent = "ready";
292
+ export type LifecycleEvent = "ready" | "unloadInterest";
264
293
  export interface LifecycleEnvelope {
265
294
  castleSdk: typeof CASTLE_SDK_PROTOCOL;
266
295
  lifecycle: LifecycleEvent;
267
296
  }
297
+ export interface UnloadRequestEnvelope {
298
+ castleSdk: typeof CASTLE_SDK_PROTOCOL;
299
+ unload: {
300
+ unloadId: string;
301
+ };
302
+ }
303
+ export declare function unloadRequestId(value: unknown): string | null;
304
+ export interface UnloadDoneEnvelope {
305
+ castleSdk: typeof CASTLE_SDK_PROTOCOL;
306
+ unloadDone: {
307
+ unloadId: string;
308
+ state?: string;
309
+ };
310
+ }
package/dist/commands.js CHANGED
@@ -14,3 +14,12 @@ export function isResponseEnvelope(value) {
14
14
  typeof record.requestId === "string" &&
15
15
  typeof record.ok === "boolean");
16
16
  }
17
+ export function unloadRequestId(value) {
18
+ if (typeof value !== "object" || value === null)
19
+ return null;
20
+ const record = value;
21
+ if (record.castleSdk !== CASTLE_SDK_PROTOCOL)
22
+ return null;
23
+ const unload = record.unload;
24
+ return typeof unload?.unloadId === "string" ? unload.unloadId : null;
25
+ }
@@ -1,4 +1,24 @@
1
1
  export interface CastleLifecycleApi {
2
2
  ready(): void;
3
+ /**
4
+ * Register a function returning the deck's current state, for the host to hand
5
+ * back if it unloads and remounts this deck. Returns a function that
6
+ * unregisters it; registering again replaces the provider.
7
+ *
8
+ * The host may call it at any moment, repeatedly, while the deck plays on; a
9
+ * call does not mean the deck is about to unload. It must be synchronous,
10
+ * cheap and side-effect-free. Return `undefined` to decline; the deck then
11
+ * starts fresh the next time it mounts.
12
+ */
13
+ provideState(provide: () => unknown): () => void;
14
+ /**
15
+ * The state this deck last provided, or `null` — nothing kept, the player
16
+ * restarted the deck, or the host doesn't keep resume state. Never throws for
17
+ * any of those.
18
+ *
19
+ * Call it before your first paint (and before `ready()`), so a deck that is
20
+ * resuming doesn't flash its fresh state first.
21
+ */
22
+ restoreState<T = unknown>(): Promise<T | null>;
3
23
  }
4
24
  export declare const Lifecycle: CastleLifecycleApi;
package/dist/lifecycle.js CHANGED
@@ -1,13 +1,19 @@
1
1
  // Deck-side lifecycle signals. The feed reveals the deck when it reports `ready`
2
- // (its first presentable frame) instead of waiting out a fixed timer.
2
+ // (its first presentable frame) instead of waiting out a fixed timer, and asks
3
+ // it for state to resume from when it may be about to unload it.
4
+ import { provideState, restoreState } from "./resumeState";
3
5
  import { hostNotify } from "./transport";
4
6
  let readySent = false;
7
+ const FIRST_FRAME_READY_MARKER = "[castle-lifecycle] first-frame-ready";
5
8
  function ready() {
6
9
  if (readySent)
7
10
  return;
8
11
  readySent = true;
12
+ console.log(FIRST_FRAME_READY_MARKER);
9
13
  hostNotify("ready");
10
14
  }
11
15
  export const Lifecycle = {
12
16
  ready,
17
+ provideState,
18
+ restoreState,
13
19
  };
@@ -1,3 +1,4 @@
1
+ import type { MultiplayerSoloReason } from "./commands";
1
2
  import { CastleError } from "./errors";
2
3
  import { type MultiplayerPlayer } from "./multiplayerProtocol";
3
4
  export type MultiplayerJoinOptions = {
@@ -31,6 +32,11 @@ export interface MultiplayerConnection {
31
32
  readonly playerId: string;
32
33
  readonly sessionId: string;
33
34
  readonly isSolo: boolean;
35
+ /**
36
+ * Why the join fell back to solo; null while multiplayer is live.
37
+ * `private_deck` and `no_server_bundle` are the creator's to fix.
38
+ */
39
+ readonly soloReason: MultiplayerSoloReason | null;
34
40
  /** Sends byte payloads as binary frames and all other payloads as JSON. */
35
41
  send(data: unknown): void;
36
42
  /** Receives binary payloads as Uint8Array values and JSON payloads otherwise. */
@@ -25,6 +25,7 @@ class MultiplayerConnectionImpl {
25
25
  currentPlayerId = "";
26
26
  currentSessionId = "";
27
27
  solo = false;
28
+ currentSoloReason = null;
28
29
  baseUrl = "";
29
30
  reconnectToken = "";
30
31
  socket = null;
@@ -52,6 +53,9 @@ class MultiplayerConnectionImpl {
52
53
  get isSolo() {
53
54
  return this.solo;
54
55
  }
56
+ get soloReason() {
57
+ return this.currentSoloReason;
58
+ }
55
59
  async start() {
56
60
  try {
57
61
  await this.acquire();
@@ -114,12 +118,14 @@ class MultiplayerConnectionImpl {
114
118
  throw multiplayerError("MULTIPLAYER_UNAVAILABLE", "Multiplayer is unavailable for this deck.");
115
119
  }
116
120
  this.solo = false;
121
+ this.currentSoloReason = null;
117
122
  this.baseUrl = result.url;
118
123
  this.transition("connecting");
119
124
  await this.openSocket("nonce", result.nonce);
120
125
  }
121
126
  connectSolo(result) {
122
127
  this.solo = true;
128
+ this.currentSoloReason = result.soloReason ?? "unavailable";
123
129
  this.currentPlayerId = "solo";
124
130
  this.currentSessionId = result.sessionId ?? "solo";
125
131
  const you = {
@@ -0,0 +1,2 @@
1
+ export declare function provideState(provide: () => unknown): () => void;
2
+ export declare function restoreState<T = unknown>(): Promise<T | null>;
@@ -0,0 +1,84 @@
1
+ // Resume state: the deck's answer to "you may be about to be unloaded, hand me
2
+ // something and I'll give it back when you mount again".
3
+ //
4
+ // This is PAUSE, not save. The host holds it in memory for a short while and
5
+ // loses it when the app dies; anything that should survive closing the app
6
+ // belongs in Storage / Store.
7
+ import { getCommandChannel, hostRequest } from "./transport";
8
+ import { notifyUnloadInterest, onHostUnload } from "./unloadHandshake";
9
+ // Measured in characters, not encoded bytes: this runs on every drag start, and
10
+ // encoding a megabyte to count it would cost more than the guard saves. Under-
11
+ // counts multi-byte text, which the host's own byte cap catches.
12
+ const WARN_STATE_CHARS = 256 * 1024;
13
+ const MAX_STATE_CHARS = 1024 * 1024;
14
+ let provider = null;
15
+ let subscribed = false;
16
+ export function provideState(provide) {
17
+ provider = provide;
18
+ if (!subscribed) {
19
+ subscribed = true;
20
+ onHostUnload(resumeStateSubscriber);
21
+ }
22
+ notifyUnloadInterest();
23
+ return () => {
24
+ if (provider === provide)
25
+ provider = null;
26
+ };
27
+ }
28
+ export async function restoreState() {
29
+ // The dev server is not a host that holds state, and the command would ride
30
+ // the local websocket to a CLI that doesn't know it — a 15 s hang at boot.
31
+ if (getCommandChannel() === "local")
32
+ return null;
33
+ try {
34
+ const result = await hostRequest("lifecycle.restoreState", {});
35
+ if (result.status !== "restored" || typeof result.state !== "string") {
36
+ return null;
37
+ }
38
+ return JSON.parse(result.state);
39
+ }
40
+ catch {
41
+ // Every failure reads the same to a deck: there is nothing to resume from.
42
+ // An old host answers UNKNOWN_COMMAND, an absent one CASTLE_HOST_UNAVAILABLE.
43
+ return null;
44
+ }
45
+ }
46
+ function resumeStateSubscriber(ctx) {
47
+ const provide = provider;
48
+ if (!provide)
49
+ return;
50
+ let value;
51
+ try {
52
+ value = provide();
53
+ }
54
+ catch (error) {
55
+ console.warn("Castle Lifecycle.provideState threw; nothing kept.", error);
56
+ return;
57
+ }
58
+ if (value === undefined || value === null)
59
+ return;
60
+ if (typeof value.then === "function") {
61
+ console.warn("Castle Lifecycle.provideState returned a Promise. It must be synchronous. Nothing kept.");
62
+ return;
63
+ }
64
+ let json;
65
+ try {
66
+ json = JSON.stringify(value);
67
+ }
68
+ catch (error) {
69
+ console.warn("Castle Lifecycle.provideState value is not JSON-serializable.", error);
70
+ return;
71
+ }
72
+ if (json === undefined)
73
+ return;
74
+ if (json.length > MAX_STATE_CHARS) {
75
+ console.warn(`Castle Lifecycle.provideState value is ${json.length} chars, over the` +
76
+ ` ${MAX_STATE_CHARS} limit. Nothing kept.`);
77
+ return;
78
+ }
79
+ if (json.length > WARN_STATE_CHARS) {
80
+ console.warn(`Castle Lifecycle.provideState value is ${json.length} chars. It is` +
81
+ ` serialized every time the host asks; keep it small.`);
82
+ }
83
+ ctx.state = json;
84
+ }
package/dist/runtime.js CHANGED
@@ -28,6 +28,7 @@ export function setup() {
28
28
  initLocalWake();
29
29
  initHostCapture();
30
30
  initPlaySelection();
31
+ initEditSelection();
31
32
  initPlayCard();
32
33
  logPanelLoad();
33
34
  }
@@ -164,6 +165,70 @@ function initPlaySelection() {
164
165
  `;
165
166
  document.head.appendChild(style);
166
167
  }
168
+ // Where a touch may start a text selection inside an EDITOR document. Everything
169
+ // else is chrome you press, drag or draw on, and a selection started there is
170
+ // always an accident.
171
+ const EDIT_SELECTABLE = 'input,textarea,[contenteditable]:not([contenteditable="false"]),.cm-editor,.xterm,[data-castle-allow-select]';
172
+ // `*` sets every element directly, so an allowed root's descendants need their
173
+ // own rule -- they inherit nothing through it.
174
+ const EDIT_SELECTABLE_SUBTREE = EDIT_SELECTABLE.split(",")
175
+ .flatMap((selector) => [selector, `${selector} *`])
176
+ .join(",");
177
+ // The same lock the Shell installs over its own chrome, for the deck document.
178
+ //
179
+ // The Shell's copy (cli/src/shell/touchSelectionLock.ts) walks into same-origin
180
+ // frames, but a deck's editor frame is NOT one: serve hands edit and play their
181
+ // own origins, so the Shell reaches the frame element and nothing inside it.
182
+ // Without this, a kit editor -- the sprite canvas above all, where every stroke
183
+ // is a press-drag over non-text chrome -- collects blue selection rectangles as
184
+ // you draw, and iOS raises the loupe and callout on top.
185
+ //
186
+ // Edit mode only, and only for a coarse pointer: with a mouse a stray selection
187
+ // is one click to dismiss and selecting editor text is often the point, while a
188
+ // touch has no equivalent escape.
189
+ function initEditSelection() {
190
+ if (!isEdit())
191
+ return;
192
+ if (typeof window.matchMedia !== "function")
193
+ return;
194
+ if (!window.matchMedia("(pointer: coarse)").matches)
195
+ return;
196
+ const style = document.createElement("style");
197
+ // The stylesheet is what WebKit's long-press gesture consults; the listeners
198
+ // are the backstop for a press-drag that a kit's own CSS out-specifies.
199
+ style.textContent =
200
+ `*{-webkit-user-select:none!important;user-select:none!important;` +
201
+ `-webkit-touch-callout:none!important;-webkit-tap-highlight-color:transparent!important;}` +
202
+ `${EDIT_SELECTABLE_SUBTREE}{-webkit-user-select:text!important;user-select:text!important;` +
203
+ `-webkit-touch-callout:default!important;}`;
204
+ document.head.appendChild(style);
205
+ const allowsSelection = (node) => {
206
+ const target = node;
207
+ const element = target && target.nodeType === 1
208
+ ? target
209
+ : (target?.parentElement ?? null);
210
+ return !!element?.closest(EDIT_SELECTABLE);
211
+ };
212
+ // Capture phase, so a handler that stops propagation cannot leak the gesture
213
+ // past us. preventDefault only suppresses the platform's own behaviour -- the
214
+ // kit's own listeners still run.
215
+ const block = (event) => {
216
+ if (!allowsSelection(event.target))
217
+ event.preventDefault();
218
+ };
219
+ document.addEventListener("selectstart", block, true);
220
+ document.addEventListener("contextmenu", block, true);
221
+ document.addEventListener("selectionchange", () => {
222
+ const selection = document.getSelection();
223
+ // Anchor, not focus: a drag that starts in a text field and runs past its
224
+ // edge is still that field's selection.
225
+ if (!selection || selection.isCollapsed)
226
+ return;
227
+ if (allowsSelection(selection.anchorNode))
228
+ return;
229
+ selection.removeAllRanges();
230
+ });
231
+ }
167
232
  // Constrains whatever the deck renders into #root to a 5:7 card in play mode.
168
233
  // Hosts own max size and padding; the SDK only preserves the card aspect ratio.
169
234
  function initPlayCard() {
@@ -14,4 +14,7 @@ declare global {
14
14
  export declare function hostRequest<C extends CommandName>(command: C, params: CommandParams[C]): Promise<CommandResult[C]>;
15
15
  export declare function getCommandChannel(): PostChannel | "local";
16
16
  export declare function hostNotify(event: LifecycleEvent): void;
17
+ export declare function postUnloadDone(unloadId: string, state?: string): void;
18
+ export declare function setUnloadHandler(handler: (unloadId: string) => void): void;
19
+ export declare function installHostListener(): void;
17
20
  export {};
package/dist/transport.js CHANGED
@@ -7,7 +7,7 @@
7
7
  // - web → window.parent.postMessage; responses via 'message' events
8
8
  // - local → the castle-web serve dev server, over runtime.ts's websocket
9
9
  // Error reconstruction is uniform here so callers always get a CastleError.
10
- import { CASTLE_SDK_PROTOCOL, isResponseEnvelope, } from "./commands";
10
+ import { CASTLE_SDK_PROTOCOL, isResponseEnvelope, unloadRequestId, } from "./commands";
11
11
  import { getCastleEmbed } from "./context";
12
12
  import { CastleError } from "./errors";
13
13
  import { sendLocalCommand } from "./runtime";
@@ -22,6 +22,7 @@ const INTERACTIVE_COMMANDS = new Set([
22
22
  let nextRequestId = 1;
23
23
  const pending = new Map();
24
24
  let listenersInstalled = false;
25
+ let unloadHandler = null;
25
26
  export async function hostRequest(command, params) {
26
27
  try {
27
28
  const channel = resolveChannel();
@@ -67,6 +68,26 @@ export function hostNotify(event) {
67
68
  };
68
69
  sendEnvelope(channel, envelope);
69
70
  }
71
+ // Reply to a host unload request. One-way like hostNotify, but it carries the
72
+ // handshake's whole point, so it posts even with no state: the host is holding
73
+ // its teardown open until this arrives or its cap runs out.
74
+ export function postUnloadDone(unloadId, state) {
75
+ if (typeof window === "undefined")
76
+ return;
77
+ const channel = resolveChannel();
78
+ if (channel === "local")
79
+ return;
80
+ const envelope = {
81
+ castleSdk: CASTLE_SDK_PROTOCOL,
82
+ unloadDone: state === undefined ? { unloadId } : { unloadId, state },
83
+ };
84
+ sendEnvelope(channel, envelope);
85
+ }
86
+ // Registered by unloadHandshake.ts rather than imported from it, so this module
87
+ // keeps no dependency on the one that depends on it.
88
+ export function setUnloadHandler(handler) {
89
+ unloadHandler = handler;
90
+ }
70
91
  function resolveChannel() {
71
92
  if (typeof window === "undefined")
72
93
  return "local";
@@ -82,7 +103,7 @@ function resolveChannel() {
82
103
  return window.parent && window.parent !== window ? "web" : "local";
83
104
  }
84
105
  function postCommand(channel, command, params) {
85
- installResponseListener();
106
+ installHostListener();
86
107
  const requestId = `csdk_${nextRequestId++}`;
87
108
  return new Promise((resolve, reject) => {
88
109
  const timeout = INTERACTIVE_COMMANDS.has(command)
@@ -106,15 +127,28 @@ function sendEnvelope(channel, envelope) {
106
127
  }
107
128
  // The mobile host can't dispatch a DOM 'message' event, so it calls this global
108
129
  // directly with the parsed envelope. The web host posts a 'message' event.
109
- function installResponseListener() {
130
+ //
131
+ // Exported because a deck that only registers an unload subscriber never posts a
132
+ // command, and without this the global would never exist — the host's inject
133
+ // would land on nothing and its handshake would run out its cap in silence.
134
+ export function installHostListener() {
110
135
  if (listenersInstalled || typeof window === "undefined")
111
136
  return;
112
137
  listenersInstalled = true;
113
- window.__castleSdkHost = { receive: (message) => settle(message) };
138
+ window.__castleSdkHost = { receive: (message) => receive(message) };
114
139
  window.addEventListener("message", (event) => {
115
- settle(event.data);
140
+ receive(event.data);
116
141
  });
117
142
  }
143
+ function receive(message) {
144
+ if (isResponseEnvelope(message)) {
145
+ settle(message);
146
+ return;
147
+ }
148
+ const unloadId = unloadRequestId(message);
149
+ if (unloadId !== null)
150
+ unloadHandler?.(unloadId);
151
+ }
118
152
  function settle(message) {
119
153
  if (!isResponseEnvelope(message))
120
154
  return;
@@ -0,0 +1,6 @@
1
+ export interface UnloadContext {
2
+ state?: string;
3
+ }
4
+ export type UnloadSubscriber = (ctx: UnloadContext) => void;
5
+ export declare function onHostUnload(subscriber: UnloadSubscriber): () => void;
6
+ export declare function notifyUnloadInterest(): void;
@@ -0,0 +1,47 @@
1
+ // The host-driven half of "this page is about to go away".
2
+ //
3
+ // `unloadFlush.ts` is the browser-event half — pagehide / visibilitychange,
4
+ // which a deck's own document raises. This one is the host's: an RN WebView
5
+ // being torn down on a feed swipe never raises those reliably, so the mobile
6
+ // host sends an explicit unload request first and holds its teardown open,
7
+ // briefly, for the reply.
8
+ //
9
+ // Subscribers run synchronously and the reply goes out in the same tick. That
10
+ // is the contract that makes the host's wait short: it is waiting on one
11
+ // postMessage, not on whatever work a subscriber might want to do. A subscriber
12
+ // with host-bound work of its own must post it BEFORE returning — deck→host
13
+ // messages are FIFO per WebView, so anything posted first arrives first.
14
+ import { hostNotify, installHostListener, postUnloadDone, setUnloadHandler } from "./transport";
15
+ const subscribers = new Set();
16
+ let installed = false;
17
+ let interestSent = false;
18
+ export function onHostUnload(subscriber) {
19
+ subscribers.add(subscriber);
20
+ if (!installed) {
21
+ installed = true;
22
+ installHostListener();
23
+ setUnloadHandler(runHandshake);
24
+ }
25
+ return () => subscribers.delete(subscriber);
26
+ }
27
+ // Tells the host this deck has something to hand over, so it runs the handshake
28
+ // (and pays its bounded wait) for this deck instead of skipping it. Sent once;
29
+ // the host doesn't care which subscriber prompted it.
30
+ export function notifyUnloadInterest() {
31
+ if (interestSent)
32
+ return;
33
+ interestSent = true;
34
+ hostNotify("unloadInterest");
35
+ }
36
+ function runHandshake(unloadId) {
37
+ const ctx = {};
38
+ for (const subscriber of subscribers) {
39
+ try {
40
+ subscriber(ctx);
41
+ }
42
+ catch (error) {
43
+ console.warn("Castle unload subscriber threw.", error);
44
+ }
45
+ }
46
+ postUnloadDone(unloadId, ctx.state);
47
+ }
package/dist/user.d.ts CHANGED
@@ -3,8 +3,12 @@ export interface CastleUser {
3
3
  username: string;
4
4
  isAnonymous: boolean;
5
5
  isActive: boolean;
6
+ photoUrl: string | null;
7
+ frameUrl: string | null;
8
+ color: string | null;
6
9
  }
7
10
  export interface CastleUserApi {
8
11
  getCurrent(): Promise<CastleUser>;
12
+ get(userId: string): Promise<CastleUser | null>;
9
13
  }
10
14
  export declare const User: CastleUserApi;
package/dist/user.js CHANGED
@@ -2,8 +2,12 @@ import { CastleError } from "./errors";
2
2
  import { hostRequest } from "./transport";
3
3
  let currentUser = null;
4
4
  let currentUserPromise = null;
5
+ // Profiles by user id. A profile changes rarely, and a multiplayer deck asks for
6
+ // the same few ids every time a player joins.
7
+ const profiles = new Map();
5
8
  export const User = {
6
9
  getCurrent,
10
+ get,
7
11
  };
8
12
  async function getCurrent() {
9
13
  if (currentUser)
@@ -25,15 +29,46 @@ async function fetchCurrentUser() {
25
29
  });
26
30
  }
27
31
  const username = requiredString(user.username, "user.username", operation);
32
+ const userId = requiredString(user.userId, "user.userId", operation);
33
+ // The host answers identity from what it already knows. The profile fields
34
+ // come from the same read `get` does; a host that predates that command
35
+ // leaves them null.
36
+ const profile = await get(userId).catch(() => null);
28
37
  return {
29
- userId: requiredString(user.userId, "user.userId", operation),
38
+ userId,
30
39
  username,
31
40
  // A host that predates the flag omits it; anonymous account usernames are
32
41
  // always minted as `anonymous-user-<uuid>`, so the prefix is the fallback.
33
42
  isAnonymous: user.isAnonymous === true || username.toLowerCase().startsWith("anonymous-user-"),
34
43
  isActive: true,
44
+ photoUrl: profile?.photoUrl ?? null,
45
+ frameUrl: profile?.frameUrl ?? null,
46
+ color: profile?.color ?? null,
35
47
  };
36
48
  }
49
+ // One user by id, or null when no such user exists. Throws `CastleError` on a
50
+ // host that predates the command (`UNKNOWN_COMMAND`).
51
+ async function get(userId) {
52
+ if (typeof userId !== "string" || userId.length === 0) {
53
+ throw new CastleError({
54
+ code: "INVALID_ARGUMENT",
55
+ message: "User.get(userId) needs a user id.",
56
+ operation: "User.get",
57
+ });
58
+ }
59
+ let pending = profiles.get(userId);
60
+ if (!pending) {
61
+ pending = fetchProfile(userId);
62
+ profiles.set(userId, pending);
63
+ // A failed read is not kept, so the next call asks again.
64
+ pending.catch(() => profiles.delete(userId));
65
+ }
66
+ return pending;
67
+ }
68
+ async function fetchProfile(userId) {
69
+ const { user } = await hostRequest("user.get", { userId });
70
+ return user ? { ...user, isActive: true } : null;
71
+ }
37
72
  function requiredString(value, field, operation) {
38
73
  if (typeof value === "string" && value.length > 0)
39
74
  return value;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "castle-web-sdk",
3
- "version": "0.4.26",
3
+ "version": "0.4.28",
4
4
  "type": "module",
5
5
  "main": "dist/castle.js",
6
6
  "types": "dist/castle.d.ts",