castle-web-sdk 0.4.24 → 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 +12 -1
- 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/server/platformHandle.d.ts +3 -0
- package/dist/server/wrapper.d.ts +3 -0
- package/dist/server/wrapper.js +42 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -54,7 +54,18 @@ metadata distinguishes a server `bye`, a replaced server version, an explicit
|
|
|
54
54
|
|
|
55
55
|
When Castle selects solo fallback, the returned connection has `isSolo === true`,
|
|
56
56
|
a one-player roster, and no server process. Calls to `send` are accepted but do
|
|
57
|
-
nothing. Client frames are limited to
|
|
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.`
|
|
58
69
|
|
|
59
70
|
Server callbacks are the default export from the deck's server entry. They are
|
|
60
71
|
session-first and receive only platform-trusted player identities:
|
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
|
+
}
|
|
@@ -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;
|
package/dist/server/wrapper.d.ts
CHANGED
|
@@ -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;
|
package/dist/server/wrapper.js
CHANGED
|
@@ -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 };
|