@jgengine/ws 0.9.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
@@ -2,6 +2,7 @@ import type { GameRuntime } from "@jgengine/core/runtime/gameRuntime";
2
2
  import type { HostPersistence, ServerListing, SessionAttributes } from "@jgengine/core/runtime/hostPersistence";
3
3
  import type { MatchFilter, SessionListing } from "@jgengine/core/multiplayer/matchmaking";
4
4
  import type { GameRuntimePlayerView, GameRuntimeServerView, JoinServerResult, TransportRunCommandResult } from "@jgengine/core/runtime/transport";
5
+ /** A change notification emitted by a `GameHost` for a server, player, or feed mutation. */
5
6
  export type HostChangeEvent = {
6
7
  type: "server";
7
8
  serverId: string;
@@ -14,6 +15,7 @@ export type HostChangeEvent = {
14
15
  serverId: string;
15
16
  action: string;
16
17
  };
18
+ /** Configuration for {@link createGameHost}, including persistence, tick rate, and game runtimes. */
17
19
  export type GameHostOptions = {
18
20
  runtimes?: GameRuntime[];
19
21
  persistence: HostPersistence;
@@ -23,12 +25,14 @@ export type GameHostOptions = {
23
25
  createServerId?: () => string;
24
26
  allowedFeedActions?: readonly string[];
25
27
  };
28
+ /** A transport-agnostic authoritative game server host that manages sessions, ticking, and persistence. */
26
29
  export type GameHost = {
27
30
  joinServer: (args: {
28
31
  userId: string;
29
32
  gameId: string;
30
33
  serverId?: string;
31
34
  attributes?: SessionAttributes;
35
+ code?: string;
32
36
  }) => Promise<JoinServerResult>;
33
37
  browseServers: (args: {
34
38
  gameId: string;
@@ -86,5 +90,9 @@ export type GameHost = {
86
90
  stop: () => Promise<void>;
87
91
  subscribe: (listener: (event: HostChangeEvent) => void) => () => void;
88
92
  };
93
+ /** Max recently-applied `runCommand` op IDs retained per (serverId, userId), oldest evicted first. */
94
+ export declare const OP_LEDGER_LIMIT = 64;
95
+ /** Creates a `GameHost` that runs game servers over the given persistence and runtimes. */
89
96
  export declare function createGameHost(options: GameHostOptions): GameHost;
97
+ /** Creates an in-memory `HostPersistence` implementation, useful for tests and ephemeral hosts. */
90
98
  export declare function memoryPersistence(now?: () => number): HostPersistence;
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,23 @@ 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
+ }
28
+ /** Creates a `GameHost` that runs game servers over the given persistence and runtimes. */
12
29
  export function createGameHost(options) {
13
30
  const now = options.now ?? Date.now;
14
31
  const tickMs = options.tickMs ?? 1_000;
@@ -34,10 +51,30 @@ export function createGameHost(options) {
34
51
  for (const listener of listeners)
35
52
  listener(event);
36
53
  };
37
- let queue = Promise.resolve();
38
- const enqueue = (operation) => {
39
- const run = queue.then(operation);
40
- 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
+ });
41
78
  return run;
42
79
  };
43
80
  const hydrate = async (record) => {
@@ -110,34 +147,39 @@ export function createGameHost(options) {
110
147
  };
111
148
  };
112
149
  let interval = null;
113
- const tickOnce = () => enqueue(async () => {
150
+ const tickOnce = async () => {
114
151
  const timestamp = now();
115
- let ticked = 0;
116
- let saved = 0;
117
- for (const entry of live.values()) {
152
+ const outcomes = await Promise.all([...live.values()].map((entry) => enqueueRoom(entry.record.serverId, async () => {
118
153
  if (entry.record.status !== "running")
119
- continue;
154
+ return { ticked: 0, saved: 0 };
120
155
  if (entry.record.memberUserIds.length === 0)
121
- continue;
156
+ return { ticked: 0, saved: 0 };
122
157
  const elapsedMs = timestamp - entry.record.tickAnchorMs;
123
158
  if (elapsedMs < tickMs)
124
- continue;
159
+ return { ticked: 0, saved: 0 };
125
160
  const runtime = resolveRuntime(entry.record.gameId);
126
161
  const before = entry.snapshot.revision;
127
162
  entry.snapshot = runtime.tick(entry.snapshot, elapsedMs / 1_000);
128
163
  entry.record = { ...entry.record, tickAnchorMs: timestamp, updatedAt: timestamp };
129
- ticked += 1;
130
164
  if (entry.snapshot.revision !== before) {
131
165
  markMutated(entry);
132
166
  emit({ type: "server", serverId: entry.record.serverId });
133
167
  }
168
+ let saved = 0;
134
169
  if (shouldAutoSave(entry.record.save, entry.record.dirtyAt, entry.record.lastSavedAt, timestamp)) {
135
170
  await flushServer(entry);
136
- saved += 1;
171
+ saved = 1;
137
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;
138
180
  }
139
181
  return { ticked, saved };
140
- });
182
+ };
141
183
  const collectListings = async (gameId, options = {}) => {
142
184
  const byId = new Map();
143
185
  for (const record of await persistence.listServers(gameId)) {
@@ -165,69 +207,88 @@ export function createGameHost(options) {
165
207
  }));
166
208
  };
167
209
  const host = {
168
- joinServer: (args) => enqueue(async () => {
169
- const timestamp = now();
170
- const runtime = resolveRuntime(args.gameId);
171
- let entry = args.serverId === undefined ? null : await getLive(args.serverId);
172
- if (args.serverId !== undefined && entry === null) {
173
- throw new Error("Server not found");
174
- }
175
- if (entry !== null && entry.record.gameId !== args.gameId) {
176
- throw new Error("Server belongs to a different game");
177
- }
178
- if (entry === null) {
179
- const attributes = args.attributes;
180
- const record = {
181
- serverId: createServerId(),
182
- 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],
183
267
  status: "running",
184
- memberUserIds: [],
185
- slotsPerServer: defaultSlots,
186
- save: runtime.save,
187
- serverState: createEmptyServerRow(),
188
- sessionPlayers: {},
189
- revision: 0,
190
- tickAnchorMs: timestamp,
191
- createdAt: timestamp,
192
268
  updatedAt: timestamp,
193
- ...(attributes?.label !== undefined ? { label: attributes.label } : {}),
194
- ...(attributes?.mode !== undefined ? { mode: attributes.mode } : {}),
195
- ...(attributes?.visibility !== undefined ? { visibility: attributes.visibility } : {}),
196
- ...(attributes?.joinCode !== undefined ? { joinCode: attributes.joinCode } : {}),
197
- ...(attributes?.tags !== undefined ? { tags: attributes.tags } : {}),
269
+ dirtyAt: timestamp,
198
270
  };
199
- entry = await hydrate(record);
200
- }
201
- const { record } = entry;
202
- if (record.memberUserIds.length >= record.slotsPerServer &&
203
- !record.memberUserIds.includes(args.userId)) {
204
- throw new Error("Server is full");
205
- }
206
- const profile = await persistence.loadProfile({ userId: args.userId, gameId: args.gameId });
207
- const isNew = profile === null;
208
- entry.record = {
209
- ...record,
210
- memberUserIds: record.memberUserIds.includes(args.userId)
211
- ? record.memberUserIds
212
- : [...record.memberUserIds, args.userId],
213
- status: "running",
214
- updatedAt: timestamp,
215
- 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 };
216
276
  };
217
- entry.snapshot = runtime.joinPlayer(entry.snapshot, args.userId, isNew);
218
- await flushServer(entry);
219
- emit({ type: "server", serverId: entry.record.serverId });
220
- emit({ type: "player", serverId: entry.record.serverId, userId: args.userId });
221
- return { serverId: entry.record.serverId, isNew };
222
- }),
277
+ return args.serverId === undefined ? joinOperation() : enqueueRoom(args.serverId, joinOperation);
278
+ },
223
279
  browseServers: async (args) => browseSessions(await collectListings(args.gameId), args.filter ?? {}, { limit: args.limit }),
224
280
  joinByCode: async (args) => {
225
281
  const match = findByJoinCode(await collectListings(args.gameId, { includeJoinCode: true }), args.code);
226
282
  if (match === null)
227
283
  return null;
228
- 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
+ });
229
290
  },
230
- leaveServer: (args) => enqueue(async () => {
291
+ leaveServer: (args) => enqueueRoom(args.serverId, async () => {
231
292
  const entry = await getLive(args.serverId);
232
293
  if (entry === null)
233
294
  return;
@@ -249,12 +310,13 @@ export function createGameHost(options) {
249
310
  };
250
311
  entry.snapshot = { ...entry.snapshot, players };
251
312
  await persistence.saveServer(entry.record);
313
+ forgetOpLedger(args.serverId, args.userId);
252
314
  if (memberUserIds.length === 0) {
253
315
  live.delete(entry.record.serverId);
254
316
  }
255
317
  emit({ type: "server", serverId: args.serverId });
256
318
  }),
257
- runCommand: (args) => enqueue(async () => {
319
+ runCommand: (args) => enqueueRoom(args.serverId, async () => {
258
320
  const entry = await getLive(args.serverId);
259
321
  if (entry === null) {
260
322
  return { ok: false, reason: "Server not found" };
@@ -262,16 +324,28 @@ export function createGameHost(options) {
262
324
  if (!entry.record.memberUserIds.includes(args.userId)) {
263
325
  return { ok: false, reason: "Not a member of this server" };
264
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;
265
334
  const runtime = resolveRuntime(entry.record.gameId);
266
- const result = runtime.runCommand(entry.snapshot, args.userId, args.command, args.input);
267
- if (!result.ok) {
268
- 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 });
269
344
  }
270
- entry.snapshot = result.snapshot;
271
- markMutated(entry);
272
- emit({ type: "server", serverId: args.serverId });
273
- emit({ type: "player", serverId: args.serverId, userId: args.userId });
274
- return { ok: true };
345
+ if (envelope !== null) {
346
+ rememberAppliedOp(args.serverId, args.userId, envelope.opId, outcome);
347
+ }
348
+ return outcome;
275
349
  }),
276
350
  isMember: async (args) => {
277
351
  const entry = await getLive(args.serverId);
@@ -327,7 +401,7 @@ export function createGameHost(options) {
327
401
  return [];
328
402
  return persistence.loadFeed({ serverId: args.serverId, action: args.action });
329
403
  },
330
- pushFeedEntry: (args) => enqueue(async () => {
404
+ pushFeedEntry: (args) => enqueueRoom(args.serverId, async () => {
331
405
  const allowed = validateFeedWrite(feedWriteGate, args.action);
332
406
  if (!allowed.ok) {
333
407
  throw new Error(allowed.reason);
@@ -342,26 +416,29 @@ export function createGameHost(options) {
342
416
  listOpenServers: async (args) => {
343
417
  const byId = new Map();
344
418
  for (const record of await persistence.listServers(args.gameId)) {
419
+ if ((record.visibility ?? "public") === "private")
420
+ continue;
345
421
  byId.set(record.serverId, toServerListing(record));
346
422
  }
347
423
  for (const entry of live.values()) {
348
424
  if (entry.record.gameId !== args.gameId)
349
425
  continue;
426
+ if ((entry.record.visibility ?? "public") === "private")
427
+ continue;
350
428
  byId.set(entry.record.serverId, toServerListing(entry.record));
351
429
  }
352
430
  return toOpenServerListings(byId.values(), args.limit);
353
431
  },
354
432
  tickOnce,
355
- flushAll: () => enqueue(async () => {
356
- let saved = 0;
357
- for (const entry of live.values()) {
433
+ flushAll: async () => {
434
+ const outcomes = await Promise.all([...live.values()].map((entry) => enqueueRoom(entry.record.serverId, async () => {
358
435
  if (entry.record.dirtyAt === undefined)
359
- continue;
436
+ return 0;
360
437
  await flushServer(entry);
361
- saved += 1;
362
- }
363
- return saved;
364
- }),
438
+ return 1;
439
+ })));
440
+ return outcomes.reduce((total, saved) => total + saved, 0);
441
+ },
365
442
  start: () => {
366
443
  if (interval !== null)
367
444
  return;
@@ -374,13 +451,12 @@ export function createGameHost(options) {
374
451
  clearInterval(interval);
375
452
  interval = null;
376
453
  }
377
- await enqueue(async () => {
378
- for (const entry of live.values()) {
379
- if (entry.record.dirtyAt === undefined)
380
- continue;
381
- await flushServer(entry);
382
- }
383
- });
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
+ })));
384
460
  },
385
461
  subscribe: (listener) => {
386
462
  listeners.add(listener);
@@ -389,6 +465,7 @@ export function createGameHost(options) {
389
465
  };
390
466
  return host;
391
467
  }
468
+ /** Creates an in-memory `HostPersistence` implementation, useful for tests and ephemeral hosts. */
392
469
  export function memoryPersistence(now = Date.now) {
393
470
  const servers = new Map();
394
471
  const profiles = new Map();
@@ -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 = {
@@ -26,6 +33,7 @@ export type HostRouterConnection = {
26
33
  handleRaw: (raw: unknown) => void;
27
34
  close: () => void;
28
35
  };
36
+ /** A player's interpolated position sampled from history at a past timestamp. */
29
37
  export type RewoundPosition = {
30
38
  userId: string;
31
39
  x: number;
@@ -41,5 +49,7 @@ export type HostRouter = {
41
49
  close: () => void;
42
50
  };
43
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;
44
54
  export declare function createHostRouter(options: HostRouterOptions): HostRouter;
45
55
  export declare function loopbackPipe(router: HostRouter): TransportPipeFactory;