@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.
- package/CHANGELOG.md +7 -0
- package/dist/chunk-EUXUH3YW.js +15 -0
- package/dist/chunk-EUXUH3YW.js.map +1 -0
- package/dist/cloudflare/index.d.ts +71 -0
- package/dist/cloudflare/index.js +320 -0
- package/dist/cloudflare/index.js.map +1 -0
- package/dist/index.d.ts +87 -188
- package/dist/index.js +860 -114
- package/dist/index.js.map +1 -1
- package/dist/node/index.d.ts +164 -0
- package/dist/node/index.js +786 -0
- package/dist/node/index.js.map +1 -0
- package/dist/party-dNs-hqkq.d.ts +175 -0
- package/examples/cloudflare/README.md +62 -0
- package/examples/cloudflare/node_modules/.bin/tsc +17 -0
- package/examples/cloudflare/node_modules/.bin/tsserver +17 -0
- package/examples/cloudflare/node_modules/.bin/wrangler +17 -0
- package/examples/cloudflare/node_modules/.bin/wrangler2 +17 -0
- package/examples/cloudflare/package.json +24 -0
- package/examples/cloudflare/public/index.html +443 -0
- package/examples/cloudflare/src/index.ts +28 -0
- package/examples/cloudflare/src/room.ts +44 -0
- package/examples/cloudflare/tsconfig.json +10 -0
- package/examples/cloudflare/wrangler.jsonc +25 -0
- package/examples/node/README.md +57 -0
- package/examples/node/node_modules/.bin/tsc +17 -0
- package/examples/node/node_modules/.bin/tsserver +17 -0
- package/examples/node/node_modules/.bin/tsx +17 -0
- package/examples/node/package.json +23 -0
- package/examples/node/public/index.html +443 -0
- package/examples/node/room.ts +44 -0
- package/examples/node/server.sqlite.ts +52 -0
- package/examples/node/server.ts +51 -0
- package/examples/node/tsconfig.json +10 -0
- package/examples/node-game/README.md +66 -0
- package/examples/node-game/package.json +23 -0
- package/examples/node-game/public/index.html +705 -0
- package/examples/node-game/room.ts +145 -0
- package/examples/node-game/server.sqlite.ts +54 -0
- package/examples/node-game/server.ts +53 -0
- package/examples/node-game/tsconfig.json +10 -0
- package/examples/node-shard/README.md +32 -0
- package/examples/node-shard/dev.ts +39 -0
- package/examples/node-shard/package.json +24 -0
- package/examples/node-shard/public/index.html +777 -0
- package/examples/node-shard/room-server.ts +68 -0
- package/examples/node-shard/room.ts +105 -0
- package/examples/node-shard/shared.ts +6 -0
- package/examples/node-shard/tsconfig.json +14 -0
- package/examples/node-shard/world-server.ts +169 -0
- package/package.json +14 -5
- package/readme.md +418 -4
- package/src/cloudflare/index.ts +474 -0
- package/src/index.ts +2 -2
- package/src/jwt.ts +1 -5
- package/src/mock.ts +29 -7
- package/src/node/index.ts +1112 -0
- package/src/server.ts +781 -60
- package/src/session.guard.ts +6 -2
- package/src/shard.ts +91 -23
- package/src/storage.ts +29 -5
- package/src/testing.ts +4 -3
- package/src/types/party.ts +30 -1
- package/src/world.guard.ts +23 -4
- package/src/world.ts +121 -21
- package/tests/storage-restore.spec.ts +122 -0
- package/examples/game/.vscode/launch.json +0 -11
- package/examples/game/.vscode/settings.json +0 -11
- package/examples/game/README.md +0 -40
- package/examples/game/app/client.tsx +0 -15
- package/examples/game/app/components/Admin.tsx +0 -1089
- package/examples/game/app/components/Room.tsx +0 -162
- package/examples/game/app/styles.css +0 -31
- package/examples/game/package-lock.json +0 -225
- package/examples/game/package.json +0 -20
- package/examples/game/party/game.room.ts +0 -32
- package/examples/game/party/server.ts +0 -10
- package/examples/game/party/shard.ts +0 -5
- package/examples/game/partykit.json +0 -14
- package/examples/game/public/favicon.ico +0 -0
- package/examples/game/public/index.html +0 -27
- package/examples/game/public/normalize.css +0 -351
- package/examples/game/shared/room.schema.ts +0 -14
- 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
|
+
"&": "&",
|
|
435
|
+
"<": "<",
|
|
436
|
+
">": ">",
|
|
437
|
+
"\"": """,
|
|
438
|
+
"'": "'",
|
|
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,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
|
+
}
|