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.
- package/README.md +253 -40
- package/dist/castle.d.ts +3 -0
- package/dist/castle.js +1 -0
- package/dist/chunk.d.ts +1 -0
- package/dist/chunk.js +15 -0
- package/dist/commands.d.ts +116 -1
- package/dist/commands.js +9 -0
- package/dist/lifecycle.d.ts +20 -0
- package/dist/lifecycle.js +7 -1
- package/dist/multiplayer.d.ts +6 -0
- package/dist/multiplayer.js +6 -0
- package/dist/resumeState.d.ts +2 -0
- package/dist/resumeState.js +84 -0
- package/dist/runtime.js +86 -12
- package/dist/server/platformHandle.d.ts +10 -0
- package/dist/server/storage.d.ts +46 -0
- package/dist/server/storage.js +218 -0
- package/dist/server/wrapper.d.ts +14 -1
- package/dist/server/wrapper.js +4 -1
- package/dist/storage.d.ts +15 -0
- package/dist/storage.js +29 -86
- package/dist/storageJson.d.ts +5 -0
- package/dist/storageJson.js +69 -0
- package/dist/store.d.ts +67 -0
- package/dist/store.js +313 -0
- package/dist/transport.d.ts +3 -0
- package/dist/transport.js +39 -5
- package/dist/unloadFlush.d.ts +3 -0
- package/dist/unloadFlush.js +30 -0
- package/dist/unloadHandshake.d.ts +6 -0
- package/dist/unloadHandshake.js +47 -0
- package/package.json +1 -1
package/dist/multiplayer.js
CHANGED
|
@@ -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,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.
|
|
72
|
-
//
|
|
73
|
-
//
|
|
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
|
|
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
|
|
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(
|
|
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:
|
|
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
|
+
}
|
package/dist/server/wrapper.d.ts
CHANGED
|
@@ -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
|
|
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;
|
package/dist/server/wrapper.js
CHANGED
|
@@ -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;
|