@jgengine/ws 0.10.0 → 0.12.0

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.
@@ -1,14 +1,11 @@
1
- import { decidePoseSync, spawnPresenceState } from "@jgengine/core/multiplayer/presenceModel";
1
+ import { DEFAULT_POSE_SYNC_RULES, decidePoseSync, spawnPresenceState, } from "@jgengine/core/multiplayer/presenceModel";
2
2
  import { createPositionHistory } from "@jgengine/core/multiplayer/lagCompensation";
3
3
  import { createChatRateLimiter, DEFAULT_CHAT_BODY_LENGTH, DEFAULT_CHAT_HISTORY_LIMIT, DEFAULT_CHAT_RATE_LIMIT, } from "@jgengine/core/game/chat";
4
+ import { createCommandMiddleware, } from "./commandMiddleware.js";
4
5
  import { decodeWsClientMessage, encodeWsMessage, inspectWsDecodeFailure, subscriptionKey, } from "./protocol.js";
5
- export const DEFAULT_POSE_RULES = {
6
- maxSpeed: 12,
7
- maxVerticalOffset: 3,
8
- minElapsedSec: 0.05,
9
- maxElapsedSec: 0.5,
10
- keepAliveRefreshMs: 10_000,
11
- };
6
+ export const DEFAULT_POSE_RULES = DEFAULT_POSE_SYNC_RULES;
7
+ /** Cap on frames queued behind a connection's in-flight message; beyond this a flood gets rejected instead of piling up unbounded promises. */
8
+ export const MAX_QUEUED_MESSAGES = 64;
12
9
  function appearanceEqual(a, b) {
13
10
  if (a === b)
14
11
  return true;
@@ -20,6 +17,29 @@ function appearanceEqual(a, b) {
20
17
  return false;
21
18
  return aKeys.every((key) => a[key] === b[key]);
22
19
  }
20
+ function getOrCreate(map, key, factory) {
21
+ let value = map.get(key);
22
+ if (value === undefined) {
23
+ value = factory();
24
+ map.set(key, value);
25
+ }
26
+ return value;
27
+ }
28
+ /**
29
+ * Remove `userId` from every visited `server|scope` bucket, drop buckets that empty out, and notify
30
+ * per changed key. Backs both the disconnect-wide and per-server drops for presence and voice.
31
+ */
32
+ function dropUserFrom(store, userId, shouldVisit, onChanged) {
33
+ for (const [key, inner] of store) {
34
+ if (!shouldVisit(key))
35
+ continue;
36
+ if (!inner.delete(userId))
37
+ continue;
38
+ if (inner.size === 0)
39
+ store.delete(key);
40
+ onChanged(key);
41
+ }
42
+ }
23
43
  export function createHostRouter(options) {
24
44
  const host = options.host;
25
45
  const now = options.now ?? Date.now;
@@ -30,17 +50,33 @@ export function createHostRouter(options) {
30
50
  (allowAnonymous ? ({ userId }) => userId : null);
31
51
  const connections = new Set();
32
52
  const sessionsByUserId = new Map();
53
+ const subscribers = new Map();
54
+ const subscribersOf = (key) => {
55
+ const set = subscribers.get(key);
56
+ return set === undefined ? [] : [...set];
57
+ };
58
+ const addSubscription = (connection, key) => {
59
+ connection.subscriptions.add(key);
60
+ let set = subscribers.get(key);
61
+ if (set === undefined) {
62
+ set = new Set();
63
+ subscribers.set(key, set);
64
+ }
65
+ set.add(connection);
66
+ };
67
+ const removeSubscription = (connection, key) => {
68
+ connection.subscriptions.delete(key);
69
+ const set = subscribers.get(key);
70
+ if (set === undefined)
71
+ return;
72
+ set.delete(connection);
73
+ if (set.size === 0)
74
+ subscribers.delete(key);
75
+ };
33
76
  const presence = new Map();
34
77
  const positionHistoryMs = options.positionHistoryMs ?? 1_000;
35
78
  const histories = new Map();
36
- const historyFor = (serverId) => {
37
- let history = histories.get(serverId);
38
- if (history === undefined) {
39
- history = createPositionHistory({ historyMs: positionHistoryMs });
40
- histories.set(serverId, history);
41
- }
42
- return history;
43
- };
79
+ const historyFor = (serverId) => getOrCreate(histories, serverId, () => createPositionHistory({ historyMs: positionHistoryMs }));
44
80
  const send = (connection, message) => {
45
81
  connection.transport.send(encodeWsMessage(message));
46
82
  };
@@ -63,81 +99,78 @@ export function createHostRouter(options) {
63
99
  appearance: pose.appearance,
64
100
  }));
65
101
  };
66
- const broadcastPresence = (serverId) => {
67
- const key = subscriptionKey("presence", serverId);
68
- const data = presenceRows(serverId);
69
- for (const connection of connections) {
70
- if (!connection.subscriptions.has(key))
71
- continue;
72
- send(connection, { v: 1, t: "update", channel: "presence", serverId, data });
102
+ const broadcast = (key, build) => {
103
+ const targets = subscribersOf(key);
104
+ if (targets.length === 0)
105
+ return;
106
+ const message = build();
107
+ for (const connection of targets)
108
+ send(connection, message);
109
+ };
110
+ const broadcastPresence = (serverId) => broadcast(subscriptionKey("presence", serverId), () => ({
111
+ v: 1,
112
+ t: "update",
113
+ channel: "presence",
114
+ serverId,
115
+ data: presenceRows(serverId),
116
+ }));
117
+ const commandMiddleware = createCommandMiddleware({
118
+ limits: options.limits,
119
+ authorize: options.authorize,
120
+ validate: options.validate,
121
+ });
122
+ const gate = async (connection, id, op, args = {}) => {
123
+ if (connection.userId === null)
124
+ return true;
125
+ const decision = await commandMiddleware.check({
126
+ connection,
127
+ userId: connection.userId,
128
+ op,
129
+ atMs: now(),
130
+ ...args,
131
+ });
132
+ if (!decision.allow) {
133
+ replyError(connection, id, decision.reason);
134
+ return false;
73
135
  }
136
+ return true;
74
137
  };
75
138
  const chatRings = new Map();
76
139
  const chatLimiter = createChatRateLimiter(options.chatRateLimit ?? DEFAULT_CHAT_RATE_LIMIT);
77
140
  const chatHistoryLimit = options.chatHistoryLimit ?? DEFAULT_CHAT_HISTORY_LIMIT;
78
141
  const chatMaxBodyLength = options.chatMaxBodyLength ?? DEFAULT_CHAT_BODY_LENGTH;
79
142
  let chatCounter = 0;
80
- const chatRing = (serverId, channelId) => {
81
- const key = `${serverId}|${channelId}`;
82
- let ring = chatRings.get(key);
83
- if (ring === undefined) {
84
- ring = [];
85
- chatRings.set(key, ring);
86
- }
87
- return ring;
88
- };
89
- const broadcastChat = (serverId, channelId) => {
90
- const key = subscriptionKey("chat", serverId, channelId);
91
- const data = chatRing(serverId, channelId).slice();
92
- for (const connection of connections) {
93
- if (!connection.subscriptions.has(key))
94
- continue;
95
- send(connection, { v: 1, t: "update", channel: "chat", serverId, action: channelId, data });
96
- }
97
- };
143
+ const chatRing = (serverId, channelId) => getOrCreate(chatRings, `${serverId}|${channelId}`, () => []);
144
+ const broadcastChat = (serverId, channelId) => broadcast(subscriptionKey("chat", serverId, channelId), () => ({
145
+ v: 1,
146
+ t: "update",
147
+ channel: "chat",
148
+ serverId,
149
+ action: channelId,
150
+ data: chatRing(serverId, channelId).slice(),
151
+ }));
98
152
  const voiceRosters = new Map();
99
- const voiceRoster = (serverId, channelId) => {
100
- const key = `${serverId}|${channelId}`;
101
- let roster = voiceRosters.get(key);
102
- if (roster === undefined) {
103
- roster = new Map();
104
- voiceRosters.set(key, roster);
105
- }
106
- return roster;
107
- };
153
+ const voiceRoster = (serverId, channelId) => getOrCreate(voiceRosters, `${serverId}|${channelId}`, () => new Map());
108
154
  const voiceParticipants = (serverId, channelId) => [...voiceRoster(serverId, channelId).values()].map((participant) => ({ ...participant }));
109
- const broadcastVoice = (serverId, channelId) => {
110
- const key = subscriptionKey("voice", serverId, channelId);
111
- const data = voiceParticipants(serverId, channelId);
112
- for (const connection of connections) {
113
- if (!connection.subscriptions.has(key))
114
- continue;
115
- send(connection, { v: 1, t: "update", channel: "voice", serverId, action: channelId, data });
116
- }
117
- };
155
+ const broadcastVoice = (serverId, channelId) => broadcast(subscriptionKey("voice", serverId, channelId), () => ({
156
+ v: 1,
157
+ t: "update",
158
+ channel: "voice",
159
+ serverId,
160
+ action: channelId,
161
+ data: voiceParticipants(serverId, channelId),
162
+ }));
118
163
  const dropVoice = (connection) => {
119
164
  if (connection.userId === null)
120
165
  return;
121
- for (const [key, roster] of voiceRosters) {
122
- if (!roster.delete(connection.userId))
123
- continue;
124
- if (roster.size === 0)
125
- voiceRosters.delete(key);
166
+ dropUserFrom(voiceRosters, connection.userId, () => true, (key) => {
126
167
  const [serverId, channelId] = key.split("|");
127
168
  broadcastVoice(serverId, channelId);
128
- }
169
+ });
129
170
  };
130
171
  const dropVoiceForServer = (userId, serverId) => {
131
172
  const prefix = `${serverId}|`;
132
- for (const [key, roster] of voiceRosters) {
133
- if (!key.startsWith(prefix))
134
- continue;
135
- if (!roster.delete(userId))
136
- continue;
137
- if (roster.size === 0)
138
- voiceRosters.delete(key);
139
- broadcastVoice(serverId, key.slice(prefix.length));
140
- }
173
+ dropUserFrom(voiceRosters, userId, (key) => key.startsWith(prefix), (key) => broadcastVoice(serverId, key.slice(prefix.length)));
141
174
  };
142
175
  const requireMembership = async (connection, id, serverId) => {
143
176
  const userId = connection.userId;
@@ -226,24 +259,25 @@ export function createHostRouter(options) {
226
259
  }
227
260
  };
228
261
  const onHostEvent = (event) => {
229
- for (const connection of connections) {
230
- if (connection.userId === null)
231
- continue;
232
- if (event.type === "server") {
233
- if (connection.subscriptions.has(subscriptionKey("server", event.serverId))) {
234
- void pushSubscription(connection, "server", event.serverId);
235
- }
262
+ if (event.type === "server") {
263
+ for (const connection of subscribersOf(subscriptionKey("server", event.serverId))) {
264
+ if (connection.userId === null)
265
+ continue;
266
+ void pushSubscription(connection, "server", event.serverId);
236
267
  }
237
- else if (event.type === "player") {
238
- if (connection.userId === event.userId &&
239
- connection.subscriptions.has(subscriptionKey("player", event.serverId))) {
268
+ }
269
+ else if (event.type === "player") {
270
+ for (const connection of subscribersOf(subscriptionKey("player", event.serverId))) {
271
+ if (connection.userId === event.userId) {
240
272
  void pushSubscription(connection, "player", event.serverId);
241
273
  }
242
274
  }
243
- else {
244
- if (connection.subscriptions.has(subscriptionKey("feed", event.serverId, event.action))) {
245
- void pushSubscription(connection, "feed", event.serverId, event.action);
246
- }
275
+ }
276
+ else {
277
+ for (const connection of subscribersOf(subscriptionKey("feed", event.serverId, event.action))) {
278
+ if (connection.userId === null)
279
+ continue;
280
+ void pushSubscription(connection, "feed", event.serverId, event.action);
247
281
  }
248
282
  }
249
283
  };
@@ -251,6 +285,15 @@ export function createHostRouter(options) {
251
285
  const handlePose = async (connection, serverId, pose) => {
252
286
  if (connection.userId === null)
253
287
  return;
288
+ const gateDecision = await commandMiddleware.check({
289
+ connection,
290
+ userId: connection.userId,
291
+ op: "pose",
292
+ atMs: now(),
293
+ serverId,
294
+ });
295
+ if (!gateDecision.allow)
296
+ return;
254
297
  if (!(await host.isMember({ userId: connection.userId, serverId })))
255
298
  return;
256
299
  const rows = presence.get(serverId) ?? new Map();
@@ -277,21 +320,10 @@ export function createHostRouter(options) {
277
320
  const dropPresence = (connection) => {
278
321
  if (connection.userId === null)
279
322
  return;
280
- for (const [serverId, rows] of presence) {
281
- if (rows.delete(connection.userId)) {
282
- if (rows.size === 0)
283
- presence.delete(serverId);
284
- broadcastPresence(serverId);
285
- }
286
- }
323
+ dropUserFrom(presence, connection.userId, () => true, (serverId) => broadcastPresence(serverId));
287
324
  };
288
325
  const dropPresenceForServer = (userId, serverId) => {
289
- const rows = presence.get(serverId);
290
- if (rows === undefined || !rows.delete(userId))
291
- return;
292
- if (rows.size === 0)
293
- presence.delete(serverId);
294
- broadcastPresence(serverId);
326
+ dropUserFrom(presence, userId, (key) => key === serverId, () => broadcastPresence(serverId));
295
327
  };
296
328
  const handleMessage = async (connection, message) => {
297
329
  if (message.t === "hello") {
@@ -335,17 +367,22 @@ export function createHostRouter(options) {
335
367
  try {
336
368
  switch (message.t) {
337
369
  case "join": {
370
+ if (!(await gate(connection, message.id, "join", { serverId: message.serverId })))
371
+ return;
338
372
  const result = await host.joinServer({
339
373
  userId,
340
374
  gameId: message.gameId,
341
375
  serverId: message.serverId,
342
376
  attributes: message.attributes,
377
+ code: message.code,
343
378
  });
344
379
  connection.joinedServers.add(result.serverId);
345
380
  reply(connection, message.id, result);
346
381
  return;
347
382
  }
348
383
  case "joinByCode": {
384
+ if (!(await gate(connection, message.id, "join", {})))
385
+ return;
349
386
  const result = await host.joinByCode({
350
387
  userId,
351
388
  gameId: message.gameId,
@@ -357,6 +394,8 @@ export function createHostRouter(options) {
357
394
  return;
358
395
  }
359
396
  case "browse": {
397
+ if (!(await gate(connection, message.id, "browse", {})))
398
+ return;
360
399
  const result = await host.browseServers({
361
400
  gameId: message.gameId,
362
401
  filter: message.filter,
@@ -374,6 +413,12 @@ export function createHostRouter(options) {
374
413
  return;
375
414
  }
376
415
  case "runCommand": {
416
+ if (!(await gate(connection, message.id, "runCommand", {
417
+ serverId: message.serverId,
418
+ command: message.command,
419
+ input: message.input,
420
+ })))
421
+ return;
377
422
  const result = await host.runCommand({
378
423
  userId,
379
424
  serverId: message.serverId,
@@ -398,6 +443,8 @@ export function createHostRouter(options) {
398
443
  return;
399
444
  }
400
445
  case "voiceJoin": {
446
+ if (!(await gate(connection, message.id, "voice", { serverId: message.serverId })))
447
+ return;
401
448
  if ((await requireMembership(connection, message.id, message.serverId)) === null)
402
449
  return;
403
450
  const participant = { userId };
@@ -409,6 +456,8 @@ export function createHostRouter(options) {
409
456
  return;
410
457
  }
411
458
  case "voiceLeave": {
459
+ if (!(await gate(connection, message.id, "voice", { serverId: message.serverId })))
460
+ return;
412
461
  if ((await requireMembership(connection, message.id, message.serverId)) === null)
413
462
  return;
414
463
  const roster = voiceRoster(message.serverId, message.channelId);
@@ -419,6 +468,8 @@ export function createHostRouter(options) {
419
468
  return;
420
469
  }
421
470
  case "voicePublish": {
471
+ if (!(await gate(connection, message.id, "voice", { serverId: message.serverId })))
472
+ return;
422
473
  if ((await requireMembership(connection, message.id, message.serverId)) === null)
423
474
  return;
424
475
  const participant = voiceRoster(message.serverId, message.channelId).get(userId);
@@ -439,13 +490,13 @@ export function createHostRouter(options) {
439
490
  replyError(connection, message.id, "Not a member of this server");
440
491
  return;
441
492
  }
442
- connection.subscriptions.add(subscriptionKey(message.channel, message.serverId, message.action));
493
+ addSubscription(connection, subscriptionKey(message.channel, message.serverId, message.action));
443
494
  reply(connection, message.id, null);
444
495
  await pushSubscription(connection, message.channel, message.serverId, message.action);
445
496
  return;
446
497
  }
447
498
  case "unsubscribe": {
448
- connection.subscriptions.delete(subscriptionKey(message.channel, message.serverId, message.action));
499
+ removeSubscription(connection, subscriptionKey(message.channel, message.serverId, message.action));
449
500
  reply(connection, message.id, null);
450
501
  return;
451
502
  }
@@ -473,30 +524,40 @@ export function createHostRouter(options) {
473
524
  subscriptions: new Set(),
474
525
  joinedServers: new Set(),
475
526
  queue: Promise.resolve(),
527
+ queuedMessages: 0,
476
528
  };
477
529
  connections.add(connection);
478
530
  return {
479
531
  handleRaw: (raw) => {
480
- connection.queue = connection.queue
481
- .then(async () => {
482
- const text = typeof raw === "string" ? raw : String(raw);
483
- const message = decodeWsClientMessage(text);
484
- if (message === null) {
485
- const failure = inspectWsDecodeFailure(text);
486
- if (failure.id !== undefined) {
487
- replyError(connection, failure.id, failure.reason);
488
- }
489
- return;
532
+ const text = typeof raw === "string" ? raw : String(raw);
533
+ const message = decodeWsClientMessage(text);
534
+ if (message === null) {
535
+ const failure = inspectWsDecodeFailure(text);
536
+ if (failure.id !== undefined) {
537
+ replyError(connection, failure.id, failure.reason);
490
538
  }
491
- await handleMessage(connection, message);
492
- })
493
- .catch(() => undefined);
539
+ return;
540
+ }
541
+ if (connection.queuedMessages >= MAX_QUEUED_MESSAGES) {
542
+ if ("id" in message)
543
+ replyError(connection, message.id, "Too many pending requests");
544
+ return;
545
+ }
546
+ connection.queuedMessages += 1;
547
+ connection.queue = connection.queue
548
+ .then(() => handleMessage(connection, message))
549
+ .catch(() => undefined)
550
+ .finally(() => {
551
+ connection.queuedMessages -= 1;
552
+ });
494
553
  },
495
554
  close: () => {
496
555
  if (connection.userId !== null && sessionsByUserId.get(connection.userId) === connection) {
497
556
  sessionsByUserId.delete(connection.userId);
498
557
  }
499
558
  connections.delete(connection);
559
+ for (const key of [...connection.subscriptions])
560
+ removeSubscription(connection, key);
500
561
  leaveJoinedServers(connection);
501
562
  dropPresence(connection);
502
563
  dropVoice(connection);
@@ -519,6 +580,7 @@ export function createHostRouter(options) {
519
580
  close: () => {
520
581
  unsubscribeHost();
521
582
  connections.clear();
583
+ subscribers.clear();
522
584
  },
523
585
  };
524
586
  }
package/dist/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ export * from "./commandMiddleware.js";
1
2
  export * from "./createWsBackend.js";
2
3
  export * from "./host.js";
3
4
  export * from "./hostRouter.js";
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ export * from "./commandMiddleware.js";
1
2
  export * from "./createWsBackend.js";
2
3
  export * from "./host.js";
3
4
  export * from "./hostRouter.js";
package/dist/pipe.js CHANGED
@@ -1,3 +1,4 @@
1
+ const OPEN = 1;
1
2
  export function webSocketPipe(url, webSocketFactory = (target) => new WebSocket(target)) {
2
3
  return (handlers) => {
3
4
  const socket = webSocketFactory(url);
@@ -6,7 +7,7 @@ export function webSocketPipe(url, webSocketFactory = (target) => new WebSocket(
6
7
  socket.onclose = () => handlers.onClose();
7
8
  return {
8
9
  send: (data) => {
9
- if (socket.readyState !== 1)
10
+ if (socket.readyState !== OPEN)
10
11
  return;
11
12
  socket.send(data);
12
13
  },
@@ -3,6 +3,16 @@ import type { PlayerPose } from "@jgengine/core/multiplayer/poseSyncGate";
3
3
  import type { SessionAttributes } from "@jgengine/core/runtime/hostPersistence";
4
4
  import type { MatchFilter, SessionListing } from "@jgengine/core/multiplayer/matchmaking";
5
5
  export declare const WS_PROTOCOL_VERSION = 1;
6
+ /** Max length of a `runCommand` command name, in UTF-16 code units. */
7
+ export declare const MAX_COMMAND_LENGTH = 4096;
8
+ /** Max length of a `pushFeed` action name, in UTF-16 code units. */
9
+ export declare const MAX_FEED_ACTION_LENGTH = 256;
10
+ /** Max serialized size of a `pushFeed` entry payload, in bytes. */
11
+ export declare const MAX_FEED_ENTRY_BYTES = 65536;
12
+ /** Max number of keys in a pose `appearance` tag map. */
13
+ export declare const MAX_APPEARANCE_ENTRIES = 32;
14
+ /** Max length of a single `appearance` tag string value, in UTF-16 code units. */
15
+ export declare const MAX_APPEARANCE_VALUE_LENGTH = 256;
6
16
  export type WsChannel = "server" | "player" | "feed" | "presence" | "chat" | "voice";
7
17
  /** Client-set cosmetic/state tags carried alongside a pose (skin, mount, emote, ...). Primitive values only. */
8
18
  export type WsAppearance = Record<string, string | number | boolean>;
@@ -36,6 +46,7 @@ export type WsClientMessage = {
36
46
  gameId: string;
37
47
  serverId?: string;
38
48
  attributes?: SessionAttributes;
49
+ code?: string;
39
50
  } | {
40
51
  v: 1;
41
52
  t: "joinByCode";
@@ -172,12 +183,17 @@ export type WsJoinResult = JoinServerResult;
172
183
  export type WsRunCommandResult = TransportRunCommandResult;
173
184
  export type WsBrowseResult = SessionListing[];
174
185
  export type WsJoinByCodeResult = JoinServerResult | null;
186
+ /** @internal */
175
187
  export declare function encodeWsMessage(message: WsClientMessage | WsServerMessage): string;
176
188
  export type WsDecodeFailure = {
177
189
  reason: string;
178
190
  id?: number;
179
191
  };
192
+ /** @internal */
180
193
  export declare function inspectWsDecodeFailure(raw: unknown): WsDecodeFailure;
194
+ /** @internal */
181
195
  export declare function decodeWsClientMessage(raw: unknown): WsClientMessage | null;
196
+ /** @internal */
182
197
  export declare function decodeWsServerMessage(raw: unknown): WsServerMessage | null;
198
+ /** @internal */
183
199
  export declare function subscriptionKey(channel: WsChannel, serverId: string, action?: string): string;
package/dist/protocol.js CHANGED
@@ -1,4 +1,15 @@
1
1
  export const WS_PROTOCOL_VERSION = 1;
2
+ /** Max length of a `runCommand` command name, in UTF-16 code units. */
3
+ export const MAX_COMMAND_LENGTH = 4_096;
4
+ /** Max length of a `pushFeed` action name, in UTF-16 code units. */
5
+ export const MAX_FEED_ACTION_LENGTH = 256;
6
+ /** Max serialized size of a `pushFeed` entry payload, in bytes. */
7
+ export const MAX_FEED_ENTRY_BYTES = 65_536;
8
+ /** Max number of keys in a pose `appearance` tag map. */
9
+ export const MAX_APPEARANCE_ENTRIES = 32;
10
+ /** Max length of a single `appearance` tag string value, in UTF-16 code units. */
11
+ export const MAX_APPEARANCE_VALUE_LENGTH = 256;
12
+ /** @internal */
2
13
  export function encodeWsMessage(message) {
3
14
  return JSON.stringify(message);
4
15
  }
@@ -32,8 +43,17 @@ function isWsChannel(value) {
32
43
  value === "voice");
33
44
  }
34
45
  function isAppearance(value) {
35
- return (isRecord(value) &&
36
- Object.values(value).every((v) => typeof v === "string" || typeof v === "number" || typeof v === "boolean"));
46
+ if (!isRecord(value))
47
+ return false;
48
+ const entries = Object.values(value);
49
+ if (entries.length > MAX_APPEARANCE_ENTRIES)
50
+ return false;
51
+ return entries.every((v) => (typeof v === "string" && v.length <= MAX_APPEARANCE_VALUE_LENGTH) ||
52
+ typeof v === "number" ||
53
+ typeof v === "boolean");
54
+ }
55
+ function withinFeedEntryBudget(entry) {
56
+ return new TextEncoder().encode(JSON.stringify(entry)).length <= MAX_FEED_ENTRY_BYTES;
37
57
  }
38
58
  function isFiniteNumber(value) {
39
59
  return typeof value === "number" && Number.isFinite(value);
@@ -47,6 +67,7 @@ function isPose(value) {
47
67
  isFiniteNumber(value.rotationPitch) &&
48
68
  (value.appearance === undefined || isAppearance(value.appearance)));
49
69
  }
70
+ /** @internal */
50
71
  export function inspectWsDecodeFailure(raw) {
51
72
  if (typeof raw !== "string")
52
73
  return { reason: "Invalid message framing" };
@@ -66,8 +87,27 @@ export function inspectWsDecodeFailure(raw) {
66
87
  if (typeof parsed.t !== "string") {
67
88
  return { reason: "Invalid message type", id };
68
89
  }
90
+ if (parsed.t === "runCommand" && typeof parsed.command === "string" && parsed.command.length > MAX_COMMAND_LENGTH) {
91
+ return { reason: "Command exceeds max length", id };
92
+ }
93
+ if (parsed.t === "pushFeed" && typeof parsed.action === "string" && parsed.action.length > MAX_FEED_ACTION_LENGTH) {
94
+ return { reason: "Feed action exceeds max length", id };
95
+ }
96
+ if (parsed.t === "pushFeed" && "entry" in parsed && !withinFeedEntryBudget(parsed.entry)) {
97
+ return { reason: "Feed entry exceeds max size", id };
98
+ }
99
+ if (parsed.t === "pose" && isRecord(parsed.pose) && isRecord(parsed.pose.appearance)) {
100
+ const values = Object.values(parsed.pose.appearance);
101
+ if (values.length > MAX_APPEARANCE_ENTRIES) {
102
+ return { reason: "Appearance exceeds max entries", id };
103
+ }
104
+ if (values.some((v) => typeof v === "string" && v.length > MAX_APPEARANCE_VALUE_LENGTH)) {
105
+ return { reason: "Appearance value exceeds max length", id };
106
+ }
107
+ }
69
108
  return { reason: "Malformed message", id };
70
109
  }
110
+ /** @internal */
71
111
  export function decodeWsClientMessage(raw) {
72
112
  const message = parseVersioned(raw);
73
113
  if (message === null)
@@ -82,7 +122,8 @@ export function decodeWsClientMessage(raw) {
82
122
  case "join":
83
123
  return typeof message.id === "number" &&
84
124
  typeof message.gameId === "string" &&
85
- (message.serverId === undefined || typeof message.serverId === "string")
125
+ (message.serverId === undefined || typeof message.serverId === "string") &&
126
+ (message.code === undefined || typeof message.code === "string")
86
127
  ? message
87
128
  : null;
88
129
  case "joinByCode":
@@ -102,13 +143,16 @@ export function decodeWsClientMessage(raw) {
102
143
  case "runCommand":
103
144
  return typeof message.id === "number" &&
104
145
  typeof message.serverId === "string" &&
105
- typeof message.command === "string"
146
+ typeof message.command === "string" &&
147
+ message.command.length <= MAX_COMMAND_LENGTH
106
148
  ? message
107
149
  : null;
108
150
  case "pushFeed":
109
151
  return typeof message.id === "number" &&
110
152
  typeof message.serverId === "string" &&
111
- typeof message.action === "string"
153
+ typeof message.action === "string" &&
154
+ message.action.length <= MAX_FEED_ACTION_LENGTH &&
155
+ withinFeedEntryBudget(message.entry)
112
156
  ? message
113
157
  : null;
114
158
  case "subscribe":
@@ -154,6 +198,7 @@ export function decodeWsClientMessage(raw) {
154
198
  return null;
155
199
  }
156
200
  }
201
+ /** @internal */
157
202
  export function decodeWsServerMessage(raw) {
158
203
  const message = parseVersioned(raw);
159
204
  if (message === null)
@@ -176,6 +221,7 @@ export function decodeWsServerMessage(raw) {
176
221
  return null;
177
222
  }
178
223
  }
224
+ /** @internal */
179
225
  export function subscriptionKey(channel, serverId, action) {
180
226
  return `${channel}|${serverId}|${action ?? ""}`;
181
227
  }
@@ -41,7 +41,8 @@ export function createReadsHandler(options) {
41
41
  return json(await options.listOpenServers({ gameId, limit }));
42
42
  }
43
43
  const records = await (await getPersistence()).listServers(gameId);
44
- return json(toOpenServerListings(records.map((record) => toServerListing(record)), limit));
44
+ const publicRecords = records.filter((record) => (record.visibility ?? "public") !== "private");
45
+ return json(toOpenServerListings(publicRecords.map((record) => toServerListing(record)), limit));
45
46
  }
46
47
  if (head === "leaderboard" && tail !== undefined && segments.length === 2) {
47
48
  const scope = url.searchParams.get("scope") ?? "global";
package/dist/worldHost.js CHANGED
@@ -72,7 +72,7 @@ export function createWorldGameHost(options) {
72
72
  async isMember({ userId, serverId }) {
73
73
  return live.get(serverId)?.session.members().includes(userId) ?? false;
74
74
  },
75
- async getServerView({ serverId }) {
75
+ async getServerView({ userId, serverId }) {
76
76
  const entry = live.get(serverId);
77
77
  if (entry === undefined)
78
78
  return null;
@@ -81,7 +81,7 @@ export function createWorldGameHost(options) {
81
81
  gameId: entry.gameId,
82
82
  revision: entry.session.revision(),
83
83
  memberUserIds: [...entry.session.members()],
84
- serverState: entry.session.runner().snapshot(),
84
+ serverState: entry.session.snapshotFor({ userId }),
85
85
  updatedAt: now(),
86
86
  };
87
87
  },