castle-web-sdk 0.4.25 → 0.4.27

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.
@@ -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
  }
@@ -68,27 +69,35 @@ export function fileUrl(path) {
68
69
  }
69
70
  // Move a deck file. `writeFile`'s counterpart for the case where the file's
70
71
  // NAME is the thing changing -- an editor renaming a blueprint has to move the
71
- // file, not write a copy and leave the old one behind. Rejects when the
72
- // destination exists or the source is gone (the serve's own guards), so a
73
- // caller can surface the collision instead of silently clobbering.
72
+ // file, not write a copy and leave the old one behind. Rides the same local
73
+ // socket / shell bridge as `writeFile`: a content iframe cannot POST the HTTP
74
+ // files API (serve capability gate), so rename must not fetch either.
75
+ // Rejects when the destination exists or the source is gone.
74
76
  export async function renameFile(from, to) {
75
- await fileOp("rename", { from, to });
77
+ const response = await sendLocalRequest({
78
+ type: "rename_file",
79
+ from,
80
+ to,
81
+ });
82
+ if (response.ok === false) {
83
+ throw new Error(typeof response.error === "string" && response.error
84
+ ? response.error
85
+ : "castle: rename failed");
86
+ }
76
87
  }
77
88
  // Delete a deck file. Editor UI only, same as `writeFile` -- a published deck
78
- // has no serve to ask.
89
+ // has no serve to ask. Still HTTP for now (shell Files panel); content iframes
90
+ // cannot call it until it grows a bridge message like `rename_file`.
79
91
  export async function deleteFile(path) {
80
- await fileOp("delete", { path });
81
- }
82
- async function fileOp(action, body) {
83
- const res = await fetch(`/__castle/files/${action}`, {
92
+ const res = await fetch("/__castle/files/delete", {
84
93
  method: "POST",
85
94
  headers: { "content-type": "application/json" },
86
- body: JSON.stringify(body),
95
+ body: JSON.stringify({ path }),
87
96
  });
88
97
  if (res.ok)
89
98
  return;
90
99
  const detail = await res.text().catch(() => "");
91
- throw new Error(`castle: ${action} failed (${res.status}) ${detail}`.trim());
100
+ throw new Error(`castle: delete failed (${res.status}) ${detail}`.trim());
92
101
  }
93
102
  // Ask the editor shell to open (or focus) an editor for `path`. This is how a
94
103
  // kit editor hands the creator off to another file -- the creation modal
@@ -156,6 +165,70 @@ function initPlaySelection() {
156
165
  `;
157
166
  document.head.appendChild(style);
158
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
+ }
159
232
  // Constrains whatever the deck renders into #root to a 5:7 card in play mode.
160
233
  // Hosts own max size and padding; the SDK only preserves the card aspect ratio.
161
234
  function initPlayCard() {
@@ -840,7 +913,8 @@ function handleLocalMessage(msg) {
840
913
  else if (msg.type === "files_changed") {
841
914
  dispatchFilesChanged(msg);
842
915
  }
843
- else if (msg.type === "write_file_response") {
916
+ else if (msg.type === "write_file_response" ||
917
+ msg.type === "rename_file_response") {
844
918
  resolveLocalRequest(msg);
845
919
  }
846
920
  else if (msg.type === "castle_command_response") {
@@ -24,5 +24,15 @@ export interface PlatformHandle {
24
24
  on(event: "binaryMessage", callback: (connId: string, bytes: Uint8Array) => unknown): void;
25
25
  on(event: "replaced", callback: () => unknown): void;
26
26
  on(event: "shutdown", callback: (reason?: string) => unknown): void;
27
+ /**
28
+ * Deck storage. Strings in and strings out, like the rest of this handle, so a
29
+ * runtime other than the Node shim can implement it without knowing what storage is.
30
+ *
31
+ * Optional because a published bundle may run on a host that predates storage.
32
+ * Rejects with an Error whose `code` is the platform's error code.
33
+ */
34
+ storage?(op: string, argsJson: string): Promise<string>;
35
+ /** False when the host cannot broker storage, so a call can fail at once. */
36
+ readonly storageEnabled?: boolean;
27
37
  readonly config: PlatformSessionConfig;
28
38
  }
@@ -0,0 +1,46 @@
1
+ import type { Json } from "../types";
2
+ import type { PlatformHandle } from "./platformHandle";
3
+ export interface ServerStorageListOptions {
4
+ prefix?: string;
5
+ limit?: number;
6
+ cursor?: string | null;
7
+ }
8
+ export interface ServerStorageListEntry<T extends Json = Json> {
9
+ key: string;
10
+ value: T;
11
+ }
12
+ export interface ServerStorageListPage<T extends Json = Json> {
13
+ entries: ServerStorageListEntry<T>[];
14
+ /** Null means there is nothing left. A short page alone does not mean that. */
15
+ cursor: string | null;
16
+ }
17
+ export interface ServerStorageScope {
18
+ get<T extends Json = Json>(keys: string[]): Promise<Record<string, T>>;
19
+ set(values: Record<string, Json>): Promise<void>;
20
+ remove(keys: string[]): Promise<void>;
21
+ increment(key: string, delta?: number): Promise<number>;
22
+ list<T extends Json = Json>(options?: ServerStorageListOptions): Promise<ServerStorageListPage<T>>;
23
+ }
24
+ export interface ServerStorageBoardScore {
25
+ subject: string;
26
+ score: number;
27
+ updatedAt: string;
28
+ }
29
+ export interface ServerStorageBoard {
30
+ /**
31
+ * Records a score for a player. A client may only submit its own; a server is the
32
+ * authority for its whole session and names whose score this is, which is the
33
+ * difference that makes a leaderboard worth trusting.
34
+ */
35
+ submit(userId: string, score: number): Promise<ServerStorageBoardScore>;
36
+ top(limit?: number): Promise<ServerStorageBoardScore[]>;
37
+ get(userId: string): Promise<ServerStorageBoardScore | null>;
38
+ }
39
+ export interface ServerStorage {
40
+ /** Shared by everyone playing the deck, and owned by this server. */
41
+ readonly deck: ServerStorageScope;
42
+ /** One player's public data. Readable by anyone; writable by them or by this server. */
43
+ user(userId: string): ServerStorageScope;
44
+ board(name: string): ServerStorageBoard;
45
+ }
46
+ export declare function createServerStorage(platformHandle: PlatformHandle): ServerStorage;
@@ -0,0 +1,218 @@
1
+ import { chunked } from "../chunk";
2
+ import { decodeStorageValue, encodeStorageValue, storageError, } from "../storageJson";
3
+ // Deck storage for an author's session server.
4
+ //
5
+ // The same store the deck's clients use, reached over the platform channel instead of
6
+ // the host bridge. Two differences follow from what a session server IS, and both are
7
+ // enforced by the platform, not here:
8
+ //
9
+ // - It owns `deck` scope. Once a deck has a session server, clients read that data
10
+ // but no longer write it, which is what makes server-validated state possible.
11
+ // - It can never touch `private` scope, not even for its own players. Private
12
+ // storage belongs to the player, so this API does not offer it at all.
13
+ //
14
+ // Writes are not coalesced, unlike the client's. A server's writes are deliberate
15
+ // rather than one-per-frame, and every call resolving when the platform has the write
16
+ // is what makes saving during `onShutdown` simply work: the author awaits, and the
17
+ // host holds the session open until the request lands.
18
+ /** The platform takes at most this many keys per call. */
19
+ const MAX_KEYS_PER_CALL = 32;
20
+ /**
21
+ * A storage request crosses the platform channel as one frame, so it has a size limit
22
+ * the client path does not. Batches are split to stay under it. A single value too
23
+ * large to fit at all is refused here with a typed error, which is the difference
24
+ * between a save the author can handle and a frame that closes the session.
25
+ */
26
+ const MAX_REQUEST_BYTES = 320 * 1024;
27
+ // Room for the operation name, the scope and the subject, all of which are bounded
28
+ // below so this allowance is a real ceiling rather than an assumption.
29
+ const REQUEST_OVERHEAD_BYTES = 4 * 1024;
30
+ /**
31
+ * The platform's own limits, mirrored so an over-long key or user id fails here with a
32
+ * typed error. Sent instead, they would push the request past the channel's frame
33
+ * limit -- and past that the socket closes and the session ends, which is a far worse
34
+ * answer than "that key is too long".
35
+ */
36
+ const MAX_KEY_BYTES = 128;
37
+ const MAX_SUBJECT_BYTES = 256;
38
+ export function createServerStorage(platformHandle) {
39
+ const call = async (op, args) => {
40
+ if (typeof platformHandle.storage !== "function") {
41
+ // An older host-agent that predates storage. Say so rather than hanging on a
42
+ // reply that is never coming.
43
+ throw storageError("CAULDRON_STORAGE_UNAVAILABLE", "This Castle host does not support session-server storage.", `session.storage.${op}`);
44
+ }
45
+ if (platformHandle.storageEnabled === false) {
46
+ throw storageError("CAULDRON_STORAGE_UNAVAILABLE", "Storage is not available to this session.", `session.storage.${op}`);
47
+ }
48
+ const result = await platformHandle.storage(op, JSON.stringify(args));
49
+ return JSON.parse(result);
50
+ };
51
+ return {
52
+ deck: createScope(call, "DECK", null),
53
+ user(userId) {
54
+ // A bad id poisons the scope rather than throwing here, so the failure arrives
55
+ // the same way every other failure does -- as a rejection the author can await.
56
+ // A server addresses other people's data, and an absent subject would be
57
+ // resolved by the platform to the deck's creator, quietly writing the wrong
58
+ // player's bucket, so nothing is sent either.
59
+ if (typeof userId !== "string" || userId.length === 0) {
60
+ return createScope(rejectMissingSubject, "USER", null);
61
+ }
62
+ if (utf8Length(userId) > MAX_SUBJECT_BYTES) {
63
+ return createScope(rejectOversizeSubject, "USER", null);
64
+ }
65
+ return createScope(call, "USER", userId);
66
+ },
67
+ board(name) {
68
+ return createBoard(call, name);
69
+ },
70
+ };
71
+ }
72
+ function createScope(call, scope, subject) {
73
+ return {
74
+ async get(keys) {
75
+ // A bare array, not a wrapper: the platform hands back exactly what the
76
+ // storage verb returns, and `get` returns its entries directly.
77
+ const entries = await call("get", {
78
+ scope,
79
+ subject,
80
+ keys: assertKeys(keys, "session.storage.get"),
81
+ });
82
+ const result = {};
83
+ for (const entry of entries ?? []) {
84
+ // defineProperty, not assignment: a stored key named "__proto__" would
85
+ // otherwise reach the prototype setter rather than becoming an own property.
86
+ Object.defineProperty(result, entry.key, {
87
+ value: decodeStorageValue(entry.value, "session.storage.get"),
88
+ writable: true,
89
+ enumerable: true,
90
+ configurable: true,
91
+ });
92
+ }
93
+ return result;
94
+ },
95
+ async set(values) {
96
+ const entries = Object.entries(values).map(([key, value]) => ({
97
+ key,
98
+ value: encodeStorageValue(value, "session.storage.set"),
99
+ }));
100
+ for (const chunk of batched(entries)) {
101
+ await call("set", { scope, subject, entries: chunk });
102
+ }
103
+ },
104
+ async remove(keys) {
105
+ for (const chunk of chunked(assertKeys(keys, "session.storage.remove"), MAX_KEYS_PER_CALL)) {
106
+ await call("delete", { scope, subject, keys: chunk });
107
+ }
108
+ },
109
+ async increment(key, delta = 1) {
110
+ const value = await call("increment", { scope, subject, key, delta });
111
+ return Number(value);
112
+ },
113
+ async list(options = {}) {
114
+ const page = await call("list", {
115
+ scope,
116
+ subject,
117
+ prefix: options.prefix ?? null,
118
+ limit: options.limit ?? null,
119
+ cursor: options.cursor ?? null,
120
+ });
121
+ return {
122
+ entries: (page?.entries ?? []).map((entry) => ({
123
+ key: entry.key,
124
+ value: decodeStorageValue(entry.value, "session.storage.list"),
125
+ })),
126
+ cursor: page?.cursor ?? null,
127
+ };
128
+ },
129
+ };
130
+ }
131
+ function createBoard(call, board) {
132
+ return {
133
+ async submit(userId, score) {
134
+ return call("boardSubmit", {
135
+ board,
136
+ subject: assertUserId(userId, "session.storage.board.submit"),
137
+ score,
138
+ });
139
+ },
140
+ async top(limit) {
141
+ const entries = await call("boardTop", {
142
+ board,
143
+ limit: limit ?? null,
144
+ });
145
+ return entries ?? [];
146
+ },
147
+ async get(userId) {
148
+ const entry = await call("boardGet", {
149
+ board,
150
+ subject: assertUserId(userId, "session.storage.board.get"),
151
+ });
152
+ return entry ?? null;
153
+ },
154
+ };
155
+ }
156
+ function assertKeys(keys, operation) {
157
+ if (!Array.isArray(keys) || keys.some((key) => typeof key !== "string")) {
158
+ throw storageError("CASTLE_STORE_INVALID_KEYS", "Storage keys must be an array of strings.", operation);
159
+ }
160
+ for (const key of keys) {
161
+ if (utf8Length(key) > MAX_KEY_BYTES) {
162
+ throw storageError("CAULDRON_STORAGE_INVALID_KEY", `Storage keys must be at most ${MAX_KEY_BYTES} bytes.`, operation);
163
+ }
164
+ }
165
+ return keys;
166
+ }
167
+ const rejectMissingSubject = (op) => Promise.reject(missingSubjectError(`session.storage.${op}`));
168
+ const rejectOversizeSubject = (op) => Promise.reject(storageError("CASTLE_STORE_INVALID_SUBJECT", `A user id must be at most ${MAX_SUBJECT_BYTES} bytes.`, `session.storage.${op}`));
169
+ function missingSubjectError(operation) {
170
+ return storageError("CASTLE_STORE_INVALID_SUBJECT", "A user id is required here; a session server has no storage of its own.", operation);
171
+ }
172
+ function assertUserId(userId, operation) {
173
+ if (typeof userId !== "string" || userId.length === 0) {
174
+ throw missingSubjectError(operation);
175
+ }
176
+ if (utf8Length(userId) > MAX_SUBJECT_BYTES) {
177
+ throw storageError("CASTLE_STORE_INVALID_SUBJECT", `A user id must be at most ${MAX_SUBJECT_BYTES} bytes.`, operation);
178
+ }
179
+ return userId;
180
+ }
181
+ /**
182
+ * Splits entries into requests that fit both limits: the platform's key count and the
183
+ * channel's frame size.
184
+ */
185
+ function batched(entries) {
186
+ const budget = MAX_REQUEST_BYTES - REQUEST_OVERHEAD_BYTES;
187
+ const batches = [];
188
+ let current = [];
189
+ let currentBytes = 0;
190
+ for (const entry of entries) {
191
+ const bytes = entryBytes(entry);
192
+ if (bytes > budget) {
193
+ throw storageError("CAULDRON_STORAGE_INVALID_VALUE", `A single stored value must be under ${budget} bytes when written from a session server.`, "session.storage.set");
194
+ }
195
+ if (current.length === MAX_KEYS_PER_CALL || currentBytes + bytes > budget) {
196
+ batches.push(current);
197
+ current = [];
198
+ currentBytes = 0;
199
+ }
200
+ current.push(entry);
201
+ currentBytes += bytes;
202
+ }
203
+ if (current.length > 0)
204
+ batches.push(current);
205
+ return batches;
206
+ }
207
+ function entryBytes(entry) {
208
+ // The ENCODED size, not the string length. `value` is already JSON, so putting it
209
+ // in the request escapes it a second time -- every quote and backslash doubles.
210
+ // Budgeting on the raw length would let an escape-heavy value slip a batch past the
211
+ // channel's limit, and past the limit the socket closes and the session with it.
212
+ return utf8Length(JSON.stringify(entry));
213
+ }
214
+ function utf8Length(value) {
215
+ if (typeof TextEncoder !== "undefined")
216
+ return new TextEncoder().encode(value).length;
217
+ return value.length;
218
+ }
@@ -1,5 +1,7 @@
1
1
  import type { MultiplayerSessionMode, PlatformHandle, PlayerIdentity } from "./platformHandle";
2
+ import { type ServerStorage } from "./storage";
2
3
  export type { MultiplayerSessionMode, PlatformHandle, PlatformSessionConfig, PlayerIdentity, } from "./platformHandle";
4
+ export type { ServerStorage, ServerStorageBoard, ServerStorageBoardScore, ServerStorageListEntry, ServerStorageListOptions, ServerStorageListPage, ServerStorageScope, } from "./storage";
3
5
  export interface MultiplayerServerPlayer extends PlayerIdentity {
4
6
  playerId: string;
5
7
  }
@@ -11,13 +13,18 @@ export interface MultiplayerServerSession {
11
13
  readonly sessionId: string;
12
14
  readonly deckId: string;
13
15
  readonly mode: MultiplayerSessionMode;
16
+ /**
17
+ * Deck storage that outlives the session. Nothing else here does: a session ends
18
+ * when its last player leaves and its memory goes with it.
19
+ */
20
+ readonly storage: ServerStorage;
14
21
  /** Sends bytes as a binary message and all other payloads as JSON. */
15
22
  send(playerId: string, data: unknown): void;
16
23
  /** Broadcasts bytes as a binary message and all other payloads as JSON. */
17
24
  broadcast(data: unknown, options?: MultiplayerBroadcastOptions): void;
18
25
  disconnect(playerId: string, reason?: string): void;
19
26
  }
20
- type CallbackResult = unknown | Promise<unknown>;
27
+ type CallbackResult = unknown;
21
28
  export interface MultiplayerServerCallbacks {
22
29
  onStart?: (session: MultiplayerServerSession) => CallbackResult;
23
30
  onPlayerJoin?: (session: MultiplayerServerSession, player: MultiplayerServerPlayer) => CallbackResult;
@@ -26,6 +33,12 @@ export interface MultiplayerServerCallbacks {
26
33
  onMessage?: (session: MultiplayerServerSession, player: MultiplayerServerPlayer, data: unknown) => CallbackResult;
27
34
  onTick?: (session: MultiplayerServerSession) => CallbackResult;
28
35
  onReplaced?: (session: MultiplayerServerSession) => CallbackResult;
36
+ /**
37
+ * The session is ending. Awaited, so a save made here lands before the runtime is
38
+ * stopped. Not a guarantee: an out-of-memory kill or a host dying sends no shutdown
39
+ * at all, so save as things change and treat this as the last chance, not the plan.
40
+ */
41
+ onShutdown?: (session: MultiplayerServerSession) => CallbackResult;
29
42
  }
30
43
  export type CastleSessionBoot = (platformHandle: PlatformHandle) => Promise<void>;
31
44
  export declare function createCastleSessionBoot(callbacks: MultiplayerServerCallbacks): CastleSessionBoot;
@@ -1,4 +1,5 @@
1
1
  import { isBinaryPayload, toUint8Array, } from "../multiplayerProtocol";
2
+ import { createServerStorage } from "./storage";
2
3
  // The publish bundler binds the author's default callbacks export once and
3
4
  // exports the returned one-argument function as `__castleBootSession`.
4
5
  export function createCastleSessionBoot(callbacks) {
@@ -44,10 +45,11 @@ async function bootSession(platformHandle, callbacks) {
44
45
  platformHandle.on("replaced", async () => {
45
46
  await callbacks.onReplaced?.(session);
46
47
  });
47
- platformHandle.on("shutdown", () => {
48
+ platformHandle.on("shutdown", async () => {
48
49
  if (tickTimer !== null)
49
50
  clearInterval(tickTimer);
50
51
  tickTimer = null;
52
+ await callbacks.onShutdown?.(session);
51
53
  });
52
54
  await callbacks.onStart?.(session);
53
55
  const onTick = callbacks.onTick;
@@ -65,6 +67,7 @@ function createAuthorSession(platformHandle, players) {
65
67
  sessionId: platformHandle.config.sessionId,
66
68
  deckId: platformHandle.config.deckId,
67
69
  mode: platformHandle.config.mode,
70
+ storage: createServerStorage(platformHandle),
68
71
  send(playerId, data) {
69
72
  if (isBinaryPayload(data)) {
70
73
  requireSendBinary(platformHandle);
package/dist/storage.d.ts CHANGED
@@ -12,5 +12,20 @@ export interface SharedStorageApi {
12
12
  set(scope: SharedScope, key: string, value: Json): void;
13
13
  remove(scope: SharedScope, key: string): void;
14
14
  }
15
+ /**
16
+ * Per-player private storage.
17
+ *
18
+ * @deprecated Use `Store.private` instead. This keeps one document per player
19
+ * and rewrites all of it on every change, so it is capped at 2 MB and loses
20
+ * concurrent writes. It stays supported for decks already using it.
21
+ */
15
22
  export declare const Storage: StorageApi;
23
+ /**
24
+ * Storage other players can read.
25
+ *
26
+ * @deprecated Use `Store.deck`, `Store.me` and `Store.user(id)` instead. This
27
+ * has no ownership rule on `'deck'` scope -- any player can overwrite any key --
28
+ * and two players writing at once lose one of the writes. It stays supported
29
+ * for decks already using it.
30
+ */
16
31
  export declare const SharedStorage: SharedStorageApi;