@signe/room 1.4.2 → 2.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/src/world.ts ADDED
@@ -0,0 +1,448 @@
1
+ import { signal } from "@signe/reactive";
2
+ import { Room, Action, Guard, Request } from "./decorators";
3
+ import { sync, id, persist } from "@signe/sync";
4
+ import { z } from "zod";
5
+ import * as Party from "./types/party";
6
+ import { guardManageWorld } from "./world.guard";
7
+ import { response } from "./utils";
8
+ import { RoomInterceptorPacket, RoomOnJoin } from "./interfaces";
9
+ import { ServerResponse } from "./request/response";
10
+
11
+ // Types definitions
12
+ type BalancingStrategy = 'round-robin' | 'least-connections' | 'random';
13
+ type ShardStatus = 'active' | 'maintenance' | 'draining';
14
+
15
+ // Schema validations
16
+ const RoomConfigSchema = z.object({
17
+ name: z.string(),
18
+ balancingStrategy: z.enum(['round-robin', 'least-connections', 'random']),
19
+ public: z.boolean(),
20
+ maxPlayersPerShard: z.number().int().positive(),
21
+ minShards: z.number().int().min(0),
22
+ maxShards: z.number().int().positive().optional(),
23
+ });
24
+
25
+ const RegisterShardSchema = z.object({
26
+ shardId: z.string(),
27
+ roomId: z.string(),
28
+ url: z.string().url(),
29
+ maxConnections: z.number().int().positive(),
30
+ });
31
+
32
+ const UpdateShardStatsSchema = z.object({
33
+ connections: z.number().int().min(0),
34
+ status: z.enum(['active', 'maintenance', 'draining']).optional(),
35
+ });
36
+
37
+ const ScaleRoomSchema = z.object({
38
+ roomId: z.string(),
39
+ targetShardCount: z.number().int().positive(),
40
+ shardTemplate: z.object({
41
+ urlTemplate: z.string(),
42
+ maxConnections: z.number().int().positive(),
43
+ }).optional(),
44
+ });
45
+
46
+ // Model classes
47
+ class RoomConfig {
48
+ @id() id: string;
49
+ @sync() name = signal("");
50
+ @sync() balancingStrategy = signal<BalancingStrategy>("round-robin");
51
+ @sync() public = signal(true);
52
+ @sync() maxPlayersPerShard = signal(100);
53
+ @sync() minShards = signal(1);
54
+ @sync() maxShards = signal<number | undefined>(undefined);
55
+ }
56
+
57
+ class ShardInfo {
58
+ @id() id: string;
59
+ @sync() roomId = signal("");
60
+ @sync() url = signal("");
61
+ @sync({
62
+ persist: false
63
+ }) currentConnections = signal(0);
64
+ @sync() maxConnections = signal(100);
65
+ @sync() status = signal<ShardStatus>("active");
66
+ @sync() lastHeartbeat = signal(0);
67
+ }
68
+
69
+ // World room implementation
70
+ @Room({
71
+ path: "world-{worldId}",
72
+ maxUsers: 100, // Limit for admin connections
73
+ throttleStorage: 2000, // Throttle storage updates (ms)
74
+ throttleSync: 500, // Throttle sync updates (ms)
75
+ })
76
+ export class WorldRoom implements RoomInterceptorPacket, RoomOnJoin {
77
+ // Synchronized state
78
+ @sync(RoomConfig) rooms = signal<Record<string, RoomConfig>>({});
79
+ @sync(ShardInfo) shards = signal<Record<string, ShardInfo>>({});
80
+
81
+ // Only persisted state (not synced to clients)
82
+ @persist() rrCounters = signal<Record<string, number>>({});
83
+
84
+ // Configuration
85
+ defaultShardUrlTemplate = signal("{shardId}");
86
+ defaultMaxConnectionsPerShard = signal(100);
87
+
88
+ constructor(private room: Party.Room) {
89
+ const { AUTH_JWT_SECRET, SHARD_SECRET } = this.room.env;
90
+ if (!AUTH_JWT_SECRET) {
91
+ throw new Error("AUTH_JWT_SECRET env variable is not set");
92
+ }
93
+ if (!SHARD_SECRET) {
94
+ throw new Error("SHARD_SECRET env variable is not set");
95
+ }
96
+ setTimeout(() => this.cleanupInactiveShards(), 60000);
97
+ }
98
+
99
+ async onJoin(user: any, conn: Party.Connection, ctx: Party.ConnectionContext) {
100
+ const canConnect = await guardManageWorld(user, ctx.request, this.room);
101
+ conn.setState({
102
+ ...conn.state,
103
+ isAdmin: canConnect
104
+ })
105
+ }
106
+
107
+ interceptorPacket(_, obj: any, conn: Party.Connection) {
108
+ if (!conn.state['isAdmin']) {
109
+ return null;
110
+ }
111
+ return obj;
112
+ }
113
+
114
+ // Helper methods
115
+ private cleanupInactiveShards() {
116
+ const now = Date.now();
117
+ const timeout = 5 * 60 * 1000; // 5 minutes timeout
118
+ const shardsValue = this.shards();
119
+
120
+ let hasChanges = false;
121
+ Object.values(shardsValue).forEach(shard => {
122
+ if (now - shard.lastHeartbeat() > timeout) {
123
+ delete this.shards()[shard.id];
124
+
125
+ hasChanges = true;
126
+ }
127
+ });
128
+
129
+ // Schedule next cleanup
130
+ setTimeout(() => this.cleanupInactiveShards(), 60000);
131
+ }
132
+
133
+ // Actions
134
+ @Request({
135
+ path: 'register-room',
136
+ method: 'POST',
137
+ })
138
+ @Guard([guardManageWorld])
139
+ async registerRoom(req: Party.Request) {
140
+ const roomConfig: z.infer<typeof RoomConfigSchema> = await req.json();
141
+ const roomId = roomConfig.name;
142
+
143
+ if (!this.rooms()[roomId]) {
144
+ const newRoom = new RoomConfig();
145
+ newRoom.id = roomId;
146
+ newRoom.name.set(roomConfig.name);
147
+ newRoom.balancingStrategy.set(roomConfig.balancingStrategy);
148
+ newRoom.public.set(roomConfig.public);
149
+ newRoom.maxPlayersPerShard.set(roomConfig.maxPlayersPerShard);
150
+ newRoom.minShards.set(roomConfig.minShards);
151
+ newRoom.maxShards.set(roomConfig.maxShards);
152
+
153
+ this.rooms()[roomId] = newRoom;
154
+
155
+ // Ensure minimum shards are created
156
+ if (roomConfig.minShards > 0) {
157
+ for (let i = 0; i < roomConfig.minShards; i++) {
158
+ await this.createShard(roomId);
159
+ }
160
+ }
161
+ } else {
162
+ // Update existing room
163
+ const room = this.rooms()[roomId];
164
+ room.balancingStrategy.set(roomConfig.balancingStrategy);
165
+ room.public.set(roomConfig.public);
166
+ room.maxPlayersPerShard.set(roomConfig.maxPlayersPerShard);
167
+ room.minShards.set(roomConfig.minShards);
168
+ room.maxShards.set(roomConfig.maxShards);
169
+ }
170
+ }
171
+
172
+ @Request({
173
+ path: 'update-shard',
174
+ method: 'POST',
175
+ })
176
+ @Guard([guardManageWorld])
177
+ async updateShardStats(req: Party.Request, res: ServerResponse) {
178
+ const body: { shardId: string; connections: number; status: ShardStatus } = await req.json();
179
+ const { shardId, connections, status } = body;
180
+ const shard = this.shards()[shardId];
181
+
182
+ if (!shard) {
183
+ return res.notFound(`Shard ${shardId} not found`);
184
+ }
185
+
186
+ shard.currentConnections.set(connections);
187
+ if (status) {
188
+ shard.status.set(status);
189
+ }
190
+ shard.lastHeartbeat.set(Date.now());
191
+ }
192
+
193
+ @Request({
194
+ path: 'scale-room',
195
+ method: 'POST',
196
+ })
197
+ @Guard([guardManageWorld])
198
+ async scaleRoom(req: Party.Request, res: ServerResponse) {
199
+ const data: z.infer<typeof ScaleRoomSchema> = await req.json();
200
+ const { targetShardCount, shardTemplate, roomId } = data;
201
+
202
+ // Validate room exists
203
+ const room = this.rooms()[roomId];
204
+ if (!room) {
205
+ return res.notFound(`Room ${roomId} does not exist`);
206
+ }
207
+
208
+ const roomShards = Object.values(this.shards())
209
+ .filter(shard => shard.roomId() === roomId);
210
+
211
+ const previousShardCount = roomShards.length;
212
+
213
+ // Check max shards constraint
214
+ if (room.maxShards() !== undefined && targetShardCount > room.maxShards()!) {
215
+ return res.badRequest(`Cannot scale beyond maximum allowed shards (${room.maxShards()})`, {
216
+ roomId,
217
+ currentShardCount: previousShardCount
218
+ });
219
+ }
220
+
221
+ // Handle scaling down
222
+ if (targetShardCount < previousShardCount) {
223
+ // Find candidates for removal (prioritize draining or low-connection shards)
224
+ const shardsToRemove = [...roomShards]
225
+ .sort((a, b) => {
226
+ // Prioritize draining status
227
+ if (a.status() === 'draining' && b.status() !== 'draining') return -1;
228
+ if (a.status() !== 'draining' && b.status() === 'draining') return 1;
229
+
230
+ // Then by connection count (ascending)
231
+ return a.currentConnections() - b.currentConnections();
232
+ })
233
+ .slice(0, previousShardCount - targetShardCount);
234
+
235
+ // Remove the selected shards
236
+ const shardsToKeep = roomShards.filter(
237
+ shard => !shardsToRemove.some(s => s.id === shard.id)
238
+ );
239
+
240
+ // Update shards
241
+ for (const shard of shardsToRemove) {
242
+ delete this.shards()[shard.id];
243
+ }
244
+
245
+ return;
246
+ }
247
+
248
+ // Handle scaling up
249
+ if (targetShardCount > previousShardCount) {
250
+ const newShards = [];
251
+
252
+ // Create new shards
253
+ for (let i = 0; i < targetShardCount - previousShardCount; i++) {
254
+ const newShard = await this.createShard(
255
+ roomId,
256
+ shardTemplate?.urlTemplate,
257
+ shardTemplate?.maxConnections
258
+ );
259
+
260
+ if (newShard) {
261
+ newShards.push(newShard);
262
+ }
263
+ }
264
+ }
265
+ }
266
+
267
+ @Request({
268
+ path: 'connect',
269
+ method: 'POST',
270
+ })
271
+ async connect(req: Party.Request, res: ServerResponse) {
272
+ try {
273
+ // Extract request data
274
+ let data: { roomId: string; autoCreate?: boolean };
275
+
276
+ try {
277
+ // Handle potential empty body or malformed JSON
278
+ const body = await req.text();
279
+ if (!body || body.trim() === '') {
280
+ return res.badRequest("Request body is empty");
281
+ }
282
+
283
+ data = JSON.parse(body);
284
+ } catch (parseError) {
285
+ return res.badRequest("Invalid JSON in request body");
286
+ }
287
+
288
+ // Verify roomId is provided
289
+ if (!data.roomId) {
290
+ return res.badRequest("roomId parameter is required");
291
+ }
292
+
293
+ // Determine if auto-creation is enabled (default to true)
294
+ const autoCreate = data.autoCreate !== undefined ? data.autoCreate : true;
295
+
296
+ // Find optimal shard
297
+ const result = await this.findOptimalShard(data.roomId, autoCreate);
298
+
299
+ // Check for errors
300
+ if ('error' in result) {
301
+ return res.notFound(result.error);
302
+ }
303
+
304
+ // Return shard information to the client
305
+ return res.success({
306
+ success: true,
307
+ shardId: result.shardId,
308
+ url: result.url
309
+ });
310
+ } catch (error) {
311
+ console.error('Error connecting to shard:', error);
312
+ return res.serverError();
313
+ }
314
+ }
315
+
316
+ private async findOptimalShard(
317
+ roomId: string,
318
+ autoCreate: boolean = true
319
+ ): Promise<{ shardId: string; url: string } | { error: string }> {
320
+ // Ensure room exists
321
+ let room = this.rooms()[roomId];
322
+ if (!room) {
323
+ if (autoCreate) {
324
+ const mockRequest = {
325
+ json: async () => ({
326
+ name: roomId,
327
+ balancingStrategy: 'round-robin',
328
+ public: true,
329
+ maxPlayersPerShard: this.defaultMaxConnectionsPerShard(),
330
+ minShards: 1,
331
+ maxShards: undefined
332
+ })
333
+ } as Party.Request;
334
+
335
+ await this.registerRoom(mockRequest);
336
+
337
+ room = this.rooms()[roomId];
338
+
339
+ if (!room) {
340
+ return { error: `Failed to create room ${roomId}` };
341
+ }
342
+ } else {
343
+ return { error: `Room ${roomId} does not exist` };
344
+ }
345
+ }
346
+
347
+ // Get shards for this room
348
+ const roomShards = Object.values(this.shards())
349
+ .filter(shard => shard.roomId() === roomId);
350
+
351
+ if (roomShards.length === 0) {
352
+ if (autoCreate) {
353
+ // Auto-create a shard
354
+ const newShard = await this.createShard(roomId);
355
+ if (newShard) {
356
+ return {
357
+ shardId: newShard.id,
358
+ url: newShard.url()
359
+ };
360
+ } else {
361
+ return { error: `Failed to create shard for room ${roomId}` };
362
+ }
363
+ } else {
364
+ return { error: `No shards available for room ${roomId}` };
365
+ }
366
+ }
367
+
368
+ // Get active shards
369
+ const activeShards = roomShards
370
+ .filter(shard => shard && shard.status() === 'active');
371
+
372
+ if (activeShards.length === 0) {
373
+ return { error: `No active shards available for room ${roomId}` };
374
+ }
375
+
376
+ // Apply balancing strategy
377
+ const balancingStrategy = room.balancingStrategy();
378
+ let selectedShard: ShardInfo;
379
+
380
+ switch (balancingStrategy) {
381
+ case 'least-connections':
382
+ // Choose shard with fewest connections
383
+ selectedShard = activeShards.reduce(
384
+ (min, shard) =>
385
+ shard.currentConnections() < min.currentConnections() ? shard : min,
386
+ activeShards[0]
387
+ );
388
+ break;
389
+
390
+ case 'random':
391
+ // Choose random shard
392
+ selectedShard = activeShards[Math.floor(Math.random() * activeShards.length)];
393
+ break;
394
+
395
+ case 'round-robin':
396
+ default:
397
+ // Round-robin selection
398
+ const counter = this.rrCounters()[roomId] || 0;
399
+ const nextCounter = (counter + 1) % activeShards.length;
400
+ this.rrCounters()[roomId] = nextCounter;
401
+
402
+ selectedShard = activeShards[counter];
403
+ break;
404
+ }
405
+
406
+ return {
407
+ shardId: selectedShard.id,
408
+ url: selectedShard.url()
409
+ };
410
+ }
411
+
412
+ // Private methods
413
+ private async createShard(
414
+ roomId: string,
415
+ urlTemplate?: string,
416
+ maxConnections?: number
417
+ ): Promise<ShardInfo | null> {
418
+ const room = this.rooms()[roomId];
419
+ if (!room) {
420
+ console.error(`Cannot create shard for non-existent room: ${roomId}`);
421
+ return null;
422
+ }
423
+
424
+ // Generate shard ID
425
+ const shardId = `${roomId}:${Date.now()}-${Math.floor(Math.random() * 10000)}`;
426
+
427
+ // Generate URL from template
428
+ const template = urlTemplate || this.defaultShardUrlTemplate();
429
+ const url = template.replace('{shardId}', shardId).replace('{roomId}', roomId);
430
+
431
+ // Set max connections
432
+ const max = maxConnections || room.maxPlayersPerShard();
433
+
434
+ // Create the shard
435
+ const newShard = new ShardInfo();
436
+ newShard.id = shardId;
437
+ newShard.roomId.set(roomId);
438
+ newShard.url.set(url);
439
+ newShard.maxConnections.set(max);
440
+ newShard.currentConnections.set(0);
441
+ newShard.status.set("active");
442
+ newShard.lastHeartbeat.set(Date.now());
443
+
444
+ // Update shards collection
445
+ this.shards()[shardId] = newShard;
446
+ return newShard;
447
+ }
448
+ }
@@ -1,82 +0,0 @@
1
- import { useEffect, useRef, useState } from "react";
2
- import { effect } from '../../../../../reactive';
3
- import { connection } from '../../../../../sync/src/client';
4
- import { RoomSchema } from "../../shared/room.schema";
5
-
6
- let val = localStorage.getItem('test')
7
-
8
- if (!val) {
9
- val = ''+Math.random()
10
- localStorage.setItem('test', val)
11
- }
12
-
13
- export default function Counter() {
14
- const [refresh, setRefresh] = useState(0);
15
- const [count, setCount] = useState(0);
16
- const [users, setUsers] = useState<any[]>([]);
17
- let socket = useRef<any>(null);
18
- let room = useRef<any>(null);
19
-
20
- useEffect(() => {
21
- room.current = new RoomSchema();
22
- socket.current = connection({
23
- host: location.hostname == 'localhost' ? 'localhost:1999' : 'https://signe.rsamaium.partykit.dev',
24
- room: 'game',
25
- id: val as string
26
- }, room.current);
27
-
28
- socket.current.on('user_disconnected', (data: any) => {
29
- console.log(data)
30
- })
31
-
32
- // Subscribe to changes
33
- effect(() => {
34
- if (room.current) {
35
- setCount(room.current.count());
36
- setUsers(Object.values(room.current.users()));
37
- setRefresh(refresh + 1);
38
- }
39
- });
40
- }, []);
41
-
42
- const increment = () => {
43
- room.current.count.update((count: number) => count + 1);
44
- socket.current.emit('increment')
45
- };
46
-
47
- const styles = {
48
- backgroundColor: "#ff0f0f",
49
- borderRadius: "9999px",
50
- border: "none",
51
- color: "white",
52
- fontSize: "0.95rem",
53
- cursor: "pointer",
54
- padding: "1rem 3rem",
55
- margin: "1rem 0rem",
56
- };
57
-
58
- const getStorage = async () => {
59
- const response = await fetch('/party/game').then(res => res.json())
60
- console.log(JSON.stringify(response, null, 2))
61
- }
62
-
63
- return (
64
- <>
65
- {
66
- room.current && (
67
- <div key={refresh}>
68
- <button style={styles} onClick={increment}>
69
- Increment me! {count !== null && <>Count: {count}</>}
70
- </button>
71
- <button onClick={getStorage}>get storage</button>
72
- <ul>
73
- {users.map((user: any) => (
74
- <li key={user.id()}>{user.id()} : {user.score()}</li>
75
- ))}
76
- </ul>
77
- </div>
78
- )
79
- }
80
- </>
81
- );
82
- }