@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/dist/index.d.ts +258 -22
- package/dist/index.js +1447 -60
- package/dist/index.js.map +1 -1
- package/examples/game/app/client.tsx +2 -2
- package/examples/game/app/components/Admin.tsx +1089 -0
- package/examples/game/app/components/Room.tsx +158 -0
- package/examples/game/party/server.ts +3 -2
- package/examples/game/party/shard.ts +5 -0
- package/examples/game/partykit.json +5 -1
- package/package.json +2 -2
- package/readme.md +226 -2
- package/src/decorators.ts +34 -2
- package/src/index.ts +4 -1
- package/src/interfaces.ts +13 -0
- package/src/jwt.ts +217 -0
- package/src/mock.ts +39 -3
- package/src/server.ts +595 -79
- package/src/shard.ts +244 -0
- package/src/testing.ts +47 -6
- package/src/utils.ts +7 -0
- package/src/world.guard.ts +28 -0
- package/src/world.ts +448 -0
- package/examples/game/app/components/Counter.tsx +0 -82
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
|
-
|
|
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
|
-
|
|
37
|
-
server
|
|
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
|
+
}
|