@seip/blue-bird 0.9.0 → 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 +155 -20
- package/core/app.js +96 -15
- package/core/cli/docker.js +343 -25
- 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/docker/nginx.conf +1 -0
- package/package.json +3 -1
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/docker/nginx.conf
CHANGED
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
|
}
|