@signe/room 1.4.2 → 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/shard.ts ADDED
@@ -0,0 +1,244 @@
1
+ import type * as Party from "./types/party";
2
+ import { response } from "./utils";
3
+
4
+ interface PartyWebSocket {
5
+ send: (data: string | ArrayBufferLike | Blob | ArrayBufferView) => void;
6
+ addEventListener: (type: string, listener: (event: any) => void) => void;
7
+ close: () => void;
8
+ }
9
+
10
+ export interface ShardOptions {
11
+ worldUrl?: string;
12
+ worldId?: string;
13
+ statsInterval?: number;
14
+ }
15
+
16
+ export class Shard {
17
+ ws: PartyWebSocket;
18
+ connectionMap = new Map<string, Party.Connection>(); // Map privateId -> connection
19
+ mainServerStub: any;
20
+ worldUrl: string | null = null;
21
+ worldId: string = 'default';
22
+ lastReportedConnections: number = 0;
23
+ statsInterval: number = 30000;
24
+ statsIntervalId: any = null;
25
+
26
+ constructor(private room: Party.Room) {}
27
+
28
+ async onStart() {
29
+ const roomId = this.room.id.split(':')[0];
30
+ const roomStub = this.room.context.parties.main.get(roomId);
31
+ if (!roomStub) {
32
+ console.warn('No room room stub found in main party context');
33
+ return;
34
+ }
35
+
36
+ this.mainServerStub = roomStub;
37
+ this.ws = await roomStub.socket({
38
+ headers: {
39
+ 'x-shard-id': this.room.id
40
+ }
41
+ }) as unknown as PartyWebSocket;
42
+
43
+ // Handle messages from the main server
44
+ this.ws.addEventListener("message", (event) => {
45
+ try {
46
+ const message = JSON.parse(event.data);
47
+
48
+ // If the message is directed to a specific client, forward it
49
+ if (message.targetClientId) {
50
+ const clientConn = this.connectionMap.get(message.targetClientId);
51
+ if (clientConn) {
52
+ // Remove the routing information before forwarding
53
+ delete message.targetClientId;
54
+ clientConn.send(message.data);
55
+ }
56
+ } else {
57
+ // Broadcast to all clients if no specific target
58
+ this.room.broadcast(event.data);
59
+ }
60
+ } catch (error) {
61
+ console.error("Error processing message from main server:", error);
62
+ }
63
+ });
64
+
65
+ await this.updateWorldStats();
66
+ this.startPeriodicStatsUpdates();
67
+ }
68
+
69
+ private startPeriodicStatsUpdates() {
70
+ if (!this.worldUrl) {
71
+ return;
72
+ }
73
+
74
+ if (this.statsIntervalId) {
75
+ clearInterval(this.statsIntervalId);
76
+ }
77
+
78
+ this.statsIntervalId = setInterval(() => {
79
+ this.updateWorldStats().catch(error => {
80
+ console.error('Error in periodic stats update:', error);
81
+ });
82
+ }, this.statsInterval);
83
+ }
84
+
85
+ private stopPeriodicStatsUpdates() {
86
+ if (this.statsIntervalId) {
87
+ clearInterval(this.statsIntervalId);
88
+ this.statsIntervalId = null;
89
+ }
90
+ }
91
+
92
+ onConnect(conn: Party.Connection, ctx: Party.ConnectionContext) {
93
+ // Store connection mapping
94
+ this.connectionMap.set(conn.id, conn);
95
+
96
+ // Notify the main server about the new connection with connection metadata
97
+ this.ws.send(JSON.stringify({
98
+ type: 'shard.clientConnected',
99
+ privateId: conn.id,
100
+ connectionInfo: {
101
+ ip: ctx.request?.headers.get('x-forwarded-for') || 'unknown',
102
+ userAgent: ctx.request?.headers.get('user-agent') || 'unknown',
103
+ // Add any other relevant connection info
104
+ }
105
+ }));
106
+
107
+ this.updateWorldStats();
108
+ }
109
+
110
+ onMessage(message: string | ArrayBuffer | ArrayBufferView, sender: Party.Connection) {
111
+ try {
112
+ // Parse the message if it's a string
113
+ const parsedMessage = typeof message === 'string' ? JSON.parse(message) : message;
114
+
115
+ // Wrap the original message with sender information
116
+ const wrappedMessage = JSON.stringify({
117
+ type: 'shard.clientMessage',
118
+ privateId: sender.id,
119
+ publicId: (sender.state as any)?.publicId,
120
+ payload: parsedMessage
121
+ });
122
+
123
+ // Forward to main server
124
+ this.ws.send(wrappedMessage);
125
+ } catch (error) {
126
+ console.error("Error forwarding message to main server:", error);
127
+ }
128
+ }
129
+
130
+ onClose(conn: Party.Connection) {
131
+ // Remove connection from the map
132
+ this.connectionMap.delete(conn.id);
133
+
134
+ // Notify main server about disconnection
135
+ this.ws.send(JSON.stringify({
136
+ type: 'shard.clientDisconnected',
137
+ privateId: conn.id,
138
+ publicId: (conn.state as any)?.publicId
139
+ }));
140
+
141
+ this.updateWorldStats();
142
+ }
143
+
144
+ async updateWorldStats(): Promise<boolean> {
145
+ const currentConnections = this.connectionMap.size;
146
+
147
+ if (currentConnections === this.lastReportedConnections) {
148
+ return true;
149
+ }
150
+
151
+ try {
152
+ const worldRoom = this.room.context.parties.world.get('world-default');
153
+ const response = await worldRoom.fetch('/update-shard', {
154
+ method: 'POST',
155
+ headers: {
156
+ 'Content-Type': 'application/json',
157
+ 'x-access-shard': this.room.env.SHARD_SECRET as string
158
+ },
159
+ body: JSON.stringify({
160
+ shardId: this.room.id,
161
+ connections: currentConnections
162
+ })
163
+ });
164
+
165
+ if (!response.ok) {
166
+ const errorData = await response.json().catch(() => ({ error: 'Unknown error' }));
167
+ console.error(`Failed to update World stats: ${response.status} - ${errorData.error || 'Unknown error'}`);
168
+ return false;
169
+ }
170
+
171
+ // Mettre à jour le dernier nombre rapporté
172
+ this.lastReportedConnections = currentConnections;
173
+ return true;
174
+ } catch (error) {
175
+ console.error('Error updating World stats:', error);
176
+ return false;
177
+ }
178
+ }
179
+
180
+ /**
181
+ * @method onRequest
182
+ * @async
183
+ * @param {Party.Request} req - The HTTP request to handle
184
+ * @description Forwards HTTP requests to the main server, preserving client context
185
+ * @returns {Promise<Response>} The response from the main server
186
+ */
187
+ async onRequest(req: Party.Request): Promise<Response> {
188
+ if (!this.mainServerStub) {
189
+ return response(503, { error: 'Shard not connected to main server' });
190
+ }
191
+
192
+ try {
193
+ // Extract necessary information from the request
194
+ const url = new URL(req.url);
195
+ const path = url.pathname;
196
+ const method = req.method;
197
+ let body: string | null = null;
198
+
199
+ if (method !== 'GET' && method !== 'HEAD') {
200
+ body = await req.text();
201
+ }
202
+
203
+ // Convert original headers to a new Headers object
204
+ const headers = new Headers();
205
+ req.headers.forEach((value, key) => {
206
+ headers.set(key, value);
207
+ });
208
+
209
+ // Add shard identification
210
+ headers.set('x-shard-id', this.room.id);
211
+ headers.set('x-forwarded-by-shard', 'true');
212
+
213
+ // Client IP tracking for the main server
214
+ const clientIp = req.headers.get('x-forwarded-for') || 'unknown';
215
+ if (clientIp) {
216
+ headers.set('x-original-client-ip', clientIp);
217
+ }
218
+
219
+ // Prepare request options
220
+ const requestInit: RequestInit = {
221
+ method,
222
+ headers,
223
+ body
224
+ };
225
+
226
+ // Forward the request to the main server
227
+ const response = await this.mainServerStub.fetch(path, requestInit);
228
+ return response;
229
+ } catch (error) {
230
+ return response(500, { error: 'Error forwarding request' });
231
+ }
232
+ }
233
+
234
+ /**
235
+ * @method onAlarm
236
+ * @async
237
+ * @description Executed periodically, used to perform maintenance tasks
238
+ */
239
+ async onAlarm() {
240
+ await this.updateWorldStats();
241
+ }
242
+ }
243
+
244
+ Shard satisfies Party.Worker;
package/src/testing.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { ServerIo } from "./mock"
2
2
  import { Server } from "./server"
3
+ import { Shard } from "./shard"
3
4
 
4
5
  /**
5
6
  * @description Test the room with a mock server and client
@@ -27,21 +28,61 @@ import { Server } from "./server"
27
28
  * @returns The server, room, and createClient function
28
29
  */
29
30
  export async function testRoom(Room, options: {
30
- hibernate?: boolean
31
+ hibernate?: boolean,
32
+ shard?: boolean,
33
+ env?: Record<string, string>
31
34
  } = {}) {
32
- const io = new ServerIo(Room.path)
35
+
36
+ const createServer = (io: any) => {
37
+ const server = new Server(io)
38
+ server.rooms = [Room]
39
+ return server
40
+ }
41
+
42
+ const isShard = options.shard || false
43
+ const io = new ServerIo(Room.path, isShard ? {
44
+ parties: {
45
+ game: createServer
46
+ },
47
+ env: options.env
48
+ } : {
49
+ env: options.env
50
+ })
33
51
  Room.prototype.throttleSync = 0
34
52
  Room.prototype.throttleStorage = 0
35
53
  Room.prototype.options = options
36
- const server = new Server(io as any)
37
- server.rooms = [Room]
54
+
55
+ let server: Server | Shard;
56
+ if (options.shard) {
57
+ const shardServer = new Shard(io as any);
58
+ // Add subRoom property to Shard for compatibility with Server
59
+ (shardServer as any).subRoom = null;
60
+ server = shardServer;
61
+ } else {
62
+ server = await createServer(io as any);
63
+ }
64
+
38
65
  await server.onStart()
66
+
39
67
  return {
40
68
  server,
41
- room: server.subRoom,
69
+ room: (server as any).subRoom,
42
70
  createClient: async () => {
43
- const client = await io.connection(server)
71
+ const client = await io.connection(server as Server)
44
72
  return client
45
73
  }
46
74
  }
47
75
  }
76
+
77
+ export async function request(room: Server, path: string, options: {
78
+ method: 'GET' | 'POST' | 'PUT' | 'DELETE',
79
+ body?: any,
80
+ headers?: Record<string, string>
81
+ } = {
82
+ method: 'GET',
83
+ }) {
84
+ const url = new URL('http://localhost' + path)
85
+ const request = new Request(url.toString(), options)
86
+ const response = await room.onRequest(request as any)
87
+ return response
88
+ }
package/src/utils.ts CHANGED
@@ -217,4 +217,11 @@ export function buildObject(valuesMap: Map<string, any>, allMemory: Record<strin
217
217
  }
218
218
  }
219
219
  return memoryObj;
220
+ }
221
+
222
+ export function response(status: number, body: any): Response {
223
+ return new Response(JSON.stringify(body), {
224
+ status,
225
+ headers: { 'Content-Type': 'application/json' }
226
+ });
220
227
  }
@@ -0,0 +1,28 @@
1
+ import * as Party from "./types/party";
2
+ import { JWTAuth } from "./jwt";
3
+ import { response } from "./utils";
4
+
5
+ export const guardManageWorld = async (_, req: Party.Request, room: Party.Room): Promise<boolean> => {
6
+ const tokenShard = req.headers.get("x-access-shard");
7
+ if (tokenShard) {
8
+ if (tokenShard !== room.env.SHARD_SECRET) {
9
+ return false
10
+ }
11
+ return true
12
+ }
13
+ const url = new URL(req.url);
14
+ const token = req.headers.get("Authorization") ?? url.searchParams.get("world-auth-token");
15
+ if (!token) {
16
+ return false;
17
+ }
18
+ const jwt = new JWTAuth(room.env.AUTH_JWT_SECRET as string);
19
+ try {
20
+ const payload = await jwt.verify(token);
21
+ if (!payload) {
22
+ return false;
23
+ }
24
+ } catch (error) {
25
+ return false;
26
+ }
27
+ return true;
28
+ }