@signe/room 2.10.0 → 3.0.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.
Files changed (84) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/dist/chunk-EUXUH3YW.js +15 -0
  3. package/dist/chunk-EUXUH3YW.js.map +1 -0
  4. package/dist/cloudflare/index.d.ts +71 -0
  5. package/dist/cloudflare/index.js +320 -0
  6. package/dist/cloudflare/index.js.map +1 -0
  7. package/dist/index.d.ts +87 -188
  8. package/dist/index.js +860 -114
  9. package/dist/index.js.map +1 -1
  10. package/dist/node/index.d.ts +164 -0
  11. package/dist/node/index.js +786 -0
  12. package/dist/node/index.js.map +1 -0
  13. package/dist/party-dNs-hqkq.d.ts +175 -0
  14. package/examples/cloudflare/README.md +62 -0
  15. package/examples/cloudflare/node_modules/.bin/tsc +17 -0
  16. package/examples/cloudflare/node_modules/.bin/tsserver +17 -0
  17. package/examples/cloudflare/node_modules/.bin/wrangler +17 -0
  18. package/examples/cloudflare/node_modules/.bin/wrangler2 +17 -0
  19. package/examples/cloudflare/package.json +24 -0
  20. package/examples/cloudflare/public/index.html +443 -0
  21. package/examples/cloudflare/src/index.ts +28 -0
  22. package/examples/cloudflare/src/room.ts +44 -0
  23. package/examples/cloudflare/tsconfig.json +10 -0
  24. package/examples/cloudflare/wrangler.jsonc +25 -0
  25. package/examples/node/README.md +57 -0
  26. package/examples/node/node_modules/.bin/tsc +17 -0
  27. package/examples/node/node_modules/.bin/tsserver +17 -0
  28. package/examples/node/node_modules/.bin/tsx +17 -0
  29. package/examples/node/package.json +23 -0
  30. package/examples/node/public/index.html +443 -0
  31. package/examples/node/room.ts +44 -0
  32. package/examples/node/server.sqlite.ts +52 -0
  33. package/examples/node/server.ts +51 -0
  34. package/examples/node/tsconfig.json +10 -0
  35. package/examples/node-game/README.md +66 -0
  36. package/examples/node-game/package.json +23 -0
  37. package/examples/node-game/public/index.html +705 -0
  38. package/examples/node-game/room.ts +145 -0
  39. package/examples/node-game/server.sqlite.ts +54 -0
  40. package/examples/node-game/server.ts +53 -0
  41. package/examples/node-game/tsconfig.json +10 -0
  42. package/examples/node-shard/README.md +32 -0
  43. package/examples/node-shard/dev.ts +39 -0
  44. package/examples/node-shard/package.json +24 -0
  45. package/examples/node-shard/public/index.html +777 -0
  46. package/examples/node-shard/room-server.ts +68 -0
  47. package/examples/node-shard/room.ts +105 -0
  48. package/examples/node-shard/shared.ts +6 -0
  49. package/examples/node-shard/tsconfig.json +14 -0
  50. package/examples/node-shard/world-server.ts +169 -0
  51. package/package.json +14 -5
  52. package/readme.md +418 -4
  53. package/src/cloudflare/index.ts +474 -0
  54. package/src/index.ts +2 -2
  55. package/src/jwt.ts +1 -5
  56. package/src/mock.ts +29 -7
  57. package/src/node/index.ts +1112 -0
  58. package/src/server.ts +781 -60
  59. package/src/session.guard.ts +6 -2
  60. package/src/shard.ts +91 -23
  61. package/src/storage.ts +29 -5
  62. package/src/testing.ts +4 -3
  63. package/src/types/party.ts +30 -1
  64. package/src/world.guard.ts +23 -4
  65. package/src/world.ts +121 -21
  66. package/tests/storage-restore.spec.ts +122 -0
  67. package/examples/game/.vscode/launch.json +0 -11
  68. package/examples/game/.vscode/settings.json +0 -11
  69. package/examples/game/README.md +0 -40
  70. package/examples/game/app/client.tsx +0 -15
  71. package/examples/game/app/components/Admin.tsx +0 -1089
  72. package/examples/game/app/components/Room.tsx +0 -162
  73. package/examples/game/app/styles.css +0 -31
  74. package/examples/game/package-lock.json +0 -225
  75. package/examples/game/package.json +0 -20
  76. package/examples/game/party/game.room.ts +0 -32
  77. package/examples/game/party/server.ts +0 -10
  78. package/examples/game/party/shard.ts +0 -5
  79. package/examples/game/partykit.json +0 -14
  80. package/examples/game/public/favicon.ico +0 -0
  81. package/examples/game/public/index.html +0 -27
  82. package/examples/game/public/normalize.css +0 -351
  83. package/examples/game/shared/room.schema.ts +0 -14
  84. package/examples/game/tsconfig.json +0 -109
@@ -0,0 +1,777 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Signe Node Shard</title>
7
+ <style>
8
+ :root {
9
+ color-scheme: light;
10
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
11
+ background: #f6f8fb;
12
+ color: #17191f;
13
+ }
14
+
15
+ * {
16
+ box-sizing: border-box;
17
+ }
18
+
19
+ body {
20
+ margin: 0;
21
+ min-height: 100vh;
22
+ background:
23
+ linear-gradient(180deg, #eef4fb 0, #f6f8fb 260px),
24
+ #f6f8fb;
25
+ }
26
+
27
+ main {
28
+ width: min(1180px, calc(100vw - 32px));
29
+ margin: 0 auto;
30
+ padding: 28px 0;
31
+ display: grid;
32
+ gap: 18px;
33
+ }
34
+
35
+ header {
36
+ display: flex;
37
+ align-items: end;
38
+ justify-content: space-between;
39
+ gap: 16px;
40
+ }
41
+
42
+ h1,
43
+ h2,
44
+ h3,
45
+ p {
46
+ margin: 0;
47
+ }
48
+
49
+ h1 {
50
+ font-size: 30px;
51
+ line-height: 1.1;
52
+ }
53
+
54
+ h2 {
55
+ font-size: 17px;
56
+ line-height: 1.2;
57
+ }
58
+
59
+ h3 {
60
+ font-size: 14px;
61
+ }
62
+
63
+ .muted,
64
+ .meta,
65
+ .status {
66
+ color: #5b6472;
67
+ }
68
+
69
+ .layout {
70
+ display: grid;
71
+ grid-template-columns: 360px minmax(0, 1fr);
72
+ gap: 18px;
73
+ align-items: start;
74
+ }
75
+
76
+ .stack {
77
+ display: grid;
78
+ gap: 14px;
79
+ }
80
+
81
+ .panel,
82
+ .card {
83
+ border: 1px solid #d7dde6;
84
+ border-radius: 8px;
85
+ background: #ffffff;
86
+ }
87
+
88
+ .panel {
89
+ padding: 18px;
90
+ display: grid;
91
+ gap: 14px;
92
+ }
93
+
94
+ .section-title {
95
+ display: flex;
96
+ align-items: center;
97
+ justify-content: space-between;
98
+ gap: 12px;
99
+ }
100
+
101
+ form,
102
+ .controls {
103
+ display: grid;
104
+ gap: 12px;
105
+ }
106
+
107
+ label {
108
+ display: grid;
109
+ gap: 6px;
110
+ font-size: 13px;
111
+ font-weight: 650;
112
+ }
113
+
114
+ input,
115
+ select {
116
+ width: 100%;
117
+ min-height: 40px;
118
+ border: 1px solid #c8d0da;
119
+ border-radius: 6px;
120
+ background: #ffffff;
121
+ color: #17191f;
122
+ padding: 0 10px;
123
+ font: inherit;
124
+ }
125
+
126
+ button {
127
+ min-height: 38px;
128
+ border: 1px solid #1f2937;
129
+ border-radius: 6px;
130
+ background: #1f2937;
131
+ color: #ffffff;
132
+ padding: 0 12px;
133
+ font: inherit;
134
+ cursor: pointer;
135
+ }
136
+
137
+ button.secondary {
138
+ background: #ffffff;
139
+ color: #1f2937;
140
+ }
141
+
142
+ button.small {
143
+ min-height: 30px;
144
+ padding: 0 9px;
145
+ font-size: 12px;
146
+ }
147
+
148
+ button:disabled {
149
+ cursor: not-allowed;
150
+ opacity: 0.55;
151
+ }
152
+
153
+ .row {
154
+ display: flex;
155
+ align-items: center;
156
+ gap: 10px;
157
+ flex-wrap: wrap;
158
+ }
159
+
160
+ .split {
161
+ display: grid;
162
+ grid-template-columns: 1fr 120px;
163
+ gap: 10px;
164
+ }
165
+
166
+ .metrics {
167
+ display: grid;
168
+ grid-template-columns: repeat(3, minmax(0, 1fr));
169
+ gap: 10px;
170
+ }
171
+
172
+ .metric {
173
+ border: 1px solid #e0e5ec;
174
+ border-radius: 8px;
175
+ padding: 12px;
176
+ background: #fbfcfe;
177
+ }
178
+
179
+ .metric strong {
180
+ display: block;
181
+ font-size: 24px;
182
+ }
183
+
184
+ .rooms,
185
+ .shards,
186
+ .users {
187
+ display: grid;
188
+ gap: 8px;
189
+ }
190
+
191
+ .room-item,
192
+ .shard-item,
193
+ .user-item {
194
+ border: 1px solid #e0e5ec;
195
+ border-radius: 8px;
196
+ padding: 12px;
197
+ display: grid;
198
+ gap: 10px;
199
+ background: #ffffff;
200
+ }
201
+
202
+ .room-line,
203
+ .shard-line,
204
+ .user-line {
205
+ display: flex;
206
+ align-items: center;
207
+ justify-content: space-between;
208
+ gap: 12px;
209
+ }
210
+
211
+ .mono {
212
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
213
+ font-size: 12px;
214
+ }
215
+
216
+ .badge {
217
+ display: inline-flex;
218
+ align-items: center;
219
+ justify-content: center;
220
+ min-height: 24px;
221
+ border-radius: 999px;
222
+ padding: 2px 8px;
223
+ background: #eef2f6;
224
+ color: #4b5563;
225
+ font-size: 12px;
226
+ white-space: nowrap;
227
+ }
228
+
229
+ .badge.active {
230
+ background: #e7f7ed;
231
+ color: #146c36;
232
+ }
233
+
234
+ .badge.draining {
235
+ background: #fff4df;
236
+ color: #8a4b00;
237
+ }
238
+
239
+ .badge.maintenance {
240
+ background: #e8f0ff;
241
+ color: #1d4ed8;
242
+ }
243
+
244
+ .room-view {
245
+ display: grid;
246
+ grid-template-columns: minmax(0, 1fr) 280px;
247
+ gap: 14px;
248
+ }
249
+
250
+ .count {
251
+ font-size: 68px;
252
+ line-height: 1;
253
+ font-weight: 760;
254
+ }
255
+
256
+ .hidden {
257
+ display: none;
258
+ }
259
+
260
+ @media (max-width: 900px) {
261
+ header,
262
+ .layout,
263
+ .room-view,
264
+ .split,
265
+ .metrics {
266
+ grid-template-columns: 1fr;
267
+ }
268
+ }
269
+ </style>
270
+ </head>
271
+ <body>
272
+ <main>
273
+ <header>
274
+ <div>
275
+ <h1>Signe Node Shard</h1>
276
+ <p class="muted" id="headerDetail">World dashboard and sharded room entry</p>
277
+ </div>
278
+ <div class="row">
279
+ <button id="refreshWorld" class="secondary" type="button">Refresh</button>
280
+ <button id="leaveRoom" class="secondary hidden" type="button">Leave room</button>
281
+ </div>
282
+ </header>
283
+
284
+ <section class="layout">
285
+ <aside class="stack">
286
+ <section class="panel">
287
+ <div class="section-title">
288
+ <h2>World</h2>
289
+ <span class="badge" id="worldState">Idle</span>
290
+ </div>
291
+ <form id="worldForm">
292
+ <label>
293
+ World id
294
+ <input id="worldInput" autocomplete="off" value="world-default" />
295
+ </label>
296
+ <label>
297
+ Room id
298
+ <input id="roomInput" autocomplete="off" value="demo" />
299
+ </label>
300
+ <label>
301
+ Name
302
+ <input id="nameInput" autocomplete="name" value="Sam" />
303
+ </label>
304
+ <label>
305
+ Room process origin
306
+ <input id="roomOriginInput" autocomplete="off" value="http://localhost:3003" />
307
+ </label>
308
+ <div class="split">
309
+ <button type="submit">Ensure room</button>
310
+ <button id="enterRoom" class="secondary" type="button">Enter</button>
311
+ </div>
312
+ </form>
313
+ </section>
314
+
315
+ <section class="panel">
316
+ <div class="section-title">
317
+ <h2>Scale</h2>
318
+ <span class="meta" id="scaleMeta">target shards</span>
319
+ </div>
320
+ <div class="split">
321
+ <label>
322
+ Count
323
+ <input id="targetShardCount" type="number" min="1" max="12" value="2" />
324
+ </label>
325
+ <button id="scaleRoom" type="button">Apply</button>
326
+ </div>
327
+ </section>
328
+ </aside>
329
+
330
+ <section class="stack">
331
+ <section class="panel">
332
+ <div class="section-title">
333
+ <h2>World Overview</h2>
334
+ <span class="status" id="lastRefresh">Not loaded</span>
335
+ </div>
336
+ <div class="metrics">
337
+ <div class="metric">
338
+ <strong id="roomTotal">0</strong>
339
+ <span class="muted">rooms</span>
340
+ </div>
341
+ <div class="metric">
342
+ <strong id="shardTotal">0</strong>
343
+ <span class="muted">shards</span>
344
+ </div>
345
+ <div class="metric">
346
+ <strong id="connectionTotal">0</strong>
347
+ <span class="muted">connections</span>
348
+ </div>
349
+ </div>
350
+ </section>
351
+
352
+ <section class="panel">
353
+ <div class="section-title">
354
+ <h2>Rooms</h2>
355
+ <span class="meta">registered in selected world</span>
356
+ </div>
357
+ <div class="rooms" id="rooms"></div>
358
+ </section>
359
+
360
+ <section class="panel">
361
+ <div class="section-title">
362
+ <h2>Shards</h2>
363
+ <span class="meta">status and load</span>
364
+ </div>
365
+ <div class="shards" id="shards"></div>
366
+ </section>
367
+
368
+ <section class="room-view hidden" id="roomView">
369
+ <div class="panel">
370
+ <div class="section-title">
371
+ <h2>Room</h2>
372
+ <span class="badge active" id="roomStatus">Connected</span>
373
+ </div>
374
+ <div class="count" id="count">0</div>
375
+ <div class="row">
376
+ <button id="increment" type="button">Increment</button>
377
+ <button id="reset" class="secondary" type="button">Reset</button>
378
+ <button id="newSession" class="secondary" type="button">New session</button>
379
+ </div>
380
+ <p class="muted mono" id="connectionDetail"></p>
381
+ </div>
382
+
383
+ <aside class="card panel">
384
+ <h2>Users</h2>
385
+ <div class="users" id="users"></div>
386
+ </aside>
387
+ </section>
388
+ </section>
389
+ </section>
390
+ </main>
391
+
392
+ <script>
393
+ const state = {
394
+ dashboard: { rooms: [], shards: [] },
395
+ socket: null,
396
+ users: {},
397
+ roomId: getRoomIdFromPath() || "demo",
398
+ worldId: localStorage.getItem("signe-node-shard-world") || "world-default",
399
+ name: localStorage.getItem("signe-node-shard-name") || "Sam",
400
+ roomOrigin: localStorage.getItem("signe-node-shard-room-origin") || `${location.protocol}//${location.hostname}:3003`,
401
+ sessionId: localStorage.getItem("signe-node-shard-session") || "",
402
+ shardInfo: null,
403
+ };
404
+
405
+ const worldInput = document.querySelector("#worldInput");
406
+ const roomInput = document.querySelector("#roomInput");
407
+ const nameInput = document.querySelector("#nameInput");
408
+ const roomOriginInput = document.querySelector("#roomOriginInput");
409
+ const targetShardCount = document.querySelector("#targetShardCount");
410
+ const worldState = document.querySelector("#worldState");
411
+ const lastRefresh = document.querySelector("#lastRefresh");
412
+ const roomTotal = document.querySelector("#roomTotal");
413
+ const shardTotal = document.querySelector("#shardTotal");
414
+ const connectionTotal = document.querySelector("#connectionTotal");
415
+ const rooms = document.querySelector("#rooms");
416
+ const shards = document.querySelector("#shards");
417
+ const roomView = document.querySelector("#roomView");
418
+ const count = document.querySelector("#count");
419
+ const users = document.querySelector("#users");
420
+ const roomStatus = document.querySelector("#roomStatus");
421
+ const connectionDetail = document.querySelector("#connectionDetail");
422
+ const leaveRoom = document.querySelector("#leaveRoom");
423
+
424
+ worldInput.value = state.worldId;
425
+ roomInput.value = state.roomId;
426
+ nameInput.value = state.name;
427
+ roomOriginInput.value = state.roomOrigin;
428
+
429
+ document.querySelector("#worldForm").addEventListener("submit", async (event) => {
430
+ event.preventDefault();
431
+ syncInputs();
432
+ await ensureRoom();
433
+ });
434
+
435
+ document.querySelector("#refreshWorld").addEventListener("click", () => {
436
+ syncInputs();
437
+ loadDashboard();
438
+ });
439
+
440
+ document.querySelector("#scaleRoom").addEventListener("click", async () => {
441
+ syncInputs();
442
+ const target = Math.max(1, Number(targetShardCount.value || 1));
443
+ await api(`/api/world/${encodeURIComponent(state.worldId)}/scale`, {
444
+ method: "POST",
445
+ body: {
446
+ roomId: state.roomId,
447
+ targetShardCount: target,
448
+ shardTemplate: {
449
+ urlTemplate: "{shardId}",
450
+ maxConnections: 75,
451
+ },
452
+ },
453
+ });
454
+ await loadDashboard();
455
+ });
456
+
457
+ document.querySelector("#enterRoom").addEventListener("click", async () => {
458
+ syncInputs();
459
+ await enterRoom();
460
+ });
461
+
462
+ document.querySelector("#increment").addEventListener("click", () => {
463
+ state.socket?.send(JSON.stringify({ action: "increment", value: { amount: 1 } }));
464
+ });
465
+
466
+ document.querySelector("#reset").addEventListener("click", async () => {
467
+ if (!state.roomId) return;
468
+ await fetch(`/api/room/${encodeURIComponent(state.roomId)}/reset`, { method: "POST" });
469
+ });
470
+
471
+ document.querySelector("#newSession").addEventListener("click", async () => {
472
+ disconnect();
473
+ localStorage.removeItem("signe-node-shard-session");
474
+ state.sessionId = "";
475
+ await enterRoom();
476
+ });
477
+
478
+ leaveRoom.addEventListener("click", () => {
479
+ disconnect();
480
+ roomView.classList.add("hidden");
481
+ leaveRoom.classList.add("hidden");
482
+ history.pushState({}, "", "/");
483
+ });
484
+
485
+ window.addEventListener("popstate", () => {
486
+ const roomId = getRoomIdFromPath();
487
+ if (roomId) {
488
+ state.roomId = roomId;
489
+ roomInput.value = roomId;
490
+ enterRoom();
491
+ }
492
+ });
493
+
494
+ rooms.addEventListener("click", (event) => {
495
+ const roomId = event.target.closest("[data-room-id]")?.dataset.roomId;
496
+ if (!roomId) return;
497
+ state.roomId = roomId;
498
+ roomInput.value = roomId;
499
+ });
500
+
501
+ shards.addEventListener("click", async (event) => {
502
+ const button = event.target.closest("[data-shard-status]");
503
+ if (!button) return;
504
+ const shard = state.dashboard.shards.find((item) => item.id === button.dataset.shardId);
505
+ if (!shard) return;
506
+
507
+ await api(`/api/world/${encodeURIComponent(state.worldId)}/shard-status`, {
508
+ method: "POST",
509
+ body: {
510
+ shardId: shard.id,
511
+ worldId: state.worldId,
512
+ connections: shard.currentConnections,
513
+ status: button.dataset.shardStatus,
514
+ },
515
+ });
516
+ await loadDashboard();
517
+ });
518
+
519
+ loadDashboard();
520
+ if (getRoomIdFromPath()) {
521
+ enterRoom();
522
+ }
523
+ setInterval(() => loadDashboard({ quiet: true }), 2500);
524
+
525
+ async function ensureRoom() {
526
+ setWorldState("Working");
527
+ const result = await api(`/api/world/${encodeURIComponent(state.worldId)}/connect`, {
528
+ method: "POST",
529
+ body: {
530
+ roomId: state.roomId,
531
+ autoCreate: true,
532
+ },
533
+ });
534
+ state.shardInfo = result;
535
+ await loadDashboard();
536
+ setWorldState("Ready");
537
+ return result;
538
+ }
539
+
540
+ async function enterRoom() {
541
+ const shardInfo = await ensureRoom();
542
+ disconnect();
543
+ state.users = {};
544
+ state.shardInfo = shardInfo;
545
+ count.textContent = "0";
546
+ renderUsers();
547
+ roomView.classList.remove("hidden");
548
+ leaveRoom.classList.remove("hidden");
549
+ history.pushState({}, "", `/rooms/${encodeURIComponent(state.roomId)}`);
550
+
551
+ const roomOrigin = new URL(state.roomOrigin);
552
+ const protocol = roomOrigin.protocol === "https:" ? "wss" : "ws";
553
+ const params = new URLSearchParams({
554
+ name: state.name || "Anonymous",
555
+ id: getSessionId(),
556
+ });
557
+ const shardRoom = encodeURIComponent(shardInfo.url || shardInfo.shardId);
558
+ state.socket = new WebSocket(`${protocol}://${roomOrigin.host}/parties/shard/${shardRoom}?${params}`);
559
+
560
+ roomStatus.textContent = "Connecting";
561
+ roomStatus.className = "badge maintenance";
562
+ connectionDetail.textContent = `world ${state.worldId} / room process ${state.roomOrigin} / shard ${shardInfo.shardId}`;
563
+
564
+ state.socket.addEventListener("open", () => {
565
+ roomStatus.textContent = "Connected";
566
+ roomStatus.className = "badge active";
567
+ });
568
+
569
+ state.socket.addEventListener("close", () => {
570
+ roomStatus.textContent = "Disconnected";
571
+ roomStatus.className = "badge";
572
+ });
573
+
574
+ state.socket.addEventListener("message", (event) => {
575
+ const packet = JSON.parse(event.data);
576
+ if (packet.type !== "sync" || !packet.value) return;
577
+
578
+ if (typeof packet.value.count === "number") {
579
+ count.textContent = String(packet.value.count);
580
+ }
581
+
582
+ if (packet.value.users) {
583
+ mergeUsers(state.users, packet.value.users);
584
+ renderUsers();
585
+ }
586
+ });
587
+ }
588
+
589
+ async function loadDashboard(options = {}) {
590
+ try {
591
+ const dashboard = await api(`/api/world/${encodeURIComponent(state.worldId)}/dashboard`);
592
+ state.dashboard = dashboard;
593
+ renderDashboard();
594
+ setWorldState("Ready");
595
+ lastRefresh.textContent = new Date().toLocaleTimeString();
596
+ } catch (error) {
597
+ if (!options.quiet) {
598
+ console.error(error);
599
+ }
600
+ setWorldState("Error");
601
+ }
602
+ }
603
+
604
+ async function api(path, options = {}) {
605
+ const response = await fetch(path, {
606
+ method: options.method || "GET",
607
+ headers: {
608
+ "Content-Type": "application/json",
609
+ },
610
+ body: options.body ? JSON.stringify(options.body) : undefined,
611
+ });
612
+
613
+ const data = await response.json().catch(() => ({}));
614
+ if (!response.ok) {
615
+ throw new Error(data.error || `Request failed with ${response.status}`);
616
+ }
617
+ return data;
618
+ }
619
+
620
+ function renderDashboard() {
621
+ const visibleRooms = state.dashboard.rooms || [];
622
+ const visibleShards = state.dashboard.shards || [];
623
+ const currentRoomShards = visibleShards.filter((shard) => shard.roomId === state.roomId);
624
+
625
+ roomTotal.textContent = String(visibleRooms.length);
626
+ shardTotal.textContent = String(visibleShards.length);
627
+ connectionTotal.textContent = String(visibleShards.reduce((total, shard) => total + shard.currentConnections, 0));
628
+ targetShardCount.value = String(Math.max(1, currentRoomShards.length || Number(targetShardCount.value || 1)));
629
+
630
+ rooms.innerHTML = visibleRooms.length
631
+ ? visibleRooms.map((room) => `
632
+ <button class="room-item" type="button" data-room-id="${escapeHtml(room.id)}">
633
+ <span class="room-line">
634
+ <strong>${escapeHtml(room.id)}</strong>
635
+ <span class="badge">${escapeHtml(room.balancingStrategy)}</span>
636
+ </span>
637
+ <span class="muted">min ${room.minShards} / max ${room.maxShards ?? "unlimited"} / capacity ${room.maxPlayersPerShard}</span>
638
+ </button>
639
+ `).join("")
640
+ : `<div class="muted">No rooms in this world yet.</div>`;
641
+
642
+ shards.innerHTML = visibleShards.length
643
+ ? visibleShards.map((shard) => renderShard(shard)).join("")
644
+ : `<div class="muted">No shards in this world yet.</div>`;
645
+ }
646
+
647
+ function renderShard(shard) {
648
+ const heartbeat = shard.lastHeartbeat ? `${Math.max(0, Math.round((Date.now() - shard.lastHeartbeat) / 1000))}s ago` : "never";
649
+ return `
650
+ <article class="shard-item">
651
+ <div class="shard-line">
652
+ <div>
653
+ <strong class="mono">${escapeHtml(shard.id)}</strong>
654
+ <div class="muted">room ${escapeHtml(shard.roomId)} / heartbeat ${heartbeat}</div>
655
+ </div>
656
+ <span class="badge ${escapeHtml(shard.status)}">${escapeHtml(shard.status)}</span>
657
+ </div>
658
+ <div class="shard-line">
659
+ <span>${shard.currentConnections} / ${shard.maxConnections} connections</span>
660
+ <span class="row">
661
+ <button class="small secondary" type="button" data-shard-id="${escapeHtml(shard.id)}" data-shard-status="active">Active</button>
662
+ <button class="small secondary" type="button" data-shard-id="${escapeHtml(shard.id)}" data-shard-status="draining">Drain</button>
663
+ <button class="small secondary" type="button" data-shard-id="${escapeHtml(shard.id)}" data-shard-status="maintenance">Maint.</button>
664
+ </span>
665
+ </div>
666
+ </article>
667
+ `;
668
+ }
669
+
670
+ function renderUsers() {
671
+ const entries = Object.entries(state.users);
672
+ users.innerHTML = entries.length
673
+ ? entries.map(([id, user]) => `
674
+ <div class="user-item">
675
+ <span class="user-line">
676
+ <strong>${escapeHtml(user.name || "Anonymous")}</strong>
677
+ <span class="badge ${user.connected ? "active" : ""}">${user.connected ? "connected" : "offline"}</span>
678
+ </span>
679
+ <span class="muted mono">${escapeHtml(id)}</span>
680
+ </div>
681
+ `).join("")
682
+ : `<div class="muted">No users in the room.</div>`;
683
+ }
684
+
685
+ function disconnect() {
686
+ if (state.socket) {
687
+ state.socket.close();
688
+ state.socket = null;
689
+ }
690
+ }
691
+
692
+ function syncInputs() {
693
+ state.worldId = normalizeId(worldInput.value, "world-default");
694
+ state.roomId = normalizeId(roomInput.value, "demo");
695
+ state.name = nameInput.value.trim() || "Anonymous";
696
+ state.roomOrigin = normalizeOrigin(roomOriginInput.value);
697
+ worldInput.value = state.worldId;
698
+ roomInput.value = state.roomId;
699
+ nameInput.value = state.name;
700
+ roomOriginInput.value = state.roomOrigin;
701
+ localStorage.setItem("signe-node-shard-world", state.worldId);
702
+ localStorage.setItem("signe-node-shard-name", state.name);
703
+ localStorage.setItem("signe-node-shard-room-origin", state.roomOrigin);
704
+ }
705
+
706
+ function getSessionId() {
707
+ if (!state.sessionId) {
708
+ state.sessionId = crypto.randomUUID();
709
+ localStorage.setItem("signe-node-shard-session", state.sessionId);
710
+ }
711
+ return state.sessionId;
712
+ }
713
+
714
+ function mergeUsers(target, patch) {
715
+ for (const [id, value] of Object.entries(patch)) {
716
+ if (value === "$delete") {
717
+ delete target[id];
718
+ continue;
719
+ }
720
+ target[id] = mergeValue(target[id], value);
721
+ }
722
+ }
723
+
724
+ function mergeValue(current, patch) {
725
+ if (!patch || typeof patch !== "object" || Array.isArray(patch)) {
726
+ return patch;
727
+ }
728
+
729
+ const next = current && typeof current === "object" && !Array.isArray(current)
730
+ ? { ...current }
731
+ : {};
732
+
733
+ for (const [key, value] of Object.entries(patch)) {
734
+ if (value === "$delete") {
735
+ delete next[key];
736
+ } else {
737
+ next[key] = mergeValue(next[key], value);
738
+ }
739
+ }
740
+
741
+ return next;
742
+ }
743
+
744
+ function setWorldState(value) {
745
+ worldState.textContent = value;
746
+ worldState.className = `badge ${value === "Ready" ? "active" : value === "Working" ? "maintenance" : ""}`;
747
+ }
748
+
749
+ function getRoomIdFromPath() {
750
+ const match = location.pathname.match(/^\/rooms\/([^/]+)$/);
751
+ return match ? decodeURIComponent(match[1]) : null;
752
+ }
753
+
754
+ function normalizeId(value, fallback) {
755
+ return value.trim().replace(/[^a-zA-Z0-9_-]/g, "-") || fallback;
756
+ }
757
+
758
+ function normalizeOrigin(value) {
759
+ try {
760
+ return new URL(value.trim()).origin;
761
+ } catch {
762
+ return `${location.protocol}//${location.hostname}:3003`;
763
+ }
764
+ }
765
+
766
+ function escapeHtml(value) {
767
+ return String(value).replace(/[&<>"']/g, (char) => ({
768
+ "&": "&amp;",
769
+ "<": "&lt;",
770
+ ">": "&gt;",
771
+ "\"": "&quot;",
772
+ "'": "&#039;",
773
+ }[char]));
774
+ }
775
+ </script>
776
+ </body>
777
+ </html>