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.
- package/LICENSE +21 -0
- package/README.md +274 -0
- package/SECURITY.md +160 -0
- package/index.js +90 -0
- package/package.json +68 -0
- package/src/cache/CacheManager.js +393 -0
- package/src/cache/MemoryAdapter.js +259 -0
- package/src/cache/MultiLevelCache.js +362 -0
- package/src/cache/RedisAdapter.js +329 -0
- package/src/cache/SQLiteAdapter.js +280 -0
- package/src/cache/index.js +15 -0
- package/src/client/Client.js +554 -0
- package/src/client/ShardingManager.js +274 -0
- package/src/config/ConfigValidator.js +172 -0
- package/src/config/index.js +7 -0
- package/src/dashboard/DashboardManager.js +362 -0
- package/src/datagen/DataGenerator.js +47 -0
- package/src/datagen/apis/GraphqlMocker.js +16 -0
- package/src/datagen/apis/OpenApiMocker.js +18 -0
- package/src/datagen/cli.js +130 -0
- package/src/datagen/formatters/csv.js +45 -0
- package/src/datagen/formatters/index.js +15 -0
- package/src/datagen/formatters/json.js +33 -0
- package/src/datagen/formatters/mongo.js +22 -0
- package/src/datagen/formatters/sql.js +32 -0
- package/src/datagen/index.js +17 -0
- package/src/datagen/plugin/PluginManager.js +43 -0
- package/src/datagen/plugins/example-plugin.js +21 -0
- package/src/datagen/schema/SchemaGenerator.js +172 -0
- package/src/datagen/schema/loadSchema.js +14 -0
- package/src/datagen/utils/seed.js +26 -0
- package/src/di/ServiceContainer.js +229 -0
- package/src/errors/ErrorCodes.js +110 -0
- package/src/errors/LumisError.js +85 -0
- package/src/game/EconomyManager.js +161 -0
- package/src/game/GameSessionManager.js +76 -0
- package/src/game/GuildManager.js +197 -0
- package/src/game/InventoryManager.js +140 -0
- package/src/game/LevelingSystem.js +194 -0
- package/src/game/MusicManager.js +587 -0
- package/src/health/HealthChecker.js +170 -0
- package/src/health/index.js +7 -0
- package/src/performance/PerformanceMonitor.js +244 -0
- package/src/performance/index.js +7 -0
- package/src/security/InputSanitizer.js +185 -0
- package/src/security/SecurityMiddleware.js +231 -0
- package/src/security/index.js +9 -0
- package/src/shutdown/GracefulShutdown.js +159 -0
- package/src/shutdown/index.js +7 -0
- package/src/utils/StructuredLogger.js +118 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { Readable } = require('stream');
|
|
4
|
+
|
|
5
|
+
function SqlFormatter(generator, { schema }) {
|
|
6
|
+
const stream = new Readable({ objectMode: true, read() {} });
|
|
7
|
+
const table = schema.table || 'data';
|
|
8
|
+
const columns = Object.keys(schema.properties || {});
|
|
9
|
+
|
|
10
|
+
stream._read = function () {
|
|
11
|
+
const { value, done } = generator.next ? generator.next() : { done: true };
|
|
12
|
+
if (done) {
|
|
13
|
+
this.push(null);
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const values = columns
|
|
18
|
+
.map((col) => {
|
|
19
|
+
const val = value[col];
|
|
20
|
+
if (val === null || val === undefined) return 'NULL';
|
|
21
|
+
if (typeof val === 'number' || typeof val === 'boolean') return val;
|
|
22
|
+
return `'${String(val).replace(/'/g, "''")}'`;
|
|
23
|
+
})
|
|
24
|
+
.join(', ');
|
|
25
|
+
|
|
26
|
+
this.push(`INSERT INTO ${table} (${columns.join(', ')}) VALUES (${values});\n`);
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
return stream;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
module.exports = { SqlFormatter };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { DataGenerator } = require('./DataGenerator');
|
|
4
|
+
const { loadSchema } = require('./schema/loadSchema');
|
|
5
|
+
const { formatters } = require('./formatters');
|
|
6
|
+
const { PluginManager } = require('./plugin/PluginManager');
|
|
7
|
+
const { mockFromOpenApi } = require('./apis/OpenApiMocker');
|
|
8
|
+
const { mockFromGraphqlSchema } = require('./apis/GraphqlMocker');
|
|
9
|
+
|
|
10
|
+
module.exports = {
|
|
11
|
+
DataGenerator,
|
|
12
|
+
loadSchema,
|
|
13
|
+
formatters,
|
|
14
|
+
PluginManager,
|
|
15
|
+
mockFromOpenApi,
|
|
16
|
+
mockFromGraphqlSchema,
|
|
17
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
class PluginManager {
|
|
7
|
+
constructor({ pluginDir, rootDir, locale }) {
|
|
8
|
+
this.rootDir = rootDir;
|
|
9
|
+
this.locale = locale;
|
|
10
|
+
this.plugins = [];
|
|
11
|
+
|
|
12
|
+
if (pluginDir) {
|
|
13
|
+
this.loadFromDirectory(pluginDir);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
loadFromDirectory(dir) {
|
|
18
|
+
const resolved = path.isAbsolute(dir) ? dir : path.join(this.rootDir, dir);
|
|
19
|
+
if (!fs.existsSync(resolved)) return;
|
|
20
|
+
const files = fs.readdirSync(resolved);
|
|
21
|
+
for (const file of files) {
|
|
22
|
+
if (!file.toLowerCase().endsWith('.js')) continue;
|
|
23
|
+
try {
|
|
24
|
+
const plugin = require(path.join(resolved, file));
|
|
25
|
+
if (plugin && typeof plugin.init === 'function') {
|
|
26
|
+
plugin.init({ locale: this.locale, register: this.register.bind(this) });
|
|
27
|
+
}
|
|
28
|
+
} catch (err) {
|
|
29
|
+
// ignore
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
register(plugin) {
|
|
35
|
+
this.plugins.push(plugin);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
getGenerator(name) {
|
|
39
|
+
return this.plugins.find((p) => p.name === name);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
module.exports = { PluginManager };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Example plugin that registers a custom generator
|
|
4
|
+
module.exports = {
|
|
5
|
+
init({ register }) {
|
|
6
|
+
register({
|
|
7
|
+
name: 'randomCountry',
|
|
8
|
+
generate({ rng, locale }) {
|
|
9
|
+
const list = [
|
|
10
|
+
'United States',
|
|
11
|
+
'Germany',
|
|
12
|
+
'France',
|
|
13
|
+
'Türkiye',
|
|
14
|
+
'Japan',
|
|
15
|
+
'Brazil',
|
|
16
|
+
];
|
|
17
|
+
return list[rng.integer({ min: 0, max: list.length - 1 })];
|
|
18
|
+
},
|
|
19
|
+
});
|
|
20
|
+
},
|
|
21
|
+
};
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { createSeededRandom } = require('../utils/seed');
|
|
4
|
+
const { faker } = require('@faker-js/faker');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Generate a value for a `string` schema node.
|
|
8
|
+
*/
|
|
9
|
+
const generateString = ({ schema, rng }) => {
|
|
10
|
+
if (schema.enum && Array.isArray(schema.enum) && schema.enum.length > 0) {
|
|
11
|
+
return rng.pick(schema.enum);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (schema.format) {
|
|
15
|
+
switch (schema.format) {
|
|
16
|
+
case 'uuid':
|
|
17
|
+
return faker.string.uuid();
|
|
18
|
+
case 'email':
|
|
19
|
+
return faker.internet.email();
|
|
20
|
+
case 'url':
|
|
21
|
+
return faker.internet.url();
|
|
22
|
+
case 'date':
|
|
23
|
+
return faker.date.past().toISOString().split('T')[0];
|
|
24
|
+
case 'date-time':
|
|
25
|
+
return faker.date.past().toISOString();
|
|
26
|
+
case 'uri':
|
|
27
|
+
return faker.internet.url();
|
|
28
|
+
case 'hostname':
|
|
29
|
+
return faker.internet.domainName();
|
|
30
|
+
case 'ipv4':
|
|
31
|
+
return faker.internet.ip();
|
|
32
|
+
case 'ipv6':
|
|
33
|
+
return faker.internet.ipv6();
|
|
34
|
+
default:
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (schema.pattern) {
|
|
40
|
+
// Return a placeholder string that matches the pattern description,
|
|
41
|
+
// since full regex generation requires an extra library.
|
|
42
|
+
// If a future version adds RandExp, it can slot in here.
|
|
43
|
+
return schema.pattern;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const min = schema.minLength ?? 3;
|
|
47
|
+
const max = schema.maxLength ?? Math.max(min, 20);
|
|
48
|
+
const length = rng.integer({ min, max });
|
|
49
|
+
return faker.string.alpha(length);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Generate a value for a `number` or `integer` schema node.
|
|
54
|
+
*/
|
|
55
|
+
const generateNumber = ({ schema, rng, integer = false }) => {
|
|
56
|
+
const min = typeof schema.minimum === 'number' ? schema.minimum : 0;
|
|
57
|
+
const max = typeof schema.maximum === 'number' ? schema.maximum : min + 100;
|
|
58
|
+
if (integer) return rng.integer({ min: Math.ceil(min), max: Math.floor(max) });
|
|
59
|
+
return rng.number({ min, max });
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const TYPE_GENERATORS = {
|
|
63
|
+
string: generateString,
|
|
64
|
+
number: (ctx) => generateNumber({ ...ctx, integer: false }),
|
|
65
|
+
integer: (ctx) => generateNumber({ ...ctx, integer: true }),
|
|
66
|
+
boolean: ({ rng }) => rng.boolean(),
|
|
67
|
+
|
|
68
|
+
object: ({ schema, rng, locale, pluginManager }) => {
|
|
69
|
+
const result = {};
|
|
70
|
+
const props = schema.properties || {};
|
|
71
|
+
const required = new Set(schema.required || []);
|
|
72
|
+
|
|
73
|
+
for (const [key, propSchema] of Object.entries(props)) {
|
|
74
|
+
// Skip optional fields ~30% of the time unless they're required
|
|
75
|
+
if (!required.has(key) && rng.number({ min: 0, max: 1 }) < 0.3) continue;
|
|
76
|
+
result[key] = generateBySchema({ schema: propSchema, rng, locale, pluginManager });
|
|
77
|
+
}
|
|
78
|
+
return result;
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
array: ({ schema, rng, locale, pluginManager }) => {
|
|
82
|
+
const minItems = schema.minItems ?? 1;
|
|
83
|
+
const maxItems = schema.maxItems ?? Math.max(minItems, 5);
|
|
84
|
+
const length = rng.integer({ min: minItems, max: maxItems });
|
|
85
|
+
const items = [];
|
|
86
|
+
for (let i = 0; i < length; i++) {
|
|
87
|
+
items.push(generateBySchema({ schema: schema.items || {}, rng, locale, pluginManager }));
|
|
88
|
+
}
|
|
89
|
+
return items;
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
null: () => null,
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Generate a value from any schema node.
|
|
97
|
+
* @param {object} ctx
|
|
98
|
+
* @param {object} ctx.schema
|
|
99
|
+
* @param {object} ctx.rng
|
|
100
|
+
* @param {string} ctx.locale
|
|
101
|
+
* @param {object} [ctx.pluginManager]
|
|
102
|
+
* @returns {any}
|
|
103
|
+
*/
|
|
104
|
+
function generateBySchema({ schema = {}, rng, locale, pluginManager }) {
|
|
105
|
+
if (schema === null || schema === undefined) return null;
|
|
106
|
+
if (schema.const !== undefined) return schema.const;
|
|
107
|
+
|
|
108
|
+
// oneOf / anyOf support
|
|
109
|
+
if (Array.isArray(schema.oneOf) && schema.oneOf.length > 0) {
|
|
110
|
+
return generateBySchema({ schema: rng.pick(schema.oneOf), rng, locale, pluginManager });
|
|
111
|
+
}
|
|
112
|
+
if (Array.isArray(schema.anyOf) && schema.anyOf.length > 0) {
|
|
113
|
+
return generateBySchema({ schema: rng.pick(schema.anyOf), rng, locale, pluginManager });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Plugin generators
|
|
117
|
+
if (schema.generator && pluginManager) {
|
|
118
|
+
const plugin = pluginManager.getGenerator?.(schema.generator);
|
|
119
|
+
if (plugin && typeof plugin.generate === 'function') {
|
|
120
|
+
return plugin.generate({ schema, rng, locale });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Nullable support
|
|
125
|
+
if (schema.nullable && rng.number({ min: 0, max: 1 }) < 0.15) return null;
|
|
126
|
+
|
|
127
|
+
// Infer type when not explicitly set
|
|
128
|
+
const type =
|
|
129
|
+
schema.type ||
|
|
130
|
+
(schema.properties ? 'object' : schema.items ? 'array' : 'string');
|
|
131
|
+
|
|
132
|
+
// Handle type arrays: e.g. { type: ['string', 'null'] }
|
|
133
|
+
const resolvedType = Array.isArray(type) ? rng.pick(type) : type;
|
|
134
|
+
|
|
135
|
+
const generator = TYPE_GENERATORS[resolvedType];
|
|
136
|
+
if (!generator) {
|
|
137
|
+
// Unknown type — return null rather than silently crashing
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return generator({ schema, rng, locale, pluginManager });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
class SchemaGenerator {
|
|
145
|
+
/**
|
|
146
|
+
* @param {object} options
|
|
147
|
+
* @param {object} options.schema - JSON Schema object
|
|
148
|
+
* @param {string|number} [options.seed] - Seed for reproducible output
|
|
149
|
+
* @param {string} [options.locale='en']
|
|
150
|
+
* @param {object} [options.pluginManager]
|
|
151
|
+
*/
|
|
152
|
+
constructor({ schema, seed, locale = 'en', pluginManager } = {}) {
|
|
153
|
+
if (!schema || typeof schema !== 'object') {
|
|
154
|
+
throw new TypeError('schema must be a non-null object');
|
|
155
|
+
}
|
|
156
|
+
this.schema = schema;
|
|
157
|
+
this.locale = locale;
|
|
158
|
+
this.rng = createSeededRandom(seed, { locale });
|
|
159
|
+
this.pluginManager = pluginManager;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
generate() {
|
|
163
|
+
return generateBySchema({
|
|
164
|
+
schema: this.schema,
|
|
165
|
+
rng: this.rng,
|
|
166
|
+
locale: this.locale,
|
|
167
|
+
pluginManager: this.pluginManager,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
module.exports = { SchemaGenerator, generateBySchema };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
|
|
5
|
+
function loadSchema(filePath) {
|
|
6
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
7
|
+
try {
|
|
8
|
+
return JSON.parse(raw);
|
|
9
|
+
} catch (err) {
|
|
10
|
+
throw new Error(`Unable to parse schema JSON: ${err.message}`);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
module.exports = { loadSchema };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const seedrandom = require('seedrandom');
|
|
4
|
+
|
|
5
|
+
function createSeededRandom(seed, { locale } = {}) {
|
|
6
|
+
const rng = seedrandom(String(seed || Date.now()));
|
|
7
|
+
|
|
8
|
+
return {
|
|
9
|
+
number({ min = 0, max = 1 }) {
|
|
10
|
+
return rng() * (max - min) + min;
|
|
11
|
+
},
|
|
12
|
+
integer({ min = 0, max = 100 }) {
|
|
13
|
+
return Math.floor(rng() * (max - min + 1)) + min;
|
|
14
|
+
},
|
|
15
|
+
boolean() {
|
|
16
|
+
return rng() >= 0.5;
|
|
17
|
+
},
|
|
18
|
+
pick(array) {
|
|
19
|
+
if (!Array.isArray(array) || array.length === 0) return null;
|
|
20
|
+
const idx = Math.floor(rng() * array.length);
|
|
21
|
+
return array[idx];
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
module.exports = { createSeededRandom };
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Dependency Injection Container.
|
|
5
|
+
* Register services and inject them into commands/plugins automatically.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* client.services.register('db', new DatabaseService());
|
|
9
|
+
* client.services.register('api', new ExternalAPI());
|
|
10
|
+
*
|
|
11
|
+
* // In a command:
|
|
12
|
+
* class MyCmd extends Command {
|
|
13
|
+
* inject = ['db', 'api'];
|
|
14
|
+
* async execute(interaction) {
|
|
15
|
+
* await this.db.query(...);
|
|
16
|
+
* }
|
|
17
|
+
* }
|
|
18
|
+
*/
|
|
19
|
+
class ServiceContainer {
|
|
20
|
+
constructor(client) {
|
|
21
|
+
this.client = client;
|
|
22
|
+
|
|
23
|
+
/** @type {Map<string, { instance: any, singleton: boolean, factory: Function|null, initialized: boolean }>} */
|
|
24
|
+
this._services = new Map();
|
|
25
|
+
|
|
26
|
+
/** @type {Map<string, Function[]>} Lifecycle hooks */
|
|
27
|
+
this._hooks = new Map();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Register a service or factory.
|
|
32
|
+
* @param {string} name - Unique service identifier
|
|
33
|
+
* @param {any|Function} instanceOrFactory - Service instance or factory (client) => instance
|
|
34
|
+
* @param {object} [options]
|
|
35
|
+
* @param {boolean} [options.singleton=true] - False creates a new instance per get()
|
|
36
|
+
* @returns {ServiceContainer} this (chainable)
|
|
37
|
+
*/
|
|
38
|
+
register(name, instanceOrFactory, options = {}) {
|
|
39
|
+
if (typeof name !== 'string' || !name.trim()) {
|
|
40
|
+
throw new TypeError('Service name must be a non-empty string.');
|
|
41
|
+
}
|
|
42
|
+
if (instanceOrFactory === undefined || instanceOrFactory === null) {
|
|
43
|
+
throw new TypeError(`Service "${name}" value cannot be null or undefined.`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const singleton = options.singleton !== false;
|
|
47
|
+
// Heuristic: arrow functions and regular functions with no .prototype.constructor
|
|
48
|
+
// pairing are treated as factories. Named classes / constructed objects are not.
|
|
49
|
+
const isFactory =
|
|
50
|
+
typeof instanceOrFactory === 'function' &&
|
|
51
|
+
!instanceOrFactory.prototype?.constructor?.name?.length;
|
|
52
|
+
|
|
53
|
+
this._services.set(name, {
|
|
54
|
+
instance: isFactory && singleton ? null : isFactory ? undefined : instanceOrFactory,
|
|
55
|
+
singleton,
|
|
56
|
+
factory: isFactory ? instanceOrFactory : null,
|
|
57
|
+
initialized: !isFactory,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
this.client.logger?.debug?.(`[ServiceContainer] Registered: ${name}`);
|
|
61
|
+
this._runHooks('register', name);
|
|
62
|
+
return this;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Register a service that is lazily constructed using a factory.
|
|
67
|
+
* More explicit alternative to passing a factory to register().
|
|
68
|
+
* @param {string} name
|
|
69
|
+
* @param {Function} factory - (client) => instance
|
|
70
|
+
* @param {object} [options]
|
|
71
|
+
* @returns {ServiceContainer}
|
|
72
|
+
*/
|
|
73
|
+
registerFactory(name, factory, options = {}) {
|
|
74
|
+
if (typeof factory !== 'function') {
|
|
75
|
+
throw new TypeError(`Factory for "${name}" must be a function.`);
|
|
76
|
+
}
|
|
77
|
+
return this.register(name, factory, { ...options, _forceFactory: true });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Get a service by name. Lazily initialises singleton factories.
|
|
82
|
+
* @param {string} name
|
|
83
|
+
* @returns {any}
|
|
84
|
+
*/
|
|
85
|
+
get(name) {
|
|
86
|
+
const entry = this._services.get(name);
|
|
87
|
+
if (!entry) {
|
|
88
|
+
throw new Error(
|
|
89
|
+
`Service "${name}" is not registered. ` +
|
|
90
|
+
`Available: [${[...this._services.keys()].join(', ')}]`
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (entry.factory) {
|
|
95
|
+
if (entry.singleton) {
|
|
96
|
+
if (!entry.initialized) {
|
|
97
|
+
entry.instance = entry.factory(this.client);
|
|
98
|
+
entry.initialized = true;
|
|
99
|
+
}
|
|
100
|
+
return entry.instance;
|
|
101
|
+
}
|
|
102
|
+
// Transient: new instance every call
|
|
103
|
+
return entry.factory(this.client);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return entry.instance;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Check if a service is registered.
|
|
111
|
+
* @param {string} name
|
|
112
|
+
* @returns {boolean}
|
|
113
|
+
*/
|
|
114
|
+
has(name) {
|
|
115
|
+
return this._services.has(name);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Unregister and destroy a service.
|
|
120
|
+
* @param {string} name
|
|
121
|
+
* @returns {boolean}
|
|
122
|
+
*/
|
|
123
|
+
unregister(name) {
|
|
124
|
+
const entry = this._services.get(name);
|
|
125
|
+
if (!entry) return false;
|
|
126
|
+
|
|
127
|
+
if (entry.instance && typeof entry.instance.destroy === 'function') {
|
|
128
|
+
try {
|
|
129
|
+
entry.instance.destroy();
|
|
130
|
+
} catch (err) {
|
|
131
|
+
this.client.logger?.warn?.(`[ServiceContainer] Error destroying "${name}": ${err.message}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
this._services.delete(name);
|
|
136
|
+
this._runHooks('unregister', name);
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Inject registered services into a target object's `inject` array as lazy getters.
|
|
142
|
+
* @param {object} target - Object with `inject: string[]` property
|
|
143
|
+
* @returns {object} target (mutated in-place)
|
|
144
|
+
*/
|
|
145
|
+
inject(target) {
|
|
146
|
+
if (!target || !Array.isArray(target.inject)) return target;
|
|
147
|
+
|
|
148
|
+
for (const name of target.inject) {
|
|
149
|
+
if (this.has(name)) {
|
|
150
|
+
Object.defineProperty(target, name, {
|
|
151
|
+
get: () => this.get(name),
|
|
152
|
+
configurable: true,
|
|
153
|
+
enumerable: false,
|
|
154
|
+
});
|
|
155
|
+
} else {
|
|
156
|
+
this.client.logger?.warn?.(
|
|
157
|
+
`[ServiceContainer] Cannot inject "${name}": not registered.`
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return target;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Subscribe to lifecycle events.
|
|
167
|
+
* @param {'register'|'unregister'} event
|
|
168
|
+
* @param {Function} fn - (name: string) => void
|
|
169
|
+
*/
|
|
170
|
+
on(event, fn) {
|
|
171
|
+
if (!['register', 'unregister'].includes(event)) {
|
|
172
|
+
throw new Error(`Unknown ServiceContainer event: "${event}"`);
|
|
173
|
+
}
|
|
174
|
+
if (!this._hooks.has(event)) this._hooks.set(event, []);
|
|
175
|
+
this._hooks.get(event).push(fn);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** @private */
|
|
179
|
+
_runHooks(event, name) {
|
|
180
|
+
const hooks = this._hooks.get(event);
|
|
181
|
+
if (!hooks) return;
|
|
182
|
+
for (const fn of hooks) {
|
|
183
|
+
try {
|
|
184
|
+
fn(name);
|
|
185
|
+
} catch (err) {
|
|
186
|
+
this.client.logger?.warn?.(`[ServiceContainer] Hook error on "${event}": ${err.message}`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* List metadata for all registered services.
|
|
193
|
+
* @returns {Array<{ name: string, singleton: boolean, initialized: boolean }>}
|
|
194
|
+
*/
|
|
195
|
+
list() {
|
|
196
|
+
return [...this._services.entries()].map(([name, entry]) => ({
|
|
197
|
+
name,
|
|
198
|
+
singleton: entry.singleton,
|
|
199
|
+
initialized: entry.initialized,
|
|
200
|
+
hasFactory: entry.factory !== null,
|
|
201
|
+
}));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Destroy all services and clear the container.
|
|
206
|
+
*/
|
|
207
|
+
async destroy() {
|
|
208
|
+
const destroyPromises = [];
|
|
209
|
+
|
|
210
|
+
for (const [name, entry] of this._services) {
|
|
211
|
+
if (entry.instance && typeof entry.instance.destroy === 'function') {
|
|
212
|
+
const result = entry.instance.destroy();
|
|
213
|
+
if (result && typeof result.then === 'function') {
|
|
214
|
+
destroyPromises.push(
|
|
215
|
+
result.catch((err) =>
|
|
216
|
+
this.client.logger?.warn?.(`[ServiceContainer] Error destroying "${name}": ${err.message}`)
|
|
217
|
+
)
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
await Promise.all(destroyPromises);
|
|
224
|
+
this._services.clear();
|
|
225
|
+
this._hooks.clear();
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
module.exports = ServiceContainer;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const ErrorCodes = {
|
|
4
|
+
// Generic
|
|
5
|
+
UNKNOWN_ERROR: 'UNKNOWN_ERROR',
|
|
6
|
+
INVALID_TOKEN: 'INVALID_TOKEN',
|
|
7
|
+
INVALID_OPTION: 'INVALID_OPTION',
|
|
8
|
+
MISSING_OPTION: 'MISSING_OPTION',
|
|
9
|
+
INVALID_ARGUMENT: 'INVALID_ARGUMENT',
|
|
10
|
+
|
|
11
|
+
// Client
|
|
12
|
+
CLIENT_NOT_READY: 'CLIENT_NOT_READY',
|
|
13
|
+
CLIENT_ALREADY_LOGGED_IN: 'CLIENT_ALREADY_LOGGED_IN',
|
|
14
|
+
CLIENT_DESTROYED: 'CLIENT_DESTROYED',
|
|
15
|
+
|
|
16
|
+
// REST / HTTP
|
|
17
|
+
API_ERROR: 'API_ERROR',
|
|
18
|
+
HTTP_ERROR: 'HTTP_ERROR',
|
|
19
|
+
RATE_LIMITED: 'RATE_LIMITED',
|
|
20
|
+
RATE_LIMIT_EXCEEDED: 'RATE_LIMIT_EXCEEDED',
|
|
21
|
+
|
|
22
|
+
// WebSocket
|
|
23
|
+
WS_CONNECTION_FAILED: 'WS_CONNECTION_FAILED',
|
|
24
|
+
WS_CLOSE_REQUESTED: 'WS_CLOSE_REQUESTED',
|
|
25
|
+
WS_NOT_OPEN: 'WS_NOT_OPEN',
|
|
26
|
+
WS_ALREADY_CONNECTED: 'WS_ALREADY_CONNECTED',
|
|
27
|
+
WS_AUTHENTICATION_FAILED: 'WS_AUTHENTICATION_FAILED',
|
|
28
|
+
WS_INVALID_SESSION: 'WS_INVALID_SESSION',
|
|
29
|
+
|
|
30
|
+
// Cache
|
|
31
|
+
CACHE_ADAPTER_ERROR: 'CACHE_ADAPTER_ERROR',
|
|
32
|
+
CACHE_ADAPTER_NOT_FOUND: 'CACHE_ADAPTER_NOT_FOUND',
|
|
33
|
+
|
|
34
|
+
// Plugins
|
|
35
|
+
PLUGIN_LOAD_ERROR: 'PLUGIN_LOAD_ERROR',
|
|
36
|
+
PLUGIN_NOT_FOUND: 'PLUGIN_NOT_FOUND',
|
|
37
|
+
PLUGIN_ALREADY_LOADED: 'PLUGIN_ALREADY_LOADED',
|
|
38
|
+
PLUGIN_DEPENDENCY_MISSING: 'PLUGIN_DEPENDENCY_MISSING',
|
|
39
|
+
PLUGIN_INVALID: 'PLUGIN_INVALID',
|
|
40
|
+
|
|
41
|
+
// Structures
|
|
42
|
+
INVALID_STRUCTURE: 'INVALID_STRUCTURE',
|
|
43
|
+
MISSING_PERMISSIONS: 'MISSING_PERMISSIONS',
|
|
44
|
+
INVALID_CHANNEL_TYPE: 'INVALID_CHANNEL_TYPE',
|
|
45
|
+
|
|
46
|
+
// i18n
|
|
47
|
+
LOCALE_NOT_FOUND: 'LOCALE_NOT_FOUND',
|
|
48
|
+
TRANSLATION_KEY_MISSING: 'TRANSLATION_KEY_MISSING',
|
|
49
|
+
|
|
50
|
+
// Interactions
|
|
51
|
+
INTERACTION_ALREADY_REPLIED: 'INTERACTION_ALREADY_REPLIED',
|
|
52
|
+
INTERACTION_CALLBACK_NOT_FOUND: 'INTERACTION_CALLBACK_NOT_FOUND',
|
|
53
|
+
INTERACTION_NOT_AUTOCOMPLETE: 'INTERACTION_NOT_AUTOCOMPLETE',
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const ErrorMessages = {
|
|
57
|
+
[ErrorCodes.UNKNOWN_ERROR]: 'An unknown error occurred.',
|
|
58
|
+
[ErrorCodes.INVALID_TOKEN]: 'An invalid bot token was provided.',
|
|
59
|
+
[ErrorCodes.INVALID_OPTION]: (option) => `Invalid option: ${option}`,
|
|
60
|
+
[ErrorCodes.MISSING_OPTION]: (option) => `Missing required option: ${option}`,
|
|
61
|
+
[ErrorCodes.INVALID_ARGUMENT]: (arg) => `Invalid argument: ${arg}`,
|
|
62
|
+
|
|
63
|
+
[ErrorCodes.CLIENT_NOT_READY]: 'The client is not ready yet.',
|
|
64
|
+
[ErrorCodes.CLIENT_ALREADY_LOGGED_IN]: 'The client is already logged in.',
|
|
65
|
+
[ErrorCodes.CLIENT_DESTROYED]: 'The client has been destroyed.',
|
|
66
|
+
|
|
67
|
+
[ErrorCodes.API_ERROR]: (code, message) => `Discord API error [${code}]: ${message}`,
|
|
68
|
+
[ErrorCodes.HTTP_ERROR]: (status) => `HTTP error: ${status}`,
|
|
69
|
+
[ErrorCodes.RATE_LIMITED]: (retryAfter) =>
|
|
70
|
+
`Rate limited. Retry after ${retryAfter}ms.`,
|
|
71
|
+
[ErrorCodes.RATE_LIMIT_EXCEEDED]: 'Maximum rate limit retry attempts reached.',
|
|
72
|
+
|
|
73
|
+
[ErrorCodes.WS_CONNECTION_FAILED]: 'WebSocket connection failed.',
|
|
74
|
+
[ErrorCodes.WS_CLOSE_REQUESTED]: 'WebSocket close was requested.',
|
|
75
|
+
[ErrorCodes.WS_NOT_OPEN]: 'WebSocket connection is not open.',
|
|
76
|
+
[ErrorCodes.WS_ALREADY_CONNECTED]: 'WebSocket is already connected.',
|
|
77
|
+
[ErrorCodes.WS_AUTHENTICATION_FAILED]: 'WebSocket authentication failed.',
|
|
78
|
+
[ErrorCodes.WS_INVALID_SESSION]: 'Invalid WebSocket session.',
|
|
79
|
+
|
|
80
|
+
[ErrorCodes.CACHE_ADAPTER_ERROR]: (msg) => `Cache adapter error: ${msg}`,
|
|
81
|
+
[ErrorCodes.CACHE_ADAPTER_NOT_FOUND]: (name) =>
|
|
82
|
+
`Cache adapter not found: "${name}". Valid options: memory, redis, sqlite`,
|
|
83
|
+
|
|
84
|
+
[ErrorCodes.PLUGIN_LOAD_ERROR]: (name, err) =>
|
|
85
|
+
`Failed to load plugin "${name}": ${err}`,
|
|
86
|
+
[ErrorCodes.PLUGIN_NOT_FOUND]: (name) => `Plugin not found: "${name}"`,
|
|
87
|
+
[ErrorCodes.PLUGIN_ALREADY_LOADED]: (name) =>
|
|
88
|
+
`Plugin "${name}" is already loaded.`,
|
|
89
|
+
[ErrorCodes.PLUGIN_DEPENDENCY_MISSING]: (name, dep) =>
|
|
90
|
+
`Plugin "${name}" is missing dependency: "${dep}"`,
|
|
91
|
+
[ErrorCodes.PLUGIN_INVALID]: (name) =>
|
|
92
|
+
`Invalid plugin "${name}". Must export a valid plugin object or class.`,
|
|
93
|
+
|
|
94
|
+
[ErrorCodes.INVALID_STRUCTURE]: (name) => `Invalid structure: ${name}`,
|
|
95
|
+
[ErrorCodes.MISSING_PERMISSIONS]: (perms) => `Missing permissions: ${perms}`,
|
|
96
|
+
[ErrorCodes.INVALID_CHANNEL_TYPE]: (type) => `Invalid channel type: ${type}`,
|
|
97
|
+
|
|
98
|
+
[ErrorCodes.LOCALE_NOT_FOUND]: (locale) => `Locale not found: "${locale}"`,
|
|
99
|
+
[ErrorCodes.TRANSLATION_KEY_MISSING]: (key) =>
|
|
100
|
+
`Translation key not found: "${key}"`,
|
|
101
|
+
|
|
102
|
+
[ErrorCodes.INTERACTION_ALREADY_REPLIED]:
|
|
103
|
+
'This interaction has already been replied to.',
|
|
104
|
+
[ErrorCodes.INTERACTION_CALLBACK_NOT_FOUND]:
|
|
105
|
+
'Interaction callback not found.',
|
|
106
|
+
[ErrorCodes.INTERACTION_NOT_AUTOCOMPLETE]:
|
|
107
|
+
'This interaction is not an autocomplete interaction.',
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
module.exports = { ErrorCodes, ErrorMessages };
|