@signe/room 0.0.1 → 0.0.2

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/server.ts CHANGED
@@ -1,6 +1,12 @@
1
- import { createStatesSnapshot, getByPath, load, syncClass } from "@signe/sync";
2
1
  import { dset } from "dset";
3
2
  import z from "zod";
3
+ import {
4
+ createStatesSnapshot,
5
+ getByPath,
6
+ load,
7
+ syncClass,
8
+ } from "../../sync/src";
9
+ import { generateShortUUID } from "../../sync/src/utils";
4
10
  import type * as Party from "./types/party";
5
11
  import {
6
12
  awaitReturn,
@@ -15,33 +21,121 @@ const Message = z.object({
15
21
  value: z.any(),
16
22
  });
17
23
 
24
+ type CreateRoomOptions = {
25
+ getMemoryAll?: boolean;
26
+ };
27
+
28
+ /**
29
+ * @class Server
30
+ * @implements {Party.Server}
31
+ * @description Represents a server that manages rooms and connections for a multiplayer game or application.
32
+ *
33
+ * @example
34
+ * ```typescript
35
+ * import { Room, Server, ServerIo } from "@yourpackage/room";
36
+ *
37
+ * @Room({ path: "game" })
38
+ * class GameRoom {
39
+ * // Room implementation
40
+ * }
41
+ *
42
+ * class MyServer extends Server {
43
+ * rooms = [GameRoom];
44
+ * }
45
+ *
46
+ * const server = new MyServer(new ServerIo("game"));
47
+ * server.onStart();
48
+ * ```
49
+ */
18
50
  export class Server implements Party.Server {
19
- memoryAll = {};
20
- subRoom = {};
21
- rooms = [];
51
+ subRoom = null;
52
+ rooms: any[] = [];
22
53
 
23
- static async onBeforeConnect(request: Party.Request) {
24
- try {
25
- request.headers.set("X-User-ID", "" + Math.random());
26
- return request;
27
- } catch (e) {
28
- return new Response("Unauthorized", { status: 401 });
54
+ /**
55
+ * @constructor
56
+ * @param {Party.Room} room - The room object representing the current game or application instance.
57
+ *
58
+ * @example
59
+ * ```typescript
60
+ * const server = new MyServer(new ServerIo("game"));
61
+ * ```
62
+ */
63
+ constructor(readonly room: Party.Room) {}
64
+
65
+ /**
66
+ * @readonly
67
+ * @property {boolean} isHibernate - Indicates whether the server is in hibernate mode.
68
+ *
69
+ * @example
70
+ * ```typescript
71
+ * if (!server.isHibernate) {
72
+ * console.log("Server is active");
73
+ * }
74
+ * ```
75
+ */
76
+ get isHibernate(): boolean {
77
+ return !!this["options"]?.hibernate;
78
+ }
79
+
80
+ /**
81
+ * @method onStart
82
+ * @async
83
+ * @description Initializes the server and creates the initial room if not in hibernate mode.
84
+ * @returns {Promise<void>}
85
+ *
86
+ * @example
87
+ * ```typescript
88
+ * async function initServer() {
89
+ * await server.onStart();
90
+ * console.log("Server started");
91
+ * }
92
+ * ```
93
+ */
94
+
95
+ async onStart() {
96
+ // Only create a room if not in hibernate mode
97
+ // This prevents unnecessary resource allocation for inactive rooms
98
+ if (!this.isHibernate) {
99
+ this.subRoom = await this.createRoom();
29
100
  }
30
101
  }
31
102
 
32
- constructor(readonly room: Party.Room) {
103
+ /**
104
+ * @method createRoom
105
+ * @private
106
+ * @async
107
+ * @param {CreateRoomOptions} [options={}] - Options for creating the room.
108
+ * @returns {Promise<Object>} The created room instance.
109
+ * @throws {Error} If no matching room is found.
110
+ *
111
+ * @example
112
+ * ```typescript
113
+ * // This method is private and called internally
114
+ * async function internalCreateRoom() {
115
+ * const room = await this.createRoom({ getMemoryAll: true });
116
+ * console.log("Room created:", room);
117
+ * }
118
+ * ```
119
+ */
120
+ private async createRoom(options: CreateRoomOptions = {}) {
121
+ let instance
122
+ let init = true
123
+
124
+ // Find the appropriate room based on the current room ID
33
125
  for (let room of this.rooms) {
34
126
  const params = extractParams(room.path, this.room.id);
35
127
  if (params) {
36
- this.subRoom = new room(this.room, params);
128
+ instance = new room(this.room, params);
37
129
  break;
38
130
  }
39
131
  }
40
132
 
41
- if (!this.subRoom) {
133
+ if (!instance) {
42
134
  throw new Error("Room not found");
43
135
  }
44
136
 
137
+ // Load the room's memory from storage
138
+ // This ensures persistence across server restarts
45
139
  const loadMemory = async () => {
46
140
  const root = await this.room.storage.get(".");
47
141
  const memory = await this.room.storage.list();
@@ -52,77 +146,188 @@ export class Server implements Party.Server {
52
146
  }
53
147
  dset(tmpObject, key, value);
54
148
  }
55
- load(this, tmpObject);
149
+ load(instance, tmpObject);
56
150
  };
57
151
 
58
- loadMemory();
152
+ await loadMemory();
59
153
 
60
- syncClass(this.subRoom, {
61
- onSync: throttle((values) => {
62
- const packet = buildObject(values, this.memoryAll);
63
- this.room.broadcast(
64
- JSON.stringify({
65
- type: "sync",
66
- value: packet,
67
- })
68
- );
69
- values.clear();
70
- }, 500),
71
- onPersist: throttle(async (values) => {
72
- for (let path of values) {
73
- const instance =
74
- path == "." ? this.subRoom : getByPath(this.subRoom, path);
75
- const itemValue = createStatesSnapshot(instance);
76
- await this.room.storage.put(path, itemValue);
77
- }
78
- values.clear();
79
- }, this.subRoom['throttleStorage'] ?? 2000),
154
+ instance.$memoryAll = {}
155
+
156
+ // Sync callback: Broadcast changes to all clients
157
+ const syncCb = (values) => {
158
+ if (options.getMemoryAll) {
159
+ buildObject(values, instance.$memoryAll);
160
+ }
161
+ if (init && this.isHibernate) {
162
+ init = false;
163
+ return;
164
+ }
165
+ const packet = buildObject(values, instance.$memoryAll);
166
+ this.room.broadcast(
167
+ JSON.stringify({
168
+ type: "sync",
169
+ value: packet,
170
+ })
171
+ );
172
+ values.clear();
173
+ }
174
+
175
+ // Persist callback: Save changes to storage
176
+ const persistCb = async (values) => {
177
+ for (let path of values) {
178
+ const _instance =
179
+ path == "." ? instance : getByPath(instance, path);
180
+ const itemValue = createStatesSnapshot(_instance);
181
+ await this.room.storage.put(path, itemValue);
182
+ }
183
+ values.clear();
184
+ }
185
+
186
+ // Set up syncing and persistence with throttling to optimize performance
187
+ syncClass(instance, {
188
+ onSync: throttle(syncCb, instance["throttleSync"] ?? 500),
189
+ onPersist: throttle(persistCb, instance["throttleStorage"] ?? 2000),
80
190
  });
191
+
192
+ return instance
193
+ }
194
+
195
+ /**
196
+ * @method getSubRoom
197
+ * @private
198
+ * @async
199
+ * @param {Object} [options={}] - Options for getting the sub-room.
200
+ * @returns {Promise<Object>} The sub-room instance.
201
+ *
202
+ * @example
203
+ * ```typescript
204
+ * // This method is private and called internally
205
+ * async function internalGetSubRoom() {
206
+ * const subRoom = await this.getSubRoom();
207
+ * console.log("Sub-room retrieved:", subRoom);
208
+ * }
209
+ * ```
210
+ */
211
+ private async getSubRoom(options = {}) {
212
+ let subRoom
213
+ if (this.isHibernate) {
214
+ subRoom = await this.createRoom(options)
215
+ }
216
+ else {
217
+ subRoom = this.subRoom
218
+ }
219
+ return subRoom
81
220
  }
82
221
 
83
- private getUsersProperty() {
84
- const meta = this.subRoom.constructor['_propertyMetadata'];
222
+ /**
223
+ * @method getUsersProperty
224
+ * @private
225
+ * @param {Object} subRoom - The sub-room instance.
226
+ * @returns {Object|null} The users property of the sub-room, or null if not found.
227
+ *
228
+ * @example
229
+ * ```typescript
230
+ * // This method is private and called internally
231
+ * function internalGetUsers(subRoom) {
232
+ * const users = this.getUsersProperty(subRoom);
233
+ * console.log("Users:", users);
234
+ * }
235
+ * ```
236
+ */
237
+
238
+ private getUsersProperty(subRoom) {
239
+ const meta = subRoom.constructor["_propertyMetadata"];
85
240
  const propId = meta?.get("users");
86
241
  if (propId) {
87
- return this.subRoom[propId];
242
+ return subRoom[propId];
88
243
  }
89
244
  return null;
90
245
  }
91
246
 
247
+ /**
248
+ * @method onConnect
249
+ * @async
250
+ * @param {Party.Connection} conn - The connection object for the new user.
251
+ * @param {Party.ConnectionContext} ctx - The context of the connection.
252
+ * @description Handles a new user connection, creates a user object, and sends initial sync data.
253
+ * @returns {Promise<void>}
254
+ *
255
+ * @example
256
+ * ```typescript
257
+ * server.onConnect = async (conn, ctx) => {
258
+ * await server.onConnect(conn, ctx);
259
+ * console.log("New user connected:", conn.id);
260
+ * };
261
+ * ```
262
+ */
92
263
  async onConnect(conn: Party.Connection, ctx: Party.ConnectionContext) {
93
- const publicId = "a" + ("" + Math.random()).split(".")[1];
264
+ const subRoom = await this.getSubRoom({
265
+ getMemoryAll: true,
266
+ })
267
+ // Generate a unique public ID for the user
268
+ const publicId = generateShortUUID()
94
269
  let user = null;
95
- const signal = this.getUsersProperty();
270
+ const signal = this.getUsersProperty(subRoom);
96
271
  if (signal) {
97
272
  const { classType } = signal.options;
273
+ // Create a new user instance based on the defined class type
98
274
  user = isClass(classType) ? new classType() : classType(conn, ctx);
99
275
  signal()[publicId] = user;
100
276
  }
101
- await awaitReturn(this.subRoom['onJoin']?.(user, conn, ctx));
277
+ // Call the room's onJoin method if it exists
278
+ await awaitReturn(subRoom["onJoin"]?.(user, conn, ctx));
102
279
  conn.setState({ publicId });
280
+ // Send initial sync data to the new connection
103
281
  conn.send(
104
282
  JSON.stringify({
105
283
  type: "sync",
106
284
  value: {
107
285
  pId: publicId,
108
- ...this.memoryAll,
286
+ ...subRoom.$memoryAll,
109
287
  },
110
288
  })
111
289
  );
112
290
  }
113
291
 
292
+ /**
293
+ * @method onMessage
294
+ * @async
295
+ * @param {string} message - The message received from a user.
296
+ * @param {Party.Connection} sender - The connection object of the sender.
297
+ * @description Processes incoming messages and triggers corresponding actions in the sub-room.
298
+ * @returns {Promise<void>}
299
+ *
300
+ * @example
301
+ * ```typescript
302
+ * server.onMessage = async (message, sender) => {
303
+ * await server.onMessage(message, sender);
304
+ * console.log("Message processed from:", sender.id);
305
+ * };
306
+ * ```
307
+ */
308
+
114
309
  async onMessage(message: string, sender: Party.Connection) {
115
- const actions = this.subRoom.constructor['_actionMetadata'];
116
- const result = Message.safeParse(JSON.parse(message));
310
+ let json
311
+ try {
312
+ json = JSON.parse(message)
313
+ }
314
+ catch (e) {
315
+ return;
316
+ }
317
+ // Validate incoming messages
318
+ const result = Message.safeParse(json);
117
319
  if (!result.success) {
118
320
  return;
119
321
  }
322
+ const subRoom = await this.getSubRoom()
323
+ const actions = subRoom.constructor["_actionMetadata"];
120
324
  if (actions) {
121
- const signal = this.getUsersProperty();
325
+ const signal = this.getUsersProperty(subRoom);
122
326
  const { publicId } = sender.state as any;
123
327
  const user = signal?.()[publicId];
124
328
  const actionName = actions.get(result.data.action);
125
329
  if (actionName) {
330
+ // Validate action body if a validation schema is defined
126
331
  if (actionName.bodyValidation) {
127
332
  const bodyResult = actionName.bodyValidation.safeParse(
128
333
  result.data.value
@@ -131,19 +336,38 @@ export class Server implements Party.Server {
131
336
  return;
132
337
  }
133
338
  }
339
+ // Execute the action
134
340
  await awaitReturn(
135
- this.subRoom[actionName.key](user, result.data.value, sender)
341
+ subRoom[actionName.key](user, result.data.value, sender)
136
342
  );
137
343
  }
138
344
  }
139
345
  }
140
346
 
347
+ /**
348
+ * @method onClose
349
+ * @async
350
+ * @param {Party.Connection} conn - The connection object of the disconnecting user.
351
+ * @description Handles user disconnection, removing them from the room and triggering the onLeave event.
352
+ * @returns {Promise<void>}
353
+ *
354
+ * @example
355
+ * ```typescript
356
+ * server.onClose = async (conn) => {
357
+ * await server.onClose(conn);
358
+ * console.log("User disconnected:", conn.id);
359
+ * };
360
+ * ```
361
+ */
141
362
  async onClose(conn: Party.Connection) {
142
- const signal = this.getUsersProperty();
363
+ const subRoom = await this.getSubRoom()
364
+ const signal = this.getUsersProperty(subRoom);
143
365
  const { publicId } = conn.state as any;
144
366
  const user = signal?.()[publicId];
145
- await awaitReturn(this.subRoom['onLeave']?.(user, conn));
367
+ // Call the room's onLeave method if it exists
368
+ await awaitReturn(subRoom["onLeave"]?.(user, conn));
146
369
  if (signal) {
370
+ // Remove the user from the room
147
371
  delete signal()[publicId];
148
372
  }
149
373
  }
package/src/utils.ts CHANGED
@@ -1,14 +1,45 @@
1
1
  import { dset } from "dset";
2
2
 
3
- export function isPromise(value: any): boolean {
4
- return value && value instanceof Promise;
3
+ /**
4
+ * Checks if a value is a Promise.
5
+ *
6
+ * @param {unknown} value - The value to check.
7
+ * @returns {boolean} - Returns true if the value is a Promise, otherwise false.
8
+ *
9
+ * @example
10
+ * isPromise(Promise.resolve()); // true
11
+ * isPromise(42); // false
12
+ */
13
+ export function isPromise(value: unknown): value is Promise<any> {
14
+ return value instanceof Promise;
5
15
  }
6
16
 
7
- export async function awaitReturn(val: any) {
17
+ /**
18
+ * Awaits the given value if it is a Promise, otherwise returns the value directly.
19
+ *
20
+ * @param {unknown} val - The value to await or return.
21
+ * @returns {Promise<any>} - Returns a Promise that resolves to the value.
22
+ *
23
+ * @example
24
+ * awaitReturn(Promise.resolve(42)); // 42
25
+ * awaitReturn(42); // 42
26
+ */
27
+ export async function awaitReturn(val: unknown): Promise<any> {
8
28
  return isPromise(val) ? await val : val;
9
29
  }
10
30
 
11
- export function isClass(obj: any): boolean {
31
+ /**
32
+ * Checks if a value is a class.
33
+ *
34
+ * @param {unknown} obj - The value to check.
35
+ * @returns {boolean} - Returns true if the value is a class, otherwise false.
36
+ *
37
+ * @example
38
+ * class MyClass {}
39
+ * isClass(MyClass); // true
40
+ * isClass(() => {}); // false
41
+ */
42
+ export function isClass(obj: unknown): boolean {
12
43
  return (
13
44
  typeof obj === "function" &&
14
45
  obj.prototype &&
@@ -16,6 +47,24 @@ export function isClass(obj: any): boolean {
16
47
  );
17
48
  }
18
49
 
50
+
51
+ /**
52
+ * Creates a throttled function that only invokes the provided function at most once per every wait milliseconds.
53
+ *
54
+ * The throttled function comes with a cancel method to cancel delayed invocations.
55
+ * If the throttled function is invoked more than once during the wait timeout,
56
+ * it will call the provided function with the latest arguments.
57
+ *
58
+ * @template F - The type of the function to throttle.
59
+ * @param {F} func - The function to throttle.
60
+ * @param {number} wait - The number of milliseconds to throttle invocations to.
61
+ * @returns {(...args: Parameters<F>) => void} - Returns the new throttled function.
62
+ *
63
+ * @example
64
+ * const log = throttle((message) => console.log(message), 1000);
65
+ * log("Hello"); // Will log "Hello" immediately
66
+ * log("World"); // Will log "World" after 1 second, if no other calls to log() are made within the 1 second.
67
+ */
19
68
  export function throttle<F extends (...args: any[]) => any>(
20
69
  func: F,
21
70
  wait: number
@@ -39,34 +88,92 @@ export function throttle<F extends (...args: any[]) => any>(
39
88
  };
40
89
  }
41
90
 
91
+ /**
92
+ * Extracts parameters from a given string based on a specified pattern.
93
+ *
94
+ * The pattern can include placeholders in the form of {paramName}, which will be
95
+ * extracted from the input string if they match.
96
+ *
97
+ * @param {string} pattern - The pattern containing placeholders.
98
+ * @param {string} str - The string to extract parameters from.
99
+ * @returns {{ [key: string]: string } | null} - An object containing the extracted parameters,
100
+ * or null if the string does not match the pattern.
101
+ *
102
+ * @example
103
+ * // returns { id: '123' }
104
+ * extractParams('game-{id}', 'game-123');
105
+ *
106
+ * @example
107
+ * // returns { foo: 'abc', bar: 'xyz' }
108
+ * extractParams('test-{foo}-{bar}', 'test-abc-xyz');
109
+ *
110
+ */
42
111
  export function extractParams(
43
112
  pattern: string,
44
113
  str: string
45
114
  ): { [key: string]: string } | null {
115
+ // Replace placeholders in the pattern with named capture groups
46
116
  const regexPattern = pattern.replace(/{(\w+)}/g, "(?<$1>[\\w-]+)");
47
117
 
118
+ // Create a strict regular expression from the pattern
48
119
  const regex = new RegExp(`^${regexPattern}$`);
49
120
  const match = regex.exec(str);
50
121
 
122
+ // If a match is found and groups are present, return the captured groups
51
123
  if (match && match.groups) {
52
124
  return match.groups;
125
+ } else if (pattern === str) {
126
+ // If the pattern exactly matches the string, return an empty object
127
+ return {};
53
128
  } else {
129
+ // Otherwise, return null
54
130
  return null;
55
131
  }
56
132
  }
57
133
 
58
- export function dremove(obj, keys) {
59
- keys.split && (keys = keys.split("."));
60
- var i = 0,
61
- l = keys.length,
62
- t = { ...obj },
63
- k;
134
+ /**
135
+ * Removes a property from an object based on a dot-separated key string or an array of keys.
136
+ *
137
+ * The function modifies the original object by deleting the specified property.
138
+ * It safely handles dangerous keys like __proto__, constructor, and prototype.
139
+ *
140
+ * @param {Record<string, any>} obj - The object from which to remove the property.
141
+ * @param {string | string[]} keys - The key(s) specifying the property to remove. Can be a dot-separated string or an array of strings.
142
+ *
143
+ * @example
144
+ * const obj = { a: { b: { c: 3 } } };
145
+ * dremove(obj, 'a.b.c');
146
+ * // obj is now { a: { b: {} } }
147
+ *
148
+ * @example
149
+ * const obj = { a: 1, b: 2 };
150
+ * dremove(obj, 'a');
151
+ * // obj is now { b: 2 }
152
+ *
153
+ * @example
154
+ * const obj = { a: { b: { c: 3 } } };
155
+ * dremove(obj, ['a', 'b', 'c']);
156
+ * // obj is now { a: { b: {} } }
157
+ */
158
+ export function dremove(
159
+ obj: Record<string, any>,
160
+ keys: string | string[]
161
+ ): void {
162
+ // If keys is a string, convert it to an array using the "." separator
163
+ if (typeof keys === "string") {
164
+ keys = keys.split(".");
165
+ }
166
+
167
+ let i = 0;
168
+ const l = keys.length;
169
+ let t = obj;
170
+ let k;
64
171
 
65
172
  while (i < l - 1) {
66
173
  k = keys[i++];
67
- if (k === "__proto__" || k === "constructor" || k === "prototype") return; // On évite les clés dangereuses
174
+ if (k === "__proto__" || k === "constructor" || k === "prototype") return; // Avoid dangerous keys
175
+ if (typeof t[k] !== "object" || t[k] === null) return; // If the object doesn't exist, stop
68
176
  t = t[k];
69
- if (typeof t !== "object" || t === null) return; // Si l'objet n'existe pas, on arrête
70
177
  }
71
178
 
72
179
  k = keys[i];
@@ -79,16 +186,35 @@ export function dremove(obj, keys) {
79
186
  }
80
187
  }
81
188
 
82
- export function buildObject(valuesMap, allMemory) {
83
- let memoryObj = {};
84
- for (let path of valuesMap.keys()) {
85
- const value = valuesMap.get(path);
86
- dset(memoryObj, path, value);
87
- if (path == "$delete") {
88
- dremove(allMemory, value);
89
- } else {
90
- dset(allMemory, path, value);
91
- }
189
+ /**
190
+ * Builds an object from a map of values and updates the provided memory object.
191
+ *
192
+ * For each key-value pair in the map, this function sets the value at the given path in the `memoryObj`.
193
+ * If the value is "$delete", it removes the corresponding path from `allMemory`.
194
+ *
195
+ * @param {Map<string, any>} valuesMap - A map where the keys are paths and the values are the values to set at those paths.
196
+ * @param {Record<string, any>} allMemory - The object to update based on the values in the map.
197
+ * @returns {Record<string, any>} - The built memory object with the applied values from the map.
198
+ *
199
+ * @example
200
+ * const valuesMap = new Map();
201
+ * valuesMap.set('a.b.c', 1);
202
+ * valuesMap.set('x.y.z', '$delete');
203
+ * const allMemory = { x: { y: { z: 2 } } };
204
+ * const result = buildObject(valuesMap, allMemory);
205
+ * // result is { a: { b: { c: 1 } }, x: { y: { z: '$delete' } } }
206
+ * // allMemory is { a: { b: { c: 1 } }, x: { y: {} } }
207
+ */
208
+ export function buildObject(valuesMap: Map<string, any>, allMemory: Record<string, any>): Record<string, any> {
209
+ let memoryObj = {};
210
+ for (let path of valuesMap.keys()) {
211
+ const value = valuesMap.get(path);
212
+ dset(memoryObj, path, value);
213
+ if (value === "$delete") {
214
+ dremove(allMemory, path);
215
+ } else {
216
+ dset(allMemory, path, value);
92
217
  }
93
- return memoryObj;
94
- }
218
+ }
219
+ return memoryObj;
220
+ }