@signe/room 1.4.1 → 2.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.
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
+
10
+ // Types definitions
11
+ type BalancingStrategy = 'round-robin' | 'least-connections' | 'random';
12
+ type ShardStatus = 'active' | 'maintenance' | 'draining';
13
+
14
+ // Schema validations
15
+ const RoomConfigSchema = z.object({
16
+ name: z.string(),
17
+ balancingStrategy: z.enum(['round-robin', 'least-connections', 'random']),
18
+ public: z.boolean(),
19
+ maxPlayersPerShard: z.number().int().positive(),
20
+ minShards: z.number().int().min(0),
21
+ maxShards: z.number().int().positive().optional(),
22
+ });
23
+
24
+ const RegisterShardSchema = z.object({
25
+ shardId: z.string(),
26
+ roomId: z.string(),
27
+ url: z.string().url(),
28
+ maxConnections: z.number().int().positive(),
29
+ });
30
+
31
+ const UpdateShardStatsSchema = z.object({
32
+ connections: z.number().int().min(0),
33
+ status: z.enum(['active', 'maintenance', 'draining']).optional(),
34
+ });
35
+
36
+ const ScaleRoomSchema = z.object({
37
+ roomId: z.string(),
38
+ targetShardCount: z.number().int().positive(),
39
+ shardTemplate: z.object({
40
+ urlTemplate: z.string(),
41
+ maxConnections: z.number().int().positive(),
42
+ }).optional(),
43
+ });
44
+
45
+ // Model classes
46
+ class RoomConfig {
47
+ @id() id: string;
48
+ @sync() name = signal("");
49
+ @sync() balancingStrategy = signal<BalancingStrategy>("round-robin");
50
+ @sync() public = signal(true);
51
+ @sync() maxPlayersPerShard = signal(100);
52
+ @sync() minShards = signal(1);
53
+ @sync() maxShards = signal<number | undefined>(undefined);
54
+ }
55
+
56
+ class ShardInfo {
57
+ @id() id: string;
58
+ @sync() roomId = signal("");
59
+ @sync() url = signal("");
60
+ @sync({
61
+ persist: false
62
+ }) currentConnections = signal(0);
63
+ @sync() maxConnections = signal(100);
64
+ @sync() status = signal<ShardStatus>("active");
65
+ @sync() lastHeartbeat = signal(0);
66
+ }
67
+
68
+ // World room implementation
69
+ @Room({
70
+ path: "world-{worldId}",
71
+ maxUsers: 100, // Limit for admin connections
72
+ throttleStorage: 2000, // Throttle storage updates (ms)
73
+ throttleSync: 500, // Throttle sync updates (ms)
74
+ })
75
+ export class WorldRoom implements RoomInterceptorPacket, RoomOnJoin {
76
+ // Synchronized state
77
+ @sync(RoomConfig) rooms = signal<Record<string, RoomConfig>>({});
78
+ @sync(ShardInfo) shards = signal<Record<string, ShardInfo>>({});
79
+
80
+ // Only persisted state (not synced to clients)
81
+ @persist() rrCounters = signal<Record<string, number>>({});
82
+
83
+ // Configuration
84
+ defaultShardUrlTemplate = signal("{shardId}");
85
+ defaultMaxConnectionsPerShard = signal(100);
86
+
87
+ constructor(private room: Party.Room) {
88
+ const { AUTH_JWT_SECRET, SHARD_SECRET } = this.room.env;
89
+ if (!AUTH_JWT_SECRET) {
90
+ throw new Error("AUTH_JWT_SECRET env variable is not set");
91
+ }
92
+ if (!SHARD_SECRET) {
93
+ throw new Error("SHARD_SECRET env variable is not set");
94
+ }
95
+ setTimeout(() => this.cleanupInactiveShards(), 60000);
96
+ }
97
+
98
+ async onJoin(user: any, conn: Party.Connection, ctx: Party.ConnectionContext) {
99
+ const canConnect = await guardManageWorld(user, ctx.request, this.room);
100
+ conn.setState({
101
+ ...conn.state,
102
+ isAdmin: canConnect
103
+ })
104
+ }
105
+
106
+ interceptorPacket(_, obj: any, conn: Party.Connection) {
107
+ if (!conn.state['isAdmin']) {
108
+ return null;
109
+ }
110
+ return obj;
111
+ }
112
+
113
+ // Helper methods
114
+ private cleanupInactiveShards() {
115
+ const now = Date.now();
116
+ const timeout = 5 * 60 * 1000; // 5 minutes timeout
117
+ const shardsValue = this.shards();
118
+
119
+ let hasChanges = false;
120
+ Object.values(shardsValue).forEach(shard => {
121
+ if (now - shard.lastHeartbeat() > timeout) {
122
+ delete this.shards()[shard.id];
123
+
124
+ hasChanges = true;
125
+ }
126
+ });
127
+
128
+ // Schedule next cleanup
129
+ setTimeout(() => this.cleanupInactiveShards(), 60000);
130
+ }
131
+
132
+ // Actions
133
+ @Request({
134
+ path: 'register-room',
135
+ method: 'POST',
136
+ })
137
+ @Guard([guardManageWorld])
138
+ async registerRoom(req: Party.Request) {
139
+ const roomConfig: z.infer<typeof RoomConfigSchema> = await req.json();
140
+ const roomId = roomConfig.name;
141
+
142
+ if (!this.rooms()[roomId]) {
143
+ const newRoom = new RoomConfig();
144
+ newRoom.id = roomId;
145
+ newRoom.name.set(roomConfig.name);
146
+ newRoom.balancingStrategy.set(roomConfig.balancingStrategy);
147
+ newRoom.public.set(roomConfig.public);
148
+ newRoom.maxPlayersPerShard.set(roomConfig.maxPlayersPerShard);
149
+ newRoom.minShards.set(roomConfig.minShards);
150
+ newRoom.maxShards.set(roomConfig.maxShards);
151
+
152
+ this.rooms()[roomId] = newRoom;
153
+
154
+ // Ensure minimum shards are created
155
+ if (roomConfig.minShards > 0) {
156
+ for (let i = 0; i < roomConfig.minShards; i++) {
157
+ await this.createShard(roomId);
158
+ }
159
+ }
160
+ } else {
161
+ // Update existing room
162
+ const room = this.rooms()[roomId];
163
+ room.balancingStrategy.set(roomConfig.balancingStrategy);
164
+ room.public.set(roomConfig.public);
165
+ room.maxPlayersPerShard.set(roomConfig.maxPlayersPerShard);
166
+ room.minShards.set(roomConfig.minShards);
167
+ room.maxShards.set(roomConfig.maxShards);
168
+ }
169
+ }
170
+
171
+ @Request({
172
+ path: 'update-shard',
173
+ method: 'POST',
174
+ })
175
+ @Guard([guardManageWorld])
176
+ async updateShardStats(req: Party.Request) {
177
+ const body: { shardId: string; connections: number; status: ShardStatus } = await req.json();
178
+ const { shardId, connections, status } = body;
179
+ const shard = this.shards()[shardId];
180
+
181
+ if (!shard) {
182
+ return { error: `Shard ${shardId} not found` };
183
+ }
184
+
185
+ shard.currentConnections.set(connections);
186
+ if (status) {
187
+ shard.status.set(status);
188
+ }
189
+ shard.lastHeartbeat.set(Date.now());
190
+ }
191
+
192
+ @Request({
193
+ path: 'scale-room',
194
+ method: 'POST',
195
+ })
196
+ @Guard([guardManageWorld])
197
+ async scaleRoom(req: Party.Request) {
198
+ const data: z.infer<typeof ScaleRoomSchema> = await req.json();
199
+ const { targetShardCount, shardTemplate, roomId } = data;
200
+
201
+ // Validate room exists
202
+ const room = this.rooms()[roomId];
203
+ if (!room) {
204
+ return { error: `Room ${roomId} does not exist` };
205
+ }
206
+
207
+ const roomShards = Object.values(this.shards())
208
+ .filter(shard => shard.roomId() === roomId);
209
+
210
+ const previousShardCount = roomShards.length;
211
+
212
+ // Check max shards constraint
213
+ if (room.maxShards() !== undefined && targetShardCount > room.maxShards()!) {
214
+ return {
215
+ error: `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) {
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 response(400, { error: "Request body is empty" });
281
+ }
282
+
283
+ data = JSON.parse(body);
284
+ } catch (parseError) {
285
+ return response(400, { error: "Invalid JSON in request body" });
286
+ }
287
+
288
+ // Verify roomId is provided
289
+ if (!data.roomId) {
290
+ return response(400, { error: "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 response(404, { error: result.error });
302
+ }
303
+
304
+ // Return shard information to the client
305
+ return response(200, {
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 response(500, { error: "Internal server error", details: error instanceof Error ? error.message : String(error) });
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
- }