castle-web-sdk 0.4.24 → 0.4.26
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 +194 -37
- package/dist/castle.d.ts +2 -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 +86 -0
- package/dist/multiplayer.d.ts +2 -0
- package/dist/multiplayer.js +59 -16
- package/dist/multiplayerProtocol.d.ts +3 -0
- package/dist/multiplayerProtocol.js +13 -0
- package/dist/runtime.js +21 -12
- package/dist/server/platformHandle.d.ts +13 -0
- package/dist/server/storage.d.ts +46 -0
- package/dist/server/storage.js +218 -0
- package/dist/server/wrapper.d.ts +17 -1
- package/dist/server/wrapper.js +46 -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/unloadFlush.d.ts +3 -0
- package/dist/unloadFlush.js +30 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -10,11 +10,12 @@ there — nothing to install, and no deck `package.json` names it. Import
|
|
|
10
10
|
what you need from `castle-web-sdk`:
|
|
11
11
|
|
|
12
12
|
```js
|
|
13
|
-
import { setup, initCard,
|
|
13
|
+
import { setup, initCard, Store, Leaderboard } from "castle-web-sdk";
|
|
14
14
|
```
|
|
15
15
|
|
|
16
16
|
## Contents
|
|
17
17
|
|
|
18
|
+
- [Store](#store)
|
|
18
19
|
- [Storage](#storage)
|
|
19
20
|
- [SharedStorage](#sharedstorage)
|
|
20
21
|
- [Leaderboard](#leaderboard)
|
|
@@ -54,7 +55,19 @@ metadata distinguishes a server `bye`, a replaced server version, an explicit
|
|
|
54
55
|
|
|
55
56
|
When Castle selects solo fallback, the returned connection has `isSolo === true`,
|
|
56
57
|
a one-player roster, and no server process. Calls to `send` are accepted but do
|
|
57
|
-
nothing. Client frames are limited to
|
|
58
|
+
nothing. Client→server frames are limited to 16 KB on the wire.
|
|
59
|
+
|
|
60
|
+
### Binary messages
|
|
61
|
+
|
|
62
|
+
Pass any typed array, `DataView`, or `ArrayBuffer` to client `send`, server `send`,
|
|
63
|
+
or server `broadcast` to send its raw bytes. Receivers always get those bytes as a
|
|
64
|
+
`Uint8Array`; reconstruct another view type such as `Float32Array` on receipt.
|
|
65
|
+
Multi-byte values use the sender platform's byte order. Every other payload uses
|
|
66
|
+
JSON. Client→server messages are capped at 16 KB and server→client messages at
|
|
67
|
+
64 KB, for both JSON and binary frames. Both caps are on the frame, and a binary
|
|
68
|
+
frame includes a one-byte protocol tag, so the largest payload is one byte under the
|
|
69
|
+
cap. A published server bundle on an older runtime throws
|
|
70
|
+
`TypeError: This Castle runtime does not support binary multiplayer messages yet.`
|
|
58
71
|
|
|
59
72
|
Server callbacks are the default export from the deck's server entry. They are
|
|
60
73
|
session-first and receive only platform-trusted player identities:
|
|
@@ -74,71 +87,215 @@ TypeScript server entries can import callback, session, player, and platform
|
|
|
74
87
|
handle types from `castle-web-sdk/server`. Server transport has no runtime
|
|
75
88
|
dependency on a WebSocket package.
|
|
76
89
|
|
|
77
|
-
|
|
90
|
+
### `session.storage`
|
|
78
91
|
|
|
79
|
-
|
|
80
|
-
|
|
92
|
+
A session ends when its last player leaves, and its memory goes with it.
|
|
93
|
+
`session.storage` is what outlives one, and it is the same store the deck's
|
|
94
|
+
clients see through [`Store`](#store):
|
|
95
|
+
|
|
96
|
+
```js
|
|
97
|
+
export default {
|
|
98
|
+
async onPlayerJoin(session, player) {
|
|
99
|
+
const { wins = 0 } = await session.storage.deck.get(["wins"]);
|
|
100
|
+
session.send(player.playerId, { wins });
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
async onShutdown(session) {
|
|
104
|
+
await session.storage.deck.set({ endedAt: Date.now() });
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
`session.storage.deck` is shared by everyone playing.
|
|
110
|
+
`session.storage.user(userId)` is one player's public data.
|
|
111
|
+
`session.storage.board(name)` is a leaderboard. Each scope has the same
|
|
112
|
+
`get`, `set`, `remove`, `increment` and `list` as `Store`.
|
|
113
|
+
|
|
114
|
+
Two differences follow from a session server not being a player:
|
|
115
|
+
|
|
116
|
+
- **It owns `deck` scope.** Once a deck has a published server, clients read
|
|
117
|
+
that data but no longer write it, which is what lets you keep authoritative
|
|
118
|
+
state players cannot forge.
|
|
119
|
+
- **It has no self.** There is no `storage.me`, and a user scope or a board
|
|
120
|
+
submission must name the player: `session.storage.user(player.userId)`,
|
|
121
|
+
`session.storage.board("high").submit(player.userId, score)`. A server can
|
|
122
|
+
submit anyone's score, which is what makes a server-run leaderboard worth
|
|
123
|
+
trusting. It can never read `private` storage, not even for its own players.
|
|
124
|
+
|
|
125
|
+
`onShutdown` is awaited, so a save made there does land — but only on a clean
|
|
126
|
+
stop. An out-of-memory kill or a host failing sends no shutdown at all, so
|
|
127
|
+
save as things change and treat `onShutdown` as the last chance, not the plan.
|
|
81
128
|
|
|
82
|
-
|
|
129
|
+
## Store
|
|
83
130
|
|
|
84
|
-
|
|
131
|
+
`Store` saves data one key at a time. It is the storage to use for
|
|
132
|
+
multiplayer decks: two players writing different keys no longer
|
|
133
|
+
overwrite each other, a counter can be added to safely from several
|
|
134
|
+
places at once, and a deck can hold much more than `Storage` allows and
|
|
135
|
+
read back a slice of it rather than the whole thing.
|
|
136
|
+
|
|
137
|
+
Values can be anything that converts to JSON, the same as `Storage`.
|
|
138
|
+
|
|
139
|
+
Scopes decide who owns a key:
|
|
140
|
+
|
|
141
|
+
- `Store.deck` — shared by everyone playing. If the deck publishes a
|
|
142
|
+
session server, the server owns these keys and players read them.
|
|
143
|
+
- `Store.me` — the current player's data, which other players can read.
|
|
144
|
+
- `Store.user(userId)` — another player's public data. Anyone can read
|
|
145
|
+
it; only that player, or the deck's session server, can write it.
|
|
146
|
+
- `Store.private` — the current player's own data. Nobody else can read
|
|
147
|
+
it, and a session server never sees it.
|
|
148
|
+
|
|
149
|
+
### `Store.<scope>.get<T>(keys): Promise<Record<string, T>>`
|
|
150
|
+
|
|
151
|
+
Reads several keys at once. Keys with nothing stored are missing from
|
|
152
|
+
the result rather than present as `null`.
|
|
85
153
|
|
|
86
154
|
```js
|
|
87
|
-
const level =
|
|
155
|
+
const { level = 1, name } = await Store.deck.get(["level", "name"]);
|
|
88
156
|
```
|
|
89
157
|
|
|
90
|
-
### `
|
|
158
|
+
### `Store.<scope>.set(values): Promise<void>`
|
|
91
159
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
save in the background.
|
|
160
|
+
Writes several keys. Nearby writes are grouped, so saving on every
|
|
161
|
+
change costs one request per burst. The promise resolves once the
|
|
162
|
+
platform has the write, so a deck can tell whether saving worked.
|
|
96
163
|
|
|
97
164
|
```js
|
|
98
|
-
|
|
99
|
-
Storage.set("settings", { sound: true, music: false });
|
|
165
|
+
await Store.deck.set({ level: 7, name: "Ada" });
|
|
100
166
|
```
|
|
101
167
|
|
|
102
|
-
### `
|
|
168
|
+
### `Store.<scope>.remove(keys): Promise<void>`
|
|
103
169
|
|
|
104
|
-
Removes
|
|
170
|
+
Removes keys.
|
|
105
171
|
|
|
106
|
-
|
|
172
|
+
### `Store.<scope>.increment(key, delta?): Promise<number>`
|
|
107
173
|
|
|
108
|
-
`
|
|
109
|
-
|
|
174
|
+
Adds `delta` (default `1`) to a number and returns the new total,
|
|
175
|
+
starting the key at `delta` if it was not set. Two players incrementing
|
|
176
|
+
at the same moment both count, which is the thing a read, add and write
|
|
177
|
+
back cannot promise.
|
|
110
178
|
|
|
111
|
-
|
|
179
|
+
```js
|
|
180
|
+
const plays = await Store.deck.increment("plays");
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
### `Store.<scope>.list(options?): Promise<{ entries, cursor }>`
|
|
184
|
+
|
|
185
|
+
Reads a page of keys in order, optionally only those starting with
|
|
186
|
+
`prefix`. Pass the `cursor` from one page to get the next.
|
|
187
|
+
|
|
188
|
+
```js
|
|
189
|
+
let cursor = null;
|
|
190
|
+
const world = [];
|
|
191
|
+
do {
|
|
192
|
+
const page = await Store.deck.list({ prefix: "block:", cursor });
|
|
193
|
+
world.push(...page.entries);
|
|
194
|
+
cursor = page.cursor;
|
|
195
|
+
} while (cursor);
|
|
196
|
+
```
|
|
112
197
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
owning player can write.
|
|
198
|
+
Keep reading until `cursor` is `null`. A page can come back shorter than
|
|
199
|
+
you asked for because it reached its size limit, not because the keys
|
|
200
|
+
ran out, so the cursor is the only reliable end signal.
|
|
117
201
|
|
|
118
|
-
|
|
202
|
+
A paged read is not a snapshot: something written while you are paging
|
|
203
|
+
may land in no page or in two. Treat the result as a starting point and
|
|
204
|
+
apply live updates on top, which a multiplayer deck is doing anyway.
|
|
119
205
|
|
|
120
|
-
|
|
121
|
-
|
|
206
|
+
### `Store.board(name)`
|
|
207
|
+
|
|
208
|
+
A leaderboard, separate from the keys above so that reading the top
|
|
209
|
+
scores stays fast no matter how many players there are.
|
|
122
210
|
|
|
123
211
|
```js
|
|
124
|
-
|
|
125
|
-
const
|
|
126
|
-
const
|
|
212
|
+
await Store.board("highscores").submit(score);
|
|
213
|
+
const top = await Store.board("highscores").top(10);
|
|
214
|
+
const mine = await Store.board("highscores").get();
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
- `submit(score)` records the current player's score. A later submission
|
|
218
|
+
replaces an earlier one, so compare first if you only want to keep a
|
|
219
|
+
personal best.
|
|
220
|
+
- `top(limit?)` returns the highest scores, best first.
|
|
221
|
+
- `get(userId?)` returns one player's score, defaulting to the current
|
|
222
|
+
player, or `null` if they have none.
|
|
223
|
+
|
|
224
|
+
A deck's session server can submit any player's score, which is how a
|
|
225
|
+
leaderboard becomes something a player cannot fake. See the multiplayer
|
|
226
|
+
guide.
|
|
227
|
+
|
|
228
|
+
### Limits
|
|
229
|
+
|
|
230
|
+
The platform caps key length, value size, how many keys one call may
|
|
231
|
+
touch, page size, and how much a deck may store in total, and it limits
|
|
232
|
+
how fast a player may read and write. Going over any of them rejects
|
|
233
|
+
with a `CastleError` whose `code` says which, so a deck can tell a
|
|
234
|
+
player it is saving too fast apart from a deck that is out of space.
|
|
235
|
+
|
|
236
|
+
## Storage
|
|
237
|
+
|
|
238
|
+
> Older API, still supported and not going away. New decks should use
|
|
239
|
+
> [`Store`](#store) instead: it stores each key on its own, so concurrent
|
|
240
|
+
> writes do not overwrite each other, and it holds far more than this does.
|
|
241
|
+
> `Storage` keeps one document per player and rewrites all of it on every
|
|
242
|
+
> change.
|
|
243
|
+
|
|
244
|
+
`Storage` saves data for the current player. Nobody else can read it.
|
|
245
|
+
|
|
246
|
+
- **`Storage.get<T>(key): Promise<T | null>`** — the value at `key`, or
|
|
247
|
+
`null` if it is not set.
|
|
248
|
+
- **`Storage.set(key, value)`** — sets `key`. `value` must be something
|
|
249
|
+
that converts to JSON. The next `get(key)` returns it immediately;
|
|
250
|
+
the write saves in the background.
|
|
251
|
+
- **`Storage.remove(key)`** — removes `key`.
|
|
252
|
+
|
|
253
|
+
```js
|
|
254
|
+
const level = (await Storage.get("level")) ?? 1;
|
|
255
|
+
Storage.set("level", 7);
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
The same thing with `Store`, which is what a new deck should write:
|
|
259
|
+
|
|
260
|
+
```js
|
|
261
|
+
const { level = 1 } = await Store.private.get(["level"]);
|
|
262
|
+
await Store.private.set({ level: 7 });
|
|
127
263
|
```
|
|
128
264
|
|
|
129
|
-
|
|
265
|
+
## SharedStorage
|
|
130
266
|
|
|
131
|
-
|
|
132
|
-
|
|
267
|
+
> Older API, still supported and not going away. New decks should use
|
|
268
|
+
> [`Store`](#store) instead. `SharedStorage` has no ownership rule — any
|
|
269
|
+
> player can overwrite any key in the `'deck'` bucket — and two players
|
|
270
|
+
> writing at the same moment lose one of the writes. `Store` fixes both,
|
|
271
|
+
> and adds counters, listing, and leaderboards.
|
|
272
|
+
|
|
273
|
+
`SharedStorage` saves data that other players can read. Values must be
|
|
274
|
+
something that converts to JSON, same as `Storage`.
|
|
275
|
+
|
|
276
|
+
Scopes: `'deck'` is one bucket for the whole deck, readable and writable
|
|
277
|
+
by any player. `'user'` is a per-player bucket that anyone can read and
|
|
278
|
+
only the owning player can write.
|
|
279
|
+
|
|
280
|
+
- **`SharedStorage.get(scope, key)`** — reads a shared value. For
|
|
281
|
+
`'user'`, pass a user id before the key to read someone else's bucket.
|
|
282
|
+
- **`SharedStorage.set(scope, key, value)`** — writes. `'user'` writes
|
|
283
|
+
always go to the current player.
|
|
284
|
+
- **`SharedStorage.remove(scope, key)`** — removes a shared value.
|
|
133
285
|
|
|
134
286
|
```js
|
|
135
|
-
SharedStorage.
|
|
287
|
+
const worldHighScore = await SharedStorage.get("deck", "highScore");
|
|
288
|
+
const theirColor = await SharedStorage.get("user", otherUserId, "color");
|
|
136
289
|
SharedStorage.set("user", "color", "red");
|
|
137
290
|
```
|
|
138
291
|
|
|
139
|
-
|
|
292
|
+
The same thing with `Store`:
|
|
140
293
|
|
|
141
|
-
|
|
294
|
+
```js
|
|
295
|
+
const { highScore } = await Store.deck.get(["highScore"]);
|
|
296
|
+
const { color } = await Store.user(otherUserId).get(["color"]);
|
|
297
|
+
await Store.me.set({ color: "red" });
|
|
298
|
+
```
|
|
142
299
|
|
|
143
300
|
## Leaderboard
|
|
144
301
|
|
package/dist/castle.d.ts
CHANGED
|
@@ -20,6 +20,8 @@ export type { FileChange, FilesChangedEvent } from "./runtime";
|
|
|
20
20
|
export { flushSaves, hasPendingSave, onSaveState, writeFile, } from "./saveQueue";
|
|
21
21
|
export type { SaveState } from "./saveQueue";
|
|
22
22
|
export { SharedStorage, Storage } from "./storage";
|
|
23
|
+
export { Store } from "./store";
|
|
24
|
+
export type { CastleStoreApi, StoreBoardApi, StoreBoardScore, StoreListEntry, StoreListOptions, StoreListPage, StoreScopeApi, } from "./store";
|
|
23
25
|
export { Time } from "./time";
|
|
24
26
|
export type { CastleClockZone, CastleDateParts, CastleTimeApi } from "./time";
|
|
25
27
|
export type { Json } from "./types";
|
package/dist/castle.js
CHANGED
|
@@ -12,5 +12,6 @@ export { Portal } from "./portal";
|
|
|
12
12
|
export { CARD_RATIO, deleteFile, fileUrl, initCard, onBeforeRestart, onFilesChanged, onSaveReloadState, openFile, renameFile, requestReload, setup, takeReloadState, writeFileOnce, } from "./runtime";
|
|
13
13
|
export { flushSaves, hasPendingSave, onSaveState, writeFile, } from "./saveQueue";
|
|
14
14
|
export { SharedStorage, Storage } from "./storage";
|
|
15
|
+
export { Store } from "./store";
|
|
15
16
|
export { Time } from "./time";
|
|
16
17
|
export { User } from "./user";
|
package/dist/chunk.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function chunked<T>(items: T[], size: number): T[][];
|
package/dist/chunk.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Both deck-storage APIs send their keys and entries a batch at a time -- the
|
|
2
|
+
// per-key Store from the browser, session storage from a server -- so the split
|
|
3
|
+
// lives here rather than once in each, where the two could drift on where a
|
|
4
|
+
// batch ends.
|
|
5
|
+
export function chunked(items, size) {
|
|
6
|
+
if (items.length === 0)
|
|
7
|
+
return [];
|
|
8
|
+
if (items.length <= size)
|
|
9
|
+
return [items];
|
|
10
|
+
const chunks = [];
|
|
11
|
+
for (let i = 0; i < items.length; i += size) {
|
|
12
|
+
chunks.push(items.slice(i, i + size));
|
|
13
|
+
}
|
|
14
|
+
return chunks;
|
|
15
|
+
}
|
package/dist/commands.d.ts
CHANGED
|
@@ -2,6 +2,20 @@ import type { Json } from "./types";
|
|
|
2
2
|
export declare const CASTLE_SDK_PROTOCOL = 1;
|
|
3
3
|
export type StorageBlob = Record<string, string>;
|
|
4
4
|
export type SharedScope = "deck" | "user";
|
|
5
|
+
export type StoreScope = "deck" | "user" | "private";
|
|
6
|
+
export interface StoreEntry {
|
|
7
|
+
key: string;
|
|
8
|
+
value: string;
|
|
9
|
+
}
|
|
10
|
+
export interface StoreUsage {
|
|
11
|
+
rowCount: number;
|
|
12
|
+
byteCount: number;
|
|
13
|
+
}
|
|
14
|
+
export interface StoreBoardEntry {
|
|
15
|
+
subject: string;
|
|
16
|
+
score: number;
|
|
17
|
+
updatedAt: string;
|
|
18
|
+
}
|
|
5
19
|
export interface StorageUpdate {
|
|
6
20
|
key: string;
|
|
7
21
|
value: string | null;
|
|
@@ -110,6 +124,53 @@ export interface CommandParams {
|
|
|
110
124
|
style: HapticStyle;
|
|
111
125
|
};
|
|
112
126
|
"multiplayer.getSession": MultiplayerGetSessionParams;
|
|
127
|
+
"cauldronStorage.get": {
|
|
128
|
+
scope: StoreScope;
|
|
129
|
+
subject?: string | null;
|
|
130
|
+
keys: string[];
|
|
131
|
+
};
|
|
132
|
+
"cauldronStorage.set": {
|
|
133
|
+
scope: StoreScope;
|
|
134
|
+
subject?: string | null;
|
|
135
|
+
entries: StoreEntry[];
|
|
136
|
+
asServer?: boolean;
|
|
137
|
+
unload?: boolean;
|
|
138
|
+
};
|
|
139
|
+
"cauldronStorage.delete": {
|
|
140
|
+
scope: StoreScope;
|
|
141
|
+
subject?: string | null;
|
|
142
|
+
keys: string[];
|
|
143
|
+
asServer?: boolean;
|
|
144
|
+
unload?: boolean;
|
|
145
|
+
};
|
|
146
|
+
"cauldronStorage.increment": {
|
|
147
|
+
scope: StoreScope;
|
|
148
|
+
subject?: string | null;
|
|
149
|
+
key: string;
|
|
150
|
+
delta: number;
|
|
151
|
+
asServer?: boolean;
|
|
152
|
+
};
|
|
153
|
+
"cauldronStorage.list": {
|
|
154
|
+
scope: StoreScope;
|
|
155
|
+
subject?: string | null;
|
|
156
|
+
prefix?: string | null;
|
|
157
|
+
limit?: number | null;
|
|
158
|
+
cursor?: string | null;
|
|
159
|
+
};
|
|
160
|
+
"cauldronStorage.boardSubmit": {
|
|
161
|
+
board: string;
|
|
162
|
+
subject?: string | null;
|
|
163
|
+
score: number;
|
|
164
|
+
asServer?: boolean;
|
|
165
|
+
};
|
|
166
|
+
"cauldronStorage.boardTop": {
|
|
167
|
+
board: string;
|
|
168
|
+
limit?: number | null;
|
|
169
|
+
};
|
|
170
|
+
"cauldronStorage.boardGet": {
|
|
171
|
+
board: string;
|
|
172
|
+
subject?: string | null;
|
|
173
|
+
};
|
|
113
174
|
}
|
|
114
175
|
export interface CommandResult {
|
|
115
176
|
"deckStorage.load": {
|
|
@@ -152,6 +213,31 @@ export interface CommandResult {
|
|
|
152
213
|
"portal.prefetch": PortalPrefetchResult;
|
|
153
214
|
"haptics.play": HapticsResult;
|
|
154
215
|
"multiplayer.getSession": MultiplayerGetSessionResult;
|
|
216
|
+
"cauldronStorage.get": {
|
|
217
|
+
entries: StoreEntry[];
|
|
218
|
+
};
|
|
219
|
+
"cauldronStorage.set": {
|
|
220
|
+
usage: StoreUsage;
|
|
221
|
+
};
|
|
222
|
+
"cauldronStorage.delete": {
|
|
223
|
+
usage: StoreUsage;
|
|
224
|
+
};
|
|
225
|
+
"cauldronStorage.increment": {
|
|
226
|
+
value: string;
|
|
227
|
+
};
|
|
228
|
+
"cauldronStorage.list": {
|
|
229
|
+
entries: StoreEntry[];
|
|
230
|
+
cursor: string | null;
|
|
231
|
+
};
|
|
232
|
+
"cauldronStorage.boardSubmit": {
|
|
233
|
+
entry: StoreBoardEntry;
|
|
234
|
+
};
|
|
235
|
+
"cauldronStorage.boardTop": {
|
|
236
|
+
entries: StoreBoardEntry[];
|
|
237
|
+
};
|
|
238
|
+
"cauldronStorage.boardGet": {
|
|
239
|
+
entry: StoreBoardEntry | null;
|
|
240
|
+
};
|
|
155
241
|
}
|
|
156
242
|
export type CommandName = keyof CommandParams;
|
|
157
243
|
export interface SerializedCommandError {
|
package/dist/multiplayer.d.ts
CHANGED
|
@@ -31,7 +31,9 @@ export interface MultiplayerConnection {
|
|
|
31
31
|
readonly playerId: string;
|
|
32
32
|
readonly sessionId: string;
|
|
33
33
|
readonly isSolo: boolean;
|
|
34
|
+
/** Sends byte payloads as binary frames and all other payloads as JSON. */
|
|
34
35
|
send(data: unknown): void;
|
|
36
|
+
/** Receives binary payloads as Uint8Array values and JSON payloads otherwise. */
|
|
35
37
|
onMessage(callback: (data: unknown) => void): () => void;
|
|
36
38
|
onRoster(callback: (roster: MultiplayerRoster) => void): () => void;
|
|
37
39
|
onStateChange(callback: (state: MultiplayerConnectionState, metadata?: MultiplayerStateMetadata) => void): () => void;
|
package/dist/multiplayer.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { CastleError } from "./errors";
|
|
2
|
-
import { MAX_CLIENT_FRAME_BYTES, } from "./multiplayerProtocol";
|
|
2
|
+
import { BINARY_MESSAGE_TAG, isBinaryPayload, MAX_CLIENT_FRAME_BYTES, toUint8Array, } from "./multiplayerProtocol";
|
|
3
3
|
import { hostRequest } from "./transport";
|
|
4
4
|
const KEEPALIVE_INTERVAL_MS = 25_000;
|
|
5
5
|
const HELLO_TIMEOUT_MS = 10_000;
|
|
@@ -70,7 +70,9 @@ class MultiplayerConnectionImpl {
|
|
|
70
70
|
// has no useful branch to take meanwhile, so those messages are dropped.
|
|
71
71
|
// State arrives in whole snapshots, so the next send past recovery is current.
|
|
72
72
|
send(data) {
|
|
73
|
-
const
|
|
73
|
+
const payload = isBinaryPayload(data)
|
|
74
|
+
? serializeClientBinaryMessage(data)
|
|
75
|
+
: serializeClientMessage(data);
|
|
74
76
|
if (this.currentState === "closed") {
|
|
75
77
|
throw multiplayerError("MULTIPLAYER_NOT_CONNECTED", "Multiplayer.send requires a connected session.", "Multiplayer.send");
|
|
76
78
|
}
|
|
@@ -78,7 +80,7 @@ class MultiplayerConnectionImpl {
|
|
|
78
80
|
return;
|
|
79
81
|
if (!this.socket || this.socket.readyState !== WebSocket.OPEN)
|
|
80
82
|
return;
|
|
81
|
-
this.socket.send(
|
|
83
|
+
this.socket.send(payload);
|
|
82
84
|
}
|
|
83
85
|
onMessage(callback) {
|
|
84
86
|
this.messages.add(callback);
|
|
@@ -132,27 +134,48 @@ class MultiplayerConnectionImpl {
|
|
|
132
134
|
openSocket(credential, value) {
|
|
133
135
|
const attempt = ++this.socketAttempt;
|
|
134
136
|
const socket = new WebSocket(authenticatedUrl(this.baseUrl, credential, value));
|
|
137
|
+
socket.binaryType = "arraybuffer";
|
|
135
138
|
this.socket = socket;
|
|
136
139
|
return new Promise((resolve, reject) => {
|
|
137
140
|
let greeted = false;
|
|
141
|
+
let inbound = Promise.resolve();
|
|
142
|
+
const isCurrent = () => this.socketAttempt === attempt &&
|
|
143
|
+
this.currentState !== "closed" &&
|
|
144
|
+
this.socket === socket;
|
|
138
145
|
const timeout = setTimeout(() => {
|
|
139
146
|
reject(new SocketClosedError(1006));
|
|
140
147
|
closeSocket(socket, 1000, "hello timeout");
|
|
141
148
|
}, HELLO_TIMEOUT_MS);
|
|
142
149
|
socket.onmessage = (event) => {
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
150
|
+
const data = event.data;
|
|
151
|
+
inbound = inbound
|
|
152
|
+
.then(async () => {
|
|
153
|
+
if (!isCurrent())
|
|
154
|
+
return;
|
|
155
|
+
if (data instanceof ArrayBuffer) {
|
|
156
|
+
this.dispatchBinary(data, greeted);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (typeof Blob !== "undefined" && data instanceof Blob) {
|
|
160
|
+
const buffer = await data.arrayBuffer();
|
|
161
|
+
if (!isCurrent())
|
|
162
|
+
return;
|
|
163
|
+
this.dispatchBinary(buffer, greeted);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
const frame = parsePlatformFrame(data);
|
|
167
|
+
if (!frame)
|
|
168
|
+
return;
|
|
169
|
+
if (frame.t === "hello") {
|
|
170
|
+
greeted = true;
|
|
171
|
+
clearTimeout(timeout);
|
|
172
|
+
this.acceptHello(frame);
|
|
173
|
+
resolve();
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
this.handleFrame(frame);
|
|
177
|
+
})
|
|
178
|
+
.catch(() => { });
|
|
156
179
|
};
|
|
157
180
|
socket.onclose = (event) => {
|
|
158
181
|
clearTimeout(timeout);
|
|
@@ -210,6 +233,16 @@ class MultiplayerConnectionImpl {
|
|
|
210
233
|
break;
|
|
211
234
|
}
|
|
212
235
|
}
|
|
236
|
+
dispatchBinary(buffer, greeted) {
|
|
237
|
+
if (!greeted)
|
|
238
|
+
return;
|
|
239
|
+
const bytes = new Uint8Array(buffer);
|
|
240
|
+
if (bytes.byteLength < 1 || bytes[0] !== BINARY_MESSAGE_TAG)
|
|
241
|
+
return;
|
|
242
|
+
const payload = new Uint8Array(buffer, 1);
|
|
243
|
+
for (const callback of this.messages)
|
|
244
|
+
callListener(callback, payload);
|
|
245
|
+
}
|
|
213
246
|
acceptRoster(frame) {
|
|
214
247
|
const players = frame.players.slice();
|
|
215
248
|
const you = players.find((player) => player.playerId === this.currentPlayerId) ?? null;
|
|
@@ -363,6 +396,16 @@ function serializeClientMessage(data) {
|
|
|
363
396
|
}
|
|
364
397
|
return text;
|
|
365
398
|
}
|
|
399
|
+
function serializeClientBinaryMessage(data) {
|
|
400
|
+
const payload = toUint8Array(data);
|
|
401
|
+
if (payload.byteLength + 1 > MAX_CLIENT_FRAME_BYTES) {
|
|
402
|
+
throw multiplayerError("MULTIPLAYER_MESSAGE_TOO_LARGE", `Multiplayer.send messages are limited to ${MAX_CLIENT_FRAME_BYTES} bytes.`, "Multiplayer.send");
|
|
403
|
+
}
|
|
404
|
+
const frame = new Uint8Array(payload.byteLength + 1);
|
|
405
|
+
frame[0] = BINARY_MESSAGE_TAG;
|
|
406
|
+
frame.set(payload, 1);
|
|
407
|
+
return frame;
|
|
408
|
+
}
|
|
366
409
|
function authenticatedUrl(baseUrl, credential, value) {
|
|
367
410
|
const fallback = typeof window === "undefined" ? undefined : window.location.href;
|
|
368
411
|
const url = new URL(baseUrl, fallback);
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
export declare const MAX_CLIENT_FRAME_BYTES: number;
|
|
2
2
|
export declare const MAX_PLAYERS_CAP = 24;
|
|
3
|
+
export declare const BINARY_MESSAGE_TAG = 1;
|
|
4
|
+
export declare function isBinaryPayload(value: unknown): value is ArrayBuffer | ArrayBufferView;
|
|
5
|
+
export declare function toUint8Array(value: ArrayBuffer | ArrayBufferView): Uint8Array;
|
|
3
6
|
export type SessionType = "named" | "party" | "public";
|
|
4
7
|
export interface PlayerIdentity {
|
|
5
8
|
userId: string;
|
|
@@ -3,3 +3,16 @@
|
|
|
3
3
|
// published and must not pull the server package into deck bundles.
|
|
4
4
|
export const MAX_CLIENT_FRAME_BYTES = 16 * 1024;
|
|
5
5
|
export const MAX_PLAYERS_CAP = 24;
|
|
6
|
+
// Binary client wire frames are one tag byte (0x01) followed by the opaque
|
|
7
|
+
// author payload. The frame-size limit includes both the tag and payload.
|
|
8
|
+
export const BINARY_MESSAGE_TAG = 0x01;
|
|
9
|
+
// ArrayBuffer and every ArrayBufferView (typed arrays and DataView) are sent as
|
|
10
|
+
// their raw bytes. Receivers always see a Uint8Array over those bytes.
|
|
11
|
+
export function isBinaryPayload(value) {
|
|
12
|
+
return value instanceof ArrayBuffer || ArrayBuffer.isView(value);
|
|
13
|
+
}
|
|
14
|
+
export function toUint8Array(value) {
|
|
15
|
+
return value instanceof ArrayBuffer
|
|
16
|
+
? new Uint8Array(value)
|
|
17
|
+
: new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
|
|
18
|
+
}
|
package/dist/runtime.js
CHANGED
|
@@ -68,27 +68,35 @@ export function fileUrl(path) {
|
|
|
68
68
|
}
|
|
69
69
|
// Move a deck file. `writeFile`'s counterpart for the case where the file's
|
|
70
70
|
// 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
|
-
//
|
|
71
|
+
// file, not write a copy and leave the old one behind. Rides the same local
|
|
72
|
+
// socket / shell bridge as `writeFile`: a content iframe cannot POST the HTTP
|
|
73
|
+
// files API (serve capability gate), so rename must not fetch either.
|
|
74
|
+
// Rejects when the destination exists or the source is gone.
|
|
74
75
|
export async function renameFile(from, to) {
|
|
75
|
-
await
|
|
76
|
+
const response = await sendLocalRequest({
|
|
77
|
+
type: "rename_file",
|
|
78
|
+
from,
|
|
79
|
+
to,
|
|
80
|
+
});
|
|
81
|
+
if (response.ok === false) {
|
|
82
|
+
throw new Error(typeof response.error === "string" && response.error
|
|
83
|
+
? response.error
|
|
84
|
+
: "castle: rename failed");
|
|
85
|
+
}
|
|
76
86
|
}
|
|
77
87
|
// Delete a deck file. Editor UI only, same as `writeFile` -- a published deck
|
|
78
|
-
// has no serve to ask.
|
|
88
|
+
// has no serve to ask. Still HTTP for now (shell Files panel); content iframes
|
|
89
|
+
// cannot call it until it grows a bridge message like `rename_file`.
|
|
79
90
|
export async function deleteFile(path) {
|
|
80
|
-
await
|
|
81
|
-
}
|
|
82
|
-
async function fileOp(action, body) {
|
|
83
|
-
const res = await fetch(`/__castle/files/${action}`, {
|
|
91
|
+
const res = await fetch("/__castle/files/delete", {
|
|
84
92
|
method: "POST",
|
|
85
93
|
headers: { "content-type": "application/json" },
|
|
86
|
-
body: JSON.stringify(
|
|
94
|
+
body: JSON.stringify({ path }),
|
|
87
95
|
});
|
|
88
96
|
if (res.ok)
|
|
89
97
|
return;
|
|
90
98
|
const detail = await res.text().catch(() => "");
|
|
91
|
-
throw new Error(`castle:
|
|
99
|
+
throw new Error(`castle: delete failed (${res.status}) ${detail}`.trim());
|
|
92
100
|
}
|
|
93
101
|
// Ask the editor shell to open (or focus) an editor for `path`. This is how a
|
|
94
102
|
// kit editor hands the creator off to another file -- the creation modal
|
|
@@ -840,7 +848,8 @@ function handleLocalMessage(msg) {
|
|
|
840
848
|
else if (msg.type === "files_changed") {
|
|
841
849
|
dispatchFilesChanged(msg);
|
|
842
850
|
}
|
|
843
|
-
else if (msg.type === "write_file_response"
|
|
851
|
+
else if (msg.type === "write_file_response" ||
|
|
852
|
+
msg.type === "rename_file_response") {
|
|
844
853
|
resolveLocalRequest(msg);
|
|
845
854
|
}
|
|
846
855
|
else if (msg.type === "castle_command_response") {
|