@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.
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,5 +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 { isListablePublicly, isPrivateJoinBlocked, isServerFull, isServerMember, statusAfterLeave, withJoinedMember, withoutMember, } from "@jgengine/core/runtime/hostPolicy";
3
4
  import { browseSessions, findByJoinCode } from "@jgengine/core/multiplayer/matchmaking";
4
5
  import { createFeedWriteGate, validateFeedWrite, } from "@jgengine/core/multiplayer/feedWriteGate";
5
6
  import { clearDirtyFlags, createEmptyServerRow, splitProfilePlayer } from "@jgengine/core/runtime/snapshot";
@@ -9,6 +10,22 @@ const builtinCommands = {
9
10
  apply: (snapshot) => snapshot,
10
11
  },
11
12
  };
13
+ /** Max recently-applied `runCommand` op IDs retained per (serverId, userId), oldest evicted first. */
14
+ export const OP_LEDGER_LIMIT = 64;
15
+ const WS_OP_ID_FIELD = "__jgWsOpId";
16
+ function unwrapOpEnvelope(input) {
17
+ if (typeof input !== "object" || input === null || Array.isArray(input))
18
+ return null;
19
+ const record = input;
20
+ const opId = record[WS_OP_ID_FIELD];
21
+ if (typeof opId !== "string")
22
+ return null;
23
+ const { [WS_OP_ID_FIELD]: _opId, ...rest } = record;
24
+ return { opId, input: rest };
25
+ }
26
+ function opLedgerKey(serverId, userId) {
27
+ return `${serverId}|${userId}`;
28
+ }
12
29
  /** Creates a `GameHost` that runs game servers over the given persistence and runtimes. */
13
30
  export function createGameHost(options) {
14
31
  const now = options.now ?? Date.now;
@@ -35,10 +52,30 @@ export function createGameHost(options) {
35
52
  for (const listener of listeners)
36
53
  listener(event);
37
54
  };
38
- let queue = Promise.resolve();
39
- const enqueue = (operation) => {
40
- const run = queue.then(operation);
41
- queue = run.catch(() => undefined);
55
+ const opLedgers = new Map();
56
+ const findAppliedOp = (serverId, userId, opId) => opLedgers.get(opLedgerKey(serverId, userId))?.find((entry) => entry.opId === opId)?.result;
57
+ const rememberAppliedOp = (serverId, userId, opId, result) => {
58
+ const key = opLedgerKey(serverId, userId);
59
+ const entries = opLedgers.get(key) ?? [];
60
+ entries.push({ opId, result });
61
+ if (entries.length > OP_LEDGER_LIMIT)
62
+ entries.shift();
63
+ opLedgers.set(key, entries);
64
+ };
65
+ const forgetOpLedger = (serverId, userId) => {
66
+ opLedgers.delete(opLedgerKey(serverId, userId));
67
+ };
68
+ const roomQueues = new Map();
69
+ const enqueueRoom = (roomId, operation) => {
70
+ const previous = roomQueues.get(roomId) ?? Promise.resolve();
71
+ const run = previous.then(operation);
72
+ const settled = run.catch(() => undefined);
73
+ roomQueues.set(roomId, settled);
74
+ void settled.finally(() => {
75
+ if (roomQueues.get(roomId) === settled) {
76
+ roomQueues.delete(roomId);
77
+ }
78
+ });
42
79
  return run;
43
80
  };
44
81
  const hydrate = async (record) => {
@@ -111,34 +148,39 @@ export function createGameHost(options) {
111
148
  };
112
149
  };
113
150
  let interval = null;
114
- const tickOnce = () => enqueue(async () => {
151
+ const tickOnce = async () => {
115
152
  const timestamp = now();
116
- let ticked = 0;
117
- let saved = 0;
118
- for (const entry of live.values()) {
153
+ const outcomes = await Promise.all([...live.values()].map((entry) => enqueueRoom(entry.record.serverId, async () => {
119
154
  if (entry.record.status !== "running")
120
- continue;
155
+ return { ticked: 0, saved: 0 };
121
156
  if (entry.record.memberUserIds.length === 0)
122
- continue;
157
+ return { ticked: 0, saved: 0 };
123
158
  const elapsedMs = timestamp - entry.record.tickAnchorMs;
124
159
  if (elapsedMs < tickMs)
125
- continue;
160
+ return { ticked: 0, saved: 0 };
126
161
  const runtime = resolveRuntime(entry.record.gameId);
127
162
  const before = entry.snapshot.revision;
128
163
  entry.snapshot = runtime.tick(entry.snapshot, elapsedMs / 1_000);
129
164
  entry.record = { ...entry.record, tickAnchorMs: timestamp, updatedAt: timestamp };
130
- ticked += 1;
131
165
  if (entry.snapshot.revision !== before) {
132
166
  markMutated(entry);
133
167
  emit({ type: "server", serverId: entry.record.serverId });
134
168
  }
169
+ let saved = 0;
135
170
  if (shouldAutoSave(entry.record.save, entry.record.dirtyAt, entry.record.lastSavedAt, timestamp)) {
136
171
  await flushServer(entry);
137
- saved += 1;
172
+ saved = 1;
138
173
  }
174
+ return { ticked: 1, saved };
175
+ })));
176
+ let ticked = 0;
177
+ let saved = 0;
178
+ for (const outcome of outcomes) {
179
+ ticked += outcome.ticked;
180
+ saved += outcome.saved;
139
181
  }
140
182
  return { ticked, saved };
141
- });
183
+ };
142
184
  const collectListings = async (gameId, options = {}) => {
143
185
  const byId = new Map();
144
186
  for (const record of await persistence.listServers(gameId)) {
@@ -166,77 +208,92 @@ export function createGameHost(options) {
166
208
  }));
167
209
  };
168
210
  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,
211
+ joinServer: (args) => {
212
+ const joinOperation = async () => {
213
+ const timestamp = now();
214
+ const runtime = resolveRuntime(args.gameId);
215
+ let entry = args.serverId === undefined ? null : await getLive(args.serverId);
216
+ if (args.serverId !== undefined && entry === null) {
217
+ throw new Error("Server not found");
218
+ }
219
+ if (entry !== null && entry.record.gameId !== args.gameId) {
220
+ throw new Error("Server belongs to a different game");
221
+ }
222
+ if (entry !== null &&
223
+ isPrivateJoinBlocked({
224
+ visibility: entry.record.visibility,
225
+ memberUserIds: entry.record.memberUserIds,
226
+ userId: args.userId,
227
+ joinCode: entry.record.joinCode,
228
+ suppliedCode: args.code,
229
+ })) {
230
+ throw new Error("Server is private");
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 (isServerFull(record.memberUserIds, record.slotsPerServer, args.userId)) {
257
+ throw new Error("Server is full");
258
+ }
259
+ const profile = await persistence.loadProfile({ userId: args.userId, gameId: args.gameId });
260
+ const isNew = profile === null;
261
+ entry.record = {
262
+ ...record,
263
+ memberUserIds: withJoinedMember(record.memberUserIds, args.userId),
184
264
  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
265
  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 } : {}),
266
+ dirtyAt: timestamp,
199
267
  };
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,
268
+ entry.snapshot = runtime.joinPlayer(entry.snapshot, args.userId, isNew);
269
+ await flushServer(entry);
270
+ emit({ type: "server", serverId: entry.record.serverId });
271
+ emit({ type: "player", serverId: entry.record.serverId, userId: args.userId });
272
+ return { serverId: entry.record.serverId, isNew };
217
273
  };
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
- }),
274
+ return args.serverId === undefined ? joinOperation() : enqueueRoom(args.serverId, joinOperation);
275
+ },
224
276
  browseServers: async (args) => browseSessions(await collectListings(args.gameId), args.filter ?? {}, { limit: args.limit }),
225
277
  joinByCode: async (args) => {
226
278
  const match = findByJoinCode(await collectListings(args.gameId, { includeJoinCode: true }), args.code);
227
279
  if (match === null)
228
280
  return null;
229
- return host.joinServer({ userId: args.userId, gameId: args.gameId, serverId: match.serverId });
281
+ return host.joinServer({
282
+ userId: args.userId,
283
+ gameId: args.gameId,
284
+ serverId: match.serverId,
285
+ code: args.code,
286
+ });
230
287
  },
231
- leaveServer: (args) => enqueue(async () => {
288
+ leaveServer: (args) => enqueueRoom(args.serverId, async () => {
232
289
  const entry = await getLive(args.serverId);
233
290
  if (entry === null)
234
291
  return;
235
- if (!entry.record.memberUserIds.includes(args.userId))
292
+ if (!isServerMember(entry.record.memberUserIds, args.userId))
236
293
  return;
237
294
  await flushServer(entry);
238
295
  const timestamp = now();
239
- const memberUserIds = entry.record.memberUserIds.filter((id) => id !== args.userId);
296
+ const memberUserIds = withoutMember(entry.record.memberUserIds, args.userId);
240
297
  const sessionPlayers = { ...entry.record.sessionPlayers };
241
298
  delete sessionPlayers[args.userId];
242
299
  const players = { ...entry.snapshot.players };
@@ -246,45 +303,58 @@ export function createGameHost(options) {
246
303
  memberUserIds,
247
304
  sessionPlayers,
248
305
  updatedAt: timestamp,
249
- status: memberUserIds.length === 0 ? "open" : entry.record.status,
306
+ status: statusAfterLeave(memberUserIds.length, entry.record.status),
250
307
  };
251
308
  entry.snapshot = { ...entry.snapshot, players };
252
309
  await persistence.saveServer(entry.record);
310
+ forgetOpLedger(args.serverId, args.userId);
253
311
  if (memberUserIds.length === 0) {
254
312
  live.delete(entry.record.serverId);
255
313
  }
256
314
  emit({ type: "server", serverId: args.serverId });
257
315
  }),
258
- runCommand: (args) => enqueue(async () => {
316
+ runCommand: (args) => enqueueRoom(args.serverId, async () => {
259
317
  const entry = await getLive(args.serverId);
260
318
  if (entry === null) {
261
319
  return { ok: false, reason: "Server not found" };
262
320
  }
263
- if (!entry.record.memberUserIds.includes(args.userId)) {
321
+ if (!isServerMember(entry.record.memberUserIds, args.userId)) {
264
322
  return { ok: false, reason: "Not a member of this server" };
265
323
  }
324
+ const envelope = unwrapOpEnvelope(args.input);
325
+ if (envelope !== null) {
326
+ const applied = findAppliedOp(args.serverId, args.userId, envelope.opId);
327
+ if (applied !== undefined)
328
+ return applied;
329
+ }
330
+ const commandInput = envelope !== null ? envelope.input : args.input;
266
331
  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 };
332
+ const result = runtime.runCommand(entry.snapshot, args.userId, args.command, commandInput);
333
+ const outcome = result.ok
334
+ ? { ok: true }
335
+ : { ok: false, reason: result.reason };
336
+ if (result.ok) {
337
+ entry.snapshot = result.snapshot;
338
+ markMutated(entry);
339
+ emit({ type: "server", serverId: args.serverId });
340
+ emit({ type: "player", serverId: args.serverId, userId: args.userId });
270
341
  }
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 };
342
+ if (envelope !== null) {
343
+ rememberAppliedOp(args.serverId, args.userId, envelope.opId, outcome);
344
+ }
345
+ return outcome;
276
346
  }),
277
347
  isMember: async (args) => {
278
348
  const entry = await getLive(args.serverId);
279
349
  if (entry === null)
280
350
  return false;
281
- return entry.record.memberUserIds.includes(args.userId);
351
+ return isServerMember(entry.record.memberUserIds, args.userId);
282
352
  },
283
353
  getServerView: async (args) => {
284
354
  const entry = await getLive(args.serverId);
285
355
  if (entry === null)
286
356
  return null;
287
- if (!entry.record.memberUserIds.includes(args.userId))
357
+ if (!isServerMember(entry.record.memberUserIds, args.userId))
288
358
  return null;
289
359
  return {
290
360
  serverId: entry.record.serverId,
@@ -299,7 +369,7 @@ export function createGameHost(options) {
299
369
  const entry = await getLive(args.serverId);
300
370
  if (entry === null)
301
371
  return null;
302
- if (!entry.record.memberUserIds.includes(args.userId))
372
+ if (!isServerMember(entry.record.memberUserIds, args.userId))
303
373
  return null;
304
374
  const player = entry.snapshot.players[args.userId];
305
375
  if (player !== undefined) {
@@ -324,17 +394,17 @@ export function createGameHost(options) {
324
394
  const entry = await getLive(args.serverId);
325
395
  if (entry === null)
326
396
  return [];
327
- if (!entry.record.memberUserIds.includes(args.userId))
397
+ if (!isServerMember(entry.record.memberUserIds, args.userId))
328
398
  return [];
329
399
  return persistence.loadFeed({ serverId: args.serverId, action: args.action });
330
400
  },
331
- pushFeedEntry: (args) => enqueue(async () => {
401
+ pushFeedEntry: (args) => enqueueRoom(args.serverId, async () => {
332
402
  const allowed = validateFeedWrite(feedWriteGate, args.action);
333
403
  if (!allowed.ok) {
334
404
  throw new Error(allowed.reason);
335
405
  }
336
406
  const entry = await getLive(args.serverId);
337
- if (entry === null || !entry.record.memberUserIds.includes(args.userId)) {
407
+ if (entry === null || !isServerMember(entry.record.memberUserIds, args.userId)) {
338
408
  throw new Error("Not a member of this server");
339
409
  }
340
410
  await persistence.appendFeed({ serverId: args.serverId, action: args.action, entry: args.entry });
@@ -343,26 +413,29 @@ export function createGameHost(options) {
343
413
  listOpenServers: async (args) => {
344
414
  const byId = new Map();
345
415
  for (const record of await persistence.listServers(args.gameId)) {
416
+ if (!isListablePublicly(record.visibility))
417
+ continue;
346
418
  byId.set(record.serverId, toServerListing(record));
347
419
  }
348
420
  for (const entry of live.values()) {
349
421
  if (entry.record.gameId !== args.gameId)
350
422
  continue;
423
+ if (!isListablePublicly(entry.record.visibility))
424
+ continue;
351
425
  byId.set(entry.record.serverId, toServerListing(entry.record));
352
426
  }
353
427
  return toOpenServerListings(byId.values(), args.limit);
354
428
  },
355
429
  tickOnce,
356
- flushAll: () => enqueue(async () => {
357
- let saved = 0;
358
- for (const entry of live.values()) {
430
+ flushAll: async () => {
431
+ const outcomes = await Promise.all([...live.values()].map((entry) => enqueueRoom(entry.record.serverId, async () => {
359
432
  if (entry.record.dirtyAt === undefined)
360
- continue;
433
+ return 0;
361
434
  await flushServer(entry);
362
- saved += 1;
363
- }
364
- return saved;
365
- }),
435
+ return 1;
436
+ })));
437
+ return outcomes.reduce((total, saved) => total + saved, 0);
438
+ },
366
439
  start: () => {
367
440
  if (interval !== null)
368
441
  return;
@@ -375,13 +448,12 @@ export function createGameHost(options) {
375
448
  clearInterval(interval);
376
449
  interval = null;
377
450
  }
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
- });
451
+ await Promise.all(roomQueues.values());
452
+ await Promise.all([...live.values()].map((entry) => enqueueRoom(entry.record.serverId, async () => {
453
+ if (entry.record.dirtyAt === undefined)
454
+ return;
455
+ await flushServer(entry);
456
+ })));
385
457
  },
386
458
  subscribe: (listener) => {
387
459
  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;