@jgengine/ws 0.18.0 → 0.18.1

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.
@@ -2,10 +2,13 @@ import { DEFAULT_POSE_SYNC_RULES, decidePoseSync, spawnPresenceState, } from "@j
2
2
  import { createPositionHistory } from "@jgengine/core/multiplayer/lagCompensation";
3
3
  import { createChatRateLimiter, DEFAULT_CHAT_BODY_LENGTH, DEFAULT_CHAT_HISTORY_LIMIT, DEFAULT_CHAT_RATE_LIMIT, } from "@jgengine/core/game/chat";
4
4
  import { createCommandMiddleware, } from "./commandMiddleware.js";
5
+ import { DEFAULT_COMMAND_LIMITS } from "./commandMiddleware.js";
5
6
  import { decodeWsClientMessage, encodeWsMessage, inspectWsDecodeFailure, subscriptionKey, } from "./protocol.js";
6
7
  export const DEFAULT_POSE_RULES = DEFAULT_POSE_SYNC_RULES;
7
8
  /** Cap on frames queued behind a connection's in-flight message; beyond this a flood gets rejected instead of piling up unbounded promises. */
8
9
  export const MAX_QUEUED_MESSAGES = 64;
10
+ /** Default reconnect grace period for hosted socket sessions. */
11
+ export const DEFAULT_GRACE_MS = 15_000;
9
12
  function appearanceEqual(a, b) {
10
13
  if (a === b)
11
14
  return true;
@@ -43,6 +46,7 @@ function dropUserFrom(store, userId, shouldVisit, onChanged) {
43
46
  export function createHostRouter(options) {
44
47
  const host = options.host;
45
48
  const now = options.now ?? Date.now;
49
+ const graceMs = options.graceMs ?? DEFAULT_GRACE_MS;
46
50
  const poseRules = options.poseRules ?? DEFAULT_POSE_RULES;
47
51
  const allowAnonymous = options.allowAnonymous === true;
48
52
  const singleSession = options.singleSession !== false;
@@ -50,6 +54,22 @@ export function createHostRouter(options) {
50
54
  (allowAnonymous ? ({ userId }) => userId : null);
51
55
  const connections = new Set();
52
56
  const sessionsByUserId = new Map();
57
+ const resumeTokens = new Map();
58
+ const pendingLeaves = new Map();
59
+ const ticketKey = (userId, serverId) => `${userId}|${serverId}`;
60
+ const ticketFor = (userId, serverId) => {
61
+ const token = resumeTokens.get(userId) ?? crypto.randomUUID();
62
+ resumeTokens.set(userId, token);
63
+ return { userId, serverId, token };
64
+ };
65
+ const clearPendingLeave = (userId, serverId) => {
66
+ const key = ticketKey(userId, serverId);
67
+ const timer = pendingLeaves.get(key);
68
+ if (timer === undefined)
69
+ return;
70
+ clearTimeout(timer);
71
+ pendingLeaves.delete(key);
72
+ };
53
73
  const subscribers = new Map();
54
74
  const subscribersOf = (key) => {
55
75
  const set = subscribers.get(key);
@@ -115,7 +135,7 @@ export function createHostRouter(options) {
115
135
  data: presenceRows(serverId),
116
136
  }));
117
137
  const commandMiddleware = createCommandMiddleware({
118
- limits: options.limits,
138
+ limits: options.limits ?? DEFAULT_COMMAND_LIMITS,
119
139
  authorize: options.authorize,
120
140
  validate: options.validate,
121
141
  });
@@ -221,7 +241,19 @@ export function createHostRouter(options) {
221
241
  if (connection.userId === null)
222
242
  return;
223
243
  if (channel === "server") {
224
- const data = await host.getServerView({ userId: connection.userId, serverId });
244
+ if (host.pullWorld !== undefined) {
245
+ const sinceRevision = connection.worldRevisions.get(serverId) ?? null;
246
+ const world = await host.pullWorld({ userId: connection.userId, serverId, sinceRevision, role: connection.roles.get(serverId) });
247
+ if (world !== null) {
248
+ connection.worldRevisions.set(serverId, world.revision);
249
+ const data = await host.getServerView({ userId: connection.userId, serverId, role: connection.roles.get(serverId) });
250
+ if (data !== null) {
251
+ send(connection, { v: 1, t: "update", channel, serverId, data: { ...data, serverState: world } });
252
+ }
253
+ return;
254
+ }
255
+ }
256
+ const data = await host.getServerView({ userId: connection.userId, serverId, role: connection.roles.get(serverId) });
225
257
  send(connection, { v: 1, t: "update", channel, serverId, data });
226
258
  }
227
259
  else if (channel === "player") {
@@ -336,7 +368,10 @@ export function createHostRouter(options) {
336
368
  connection.transport.close();
337
369
  return;
338
370
  }
339
- const userId = await authenticate({ userId: message.userId, token: message.token });
371
+ const resumeToken = resumeTokens.get(message.userId);
372
+ const userId = resumeToken !== undefined && message.token === resumeToken
373
+ ? message.userId
374
+ : await authenticate({ userId: message.userId, token: message.token });
340
375
  if (userId === null) {
341
376
  replyError(connection, message.id, "Not authenticated");
342
377
  connection.transport.close();
@@ -352,10 +387,20 @@ export function createHostRouter(options) {
352
387
  sessionsByUserId.set(userId, connection);
353
388
  }
354
389
  connection.userId = userId;
390
+ if (resumeToken !== undefined && message.token === resumeToken) {
391
+ for (const key of [...pendingLeaves.keys()]) {
392
+ if (key.startsWith(`${userId}|`)) {
393
+ const serverId = key.slice(userId.length + 1);
394
+ clearPendingLeave(userId, serverId);
395
+ }
396
+ }
397
+ }
355
398
  reply(connection, message.id, { userId });
356
399
  return;
357
400
  }
358
401
  if (message.t === "pose") {
402
+ if (connection.roles.get(message.serverId) === "spectator")
403
+ return;
359
404
  await handlePose(connection, message.serverId, message.pose);
360
405
  return;
361
406
  }
@@ -366,6 +411,9 @@ export function createHostRouter(options) {
366
411
  const userId = connection.userId;
367
412
  try {
368
413
  switch (message.t) {
414
+ case "ping":
415
+ send(connection, { v: 1, t: "pong", id: message.id, at: message.at, serverAt: now() });
416
+ break;
369
417
  case "join": {
370
418
  if (!(await gate(connection, message.id, "join", { serverId: message.serverId })))
371
419
  return;
@@ -375,9 +423,14 @@ export function createHostRouter(options) {
375
423
  serverId: message.serverId,
376
424
  attributes: message.attributes,
377
425
  code: message.code,
426
+ role: message.role,
378
427
  });
379
428
  connection.joinedServers.add(result.serverId);
380
- reply(connection, message.id, result);
429
+ connection.roles.set(result.serverId, message.role ?? "player");
430
+ reply(connection, message.id, {
431
+ ...result,
432
+ resumeTicket: ticketFor(userId, result.serverId),
433
+ });
381
434
  return;
382
435
  }
383
436
  case "joinByCode": {
@@ -388,9 +441,13 @@ export function createHostRouter(options) {
388
441
  gameId: message.gameId,
389
442
  code: message.code,
390
443
  });
391
- if (result !== null)
444
+ if (result !== null) {
392
445
  connection.joinedServers.add(result.serverId);
393
- reply(connection, message.id, result);
446
+ connection.roles.set(result.serverId, message.role ?? "player");
447
+ }
448
+ reply(connection, message.id, result === null
449
+ ? null
450
+ : { ...result, resumeTicket: ticketFor(userId, result.serverId) });
394
451
  return;
395
452
  }
396
453
  case "browse": {
@@ -406,13 +463,19 @@ export function createHostRouter(options) {
406
463
  }
407
464
  case "leave": {
408
465
  await host.leaveServer({ userId, serverId: message.serverId });
466
+ clearPendingLeave(userId, message.serverId);
409
467
  connection.joinedServers.delete(message.serverId);
468
+ connection.roles.delete(message.serverId);
410
469
  dropPresenceForServer(userId, message.serverId);
411
470
  dropVoiceForServer(userId, message.serverId);
412
471
  reply(connection, message.id, null);
413
472
  return;
414
473
  }
415
474
  case "runCommand": {
475
+ if (connection.roles.get(message.serverId) === "spectator") {
476
+ replyError(connection, message.id, "Spectators cannot run commands");
477
+ return;
478
+ }
416
479
  if (!(await gate(connection, message.id, "runCommand", {
417
480
  serverId: message.serverId,
418
481
  command: message.command,
@@ -490,6 +553,8 @@ export function createHostRouter(options) {
490
553
  replyError(connection, message.id, "Not a member of this server");
491
554
  return;
492
555
  }
556
+ if (message.channel === "server")
557
+ connection.worldRevisions.set(message.serverId, null);
493
558
  addSubscription(connection, subscriptionKey(message.channel, message.serverId, message.action));
494
559
  reply(connection, message.id, null);
495
560
  await pushSubscription(connection, message.channel, message.serverId, message.action);
@@ -513,7 +578,12 @@ export function createHostRouter(options) {
513
578
  const servers = [...connection.joinedServers];
514
579
  connection.joinedServers.clear();
515
580
  for (const serverId of servers) {
516
- void host.leaveServer({ userId, serverId }).catch(() => undefined);
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));
517
587
  }
518
588
  };
519
589
  return {
@@ -525,6 +595,8 @@ export function createHostRouter(options) {
525
595
  joinedServers: new Set(),
526
596
  queue: Promise.resolve(),
527
597
  queuedMessages: 0,
598
+ worldRevisions: new Map(),
599
+ roles: new Map(),
528
600
  };
529
601
  connections.add(connection);
530
602
  return {
@@ -579,6 +651,9 @@ export function createHostRouter(options) {
579
651
  },
580
652
  close: () => {
581
653
  unsubscribeHost();
654
+ for (const timer of pendingLeaves.values())
655
+ clearTimeout(timer);
656
+ pendingLeaves.clear();
582
657
  connections.clear();
583
658
  subscribers.clear();
584
659
  },
@@ -34,6 +34,11 @@ export type WsVoiceParticipant = {
34
34
  streamId?: string;
35
35
  };
36
36
  export type WsClientMessage = {
37
+ v: 1;
38
+ t: "ping";
39
+ id: number;
40
+ at: number;
41
+ } | {
37
42
  v: 1;
38
43
  t: "hello";
39
44
  id: number;
@@ -47,12 +52,14 @@ export type WsClientMessage = {
47
52
  serverId?: string;
48
53
  attributes?: SessionAttributes;
49
54
  code?: string;
55
+ role?: "player" | "spectator";
50
56
  } | {
51
57
  v: 1;
52
58
  t: "joinByCode";
53
59
  id: number;
54
60
  gameId: string;
55
61
  code: string;
62
+ role?: "player" | "spectator";
56
63
  } | {
57
64
  v: 1;
58
65
  t: "browse";
@@ -167,6 +174,12 @@ export type WsUpdateMessage = {
167
174
  data: WsVoiceParticipant[];
168
175
  };
169
176
  export type WsServerMessage = {
177
+ v: 1;
178
+ t: "pong";
179
+ id: number;
180
+ at: number;
181
+ serverAt: number;
182
+ } | {
170
183
  v: 1;
171
184
  t: "reply";
172
185
  id: number;
package/dist/protocol.js CHANGED
@@ -113,6 +113,8 @@ export function decodeWsClientMessage(raw) {
113
113
  if (message === null)
114
114
  return null;
115
115
  switch (message.t) {
116
+ case "ping":
117
+ return typeof message.id === "number" && typeof message.at === "number" ? message : null;
116
118
  case "hello":
117
119
  return typeof message.id === "number" &&
118
120
  typeof message.userId === "string" &&
@@ -123,13 +125,15 @@ export function decodeWsClientMessage(raw) {
123
125
  return typeof message.id === "number" &&
124
126
  typeof message.gameId === "string" &&
125
127
  (message.serverId === undefined || typeof message.serverId === "string") &&
126
- (message.code === undefined || typeof message.code === "string")
128
+ (message.code === undefined || typeof message.code === "string") &&
129
+ (message.role === undefined || message.role === "player" || message.role === "spectator")
127
130
  ? message
128
131
  : null;
129
132
  case "joinByCode":
130
133
  return typeof message.id === "number" &&
131
134
  typeof message.gameId === "string" &&
132
- typeof message.code === "string"
135
+ typeof message.code === "string" &&
136
+ (message.role === undefined || message.role === "player" || message.role === "spectator")
133
137
  ? message
134
138
  : null;
135
139
  case "browse":
@@ -204,6 +208,8 @@ export function decodeWsServerMessage(raw) {
204
208
  if (message === null)
205
209
  return null;
206
210
  switch (message.t) {
211
+ case "pong":
212
+ return typeof message.id === "number" && typeof message.at === "number" && typeof message.serverAt === "number" ? message : null;
207
213
  case "reply":
208
214
  if (typeof message.id !== "number")
209
215
  return null;
@@ -9,7 +9,7 @@ export interface WorldGameHostOptions {
9
9
  session(args: {
10
10
  gameId: string;
11
11
  serverId: string;
12
- }): HostedWorldSession | null;
12
+ }): HostedWorldSession | Promise<HostedWorldSession | null> | null;
13
13
  now?: () => number;
14
14
  }
15
15
  /** A {@link GameHost} whose worlds run on `HostedWorldSession`s; `tick` advances them and re-broadcasts on change. */
package/dist/worldHost.js CHANGED
@@ -7,6 +7,7 @@ import { INPUT_COMMAND } from "@jgengine/core/runtime/hostedGameRunner";
7
7
  */
8
8
  export function createWorldGameHost(options) {
9
9
  const live = new Map();
10
+ const roles = new Map();
10
11
  const listeners = new Set();
11
12
  const now = options.now ?? (() => Date.now());
12
13
  function emit(event) {
@@ -17,10 +18,19 @@ export function createWorldGameHost(options) {
17
18
  const existing = live.get(serverId);
18
19
  if (existing !== undefined)
19
20
  return existing;
20
- const session = options.session({ gameId, serverId });
21
- if (session === null)
21
+ const resolved = options.session({ gameId, serverId });
22
+ if (resolved instanceof Promise) {
23
+ return resolved.then((session) => {
24
+ if (session === null)
25
+ return null;
26
+ const entry = { gameId, session };
27
+ live.set(serverId, entry);
28
+ return entry;
29
+ });
30
+ }
31
+ if (resolved === null)
22
32
  return null;
23
- const entry = { gameId, session };
33
+ const entry = { gameId, session: resolved };
24
34
  live.set(serverId, entry);
25
35
  return entry;
26
36
  }
@@ -33,13 +43,21 @@ export function createWorldGameHost(options) {
33
43
  }
34
44
  }
35
45
  return {
36
- async joinServer({ userId, gameId, serverId }) {
46
+ async joinServer({ userId, gameId, serverId, role }) {
37
47
  const id = serverId ?? gameId;
38
- const entry = ensure(gameId, id);
48
+ const pending = ensure(gameId, id);
49
+ const entry = pending instanceof Promise ? await pending : pending;
39
50
  if (entry === null)
40
51
  throw new Error(`no hosted world for game "${gameId}"`);
41
52
  const isNew = !entry.session.members().includes(userId);
42
- entry.session.join(userId, isNew);
53
+ let serverRoles = roles.get(id);
54
+ if (serverRoles === undefined) {
55
+ serverRoles = new Map();
56
+ roles.set(id, serverRoles);
57
+ }
58
+ serverRoles.set(userId, role ?? "player");
59
+ if (role !== "spectator")
60
+ entry.session.join(userId, isNew);
43
61
  emit({ type: "server", serverId: id });
44
62
  emit({ type: "player", serverId: id, userId });
45
63
  return { serverId: id, isNew };
@@ -48,7 +66,9 @@ export function createWorldGameHost(options) {
48
66
  const entry = live.get(serverId);
49
67
  if (entry === undefined)
50
68
  return;
51
- entry.session.leave(userId);
69
+ if (entry.session.members().includes(userId))
70
+ entry.session.leave(userId);
71
+ roles.get(serverId)?.delete(userId);
52
72
  emit({ type: "server", serverId });
53
73
  },
54
74
  async runCommand({ userId, serverId, command, input }) {
@@ -70,9 +90,9 @@ export function createWorldGameHost(options) {
70
90
  return { ok: true };
71
91
  },
72
92
  async isMember({ userId, serverId }) {
73
- return live.get(serverId)?.session.members().includes(userId) ?? false;
93
+ return live.get(serverId)?.session.members().includes(userId) || roles.get(serverId)?.has(userId) === true;
74
94
  },
75
- async getServerView({ userId, serverId }) {
95
+ async getServerView({ userId, serverId, role }) {
76
96
  const entry = live.get(serverId);
77
97
  if (entry === undefined)
78
98
  return null;
@@ -81,10 +101,26 @@ export function createWorldGameHost(options) {
81
101
  gameId: entry.gameId,
82
102
  revision: entry.session.revision(),
83
103
  memberUserIds: [...entry.session.members()],
84
- serverState: entry.session.snapshotFor({ userId }),
104
+ serverState: entry.session.snapshotFor({ userId, role: role ?? roles.get(serverId)?.get(userId) ?? "player" }),
85
105
  updatedAt: now(),
86
106
  };
87
107
  },
108
+ async pullWorld({ userId, serverId, sinceRevision, role }) {
109
+ const entry = live.get(serverId);
110
+ if (entry === undefined || (!entry.session.members().includes(userId) && roles.get(serverId)?.get(userId) !== "spectator"))
111
+ return null;
112
+ if (entry.session.projectsViewers()) {
113
+ return {
114
+ kind: "baseline",
115
+ revision: entry.session.revision(),
116
+ snapshot: entry.session.snapshotFor({ userId, role: role ?? roles.get(serverId)?.get(userId) ?? "player" }),
117
+ };
118
+ }
119
+ const sync = entry.session.pull(sinceRevision);
120
+ return sync.kind === "baseline"
121
+ ? sync
122
+ : { kind: "diff", revision: sync.diff.revision, diff: sync.diff };
123
+ },
88
124
  async getPlayerView() {
89
125
  return null;
90
126
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jgengine/ws",
3
- "version": "0.18.0",
3
+ "version": "0.18.1",
4
4
  "description": "Browser-safe WebSocket client backend for JGengine: protocol codec, createWsBackend, and HTTP read helpers.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -31,7 +31,7 @@
31
31
  "test": "bun test src"
32
32
  },
33
33
  "dependencies": {
34
- "@jgengine/core": "^0.18.0"
34
+ "@jgengine/core": "^0.18.1"
35
35
  },
36
36
  "publishConfig": {
37
37
  "access": "public"
@@ -146,4 +146,4 @@ When sufficient, return a concise orientation: objective, progress, decisions, c
146
146
  - Multi-session engine work often spans `docs/plans/`, GitHub issues, and stacked PRs — point at those; do not paste full plan bodies.
147
147
  - Prefer `bun run agent:bootstrap --check` status, branch names, and PR links as state anchors.
148
148
  - Local parallelism uses `.claude/worktrees/`; never invent `C:\tmp\…` Codex-shared trees.
149
- - Papercuts and durable process changes belong in `PAPERCUTS.md` / skills, not only the handoff.
149
+ - Durable process changes belong in skills, not only the handoff.
@@ -9,6 +9,8 @@ Read the JGengine README for package truth, commands, layering, and license —
9
9
 
10
10
  ## 1. Establish the target
11
11
 
12
+ For greenfield builds, write `src/art-direction.md` before any code.
13
+
12
14
  Treat the pitch as a unique composition of needs, not a genre to fill in — never reach for a genre kit, preset, archetype, or class template ("default sports car", "default RPG", "default FPS") in the SDK or skills (see [AGENTS.md](../../../AGENTS.md)). Use `game-design` to turn the pitch into a testable player promise, verbs, loops, progression, failure, and completion scenario. Use `level-design` when play depends on authored spaces, routes, encounters, exploration, or spatial pacing. Capture only decisions that change architecture:
13
15
 
14
16
  - player point of view and controls