@jgengine/ws 0.10.0 → 0.11.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.
package/README.md CHANGED
@@ -1,3 +1,3 @@
1
1
  # @jgengine/ws
2
2
 
3
- Browser-safe WebSocket client backend for [JGengine](https://github.com/Noisemaker111/jgengine): protocol codec, `createWsBackend`, `createHttpReads`. AGPL-3.0-only.
3
+ Browser-safe WebSocket client backend for [JGengine](https://github.com/Noisemaker111/jgengine): protocol codec, `createWsBackend`, `createHttpReads`. Apache-2.0.
@@ -0,0 +1,62 @@
1
+ import type { CommandRejection } from "@jgengine/core/commands/commandRegistry";
2
+ /** A host-side op the command middleware pipeline can gate: pose sync, `runCommand`, join/joinByCode, browse, or voice join/leave/publish. */
3
+ export type HostCommandOp = "pose" | "runCommand" | "join" | "browse" | "voice";
4
+ /** A sliding-window rate limit: at most `count` calls per `perMs` window. */
5
+ export type CommandRateLimit = {
6
+ count: number;
7
+ perMs: number;
8
+ };
9
+ /** Per-op rate limits. An op with no entry (or an undefined `limits`) is unlimited. */
10
+ export type CommandLimits = Partial<Record<HostCommandOp, CommandRateLimit>>;
11
+ /** Recommended per-op limits a host can opt into via `limits: DEFAULT_COMMAND_LIMITS`. Rate limiting is off unless `limits` is set. */
12
+ export declare const DEFAULT_COMMAND_LIMITS: CommandLimits;
13
+ /** A declared `runCommand` name's input validator, run before the command reaches the game host. */
14
+ export type CommandCatalogEntry = {
15
+ validate?: (input: unknown) => CommandRejection | null;
16
+ };
17
+ /** Declared `runCommand` names and their input validators. When set, any `runCommand` name absent from this catalog is rejected as unknown. */
18
+ export type CommandCatalog = Record<string, CommandCatalogEntry>;
19
+ /** Per-command authorization hook: return `false` to reject. Defaults to allow-all when omitted. */
20
+ export type CommandAuthorize = (args: {
21
+ userId: string;
22
+ op: HostCommandOp;
23
+ serverId?: string;
24
+ command?: string;
25
+ }) => boolean | Promise<boolean>;
26
+ /** A composable per-connection/per-op sliding-window rate limiter. */
27
+ export type CommandRateLimiter = {
28
+ allow: (connection: object, op: HostCommandOp, atMs: number) => boolean;
29
+ };
30
+ /** Creates a sliding-window rate limiter keyed by connection identity and op; ops absent from `limits` are always allowed. */
31
+ export declare function createCommandRateLimiter(limits: CommandLimits): CommandRateLimiter;
32
+ /** Validates a `runCommand` input against a declared catalog. `undefined` catalog means "no declarations" — everything passes through unchanged. */
33
+ export declare function validateCommandInput(catalog: CommandCatalog | undefined, command: string, input: unknown): CommandRejection | null;
34
+ /** Config for {@link createCommandMiddleware}: the same `limits`/`authorize`/`validate` fields accepted by `HostRouterOptions`. */
35
+ export type CommandMiddlewareOptions = {
36
+ limits?: CommandLimits;
37
+ authorize?: CommandAuthorize;
38
+ validate?: CommandCatalog;
39
+ };
40
+ /** One op attempt to run through the middleware pipeline: which connection, which op, and (for `runCommand`) the command name/input. */
41
+ export type CommandGateArgs = {
42
+ connection: object;
43
+ userId: string;
44
+ op: HostCommandOp;
45
+ atMs: number;
46
+ serverId?: string;
47
+ command?: string;
48
+ input?: unknown;
49
+ };
50
+ /** The pipeline's verdict for one {@link CommandGateArgs}: allowed, or rejected with a client-facing reason. */
51
+ export type CommandGateDecision = {
52
+ allow: true;
53
+ } | {
54
+ allow: false;
55
+ reason: string;
56
+ };
57
+ /** A composable rate-limit → validate → authorize pipeline the host router runs before dispatching pose/runCommand/join/browse/voice ops. Every stage defaults to a no-op, so an unconfigured router behaves exactly as before. */
58
+ export type CommandMiddleware = {
59
+ check: (args: CommandGateArgs) => Promise<CommandGateDecision>;
60
+ };
61
+ /** Builds the composed command middleware pipeline from game-intent config: `limits`, `validate`, `authorize`. */
62
+ export declare function createCommandMiddleware(options: CommandMiddlewareOptions): CommandMiddleware;
@@ -0,0 +1,66 @@
1
+ /** Recommended per-op limits a host can opt into via `limits: DEFAULT_COMMAND_LIMITS`. Rate limiting is off unless `limits` is set. */
2
+ export const DEFAULT_COMMAND_LIMITS = {
3
+ pose: { count: 60, perMs: 1_000 },
4
+ runCommand: { count: 30, perMs: 1_000 },
5
+ join: { count: 5, perMs: 10_000 },
6
+ browse: { count: 10, perMs: 5_000 },
7
+ voice: { count: 20, perMs: 1_000 },
8
+ };
9
+ /** Creates a sliding-window rate limiter keyed by connection identity and op; ops absent from `limits` are always allowed. */
10
+ export function createCommandRateLimiter(limits) {
11
+ const windows = new WeakMap();
12
+ return {
13
+ allow(connection, op, atMs) {
14
+ const limit = limits[op];
15
+ if (limit === undefined)
16
+ return true;
17
+ let perOp = windows.get(connection);
18
+ if (perOp === undefined) {
19
+ perOp = new Map();
20
+ windows.set(connection, perOp);
21
+ }
22
+ const cutoff = atMs - limit.perMs;
23
+ const stamps = (perOp.get(op) ?? []).filter((stamp) => stamp > cutoff);
24
+ if (stamps.length >= limit.count) {
25
+ perOp.set(op, stamps);
26
+ return false;
27
+ }
28
+ stamps.push(atMs);
29
+ perOp.set(op, stamps);
30
+ return true;
31
+ },
32
+ };
33
+ }
34
+ /** Validates a `runCommand` input against a declared catalog. `undefined` catalog means "no declarations" — everything passes through unchanged. */
35
+ export function validateCommandInput(catalog, command, input) {
36
+ if (catalog === undefined)
37
+ return null;
38
+ const declared = catalog[command];
39
+ if (declared === undefined)
40
+ return { reason: `Unknown command: ${command}` };
41
+ return declared.validate?.(input) ?? null;
42
+ }
43
+ /** Builds the composed command middleware pipeline from game-intent config: `limits`, `validate`, `authorize`. */
44
+ export function createCommandMiddleware(options) {
45
+ const limiter = createCommandRateLimiter(options.limits ?? {});
46
+ const catalog = options.validate;
47
+ const authorize = options.authorize;
48
+ return {
49
+ async check({ connection, userId, op, atMs, serverId, command, input }) {
50
+ if (!limiter.allow(connection, op, atMs)) {
51
+ return { allow: false, reason: `Rate limited: ${op}` };
52
+ }
53
+ if (op === "runCommand" && command !== undefined) {
54
+ const rejection = validateCommandInput(catalog, command, input);
55
+ if (rejection !== null)
56
+ return { allow: false, reason: rejection.reason };
57
+ }
58
+ if (authorize !== undefined) {
59
+ const authorized = await authorize({ userId, op, serverId, command });
60
+ if (!authorized)
61
+ return { allow: false, reason: `Not authorized: ${op}` };
62
+ }
63
+ return { allow: true };
64
+ },
65
+ };
66
+ }
@@ -11,6 +11,16 @@ const DEFAULT_POSE_TUNING = {
11
11
  const DEFAULT_RPC_TIMEOUT_MS = 15_000;
12
12
  const DEFAULT_RECONNECT_DELAY_MS = 1_000;
13
13
  const DEFAULT_MAX_RECONNECT_DELAY_MS = 30_000;
14
+ const WS_OP_ID_FIELD = "__jgWsOpId";
15
+ const RESERVED_INPUT_COMMAND = "engine.input";
16
+ function isPlainRecord(value) {
17
+ return typeof value === "object" && value !== null && !Array.isArray(value);
18
+ }
19
+ function stampOpId(command, opId, input) {
20
+ if (command === RESERVED_INPUT_COMMAND || !isPlainRecord(input))
21
+ return input;
22
+ return { ...input, [WS_OP_ID_FIELD]: opId };
23
+ }
14
24
  export function createWsBackend(options) {
15
25
  const now = options.now ?? Date.now;
16
26
  const baseReconnectDelayMs = options.reconnectDelayMs ?? DEFAULT_RECONNECT_DELAY_MS;
@@ -31,6 +41,7 @@ export function createWsBackend(options) {
31
41
  let closed = false;
32
42
  let wantConnection = false;
33
43
  let nextId = 1;
44
+ let nextOpId = 1;
34
45
  let reconnectAttempt = 0;
35
46
  let reconnectTimer = null;
36
47
  const pending = new Map();
@@ -273,6 +284,7 @@ export function createWsBackend(options) {
273
284
  await request((id) => ({ v: 1, t: "leave", id, serverId: args.serverId }));
274
285
  },
275
286
  async runCommand(args) {
287
+ const opId = String(nextOpId++);
276
288
  try {
277
289
  const result = await request((id) => ({
278
290
  v: 1,
@@ -280,7 +292,7 @@ export function createWsBackend(options) {
280
292
  id,
281
293
  serverId: args.serverId,
282
294
  command: args.command,
283
- input: args.input,
295
+ input: stampOpId(args.command, opId, args.input),
284
296
  }));
285
297
  return result;
286
298
  }
package/dist/host.d.ts CHANGED
@@ -32,6 +32,7 @@ export type GameHost = {
32
32
  gameId: string;
33
33
  serverId?: string;
34
34
  attributes?: SessionAttributes;
35
+ code?: string;
35
36
  }) => Promise<JoinServerResult>;
36
37
  browseServers: (args: {
37
38
  gameId: string;
@@ -89,6 +90,8 @@ export type GameHost = {
89
90
  stop: () => Promise<void>;
90
91
  subscribe: (listener: (event: HostChangeEvent) => void) => () => void;
91
92
  };
93
+ /** Max recently-applied `runCommand` op IDs retained per (serverId, userId), oldest evicted first. */
94
+ export declare const OP_LEDGER_LIMIT = 64;
92
95
  /** Creates a `GameHost` that runs game servers over the given persistence and runtimes. */
93
96
  export declare function createGameHost(options: GameHostOptions): GameHost;
94
97
  /** Creates an in-memory `HostPersistence` implementation, useful for tests and ephemeral hosts. */
package/dist/host.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { createGameRuntime } from "@jgengine/core/runtime/gameRuntime";
2
2
  import { applyLeaderboardRows, buildHydratePlayers, planServerPersist, profileLeaderboardStats, shouldAutoSave, topLeaderboardRows, toOpenServerListings, toServerListing, trimFeedEntries, } from "@jgengine/core/runtime/hostPersistence";
3
- import { browseSessions, findByJoinCode } from "@jgengine/core/multiplayer/matchmaking";
3
+ import { browseSessions, findByJoinCode, normalizeJoinCode } from "@jgengine/core/multiplayer/matchmaking";
4
4
  import { createFeedWriteGate, validateFeedWrite, } from "@jgengine/core/multiplayer/feedWriteGate";
5
5
  import { clearDirtyFlags, createEmptyServerRow, splitProfilePlayer } from "@jgengine/core/runtime/snapshot";
6
6
  const builtinCommands = {
@@ -9,6 +9,22 @@ const builtinCommands = {
9
9
  apply: (snapshot) => snapshot,
10
10
  },
11
11
  };
12
+ /** Max recently-applied `runCommand` op IDs retained per (serverId, userId), oldest evicted first. */
13
+ export const OP_LEDGER_LIMIT = 64;
14
+ const WS_OP_ID_FIELD = "__jgWsOpId";
15
+ function unwrapOpEnvelope(input) {
16
+ if (typeof input !== "object" || input === null || Array.isArray(input))
17
+ return null;
18
+ const record = input;
19
+ const opId = record[WS_OP_ID_FIELD];
20
+ if (typeof opId !== "string")
21
+ return null;
22
+ const { [WS_OP_ID_FIELD]: _opId, ...rest } = record;
23
+ return { opId, input: rest };
24
+ }
25
+ function opLedgerKey(serverId, userId) {
26
+ return `${serverId}|${userId}`;
27
+ }
12
28
  /** Creates a `GameHost` that runs game servers over the given persistence and runtimes. */
13
29
  export function createGameHost(options) {
14
30
  const now = options.now ?? Date.now;
@@ -35,10 +51,30 @@ export function createGameHost(options) {
35
51
  for (const listener of listeners)
36
52
  listener(event);
37
53
  };
38
- let queue = Promise.resolve();
39
- const enqueue = (operation) => {
40
- const run = queue.then(operation);
41
- queue = run.catch(() => undefined);
54
+ const opLedgers = new Map();
55
+ const findAppliedOp = (serverId, userId, opId) => opLedgers.get(opLedgerKey(serverId, userId))?.find((entry) => entry.opId === opId)?.result;
56
+ const rememberAppliedOp = (serverId, userId, opId, result) => {
57
+ const key = opLedgerKey(serverId, userId);
58
+ const entries = opLedgers.get(key) ?? [];
59
+ entries.push({ opId, result });
60
+ if (entries.length > OP_LEDGER_LIMIT)
61
+ entries.shift();
62
+ opLedgers.set(key, entries);
63
+ };
64
+ const forgetOpLedger = (serverId, userId) => {
65
+ opLedgers.delete(opLedgerKey(serverId, userId));
66
+ };
67
+ const roomQueues = new Map();
68
+ const enqueueRoom = (roomId, operation) => {
69
+ const previous = roomQueues.get(roomId) ?? Promise.resolve();
70
+ const run = previous.then(operation);
71
+ const settled = run.catch(() => undefined);
72
+ roomQueues.set(roomId, settled);
73
+ void settled.finally(() => {
74
+ if (roomQueues.get(roomId) === settled) {
75
+ roomQueues.delete(roomId);
76
+ }
77
+ });
42
78
  return run;
43
79
  };
44
80
  const hydrate = async (record) => {
@@ -111,34 +147,39 @@ export function createGameHost(options) {
111
147
  };
112
148
  };
113
149
  let interval = null;
114
- const tickOnce = () => enqueue(async () => {
150
+ const tickOnce = async () => {
115
151
  const timestamp = now();
116
- let ticked = 0;
117
- let saved = 0;
118
- for (const entry of live.values()) {
152
+ const outcomes = await Promise.all([...live.values()].map((entry) => enqueueRoom(entry.record.serverId, async () => {
119
153
  if (entry.record.status !== "running")
120
- continue;
154
+ return { ticked: 0, saved: 0 };
121
155
  if (entry.record.memberUserIds.length === 0)
122
- continue;
156
+ return { ticked: 0, saved: 0 };
123
157
  const elapsedMs = timestamp - entry.record.tickAnchorMs;
124
158
  if (elapsedMs < tickMs)
125
- continue;
159
+ return { ticked: 0, saved: 0 };
126
160
  const runtime = resolveRuntime(entry.record.gameId);
127
161
  const before = entry.snapshot.revision;
128
162
  entry.snapshot = runtime.tick(entry.snapshot, elapsedMs / 1_000);
129
163
  entry.record = { ...entry.record, tickAnchorMs: timestamp, updatedAt: timestamp };
130
- ticked += 1;
131
164
  if (entry.snapshot.revision !== before) {
132
165
  markMutated(entry);
133
166
  emit({ type: "server", serverId: entry.record.serverId });
134
167
  }
168
+ let saved = 0;
135
169
  if (shouldAutoSave(entry.record.save, entry.record.dirtyAt, entry.record.lastSavedAt, timestamp)) {
136
170
  await flushServer(entry);
137
- saved += 1;
171
+ saved = 1;
138
172
  }
173
+ return { ticked: 1, saved };
174
+ })));
175
+ let ticked = 0;
176
+ let saved = 0;
177
+ for (const outcome of outcomes) {
178
+ ticked += outcome.ticked;
179
+ saved += outcome.saved;
139
180
  }
140
181
  return { ticked, saved };
141
- });
182
+ };
142
183
  const collectListings = async (gameId, options = {}) => {
143
184
  const byId = new Map();
144
185
  for (const record of await persistence.listServers(gameId)) {
@@ -166,69 +207,88 @@ export function createGameHost(options) {
166
207
  }));
167
208
  };
168
209
  const host = {
169
- joinServer: (args) => enqueue(async () => {
170
- const timestamp = now();
171
- const runtime = resolveRuntime(args.gameId);
172
- let entry = args.serverId === undefined ? null : await getLive(args.serverId);
173
- if (args.serverId !== undefined && entry === null) {
174
- throw new Error("Server not found");
175
- }
176
- if (entry !== null && entry.record.gameId !== args.gameId) {
177
- throw new Error("Server belongs to a different game");
178
- }
179
- if (entry === null) {
180
- const attributes = args.attributes;
181
- const record = {
182
- serverId: createServerId(),
183
- gameId: args.gameId,
210
+ joinServer: (args) => {
211
+ const joinOperation = async () => {
212
+ const timestamp = now();
213
+ const runtime = resolveRuntime(args.gameId);
214
+ let entry = args.serverId === undefined ? null : await getLive(args.serverId);
215
+ if (args.serverId !== undefined && entry === null) {
216
+ throw new Error("Server not found");
217
+ }
218
+ if (entry !== null && entry.record.gameId !== args.gameId) {
219
+ throw new Error("Server belongs to a different game");
220
+ }
221
+ if (entry !== null &&
222
+ entry.record.visibility === "private" &&
223
+ !entry.record.memberUserIds.includes(args.userId)) {
224
+ const requiredCode = entry.record.joinCode;
225
+ const matches = requiredCode !== undefined &&
226
+ args.code !== undefined &&
227
+ normalizeJoinCode(args.code) === normalizeJoinCode(requiredCode);
228
+ if (!matches) {
229
+ throw new Error("Server is private");
230
+ }
231
+ }
232
+ if (entry === null) {
233
+ const attributes = args.attributes;
234
+ const record = {
235
+ serverId: createServerId(),
236
+ gameId: args.gameId,
237
+ status: "running",
238
+ memberUserIds: [],
239
+ slotsPerServer: defaultSlots,
240
+ save: runtime.save,
241
+ serverState: createEmptyServerRow(),
242
+ sessionPlayers: {},
243
+ revision: 0,
244
+ tickAnchorMs: timestamp,
245
+ createdAt: timestamp,
246
+ updatedAt: timestamp,
247
+ ...(attributes?.label !== undefined ? { label: attributes.label } : {}),
248
+ ...(attributes?.mode !== undefined ? { mode: attributes.mode } : {}),
249
+ ...(attributes?.visibility !== undefined ? { visibility: attributes.visibility } : {}),
250
+ ...(attributes?.joinCode !== undefined ? { joinCode: attributes.joinCode } : {}),
251
+ ...(attributes?.tags !== undefined ? { tags: attributes.tags } : {}),
252
+ };
253
+ entry = await hydrate(record);
254
+ }
255
+ const { record } = entry;
256
+ if (record.memberUserIds.length >= record.slotsPerServer &&
257
+ !record.memberUserIds.includes(args.userId)) {
258
+ throw new Error("Server is full");
259
+ }
260
+ const profile = await persistence.loadProfile({ userId: args.userId, gameId: args.gameId });
261
+ const isNew = profile === null;
262
+ entry.record = {
263
+ ...record,
264
+ memberUserIds: record.memberUserIds.includes(args.userId)
265
+ ? record.memberUserIds
266
+ : [...record.memberUserIds, args.userId],
184
267
  status: "running",
185
- memberUserIds: [],
186
- slotsPerServer: defaultSlots,
187
- save: runtime.save,
188
- serverState: createEmptyServerRow(),
189
- sessionPlayers: {},
190
- revision: 0,
191
- tickAnchorMs: timestamp,
192
- createdAt: timestamp,
193
268
  updatedAt: timestamp,
194
- ...(attributes?.label !== undefined ? { label: attributes.label } : {}),
195
- ...(attributes?.mode !== undefined ? { mode: attributes.mode } : {}),
196
- ...(attributes?.visibility !== undefined ? { visibility: attributes.visibility } : {}),
197
- ...(attributes?.joinCode !== undefined ? { joinCode: attributes.joinCode } : {}),
198
- ...(attributes?.tags !== undefined ? { tags: attributes.tags } : {}),
269
+ dirtyAt: timestamp,
199
270
  };
200
- entry = await hydrate(record);
201
- }
202
- const { record } = entry;
203
- if (record.memberUserIds.length >= record.slotsPerServer &&
204
- !record.memberUserIds.includes(args.userId)) {
205
- throw new Error("Server is full");
206
- }
207
- const profile = await persistence.loadProfile({ userId: args.userId, gameId: args.gameId });
208
- const isNew = profile === null;
209
- entry.record = {
210
- ...record,
211
- memberUserIds: record.memberUserIds.includes(args.userId)
212
- ? record.memberUserIds
213
- : [...record.memberUserIds, args.userId],
214
- status: "running",
215
- updatedAt: timestamp,
216
- dirtyAt: timestamp,
271
+ entry.snapshot = runtime.joinPlayer(entry.snapshot, args.userId, isNew);
272
+ await flushServer(entry);
273
+ emit({ type: "server", serverId: entry.record.serverId });
274
+ emit({ type: "player", serverId: entry.record.serverId, userId: args.userId });
275
+ return { serverId: entry.record.serverId, isNew };
217
276
  };
218
- entry.snapshot = runtime.joinPlayer(entry.snapshot, args.userId, isNew);
219
- await flushServer(entry);
220
- emit({ type: "server", serverId: entry.record.serverId });
221
- emit({ type: "player", serverId: entry.record.serverId, userId: args.userId });
222
- return { serverId: entry.record.serverId, isNew };
223
- }),
277
+ return args.serverId === undefined ? joinOperation() : enqueueRoom(args.serverId, joinOperation);
278
+ },
224
279
  browseServers: async (args) => browseSessions(await collectListings(args.gameId), args.filter ?? {}, { limit: args.limit }),
225
280
  joinByCode: async (args) => {
226
281
  const match = findByJoinCode(await collectListings(args.gameId, { includeJoinCode: true }), args.code);
227
282
  if (match === null)
228
283
  return null;
229
- return host.joinServer({ userId: args.userId, gameId: args.gameId, serverId: match.serverId });
284
+ return host.joinServer({
285
+ userId: args.userId,
286
+ gameId: args.gameId,
287
+ serverId: match.serverId,
288
+ code: args.code,
289
+ });
230
290
  },
231
- leaveServer: (args) => enqueue(async () => {
291
+ leaveServer: (args) => enqueueRoom(args.serverId, async () => {
232
292
  const entry = await getLive(args.serverId);
233
293
  if (entry === null)
234
294
  return;
@@ -250,12 +310,13 @@ export function createGameHost(options) {
250
310
  };
251
311
  entry.snapshot = { ...entry.snapshot, players };
252
312
  await persistence.saveServer(entry.record);
313
+ forgetOpLedger(args.serverId, args.userId);
253
314
  if (memberUserIds.length === 0) {
254
315
  live.delete(entry.record.serverId);
255
316
  }
256
317
  emit({ type: "server", serverId: args.serverId });
257
318
  }),
258
- runCommand: (args) => enqueue(async () => {
319
+ runCommand: (args) => enqueueRoom(args.serverId, async () => {
259
320
  const entry = await getLive(args.serverId);
260
321
  if (entry === null) {
261
322
  return { ok: false, reason: "Server not found" };
@@ -263,16 +324,28 @@ export function createGameHost(options) {
263
324
  if (!entry.record.memberUserIds.includes(args.userId)) {
264
325
  return { ok: false, reason: "Not a member of this server" };
265
326
  }
327
+ const envelope = unwrapOpEnvelope(args.input);
328
+ if (envelope !== null) {
329
+ const applied = findAppliedOp(args.serverId, args.userId, envelope.opId);
330
+ if (applied !== undefined)
331
+ return applied;
332
+ }
333
+ const commandInput = envelope !== null ? envelope.input : args.input;
266
334
  const runtime = resolveRuntime(entry.record.gameId);
267
- const result = runtime.runCommand(entry.snapshot, args.userId, args.command, args.input);
268
- if (!result.ok) {
269
- return { ok: false, reason: result.reason };
335
+ const result = runtime.runCommand(entry.snapshot, args.userId, args.command, commandInput);
336
+ const outcome = result.ok
337
+ ? { ok: true }
338
+ : { ok: false, reason: result.reason };
339
+ if (result.ok) {
340
+ entry.snapshot = result.snapshot;
341
+ markMutated(entry);
342
+ emit({ type: "server", serverId: args.serverId });
343
+ emit({ type: "player", serverId: args.serverId, userId: args.userId });
270
344
  }
271
- entry.snapshot = result.snapshot;
272
- markMutated(entry);
273
- emit({ type: "server", serverId: args.serverId });
274
- emit({ type: "player", serverId: args.serverId, userId: args.userId });
275
- return { ok: true };
345
+ if (envelope !== null) {
346
+ rememberAppliedOp(args.serverId, args.userId, envelope.opId, outcome);
347
+ }
348
+ return outcome;
276
349
  }),
277
350
  isMember: async (args) => {
278
351
  const entry = await getLive(args.serverId);
@@ -328,7 +401,7 @@ export function createGameHost(options) {
328
401
  return [];
329
402
  return persistence.loadFeed({ serverId: args.serverId, action: args.action });
330
403
  },
331
- pushFeedEntry: (args) => enqueue(async () => {
404
+ pushFeedEntry: (args) => enqueueRoom(args.serverId, async () => {
332
405
  const allowed = validateFeedWrite(feedWriteGate, args.action);
333
406
  if (!allowed.ok) {
334
407
  throw new Error(allowed.reason);
@@ -343,26 +416,29 @@ export function createGameHost(options) {
343
416
  listOpenServers: async (args) => {
344
417
  const byId = new Map();
345
418
  for (const record of await persistence.listServers(args.gameId)) {
419
+ if ((record.visibility ?? "public") === "private")
420
+ continue;
346
421
  byId.set(record.serverId, toServerListing(record));
347
422
  }
348
423
  for (const entry of live.values()) {
349
424
  if (entry.record.gameId !== args.gameId)
350
425
  continue;
426
+ if ((entry.record.visibility ?? "public") === "private")
427
+ continue;
351
428
  byId.set(entry.record.serverId, toServerListing(entry.record));
352
429
  }
353
430
  return toOpenServerListings(byId.values(), args.limit);
354
431
  },
355
432
  tickOnce,
356
- flushAll: () => enqueue(async () => {
357
- let saved = 0;
358
- for (const entry of live.values()) {
433
+ flushAll: async () => {
434
+ const outcomes = await Promise.all([...live.values()].map((entry) => enqueueRoom(entry.record.serverId, async () => {
359
435
  if (entry.record.dirtyAt === undefined)
360
- continue;
436
+ return 0;
361
437
  await flushServer(entry);
362
- saved += 1;
363
- }
364
- return saved;
365
- }),
438
+ return 1;
439
+ })));
440
+ return outcomes.reduce((total, saved) => total + saved, 0);
441
+ },
366
442
  start: () => {
367
443
  if (interval !== null)
368
444
  return;
@@ -375,13 +451,12 @@ export function createGameHost(options) {
375
451
  clearInterval(interval);
376
452
  interval = null;
377
453
  }
378
- await enqueue(async () => {
379
- for (const entry of live.values()) {
380
- if (entry.record.dirtyAt === undefined)
381
- continue;
382
- await flushServer(entry);
383
- }
384
- });
454
+ await Promise.all(roomQueues.values());
455
+ await Promise.all([...live.values()].map((entry) => enqueueRoom(entry.record.serverId, async () => {
456
+ if (entry.record.dirtyAt === undefined)
457
+ return;
458
+ await flushServer(entry);
459
+ })));
385
460
  },
386
461
  subscribe: (listener) => {
387
462
  listeners.add(listener);
@@ -1,5 +1,6 @@
1
1
  import type { PoseSyncRules } from "@jgengine/core/multiplayer/presenceModel";
2
2
  import { type ChatRateLimit } from "@jgengine/core/game/chat";
3
+ import { type CommandAuthorize, type CommandCatalog, type CommandLimits } from "./commandMiddleware.js";
3
4
  import type { GameHost } from "./host.js";
4
5
  import type { TransportPipeFactory } from "./pipe.js";
5
6
  export type HostRouterAuthenticate = (args: {
@@ -16,6 +17,12 @@ export type HostRouterOptions = {
16
17
  chatRateLimit?: ChatRateLimit;
17
18
  chatHistoryLimit?: number;
18
19
  chatMaxBodyLength?: number;
20
+ /** Per-op rate limits for pose/runCommand/join/browse/voice. Omit (default) for no rate limiting; pass {@link DEFAULT_COMMAND_LIMITS} or a custom {@link CommandLimits} to opt in. */
21
+ limits?: CommandLimits;
22
+ /** Per-command authorization hook; defaults to allow-all. */
23
+ authorize?: CommandAuthorize;
24
+ /** Declared `runCommand` names and input validators. Omit (default) to pass every command through unchanged; set to reject unknown command names and validate declared ones. */
25
+ validate?: CommandCatalog;
19
26
  now?: () => number;
20
27
  };
21
28
  export type HostRouterTransport = {
@@ -42,5 +49,7 @@ export type HostRouter = {
42
49
  close: () => void;
43
50
  };
44
51
  export declare const DEFAULT_POSE_RULES: PoseSyncRules;
52
+ /** Cap on frames queued behind a connection's in-flight message; beyond this a flood gets rejected instead of piling up unbounded promises. */
53
+ export declare const MAX_QUEUED_MESSAGES = 64;
45
54
  export declare function createHostRouter(options: HostRouterOptions): HostRouter;
46
55
  export declare function loopbackPipe(router: HostRouter): TransportPipeFactory;