@signe/room 2.10.0 → 3.0.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.
Files changed (82) 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 +66 -187
  8. package/dist/index.js +727 -106
  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 +371 -4
  53. package/src/cloudflare/index.ts +474 -0
  54. package/src/jwt.ts +1 -5
  55. package/src/mock.ts +29 -7
  56. package/src/node/index.ts +1112 -0
  57. package/src/server.ts +600 -51
  58. package/src/session.guard.ts +6 -2
  59. package/src/shard.ts +91 -23
  60. package/src/storage.ts +29 -5
  61. package/src/testing.ts +4 -3
  62. package/src/types/party.ts +4 -1
  63. package/src/world.guard.ts +23 -4
  64. package/src/world.ts +121 -21
  65. package/examples/game/.vscode/launch.json +0 -11
  66. package/examples/game/.vscode/settings.json +0 -11
  67. package/examples/game/README.md +0 -40
  68. package/examples/game/app/client.tsx +0 -15
  69. package/examples/game/app/components/Admin.tsx +0 -1089
  70. package/examples/game/app/components/Room.tsx +0 -162
  71. package/examples/game/app/styles.css +0 -31
  72. package/examples/game/package-lock.json +0 -225
  73. package/examples/game/package.json +0 -20
  74. package/examples/game/party/game.room.ts +0 -32
  75. package/examples/game/party/server.ts +0 -10
  76. package/examples/game/party/shard.ts +0 -5
  77. package/examples/game/partykit.json +0 -14
  78. package/examples/game/public/favicon.ico +0 -0
  79. package/examples/game/public/index.html +0 -27
  80. package/examples/game/public/normalize.css +0 -351
  81. package/examples/game/shared/room.schema.ts +0 -14
  82. package/examples/game/tsconfig.json +0 -109
@@ -0,0 +1,443 @@
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 Room</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
+ }
12
+
13
+ * {
14
+ box-sizing: border-box;
15
+ }
16
+
17
+ body {
18
+ margin: 0;
19
+ min-height: 100vh;
20
+ background: #f5f7fa;
21
+ color: #17191c;
22
+ }
23
+
24
+ main {
25
+ width: min(960px, calc(100vw - 32px));
26
+ margin: 0 auto;
27
+ padding: 32px 0;
28
+ display: grid;
29
+ gap: 18px;
30
+ }
31
+
32
+ header {
33
+ display: flex;
34
+ align-items: end;
35
+ justify-content: space-between;
36
+ gap: 16px;
37
+ }
38
+
39
+ h1,
40
+ h2 {
41
+ margin: 0;
42
+ line-height: 1.1;
43
+ }
44
+
45
+ h1 {
46
+ font-size: 30px;
47
+ }
48
+
49
+ h2 {
50
+ font-size: 18px;
51
+ }
52
+
53
+ .muted,
54
+ .status {
55
+ color: #5b6472;
56
+ }
57
+
58
+ .panel,
59
+ .card {
60
+ border: 1px solid #d9dde3;
61
+ border-radius: 8px;
62
+ background: #ffffff;
63
+ }
64
+
65
+ .panel {
66
+ padding: 20px;
67
+ display: grid;
68
+ gap: 16px;
69
+ }
70
+
71
+ .join {
72
+ max-width: 520px;
73
+ }
74
+
75
+ form {
76
+ display: grid;
77
+ gap: 12px;
78
+ }
79
+
80
+ label {
81
+ display: grid;
82
+ gap: 6px;
83
+ font-size: 14px;
84
+ font-weight: 600;
85
+ }
86
+
87
+ input {
88
+ min-height: 42px;
89
+ border: 1px solid #c9d0d9;
90
+ border-radius: 6px;
91
+ padding: 0 12px;
92
+ font: inherit;
93
+ }
94
+
95
+ button {
96
+ min-height: 40px;
97
+ border: 1px solid #17191c;
98
+ border-radius: 6px;
99
+ background: #17191c;
100
+ color: #ffffff;
101
+ padding: 0 14px;
102
+ font: inherit;
103
+ cursor: pointer;
104
+ }
105
+
106
+ button.secondary {
107
+ background: #ffffff;
108
+ color: #17191c;
109
+ }
110
+
111
+ .room {
112
+ display: grid;
113
+ grid-template-columns: minmax(0, 1fr) 280px;
114
+ gap: 18px;
115
+ }
116
+
117
+ .count {
118
+ font-size: 72px;
119
+ line-height: 1;
120
+ font-weight: 700;
121
+ }
122
+
123
+ .actions {
124
+ display: flex;
125
+ gap: 10px;
126
+ flex-wrap: wrap;
127
+ }
128
+
129
+ .session {
130
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
131
+ font-size: 12px;
132
+ }
133
+
134
+ .users {
135
+ padding: 16px;
136
+ display: grid;
137
+ gap: 12px;
138
+ align-content: start;
139
+ }
140
+
141
+ .user-list {
142
+ display: grid;
143
+ gap: 8px;
144
+ }
145
+
146
+ .user {
147
+ display: flex;
148
+ align-items: center;
149
+ justify-content: space-between;
150
+ gap: 10px;
151
+ min-height: 40px;
152
+ padding: 8px 10px;
153
+ border: 1px solid #e1e5ea;
154
+ border-radius: 6px;
155
+ }
156
+
157
+ .badge {
158
+ border-radius: 999px;
159
+ padding: 3px 8px;
160
+ font-size: 12px;
161
+ background: #edf1f5;
162
+ color: #4b5563;
163
+ white-space: nowrap;
164
+ }
165
+
166
+ .badge.online {
167
+ background: #e7f7ed;
168
+ color: #146c36;
169
+ }
170
+
171
+ .hidden {
172
+ display: none;
173
+ }
174
+
175
+ @media (max-width: 720px) {
176
+ header,
177
+ .room {
178
+ grid-template-columns: 1fr;
179
+ display: grid;
180
+ }
181
+ }
182
+ </style>
183
+ </head>
184
+ <body>
185
+ <main>
186
+ <header>
187
+ <div>
188
+ <h1>Signe Node Room</h1>
189
+ <div class="muted" id="roomLabel">Choose a room</div>
190
+ </div>
191
+ <button id="leaveRoom" class="secondary hidden" type="button">Change room</button>
192
+ </header>
193
+
194
+ <section class="panel join" id="joinPanel">
195
+ <h2>Join a room</h2>
196
+ <form id="joinForm">
197
+ <label>
198
+ Room
199
+ <input id="roomInput" name="room" autocomplete="off" required value="demo" />
200
+ </label>
201
+ <label>
202
+ Name
203
+ <input id="nameInput" name="name" autocomplete="name" required />
204
+ </label>
205
+ <button type="submit">Enter room</button>
206
+ </form>
207
+ </section>
208
+
209
+ <section class="room hidden" id="roomPanel">
210
+ <div class="panel">
211
+ <h2>Counter</h2>
212
+ <div class="count" id="count">0</div>
213
+ <div class="actions">
214
+ <button id="increment" type="button">Increment</button>
215
+ <button id="reset" class="secondary" type="button">Reset</button>
216
+ <button id="resetSession" class="secondary" type="button">New session</button>
217
+ </div>
218
+ <div class="status" id="status">Disconnected</div>
219
+ <div class="muted session" id="sessionLabel"></div>
220
+ </div>
221
+
222
+ <aside class="card users">
223
+ <h2>Users</h2>
224
+ <div class="user-list" id="users"></div>
225
+ </aside>
226
+ </section>
227
+ </main>
228
+
229
+ <script>
230
+ const state = {
231
+ socket: null,
232
+ roomId: getRoomIdFromPath(),
233
+ name: localStorage.getItem("signe-node-name") || "",
234
+ sessionId: localStorage.getItem("signe-node-session-id") || "",
235
+ users: {},
236
+ };
237
+
238
+ const joinPanel = document.querySelector("#joinPanel");
239
+ const roomPanel = document.querySelector("#roomPanel");
240
+ const roomLabel = document.querySelector("#roomLabel");
241
+ const leaveRoom = document.querySelector("#leaveRoom");
242
+ const joinForm = document.querySelector("#joinForm");
243
+ const roomInput = document.querySelector("#roomInput");
244
+ const nameInput = document.querySelector("#nameInput");
245
+ const count = document.querySelector("#count");
246
+ const status = document.querySelector("#status");
247
+ const sessionLabel = document.querySelector("#sessionLabel");
248
+ const users = document.querySelector("#users");
249
+
250
+ nameInput.value = state.name;
251
+
252
+ if (state.roomId) {
253
+ roomInput.value = state.roomId;
254
+ }
255
+
256
+ joinForm.addEventListener("submit", (event) => {
257
+ event.preventDefault();
258
+ const roomId = normalizeRoomId(roomInput.value);
259
+ const name = nameInput.value.trim() || "Anonymous";
260
+
261
+ localStorage.setItem("signe-node-name", name);
262
+ history.pushState({}, "", `/rooms/${encodeURIComponent(roomId)}`);
263
+ enterRoom(roomId, name);
264
+ });
265
+
266
+ leaveRoom.addEventListener("click", () => {
267
+ disconnect();
268
+ history.pushState({}, "", "/");
269
+ showJoin();
270
+ });
271
+
272
+ window.addEventListener("popstate", () => {
273
+ const roomId = getRoomIdFromPath();
274
+ if (roomId) {
275
+ enterRoom(roomId, state.name || "Anonymous");
276
+ } else {
277
+ disconnect();
278
+ showJoin();
279
+ }
280
+ });
281
+
282
+ document.querySelector("#increment").addEventListener("click", () => {
283
+ state.socket?.send(JSON.stringify({ action: "increment", value: { amount: 1 } }));
284
+ });
285
+
286
+ document.querySelector("#reset").addEventListener("click", async () => {
287
+ if (!state.roomId) return;
288
+ await fetch(`/parties/main/${encodeURIComponent(state.roomId)}/reset`, { method: "POST" });
289
+ });
290
+
291
+ document.querySelector("#resetSession").addEventListener("click", () => {
292
+ if (!state.roomId) return;
293
+ disconnect();
294
+ localStorage.removeItem("signe-node-session-id");
295
+ state.sessionId = "";
296
+ enterRoom(state.roomId, state.name || "Anonymous");
297
+ });
298
+
299
+ if (state.roomId) {
300
+ enterRoom(state.roomId, state.name || "Anonymous");
301
+ } else {
302
+ showJoin();
303
+ }
304
+
305
+ function enterRoom(roomId, name) {
306
+ disconnect();
307
+ state.roomId = roomId;
308
+ state.name = name;
309
+ state.users = {};
310
+ count.textContent = "0";
311
+ renderUsers();
312
+ showRoom();
313
+
314
+ const protocol = location.protocol === "https:" ? "wss" : "ws";
315
+ const sessionId = getSessionId();
316
+ const params = new URLSearchParams({ name, id: sessionId });
317
+ state.socket = new WebSocket(`${protocol}://${location.host}/parties/main/${encodeURIComponent(roomId)}?${params}`);
318
+
319
+ state.socket.addEventListener("open", () => {
320
+ status.textContent = "Connected";
321
+ });
322
+
323
+ state.socket.addEventListener("close", () => {
324
+ status.textContent = "Disconnected";
325
+ });
326
+
327
+ state.socket.addEventListener("message", (event) => {
328
+ const packet = JSON.parse(event.data);
329
+ if (packet.type !== "sync" || !packet.value) return;
330
+
331
+ if (typeof packet.value.count === "number") {
332
+ count.textContent = String(packet.value.count);
333
+ }
334
+
335
+ if (packet.value.users) {
336
+ mergeUsers(state.users, packet.value.users);
337
+ renderUsers();
338
+ }
339
+ });
340
+ }
341
+
342
+ function disconnect() {
343
+ if (state.socket) {
344
+ state.socket.close();
345
+ state.socket = null;
346
+ }
347
+ }
348
+
349
+ function showJoin() {
350
+ state.roomId = null;
351
+ joinPanel.classList.remove("hidden");
352
+ roomPanel.classList.add("hidden");
353
+ leaveRoom.classList.add("hidden");
354
+ roomLabel.textContent = "Choose a room";
355
+ status.textContent = "Disconnected";
356
+ sessionLabel.textContent = "";
357
+ }
358
+
359
+ function showRoom() {
360
+ joinPanel.classList.add("hidden");
361
+ roomPanel.classList.remove("hidden");
362
+ leaveRoom.classList.remove("hidden");
363
+ roomLabel.textContent = `/rooms/${state.roomId}`;
364
+ sessionLabel.textContent = `session ${getSessionId().slice(0, 8)}`;
365
+ }
366
+
367
+ function getSessionId() {
368
+ if (!state.sessionId) {
369
+ state.sessionId = crypto.randomUUID();
370
+ localStorage.setItem("signe-node-session-id", state.sessionId);
371
+ }
372
+
373
+ return state.sessionId;
374
+ }
375
+
376
+ function renderUsers() {
377
+ const entries = Object.entries(state.users);
378
+ users.innerHTML = entries.length
379
+ ? entries.map(([id, user]) => {
380
+ const name = escapeHtml(user.name || "Anonymous");
381
+ const connected = Boolean(user.connected);
382
+ return `
383
+ <div class="user">
384
+ <span>${name}</span>
385
+ <span class="badge ${connected ? "online" : ""}">${connected ? "connected" : "offline"}</span>
386
+ </div>
387
+ `;
388
+ }).join("")
389
+ : `<div class="muted">No users yet</div>`;
390
+ }
391
+
392
+ function mergeUsers(target, patch) {
393
+ for (const [id, value] of Object.entries(patch)) {
394
+ if (value === "$delete") {
395
+ delete target[id];
396
+ continue;
397
+ }
398
+
399
+ target[id] = mergeValue(target[id], value);
400
+ }
401
+ }
402
+
403
+ function mergeValue(current, patch) {
404
+ if (!patch || typeof patch !== "object" || Array.isArray(patch)) {
405
+ return patch;
406
+ }
407
+
408
+ const next = current && typeof current === "object" && !Array.isArray(current)
409
+ ? { ...current }
410
+ : {};
411
+
412
+ for (const [key, value] of Object.entries(patch)) {
413
+ if (value === "$delete") {
414
+ delete next[key];
415
+ } else {
416
+ next[key] = mergeValue(next[key], value);
417
+ }
418
+ }
419
+
420
+ return next;
421
+ }
422
+
423
+ function getRoomIdFromPath() {
424
+ const match = location.pathname.match(/^\/rooms\/([^/]+)$/);
425
+ return match ? decodeURIComponent(match[1]) : null;
426
+ }
427
+
428
+ function normalizeRoomId(value) {
429
+ return value.trim().replace(/[^a-zA-Z0-9_-]/g, "-") || "demo";
430
+ }
431
+
432
+ function escapeHtml(value) {
433
+ return value.replace(/[&<>"']/g, (char) => ({
434
+ "&": "&amp;",
435
+ "<": "&lt;",
436
+ ">": "&gt;",
437
+ "\"": "&quot;",
438
+ "'": "&#039;",
439
+ }[char]));
440
+ }
441
+ </script>
442
+ </body>
443
+ </html>
@@ -0,0 +1,44 @@
1
+ import { Action, Request, Room, Server } from "@signe/room";
2
+ import { signal } from "@signe/reactive";
3
+ import { connected, sync, users } from "@signe/sync";
4
+ import { z } from "zod";
5
+
6
+ class DemoUser {
7
+ @sync() name = signal("Anonymous");
8
+ @connected() connected = signal(false);
9
+ }
10
+
11
+ @Room({ path: "{roomId}", sessionExpiryTime: 2000 })
12
+ class CounterRoom {
13
+ @sync() count = signal(0);
14
+ @users(DemoUser) users = signal<Record<string, DemoUser>>({});
15
+
16
+ onJoin(user: DemoUser, _conn: unknown, ctx: { request?: Request }) {
17
+ const url = new URL(ctx.request?.url ?? "http://localhost");
18
+ const name = url.searchParams.get("name")?.trim();
19
+
20
+ if (name) {
21
+ user.name.set(name.slice(0, 40));
22
+ }
23
+ }
24
+
25
+ @Action("increment", z.object({ amount: z.number().optional() }))
26
+ increment(_user: DemoUser, value: { amount?: number }) {
27
+ this.count.update((count) => count + (value.amount ?? 1));
28
+ }
29
+
30
+ @Request({ path: "/count" })
31
+ getCount() {
32
+ return { count: this.count() };
33
+ }
34
+
35
+ @Request({ path: "/reset", method: "POST" })
36
+ reset() {
37
+ this.count.set(0);
38
+ return { count: this.count() };
39
+ }
40
+ }
41
+
42
+ export class CounterServer extends Server {
43
+ rooms = [CounterRoom];
44
+ }
@@ -0,0 +1,52 @@
1
+ import { createServer } from "node:http";
2
+ import { readFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { WebSocketServer } from "ws";
6
+ import { createNodeRoomTransport, createSqliteNodeRoomStorage } from "@signe/room/node";
7
+ import { CounterServer } from "./room";
8
+
9
+ const root = fileURLToPath(new URL(".", import.meta.url));
10
+
11
+ const transport = createNodeRoomTransport(CounterServer, {
12
+ partiesPath: "/parties/main",
13
+ storage: createSqliteNodeRoomStorage({
14
+ databasePath: join(root, "rooms.sqlite"),
15
+ }),
16
+ });
17
+
18
+ const server = createServer(async (req, res) => {
19
+ if (req.url?.startsWith("/parties/main/")) {
20
+ await transport.handleNodeRequest(req, res);
21
+ return;
22
+ }
23
+
24
+ if (req.url === "/" || req.url === "/index.html" || req.url?.startsWith("/rooms/")) {
25
+ const html = await readFile(join(root, "public/index.html"), "utf8");
26
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
27
+ res.end(html);
28
+ return;
29
+ }
30
+
31
+ res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
32
+ res.end("Not Found");
33
+ });
34
+
35
+ const wsServer = new WebSocketServer({ noServer: true });
36
+
37
+ server.on("upgrade", (request, socket, head) => {
38
+ if (request.url?.startsWith("/parties/main/")) {
39
+ transport.handleUpgrade(wsServer, request, socket, head);
40
+ return;
41
+ }
42
+
43
+ socket.destroy();
44
+ });
45
+
46
+ server.listen(3000, () => {
47
+ console.log("Signe Node room SQLite example: http://localhost:3000");
48
+ console.log("SQLite file: packages/room/examples/node/rooms.sqlite");
49
+ console.log("Room URL: http://localhost:3000/rooms/demo");
50
+ console.log("HTTP endpoint: http://localhost:3000/parties/main/demo/count");
51
+ console.log("WebSocket: ws://localhost:3000/parties/main/demo?name=Sam");
52
+ });
@@ -0,0 +1,51 @@
1
+ import { createServer } from "node:http";
2
+ import { readFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { WebSocketServer } from "ws";
6
+ import { createMemoryNodeRoomStorage, createNodeRoomTransport } from "@signe/room/node";
7
+ import { CounterServer } from "./room";
8
+
9
+ const root = fileURLToPath(new URL(".", import.meta.url));
10
+
11
+ const storage = createMemoryNodeRoomStorage();
12
+
13
+ const transport = createNodeRoomTransport(CounterServer, {
14
+ partiesPath: "/parties/main",
15
+ storage,
16
+ });
17
+
18
+ const server = createServer(async (req, res) => {
19
+ if (req.url?.startsWith("/parties/main/")) {
20
+ await transport.handleNodeRequest(req, res);
21
+ return;
22
+ }
23
+
24
+ if (req.url === "/" || req.url === "/index.html" || req.url?.startsWith("/rooms/")) {
25
+ const html = await readFile(join(root, "public/index.html"), "utf8");
26
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
27
+ res.end(html);
28
+ return;
29
+ }
30
+
31
+ res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
32
+ res.end("Not Found");
33
+ });
34
+
35
+ const wsServer = new WebSocketServer({ noServer: true });
36
+
37
+ server.on("upgrade", (request, socket, head) => {
38
+ if (request.url?.startsWith("/parties/main/")) {
39
+ transport.handleUpgrade(wsServer, request, socket, head);
40
+ return;
41
+ }
42
+
43
+ socket.destroy();
44
+ });
45
+
46
+ server.listen(3000, () => {
47
+ console.log("Signe Node room example: http://localhost:3000");
48
+ console.log("Room URL: http://localhost:3000/rooms/demo");
49
+ console.log("HTTP endpoint: http://localhost:3000/parties/main/demo/count");
50
+ console.log("WebSocket: ws://localhost:3000/parties/main/demo?name=Sam");
51
+ });
@@ -0,0 +1,10 @@
1
+ {
2
+ "extends": "../../../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "types": ["node"]
7
+ },
8
+ "include": ["room.ts", "server.ts", "server.sqlite.ts"],
9
+ "exclude": []
10
+ }
@@ -0,0 +1,66 @@
1
+ # `@signe/room` Node Game Example
2
+
3
+ This example runs a small multiplayer arena game on a plain Node.js HTTP server
4
+ with WebSocket upgrades handled by `ws`.
5
+
6
+ ```bash
7
+ pnpm install
8
+ pnpm --filter @signe/room-node-game-example dev
9
+ ```
10
+
11
+ Open http://localhost:3000, choose a room id and a display name, then enter the
12
+ arena. Open the same room in another browser tab with a different session to see
13
+ both players move and score in real time.
14
+
15
+ Set `PORT=3001` before the command if port `3000` is already in use.
16
+
17
+ - App URL: `http://localhost:3000/rooms/demo`
18
+ - HTTP: `GET /parties/main/demo/state`
19
+ - HTTP: `POST /parties/main/demo/reset`
20
+ - WebSocket: `ws://localhost:3000/parties/main/demo?name=Sam&id=browser-session-id`
21
+
22
+ The room uses `@users()` and `@connected()` from `@signe/sync`, so the players
23
+ panel shows every known player and whether they are currently connected.
24
+
25
+ The browser stores a session id in `localStorage` and sends it as the WebSocket
26
+ `id` query parameter. That id is the private session id used by the room server,
27
+ so refreshing or reconnecting brings the same player back online. Use "New
28
+ session" to create another player from the same browser. Multiple tabs with the
29
+ same stored session id stay attached to the same player; server handlers receive
30
+ a unique `conn.id` per WebSocket and the shared private session id as
31
+ `conn.sessionId`.
32
+
33
+ ## Game room
34
+
35
+ The game demonstrates a server-authoritative flow:
36
+
37
+ - `move` updates a player's bounded position in the arena.
38
+ - `collect` checks the player's distance from the star on the server before
39
+ awarding a point.
40
+ - `reset` clears scores and respawns the star.
41
+
42
+ Client messages use the same shape as the counter example:
43
+
44
+ ```json
45
+ { "action": "move", "value": { "x": 120, "y": 180 } }
46
+ ```
47
+
48
+ ```json
49
+ { "action": "collect", "value": {} }
50
+ ```
51
+
52
+ ## SQLite storage
53
+
54
+ The default example uses `createMemoryNodeRoomStorage()`. To run the same room
55
+ with the package's SQLite-backed `room.storage`, use:
56
+
57
+ ```bash
58
+ pnpm --filter @signe/room-node-game-example dev:sqlite
59
+ ```
60
+
61
+ The SQLite example uses `createSqliteNodeRoomStorage()` from `@signe/room/node`
62
+ and Node's built-in `node:sqlite` module. It stores room state in
63
+ `packages/room/examples/node-game/rooms.sqlite`.
64
+
65
+ The game room also throttles storage writes, so movement can stay responsive
66
+ without persisting every single position update immediately.
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "@signe/room-node-game-example",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "tsx server.ts",
8
+ "dev:sqlite": "node --experimental-sqlite --import tsx server.sqlite.ts",
9
+ "build": "tsc --noEmit"
10
+ },
11
+ "dependencies": {
12
+ "@signe/reactive": "workspace:*",
13
+ "@signe/room": "workspace:*",
14
+ "@signe/sync": "workspace:*",
15
+ "ws": "^8.17.0"
16
+ },
17
+ "devDependencies": {
18
+ "@types/node": "^22.13.9",
19
+ "@types/ws": "^8.5.12",
20
+ "tsx": "^4.19.2",
21
+ "typescript": "^5.4.5"
22
+ }
23
+ }