castle-web-sdk 0.4.23 → 0.4.25

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
@@ -4,8 +4,10 @@
4
4
  platform — for example, a deck can save per-player data, post and read
5
5
  scores on a leaderboard, or get a server-synced time for daily content.
6
6
 
7
- Comes with `castle-web init`. Import what you need from
8
- `castle-web-sdk`:
7
+ Comes with every deck: the SDK is a file of the `base` kit each deck
8
+ imports (`imports/castle.base/sdk/`), and the bare specifier resolves
9
+ there — nothing to install, and no deck `package.json` names it. Import
10
+ what you need from `castle-web-sdk`:
9
11
 
10
12
  ```js
11
13
  import { setup, initCard, Storage, Leaderboard } from "castle-web-sdk";
@@ -52,7 +54,18 @@ metadata distinguishes a server `bye`, a replaced server version, an explicit
52
54
 
53
55
  When Castle selects solo fallback, the returned connection has `isSolo === true`,
54
56
  a one-player roster, and no server process. Calls to `send` are accepted but do
55
- nothing. Client frames are limited to 16KB after JSON serialization.
57
+ nothing. Client→server frames are limited to 16 KB on the wire.
58
+
59
+ ### Binary messages
60
+
61
+ Pass any typed array, `DataView`, or `ArrayBuffer` to client `send`, server `send`,
62
+ or server `broadcast` to send its raw bytes. Receivers always get those bytes as a
63
+ `Uint8Array`; reconstruct another view type such as `Float32Array` on receipt.
64
+ Multi-byte values use the sender platform's byte order. Every other payload uses
65
+ JSON. Client→server messages are capped at 16 KB and server→client messages at
66
+ 64 KB, for both JSON and binary frames. A binary frame includes a one-byte protocol
67
+ tag. A published server bundle on an older runtime throws
68
+ `TypeError: This Castle runtime does not support binary multiplayer messages yet.`
56
69
 
57
70
  Server callbacks are the default export from the deck's server entry. They are
58
71
  session-first and receive only platform-trusted player identities:
@@ -255,7 +268,10 @@ const dailyPuzzle = (date.daysSinceCastleEpoch % 30) + 1;
255
268
  Returns the signed-in player. Throws `CastleError`
256
269
  (`LOGIN_REQUIRED`) when nobody is signed in.
257
270
 
258
- The returned `CastleUser` has `userId`, `username`, and `isActive`.
271
+ The returned `CastleUser` has `userId`, `username`, `isAnonymous`, and `isActive`.
272
+ Anonymous accounts are real logins with generated `anonymous-user-...` names;
273
+ check `isAnonymous` before accepting writes into anything other players see,
274
+ such as a shared gallery.
259
275
 
260
276
  ```js
261
277
  const me = await User.getCurrent();
@@ -136,6 +136,7 @@ export interface CommandResult {
136
136
  user: {
137
137
  userId: string;
138
138
  username: string;
139
+ isAnonymous?: boolean;
139
140
  } | null;
140
141
  };
141
142
  "time.getServerTime": {
@@ -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;
@@ -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 text = serializeClientMessage(data);
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(text);
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
- if (this.socketAttempt !== attempt || this.currentState === "closed")
144
- return;
145
- const frame = parsePlatformFrame(event.data);
146
- if (!frame)
147
- return;
148
- if (frame.t === "hello") {
149
- greeted = true;
150
- clearTimeout(timeout);
151
- this.acceptHello(frame);
152
- resolve();
153
- return;
154
- }
155
- this.handleFrame(frame);
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
+ }
@@ -15,10 +15,13 @@ export interface PlatformSessionConfig {
15
15
  export interface PlatformHandle {
16
16
  send(connId: string, text: string): void;
17
17
  broadcast(text: string): void;
18
+ sendBinary?(connId: string, bytes: Uint8Array | ArrayBuffer): void;
19
+ broadcastBinary?(bytes: Uint8Array | ArrayBuffer): void;
18
20
  disconnect(connId: string, code: string | number): void;
19
21
  on(event: "join", callback: (connId: string, player: PlayerIdentity) => unknown): void;
20
22
  on(event: "leave", callback: (connId: string) => unknown): void;
21
23
  on(event: "message", callback: (connId: string, text: string) => unknown): void;
24
+ on(event: "binaryMessage", callback: (connId: string, bytes: Uint8Array) => unknown): void;
22
25
  on(event: "replaced", callback: () => unknown): void;
23
26
  on(event: "shutdown", callback: (reason?: string) => unknown): void;
24
27
  readonly config: PlatformSessionConfig;
@@ -11,7 +11,9 @@ export interface MultiplayerServerSession {
11
11
  readonly sessionId: string;
12
12
  readonly deckId: string;
13
13
  readonly mode: MultiplayerSessionMode;
14
+ /** Sends bytes as a binary message and all other payloads as JSON. */
14
15
  send(playerId: string, data: unknown): void;
16
+ /** Broadcasts bytes as a binary message and all other payloads as JSON. */
15
17
  broadcast(data: unknown, options?: MultiplayerBroadcastOptions): void;
16
18
  disconnect(playerId: string, reason?: string): void;
17
19
  }
@@ -20,6 +22,7 @@ export interface MultiplayerServerCallbacks {
20
22
  onStart?: (session: MultiplayerServerSession) => CallbackResult;
21
23
  onPlayerJoin?: (session: MultiplayerServerSession, player: MultiplayerServerPlayer) => CallbackResult;
22
24
  onPlayerLeave?: (session: MultiplayerServerSession, player: MultiplayerServerPlayer) => CallbackResult;
25
+ /** Receives binary messages as Uint8Array values and JSON messages otherwise. */
23
26
  onMessage?: (session: MultiplayerServerSession, player: MultiplayerServerPlayer, data: unknown) => CallbackResult;
24
27
  onTick?: (session: MultiplayerServerSession) => CallbackResult;
25
28
  onReplaced?: (session: MultiplayerServerSession) => CallbackResult;
@@ -1,3 +1,4 @@
1
+ import { isBinaryPayload, toUint8Array, } from "../multiplayerProtocol";
1
2
  // The publish bundler binds the author's default callbacks export once and
2
3
  // exports the returned one-argument function as `__castleBootSession`.
3
4
  export function createCastleSessionBoot(callbacks) {
@@ -29,6 +30,17 @@ async function bootSession(platformHandle, callbacks) {
29
30
  return;
30
31
  await callbacks.onMessage?.(session, player, parseHandleMessage(text));
31
32
  });
33
+ try {
34
+ platformHandle.on("binaryMessage", async (connId, bytes) => {
35
+ const player = players.get(connId);
36
+ if (!player)
37
+ return;
38
+ await callbacks.onMessage?.(session, player, bytes);
39
+ });
40
+ }
41
+ catch {
42
+ // Older shims may reject event names they do not recognize.
43
+ }
32
44
  platformHandle.on("replaced", async () => {
33
45
  await callbacks.onReplaced?.(session);
34
46
  });
@@ -54,9 +66,28 @@ function createAuthorSession(platformHandle, players) {
54
66
  deckId: platformHandle.config.deckId,
55
67
  mode: platformHandle.config.mode,
56
68
  send(playerId, data) {
69
+ if (isBinaryPayload(data)) {
70
+ requireSendBinary(platformHandle);
71
+ platformHandle.sendBinary(playerId, toUint8Array(data));
72
+ return;
73
+ }
57
74
  platformHandle.send(playerId, stringifyHandleMessage(data));
58
75
  },
59
76
  broadcast(data, options) {
77
+ if (isBinaryPayload(data)) {
78
+ const bytes = toUint8Array(data);
79
+ if (!options?.except) {
80
+ requireBroadcastBinary(platformHandle);
81
+ platformHandle.broadcastBinary(bytes);
82
+ return;
83
+ }
84
+ requireSendBinary(platformHandle);
85
+ for (const playerId of players.keys()) {
86
+ if (playerId !== options.except)
87
+ platformHandle.sendBinary(playerId, bytes);
88
+ }
89
+ return;
90
+ }
60
91
  const text = stringifyHandleMessage(data);
61
92
  if (!options?.except) {
62
93
  platformHandle.broadcast(text);
@@ -72,6 +103,17 @@ function createAuthorSession(platformHandle, players) {
72
103
  },
73
104
  };
74
105
  }
106
+ function requireSendBinary(platformHandle) {
107
+ if (typeof platformHandle.sendBinary !== "function")
108
+ unsupportedBinaryRuntime();
109
+ }
110
+ function requireBroadcastBinary(platformHandle) {
111
+ if (typeof platformHandle.broadcastBinary !== "function")
112
+ unsupportedBinaryRuntime();
113
+ }
114
+ function unsupportedBinaryRuntime() {
115
+ throw new TypeError("This Castle runtime does not support binary multiplayer messages yet.");
116
+ }
75
117
  function joinedPlayer(connId, identity) {
76
118
  if (typeof connId === "string")
77
119
  return { ...identity, playerId: connId };
package/dist/user.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export interface CastleUser {
2
2
  userId: string;
3
3
  username: string;
4
+ isAnonymous: boolean;
4
5
  isActive: boolean;
5
6
  }
6
7
  export interface CastleUserApi {
package/dist/user.js CHANGED
@@ -24,9 +24,13 @@ async function fetchCurrentUser() {
24
24
  operation,
25
25
  });
26
26
  }
27
+ const username = requiredString(user.username, "user.username", operation);
27
28
  return {
28
29
  userId: requiredString(user.userId, "user.userId", operation),
29
- username: requiredString(user.username, "user.username", operation),
30
+ username,
31
+ // A host that predates the flag omits it; anonymous account usernames are
32
+ // always minted as `anonymous-user-<uuid>`, so the prefix is the fallback.
33
+ isAnonymous: user.isAnonymous === true || username.toLowerCase().startsWith("anonymous-user-"),
30
34
  isActive: true,
31
35
  };
32
36
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "castle-web-sdk",
3
- "version": "0.4.23",
3
+ "version": "0.4.25",
4
4
  "type": "module",
5
5
  "main": "dist/castle.js",
6
6
  "types": "dist/castle.d.ts",