@triophore/falconjs 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/FalconAuthPlugin.js +473 -0
- package/LICENSE +21 -0
- package/README.md +2 -0
- package/core/auth.js +200 -0
- package/core/cache/redis_cacher.js +7 -0
- package/core/check_collection.js +9 -0
- package/core/crypto/encrypt_decrypt.js +19 -0
- package/core/errors.js +48 -0
- package/core/logger/log4js.js +89 -0
- package/core/logo.js +3 -0
- package/core/mongo/generateModelfromJsonFile.js +128 -0
- package/core/mongo/mongoSchmeFromJson.js +90 -0
- package/core/parse_num.js +8 -0
- package/core/rannum.js +33 -0
- package/core/ranstring.js +33 -0
- package/core/recursive-require-call.js +121 -0
- package/core/uitls/mongoose_to_joi.js +72 -0
- package/core/uitls/return.js +7 -0
- package/falcon.js +1644 -0
- package/falconAuthPlugin.js +17 -0
- package/falconBaseService.js +532 -0
- package/falconBaseWorker.js +540 -0
- package/index.js +4 -0
- package/out/Falcon.html +777 -0
- package/out/falcon.js.html +525 -0
- package/out/fonts/OpenSans-Bold-webfont.eot +0 -0
- package/out/fonts/OpenSans-Bold-webfont.svg +1830 -0
- package/out/fonts/OpenSans-Bold-webfont.woff +0 -0
- package/out/fonts/OpenSans-BoldItalic-webfont.eot +0 -0
- package/out/fonts/OpenSans-BoldItalic-webfont.svg +1830 -0
- package/out/fonts/OpenSans-BoldItalic-webfont.woff +0 -0
- package/out/fonts/OpenSans-Italic-webfont.eot +0 -0
- package/out/fonts/OpenSans-Italic-webfont.svg +1830 -0
- package/out/fonts/OpenSans-Italic-webfont.woff +0 -0
- package/out/fonts/OpenSans-Light-webfont.eot +0 -0
- package/out/fonts/OpenSans-Light-webfont.svg +1831 -0
- package/out/fonts/OpenSans-Light-webfont.woff +0 -0
- package/out/fonts/OpenSans-LightItalic-webfont.eot +0 -0
- package/out/fonts/OpenSans-LightItalic-webfont.svg +1835 -0
- package/out/fonts/OpenSans-LightItalic-webfont.woff +0 -0
- package/out/fonts/OpenSans-Regular-webfont.eot +0 -0
- package/out/fonts/OpenSans-Regular-webfont.svg +1831 -0
- package/out/fonts/OpenSans-Regular-webfont.woff +0 -0
- package/out/index.html +65 -0
- package/out/scripts/linenumber.js +25 -0
- package/out/scripts/prettify/Apache-License-2.0.txt +202 -0
- package/out/scripts/prettify/lang-css.js +2 -0
- package/out/scripts/prettify/prettify.js +28 -0
- package/out/styles/jsdoc-default.css +358 -0
- package/out/styles/prettify-jsdoc.css +111 -0
- package/out/styles/prettify-tomorrow.css +132 -0
- package/package.json +106 -0
- package/settings.js +1 -0
package/falcon.js
ADDED
|
@@ -0,0 +1,1644 @@
|
|
|
1
|
+
require("dotenv").config({ quiet: true, debug: false });
|
|
2
|
+
const config = process.env;
|
|
3
|
+
const crypto = require("crypto");
|
|
4
|
+
const mongoose = require("mongoose");
|
|
5
|
+
const bcrypt = require("bcrypt");
|
|
6
|
+
const Hapi = require("@hapi/hapi");
|
|
7
|
+
const Blipp = require("blipp");
|
|
8
|
+
const Inert = require("@hapi/inert");
|
|
9
|
+
const path = require("path");
|
|
10
|
+
const fs = require("fs");
|
|
11
|
+
const fsp = require("fs/promises");
|
|
12
|
+
const Crumb = require("@hapi/crumb");
|
|
13
|
+
const JWT = require("jsonwebtoken");
|
|
14
|
+
const { v4: uuidv4 } = require("uuid");
|
|
15
|
+
const { Server } = require("socket.io");
|
|
16
|
+
const redis = require("redis");
|
|
17
|
+
const axios = require("axios");
|
|
18
|
+
const spawn = require("child_process").spawn;
|
|
19
|
+
const customParser = require("socket.io-msgpack-parser");
|
|
20
|
+
const encrypt = require("./core/crypto/encrypt_decrypt").encrypt;
|
|
21
|
+
const decrypt = require("./core/crypto/encrypt_decrypt").decrypt;
|
|
22
|
+
const mqtt = require("mqtt");
|
|
23
|
+
const Scooter = require("@hapi/scooter");
|
|
24
|
+
const { createServer } = require("node:http");
|
|
25
|
+
const logo = require("./core/logo").logo;
|
|
26
|
+
const Joi = require("joi");
|
|
27
|
+
const Boom = require("@hapi/boom");
|
|
28
|
+
const { ConfigurationError, ValidationError } = require("./core/errors");
|
|
29
|
+
const { RateLimiterMemory } = require("rate-limiter-flexible");
|
|
30
|
+
const mongooseToJoi = require("./core/uitls/mongoose_to_joi");
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Represents the Falcon Server Core.
|
|
34
|
+
* Handles initialization of Database, Redis, MQTT, Workers, and Hapi.js HTTP Server.
|
|
35
|
+
*
|
|
36
|
+
* @class
|
|
37
|
+
* @example
|
|
38
|
+
* const app = new Falcon(__dirname);
|
|
39
|
+
* app.setAuthStrategy(async (server, config) => {
|
|
40
|
+
* // Custom auth logic
|
|
41
|
+
* });
|
|
42
|
+
* await app.init();
|
|
43
|
+
*/
|
|
44
|
+
class Falcon {
|
|
45
|
+
// #models = {}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Creates an instance of the Falcon Server.
|
|
49
|
+
* @constructor
|
|
50
|
+
* @param {String} basePath - The absolute path to the application root (usually __dirname).
|
|
51
|
+
* @throws {Error} Will throw an error if the 'settings.js' file is missing in the basePath.
|
|
52
|
+
*/
|
|
53
|
+
constructor(basePath) {
|
|
54
|
+
// Validate input
|
|
55
|
+
if (!basePath || typeof basePath !== 'string') {
|
|
56
|
+
throw new ValidationError('basePath must be a valid string');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Check if settings file exists
|
|
60
|
+
const settingsPath = path.join(basePath, "settings.js");
|
|
61
|
+
if (!fs.existsSync(settingsPath)) {
|
|
62
|
+
throw new ConfigurationError(`Settings file not found at: ${settingsPath}`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
this.SETTINGS = require(settingsPath).settings;
|
|
66
|
+
this.db = null;
|
|
67
|
+
this.redisClient = null;
|
|
68
|
+
this.httpServer = null;
|
|
69
|
+
this.socketIoConfig = this.SETTINGS || null;
|
|
70
|
+
this.models = {};
|
|
71
|
+
this.CONTEXT = {};
|
|
72
|
+
this.CONTEXT["models"] = {};
|
|
73
|
+
this.CONTEXT["env"] = config;
|
|
74
|
+
this.CONTEXT["services"] = [];
|
|
75
|
+
this.CONTEXT["workers"] = {};
|
|
76
|
+
this.aedes = null;
|
|
77
|
+
this.mqttClient = null;
|
|
78
|
+
this.mqttServer = null;
|
|
79
|
+
this.mqttPort = null;
|
|
80
|
+
this.CONTEXT["mqtt"] = null;
|
|
81
|
+
this.CONTEXT["settings"] = this.SETTINGS;
|
|
82
|
+
|
|
83
|
+
if (this.SETTINGS.log) {
|
|
84
|
+
this.CONTEXT["logger"] = require("./core/logger/log4js").getLogger(
|
|
85
|
+
this.SETTINGS.log,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
this.config = config;
|
|
90
|
+
this.basePath = basePath;
|
|
91
|
+
this.CONTEXT["custom"] = {};
|
|
92
|
+
this.customAuthHandler = null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Adds a custom context to the Falcon instance that can be accessed throughout the application.
|
|
97
|
+
* The context is stored with special delimiters to avoid naming conflicts.
|
|
98
|
+
*
|
|
99
|
+
* @param {string} customContextName - The name identifier for the custom context
|
|
100
|
+
* @param {*} customContext - The context data/object to store (can be any type)
|
|
101
|
+
* @returns {Falcon} The current Falcon instance for method chaining
|
|
102
|
+
*
|
|
103
|
+
* @example
|
|
104
|
+
* const app = new Falcon(__dirname);
|
|
105
|
+
* app.addContext('database', { host: 'localhost', port: 5432 });
|
|
106
|
+
* app.addContext('cache', redisClient);
|
|
107
|
+
*/
|
|
108
|
+
addContext(customContextName, customContext) {
|
|
109
|
+
if (!customContextName || typeof customContextName !== 'string') {
|
|
110
|
+
throw new ValidationError('customContextName must be a non-empty string');
|
|
111
|
+
}
|
|
112
|
+
this.CONTEXT["custom"]["$$" + customContextName + "$$"] = customContext;
|
|
113
|
+
return this;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Retrieves a previously stored custom context by name.
|
|
118
|
+
* Note: This method has a bug - it doesn't return the context value.
|
|
119
|
+
*
|
|
120
|
+
* @param {string} custom_context_name - The name identifier of the context to retrieve
|
|
121
|
+
* @returns {Falcon} The current Falcon instance (should return the context value)
|
|
122
|
+
*
|
|
123
|
+
* @example
|
|
124
|
+
* const dbConfig = app.getContext('database');
|
|
125
|
+
*
|
|
126
|
+
* @todo Fix return statement to actually return the context value
|
|
127
|
+
*/
|
|
128
|
+
getContext(custom_context_name) {
|
|
129
|
+
return this.CONTEXT["custom"]["$$" + custom_context_name + "$$"];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Removes a custom context from the Falcon instance if it exists.
|
|
134
|
+
*
|
|
135
|
+
* @param {string} custom_context_name - The name identifier of the context to remove
|
|
136
|
+
* @returns {Falcon} The current Falcon instance for method chaining
|
|
137
|
+
*
|
|
138
|
+
* @example
|
|
139
|
+
* app.removeContext('database');
|
|
140
|
+
*/
|
|
141
|
+
removeContext(custom_context_name) {
|
|
142
|
+
if (
|
|
143
|
+
this.CONTEXT["custom"].hasOwnProperty("$$" + custom_context_name + "$$")
|
|
144
|
+
) {
|
|
145
|
+
delete this.CONTEXT["custom"]["$$" + custom_context_name + "$$"];
|
|
146
|
+
}
|
|
147
|
+
return this;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Validates environment variables using Joi schema.
|
|
152
|
+
* Ensures required environment variables are present and have valid values.
|
|
153
|
+
*
|
|
154
|
+
* @private
|
|
155
|
+
* @throws {Error} Throws error if environment validation fails
|
|
156
|
+
*/
|
|
157
|
+
async #validateEnv() {
|
|
158
|
+
const schema = Joi.object({
|
|
159
|
+
MODE: Joi.string().valid('DEV', 'PROD').default('DEV'),
|
|
160
|
+
MQTT_PORT: Joi.number().optional(),
|
|
161
|
+
MQTT_URL: Joi.string().optional(),
|
|
162
|
+
REDIS_ENABLE: Joi.string().valid('true', 'false').default('false'),
|
|
163
|
+
REDIS_URL: Joi.string().when('REDIS_ENABLE', { is: 'true', then: Joi.required() })
|
|
164
|
+
}).unknown();
|
|
165
|
+
|
|
166
|
+
const { error, value } = schema.validate(process.env);
|
|
167
|
+
if (error) {
|
|
168
|
+
throw new Error(`Config validation error: ${error.message}`);
|
|
169
|
+
}
|
|
170
|
+
this.config = value; // Update config with defaults
|
|
171
|
+
this.CONTEXT["env"] = this.config;
|
|
172
|
+
|
|
173
|
+
if (!process.env.MODE) {
|
|
174
|
+
this.CONTEXT["logger"]?.warn("MODE not set in env, defaulting to PROD");
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
#isDev() {
|
|
179
|
+
return process.env.MODE == "DEV" ? true : false;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async #mqtt_aedes() {
|
|
183
|
+
this.aedes = require("aedes")();
|
|
184
|
+
this.mqtt_client = false;
|
|
185
|
+
this.mqtt_server = require("net").createServer(this.aedes.handle);
|
|
186
|
+
this.mqtt_port = process.env.MQTT_PORT;
|
|
187
|
+
this.mqtt_server.listen(this.mqtt_port, function () {
|
|
188
|
+
this.CONTEXT["logger"].info(
|
|
189
|
+
"MQTT server started and listening on port ",
|
|
190
|
+
this.mqtt_port,
|
|
191
|
+
);
|
|
192
|
+
});
|
|
193
|
+
this.CONTEXT["mqtt"] = this.mqtt_server;
|
|
194
|
+
}
|
|
195
|
+
async #mqtt_external() {
|
|
196
|
+
this.CONTEXT["mqtt"] = null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async #mqtt() {
|
|
200
|
+
if (this.SETTINGS.mqtt) {
|
|
201
|
+
if (this.SETTINGS.mqtt.internal) {
|
|
202
|
+
await this.#mqtt_aedes();
|
|
203
|
+
} else {
|
|
204
|
+
if (this.SETTINGS.mqtt.external) {
|
|
205
|
+
await this.#mqtt_external();
|
|
206
|
+
} else {
|
|
207
|
+
await this.#mqtt_aedes();
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
} else {
|
|
211
|
+
await this.#mqtt_aedes();
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async #Prep() {
|
|
216
|
+
this.CONTEXT["logger"].info("");
|
|
217
|
+
this.CONTEXT["logger"].info("");
|
|
218
|
+
this.CONTEXT["logger"].info(
|
|
219
|
+
"......................................................................",
|
|
220
|
+
);
|
|
221
|
+
this.CONTEXT["logger"].info("TRIOPHORE SERVER");
|
|
222
|
+
this.CONTEXT["logger"].info("Developed and maintained by");
|
|
223
|
+
this.CONTEXT["logger"].info(logo);
|
|
224
|
+
this.CONTEXT["logger"].info("Triophore");
|
|
225
|
+
this.CONTEXT["logger"].info("visit us on : https://triophore.com");
|
|
226
|
+
this.CONTEXT["logger"].info(
|
|
227
|
+
"......................................................................",
|
|
228
|
+
);
|
|
229
|
+
this.CONTEXT["logger"].info(
|
|
230
|
+
"......................................................................",
|
|
231
|
+
);
|
|
232
|
+
this.CONTEXT["logger"].info("Starting server");
|
|
233
|
+
this.CONTEXT["logger"].info("Date :: " + Date.now());
|
|
234
|
+
this.CONTEXT["logger"].info(
|
|
235
|
+
"......................................................................",
|
|
236
|
+
);
|
|
237
|
+
this.CONTEXT["logger"].info("");
|
|
238
|
+
this.CONTEXT["logger"].info(
|
|
239
|
+
"......................................................................",
|
|
240
|
+
);
|
|
241
|
+
this.CONTEXT["logger"].info("Starting bind for services");
|
|
242
|
+
this.CONTEXT["logger"].info("Date :: " + Date.now());
|
|
243
|
+
this.CONTEXT["logger"].info("Binding complete");
|
|
244
|
+
this.CONTEXT["logger"].info(
|
|
245
|
+
"......................................................................",
|
|
246
|
+
);
|
|
247
|
+
this.CONTEXT["logger"].info("");
|
|
248
|
+
this.CONTEXT["logger"].info("MQTT Service details");
|
|
249
|
+
this.CONTEXT["logger"].info("launching worker process....");
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Initializes the Falcon server with all configured components.
|
|
254
|
+
* This method sets up graceful shutdown handlers, database connections, Redis,
|
|
255
|
+
* models, HTTP server, MQTT client, Socket.IO, and runs post-initialization hooks.
|
|
256
|
+
*
|
|
257
|
+
* @async
|
|
258
|
+
* @returns {Promise<Falcon>} The current Falcon instance after successful initialization
|
|
259
|
+
* @throws {Error} Throws error if any initialization step fails
|
|
260
|
+
*
|
|
261
|
+
* @example
|
|
262
|
+
* const app = new Falcon(__dirname);
|
|
263
|
+
* await app.init();
|
|
264
|
+
* await app.runServer();
|
|
265
|
+
*/
|
|
266
|
+
async init() {
|
|
267
|
+
await this.#validateEnv();
|
|
268
|
+
this.setupGracefulShutdown();
|
|
269
|
+
await this.#Prep();
|
|
270
|
+
await this.#startService();
|
|
271
|
+
await this.#startServiceDB();
|
|
272
|
+
await this.#startRedis();
|
|
273
|
+
// this.CONTEXT["return"] = require("./core/uitls/return").return_obj;
|
|
274
|
+
await this.#loadModels();
|
|
275
|
+
await this.#startHttpServer();
|
|
276
|
+
await this.#loadRoutes();
|
|
277
|
+
await this.#startMqttClient();
|
|
278
|
+
await this.#startSocketIO();
|
|
279
|
+
await this.#generateSwaggerSchemas(); // Generate schemas before routes
|
|
280
|
+
await this.#startCrudRoutes(); // Added CRUD generation
|
|
281
|
+
|
|
282
|
+
await this.#Postinit();
|
|
283
|
+
|
|
284
|
+
// Final immutable context
|
|
285
|
+
const deepFreeze = (obj) => {
|
|
286
|
+
if (typeof obj !== "object" || obj === null) return obj;
|
|
287
|
+
Object.freeze(obj);
|
|
288
|
+
Object.keys(obj).forEach(key => {
|
|
289
|
+
const value = obj[key];
|
|
290
|
+
if (typeof value === "object" && value !== null && !Object.isFrozen(value)) {
|
|
291
|
+
deepFreeze(value);
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
return obj;
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
// Deep freeze the most critical parts
|
|
298
|
+
const immutableModels = deepFreeze({ ...this.CONTEXT.models });
|
|
299
|
+
const immutableValidators = deepFreeze({ ...this.CONTEXT.validators });
|
|
300
|
+
|
|
301
|
+
// Create final frozen context
|
|
302
|
+
this.CONTEXT = Object.freeze({
|
|
303
|
+
...this.CONTEXT,
|
|
304
|
+
models: immutableModels,
|
|
305
|
+
validators: immutableValidators,
|
|
306
|
+
// Allow custom to remain mutable (for flexibility)
|
|
307
|
+
custom: this.CONTEXT.custom || {},
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
this.CONTEXT.logger.info("Falcon ready — CONTEXT is now immutable and protected!");
|
|
311
|
+
return this;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async #startService() {
|
|
315
|
+
if (this.SETTINGS.hasOwnProperty("services")) {
|
|
316
|
+
if (this.SETTINGS.services.length > 0) {
|
|
317
|
+
this.CONTEXT["logger"].info(
|
|
318
|
+
"-----------------Spawning Services----------------",
|
|
319
|
+
);
|
|
320
|
+
this.CONTEXT["logger"].info(
|
|
321
|
+
"......................................................................",
|
|
322
|
+
);
|
|
323
|
+
this._spawnServices(this.SETTINGS.services);
|
|
324
|
+
this.CONTEXT["logger"].info(
|
|
325
|
+
"......................................................................",
|
|
326
|
+
);
|
|
327
|
+
this.CONTEXT["logger"].info(
|
|
328
|
+
"-----------------Spawning Services----------------",
|
|
329
|
+
);
|
|
330
|
+
} else {
|
|
331
|
+
this.CONTEXT["logger"].info(
|
|
332
|
+
"-----------------Spawning Services----------------",
|
|
333
|
+
);
|
|
334
|
+
this.CONTEXT["logger"].info(
|
|
335
|
+
"......................................................................",
|
|
336
|
+
);
|
|
337
|
+
this.CONTEXT["logger"].info("No services registered..");
|
|
338
|
+
this.CONTEXT["logger"].info(
|
|
339
|
+
"......................................................................",
|
|
340
|
+
);
|
|
341
|
+
this.CONTEXT["logger"].info(
|
|
342
|
+
"-----------------Spawning Services----------------",
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async #Postinit() {
|
|
349
|
+
if (this.SETTINGS.postInit) {
|
|
350
|
+
let post_file_path = path.join(
|
|
351
|
+
this.basePath,
|
|
352
|
+
"init",
|
|
353
|
+
this.SETTINGS.postInit + ".js",
|
|
354
|
+
);
|
|
355
|
+
if (fs.existsSync(post_file_path)) {
|
|
356
|
+
await require(post_file_path).run(this.CONTEXT);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
async #startServiceDB() {
|
|
362
|
+
if (this.SETTINGS.database) {
|
|
363
|
+
if (this.SETTINGS.database.mongodb) {
|
|
364
|
+
if (this.#isDev()) {
|
|
365
|
+
mongoose.set("debug", true);
|
|
366
|
+
this.CONTEXT["logger"].debug("Mongoose ODM is set in Debug Mode");
|
|
367
|
+
this.CONTEXT["logger"].info("Mongoose Log will be collected");
|
|
368
|
+
}
|
|
369
|
+
await mongoose.connect(this.SETTINGS.database.mongodb.database, {}); //useNewUrlParser: true, useUnifiedTopology: true
|
|
370
|
+
mongoose.set("debug", (collectionName, method, query, doc) => {
|
|
371
|
+
this.CONTEXT["logger"].info(
|
|
372
|
+
`MONGOOSE ==> ${collectionName}.${method}`,
|
|
373
|
+
JSON.stringify(query),
|
|
374
|
+
doc,
|
|
375
|
+
);
|
|
376
|
+
});
|
|
377
|
+
this.db = mongoose.connection.db;
|
|
378
|
+
this.CONTEXT["logger"].info("MongoDB connected");
|
|
379
|
+
this.CONTEXT["db"] = this.db;
|
|
380
|
+
this.CONTEXT["mongoose"] = mongoose;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Registers a plugin with the Falcon server. Plugins can extend server functionality
|
|
387
|
+
* for different components like server middleware, Socket.IO handlers, or Mongoose plugins.
|
|
388
|
+
*
|
|
389
|
+
* @async
|
|
390
|
+
* @param {Object} pluginObj - The plugin configuration object
|
|
391
|
+
* @param {string} pluginObj.type - Plugin type: 'server', 'socketio', 'mongoose', or 'falcon'
|
|
392
|
+
* @param {*} pluginObj.plugin - The actual plugin implementation
|
|
393
|
+
* @returns {Promise<Falcon>} The current Falcon instance for method chaining
|
|
394
|
+
* @throws {Error} Throws error if plugin type is invalid
|
|
395
|
+
*
|
|
396
|
+
* @example
|
|
397
|
+
* await app.AddPlugin({
|
|
398
|
+
* type: 'server',
|
|
399
|
+
* plugin: myHapiPlugin
|
|
400
|
+
* });
|
|
401
|
+
*/
|
|
402
|
+
async AddPlugin(pluginObj) {
|
|
403
|
+
this.CONTEXT["logger"].info("Plugin added");
|
|
404
|
+
await this.#regsiterPlugin(pluginObj);
|
|
405
|
+
return this;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
async #regsiterPlugin(pluginObj) {
|
|
409
|
+
switch (pluginObj.type) {
|
|
410
|
+
case "server":
|
|
411
|
+
// this.middleware.push(pluginObj);
|
|
412
|
+
break;
|
|
413
|
+
case "socketio":
|
|
414
|
+
// this.controllers.push(pluginObj);
|
|
415
|
+
break;
|
|
416
|
+
case "mongoose":
|
|
417
|
+
break;
|
|
418
|
+
case "falcon":
|
|
419
|
+
break;
|
|
420
|
+
default:
|
|
421
|
+
throw new Error(`Invalid plugin type: ${pluginObj.type}`);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Asynchronously checks if a given path exists and is a directory.
|
|
427
|
+
* This utility method provides safe directory existence checking with proper error handling.
|
|
428
|
+
*
|
|
429
|
+
* @async
|
|
430
|
+
* @param {string} directoryPath - The file system path to check
|
|
431
|
+
* @returns {Promise<boolean>} True if path exists and is a directory, false if path doesn't exist
|
|
432
|
+
* @throws {Error} Throws error for permission issues or other file system errors (not ENOENT)
|
|
433
|
+
*
|
|
434
|
+
* @example
|
|
435
|
+
* const exists = await app.checkDirectoryAsync('./models');
|
|
436
|
+
* if (exists) {
|
|
437
|
+
* console.log('Models directory found');
|
|
438
|
+
* }
|
|
439
|
+
*
|
|
440
|
+
* @example
|
|
441
|
+
* try {
|
|
442
|
+
* const isDir = await app.checkDirectoryAsync('/restricted/path');
|
|
443
|
+
* } catch (error) {
|
|
444
|
+
* console.error('Permission denied or other error:', error.message);
|
|
445
|
+
* }
|
|
446
|
+
*/
|
|
447
|
+
async checkDirectoryAsync(directoryPath) {
|
|
448
|
+
try {
|
|
449
|
+
// Use fs.promises.stat() to get file statistics
|
|
450
|
+
const stats = await fs.promises.stat(directoryPath);
|
|
451
|
+
// Check if the path exists AND if it's a directory
|
|
452
|
+
return stats.isDirectory();
|
|
453
|
+
} catch (error) {
|
|
454
|
+
// Check if the error is "No such file or directory" (ENOENT)
|
|
455
|
+
if (error.code === "ENOENT") {
|
|
456
|
+
return false;
|
|
457
|
+
}
|
|
458
|
+
// For other errors (permissions, etc.), re-throw the error
|
|
459
|
+
throw error;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Recursively finds all JavaScript files in a directory.
|
|
465
|
+
* Skips hidden directories and test files for efficiency.
|
|
466
|
+
*
|
|
467
|
+
* @private
|
|
468
|
+
* @param {string} dir - Directory to search
|
|
469
|
+
* @param {string[]} files - Array to collect file paths (used for recursion)
|
|
470
|
+
* @returns {Promise<string[]>} Array of JavaScript file paths
|
|
471
|
+
*/
|
|
472
|
+
async #findJsFilesRecursively(dir, files = []) {
|
|
473
|
+
const items = await fs.promises.readdir(dir, { withFileTypes: true });
|
|
474
|
+
|
|
475
|
+
for (const item of items) {
|
|
476
|
+
const fullPath = path.join(dir, item.name);
|
|
477
|
+
|
|
478
|
+
if (item.isDirectory() && !item.name.startsWith('.')) {
|
|
479
|
+
await this.#findJsFilesRecursively(fullPath, files);
|
|
480
|
+
}
|
|
481
|
+
else if (item.isFile() && item.name.endsWith('.js') && !item.name.endsWith('.test.js')) {
|
|
482
|
+
files.push(fullPath);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
return files;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Builds a clean route path from file system path.
|
|
491
|
+
* Converts folder structure to REST-style paths with parameter support.
|
|
492
|
+
*
|
|
493
|
+
* @private
|
|
494
|
+
* @param {string} relativePath - Relative path from routes directory
|
|
495
|
+
* @returns {string} Clean route path
|
|
496
|
+
*/
|
|
497
|
+
#buildRoutePath(relativePath) {
|
|
498
|
+
let routePath = '/' + path.dirname(relativePath)
|
|
499
|
+
.replace(/\\/g, '/')
|
|
500
|
+
.replace(/\/index$/gi, '')
|
|
501
|
+
.replace(/\/_/g, '/:')
|
|
502
|
+
.replace(/_/g, '-')
|
|
503
|
+
.replace(/(^|\/)\[([^\]]+)\]/g, '$1:$2');
|
|
504
|
+
|
|
505
|
+
const baseName = path.basename(relativePath, '.js');
|
|
506
|
+
if (baseName !== 'index') {
|
|
507
|
+
let segment = baseName
|
|
508
|
+
.replace(/^_/, ':')
|
|
509
|
+
.replace(/_/g, '-')
|
|
510
|
+
.replace(/^\[([^\]]+)\]$/, ':$1');
|
|
511
|
+
routePath = path.posix.join(routePath, segment);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
return routePath.replace(/\/+$/, '') || '/';
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Finds the best matching validator for a route.
|
|
519
|
+
*
|
|
520
|
+
* @private
|
|
521
|
+
* @param {string} baseName - Base filename without extension
|
|
522
|
+
* @param {string} relativePath - Relative path from routes directory
|
|
523
|
+
* @param {Object} validators - Available validators
|
|
524
|
+
* @returns {string|null} Validator name or null if not found
|
|
525
|
+
*/
|
|
526
|
+
#findBestValidatorName(baseName, relativePath, validators) {
|
|
527
|
+
const capitalize = (str) => str.charAt(0).toUpperCase() + str.slice(1);
|
|
528
|
+
|
|
529
|
+
const candidates = [
|
|
530
|
+
baseName,
|
|
531
|
+
baseName + 'Payload',
|
|
532
|
+
path.dirname(relativePath).split(path.sep).pop() + baseName,
|
|
533
|
+
capitalize(baseName),
|
|
534
|
+
baseName.replace(/Handler$/i, ''),
|
|
535
|
+
];
|
|
536
|
+
for (const c of candidates) {
|
|
537
|
+
if (validators[c]) return c;
|
|
538
|
+
}
|
|
539
|
+
return null;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
async #startRedis() {
|
|
543
|
+
if (this.config.REDIS_ENABLE === "true") {
|
|
544
|
+
this.CONTEXT["logger"].info("Redis enabled!");
|
|
545
|
+
this.redis_client = await redis
|
|
546
|
+
.createClient({
|
|
547
|
+
url: this.config.REDIS_URL,
|
|
548
|
+
})
|
|
549
|
+
.on("error", (err) =>
|
|
550
|
+
this.CONTEXT["logger"].error(`Error connecting to Redis ${err}`),
|
|
551
|
+
)
|
|
552
|
+
.on("ready", () =>
|
|
553
|
+
this.CONTEXT["logger"].info(`Connected to Redis ${config.REDIS_URL}`),
|
|
554
|
+
)
|
|
555
|
+
.connect();
|
|
556
|
+
this.CONTEXT["redis"] = this.redis_client;
|
|
557
|
+
} else {
|
|
558
|
+
this.CONTEXT["logger"].warn("Redis disabled");
|
|
559
|
+
this.CONTEXT["redis"] = false;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Dynamically adds a route to the Hapi.js server after initialization.
|
|
565
|
+
* This allows for runtime route registration beyond the initial setup.
|
|
566
|
+
*
|
|
567
|
+
* @async
|
|
568
|
+
* @param {Object} route - Hapi.js route configuration object
|
|
569
|
+
* @param {string} route.method - HTTP method (GET, POST, PUT, DELETE, etc.)
|
|
570
|
+
* @param {string} route.path - Route path with optional parameters
|
|
571
|
+
* @param {Function} route.handler - Route handler function
|
|
572
|
+
* @param {Object} [route.options] - Additional route options (auth, validation, etc.)
|
|
573
|
+
* @returns {Promise<Falcon>} The current Falcon instance for method chaining
|
|
574
|
+
*
|
|
575
|
+
* @example
|
|
576
|
+
* await app.addRoute({
|
|
577
|
+
* method: 'GET',
|
|
578
|
+
* path: '/api/health',
|
|
579
|
+
* handler: (request, h) => ({ status: 'ok' })
|
|
580
|
+
* });
|
|
581
|
+
*/
|
|
582
|
+
async addRoute(route) {
|
|
583
|
+
await this.httpServer.route(route);
|
|
584
|
+
return this;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Loads routes recursively from the routes directory with automatic validation and Swagger documentation.
|
|
589
|
+
* Supports nested folder structures and automatic validator matching.
|
|
590
|
+
*
|
|
591
|
+
* @private
|
|
592
|
+
* @async
|
|
593
|
+
*/
|
|
594
|
+
async #loadRoutes() {
|
|
595
|
+
const routesDir = path.join(this.basePath, "routes");
|
|
596
|
+
const validatorsDir = path.join(this.basePath, "validators");
|
|
597
|
+
|
|
598
|
+
if (!fs.existsSync(routesDir)) {
|
|
599
|
+
this.CONTEXT["logger"]?.info("No routes/ folder — skipping");
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// Load validators (for validation + Swagger schemas)
|
|
604
|
+
const validators = {};
|
|
605
|
+
if (fs.existsSync(validatorsDir)) {
|
|
606
|
+
const files = await this.#findJsFilesRecursively(validatorsDir);
|
|
607
|
+
for (const file of files) {
|
|
608
|
+
const name = path.basename(file, '.js');
|
|
609
|
+
try {
|
|
610
|
+
const schema = require(file);
|
|
611
|
+
if (schema && typeof schema.describe === 'function') {
|
|
612
|
+
validators[name] = schema;
|
|
613
|
+
}
|
|
614
|
+
} catch (e) { /* ignore */ }
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
this.CONTEXT.validators = validators;
|
|
618
|
+
|
|
619
|
+
// Register Swagger first (needs to be before routes)
|
|
620
|
+
await this.#registerSwagger();
|
|
621
|
+
|
|
622
|
+
this.CONTEXT["logger"].info("Loading routes with validation + Swagger docs...");
|
|
623
|
+
|
|
624
|
+
const routeFiles = await this.#findJsFilesRecursively(routesDir);
|
|
625
|
+
|
|
626
|
+
for (const filePath of routeFiles) {
|
|
627
|
+
const relativePath = path.relative(routesDir, filePath);
|
|
628
|
+
const baseName = path.basename(filePath, '.js');
|
|
629
|
+
let routePath = this.#buildRoutePath(relativePath);
|
|
630
|
+
this.CONTEXT["logger"].info("Route Name : " + routePath);
|
|
631
|
+
|
|
632
|
+
try {
|
|
633
|
+
const routeModule = require(filePath);
|
|
634
|
+
const register = typeof routeModule === 'function' ? routeModule : routeModule.route?.bind(routeModule);
|
|
635
|
+
|
|
636
|
+
if (typeof register !== 'function') continue;
|
|
637
|
+
|
|
638
|
+
// Find validator + extract schema
|
|
639
|
+
const validatorName = this.#findBestValidatorName(baseName, relativePath, validators);
|
|
640
|
+
const validator = validatorName ? validators[validatorName] : null;
|
|
641
|
+
|
|
642
|
+
// Auto-generate tag from folder
|
|
643
|
+
const tag = path.dirname(relativePath).split(path.sep)[0] || 'api';
|
|
644
|
+
|
|
645
|
+
const wrappedRoute = async (server, ctx) => {
|
|
646
|
+
// Add auto-generated route info to context
|
|
647
|
+
ctx.route = {
|
|
648
|
+
path: routePath,
|
|
649
|
+
tag: tag,
|
|
650
|
+
validator: validator,
|
|
651
|
+
validatorName: validatorName
|
|
652
|
+
};
|
|
653
|
+
|
|
654
|
+
await register(ctx); // Only pass context, server is already in ctx.server
|
|
655
|
+
};
|
|
656
|
+
|
|
657
|
+
await wrappedRoute(this.httpServer, this.CONTEXT);
|
|
658
|
+
|
|
659
|
+
const valMark = validator ? 'Validated + Documented' : 'Documented';
|
|
660
|
+
this.CONTEXT["logger"].info(`${valMark} ${routePath} ← ${relativePath}`);
|
|
661
|
+
} catch (err) {
|
|
662
|
+
this.CONTEXT["logger"].error(`Route failed: ${filePath}`, err.message);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
this.CONTEXT["logger"].info("API fully documented at /documentation");
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* Registers Swagger UI for API documentation if enabled in settings.
|
|
671
|
+
*
|
|
672
|
+
* @private
|
|
673
|
+
* @async
|
|
674
|
+
*/
|
|
675
|
+
async #registerSwagger() {
|
|
676
|
+
if (!this.SETTINGS.swagger?.enabled) return;
|
|
677
|
+
|
|
678
|
+
const Inert = require('@hapi/inert');
|
|
679
|
+
const Vision = require('@hapi/vision');
|
|
680
|
+
const HapiSwagger = require('hapi-swagger');
|
|
681
|
+
|
|
682
|
+
const swaggerOptions = {
|
|
683
|
+
info: {
|
|
684
|
+
title: this.SETTINGS.name || 'Falcon API',
|
|
685
|
+
version: require(path.join(this.basePath, 'package.json')).version || '1.0.0',
|
|
686
|
+
},
|
|
687
|
+
schemes: ['http', 'https'],
|
|
688
|
+
grouping: 'tags',
|
|
689
|
+
tags: [
|
|
690
|
+
{ name: 'api', description: 'General API' },
|
|
691
|
+
{ name: 'auth', description: 'Authentication' },
|
|
692
|
+
{ name: 'users', description: 'User operations' },
|
|
693
|
+
],
|
|
694
|
+
documentationPath: this.SETTINGS.swagger.path || '/documentation',
|
|
695
|
+
|
|
696
|
+
validatorUrl: null,
|
|
697
|
+
reuseDefinitions: true,
|
|
698
|
+
};
|
|
699
|
+
|
|
700
|
+
await this.httpServer.register([
|
|
701
|
+
Inert,
|
|
702
|
+
Vision,
|
|
703
|
+
{
|
|
704
|
+
plugin: HapiSwagger,
|
|
705
|
+
options: swaggerOptions
|
|
706
|
+
}
|
|
707
|
+
]);
|
|
708
|
+
|
|
709
|
+
this.CONTEXT["logger"].info(`Swagger UI → http://localhost:${this.SETTINGS.http.port}${swaggerOptions.documentationPath}`);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
async #loadModels() {
|
|
713
|
+
/*MODELS*/
|
|
714
|
+
if (this.CONTEXT["mongoose"]) {
|
|
715
|
+
let files = await fsp.readdir(
|
|
716
|
+
path.join(this.basePath, "models", "mongo"),
|
|
717
|
+
);
|
|
718
|
+
const jsFiles = files.filter((file) => path.extname(file) === ".js");
|
|
719
|
+
this.CONTEXT["logger"].info(
|
|
720
|
+
"-----------------Registering Model----------------",
|
|
721
|
+
);
|
|
722
|
+
for (let i = 0; i < jsFiles.length; i++) {
|
|
723
|
+
console.log(path.join(this.basePath, "models", "mongo", jsFiles[i]));
|
|
724
|
+
await this._load_model_name(
|
|
725
|
+
path.join(this.basePath, "models", "mongo", jsFiles[i]),
|
|
726
|
+
);
|
|
727
|
+
}
|
|
728
|
+
// for (let mindex = 0; mindex < _models.length; mindex++) {
|
|
729
|
+
// const model = _models[mindex];
|
|
730
|
+
// if (await fsp.exists(path.join(this.basePath, "models", model + ".js"))) {
|
|
731
|
+
// await this.#load_model_name(model);
|
|
732
|
+
// } else {
|
|
733
|
+
// this.CONTEXT["logger"].error("Model file not found :: " + model)
|
|
734
|
+
// }
|
|
735
|
+
// }
|
|
736
|
+
this.CONTEXT["logger"].info(
|
|
737
|
+
"-----------------Registering Model----------------",
|
|
738
|
+
);
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/*MODELS*/
|
|
742
|
+
// this.CONTEXT["models"] = models;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
async #startHttpServer() {
|
|
746
|
+
//creating server object
|
|
747
|
+
this.httpServer = null;
|
|
748
|
+
this.httpServer = Hapi.server({
|
|
749
|
+
port: this.SETTINGS.http.port,
|
|
750
|
+
host: this.SETTINGS.http.host,
|
|
751
|
+
routes: {
|
|
752
|
+
cors: true,
|
|
753
|
+
},
|
|
754
|
+
debug: { request: ['error'] } // Enable better error logging
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
// Set Joi as the validator for the server
|
|
758
|
+
this.httpServer.validator(Joi);
|
|
759
|
+
this.httpServer.events.on("log", (event, tags) => {
|
|
760
|
+
this.CONTEXT["logger"].error(
|
|
761
|
+
`Server error: ${event.error ? event.error.message : "unknown"} -- ${tags}`,
|
|
762
|
+
);
|
|
763
|
+
});
|
|
764
|
+
this.httpServer.events.on("request", (request, event, tags) => {
|
|
765
|
+
this.CONTEXT["logger"].info(`Server request: ${request} ${event}`);
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
//Enable Blipp
|
|
769
|
+
await this.httpServer.register({
|
|
770
|
+
plugin: Blipp,
|
|
771
|
+
options: { showAuth: true },
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
// Health Checks
|
|
775
|
+
await this.httpServer.register({
|
|
776
|
+
plugin: require('hapi-alive'),
|
|
777
|
+
options: {
|
|
778
|
+
path: '/health',
|
|
779
|
+
tags: ['health', 'monitor'],
|
|
780
|
+
responses: {
|
|
781
|
+
healthy: { message: 'I am healthy' },
|
|
782
|
+
unhealthy: { message: 'I am unhealthy' }
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
// Rate Limiter
|
|
788
|
+
const rateLimiter = new RateLimiterMemory({
|
|
789
|
+
points: 10, // 10 points
|
|
790
|
+
duration: 1, // per second
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
this.httpServer.ext('onPreHandler', async (request, h) => {
|
|
794
|
+
try {
|
|
795
|
+
await rateLimiter.consume(request.info.remoteAddress);
|
|
796
|
+
return h.continue;
|
|
797
|
+
} catch (rejRes) {
|
|
798
|
+
throw Boom.tooManyRequests('Rate limit exceeded');
|
|
799
|
+
}
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
// Security Headers
|
|
803
|
+
this.httpServer.ext('onPreResponse', (request, h) => {
|
|
804
|
+
const response = request.response;
|
|
805
|
+
if (response.isBoom) {
|
|
806
|
+
return h.continue;
|
|
807
|
+
}
|
|
808
|
+
response.header('X-Frame-Options', 'DENY');
|
|
809
|
+
response.header('X-Content-Type-Options', 'nosniff');
|
|
810
|
+
response.header('X-XSS-Protection', '1; mode=block');
|
|
811
|
+
response.header('Strict-Transport-Security', 'max-age=63072000; includeSubDomains; preload');
|
|
812
|
+
return h.continue;
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
await this.#registerAuth();
|
|
816
|
+
|
|
817
|
+
//hapi scooter
|
|
818
|
+
await this.httpServer.register(Scooter);
|
|
819
|
+
this.CONTEXT["logger"].info(await this.httpServer.plugins.blipp.info());
|
|
820
|
+
|
|
821
|
+
if (this.#isDev()) {
|
|
822
|
+
this.CONTEXT["logger"].info("-----------------DEV MODE----------------");
|
|
823
|
+
this.CONTEXT["logger"].info("-----------------DEV MODE----------------");
|
|
824
|
+
this.CONTEXT["logger"].info("-----------------DEV MODE----------------");
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
this.CONTEXT["server"] = this.httpServer;
|
|
828
|
+
|
|
829
|
+
this.httpServer.events.on("response", (request) => {
|
|
830
|
+
this.CONTEXT["logger"].info(
|
|
831
|
+
request.info.remoteAddress +
|
|
832
|
+
": " +
|
|
833
|
+
request.method.toUpperCase() +
|
|
834
|
+
" " +
|
|
835
|
+
request.path +
|
|
836
|
+
" --> " +
|
|
837
|
+
request.response.statusCode,
|
|
838
|
+
);
|
|
839
|
+
});
|
|
840
|
+
this.CONTEXT["logger"].info(
|
|
841
|
+
"-----------------Registered Routes----------------",
|
|
842
|
+
);
|
|
843
|
+
await this.httpServer
|
|
844
|
+
.table()
|
|
845
|
+
.forEach((route) =>
|
|
846
|
+
this.CONTEXT["logger"].info(`${route.method}\t${route.path}`),
|
|
847
|
+
);
|
|
848
|
+
this.CONTEXT["logger"].info(
|
|
849
|
+
"-----------------Registered Routes----------------",
|
|
850
|
+
);
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/**
|
|
854
|
+
* Registers a custom authentication strategy for the Hapi.js server.
|
|
855
|
+
* This method allows complete control over authentication setup and MUST be called before init().
|
|
856
|
+
* The handler function receives the Falcon context including the server instance.
|
|
857
|
+
*
|
|
858
|
+
* @param {Function} handler - Async function that configures authentication
|
|
859
|
+
* @param {Object} handler.context - Falcon context object containing server, logger, etc.
|
|
860
|
+
* @param {Object} handler.context.server - Hapi.js server instance
|
|
861
|
+
* @param {Object} handler.context.logger - Logger instance
|
|
862
|
+
* @returns {Falcon} The current Falcon instance for method chaining
|
|
863
|
+
*
|
|
864
|
+
* @example
|
|
865
|
+
* // Custom JWT authentication
|
|
866
|
+
* falcon.setAuthStrategy(async (context) => {
|
|
867
|
+
* await context.server.register(require('hapi-auth-jwt2'));
|
|
868
|
+
* context.server.auth.strategy('jwt', 'jwt', {
|
|
869
|
+
* key: process.env.JWT_SECRET,
|
|
870
|
+
* validate: async (decoded) => {
|
|
871
|
+
* const user = await getUserById(decoded.id);
|
|
872
|
+
* return { isValid: !!user, credentials: user };
|
|
873
|
+
* }
|
|
874
|
+
* });
|
|
875
|
+
* context.server.auth.default('jwt');
|
|
876
|
+
* });
|
|
877
|
+
*/
|
|
878
|
+
setAuthStrategy(handler) {
|
|
879
|
+
this.customAuthHandler = handler;
|
|
880
|
+
return this;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
async #registerAuth() {
|
|
884
|
+
// 1. Check if the user provided their own logic
|
|
885
|
+
if (this.customAuthHandler) {
|
|
886
|
+
this.CONTEXT["logger"].info("Registering CUSTOM Auth Strategy...");
|
|
887
|
+
|
|
888
|
+
// Execute the user's function, passing the Hapi server instance
|
|
889
|
+
await this.customAuthHandler(this.CONTEXT);
|
|
890
|
+
|
|
891
|
+
return this;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
// 2. Register the default Falcon auth plugin if auth is configured in settings
|
|
895
|
+
if (this.SETTINGS.auth && Object.keys(this.SETTINGS.auth).length > 0) {
|
|
896
|
+
this.CONTEXT["logger"].info("Registering Falcon Auth Plugin...");
|
|
897
|
+
|
|
898
|
+
// Pass context to server app for auth plugin access
|
|
899
|
+
this.httpServer.app.falconContext = this.CONTEXT;
|
|
900
|
+
|
|
901
|
+
const { plugin } = require('./FalconAuthPlugin');
|
|
902
|
+
await this.httpServer.register({
|
|
903
|
+
plugin: plugin,
|
|
904
|
+
options: this.SETTINGS.auth
|
|
905
|
+
});
|
|
906
|
+
|
|
907
|
+
// Store auth instance for Socket.IO use
|
|
908
|
+
this.CONTEXT["auth"] = this.httpServer.app.falconAuth;
|
|
909
|
+
} else {
|
|
910
|
+
this.CONTEXT["logger"].info("No auth configuration found - skipping auth setup");
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
return this;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
/**
|
|
917
|
+
* Starts the Hapi.js HTTP server and begins listening for incoming requests.
|
|
918
|
+
* This method should be called after init() to actually start serving traffic.
|
|
919
|
+
* The server will listen on the host and port specified in settings.
|
|
920
|
+
*
|
|
921
|
+
* @async
|
|
922
|
+
* @returns {Promise<void>} Resolves when the server has started successfully
|
|
923
|
+
* @throws {Error} Throws error if server fails to start
|
|
924
|
+
*
|
|
925
|
+
* @example
|
|
926
|
+
* const app = new Falcon(__dirname);
|
|
927
|
+
* await app.init();
|
|
928
|
+
* await app.runServer();
|
|
929
|
+
* console.log('Server running on', app.CONTEXT.server.info.uri);
|
|
930
|
+
*/
|
|
931
|
+
async runServer() {
|
|
932
|
+
await this.CONTEXT["server"].start();
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
async #startSocketIO() {
|
|
936
|
+
if (this.socketIoConfig) {
|
|
937
|
+
const io = new Server(this.httpServer.listener, this.socketIoConfig);
|
|
938
|
+
|
|
939
|
+
// Add authentication middleware if auth is configured
|
|
940
|
+
if (this.CONTEXT["auth"]) {
|
|
941
|
+
const authMiddleware = this.CONTEXT["auth"].createSocketIOMiddleware();
|
|
942
|
+
io.use(authMiddleware);
|
|
943
|
+
this.CONTEXT["logger"].info("Socket.IO authentication middleware enabled");
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
// Store Socket.IO instance in context
|
|
947
|
+
this.CONTEXT["io"] = io;
|
|
948
|
+
|
|
949
|
+
// Load socket handlers from socket/ directory
|
|
950
|
+
await this.#loadSocketHandlers(io);
|
|
951
|
+
|
|
952
|
+
// Basic connection handling
|
|
953
|
+
io.on('connection', (socket) => {
|
|
954
|
+
this.CONTEXT["logger"].info(`Socket.IO client connected: ${socket.id}`);
|
|
955
|
+
|
|
956
|
+
if (socket.authenticated) {
|
|
957
|
+
this.CONTEXT["logger"].info(`Authenticated user: ${socket.user?.id || 'unknown'}`);
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
socket.on('disconnect', () => {
|
|
961
|
+
this.CONTEXT["logger"].info(`Socket.IO client disconnected: ${socket.id}`);
|
|
962
|
+
});
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
} else {
|
|
966
|
+
this.CONTEXT["logger"].info("Socket.IO config not found!");
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
async #loadSocketHandlers(io) {
|
|
971
|
+
const socketDir = path.join(this.basePath, 'sockets');
|
|
972
|
+
|
|
973
|
+
if (!fs.existsSync(socketDir)) {
|
|
974
|
+
this.CONTEXT["logger"].info("Socket handlers directory not found, skipping");
|
|
975
|
+
return;
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
const files = fs.readdirSync(socketDir).filter(file => file.endsWith('.js'));
|
|
979
|
+
|
|
980
|
+
for (const file of files) {
|
|
981
|
+
const filePath = path.join(socketDir, file);
|
|
982
|
+
const handlerName = path.basename(file, '.js');
|
|
983
|
+
|
|
984
|
+
try {
|
|
985
|
+
const handlerModule = require(filePath);
|
|
986
|
+
const handler = typeof handlerModule === 'function' ? handlerModule : handlerModule.handler;
|
|
987
|
+
|
|
988
|
+
if (typeof handler === 'function') {
|
|
989
|
+
await handler(io, this.CONTEXT);
|
|
990
|
+
this.CONTEXT["logger"].info(`Socket handler loaded: ${handlerName}`);
|
|
991
|
+
}
|
|
992
|
+
} catch (err) {
|
|
993
|
+
this.CONTEXT["logger"].error(`Failed to load socket handler ${handlerName}:`, err.message);
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
async #startInternalMqttClient(urlObj) {
|
|
999
|
+
const mqttUrl = this.SETTINGS.mqtt.internal;
|
|
1000
|
+
this.mqttClient = mqtt.connect(`${config.MQTT_URL}`);
|
|
1001
|
+
this.CONTEXT["mqttClient"] = this.mqttClient;
|
|
1002
|
+
this.mqttClient.on("connect", () => {
|
|
1003
|
+
this.mqttClient.subscribe("service_utils", (err) => {
|
|
1004
|
+
this.CONTEXT["logger"].info(
|
|
1005
|
+
"mqtt connected and subscribed to utility service",
|
|
1006
|
+
);
|
|
1007
|
+
});
|
|
1008
|
+
|
|
1009
|
+
// Subscribe to WebSocket topics
|
|
1010
|
+
this.mqttClient.subscribe("websocket_broadcast", (err) => {
|
|
1011
|
+
this.CONTEXT["logger"].info("mqtt subscribed to websocket_broadcast");
|
|
1012
|
+
});
|
|
1013
|
+
this.mqttClient.subscribe("websocket_emit_group", (err) => {
|
|
1014
|
+
this.CONTEXT["logger"].info("mqtt subscribed to websocket_emit_group");
|
|
1015
|
+
});
|
|
1016
|
+
this.mqttClient.subscribe("websocket_emit_socket", (err) => {
|
|
1017
|
+
this.CONTEXT["logger"].info("mqtt subscribed to websocket_emit_socket");
|
|
1018
|
+
});
|
|
1019
|
+
});
|
|
1020
|
+
|
|
1021
|
+
// Handle MQTT messages for WebSocket bridging
|
|
1022
|
+
this.mqttClient.on("message", (topic, message) => {
|
|
1023
|
+
this.#handleMqttToWebSocket(topic, message);
|
|
1024
|
+
});
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
async #startExternalMqttClient() {
|
|
1028
|
+
this.mqttClient = mqtt.connect(`${config.MQTT_URL}`);
|
|
1029
|
+
this.CONTEXT["mqttClient"] = this.mqttClient;
|
|
1030
|
+
this.mqttClient.on("connect", () => {
|
|
1031
|
+
this.mqttClient.subscribe("service_utils", (err) => {
|
|
1032
|
+
this.CONTEXT["logger"].info(
|
|
1033
|
+
"mqtt connected and subscribed to utility service",
|
|
1034
|
+
);
|
|
1035
|
+
});
|
|
1036
|
+
|
|
1037
|
+
// Subscribe to WebSocket topics
|
|
1038
|
+
this.mqttClient.subscribe("websocket_broadcast", (err) => {
|
|
1039
|
+
this.CONTEXT["logger"].info("mqtt subscribed to websocket_broadcast");
|
|
1040
|
+
});
|
|
1041
|
+
this.mqttClient.subscribe("websocket_emit_group", (err) => {
|
|
1042
|
+
this.CONTEXT["logger"].info("mqtt subscribed to websocket_emit_group");
|
|
1043
|
+
});
|
|
1044
|
+
this.mqttClient.subscribe("websocket_emit_socket", (err) => {
|
|
1045
|
+
this.CONTEXT["logger"].info("mqtt subscribed to websocket_emit_socket");
|
|
1046
|
+
});
|
|
1047
|
+
});
|
|
1048
|
+
|
|
1049
|
+
// Handle MQTT messages for WebSocket bridging
|
|
1050
|
+
this.mqttClient.on("message", (topic, message) => {
|
|
1051
|
+
this.#handleMqttToWebSocket(topic, message);
|
|
1052
|
+
});
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
#handleMqttToWebSocket(topic, message) {
|
|
1056
|
+
if (!this.CONTEXT["io"]) return;
|
|
1057
|
+
|
|
1058
|
+
try {
|
|
1059
|
+
const data = JSON.parse(message.toString());
|
|
1060
|
+
const io = this.CONTEXT["io"];
|
|
1061
|
+
|
|
1062
|
+
switch (topic) {
|
|
1063
|
+
case "websocket_broadcast":
|
|
1064
|
+
io.emit(data.event || 'message', data.payload || data);
|
|
1065
|
+
this.CONTEXT["logger"].debug(`WebSocket broadcast: ${data.event}`);
|
|
1066
|
+
break;
|
|
1067
|
+
|
|
1068
|
+
case "websocket_emit_group":
|
|
1069
|
+
if (data.room) {
|
|
1070
|
+
io.to(data.room).emit(data.event || 'message', data.payload || data);
|
|
1071
|
+
this.CONTEXT["logger"].debug(`WebSocket emit to room ${data.room}: ${data.event}`);
|
|
1072
|
+
}
|
|
1073
|
+
break;
|
|
1074
|
+
|
|
1075
|
+
case "websocket_emit_socket":
|
|
1076
|
+
if (data.socketId) {
|
|
1077
|
+
io.to(data.socketId).emit(data.event || 'message', data.payload || data);
|
|
1078
|
+
this.CONTEXT["logger"].debug(`WebSocket emit to socket ${data.socketId}: ${data.event}`);
|
|
1079
|
+
}
|
|
1080
|
+
break;
|
|
1081
|
+
}
|
|
1082
|
+
} catch (err) {
|
|
1083
|
+
this.CONTEXT["logger"].error(`Failed to handle MQTT to WebSocket message:`, err.message);
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
async #startMqttClient() {
|
|
1088
|
+
if (this.SETTINGS.mqtt) {
|
|
1089
|
+
if (this.SETTINGS.mqtt.internal) {
|
|
1090
|
+
await this.#startInternalMqttClient(this.SETTINGS.mqtt.internal);
|
|
1091
|
+
} else {
|
|
1092
|
+
if (this.SETTINGS.mqtt.external) {
|
|
1093
|
+
await this.#startExternalMqttClient(this.SETTINGS.mqtt.external);
|
|
1094
|
+
} else {
|
|
1095
|
+
await this.#startInternalMqttClient(this.SETTINGS.mqtt.internal);
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
/**
|
|
1102
|
+
*
|
|
1103
|
+
* Get Socket IO middle ware
|
|
1104
|
+
* @returns Falcon instance
|
|
1105
|
+
*/
|
|
1106
|
+
registerSocketIOMiddleware() {
|
|
1107
|
+
return this;
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
|
|
1111
|
+
async #validate(decoded, request, h) {
|
|
1112
|
+
await this.validateAuth(decoded, request, h, this.CONTEXT);
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
|
|
1116
|
+
|
|
1117
|
+
// --- LOADER FUNCTION --- //
|
|
1118
|
+
async _load_model_name(name) {
|
|
1119
|
+
let _temp = await require(name)(mongoose);
|
|
1120
|
+
this.models[path.basename(name).trim()] = _temp;
|
|
1121
|
+
this.CONTEXT["models"][path.basename(name).trim()] = _temp;
|
|
1122
|
+
this.CONTEXT["logger"].info(name + " model registered");
|
|
1123
|
+
}
|
|
1124
|
+
async #load_route_name(name, server) {
|
|
1125
|
+
await require(path.join(this.basePath, "routes", name)).route(
|
|
1126
|
+
server,
|
|
1127
|
+
models,
|
|
1128
|
+
logger,
|
|
1129
|
+
);
|
|
1130
|
+
this.CONTEXT["logger"].info(name + " route registered");
|
|
1131
|
+
}
|
|
1132
|
+
// --- LOADER FUNCTION --- //
|
|
1133
|
+
|
|
1134
|
+
/**
|
|
1135
|
+
* Starts a worker process with the given arguments.
|
|
1136
|
+
* Workers are spawned as separate Node.js processes.
|
|
1137
|
+
*
|
|
1138
|
+
* @param {string} worker_name - Name of the worker file (without .js extension)
|
|
1139
|
+
* @param {Object} args - Arguments to pass to the worker
|
|
1140
|
+
* @returns {Promise<Object>} Worker process information
|
|
1141
|
+
*/
|
|
1142
|
+
async startWorker(worker_name, args = {}) {
|
|
1143
|
+
const worker_file = path.join(this.basePath, "workers", worker_name + ".js");
|
|
1144
|
+
|
|
1145
|
+
if (!fs.existsSync(worker_file)) {
|
|
1146
|
+
throw new Error(`Worker file not found: ${worker_file}`);
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
const args_json = JSON.stringify(args);
|
|
1150
|
+
const base64Args = Buffer.from(args_json, "utf8").toString("base64");
|
|
1151
|
+
|
|
1152
|
+
const worker_process = spawn(process.execPath, [worker_file, base64Args], {
|
|
1153
|
+
env: { ...process.env },
|
|
1154
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
1155
|
+
});
|
|
1156
|
+
|
|
1157
|
+
worker_process.stdout.setEncoding("utf8");
|
|
1158
|
+
worker_process.stderr.setEncoding("utf8");
|
|
1159
|
+
|
|
1160
|
+
worker_process.stdout.on("data", (data) => {
|
|
1161
|
+
this.CONTEXT["logger"].info(`[WORKER-${worker_name}] ${data}`);
|
|
1162
|
+
});
|
|
1163
|
+
|
|
1164
|
+
worker_process.stderr.on("data", (data) => {
|
|
1165
|
+
this.CONTEXT["logger"].error(`[WORKER-${worker_name}] ERROR: ${data}`);
|
|
1166
|
+
});
|
|
1167
|
+
|
|
1168
|
+
worker_process.on("close", (code) => {
|
|
1169
|
+
this.CONTEXT["logger"].info(`[WORKER-${worker_name}] Exited with code ${code}`);
|
|
1170
|
+
});
|
|
1171
|
+
|
|
1172
|
+
worker_process.on("error", (err) => {
|
|
1173
|
+
this.CONTEXT["logger"].error(`[WORKER-${worker_name}] Failed to start:`, err);
|
|
1174
|
+
});
|
|
1175
|
+
|
|
1176
|
+
// Store worker reference
|
|
1177
|
+
if (!this.CONTEXT["workers"]) {
|
|
1178
|
+
this.CONTEXT["workers"] = {};
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
this.CONTEXT["workers"][worker_name] = {
|
|
1182
|
+
pid: worker_process.pid,
|
|
1183
|
+
process: worker_process,
|
|
1184
|
+
started: new Date(),
|
|
1185
|
+
args: args
|
|
1186
|
+
};
|
|
1187
|
+
|
|
1188
|
+
this.CONTEXT["logger"].info(`Worker ${worker_name} started with PID ${worker_process.pid}`);
|
|
1189
|
+
return this.CONTEXT["workers"][worker_name];
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
/**
|
|
1193
|
+
* Sends a message to a worker via MQTT.
|
|
1194
|
+
*
|
|
1195
|
+
* @param {string} worker_name - Name of the worker
|
|
1196
|
+
* @param {*} message - Message to send
|
|
1197
|
+
* @param {string} [action='job'] - Action type (job, ping, etc.)
|
|
1198
|
+
*/
|
|
1199
|
+
sendToWorker(worker_name, message, action = 'job') {
|
|
1200
|
+
if (this.mqtt_client) {
|
|
1201
|
+
const topic = `worker_${worker_name}_${action}`;
|
|
1202
|
+
const payload = typeof message === 'string' ? message : JSON.stringify(message);
|
|
1203
|
+
this.mqtt_client.publish(topic, payload);
|
|
1204
|
+
this.CONTEXT["logger"].info(`Message sent to worker ${worker_name} on topic ${topic}`);
|
|
1205
|
+
} else {
|
|
1206
|
+
this.CONTEXT["logger"].warn("MQTT client not available, cannot send message to worker");
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
/**
|
|
1211
|
+
* Sends a message to a service via MQTT.
|
|
1212
|
+
*
|
|
1213
|
+
* @param {string} service_name - Name of the service
|
|
1214
|
+
* @param {*} message - Message to send
|
|
1215
|
+
*/
|
|
1216
|
+
sendToService(service_name, message) {
|
|
1217
|
+
if (this.mqtt_client) {
|
|
1218
|
+
const topic = `service_${service_name}`;
|
|
1219
|
+
const payload = typeof message === 'string' ? message : JSON.stringify(message);
|
|
1220
|
+
this.mqtt_client.publish(topic, payload);
|
|
1221
|
+
this.CONTEXT["logger"].info(`Message sent to service ${service_name}`);
|
|
1222
|
+
} else {
|
|
1223
|
+
this.CONTEXT["logger"].warn("MQTT client not available, cannot send message to service");
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
/**
|
|
1228
|
+
* Broadcasts a message to all websocket clients.
|
|
1229
|
+
*
|
|
1230
|
+
* @param {*} data - Data to broadcast
|
|
1231
|
+
*/
|
|
1232
|
+
broadcastToWebsockets(data) {
|
|
1233
|
+
if (this.mqtt_client) {
|
|
1234
|
+
this.mqtt_client.publish('websocket_broadcast', JSON.stringify(data));
|
|
1235
|
+
this.CONTEXT["logger"].info("Message broadcasted to websockets");
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
#workers() {
|
|
1240
|
+
// Legacy method - keeping for backward compatibility
|
|
1241
|
+
this.CONTEXT.startWorker = this.startWorker.bind(this);
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
_spawnServices(filesToRun) {
|
|
1245
|
+
filesToRun.forEach((file) => {
|
|
1246
|
+
const spawnOptions = {
|
|
1247
|
+
stdio: ["pipe", "pipe", "pipe"], // Pipe stdout and stderr to our custom logging function
|
|
1248
|
+
env: { ...process.env, LOG4JS_LOGGER_LEVEL: "DEBUG" }, // Set the Log4js logger level for each process
|
|
1249
|
+
};
|
|
1250
|
+
|
|
1251
|
+
const s_path = path.join(this.basePath, "services", file + ".js");
|
|
1252
|
+
|
|
1253
|
+
// Use process.execPath to ensure we use the same Node binary
|
|
1254
|
+
const cprocess = spawn(process.execPath, [s_path], spawnOptions);
|
|
1255
|
+
|
|
1256
|
+
cprocess.stdout.on("data", (data) => {
|
|
1257
|
+
this.CONTEXT["logger"].info(
|
|
1258
|
+
`------------------------SERVICE - ${file}----------------`,
|
|
1259
|
+
);
|
|
1260
|
+
this.CONTEXT["logger"].info(data.toString());
|
|
1261
|
+
this.CONTEXT["logger"].info(
|
|
1262
|
+
`------------------------SERVICE - ${file}----------------`,
|
|
1263
|
+
);
|
|
1264
|
+
});
|
|
1265
|
+
cprocess.stderr.on("data", (data) => {
|
|
1266
|
+
this.CONTEXT["logger"].error(
|
|
1267
|
+
`------------------------SERVICE - ${file}----------------`,
|
|
1268
|
+
);
|
|
1269
|
+
this.CONTEXT["logger"].error(data.toString());
|
|
1270
|
+
this.CONTEXT["logger"].error(
|
|
1271
|
+
`------------------------SERVICE - ${file}----------------`,
|
|
1272
|
+
);
|
|
1273
|
+
});
|
|
1274
|
+
|
|
1275
|
+
cprocess.on('error', (err) => {
|
|
1276
|
+
this.CONTEXT["logger"].error(`Failed to spawn service ${file}:`, err);
|
|
1277
|
+
});
|
|
1278
|
+
|
|
1279
|
+
cprocess.on('exit', (code, signal) => {
|
|
1280
|
+
if (code !== 0) {
|
|
1281
|
+
this.CONTEXT["logger"].warn(`Service ${file} exited with code ${code} and signal ${signal}`);
|
|
1282
|
+
} else {
|
|
1283
|
+
this.CONTEXT["logger"].info(`Service ${file} exited gracefully.`);
|
|
1284
|
+
}
|
|
1285
|
+
});
|
|
1286
|
+
|
|
1287
|
+
this.CONTEXT["services"].push({
|
|
1288
|
+
pid: cprocess.pid,
|
|
1289
|
+
stdout: cprocess.stdout,
|
|
1290
|
+
stderr: cprocess.stderr,
|
|
1291
|
+
});
|
|
1292
|
+
});
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
/**
|
|
1296
|
+
* Gracefully closes all active connections and cleans up resources.
|
|
1297
|
+
* This method safely shuts down HTTP server, database connections, Redis client,
|
|
1298
|
+
* MQTT connections, and terminates spawned service processes.
|
|
1299
|
+
*
|
|
1300
|
+
* @async
|
|
1301
|
+
* @returns {Promise<void>} Resolves when all cleanup operations complete
|
|
1302
|
+
*
|
|
1303
|
+
* @example
|
|
1304
|
+
* // Manual cleanup
|
|
1305
|
+
* await app.cleanup();
|
|
1306
|
+
*
|
|
1307
|
+
* // Automatic cleanup on process signals (handled by setupGracefulShutdown)
|
|
1308
|
+
* process.on('SIGTERM', async () => {
|
|
1309
|
+
* await app.cleanup();
|
|
1310
|
+
* process.exit(0);
|
|
1311
|
+
* });
|
|
1312
|
+
*/
|
|
1313
|
+
async cleanup() {
|
|
1314
|
+
if (this.CONTEXT["logger"]) {
|
|
1315
|
+
this.CONTEXT["logger"].info("Starting cleanup process...");
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
// Close HTTP server
|
|
1319
|
+
if (this.httpServer) {
|
|
1320
|
+
await this.httpServer.stop();
|
|
1321
|
+
this.httpServer = null;
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
// Close MongoDB connection
|
|
1325
|
+
if (this.db && mongoose.connection.readyState === 1) {
|
|
1326
|
+
await mongoose.connection.close();
|
|
1327
|
+
this.db = null;
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
// Close Redis connection
|
|
1331
|
+
if (this.redis_client && this.redis_client.isOpen) {
|
|
1332
|
+
await this.redis_client.quit();
|
|
1333
|
+
this.redis_client = null;
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
// Close MQTT connections
|
|
1337
|
+
if (this.mqtt_client && this.mqtt_client.connected) {
|
|
1338
|
+
await this.mqtt_client.end();
|
|
1339
|
+
this.mqtt_client = null;
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
if (this.mqtt_server) {
|
|
1343
|
+
this.mqtt_server.close();
|
|
1344
|
+
this.mqtt_server = null;
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
// Kill spawned services
|
|
1348
|
+
if (this.CONTEXT["services"] && this.CONTEXT["services"].length > 0) {
|
|
1349
|
+
this.CONTEXT["services"].forEach(service => {
|
|
1350
|
+
if (service.pid) {
|
|
1351
|
+
process.kill(service.pid, 'SIGTERM');
|
|
1352
|
+
}
|
|
1353
|
+
});
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
if (this.CONTEXT["logger"]) {
|
|
1357
|
+
this.CONTEXT["logger"].info("Cleanup completed");
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
/**
|
|
1362
|
+
* Sets up graceful shutdown handlers for process signals and uncaught exceptions.
|
|
1363
|
+
* Automatically registers handlers for SIGTERM, SIGINT, uncaughtException, and unhandledRejection.
|
|
1364
|
+
* When any of these events occur, the cleanup method is called before process termination.
|
|
1365
|
+
*
|
|
1366
|
+
* @returns {void}
|
|
1367
|
+
*
|
|
1368
|
+
* @example
|
|
1369
|
+
* // Called automatically in init(), but can be called manually if needed
|
|
1370
|
+
* app.setupGracefulShutdown();
|
|
1371
|
+
*
|
|
1372
|
+
* // The following signals will trigger graceful shutdown:
|
|
1373
|
+
* // - SIGTERM (termination request)
|
|
1374
|
+
* // - SIGINT (interrupt signal, Ctrl+C)
|
|
1375
|
+
* // - uncaughtException (unhandled errors)
|
|
1376
|
+
* // - unhandledRejection (unhandled promise rejections)
|
|
1377
|
+
*/
|
|
1378
|
+
setupGracefulShutdown() {
|
|
1379
|
+
const signals = ['SIGINT', 'SIGTERM'];
|
|
1380
|
+
const logger = this.CONTEXT["logger"] || console;
|
|
1381
|
+
|
|
1382
|
+
signals.forEach((signal) => {
|
|
1383
|
+
process.on(signal, async () => {
|
|
1384
|
+
logger.info(`Received ${signal}. Starting graceful shutdown...`);
|
|
1385
|
+
|
|
1386
|
+
try {
|
|
1387
|
+
if (this.httpServer) {
|
|
1388
|
+
await this.httpServer.stop({ timeout: 10000 });
|
|
1389
|
+
logger.info('HTTP server stopped');
|
|
1390
|
+
this.httpServer = null;
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
if (this.db && mongoose.connection.readyState === 1) {
|
|
1394
|
+
await mongoose.connection.close();
|
|
1395
|
+
logger.info('MongoDB connection closed');
|
|
1396
|
+
this.db = null;
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
if (this.redis_client && this.redis_client.isOpen) {
|
|
1400
|
+
await this.redis_client.quit();
|
|
1401
|
+
logger.info('Redis connection closed');
|
|
1402
|
+
this.redis_client = null;
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
if (this.mqtt_client && this.mqtt_client.connected) {
|
|
1406
|
+
this.mqtt_client.end();
|
|
1407
|
+
logger.info('MQTT client disconnected');
|
|
1408
|
+
this.mqtt_client = null;
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
if (this.mqtt_server) {
|
|
1412
|
+
this.mqtt_server.close();
|
|
1413
|
+
logger.info('MQTT server closed');
|
|
1414
|
+
this.mqtt_server = null;
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
// Kill spawned services
|
|
1418
|
+
if (this.CONTEXT["services"] && this.CONTEXT["services"].length > 0) {
|
|
1419
|
+
this.CONTEXT["services"].forEach(service => {
|
|
1420
|
+
if (service.pid) {
|
|
1421
|
+
process.kill(service.pid, 'SIGTERM');
|
|
1422
|
+
}
|
|
1423
|
+
});
|
|
1424
|
+
logger.info('Services terminated');
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
logger.info('Graceful shutdown complete. Exiting.');
|
|
1428
|
+
process.exit(0);
|
|
1429
|
+
} catch (err) {
|
|
1430
|
+
logger.error('Error during graceful shutdown:', err);
|
|
1431
|
+
process.exit(1);
|
|
1432
|
+
}
|
|
1433
|
+
});
|
|
1434
|
+
});
|
|
1435
|
+
|
|
1436
|
+
process.on('uncaughtException', async (err) => {
|
|
1437
|
+
logger.error('Uncaught Exception:', err);
|
|
1438
|
+
await this.cleanup();
|
|
1439
|
+
process.exit(1);
|
|
1440
|
+
});
|
|
1441
|
+
|
|
1442
|
+
process.on('unhandledRejection', async (reason) => {
|
|
1443
|
+
console.log(reason)
|
|
1444
|
+
logger.error('Unhandled Rejection:', reason);
|
|
1445
|
+
await this.cleanup();
|
|
1446
|
+
process.exit(1);
|
|
1447
|
+
});
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
|
|
1451
|
+
async #generateSwaggerSchemas() {
|
|
1452
|
+
this.CONTEXT["swagger_schema"] = {};
|
|
1453
|
+
if (this.CONTEXT["models"]) {
|
|
1454
|
+
for (const [name, model] of Object.entries(this.CONTEXT["models"])) {
|
|
1455
|
+
try {
|
|
1456
|
+
if (model.schema) {
|
|
1457
|
+
this.CONTEXT["swagger_schema"][name] = mongooseToJoi(model.schema).label(model.modelName || name);
|
|
1458
|
+
}
|
|
1459
|
+
} catch (e) {
|
|
1460
|
+
this.CONTEXT["logger"].error(`Failed to generate swagger schema for ${name}`, e);
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
|
|
1467
|
+
|
|
1468
|
+
async #startCrudRoutes() {
|
|
1469
|
+
if (!this.SETTINGS.crud || Object.keys(this.SETTINGS.crud).length === 0) return;
|
|
1470
|
+
|
|
1471
|
+
this.CONTEXT["logger"].info("Starting Auto-Generated CRUD Routes...");
|
|
1472
|
+
|
|
1473
|
+
const allowedOps = ['create', 'read', 'update', 'delete', 'paginate'];
|
|
1474
|
+
|
|
1475
|
+
for (const [modelName, config] of Object.entries(this.SETTINGS.crud)) {
|
|
1476
|
+
// 1. Validate Model Exists
|
|
1477
|
+
// Models are stored in context with .js extension usually, wait, loaded models keys might vary
|
|
1478
|
+
// Let's check against loaded models. The loader uses filenames as keys usually.
|
|
1479
|
+
// We need to find the matching model key.
|
|
1480
|
+
const modelKey = Object.keys(this.CONTEXT.models).find(k => k.replace(/\.js$/, '') === modelName);
|
|
1481
|
+
|
|
1482
|
+
if (!modelKey) {
|
|
1483
|
+
this.CONTEXT["logger"].warn(`CRUD Gen: Model "${modelName}" not found in loaded models. Skipping.`);
|
|
1484
|
+
continue;
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
const Model = this.CONTEXT.models[modelKey];
|
|
1488
|
+
let ops = [];
|
|
1489
|
+
|
|
1490
|
+
// 2. Parse Config
|
|
1491
|
+
if (config === 'all') {
|
|
1492
|
+
ops = allowedOps;
|
|
1493
|
+
} else if (Array.isArray(config)) {
|
|
1494
|
+
// Validate ops
|
|
1495
|
+
const invalidOps = config.filter(op => !allowedOps.includes(op));
|
|
1496
|
+
if (invalidOps.length > 0) {
|
|
1497
|
+
this.CONTEXT["logger"].warn(`CRUD Gen: Invalid operations [${invalidOps.join(', ')}] config for model "${modelName}". Ignoring ENTIRE model.`);
|
|
1498
|
+
continue;
|
|
1499
|
+
}
|
|
1500
|
+
ops = config;
|
|
1501
|
+
} else {
|
|
1502
|
+
this.CONTEXT["logger"].warn(`CRUD Gen: Invalid config format for model "${modelName}". Expected "all" or array. Skipping.`);
|
|
1503
|
+
continue;
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
this.CONTEXT["logger"].info(`CRUD Gen: Generating [${ops.join(', ')}] for ${modelName}`);
|
|
1507
|
+
|
|
1508
|
+
const basePath = `/crud/${modelName}`;
|
|
1509
|
+
|
|
1510
|
+
// Retrieve Joi Schema from Context
|
|
1511
|
+
const joiSchema = this.CONTEXT["swagger_schema"][modelName] || this.CONTEXT["swagger_schema"][modelKey];
|
|
1512
|
+
|
|
1513
|
+
// 3. Generate Routes
|
|
1514
|
+
if (ops.includes('create')) {
|
|
1515
|
+
this.httpServer.route({
|
|
1516
|
+
method: 'POST',
|
|
1517
|
+
path: basePath,
|
|
1518
|
+
options: {
|
|
1519
|
+
tags: ['api'],
|
|
1520
|
+
description: `Create a new ${modelName}`,
|
|
1521
|
+
notes: `Auto-generated create route for ${modelName}`,
|
|
1522
|
+
validate: {
|
|
1523
|
+
payload: joiSchema
|
|
1524
|
+
}
|
|
1525
|
+
},
|
|
1526
|
+
handler: async (request, h) => {
|
|
1527
|
+
try {
|
|
1528
|
+
const doc = await Model.create(request.payload);
|
|
1529
|
+
return h.response(doc).code(201);
|
|
1530
|
+
} catch (err) {
|
|
1531
|
+
return h.response({ error: err.message }).code(500);
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
});
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
if (ops.includes('read')) { // Get By ID
|
|
1538
|
+
this.httpServer.route({
|
|
1539
|
+
method: 'GET',
|
|
1540
|
+
path: `${basePath}/{id}`,
|
|
1541
|
+
options: {
|
|
1542
|
+
tags: ['api'],
|
|
1543
|
+
description: `Get ${modelName} by ID`,
|
|
1544
|
+
notes: `Auto-generated get by id route for ${modelName}`
|
|
1545
|
+
},
|
|
1546
|
+
handler: async (request, h) => {
|
|
1547
|
+
try {
|
|
1548
|
+
const doc = await Model.findById(request.params.id);
|
|
1549
|
+
if (!doc) return h.response({ error: "Not Found" }).code(404);
|
|
1550
|
+
return doc;
|
|
1551
|
+
} catch (err) {
|
|
1552
|
+
return h.response({ error: err.message }).code(500);
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
});
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
if (ops.includes('update')) {
|
|
1559
|
+
this.httpServer.route({
|
|
1560
|
+
method: 'PUT',
|
|
1561
|
+
path: `${basePath}/{id}`,
|
|
1562
|
+
options: {
|
|
1563
|
+
tags: ['api'],
|
|
1564
|
+
description: `Update ${modelName} by ID`,
|
|
1565
|
+
notes: `Auto-generated update route for ${modelName}`,
|
|
1566
|
+
validate: {
|
|
1567
|
+
payload: joiSchema
|
|
1568
|
+
}
|
|
1569
|
+
},
|
|
1570
|
+
handler: async (request, h) => {
|
|
1571
|
+
try {
|
|
1572
|
+
const doc = await Model.findByIdAndUpdate(request.params.id, request.payload, { new: true });
|
|
1573
|
+
if (!doc) return h.response({ error: "Not Found" }).code(404);
|
|
1574
|
+
return doc;
|
|
1575
|
+
} catch (err) {
|
|
1576
|
+
return h.response({ error: err.message }).code(500);
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
});
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
if (ops.includes('delete')) {
|
|
1583
|
+
this.httpServer.route({
|
|
1584
|
+
method: 'DELETE',
|
|
1585
|
+
path: `${basePath}/{id}`,
|
|
1586
|
+
options: {
|
|
1587
|
+
tags: ['api'],
|
|
1588
|
+
description: `Delete ${modelName} by ID`,
|
|
1589
|
+
notes: `Auto-generated delete route for ${modelName}`
|
|
1590
|
+
},
|
|
1591
|
+
handler: async (request, h) => {
|
|
1592
|
+
try {
|
|
1593
|
+
const doc = await Model.findByIdAndDelete(request.params.id);
|
|
1594
|
+
if (!doc) return h.response({ error: "Not Found" }).code(404);
|
|
1595
|
+
return { message: "Deleted successfully", id: doc._id };
|
|
1596
|
+
} catch (err) {
|
|
1597
|
+
return h.response({ error: err.message }).code(500);
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
});
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
if (ops.includes('paginate')) {
|
|
1604
|
+
this.httpServer.route({
|
|
1605
|
+
method: 'GET',
|
|
1606
|
+
path: basePath,
|
|
1607
|
+
options: {
|
|
1608
|
+
tags: ['api'],
|
|
1609
|
+
description: `Paginate ${modelName}`,
|
|
1610
|
+
notes: `Auto-generated pagination route for ${modelName}`
|
|
1611
|
+
},
|
|
1612
|
+
handler: async (request, h) => {
|
|
1613
|
+
try {
|
|
1614
|
+
const page = parseInt(request.query.page) || 1;
|
|
1615
|
+
const limit = parseInt(request.query.limit) || 10;
|
|
1616
|
+
const skip = (page - 1) * limit;
|
|
1617
|
+
|
|
1618
|
+
// Basic filtering support can be added here if needed
|
|
1619
|
+
const docs = await Model.find({}).skip(skip).limit(limit);
|
|
1620
|
+
const total = await Model.countDocuments({});
|
|
1621
|
+
|
|
1622
|
+
return {
|
|
1623
|
+
data: docs,
|
|
1624
|
+
pagination: {
|
|
1625
|
+
page,
|
|
1626
|
+
limit,
|
|
1627
|
+
total,
|
|
1628
|
+
pages: Math.ceil(total / limit)
|
|
1629
|
+
}
|
|
1630
|
+
};
|
|
1631
|
+
} catch (err) {
|
|
1632
|
+
return h.response({ error: err.message }).code(500);
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
});
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
module.exports = Falcon;
|
|
1644
|
+
|