lumisjs 1.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.
Files changed (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +274 -0
  3. package/SECURITY.md +160 -0
  4. package/index.js +90 -0
  5. package/package.json +68 -0
  6. package/src/cache/CacheManager.js +393 -0
  7. package/src/cache/MemoryAdapter.js +259 -0
  8. package/src/cache/MultiLevelCache.js +362 -0
  9. package/src/cache/RedisAdapter.js +329 -0
  10. package/src/cache/SQLiteAdapter.js +280 -0
  11. package/src/cache/index.js +15 -0
  12. package/src/client/Client.js +554 -0
  13. package/src/client/ShardingManager.js +274 -0
  14. package/src/config/ConfigValidator.js +172 -0
  15. package/src/config/index.js +7 -0
  16. package/src/dashboard/DashboardManager.js +362 -0
  17. package/src/datagen/DataGenerator.js +47 -0
  18. package/src/datagen/apis/GraphqlMocker.js +16 -0
  19. package/src/datagen/apis/OpenApiMocker.js +18 -0
  20. package/src/datagen/cli.js +130 -0
  21. package/src/datagen/formatters/csv.js +45 -0
  22. package/src/datagen/formatters/index.js +15 -0
  23. package/src/datagen/formatters/json.js +33 -0
  24. package/src/datagen/formatters/mongo.js +22 -0
  25. package/src/datagen/formatters/sql.js +32 -0
  26. package/src/datagen/index.js +17 -0
  27. package/src/datagen/plugin/PluginManager.js +43 -0
  28. package/src/datagen/plugins/example-plugin.js +21 -0
  29. package/src/datagen/schema/SchemaGenerator.js +172 -0
  30. package/src/datagen/schema/loadSchema.js +14 -0
  31. package/src/datagen/utils/seed.js +26 -0
  32. package/src/di/ServiceContainer.js +229 -0
  33. package/src/errors/ErrorCodes.js +110 -0
  34. package/src/errors/LumisError.js +85 -0
  35. package/src/game/EconomyManager.js +161 -0
  36. package/src/game/GameSessionManager.js +76 -0
  37. package/src/game/GuildManager.js +197 -0
  38. package/src/game/InventoryManager.js +140 -0
  39. package/src/game/LevelingSystem.js +194 -0
  40. package/src/game/MusicManager.js +587 -0
  41. package/src/health/HealthChecker.js +170 -0
  42. package/src/health/index.js +7 -0
  43. package/src/performance/PerformanceMonitor.js +244 -0
  44. package/src/performance/index.js +7 -0
  45. package/src/security/InputSanitizer.js +185 -0
  46. package/src/security/SecurityMiddleware.js +231 -0
  47. package/src/security/index.js +9 -0
  48. package/src/shutdown/GracefulShutdown.js +159 -0
  49. package/src/shutdown/index.js +7 -0
  50. package/src/utils/StructuredLogger.js +118 -0
@@ -0,0 +1,274 @@
1
+ 'use strict';
2
+
3
+ const { EventEmitter } = require('node:events');
4
+ const { fork } = require('node:child_process');
5
+ const path = require('node:path');
6
+ const Logger = require('../utils/Logger');
7
+ const Collection = require('../utils/Collection');
8
+
9
+ class ShardingManager extends EventEmitter {
10
+ /**
11
+ * @param {string} file - Path to the bot's main file
12
+ * @param {object} [options]
13
+ * @param {string} [options.token] - Bot token (used to fetch shard count)
14
+ * @param {number|'auto'} [options.totalShards='auto']
15
+ * @param {string[]} [options.execArgv]
16
+ * @param {number} [options.spawnDelay=5500] - ms delay between shard spawns
17
+ * @param {number} [options.respawnDelay=5000] - ms before crashed shard respawns
18
+ * @param {number} [options.maxRespawnAttempts=5]
19
+ */
20
+ constructor(file, options = {}) {
21
+ super();
22
+
23
+ if (!file || typeof file !== 'string') {
24
+ throw new TypeError('ShardingManager requires a valid file path.');
25
+ }
26
+
27
+ this.file = path.resolve(file);
28
+ this.token = options.token ? options.token.replace(/^Bot\s*/i, '') : null;
29
+ this.totalShards = options.totalShards ?? 'auto';
30
+ this.execArgv = options.execArgv ?? [];
31
+ this.autoFetchShards = options.autoFetchShards ?? true;
32
+ this.spawnDelay = options.spawnDelay ?? 5500;
33
+ this.respawnDelay = options.respawnDelay ?? 5000;
34
+ this.maxRespawnAttempts = options.maxRespawnAttempts ?? 5;
35
+
36
+ /** @type {Collection<number, import('node:child_process').ChildProcess>} */
37
+ this.shards = new Collection();
38
+
39
+ this.logger = new Logger({ prefix: 'ShardingManager', level: 'info' });
40
+
41
+ /** @type {Map<string, { resolve: Function, reject: Function, timer: NodeJS.Timeout }>} */
42
+ this._promises = new Map();
43
+ this._nonce = 0;
44
+
45
+ /** @type {Map<number, number>} */
46
+ this._respawnCounts = new Map();
47
+ }
48
+
49
+ /**
50
+ * Fetch the recommended shard count from Discord.
51
+ * @returns {Promise<number>}
52
+ */
53
+ async fetchRecommendedShards() {
54
+ if (!this.token) {
55
+ throw new Error('A token is required to fetch the recommended shard count.');
56
+ }
57
+
58
+ const { fetch } = require('undici');
59
+
60
+ try {
61
+ const controller = new AbortController();
62
+ const timeout = setTimeout(() => controller.abort(), 5000);
63
+
64
+ const res = await fetch('https://discord.com/api/v10/gateway/bot', {
65
+ headers: { Authorization: `Bot ${this.token}` },
66
+ signal: controller.signal,
67
+ });
68
+
69
+ clearTimeout(timeout);
70
+
71
+ if (!res.ok) {
72
+ throw new Error(`Failed to fetch gateway info: ${res.status} ${res.statusText}`);
73
+ }
74
+
75
+ const data = await res.json();
76
+
77
+ if (!data || typeof data.shards !== 'number' || data.shards < 1) {
78
+ throw new Error('Invalid shard data received from gateway.');
79
+ }
80
+
81
+ return data.shards;
82
+ } catch (err) {
83
+ // Provide actionable message while keeping original error available
84
+ throw new Error(`Could not determine recommended shard count: ${err.message}`);
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Spawn all shards sequentially.
90
+ * @returns {Promise<void>}
91
+ */
92
+ async spawn() {
93
+ if (this.totalShards === 'auto') {
94
+ if (!this.autoFetchShards) {
95
+ throw new Error(
96
+ "totalShards is set to 'auto' but autoFetchShards is disabled. Provide totalShards explicitly."
97
+ );
98
+ }
99
+
100
+ this.logger.info('Fetching recommended shard count from Discord...');
101
+ this.totalShards = await this.fetchRecommendedShards();
102
+ this.logger.info(`Recommended shard count: ${this.totalShards}`);
103
+ }
104
+
105
+ if (typeof this.totalShards !== 'number' || this.totalShards < 1) {
106
+ throw new RangeError('totalShards must be a positive integer.');
107
+ }
108
+
109
+ this.logger.info(`Spawning ${this.totalShards} shard(s)...`);
110
+
111
+ for (let i = 0; i < this.totalShards; i++) {
112
+ this._spawnShard(i);
113
+ if (i < this.totalShards - 1) {
114
+ // Discord requires a delay between shard connections to avoid rate limits
115
+ await new Promise((r) => setTimeout(r, this.spawnDelay));
116
+ }
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Spawn a single shard by ID.
122
+ * @param {number} id
123
+ */
124
+ _spawnShard(id) {
125
+ this.logger.info(`[Shard ${id}] Spawning...`);
126
+
127
+ const env = {
128
+ ...process.env,
129
+ SHARD_ID: String(id),
130
+ TOTAL_SHARDS: String(this.totalShards),
131
+ ...(this.token ? { DISCORD_TOKEN: this.token } : {}),
132
+ };
133
+
134
+ const child = fork(this.file, [], {
135
+ env,
136
+ execArgv: this.execArgv,
137
+ });
138
+
139
+ this.shards.set(id, child);
140
+
141
+ child.on('message', (message) => this._handleMessage(child, message, id));
142
+
143
+ child.on('exit', (code, signal) => {
144
+ this.shards.delete(id);
145
+
146
+ const attempts = this._respawnCounts.get(id) ?? 0;
147
+
148
+ if (attempts >= this.maxRespawnAttempts) {
149
+ this.logger.error(
150
+ `[Shard ${id}] Reached max respawn attempts (${this.maxRespawnAttempts}). Giving up.`
151
+ );
152
+ this.emit('shardDeath', id);
153
+ return;
154
+ }
155
+
156
+ this.logger.warn(
157
+ `[Shard ${id}] Exited (code=${code ?? 'null'}, signal=${signal ?? 'null'}). ` +
158
+ `Respawning in ${this.respawnDelay}ms (${attempts + 1}/${this.maxRespawnAttempts})...`
159
+ );
160
+
161
+ this._respawnCounts.set(id, attempts + 1);
162
+
163
+ setTimeout(() => {
164
+ this._spawnShard(id);
165
+ }, this.respawnDelay).unref();
166
+ });
167
+
168
+ child.on('error', (err) => {
169
+ this.logger.error(`[Shard ${id}] Process error: ${err.message}`);
170
+ this.emit('shardError', err, id);
171
+ });
172
+
173
+ // Reset respawn counter once it stays alive for >30s
174
+ const stableTimer = setTimeout(() => {
175
+ this._respawnCounts.delete(id);
176
+ }, 30_000).unref();
177
+
178
+ this.emit('shardCreate', child, id);
179
+ }
180
+
181
+ /**
182
+ * Broadcast a named action to all shards. Shards must register handlers
183
+ * via `client.registerBroadcastHandler(name, fn)` to respond to actions.
184
+ * @param {string} action
185
+ * @param {Array<any>} [args]
186
+ * @param {object} [options]
187
+ */
188
+ async broadcastAction(action, args = [], options = {}) {
189
+ if (typeof action !== 'string') throw new TypeError('action must be a string');
190
+
191
+ const timeout = options.timeout ?? 10_000;
192
+ const nonce = `${Date.now()}_${++this._nonce}`;
193
+ const promises = [];
194
+
195
+ for (const [id, shard] of this.shards) {
196
+ promises.push(
197
+ new Promise((resolve, reject) => {
198
+ const key = `${nonce}_${id}`;
199
+
200
+ const timer = setTimeout(() => {
201
+ this._promises.delete(key);
202
+ reject(new Error(`Shard ${id} action timed out after ${timeout}ms`));
203
+ }, timeout);
204
+
205
+ this._promises.set(key, { resolve, reject, timer });
206
+
207
+ shard.send({
208
+ _type: 'BROADCAST_ACTION',
209
+ _nonce: nonce,
210
+ action,
211
+ args,
212
+ });
213
+ })
214
+ );
215
+ }
216
+
217
+ return Promise.all(promises);
218
+ }
219
+
220
+ /**
221
+ * Send a message to a specific shard.
222
+ * @param {number} shardId
223
+ * @param {any} message
224
+ */
225
+ sendTo(shardId, message) {
226
+ const shard = this.shards.get(shardId);
227
+ if (!shard) throw new Error(`Shard ${shardId} is not running.`);
228
+ shard.send(message);
229
+ }
230
+
231
+ /** @private */
232
+ _handleMessage(shard, message, shardId) {
233
+ if (!message || typeof message !== 'object') return;
234
+
235
+ if (message._type === 'ACTION_RESULT') {
236
+ const key = `${message._nonce}_${message._shardId}`;
237
+ const entry = this._promises.get(key);
238
+ if (!entry) return;
239
+
240
+ clearTimeout(entry.timer);
241
+ this._promises.delete(key);
242
+
243
+ if (message._error) {
244
+ entry.reject(new Error(message._error));
245
+ } else {
246
+ entry.resolve(message._result);
247
+ }
248
+ } else {
249
+ this.emit('message', shard, message, shardId);
250
+ }
251
+ }
252
+
253
+ /**
254
+ * Kill all shards and clean up.
255
+ */
256
+ destroy() {
257
+ for (const [id, shard] of this.shards) {
258
+ this.logger.info(`[Shard ${id}] Killing...`);
259
+ shard.removeAllListeners();
260
+ shard.kill();
261
+ }
262
+ this.shards.clear();
263
+ this._respawnCounts.clear();
264
+
265
+ // Reject pending evals
266
+ for (const [key, entry] of this._promises) {
267
+ clearTimeout(entry.timer);
268
+ entry.reject(new Error('ShardingManager was destroyed.'));
269
+ }
270
+ this._promises.clear();
271
+ }
272
+ }
273
+
274
+ module.exports = ShardingManager;
@@ -0,0 +1,172 @@
1
+ 'use strict';
2
+
3
+ const Logger = require('../utils/Logger');
4
+
5
+ /**
6
+ * Configuration Validator
7
+ * Validates configuration objects against schemas.
8
+ */
9
+
10
+ class ConfigValidator {
11
+ constructor() {
12
+ this.logger = new Logger({ prefix: 'ConfigValidator', level: 'info' });
13
+ this.schemas = new Map();
14
+ }
15
+
16
+ /**
17
+ * Register a configuration schema.
18
+ * @param {string} name
19
+ * @param {object} schema - Schema definition
20
+ */
21
+ registerSchema(name, schema) {
22
+ this.schemas.set(name, schema);
23
+ this.logger.info(`Registered schema: ${name}`);
24
+ }
25
+
26
+ /**
27
+ * Validate configuration against a schema.
28
+ * @param {string} schemaName
29
+ * @param {object} config
30
+ * @returns {object} { valid: boolean, errors: string[] }
31
+ */
32
+ validate(schemaName, config) {
33
+ const schema = this.schemas.get(schemaName);
34
+ if (!schema) {
35
+ throw new Error(`Schema not found: ${schemaName}`);
36
+ }
37
+
38
+ const errors = [];
39
+ const warnings = [];
40
+
41
+ this._validateObject(config, schema, '', errors, warnings);
42
+
43
+ return {
44
+ valid: errors.length === 0,
45
+ errors,
46
+ warnings,
47
+ };
48
+ }
49
+
50
+ /**
51
+ * Validate an object against a schema.
52
+ * @private
53
+ */
54
+ _validateObject(obj, schema, path, errors, warnings) {
55
+ // Check required fields
56
+ if (schema.required) {
57
+ for (const field of schema.required) {
58
+ if (!(field in obj)) {
59
+ errors.push(`${path}${field}: Required field is missing`);
60
+ }
61
+ }
62
+ }
63
+
64
+ // Check each property
65
+ for (const [key, value] of Object.entries(obj)) {
66
+ const fieldSchema = schema.properties?.[key];
67
+ if (!fieldSchema) {
68
+ warnings.push(`${path}${key}: Unknown field`);
69
+ continue;
70
+ }
71
+
72
+ const fieldPath = path ? `${path}.${key}` : key;
73
+ this._validateValue(value, fieldSchema, fieldPath, errors, warnings);
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Validate a value against a field schema.
79
+ * @private
80
+ */
81
+ _validateValue(value, schema, path, errors, warnings) {
82
+ // Check type
83
+ if (schema.type && typeof value !== schema.type) {
84
+ errors.push(`${path}: Expected type '${schema.type}', got '${typeof value}'`);
85
+ return;
86
+ }
87
+
88
+ // Check enum
89
+ if (schema.enum && !schema.enum.includes(value)) {
90
+ errors.push(`${path}: Must be one of ${schema.enum.join(', ')}`);
91
+ return;
92
+ }
93
+
94
+ // Check range for numbers
95
+ if (typeof value === 'number') {
96
+ if (schema.min !== undefined && value < schema.min) {
97
+ errors.push(`${path}: Must be >= ${schema.min}`);
98
+ }
99
+ if (schema.max !== undefined && value > schema.max) {
100
+ errors.push(`${path}: Must be <= ${schema.max}`);
101
+ }
102
+ }
103
+
104
+ // Check length for strings
105
+ if (typeof value === 'string') {
106
+ if (schema.minLength && value.length < schema.minLength) {
107
+ errors.push(`${path}: Must be at least ${schema.minLength} characters`);
108
+ }
109
+ if (schema.maxLength && value.length > schema.maxLength) {
110
+ errors.push(`${path}: Must be at most ${schema.maxLength} characters`);
111
+ }
112
+ if (schema.pattern && !new RegExp(schema.pattern).test(value)) {
113
+ errors.push(`${path}: Does not match required pattern`);
114
+ }
115
+ }
116
+
117
+ // Check array
118
+ if (Array.isArray(value)) {
119
+ if (schema.minItems && value.length < schema.minItems) {
120
+ errors.push(`${path}: Must have at least ${schema.minItems} items`);
121
+ }
122
+ if (schema.maxItems && value.length > schema.maxItems) {
123
+ errors.push(`${path}: Must have at most ${schema.maxItems} items`);
124
+ }
125
+ if (schema.items) {
126
+ value.forEach((item, index) => {
127
+ this._validateValue(item, schema.items, `${path}[${index}]`, errors, warnings);
128
+ });
129
+ }
130
+ }
131
+
132
+ // Check nested object
133
+ if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
134
+ if (schema.properties) {
135
+ this._validateObject(value, schema, path, errors, warnings);
136
+ }
137
+ }
138
+
139
+ // Custom validator
140
+ if (schema.validator && typeof schema.validator === 'function') {
141
+ const result = schema.validator(value);
142
+ if (result !== true) {
143
+ errors.push(`${path}: ${result || 'Custom validation failed'}`);
144
+ }
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Get all registered schemas.
150
+ * @returns {Array}
151
+ */
152
+ getSchemas() {
153
+ return Array.from(this.schemas.keys());
154
+ }
155
+
156
+ /**
157
+ * Remove a schema.
158
+ * @param {string} name
159
+ */
160
+ removeSchema(name) {
161
+ this.schemas.delete(name);
162
+ }
163
+
164
+ /**
165
+ * Clear all schemas.
166
+ */
167
+ clear() {
168
+ this.schemas.clear();
169
+ }
170
+ }
171
+
172
+ module.exports = ConfigValidator;
@@ -0,0 +1,7 @@
1
+ 'use strict';
2
+
3
+ const ConfigValidator = require('./ConfigValidator');
4
+
5
+ module.exports = {
6
+ ConfigValidator,
7
+ };