@seip/blue-bird 0.9.1 → 0.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +71 -1
- package/core/app.js +96 -15
- package/core/database.js +136 -0
- package/core/index.d.ts +132 -0
- package/core/router.js +33 -8
- package/core/ws.js +210 -0
- package/package.json +3 -1
package/README.md
CHANGED
|
@@ -293,11 +293,81 @@ const stats = await connection.query("SELECT COUNT(*) as count FROM access_logs"
|
|
|
293
293
|
|
|
294
294
|
// 3. INSERT query (returns insertId for MySQL, or inserted row ID / rowCount for PostgreSQL)
|
|
295
295
|
const newId = await connection.query("INSERT INTO users (name) VALUES (?)", ["John"]);
|
|
296
|
+
|
|
297
|
+
// 4. Automatic SQL Query Pagination (Runs count query + LIMIT/OFFSET calculation)
|
|
298
|
+
const paginated = await connection.paginate(
|
|
299
|
+
"SELECT * FROM users WHERE status = ?",
|
|
300
|
+
["active"],
|
|
301
|
+
{ page: 1, limit: 10, cache: 60 }
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
// 5. Atomic Database Transactions with Automatic Commit & Rollback
|
|
305
|
+
const txUserId = await connection.transaction(async (tx) => {
|
|
306
|
+
const userId = await tx.query("INSERT INTO users (name, email) VALUES (?, ?)", ["Alice", "alice@example.com"]);
|
|
307
|
+
await tx.query("INSERT INTO profiles (user_id) VALUES (?)", [userId]);
|
|
308
|
+
return userId;
|
|
309
|
+
});
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
---
|
|
313
|
+
|
|
314
|
+
### 8. Real-Time WebSockets Engine (`WebSocketManager`)
|
|
315
|
+
|
|
316
|
+
Built-in high-performance vanilla WebSocket server (`ws`) sharing the **exact same HTTP server and port as Express** (port 3000), supporting room subscriptions, JWT authentication, 30s heartbeat ping/pong, and Redis Pub/Sub cluster synchronization:
|
|
317
|
+
|
|
318
|
+
```javascript
|
|
319
|
+
import App from "@seip/blue-bird/core/app.js";
|
|
320
|
+
|
|
321
|
+
const app = new App({ ... });
|
|
322
|
+
|
|
323
|
+
// 1. Initialize WebSocket server on route /ws with optional JWT Auth check
|
|
324
|
+
const ws = app.websocket({ path: "/ws", auth: true });
|
|
325
|
+
|
|
326
|
+
ws.onConnection((socket, req) => {
|
|
327
|
+
socket.join("lobby");
|
|
328
|
+
socket.sendJSON({ status: "connected", user: socket.user });
|
|
329
|
+
|
|
330
|
+
socket.on("message", (raw) => {
|
|
331
|
+
// Broadcast to room (synced across PM2 cluster via Redis Pub/Sub)
|
|
332
|
+
ws.broadcast({ room: "lobby", text: raw.toString() }, "lobby");
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
app.run();
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
#### Broadcasting from Express API Routes:
|
|
340
|
+
```javascript
|
|
341
|
+
router.post("/api/comments", async (req, res) => {
|
|
342
|
+
// 1. Save comment into database...
|
|
343
|
+
const comment = { id: 1, text: req.body.text };
|
|
344
|
+
|
|
345
|
+
// 2. Broadcast in real time to all WebSocket clients in the "lobby" room
|
|
346
|
+
app.wsManager.broadcast({ type: "NEW_COMMENT", data: comment }, "lobby");
|
|
347
|
+
|
|
348
|
+
return res.success(comment, "Comment created");
|
|
349
|
+
});
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
#### Native Client Connection (Browser & Node.js v22+):
|
|
353
|
+
```javascript
|
|
354
|
+
// Native W3C Standard WebSocket Client (Browser & Node.js v22+)
|
|
355
|
+
const socket = new WebSocket("ws://localhost:3000/ws");
|
|
356
|
+
|
|
357
|
+
socket.addEventListener("open", () => {
|
|
358
|
+
console.log("Connected to WebSocket server!");
|
|
359
|
+
socket.send("Hello server from native client!");
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
socket.addEventListener("message", (event) => {
|
|
363
|
+
const data = JSON.parse(event.data);
|
|
364
|
+
console.log("Message received from server:", data);
|
|
365
|
+
});
|
|
296
366
|
```
|
|
297
367
|
|
|
298
368
|
---
|
|
299
369
|
|
|
300
|
-
###
|
|
370
|
+
### 9. Nginx Static Asset Caching
|
|
301
371
|
|
|
302
372
|
Nginx is configured to explicitly cache static assets (`.js`, `.css`, `.jpg`, `.png`, etc.) in the user's browser with the `Cache-Control` header (valid for 1 month). HTML and API endpoints (`/api/*`) are not cached by Nginx to ensure they serve dynamic and up-to-date content, relying instead on the Node.js application and Redis for data-layer caching.
|
|
303
373
|
|
package/core/app.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import http from "node:http";
|
|
1
2
|
import express from "express";
|
|
2
3
|
import cors from "cors";
|
|
3
4
|
import path from "path";
|
|
@@ -10,6 +11,7 @@ import compression from "compression";
|
|
|
10
11
|
import Config from "./config.js";
|
|
11
12
|
import Logger from "./logger.js";
|
|
12
13
|
import Debug from "./debug.js";
|
|
14
|
+
import WebSocketManager from "./ws.js";
|
|
13
15
|
|
|
14
16
|
const __dirname = Config.dirname();
|
|
15
17
|
const props = Config.props();
|
|
@@ -58,6 +60,8 @@ class App {
|
|
|
58
60
|
*/
|
|
59
61
|
constructor(options = {}) {
|
|
60
62
|
this.app = express();
|
|
63
|
+
this.server = http.createServer(this.app);
|
|
64
|
+
this.wsManager = null;
|
|
61
65
|
this.routes = options.routes || [];
|
|
62
66
|
this.cors = options.cors || {};
|
|
63
67
|
this.middlewares = options.middlewares || [];
|
|
@@ -114,6 +118,42 @@ class App {
|
|
|
114
118
|
this.app.use((req, res, next) => {
|
|
115
119
|
req.lang = req.query?.lang || req.body?.lang || req.cookies?.lang || "en";
|
|
116
120
|
res.locals.lang = req.lang;
|
|
121
|
+
|
|
122
|
+
res.success = (data = null, message = "Success", statusCode = 200) => {
|
|
123
|
+
return res.status(statusCode).json({
|
|
124
|
+
status: "success",
|
|
125
|
+
message,
|
|
126
|
+
data,
|
|
127
|
+
});
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
res.error = (message = "Error", statusCode = 400, errors = []) => {
|
|
131
|
+
return res.status(statusCode).json({
|
|
132
|
+
status: "error",
|
|
133
|
+
message,
|
|
134
|
+
errors,
|
|
135
|
+
});
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
res.paginate = (data = [], pagination = {}, message = "Success") => {
|
|
139
|
+
const page = Number(pagination.page) || 1;
|
|
140
|
+
const limit = Number(pagination.limit) || data.length;
|
|
141
|
+
const total = Number(pagination.total) || data.length;
|
|
142
|
+
const totalPages = limit > 0 ? Math.ceil(total / limit) : 1;
|
|
143
|
+
|
|
144
|
+
return res.status(200).json({
|
|
145
|
+
status: "success",
|
|
146
|
+
message,
|
|
147
|
+
data,
|
|
148
|
+
pagination: {
|
|
149
|
+
page,
|
|
150
|
+
limit,
|
|
151
|
+
total,
|
|
152
|
+
totalPages,
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
};
|
|
156
|
+
|
|
117
157
|
next();
|
|
118
158
|
});
|
|
119
159
|
|
|
@@ -241,25 +281,23 @@ class App {
|
|
|
241
281
|
*/
|
|
242
282
|
_errorHandler() {
|
|
243
283
|
this.app.use((err, req, res, next) => {
|
|
244
|
-
const
|
|
284
|
+
const statusCode = err.statusCode || err.status || 500;
|
|
245
285
|
const message = err.message || "Internal Server Error";
|
|
286
|
+
const errors = err.errors || [];
|
|
246
287
|
|
|
247
|
-
this.loggerInstance.error(`[${
|
|
288
|
+
this.loggerInstance.error(`[${statusCode}] ${message} - ${err.stack}`);
|
|
248
289
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
290
|
+
const responsePayload = {
|
|
291
|
+
status: "error",
|
|
292
|
+
message: statusCode === 500 && !props.debug ? "Internal Server Error" : message,
|
|
293
|
+
errors,
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
if (props.debug && err.stack) {
|
|
297
|
+
responsePayload.stack = err.stack;
|
|
256
298
|
}
|
|
257
299
|
|
|
258
|
-
return res.status(
|
|
259
|
-
success: false,
|
|
260
|
-
error: true,
|
|
261
|
-
message: status === 500 ? "Internal Server Error" : message,
|
|
262
|
-
});
|
|
300
|
+
return res.status(statusCode).json(responsePayload);
|
|
263
301
|
});
|
|
264
302
|
}
|
|
265
303
|
|
|
@@ -295,7 +333,7 @@ class App {
|
|
|
295
333
|
run() {
|
|
296
334
|
this._ready
|
|
297
335
|
.then(() => {
|
|
298
|
-
this.
|
|
336
|
+
this.server.listen(this.port, () => {
|
|
299
337
|
console.log(
|
|
300
338
|
chalk.bold.blue("Blue Bird Server Online\n") +
|
|
301
339
|
chalk.bold.cyan("App URL: ") +
|
|
@@ -318,6 +356,29 @@ class App {
|
|
|
318
356
|
});
|
|
319
357
|
}
|
|
320
358
|
|
|
359
|
+
/**
|
|
360
|
+
* Initializes and returns the WebSocket manager attached to the Express HTTP server instance.
|
|
361
|
+
* @param {Function|Object} [options] - Connection callback handler: (ws, req) => {} or options object.
|
|
362
|
+
* @returns {WebSocketManager}
|
|
363
|
+
* @example
|
|
364
|
+
* app.websocket((ws, req) => {
|
|
365
|
+
* ws.join("chat");
|
|
366
|
+
* ws.sendJSON({ message: "Welcome to Blue Bird WebSockets" });
|
|
367
|
+
* });
|
|
368
|
+
*/
|
|
369
|
+
websocket(options = {}) {
|
|
370
|
+
const handler = typeof options === "function" ? options : null;
|
|
371
|
+
const wsOptions = typeof options === "object" && options !== null ? options : {};
|
|
372
|
+
|
|
373
|
+
if (!this.wsManager) {
|
|
374
|
+
this.wsManager = new WebSocketManager(this.server, wsOptions);
|
|
375
|
+
}
|
|
376
|
+
if (handler) {
|
|
377
|
+
this.wsManager.onConnection(handler);
|
|
378
|
+
}
|
|
379
|
+
return this.wsManager;
|
|
380
|
+
}
|
|
381
|
+
|
|
321
382
|
/**
|
|
322
383
|
* Returns a pre-configured Helmet middleware for use on specific routers.
|
|
323
384
|
* @param {Object} [options={}] - Helmet options to override defaults.
|
|
@@ -335,4 +396,24 @@ class App {
|
|
|
335
396
|
}
|
|
336
397
|
}
|
|
337
398
|
|
|
399
|
+
/**
|
|
400
|
+
* Operational application error class for standardizing custom API errors.
|
|
401
|
+
*/
|
|
402
|
+
export class AppError extends Error {
|
|
403
|
+
/**
|
|
404
|
+
* Creates an AppError instance.
|
|
405
|
+
* @param {string} message - Error message description.
|
|
406
|
+
* @param {number} [statusCode=500] - HTTP status code.
|
|
407
|
+
* @param {Array|Object} [errors=[]] - Array or object of detailed errors.
|
|
408
|
+
*/
|
|
409
|
+
constructor(message, statusCode = 500, errors = []) {
|
|
410
|
+
super(message);
|
|
411
|
+
this.name = "AppError";
|
|
412
|
+
this.statusCode = statusCode;
|
|
413
|
+
this.errors = errors;
|
|
414
|
+
this.isOperational = true;
|
|
415
|
+
Error.captureStackTrace(this, this.constructor);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
338
419
|
export default App;
|
package/core/database.js
CHANGED
|
@@ -258,6 +258,142 @@ class Database {
|
|
|
258
258
|
throw err;
|
|
259
259
|
}
|
|
260
260
|
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Executes a paginated SQL query.
|
|
264
|
+
* Runs an automatic count query to calculate total records and pages, then appends LIMIT and OFFSET.
|
|
265
|
+
*
|
|
266
|
+
* @param {string} sql - SQL query string.
|
|
267
|
+
* @param {Array} [params=[]] - Query parameters.
|
|
268
|
+
* @param {Object} [options={}] - Pagination options: page, limit, cache.
|
|
269
|
+
* @returns {Promise<{data: Array, total: number, page: number, limit: number, totalPages: number}>}
|
|
270
|
+
* @example const result = await connection.paginate("SELECT * FROM users WHERE status = ?", ["active"], { page: 1, limit: 10 });
|
|
271
|
+
*/
|
|
272
|
+
async paginate(sql, params = [], options = {}) {
|
|
273
|
+
const page = Math.max(1, parseInt(options.page) || 1);
|
|
274
|
+
const limit = Math.max(1, parseInt(options.limit) || 10);
|
|
275
|
+
const offset = (page - 1) * limit;
|
|
276
|
+
|
|
277
|
+
const cleanSql = sql.trim().replace(/;$/, "");
|
|
278
|
+
const countSql = `SELECT COUNT(*) as total FROM (${cleanSql}) as _count_subquery`;
|
|
279
|
+
|
|
280
|
+
const countResult = await this.query(countSql, params, { return_row: true });
|
|
281
|
+
const total = Number(countResult?.total || countResult?.count || 0);
|
|
282
|
+
const totalPages = Math.ceil(total / limit);
|
|
283
|
+
|
|
284
|
+
const paginatedSql = `${cleanSql} LIMIT ${limit} OFFSET ${offset}`;
|
|
285
|
+
const rows = await this.query(paginatedSql, params, options);
|
|
286
|
+
|
|
287
|
+
return {
|
|
288
|
+
data: Array.isArray(rows) ? rows : [],
|
|
289
|
+
total,
|
|
290
|
+
page,
|
|
291
|
+
limit,
|
|
292
|
+
totalPages,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Executes a database transaction with automatic commit and rollback.
|
|
298
|
+
* @param {Function} callback - Async function receiving transaction client: async (tx) => { ... }
|
|
299
|
+
* @returns {Promise<*>} Value returned from callback.
|
|
300
|
+
* @example
|
|
301
|
+
* const userId = await connection.transaction(async (tx) => {
|
|
302
|
+
* const id = await tx.query("INSERT INTO users (name) VALUES (?)", ["Alice"]);
|
|
303
|
+
* await tx.query("INSERT INTO profiles (user_id) VALUES (?)", [id]);
|
|
304
|
+
* return id;
|
|
305
|
+
* });
|
|
306
|
+
*/
|
|
307
|
+
async transaction(callback) {
|
|
308
|
+
if (!mysqlPromise && !pgPromise) throw new Error("[DATABASE ERROR] No database driver available.");
|
|
309
|
+
if (!this.pool) {
|
|
310
|
+
const initialized = await this.init();
|
|
311
|
+
if (!initialized) throw new Error("[DATABASE ERROR] Failed to initialize database pool.");
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (this.type === "postgres" && pgPromise) {
|
|
315
|
+
const client = await this.pool.connect();
|
|
316
|
+
try {
|
|
317
|
+
await client.query("BEGIN");
|
|
318
|
+
|
|
319
|
+
const tx = {
|
|
320
|
+
query: async (sql, params = [], options = {}) => {
|
|
321
|
+
const queryOptions = typeof options === "string" ? { [options]: true } : options;
|
|
322
|
+
const cleanSql = sql.trim();
|
|
323
|
+
const isSelect = cleanSql.toLowerCase().startsWith("select");
|
|
324
|
+
const isInsert = cleanSql.toLowerCase().startsWith("insert");
|
|
325
|
+
|
|
326
|
+
let paramIndex = 1;
|
|
327
|
+
const pgSql = cleanSql.replace(/\?/g, () => `$${paramIndex++}`);
|
|
328
|
+
const res = await client.query(pgSql, params);
|
|
329
|
+
|
|
330
|
+
if (isSelect) {
|
|
331
|
+
const rows = res.rows || [];
|
|
332
|
+
return queryOptions.return_row ? (rows[0] || null) : rows;
|
|
333
|
+
}
|
|
334
|
+
if (isInsert) {
|
|
335
|
+
if (res.rows && res.rows.length > 0) return res.rows[0].id || res.rows[0];
|
|
336
|
+
return res.rowCount;
|
|
337
|
+
}
|
|
338
|
+
return res.rowCount;
|
|
339
|
+
}
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
const result = await callback(tx);
|
|
343
|
+
await client.query("COMMIT");
|
|
344
|
+
return result;
|
|
345
|
+
} catch (err) {
|
|
346
|
+
await client.query("ROLLBACK").catch(() => {});
|
|
347
|
+
console.error("[DATABASE ERROR] Transaction rolled back:", err.message);
|
|
348
|
+
throw err;
|
|
349
|
+
} finally {
|
|
350
|
+
client.release();
|
|
351
|
+
}
|
|
352
|
+
} else {
|
|
353
|
+
const connection = await this.pool.getConnection();
|
|
354
|
+
try {
|
|
355
|
+
await connection.beginTransaction();
|
|
356
|
+
|
|
357
|
+
const tx = {
|
|
358
|
+
query: async (sql, params = [], options = {}) => {
|
|
359
|
+
const queryOptions = typeof options === "string" ? { [options]: true } : options;
|
|
360
|
+
const cleanSql = sql.trim();
|
|
361
|
+
const isSelect = cleanSql.toLowerCase().startsWith("select");
|
|
362
|
+
const isInsert = cleanSql.toLowerCase().startsWith("insert");
|
|
363
|
+
|
|
364
|
+
const [results] = await connection.execute(cleanSql, params);
|
|
365
|
+
|
|
366
|
+
if (isSelect) {
|
|
367
|
+
const rows = Array.isArray(results) ? results : [];
|
|
368
|
+
return queryOptions.return_row ? (rows[0] || null) : rows;
|
|
369
|
+
}
|
|
370
|
+
if (isInsert) {
|
|
371
|
+
return results.insertId || results;
|
|
372
|
+
}
|
|
373
|
+
return results;
|
|
374
|
+
}
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
const result = await callback(tx);
|
|
378
|
+
await connection.commit();
|
|
379
|
+
return result;
|
|
380
|
+
} catch (err) {
|
|
381
|
+
await connection.rollback().catch(() => {});
|
|
382
|
+
console.error("[DATABASE ERROR] Transaction rolled back:", err.message);
|
|
383
|
+
throw err;
|
|
384
|
+
} finally {
|
|
385
|
+
connection.release();
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Alias for transaction().
|
|
392
|
+
* @param {Function} callback
|
|
393
|
+
*/
|
|
394
|
+
async executeTransaction(callback) {
|
|
395
|
+
return this.transaction(callback);
|
|
396
|
+
}
|
|
261
397
|
}
|
|
262
398
|
|
|
263
399
|
export { Database, DB_TYPE };
|
package/core/index.d.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { Router as ExpressRouter, Request, Response, NextFunction } from "express";
|
|
2
|
+
|
|
3
|
+
declare global {
|
|
4
|
+
namespace Express {
|
|
5
|
+
interface Response {
|
|
6
|
+
/**
|
|
7
|
+
* Sends a standardized JSON success response.
|
|
8
|
+
* @param data Data payload to return.
|
|
9
|
+
* @param message Success message.
|
|
10
|
+
* @param statusCode HTTP status code (default: 200).
|
|
11
|
+
*/
|
|
12
|
+
success(data?: any, message?: string, statusCode?: number): Response;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Sends a standardized JSON error response.
|
|
16
|
+
* @param message Error message.
|
|
17
|
+
* @param statusCode HTTP status code (default: 400).
|
|
18
|
+
* @param errors Array or object of detailed errors.
|
|
19
|
+
*/
|
|
20
|
+
error(message?: string, statusCode?: number, errors?: any): Response;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Sends a standardized paginated JSON response.
|
|
24
|
+
* @param data Array of records for current page.
|
|
25
|
+
* @param pagination Object containing page, limit, and total count.
|
|
26
|
+
* @param message Success message.
|
|
27
|
+
*/
|
|
28
|
+
paginate(
|
|
29
|
+
data?: any[],
|
|
30
|
+
pagination?: { page?: number; limit?: number; total?: number },
|
|
31
|
+
message?: string
|
|
32
|
+
): Response;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class AppError extends Error {
|
|
38
|
+
statusCode: number;
|
|
39
|
+
errors: any;
|
|
40
|
+
isOperational: boolean;
|
|
41
|
+
|
|
42
|
+
constructor(message: string, statusCode?: number, errors?: any);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export class App {
|
|
46
|
+
constructor(options?: {
|
|
47
|
+
routes?: any[];
|
|
48
|
+
cors?: any;
|
|
49
|
+
middlewares?: any[];
|
|
50
|
+
port?: number | string;
|
|
51
|
+
host?: string;
|
|
52
|
+
logger?: boolean;
|
|
53
|
+
notFound?: boolean;
|
|
54
|
+
json?: boolean;
|
|
55
|
+
urlencoded?: boolean;
|
|
56
|
+
static?: { path: string; options?: any };
|
|
57
|
+
cookieParser?: boolean;
|
|
58
|
+
rateLimit?: boolean | any;
|
|
59
|
+
swagger?: boolean | any;
|
|
60
|
+
compression?: boolean;
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
use(record: any): void;
|
|
64
|
+
set(key: string, value: any): void;
|
|
65
|
+
websocket(
|
|
66
|
+
options?:
|
|
67
|
+
| ((ws: any, req: any) => void)
|
|
68
|
+
| { path?: string; auth?: boolean }
|
|
69
|
+
): WebSocketManager;
|
|
70
|
+
run(): void;
|
|
71
|
+
|
|
72
|
+
static helmet(options?: any): any;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export class WebSocketManager {
|
|
76
|
+
constructor(server: any, options?: { path?: string; auth?: boolean });
|
|
77
|
+
onConnection(handler: (ws: any, req: any) => void): void;
|
|
78
|
+
join(room: string, ws: any): void;
|
|
79
|
+
leave(room: string, ws: any): void;
|
|
80
|
+
broadcast(data: any, room?: string | null): void;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export class Router {
|
|
84
|
+
constructor(path?: string, options?: { seo?: boolean; languages?: string[] });
|
|
85
|
+
|
|
86
|
+
use(...middleware: any[]): void;
|
|
87
|
+
get(path: string | RegExp, ...callback: any[]): void;
|
|
88
|
+
post(path: string | RegExp, ...callback: any[]): void;
|
|
89
|
+
put(path: string | RegExp, ...callback: any[]): void;
|
|
90
|
+
delete(path: string | RegExp, ...callback: any[]): void;
|
|
91
|
+
patch(path: string | RegExp, ...callback: any[]): void;
|
|
92
|
+
options(path: string | RegExp, ...callback: any[]): void;
|
|
93
|
+
getRouter(): ExpressRouter;
|
|
94
|
+
getPath(): string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export class Validator {
|
|
98
|
+
constructor(schema: Record<string, any>, lang?: string);
|
|
99
|
+
middleware(): (req: Request, res: Response, next: NextFunction) => void;
|
|
100
|
+
validate(data: Record<string, any>): { valid: boolean; errors: any[] };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export class Auth {
|
|
104
|
+
static encrypt(payload: any, secret: string): string;
|
|
105
|
+
static decrypt(data: string, secret: string): any;
|
|
106
|
+
static generateToken(payload: any, secret?: string, expiresIn?: string | number): string;
|
|
107
|
+
static verifyToken(token: string, secret?: string): any;
|
|
108
|
+
static protect(options?: { redirect?: string | null; key?: string; cookieKey?: string }): (req: Request, res: Response, next: NextFunction) => Promise<any>;
|
|
109
|
+
static login(res: Response, data: any, key?: string, options?: { expiresIn?: string | number; cookie?: any }): Promise<string>;
|
|
110
|
+
static logout(res: Response, key?: string, options?: any, req?: Request): Promise<boolean>;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export class Cache {
|
|
114
|
+
static middleware(seconds?: number): (req: Request, res: Response, next: NextFunction) => Promise<any>;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function getRedisClient(): any;
|
|
118
|
+
|
|
119
|
+
export class Database {
|
|
120
|
+
constructor(connectionLimit?: number, queueLimit?: number, config?: any);
|
|
121
|
+
init(retries?: number): Promise<boolean>;
|
|
122
|
+
query(sql: string, params?: any[], options?: any): Promise<any>;
|
|
123
|
+
paginate(
|
|
124
|
+
sql: string,
|
|
125
|
+
params?: any[],
|
|
126
|
+
options?: { page?: number; limit?: number; cache?: number }
|
|
127
|
+
): Promise<{ data: any[]; total: number; page: number; limit: number; totalPages: number }>;
|
|
128
|
+
transaction<T = any>(callback: (tx: { query: (sql: string, params?: any[], options?: any) => Promise<any> }) => Promise<T>): Promise<T>;
|
|
129
|
+
executeTransaction<T = any>(callback: (tx: { query: (sql: string, params?: any[], options?: any) => Promise<any> }) => Promise<T>): Promise<T>;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export default App;
|
package/core/router.js
CHANGED
|
@@ -4,6 +4,25 @@ import Config from "./config.js";
|
|
|
4
4
|
|
|
5
5
|
const props = Config.props();
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Wraps route handlers to automatically catch async rejections and forward them to next(err).
|
|
9
|
+
* @param {Function} fn - Handler function.
|
|
10
|
+
* @returns {Function} Wrapped handler.
|
|
11
|
+
*/
|
|
12
|
+
function wrapAsync(fn) {
|
|
13
|
+
if (typeof fn !== "function") return fn;
|
|
14
|
+
return (req, res, next) => {
|
|
15
|
+
try {
|
|
16
|
+
const result = fn(req, res, next);
|
|
17
|
+
if (result && typeof result.catch === "function") {
|
|
18
|
+
result.catch(next);
|
|
19
|
+
}
|
|
20
|
+
} catch (err) {
|
|
21
|
+
next(err);
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
7
26
|
/**
|
|
8
27
|
* Router wrapper class for handling Express routing logic.
|
|
9
28
|
* When created with { seo: true }, all GET routes registered on this router
|
|
@@ -37,7 +56,8 @@ class Router {
|
|
|
37
56
|
* router.use(App.helmet());
|
|
38
57
|
*/
|
|
39
58
|
use(...middleware) {
|
|
40
|
-
|
|
59
|
+
const handlers = middleware.map(wrapAsync);
|
|
60
|
+
this.router.use(...handlers);
|
|
41
61
|
}
|
|
42
62
|
|
|
43
63
|
/**
|
|
@@ -57,8 +77,8 @@ class Router {
|
|
|
57
77
|
if (path === "/*" || path === "*") {
|
|
58
78
|
path = /.*/;
|
|
59
79
|
}
|
|
60
|
-
|
|
61
|
-
this.router.get(path,
|
|
80
|
+
const handlers = callback.map(wrapAsync);
|
|
81
|
+
this.router.get(path, ...handlers);
|
|
62
82
|
}
|
|
63
83
|
|
|
64
84
|
/**
|
|
@@ -74,7 +94,8 @@ class Router {
|
|
|
74
94
|
if (path === "/*" || path === "*") {
|
|
75
95
|
path = /.*/;
|
|
76
96
|
}
|
|
77
|
-
|
|
97
|
+
const handlers = callback.map(wrapAsync);
|
|
98
|
+
this.router.post(path, ...handlers);
|
|
78
99
|
}
|
|
79
100
|
|
|
80
101
|
/**
|
|
@@ -87,7 +108,8 @@ class Router {
|
|
|
87
108
|
* })
|
|
88
109
|
*/
|
|
89
110
|
put(path, ...callback) {
|
|
90
|
-
|
|
111
|
+
const handlers = callback.map(wrapAsync);
|
|
112
|
+
this.router.put(path, ...handlers);
|
|
91
113
|
}
|
|
92
114
|
|
|
93
115
|
/**
|
|
@@ -100,7 +122,8 @@ class Router {
|
|
|
100
122
|
* })
|
|
101
123
|
*/
|
|
102
124
|
delete(path, ...callback) {
|
|
103
|
-
|
|
125
|
+
const handlers = callback.map(wrapAsync);
|
|
126
|
+
this.router.delete(path, ...handlers);
|
|
104
127
|
}
|
|
105
128
|
|
|
106
129
|
/**
|
|
@@ -113,7 +136,8 @@ class Router {
|
|
|
113
136
|
* })
|
|
114
137
|
*/
|
|
115
138
|
patch(path, ...callback) {
|
|
116
|
-
|
|
139
|
+
const handlers = callback.map(wrapAsync);
|
|
140
|
+
this.router.patch(path, ...handlers);
|
|
117
141
|
}
|
|
118
142
|
|
|
119
143
|
/**
|
|
@@ -122,7 +146,8 @@ class Router {
|
|
|
122
146
|
* @param {...Function} callback - One or more handler functions (middlewares and controller).
|
|
123
147
|
*/
|
|
124
148
|
options(path, ...callback) {
|
|
125
|
-
|
|
149
|
+
const handlers = callback.map(wrapAsync);
|
|
150
|
+
this.router.options(path, ...handlers);
|
|
126
151
|
}
|
|
127
152
|
|
|
128
153
|
/**
|
package/core/ws.js
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { WebSocketServer, WebSocket } from "ws";
|
|
2
|
+
import { getRedisClient } from "./cache.js";
|
|
3
|
+
import Auth from "./auth.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* High-performance WebSocketManager providing real-time channels, room management,
|
|
7
|
+
* heartbeat ping/pong, Auth token verification, and Redis Pub/Sub multi-process cluster scaling.
|
|
8
|
+
*/
|
|
9
|
+
class WebSocketManager {
|
|
10
|
+
/**
|
|
11
|
+
* Initializes the WebSocket manager attached to an HTTP server.
|
|
12
|
+
* @param {import('http').Server} server - Express HTTP server instance.
|
|
13
|
+
* @param {Object} [options={}] - Configuration options.
|
|
14
|
+
* @param {string} [options.path="/ws"] - WebSocket endpoint route.
|
|
15
|
+
* @param {boolean} [options.auth=false] - Require valid Auth JWT token on connection.
|
|
16
|
+
*/
|
|
17
|
+
constructor(server, options = {}) {
|
|
18
|
+
this.server = server;
|
|
19
|
+
this.path = options.path || "/ws";
|
|
20
|
+
this.requireAuth = options.auth ?? false;
|
|
21
|
+
this.rooms = new Map();
|
|
22
|
+
this.clients = new Set();
|
|
23
|
+
this.connectionHandler = null;
|
|
24
|
+
this.redisPublisher = null;
|
|
25
|
+
this.redisSubscriber = null;
|
|
26
|
+
|
|
27
|
+
this.wss = new WebSocketServer({ noServer: true });
|
|
28
|
+
|
|
29
|
+
this._setupUpgrade();
|
|
30
|
+
this._setupHeartbeat();
|
|
31
|
+
this._setupRedisPubSub();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Attaches the HTTP Upgrade listener to the Express HTTP server instance.
|
|
36
|
+
* @private
|
|
37
|
+
*/
|
|
38
|
+
_setupUpgrade() {
|
|
39
|
+
this.server.on("upgrade", async (request, socket, head) => {
|
|
40
|
+
const urlObj = new URL(request.url, `http://${request.headers.host || "localhost"}`);
|
|
41
|
+
if (urlObj.pathname !== this.path) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (this.requireAuth) {
|
|
46
|
+
const cookieHeader = request.headers.cookie || "";
|
|
47
|
+
const cookies = {};
|
|
48
|
+
cookieHeader.split(";").forEach((c) => {
|
|
49
|
+
const parts = c.trim().split("=");
|
|
50
|
+
if (parts[0]) cookies[parts[0]] = decodeURIComponent(parts[1] || "");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const token = cookies.auth || urlObj.searchParams.get("token") || request.headers.authorization?.split(" ")[1];
|
|
54
|
+
const user = token ? Auth.verifyToken(token) : null;
|
|
55
|
+
|
|
56
|
+
if (!user) {
|
|
57
|
+
socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
|
|
58
|
+
socket.destroy();
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
request.user = user;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
this.wss.handleUpgrade(request, socket, head, (ws) => {
|
|
65
|
+
this.wss.emit("connection", ws, request);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
this.wss.on("connection", (ws, req) => {
|
|
70
|
+
ws.isAlive = true;
|
|
71
|
+
ws.user = req.user || null;
|
|
72
|
+
ws.rooms = new Set();
|
|
73
|
+
this.clients.add(ws);
|
|
74
|
+
|
|
75
|
+
ws.on("pong", () => {
|
|
76
|
+
ws.isAlive = true;
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
ws.on("close", () => {
|
|
80
|
+
this.clients.delete(ws);
|
|
81
|
+
ws.rooms.forEach((room) => this.leave(room, ws));
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
ws.on("error", () => {
|
|
85
|
+
this.clients.delete(ws);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
ws.join = (room) => this.join(room, ws);
|
|
89
|
+
ws.leave = (room) => this.leave(room, ws);
|
|
90
|
+
ws.sendJSON = (data) => {
|
|
91
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
92
|
+
ws.send(JSON.stringify(data));
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
if (this.connectionHandler) {
|
|
97
|
+
this.connectionHandler(ws, req);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Registers a connection callback handler.
|
|
104
|
+
* @param {Function} handler - Callback function: (ws, req) => {}
|
|
105
|
+
*/
|
|
106
|
+
onConnection(handler) {
|
|
107
|
+
this.connectionHandler = handler;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Subscribes a WebSocket connection to a room.
|
|
112
|
+
* @param {string} room - Room identifier.
|
|
113
|
+
* @param {WebSocket} ws - Target WebSocket instance.
|
|
114
|
+
*/
|
|
115
|
+
join(room, ws) {
|
|
116
|
+
if (!this.rooms.has(room)) {
|
|
117
|
+
this.rooms.set(room, new Set());
|
|
118
|
+
}
|
|
119
|
+
this.rooms.get(room).add(ws);
|
|
120
|
+
ws.rooms.add(room);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Unsubscribes a WebSocket connection from a room.
|
|
125
|
+
* @param {string} room - Room identifier.
|
|
126
|
+
* @param {WebSocket} ws - Target WebSocket instance.
|
|
127
|
+
*/
|
|
128
|
+
leave(room, ws) {
|
|
129
|
+
if (this.rooms.has(room)) {
|
|
130
|
+
this.rooms.get(room).delete(ws);
|
|
131
|
+
if (this.rooms.get(room).size === 0) {
|
|
132
|
+
this.rooms.delete(room);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
ws.rooms.delete(room);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Broadcasts a JSON payload or string to all connected clients (or specific room).
|
|
140
|
+
* Automatically synchronizes across PM2 cluster workers via Redis Pub/Sub if Redis is active.
|
|
141
|
+
* @param {any} data - Data to send.
|
|
142
|
+
* @param {string} [room=null] - Optional target room.
|
|
143
|
+
*/
|
|
144
|
+
broadcast(data, room = null) {
|
|
145
|
+
const payload = typeof data === "string" ? data : JSON.stringify(data);
|
|
146
|
+
|
|
147
|
+
this._sendLocal(payload, room);
|
|
148
|
+
|
|
149
|
+
if (this.redisPublisher && this.redisPublisher.isOpen) {
|
|
150
|
+
this.redisPublisher.publish("bluebird:ws:broadcast", JSON.stringify({ room, payload })).catch(() => {});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Transmits payload to local WebSocket connections.
|
|
156
|
+
* @private
|
|
157
|
+
*/
|
|
158
|
+
_sendLocal(payload, room = null) {
|
|
159
|
+
const targetSockets = room && this.rooms.has(room) ? this.rooms.get(room) : this.clients;
|
|
160
|
+
targetSockets.forEach((client) => {
|
|
161
|
+
if (client.readyState === WebSocket.OPEN) {
|
|
162
|
+
client.send(payload);
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Sets up 30s ping/pong heartbeat to clear dead TCP sockets.
|
|
169
|
+
* @private
|
|
170
|
+
*/
|
|
171
|
+
_setupHeartbeat() {
|
|
172
|
+
this.heartbeatInterval = setInterval(() => {
|
|
173
|
+
this.clients.forEach((ws) => {
|
|
174
|
+
if (ws.isAlive === false) {
|
|
175
|
+
this.clients.delete(ws);
|
|
176
|
+
return ws.terminate();
|
|
177
|
+
}
|
|
178
|
+
ws.isAlive = false;
|
|
179
|
+
ws.ping();
|
|
180
|
+
});
|
|
181
|
+
}, 30000);
|
|
182
|
+
this.heartbeatInterval.unref();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Initializes Redis Pub/Sub channels for multi-process PM2 cluster synchronization.
|
|
187
|
+
* @private
|
|
188
|
+
*/
|
|
189
|
+
async _setupRedisPubSub() {
|
|
190
|
+
try {
|
|
191
|
+
const redisClient = getRedisClient();
|
|
192
|
+
if (!redisClient) return;
|
|
193
|
+
|
|
194
|
+
this.redisPublisher = redisClient.duplicate();
|
|
195
|
+
this.redisSubscriber = redisClient.duplicate();
|
|
196
|
+
|
|
197
|
+
await this.redisPublisher.connect();
|
|
198
|
+
await this.redisSubscriber.connect();
|
|
199
|
+
|
|
200
|
+
await this.redisSubscriber.subscribe("bluebird:ws:broadcast", (message) => {
|
|
201
|
+
try {
|
|
202
|
+
const { room, payload } = JSON.parse(message);
|
|
203
|
+
this._sendLocal(payload, room);
|
|
204
|
+
} catch {}
|
|
205
|
+
});
|
|
206
|
+
} catch {}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export default WebSocketManager;
|
package/package.json
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@seip/blue-bird",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.2",
|
|
4
4
|
"description": "Express opinionated API framework with built-in JWT auth, validation, and caching",
|
|
5
5
|
"type": "module",
|
|
6
|
+
"types": "core/index.d.ts",
|
|
6
7
|
"exports": {
|
|
7
8
|
"./*": "./*"
|
|
8
9
|
},
|
|
@@ -59,6 +60,7 @@
|
|
|
59
60
|
"jsonwebtoken": "^9.0.2",
|
|
60
61
|
"multer": "^2.0.2",
|
|
61
62
|
"redis": "^6.1.0",
|
|
63
|
+
"ws": "^8.18.0",
|
|
62
64
|
"xss": "^1.0.15"
|
|
63
65
|
}
|
|
64
66
|
}
|