@jgengine/ws 0.19.0 → 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.
@@ -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();
@@ -77,6 +84,45 @@ export function createHostRouter(options) {
77
84
  }
78
85
  return false;
79
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
+ };
80
126
  const subscribers = new Map();
81
127
  const subscribersOf = (key) => {
82
128
  const set = subscribers.get(key);
@@ -84,6 +130,13 @@ export function createHostRouter(options) {
84
130
  };
85
131
  const addSubscription = (connection, key) => {
86
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
+ }
87
140
  let set = subscribers.get(key);
88
141
  if (set === undefined) {
89
142
  set = new Set();
@@ -93,6 +146,13 @@ export function createHostRouter(options) {
93
146
  };
94
147
  const removeSubscription = (connection, key) => {
95
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
+ }
96
156
  const set = subscribers.get(key);
97
157
  if (set === undefined)
98
158
  return;
@@ -105,6 +165,8 @@ export function createHostRouter(options) {
105
165
  const histories = new Map();
106
166
  const historyFor = (serverId) => getOrCreate(histories, serverId, () => createPositionHistory({ historyMs: positionHistoryMs }));
107
167
  const send = (connection, message) => {
168
+ if (closed || connection.closed)
169
+ return;
108
170
  connection.transport.send(encodeWsMessage(message));
109
171
  };
110
172
  const reply = (connection, id, result) => {
@@ -156,6 +218,8 @@ export function createHostRouter(options) {
156
218
  atMs: now(),
157
219
  ...args,
158
220
  });
221
+ if (closed || connection.closed)
222
+ return false;
159
223
  if (!decision.allow) {
160
224
  replyError(connection, id, decision.reason);
161
225
  return false;
@@ -244,32 +308,42 @@ export function createHostRouter(options) {
244
308
  reply(connection, message.id, null);
245
309
  broadcastChat(message.serverId, message.channelId);
246
310
  };
247
- const pushSubscription = async (connection, channel, serverId, action) => {
248
- if (connection.userId === null)
311
+ const pushSubscription = async (connection, channel, serverId, action, isCurrent) => {
312
+ if (connection.userId === null || !isCurrent())
249
313
  return;
314
+ if (host.pullWorld !== undefined) {
315
+ if (!(await host.isMember({ userId: connection.userId, serverId })) || !isCurrent())
316
+ return;
317
+ }
250
318
  if (channel === "server") {
251
319
  if (host.pullWorld !== undefined) {
252
320
  const sinceRevision = connection.worldRevisions.get(serverId) ?? null;
253
321
  const world = await host.pullWorld({ userId: connection.userId, serverId, sinceRevision, role: connection.roles.get(serverId) });
322
+ if (!isCurrent())
323
+ return;
254
324
  if (world !== null) {
255
- connection.worldRevisions.set(serverId, world.revision);
256
325
  const data = await host.getServerView({ userId: connection.userId, serverId, role: connection.roles.get(serverId) });
257
- if (data !== null) {
326
+ if (data !== null && isCurrent()) {
258
327
  send(connection, { v: 1, t: "update", channel, serverId, data: { ...data, serverState: world } });
328
+ connection.worldRevisions.set(serverId, world.revision);
259
329
  }
260
330
  return;
261
331
  }
332
+ return;
262
333
  }
263
334
  const data = await host.getServerView({ userId: connection.userId, serverId, role: connection.roles.get(serverId) });
264
- send(connection, { v: 1, t: "update", channel, serverId, data });
335
+ if (isCurrent())
336
+ send(connection, { v: 1, t: "update", channel, serverId, data });
265
337
  }
266
338
  else if (channel === "player") {
267
339
  const data = await host.getPlayerView({ userId: connection.userId, serverId });
268
- send(connection, { v: 1, t: "update", channel, serverId, data });
340
+ if (isCurrent())
341
+ send(connection, { v: 1, t: "update", channel, serverId, data });
269
342
  }
270
343
  else if (channel === "feed") {
271
344
  const data = await host.getFeed({ userId: connection.userId, serverId, action: action ?? "" });
272
- 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 });
273
347
  }
274
348
  else if (channel === "chat") {
275
349
  const channelId = action ?? "";
@@ -297,18 +371,50 @@ export function createHostRouter(options) {
297
371
  send(connection, { v: 1, t: "update", channel, serverId, data: presenceRows(serverId) });
298
372
  }
299
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
+ };
300
406
  const onHostEvent = (event) => {
301
407
  if (event.type === "server") {
302
408
  for (const connection of subscribersOf(subscriptionKey("server", event.serverId))) {
303
409
  if (connection.userId === null)
304
410
  continue;
305
- void pushSubscription(connection, "server", event.serverId);
411
+ void requestSubscriptionPush(connection, "server", event.serverId);
306
412
  }
307
413
  }
308
414
  else if (event.type === "player") {
309
415
  for (const connection of subscribersOf(subscriptionKey("player", event.serverId))) {
310
416
  if (connection.userId === event.userId) {
311
- void pushSubscription(connection, "player", event.serverId);
417
+ void requestSubscriptionPush(connection, "player", event.serverId);
312
418
  }
313
419
  }
314
420
  }
@@ -316,7 +422,7 @@ export function createHostRouter(options) {
316
422
  for (const connection of subscribersOf(subscriptionKey("feed", event.serverId, event.action))) {
317
423
  if (connection.userId === null)
318
424
  continue;
319
- void pushSubscription(connection, "feed", event.serverId, event.action);
425
+ void requestSubscriptionPush(connection, "feed", event.serverId, event.action);
320
426
  }
321
427
  }
322
428
  };
@@ -365,6 +471,8 @@ export function createHostRouter(options) {
365
471
  dropUserFrom(presence, userId, (key) => key === serverId, () => broadcastPresence(serverId));
366
472
  };
367
473
  const handleMessage = async (connection, message) => {
474
+ if (closed || connection.closed)
475
+ return;
368
476
  if (message.t === "hello") {
369
477
  if (connection.userId !== null) {
370
478
  replyError(connection, message.id, "Already authenticated");
@@ -379,6 +487,8 @@ export function createHostRouter(options) {
379
487
  const userId = resumeToken !== undefined && message.token === resumeToken
380
488
  ? message.userId
381
489
  : await authenticate({ userId: message.userId, token: message.token });
490
+ if (closed || connection.closed)
491
+ return;
382
492
  if (userId === null) {
383
493
  replyError(connection, message.id, "Not authenticated");
384
494
  connection.transport.close();
@@ -424,18 +534,18 @@ export function createHostRouter(options) {
424
534
  case "join": {
425
535
  if (!(await gate(connection, message.id, "join", { serverId: message.serverId })))
426
536
  return;
427
- const result = await host.joinServer({
537
+ const result = await admit(connection, userId, () => host.joinServer({
428
538
  userId,
429
539
  gameId: message.gameId,
430
540
  serverId: message.serverId,
431
541
  attributes: message.attributes,
432
542
  code: message.code,
433
543
  role: message.role,
434
- });
435
- connection.joinedServers.add(result.serverId);
436
- connection.roles.set(result.serverId, message.role ?? "player");
437
- if (message.sessionId !== undefined)
438
- getOrCreate(connection.sessions, result.serverId, () => new Set()).add(message.sessionId);
544
+ }), message.role, message.sessionId, message.serverId);
545
+ if (result === null)
546
+ return;
547
+ if (closed || connection.closed || connection.userId !== userId)
548
+ return;
439
549
  reply(connection, message.id, {
440
550
  ...result,
441
551
  resumeTicket: ticketFor(userId, result.serverId),
@@ -445,14 +555,14 @@ export function createHostRouter(options) {
445
555
  case "joinByCode": {
446
556
  if (!(await gate(connection, message.id, "join", {})))
447
557
  return;
448
- const result = await host.joinByCode({
558
+ const result = await admit(connection, userId, () => host.joinByCode({
449
559
  userId,
450
560
  gameId: message.gameId,
451
561
  code: message.code,
452
- });
562
+ }), message.role);
453
563
  if (result !== null) {
454
- connection.joinedServers.add(result.serverId);
455
- connection.roles.set(result.serverId, message.role ?? "player");
564
+ if (closed || connection.closed || connection.userId !== userId)
565
+ return;
456
566
  }
457
567
  reply(connection, message.id, result === null
458
568
  ? null
@@ -566,18 +676,20 @@ export function createHostRouter(options) {
566
676
  return;
567
677
  }
568
678
  case "subscribe": {
569
- if ((message.channel === "chat" ||
679
+ if ((host.pullWorld !== undefined || message.channel === "chat" ||
570
680
  message.channel === "voice" ||
571
681
  message.channel === "presence") &&
572
682
  !(await host.isMember({ userId, serverId: message.serverId }))) {
573
683
  replyError(connection, message.id, "Not a member of this server");
574
684
  return;
575
685
  }
686
+ if (closed || connection.closed)
687
+ return;
576
688
  if (message.channel === "server")
577
689
  connection.worldRevisions.set(message.serverId, null);
578
690
  addSubscription(connection, subscriptionKey(message.channel, message.serverId, message.action));
579
691
  reply(connection, message.id, null);
580
- await pushSubscription(connection, message.channel, message.serverId, message.action);
692
+ void requestSubscriptionPush(connection, message.channel, message.serverId, message.action);
581
693
  return;
582
694
  }
583
695
  case "unsubscribe": {
@@ -592,26 +704,22 @@ export function createHostRouter(options) {
592
704
  }
593
705
  };
594
706
  const leaveJoinedServers = (connection) => {
595
- if (connection.userId === null)
707
+ if (closed || connection.userId === null)
596
708
  return;
597
709
  const userId = connection.userId;
598
710
  const servers = [...connection.joinedServers];
599
711
  connection.joinedServers.clear();
600
712
  connection.sessions.clear();
601
713
  for (const serverId of servers) {
602
- const key = ticketKey(userId, serverId);
603
- clearPendingLeave(userId, serverId);
604
- pendingLeaves.set(key, setTimeout(() => {
605
- pendingLeaves.delete(key);
606
- if (heldByOtherConnection(connection, userId, serverId))
607
- return;
608
- void host.leaveServer({ userId, serverId }).catch(() => undefined);
609
- }, graceMs));
714
+ scheduleLeave(connection, userId, serverId);
610
715
  }
611
716
  };
612
717
  return {
613
718
  connect: (transport) => {
719
+ if (closed)
720
+ throw new Error("Host router is closed");
614
721
  const connection = {
722
+ closed: false,
615
723
  transport,
616
724
  userId: null,
617
725
  subscriptions: new Set(),
@@ -621,10 +729,14 @@ export function createHostRouter(options) {
621
729
  queuedMessages: 0,
622
730
  worldRevisions: new Map(),
623
731
  roles: new Map(),
732
+ subscriptionPushes: new Map(),
733
+ admission: null,
624
734
  };
625
735
  connections.add(connection);
626
736
  return {
627
737
  handleRaw: (raw) => {
738
+ if (closed || connection.closed)
739
+ return;
628
740
  const text = typeof raw === "string" ? raw : String(raw);
629
741
  const message = decodeWsClientMessage(text);
630
742
  if (message === null) {
@@ -640,14 +752,17 @@ export function createHostRouter(options) {
640
752
  return;
641
753
  }
642
754
  connection.queuedMessages += 1;
643
- connection.queue = connection.queue
755
+ connection.queue = track(connection.queue
644
756
  .then(() => handleMessage(connection, message))
645
757
  .catch(() => undefined)
646
758
  .finally(() => {
647
759
  connection.queuedMessages -= 1;
648
- });
760
+ }));
649
761
  },
650
762
  close: () => {
763
+ if (connection.closed)
764
+ return;
765
+ connection.closed = true;
651
766
  if (connection.userId !== null && sessionsByUserId.get(connection.userId) === connection) {
652
767
  sessionsByUserId.delete(connection.userId);
653
768
  }
@@ -674,6 +789,9 @@ export function createHostRouter(options) {
674
789
  return positions;
675
790
  },
676
791
  close: () => {
792
+ if (closed)
793
+ return;
794
+ closed = true;
677
795
  unsubscribeHost();
678
796
  for (const timer of pendingLeaves.values())
679
797
  clearTimeout(timer);
@@ -681,6 +799,10 @@ export function createHostRouter(options) {
681
799
  connections.clear();
682
800
  subscribers.clear();
683
801
  },
802
+ async drain() {
803
+ while (work.size > 0)
804
+ await Promise.allSettled([...work]);
805
+ },
684
806
  };
685
807
  }
686
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
  },
@@ -4,18 +4,29 @@ import type { GameHost } from "./host.js";
4
4
  export interface WorldGameHostOptions {
5
5
  /**
6
6
  * Resolve (or lazily build) the authoritative {@link HostedWorldSession} for a server — called once per new
7
- * `serverId`. Return `null` for an unknown game. Bind the game's definition + content here.
7
+ * loaded `serverId`, and again after successful unload. Create a fresh session on reload. Return `null` for an
8
+ * unknown game. Bind the game's definition + content here.
8
9
  */
9
10
  session(args: {
10
11
  gameId: string;
11
12
  serverId: string;
12
13
  }): HostedWorldSession | Promise<HostedWorldSession | null> | null;
13
14
  now?: () => number;
15
+ /** Maximum active players per world; reconnecting members and spectators do not consume a new slot. */
16
+ slotsPerServer?: number;
14
17
  }
15
- /** A {@link GameHost} whose worlds run on `HostedWorldSession`s; `tick` advances them and re-broadcasts on change. */
18
+ /** A {@link GameHost} backed by `HostedWorldSession`s; `tick` advances worlds and `unload` explicitly saves and releases idle world references. */
16
19
  export interface WorldGameHost extends GameHost {
17
20
  /** Advance every live world by `dtSeconds` and emit a `server` change for each whose revision moved. */
18
21
  tick(dtSeconds: number): void;
22
+ /**
23
+ * Save and release an unoccupied world's host references. Returns `occupied` for admitted players/spectators or
24
+ * resident members, and `missing` for an unloaded world. Save failure rejects and retains the world for retry.
25
+ * Later joins resolve a fresh session; external resources remain caller-owned. Closed hosts reject new unloads.
26
+ */
27
+ unload(serverId: string): Promise<"unloaded" | "missing" | "occupied">;
28
+ /** Permanently close admission/ticks, drain accepted world operations, and save. Repeated calls share completion. */
29
+ stop(): Promise<void>;
19
30
  }
20
31
  /**
21
32
  * The GameContext-loop counterpart of the reducer `createGameHost`: a structural {@link GameHost} that serves each