@jgengine/ws 0.18.1 → 0.21.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.
@@ -42,6 +42,7 @@ export function createWsBackend(options) {
42
42
  let wantConnection = false;
43
43
  let nextId = 1;
44
44
  let nextOpId = 1;
45
+ const operationSession = crypto.randomUUID();
45
46
  let reconnectAttempt = 0;
46
47
  let reconnectTimer = null;
47
48
  const pending = new Map();
@@ -49,6 +50,7 @@ export function createWsBackend(options) {
49
50
  const resumeTickets = new Map();
50
51
  const joinedGames = new Map();
51
52
  const joinedRoles = new Map();
53
+ const joinedSessions = new Map();
52
54
  const rtt = { sampleMs: 0, smoothedMs: 0 };
53
55
  let pingTimer = null;
54
56
  const schedulePing = () => {
@@ -119,7 +121,10 @@ export function createWsBackend(options) {
119
121
  };
120
122
  const rejoinServers = () => {
121
123
  for (const [serverId, gameId] of joinedGames) {
122
- rawSend({ v: 1, t: "join", id: nextId++, gameId, serverId, role: joinedRoles.get(serverId) });
124
+ const sessions = [...(joinedSessions.get(serverId) ?? [])];
125
+ for (const sessionId of sessions.length > 0 ? sessions : [undefined]) {
126
+ rawSend({ v: 1, t: "join", id: nextId++, gameId, serverId, role: joinedRoles.get(serverId), ...(sessionId === undefined ? {} : { sessionId }) });
127
+ }
123
128
  }
124
129
  };
125
130
  const helloToken = () => resumeTickets.values().next().value?.token ?? options.token;
@@ -315,10 +320,16 @@ export function createWsBackend(options) {
315
320
  gameId: args.gameId,
316
321
  serverId: args.serverId,
317
322
  role: args.role,
323
+ ...(args.sessionId === undefined ? {} : { sessionId: args.sessionId }),
318
324
  }));
319
325
  const joined = result;
320
326
  joinedGames.set(joined.serverId, args.gameId);
321
327
  joinedRoles.set(joined.serverId, args.role ?? "player");
328
+ if (args.sessionId !== undefined) {
329
+ const sessions = joinedSessions.get(joined.serverId) ?? new Set();
330
+ sessions.add(args.sessionId);
331
+ joinedSessions.set(joined.serverId, sessions);
332
+ }
322
333
  if (joined.resumeTicket !== undefined)
323
334
  resumeTickets.set(joined.serverId, joined.resumeTicket);
324
335
  return { ...joined, ok: true };
@@ -335,14 +346,23 @@ export function createWsBackend(options) {
335
346
  }
336
347
  },
337
348
  async leaveServer(args) {
349
+ const sessions = joinedSessions.get(args.serverId);
350
+ const partial = args.sessionId !== undefined && sessions !== undefined && sessions.size > 0 &&
351
+ (!sessions.has(args.sessionId) || sessions.size > 1);
352
+ if (partial) {
353
+ await request((id) => ({ v: 1, t: "leave", id, serverId: args.serverId, sessionId: args.sessionId }));
354
+ sessions.delete(args.sessionId);
355
+ return;
356
+ }
338
357
  poseGates.delete(args.serverId);
339
- await request((id) => ({ v: 1, t: "leave", id, serverId: args.serverId }));
358
+ await request((id) => ({ v: 1, t: "leave", id, serverId: args.serverId, ...(args.sessionId === undefined ? {} : { sessionId: args.sessionId }) }));
359
+ joinedSessions.delete(args.serverId);
340
360
  joinedGames.delete(args.serverId);
341
361
  joinedRoles.delete(args.serverId);
342
362
  resumeTickets.delete(args.serverId);
343
363
  },
344
364
  async runCommand(args) {
345
- const opId = String(nextOpId++);
365
+ const opId = `${operationSession}:${nextOpId++}`;
346
366
  try {
347
367
  const result = await request((id) => ({
348
368
  v: 1,
package/dist/host.d.ts CHANGED
@@ -55,6 +55,8 @@ export type GameHost = {
55
55
  serverId: string;
56
56
  command: string;
57
57
  input: unknown;
58
+ /** Set only from host code: runs commands marked `access: "server"`, which client messages cannot. */
59
+ trusted?: boolean;
58
60
  }) => Promise<TransportRunCommandResult>;
59
61
  isMember: (args: {
60
62
  userId: string;
package/dist/host.js CHANGED
@@ -1,9 +1,11 @@
1
1
  import { createGameRuntime } from "@jgengine/core/runtime/gameRuntime";
2
+ import { SERVER_ONLY_COMMAND_REASON } from "@jgengine/core/runtime/commandRunner";
2
3
  import { applyLeaderboardRows, buildHydratePlayers, planServerPersist, profileLeaderboardStats, shouldAutoSave, topLeaderboardRows, toOpenServerListings, toServerListing, trimFeedEntries, } from "@jgengine/core/runtime/hostPersistence";
3
4
  import { isListablePublicly, isPrivateJoinBlocked, isServerFull, isServerMember, statusAfterLeave, withJoinedMember, withoutMember, } from "@jgengine/core/runtime/hostPolicy";
4
5
  import { browseSessions, findByJoinCode } from "@jgengine/core/multiplayer/matchmaking";
5
6
  import { createFeedWriteGate, validateFeedWrite, } from "@jgengine/core/multiplayer/feedWriteGate";
6
7
  import { clearDirtyFlags, createEmptyServerRow, splitProfilePlayer } from "@jgengine/core/runtime/snapshot";
8
+ import { unwrapOpEnvelope } from "@jgengine/core/runtime/hostedWorldSession";
7
9
  const builtinCommands = {
8
10
  "engine.ping": {
9
11
  validate: () => null,
@@ -12,17 +14,6 @@ const builtinCommands = {
12
14
  };
13
15
  /** Max recently-applied `runCommand` op IDs retained per (serverId, userId), oldest evicted first. */
14
16
  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
17
  function opLedgerKey(serverId, userId) {
27
18
  return `${serverId}|${userId}`;
28
19
  }
@@ -330,6 +321,9 @@ export function createGameHost(options) {
330
321
  }
331
322
  const commandInput = envelope !== null ? envelope.input : args.input;
332
323
  const runtime = resolveRuntime(entry.record.gameId);
324
+ if (args.trusted !== true && runtime.commandAccess(args.command) === "server") {
325
+ return { ok: false, reason: SERVER_ONLY_COMMAND_REASON };
326
+ }
333
327
  const result = runtime.runCommand(entry.snapshot, args.userId, args.command, commandInput, now());
334
328
  const outcome = result.ok
335
329
  ? { ok: true }
@@ -49,6 +49,8 @@ export type HostRouter = {
49
49
  atMs: number;
50
50
  }) => RewoundPosition[];
51
51
  close: () => void;
52
+ /** Wait for accepted message handlers and subscription reads to settle. Close first to prevent new work. */
53
+ drain: () => Promise<void>;
52
54
  };
53
55
  export declare const DEFAULT_POSE_RULES: PoseSyncRules;
54
56
  /** Cap on frames queued behind a connection's in-flight message; beyond this a flood gets rejected instead of piling up unbounded promises. */
@@ -53,6 +53,13 @@ export function createHostRouter(options) {
53
53
  const authenticate = options.authenticate ??
54
54
  (allowAnonymous ? ({ userId }) => userId : null);
55
55
  const connections = new Set();
56
+ let closed = false;
57
+ const work = new Set();
58
+ const track = (promise) => {
59
+ work.add(promise);
60
+ void promise.finally(() => work.delete(promise)).catch(() => { });
61
+ return promise;
62
+ };
56
63
  const sessionsByUserId = new Map();
57
64
  const resumeTokens = new Map();
58
65
  const pendingLeaves = new Map();
@@ -70,6 +77,52 @@ export function createHostRouter(options) {
70
77
  clearTimeout(timer);
71
78
  pendingLeaves.delete(key);
72
79
  };
80
+ const heldByOtherConnection = (self, userId, serverId) => {
81
+ for (const other of connections) {
82
+ if (other !== self && other.userId === userId && other.joinedServers.has(serverId))
83
+ return true;
84
+ }
85
+ return false;
86
+ };
87
+ const scheduleLeave = (connection, userId, serverId) => {
88
+ clearPendingLeave(userId, serverId);
89
+ const key = ticketKey(userId, serverId);
90
+ pendingLeaves.set(key, setTimeout(() => {
91
+ pendingLeaves.delete(key);
92
+ void track((async () => {
93
+ const admissions = [...connections]
94
+ .filter(other => other !== connection && other.userId === userId)
95
+ .flatMap(other => other.admission === null || (other.admission.serverId !== undefined && other.admission.serverId !== serverId)
96
+ ? [] : [other.admission.result]);
97
+ await Promise.allSettled(admissions);
98
+ if (closed || heldByOtherConnection(connection, userId, serverId))
99
+ return;
100
+ await host.leaveServer({ userId, serverId });
101
+ })()).catch(() => { });
102
+ }, graceMs));
103
+ };
104
+ const admit = async (connection, userId, join, role, sessionId, serverId) => {
105
+ const admission = join().then(result => {
106
+ if (result === null || closed)
107
+ return result;
108
+ if (connection.closed || connection.userId !== userId) {
109
+ scheduleLeave(connection, userId, result.serverId);
110
+ return result;
111
+ }
112
+ connection.joinedServers.add(result.serverId);
113
+ connection.roles.set(result.serverId, role ?? "player");
114
+ if (sessionId !== undefined)
115
+ getOrCreate(connection.sessions, result.serverId, () => new Set()).add(sessionId);
116
+ return result;
117
+ });
118
+ connection.admission = { serverId, result: admission };
119
+ try {
120
+ return await admission;
121
+ }
122
+ finally {
123
+ connection.admission = null;
124
+ }
125
+ };
73
126
  const subscribers = new Map();
74
127
  const subscribersOf = (key) => {
75
128
  const set = subscribers.get(key);
@@ -77,6 +130,13 @@ export function createHostRouter(options) {
77
130
  };
78
131
  const addSubscription = (connection, key) => {
79
132
  connection.subscriptions.add(key);
133
+ const push = connection.subscriptionPushes.get(key);
134
+ if (push === undefined) {
135
+ connection.subscriptionPushes.set(key, { generation: 0, pending: false });
136
+ }
137
+ else {
138
+ push.generation += 1;
139
+ }
80
140
  let set = subscribers.get(key);
81
141
  if (set === undefined) {
82
142
  set = new Set();
@@ -86,6 +146,13 @@ export function createHostRouter(options) {
86
146
  };
87
147
  const removeSubscription = (connection, key) => {
88
148
  connection.subscriptions.delete(key);
149
+ const push = connection.subscriptionPushes.get(key);
150
+ if (push !== undefined) {
151
+ push.generation += 1;
152
+ push.pending = false;
153
+ if (push.run === undefined)
154
+ connection.subscriptionPushes.delete(key);
155
+ }
89
156
  const set = subscribers.get(key);
90
157
  if (set === undefined)
91
158
  return;
@@ -98,6 +165,8 @@ export function createHostRouter(options) {
98
165
  const histories = new Map();
99
166
  const historyFor = (serverId) => getOrCreate(histories, serverId, () => createPositionHistory({ historyMs: positionHistoryMs }));
100
167
  const send = (connection, message) => {
168
+ if (closed || connection.closed)
169
+ return;
101
170
  connection.transport.send(encodeWsMessage(message));
102
171
  };
103
172
  const reply = (connection, id, result) => {
@@ -149,6 +218,8 @@ export function createHostRouter(options) {
149
218
  atMs: now(),
150
219
  ...args,
151
220
  });
221
+ if (closed || connection.closed)
222
+ return false;
152
223
  if (!decision.allow) {
153
224
  replyError(connection, id, decision.reason);
154
225
  return false;
@@ -237,32 +308,42 @@ export function createHostRouter(options) {
237
308
  reply(connection, message.id, null);
238
309
  broadcastChat(message.serverId, message.channelId);
239
310
  };
240
- const pushSubscription = async (connection, channel, serverId, action) => {
241
- if (connection.userId === null)
311
+ const pushSubscription = async (connection, channel, serverId, action, isCurrent) => {
312
+ if (connection.userId === null || !isCurrent())
242
313
  return;
314
+ if (host.pullWorld !== undefined) {
315
+ if (!(await host.isMember({ userId: connection.userId, serverId })) || !isCurrent())
316
+ return;
317
+ }
243
318
  if (channel === "server") {
244
319
  if (host.pullWorld !== undefined) {
245
320
  const sinceRevision = connection.worldRevisions.get(serverId) ?? null;
246
321
  const world = await host.pullWorld({ userId: connection.userId, serverId, sinceRevision, role: connection.roles.get(serverId) });
322
+ if (!isCurrent())
323
+ return;
247
324
  if (world !== null) {
248
- connection.worldRevisions.set(serverId, world.revision);
249
325
  const data = await host.getServerView({ userId: connection.userId, serverId, role: connection.roles.get(serverId) });
250
- if (data !== null) {
326
+ if (data !== null && isCurrent()) {
251
327
  send(connection, { v: 1, t: "update", channel, serverId, data: { ...data, serverState: world } });
328
+ connection.worldRevisions.set(serverId, world.revision);
252
329
  }
253
330
  return;
254
331
  }
332
+ return;
255
333
  }
256
334
  const data = await host.getServerView({ userId: connection.userId, serverId, role: connection.roles.get(serverId) });
257
- send(connection, { v: 1, t: "update", channel, serverId, data });
335
+ if (isCurrent())
336
+ send(connection, { v: 1, t: "update", channel, serverId, data });
258
337
  }
259
338
  else if (channel === "player") {
260
339
  const data = await host.getPlayerView({ userId: connection.userId, serverId });
261
- send(connection, { v: 1, t: "update", channel, serverId, data });
340
+ if (isCurrent())
341
+ send(connection, { v: 1, t: "update", channel, serverId, data });
262
342
  }
263
343
  else if (channel === "feed") {
264
344
  const data = await host.getFeed({ userId: connection.userId, serverId, action: action ?? "" });
265
- send(connection, { v: 1, t: "update", channel, serverId, action: action ?? "", data });
345
+ if (isCurrent())
346
+ send(connection, { v: 1, t: "update", channel, serverId, action: action ?? "", data });
266
347
  }
267
348
  else if (channel === "chat") {
268
349
  const channelId = action ?? "";
@@ -290,18 +371,50 @@ export function createHostRouter(options) {
290
371
  send(connection, { v: 1, t: "update", channel, serverId, data: presenceRows(serverId) });
291
372
  }
292
373
  };
374
+ const requestSubscriptionPush = (connection, channel, serverId, action) => {
375
+ const key = subscriptionKey(channel, serverId, action);
376
+ const push = connection.subscriptionPushes.get(key);
377
+ if (push === undefined)
378
+ return Promise.resolve();
379
+ push.pending = true;
380
+ if (push.run !== undefined)
381
+ return push.run;
382
+ const isActive = () => connections.has(connection) && connection.userId !== null && connection.subscriptions.has(key) && connection.subscriptionPushes.get(key) === push;
383
+ push.run = track(Promise.resolve().then(async () => {
384
+ while (push.pending && isActive()) {
385
+ push.pending = false;
386
+ const generation = push.generation;
387
+ try {
388
+ await pushSubscription(connection, channel, serverId, action, () => isActive() && push.generation === generation);
389
+ }
390
+ catch {
391
+ // A failed read leaves the sent revision intact; a later notification retries it.
392
+ }
393
+ }
394
+ }).finally(() => {
395
+ push.run = undefined;
396
+ if (!isActive()) {
397
+ if (connection.subscriptionPushes.get(key) === push)
398
+ connection.subscriptionPushes.delete(key);
399
+ }
400
+ else if (push.pending) {
401
+ void requestSubscriptionPush(connection, channel, serverId, action);
402
+ }
403
+ }));
404
+ return push.run;
405
+ };
293
406
  const onHostEvent = (event) => {
294
407
  if (event.type === "server") {
295
408
  for (const connection of subscribersOf(subscriptionKey("server", event.serverId))) {
296
409
  if (connection.userId === null)
297
410
  continue;
298
- void pushSubscription(connection, "server", event.serverId);
411
+ void requestSubscriptionPush(connection, "server", event.serverId);
299
412
  }
300
413
  }
301
414
  else if (event.type === "player") {
302
415
  for (const connection of subscribersOf(subscriptionKey("player", event.serverId))) {
303
416
  if (connection.userId === event.userId) {
304
- void pushSubscription(connection, "player", event.serverId);
417
+ void requestSubscriptionPush(connection, "player", event.serverId);
305
418
  }
306
419
  }
307
420
  }
@@ -309,7 +422,7 @@ export function createHostRouter(options) {
309
422
  for (const connection of subscribersOf(subscriptionKey("feed", event.serverId, event.action))) {
310
423
  if (connection.userId === null)
311
424
  continue;
312
- void pushSubscription(connection, "feed", event.serverId, event.action);
425
+ void requestSubscriptionPush(connection, "feed", event.serverId, event.action);
313
426
  }
314
427
  }
315
428
  };
@@ -358,6 +471,8 @@ export function createHostRouter(options) {
358
471
  dropUserFrom(presence, userId, (key) => key === serverId, () => broadcastPresence(serverId));
359
472
  };
360
473
  const handleMessage = async (connection, message) => {
474
+ if (closed || connection.closed)
475
+ return;
361
476
  if (message.t === "hello") {
362
477
  if (connection.userId !== null) {
363
478
  replyError(connection, message.id, "Already authenticated");
@@ -372,6 +487,8 @@ export function createHostRouter(options) {
372
487
  const userId = resumeToken !== undefined && message.token === resumeToken
373
488
  ? message.userId
374
489
  : await authenticate({ userId: message.userId, token: message.token });
490
+ if (closed || connection.closed)
491
+ return;
375
492
  if (userId === null) {
376
493
  replyError(connection, message.id, "Not authenticated");
377
494
  connection.transport.close();
@@ -417,16 +534,18 @@ export function createHostRouter(options) {
417
534
  case "join": {
418
535
  if (!(await gate(connection, message.id, "join", { serverId: message.serverId })))
419
536
  return;
420
- const result = await host.joinServer({
537
+ const result = await admit(connection, userId, () => host.joinServer({
421
538
  userId,
422
539
  gameId: message.gameId,
423
540
  serverId: message.serverId,
424
541
  attributes: message.attributes,
425
542
  code: message.code,
426
543
  role: message.role,
427
- });
428
- connection.joinedServers.add(result.serverId);
429
- connection.roles.set(result.serverId, message.role ?? "player");
544
+ }), message.role, message.sessionId, message.serverId);
545
+ if (result === null)
546
+ return;
547
+ if (closed || connection.closed || connection.userId !== userId)
548
+ return;
430
549
  reply(connection, message.id, {
431
550
  ...result,
432
551
  resumeTicket: ticketFor(userId, result.serverId),
@@ -436,14 +555,14 @@ export function createHostRouter(options) {
436
555
  case "joinByCode": {
437
556
  if (!(await gate(connection, message.id, "join", {})))
438
557
  return;
439
- const result = await host.joinByCode({
558
+ const result = await admit(connection, userId, () => host.joinByCode({
440
559
  userId,
441
560
  gameId: message.gameId,
442
561
  code: message.code,
443
- });
562
+ }), message.role);
444
563
  if (result !== null) {
445
- connection.joinedServers.add(result.serverId);
446
- connection.roles.set(result.serverId, message.role ?? "player");
564
+ if (closed || connection.closed || connection.userId !== userId)
565
+ return;
447
566
  }
448
567
  reply(connection, message.id, result === null
449
568
  ? null
@@ -462,12 +581,23 @@ export function createHostRouter(options) {
462
581
  return;
463
582
  }
464
583
  case "leave": {
465
- await host.leaveServer({ userId, serverId: message.serverId });
466
- clearPendingLeave(userId, message.serverId);
584
+ const sessions = connection.sessions.get(message.serverId);
585
+ // A leave for a session that is not live (a disposed join) must not end the sessions that are.
586
+ if (message.sessionId !== undefined && sessions !== undefined && sessions.size > 0) {
587
+ if (!sessions.delete(message.sessionId) || sessions.size > 0) {
588
+ reply(connection, message.id, null);
589
+ return;
590
+ }
591
+ }
592
+ connection.sessions.delete(message.serverId);
467
593
  connection.joinedServers.delete(message.serverId);
468
594
  connection.roles.delete(message.serverId);
469
- dropPresenceForServer(userId, message.serverId);
470
- dropVoiceForServer(userId, message.serverId);
595
+ if (!heldByOtherConnection(connection, userId, message.serverId)) {
596
+ await host.leaveServer({ userId, serverId: message.serverId });
597
+ clearPendingLeave(userId, message.serverId);
598
+ dropPresenceForServer(userId, message.serverId);
599
+ dropVoiceForServer(userId, message.serverId);
600
+ }
471
601
  reply(connection, message.id, null);
472
602
  return;
473
603
  }
@@ -546,18 +676,20 @@ export function createHostRouter(options) {
546
676
  return;
547
677
  }
548
678
  case "subscribe": {
549
- if ((message.channel === "chat" ||
679
+ if ((host.pullWorld !== undefined || message.channel === "chat" ||
550
680
  message.channel === "voice" ||
551
681
  message.channel === "presence") &&
552
682
  !(await host.isMember({ userId, serverId: message.serverId }))) {
553
683
  replyError(connection, message.id, "Not a member of this server");
554
684
  return;
555
685
  }
686
+ if (closed || connection.closed)
687
+ return;
556
688
  if (message.channel === "server")
557
689
  connection.worldRevisions.set(message.serverId, null);
558
690
  addSubscription(connection, subscriptionKey(message.channel, message.serverId, message.action));
559
691
  reply(connection, message.id, null);
560
- await pushSubscription(connection, message.channel, message.serverId, message.action);
692
+ void requestSubscriptionPush(connection, message.channel, message.serverId, message.action);
561
693
  return;
562
694
  }
563
695
  case "unsubscribe": {
@@ -572,35 +704,39 @@ export function createHostRouter(options) {
572
704
  }
573
705
  };
574
706
  const leaveJoinedServers = (connection) => {
575
- if (connection.userId === null)
707
+ if (closed || connection.userId === null)
576
708
  return;
577
709
  const userId = connection.userId;
578
710
  const servers = [...connection.joinedServers];
579
711
  connection.joinedServers.clear();
712
+ connection.sessions.clear();
580
713
  for (const serverId of servers) {
581
- const key = ticketKey(userId, serverId);
582
- clearPendingLeave(userId, serverId);
583
- pendingLeaves.set(key, setTimeout(() => {
584
- pendingLeaves.delete(key);
585
- void host.leaveServer({ userId, serverId }).catch(() => undefined);
586
- }, graceMs));
714
+ scheduleLeave(connection, userId, serverId);
587
715
  }
588
716
  };
589
717
  return {
590
718
  connect: (transport) => {
719
+ if (closed)
720
+ throw new Error("Host router is closed");
591
721
  const connection = {
722
+ closed: false,
592
723
  transport,
593
724
  userId: null,
594
725
  subscriptions: new Set(),
595
726
  joinedServers: new Set(),
727
+ sessions: new Map(),
596
728
  queue: Promise.resolve(),
597
729
  queuedMessages: 0,
598
730
  worldRevisions: new Map(),
599
731
  roles: new Map(),
732
+ subscriptionPushes: new Map(),
733
+ admission: null,
600
734
  };
601
735
  connections.add(connection);
602
736
  return {
603
737
  handleRaw: (raw) => {
738
+ if (closed || connection.closed)
739
+ return;
604
740
  const text = typeof raw === "string" ? raw : String(raw);
605
741
  const message = decodeWsClientMessage(text);
606
742
  if (message === null) {
@@ -616,14 +752,17 @@ export function createHostRouter(options) {
616
752
  return;
617
753
  }
618
754
  connection.queuedMessages += 1;
619
- connection.queue = connection.queue
755
+ connection.queue = track(connection.queue
620
756
  .then(() => handleMessage(connection, message))
621
757
  .catch(() => undefined)
622
758
  .finally(() => {
623
759
  connection.queuedMessages -= 1;
624
- });
760
+ }));
625
761
  },
626
762
  close: () => {
763
+ if (connection.closed)
764
+ return;
765
+ connection.closed = true;
627
766
  if (connection.userId !== null && sessionsByUserId.get(connection.userId) === connection) {
628
767
  sessionsByUserId.delete(connection.userId);
629
768
  }
@@ -650,6 +789,9 @@ export function createHostRouter(options) {
650
789
  return positions;
651
790
  },
652
791
  close: () => {
792
+ if (closed)
793
+ return;
794
+ closed = true;
653
795
  unsubscribeHost();
654
796
  for (const timer of pendingLeaves.values())
655
797
  clearTimeout(timer);
@@ -657,6 +799,10 @@ export function createHostRouter(options) {
657
799
  connections.clear();
658
800
  subscribers.clear();
659
801
  },
802
+ async drain() {
803
+ while (work.size > 0)
804
+ await Promise.allSettled([...work]);
805
+ },
660
806
  };
661
807
  }
662
808
  export function loopbackPipe(router) {
package/dist/peer.js CHANGED
@@ -126,12 +126,19 @@ export function createPeerHost(options) {
126
126
  };
127
127
  channel.onclose = () => connection.close();
128
128
  };
129
- await pc.setRemoteDescription({ type: payload.type, sdp: payload.sdp });
130
- const answer = await pc.createAnswer();
131
- await pc.setLocalDescription(answer);
132
- await waitForIceGathering(pc, iceGatherTimeoutMs);
133
- const description = localDescriptionOrThrow(pc);
134
- return encodePeerSignal({ type: "answer", sdp: description.sdp });
129
+ try {
130
+ await pc.setRemoteDescription({ type: payload.type, sdp: payload.sdp });
131
+ const answer = await pc.createAnswer();
132
+ await pc.setLocalDescription(answer);
133
+ await waitForIceGathering(pc, iceGatherTimeoutMs);
134
+ const description = localDescriptionOrThrow(pc);
135
+ return encodePeerSignal({ type: "answer", sdp: description.sdp });
136
+ }
137
+ catch (error) {
138
+ peerConnections.delete(pc);
139
+ pc.close();
140
+ throw error;
141
+ }
135
142
  };
136
143
  return {
137
144
  backend,
@@ -190,7 +197,10 @@ export function broadcastChannelSignaling(room) {
190
197
  const channel = new BroadcastChannel(room);
191
198
  const pendingAnswers = new Map();
192
199
  let currentAnswer = null;
200
+ let closed = false;
193
201
  channel.onmessage = (event) => {
202
+ if (closed)
203
+ return;
194
204
  const envelope = event.data;
195
205
  if (!isSignalEnvelope(envelope))
196
206
  return;
@@ -199,25 +209,40 @@ export function broadcastChannelSignaling(room) {
199
209
  if (answer === null)
200
210
  return;
201
211
  void answer(envelope.code).then((answerCode) => {
212
+ if (closed)
213
+ return;
202
214
  channel.postMessage({ t: "answer", id: envelope.id, code: answerCode });
215
+ }, (error) => {
216
+ if (!closed)
217
+ throw error;
203
218
  });
204
219
  return;
205
220
  }
206
- const resolve = pendingAnswers.get(envelope.id);
207
- if (resolve === undefined)
221
+ const pending = pendingAnswers.get(envelope.id);
222
+ if (pending === undefined)
208
223
  return;
209
224
  pendingAnswers.delete(envelope.id);
210
- resolve(envelope.code);
225
+ pending.resolve(envelope.code);
211
226
  };
212
227
  return {
213
228
  publishOffer: (offerCode) => {
229
+ if (closed)
230
+ return Promise.reject(new Error("Peer signaling is closed"));
214
231
  const id = crypto.randomUUID();
215
- return new Promise((resolve) => {
216
- pendingAnswers.set(id, resolve);
217
- channel.postMessage({ t: "offer", id, code: offerCode });
232
+ return new Promise((resolve, reject) => {
233
+ pendingAnswers.set(id, { resolve, reject });
234
+ try {
235
+ channel.postMessage({ t: "offer", id, code: offerCode });
236
+ }
237
+ catch (error) {
238
+ pendingAnswers.delete(id);
239
+ reject(error);
240
+ }
218
241
  });
219
242
  },
220
243
  onOffer: (answer) => {
244
+ if (closed)
245
+ throw new Error("Peer signaling is closed");
221
246
  currentAnswer = answer;
222
247
  return () => {
223
248
  if (currentAnswer === answer)
@@ -225,6 +250,14 @@ export function broadcastChannelSignaling(room) {
225
250
  };
226
251
  },
227
252
  close: () => {
253
+ if (closed)
254
+ return;
255
+ closed = true;
256
+ currentAnswer = null;
257
+ channel.onmessage = null;
258
+ for (const pending of pendingAnswers.values()) {
259
+ pending.reject(new Error("Peer signaling is closed"));
260
+ }
228
261
  pendingAnswers.clear();
229
262
  channel.close();
230
263
  },
@@ -5,6 +5,8 @@ import type { MatchFilter, SessionListing } from "@jgengine/core/multiplayer/mat
5
5
  export declare const WS_PROTOCOL_VERSION = 1;
6
6
  /** Max length of a `runCommand` command name, in UTF-16 code units. */
7
7
  export declare const MAX_COMMAND_LENGTH = 4096;
8
+ /** Max length of a join/leave `sessionId`, in UTF-16 code units. */
9
+ export declare const MAX_SESSION_ID_LENGTH = 128;
8
10
  /** Max length of a `pushFeed` action name, in UTF-16 code units. */
9
11
  export declare const MAX_FEED_ACTION_LENGTH = 256;
10
12
  /** Max serialized size of a `pushFeed` entry payload, in bytes. */
@@ -53,6 +55,8 @@ export type WsClientMessage = {
53
55
  attributes?: SessionAttributes;
54
56
  code?: string;
55
57
  role?: "player" | "spectator";
58
+ /** Client session holding this join; see `GameRuntimeTransport.joinServer`. */
59
+ sessionId?: string;
56
60
  } | {
57
61
  v: 1;
58
62
  t: "joinByCode";
@@ -72,6 +76,7 @@ export type WsClientMessage = {
72
76
  t: "leave";
73
77
  id: number;
74
78
  serverId: string;
79
+ sessionId?: string;
75
80
  } | {
76
81
  v: 1;
77
82
  t: "runCommand";