@seip/blue-bird 0.7.1 → 0.7.4
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/AGENTS.md +50 -0
- package/README.md +43 -0
- package/backend/logs/2026-07-14/info.log +48 -0
- package/backend/routes/api.js +2 -2
- package/core/auth.js +84 -8
- package/core/cache.js +14 -0
- package/core/cli/docker.js +13 -1
- package/core/database.js +182 -0
- package/core/logger.js +35 -19
- package/docker/nginx.conf +23 -0
- package/index.js +3 -1
- package/package.json +2 -1
package/AGENTS.md
CHANGED
|
@@ -176,6 +176,7 @@ npx blue-bird docker ps # Shows status of active containers
|
|
|
176
176
|
npx blue-bird docker logs # Tails Node.js app container logs
|
|
177
177
|
npx blue-bird docker pm2 [args] # Runs PM2 commands inside the app container (e.g. status, monit)
|
|
178
178
|
npx blue-bird docker mysql # Runs interactive MySQL client terminal inside the container
|
|
179
|
+
npx blue-bird docker redis # Runs interactive Redis client terminal inside the container
|
|
179
180
|
npx blue-bird docker prune # Cleans unused volumes, dangling images, and BuildKit caches
|
|
180
181
|
```
|
|
181
182
|
|
|
@@ -188,4 +189,53 @@ The container names and virtual networks are namespaced by the `TITLE` environme
|
|
|
188
189
|
3. **Magic Imports**: Stick to pure relative imports or well-configured aliases (imports natively resolve from `@seip/blue-bird/...` or relative directories like `../../`).
|
|
189
190
|
4. **No inline comments**: Only use JSDoc for documentation.
|
|
190
191
|
|
|
192
|
+
## 10. Database Module (database.js)
|
|
193
|
+
|
|
194
|
+
Blue Bird provides a unified wrapper class for MySQL databases via `mysql2` connections pool with automatic retries and built-in query caching:
|
|
195
|
+
|
|
196
|
+
```javascript
|
|
197
|
+
import connection from "@seip/blue-bird/core/database.js";
|
|
198
|
+
|
|
199
|
+
// Basic SELECT query returning single row
|
|
200
|
+
const user = await connection.query("SELECT * FROM users WHERE id = ?", [1], "return_row");
|
|
201
|
+
|
|
202
|
+
// Query caching in Redis (stores results in Redis for 60 seconds)
|
|
203
|
+
const stats = await connection.query("SELECT COUNT(*) as cnt FROM logs", [], { cache: 60 });
|
|
204
|
+
|
|
205
|
+
// INSERT query returns insertId directly
|
|
206
|
+
const newUserId = await connection.query("INSERT INTO users (name) VALUES (?)", ["Alice"]);
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
## 11. Nginx Proxy Caching
|
|
210
|
+
|
|
211
|
+
In production, Nginx caches Astro page responses for 10 seconds. Requests with an active session cookie (`auth`) or `Authorization` header bypass the cache to ensure dynamic page personalized output.
|
|
212
|
+
|
|
213
|
+
### Disabling Cache
|
|
214
|
+
|
|
215
|
+
To disable Nginx proxy caching, comment out the `proxy_cache` directives in `docker/nginx.conf`:
|
|
216
|
+
|
|
217
|
+
```nginx
|
|
218
|
+
# proxy_cache astro_cache;
|
|
219
|
+
# proxy_cache_valid 200 302 10s;
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
### Caching API routes
|
|
223
|
+
|
|
224
|
+
To cache specific GET API routes at the proxy layer (which is faster and consumes less resources than Node/Redis query caching), define a specific location block in `docker/nginx.conf` before the generic `/api/` routing rule:
|
|
225
|
+
|
|
226
|
+
```nginx
|
|
227
|
+
location /api/cached-endpoint {
|
|
228
|
+
limit_req zone=bluebird_limit burst=20 nodelay;
|
|
229
|
+
set $upstream_target http://app:3000;
|
|
230
|
+
proxy_pass $upstream_target;
|
|
231
|
+
proxy_http_version 1.1;
|
|
232
|
+
proxy_set_header Connection "";
|
|
233
|
+
proxy_set_header Host $host;
|
|
234
|
+
|
|
235
|
+
proxy_cache astro_cache;
|
|
236
|
+
proxy_cache_valid 200 10s;
|
|
237
|
+
add_header X-Cache-Status $upstream_cache_status;
|
|
238
|
+
}
|
|
239
|
+
```
|
|
240
|
+
|
|
191
241
|
_This file can be retrieved by intelligent agents reading its absolute physical path during reasoning._
|
package/README.md
CHANGED
|
@@ -218,6 +218,48 @@ webRouter.use(App.helmet());
|
|
|
218
218
|
|
|
219
219
|
---
|
|
220
220
|
|
|
221
|
+
### 7. Database wrapper (`Database`)
|
|
222
|
+
|
|
223
|
+
MySQL database client connection pool configuration featuring automated retry loops, query formatting utilities, and Redis query caching.
|
|
224
|
+
|
|
225
|
+
```javascript
|
|
226
|
+
import connection from "@seip/blue-bird/core/database.js";
|
|
227
|
+
|
|
228
|
+
// Fetch single row from a SELECT query
|
|
229
|
+
const user = await connection.query("SELECT * FROM users WHERE email = ?", ["test@example.com"], "return_row");
|
|
230
|
+
|
|
231
|
+
// Fetch rows with 60 seconds Redis caching enabled
|
|
232
|
+
const stats = await connection.query("SELECT COUNT(*) as count FROM access_logs", [], { cache: 60 });
|
|
233
|
+
|
|
234
|
+
// INSERT queries return the last insert ID directly
|
|
235
|
+
const newId = await connection.query("INSERT INTO users (name) VALUES (?)", ["John"]);
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
---
|
|
239
|
+
|
|
240
|
+
### 8. Nginx Proxy Caching
|
|
241
|
+
|
|
242
|
+
Nginx reverse proxy is preconfigured with a page cache zone (`astro_cache`) that stores public page outputs (Astro SSR/SSG) for 10 seconds.
|
|
243
|
+
- **Cache Bypass:** Requests with an `auth` cookie or `Authorization` header automatically bypass the cache to ensure dynamic page outputs.
|
|
244
|
+
- **Disabling:** Caching can be turned off in `docker/nginx.conf` by commenting out the `proxy_cache` directives.
|
|
245
|
+
- **Caching API routes:** If you want Nginx to cache GET endpoints from `/api/` directly (which is much faster than Node query/redis caching), add a matching location block inside `docker/nginx.conf` before the generic `/api/` block:
|
|
246
|
+
```nginx
|
|
247
|
+
location /api/cached-stats {
|
|
248
|
+
limit_req zone=bluebird_limit burst=20 nodelay;
|
|
249
|
+
set $upstream_target http://app:3000;
|
|
250
|
+
proxy_pass $upstream_target;
|
|
251
|
+
proxy_http_version 1.1;
|
|
252
|
+
proxy_set_header Connection "";
|
|
253
|
+
proxy_set_header Host $host;
|
|
254
|
+
|
|
255
|
+
proxy_cache astro_cache;
|
|
256
|
+
proxy_cache_valid 200 10s;
|
|
257
|
+
add_header X-Cache-Status $upstream_cache_status;
|
|
258
|
+
}
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
---
|
|
262
|
+
|
|
221
263
|
## 🐳 Docker CLI Workflow
|
|
222
264
|
|
|
223
265
|
Blue Bird comes with a built-in Docker CLI wrapper that handles both local development database bootstrapping and full-stack VPS production deployments.
|
|
@@ -240,6 +282,7 @@ npx blue-bird docker <command> [options]
|
|
|
240
282
|
- **`npx blue-bird docker logs [app|mysql]`**: Tails logs for the specified container.
|
|
241
283
|
- **`npx blue-bird docker pm2 [args]`**: Runs PM2 commands inside the Node.js application container (e.g. `status`, `monit`, `reload all`).
|
|
242
284
|
- **`npx blue-bird docker db`**: Connects into the container's interactive MySQL shell using credentials from `.env`.
|
|
285
|
+
- **`npx blue-bird docker redis`**: Connects into the container's interactive Redis CLI terminal.
|
|
243
286
|
- **`npx blue-bird docker prune`**: Safely clears orphaned volumes, dangling build caches, and images.
|
|
244
287
|
|
|
245
288
|
---
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
2026-07-14 22:04:34 -::1 -[GET] /
|
|
2
|
+
2026-07-14 22:04:38 -::1 -[GET] /about
|
|
3
|
+
2026-07-14 22:04:38 -::1 -[GET] /about
|
|
4
|
+
2026-07-14 22:04:39 -::1 -[GET] /
|
|
5
|
+
2026-07-14 22:04:39 -::1 -[GET] /
|
|
6
|
+
2026-07-14 22:06:26 -::1 -[GET] /api/users
|
|
7
|
+
2026-07-14 22:08:02 -::1 -[GET] /api/users
|
|
8
|
+
2026-07-14 22:08:05 -::1 -[GET] /api/
|
|
9
|
+
2026-07-14 22:42:04 -::1 -[GET] /api/auth_generate
|
|
10
|
+
2026-07-14 22:42:05 -::1 -[GET] /api/auth_generate
|
|
11
|
+
2026-07-14 22:42:10 -::1 -[GET] /api/auth_verify
|
|
12
|
+
2026-07-14 22:42:12 -::1 -[GET] /api/auth_verify
|
|
13
|
+
2026-07-14 22:42:21 -::1 -[GET] /api/auth_logout
|
|
14
|
+
2026-07-14 22:42:24 -::1 -[GET] /api/auth_verify
|
|
15
|
+
2026-07-14 22:42:28 -::1 -[GET] /
|
|
16
|
+
2026-07-14 22:42:28 -::ffff:127.0.0.1 -[GET] /api/
|
|
17
|
+
2026-07-14 22:42:29 -::1 -[GET] /
|
|
18
|
+
2026-07-14 22:42:29 -::ffff:127.0.0.1 -[GET] /api/
|
|
19
|
+
2026-07-14 22:42:30 -::1 -[GET] /about
|
|
20
|
+
2026-07-14 22:42:31 -::1 -[GET] /about
|
|
21
|
+
2026-07-14 22:42:32 -::1 -[GET] /
|
|
22
|
+
2026-07-14 22:42:32 -::ffff:127.0.0.1 -[GET] /api/
|
|
23
|
+
2026-07-14 22:42:32 -::1 -[GET] /
|
|
24
|
+
2026-07-14 22:42:32 -::ffff:127.0.0.1 -[GET] /api/
|
|
25
|
+
2026-07-14 22:42:33 -::1 -[GET] /about
|
|
26
|
+
2026-07-14 22:42:35 -::1 -[GET] /
|
|
27
|
+
2026-07-14 22:42:35 -::ffff:127.0.0.1 -[GET] /api/
|
|
28
|
+
2026-07-14 22:42:37 -::1 -[GET] /about
|
|
29
|
+
2026-07-14 22:42:38 -::1 -[GET] /
|
|
30
|
+
2026-07-14 22:42:38 -::ffff:127.0.0.1 -[GET] /api/
|
|
31
|
+
2026-07-14 22:48:54 -::1 -[GET] /
|
|
32
|
+
2026-07-14 22:48:54 -::ffff:127.0.0.1 -[GET] /api/
|
|
33
|
+
2026-07-14 22:48:56 -::1 -[GET] /about
|
|
34
|
+
2026-07-14 22:48:58 -::1 -[GET] /about
|
|
35
|
+
2026-07-14 22:48:59 -::1 -[GET] /
|
|
36
|
+
2026-07-14 22:48:59 -::ffff:127.0.0.1 -[GET] /api/
|
|
37
|
+
2026-07-14 22:48:59 -::1 -[GET] /
|
|
38
|
+
2026-07-14 22:48:59 -::ffff:127.0.0.1 -[GET] /api/
|
|
39
|
+
2026-07-14 22:49:01 -::1 -[GET] /about
|
|
40
|
+
2026-07-14 22:49:02 -::1 -[GET] /
|
|
41
|
+
2026-07-14 22:49:02 -::ffff:127.0.0.1 -[GET] /api/
|
|
42
|
+
2026-07-14 22:49:03 -::1 -[GET] /about
|
|
43
|
+
2026-07-14 22:49:04 -::1 -[GET] /
|
|
44
|
+
2026-07-14 22:49:04 -::ffff:127.0.0.1 -[GET] /api/
|
|
45
|
+
2026-07-14 22:49:05 -::1 -[GET] /about
|
|
46
|
+
2026-07-14 22:49:07 -::1 -[GET] /
|
|
47
|
+
2026-07-14 22:49:07 -::ffff:127.0.0.1 -[GET] /api/
|
|
48
|
+
2026-07-14 22:49:07 -::1 -[GET] /about
|
package/backend/routes/api.js
CHANGED
|
@@ -40,12 +40,12 @@ routerApi.get("/cache", Cache.middleware(), async (req, res) => {
|
|
|
40
40
|
});
|
|
41
41
|
|
|
42
42
|
routerApi.get("/auth_generate", async (req, res) => {
|
|
43
|
-
const token = await Auth.login(res, { id: 1, name: "John Doe" });
|
|
43
|
+
const token = await Auth.login(res, { id: 1, name: "John Doe" }, "auth");
|
|
44
44
|
res.json({ message: "Auth successful", token });
|
|
45
45
|
});
|
|
46
46
|
|
|
47
47
|
routerApi.get("/auth_logout", async (req, res) => {
|
|
48
|
-
await Auth.logout(res);
|
|
48
|
+
await Auth.logout(res, "auth", {}, req);
|
|
49
49
|
res.json({ message: "Auth successful" });
|
|
50
50
|
});
|
|
51
51
|
|
package/core/auth.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import jwt from "jsonwebtoken";
|
|
2
2
|
import crypto from "node:crypto";
|
|
3
3
|
import Config from "./config.js";
|
|
4
|
+
import { getRedisClient } from "./cache.js";
|
|
4
5
|
|
|
5
6
|
const propsConfig = Config.props();
|
|
6
7
|
const jwtSecret = propsConfig.jwtSecret;
|
|
@@ -57,11 +58,7 @@ class Auth {
|
|
|
57
58
|
* @param {string} [expiresIn="24h"] - Expiration time.
|
|
58
59
|
* @returns {string} The generated token.
|
|
59
60
|
*/
|
|
60
|
-
static generateToken(
|
|
61
|
-
payload,
|
|
62
|
-
secret = jwtSecret,
|
|
63
|
-
expiresIn = "24h"
|
|
64
|
-
) {
|
|
61
|
+
static generateToken(payload, secret = jwtSecret, expiresIn = "24h") {
|
|
65
62
|
if (!secret)
|
|
66
63
|
throw new Error("FATAL: JWT_SECRET environment variable is not defined.");
|
|
67
64
|
const encrypted = this.encrypt(payload, secret);
|
|
@@ -101,7 +98,7 @@ class Auth {
|
|
|
101
98
|
static protect(options = {}) {
|
|
102
99
|
const { redirect = null, key = "user", cookieKey = "auth" } = options;
|
|
103
100
|
|
|
104
|
-
return (req, res, next) => {
|
|
101
|
+
return async (req, res, next) => {
|
|
105
102
|
const token =
|
|
106
103
|
req.cookies?.[cookieKey] || req.headers.authorization?.split(" ")[1];
|
|
107
104
|
|
|
@@ -123,6 +120,32 @@ class Auth {
|
|
|
123
120
|
: res.status(401).send();
|
|
124
121
|
}
|
|
125
122
|
|
|
123
|
+
const redisClient = getRedisClient();
|
|
124
|
+
if (redisClient && decoded._sessionId) {
|
|
125
|
+
try {
|
|
126
|
+
const sessionData = await redisClient.get(
|
|
127
|
+
`session:${decoded._sessionId}`,
|
|
128
|
+
);
|
|
129
|
+
if (!sessionData) {
|
|
130
|
+
if (redirect && !isContentTypeJson) return res.redirect(redirect);
|
|
131
|
+
return isContentTypeJson
|
|
132
|
+
? res.status(401).json({ message: "Unauthorized" })
|
|
133
|
+
: res.status(401).send();
|
|
134
|
+
}
|
|
135
|
+
req[key || "user"] = JSON.parse(sessionData);
|
|
136
|
+
return next();
|
|
137
|
+
} catch (err) {
|
|
138
|
+
console.error(
|
|
139
|
+
"[AUTH ERROR] Failed to get session data from Redis:",
|
|
140
|
+
err.message,
|
|
141
|
+
);
|
|
142
|
+
if (redirect && !isContentTypeJson) return res.redirect(redirect);
|
|
143
|
+
return isContentTypeJson
|
|
144
|
+
? res.status(401).json({ message: "Unauthorized" })
|
|
145
|
+
: res.status(401).send();
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
126
149
|
req[key || "user"] = decoded;
|
|
127
150
|
next();
|
|
128
151
|
};
|
|
@@ -142,8 +165,10 @@ class Auth {
|
|
|
142
165
|
*/
|
|
143
166
|
static async login(res, data, key = "auth", options = {}) {
|
|
144
167
|
const { expiresIn = "24h", cookie = {} } = options;
|
|
168
|
+
const sessionId = crypto.randomUUID();
|
|
169
|
+
const tokenPayload = { ...data, _sessionId: sessionId };
|
|
145
170
|
|
|
146
|
-
const token = this.generateToken(
|
|
171
|
+
const token = this.generateToken(tokenPayload, jwtSecret, expiresIn);
|
|
147
172
|
|
|
148
173
|
const defaultCookieOptions = {
|
|
149
174
|
maxAge: 24 * 60 * 60 * 1000,
|
|
@@ -155,6 +180,34 @@ class Auth {
|
|
|
155
180
|
|
|
156
181
|
const finalCookieOptions = { ...defaultCookieOptions, ...cookie };
|
|
157
182
|
|
|
183
|
+
const redisClient = getRedisClient();
|
|
184
|
+
if (redisClient) {
|
|
185
|
+
try {
|
|
186
|
+
let ttl = 86400;
|
|
187
|
+
if (typeof expiresIn === "string") {
|
|
188
|
+
const match = expiresIn.match(/^(\d+)([smhd])$/);
|
|
189
|
+
if (match) {
|
|
190
|
+
const val = parseInt(match[1]);
|
|
191
|
+
const unit = match[2];
|
|
192
|
+
if (unit === "s") ttl = val;
|
|
193
|
+
else if (unit === "m") ttl = val * 60;
|
|
194
|
+
else if (unit === "h") ttl = val * 3600;
|
|
195
|
+
else if (unit === "d") ttl = val * 86400;
|
|
196
|
+
}
|
|
197
|
+
} else if (typeof expiresIn === "number") {
|
|
198
|
+
ttl = expiresIn;
|
|
199
|
+
}
|
|
200
|
+
await redisClient.set(`session:${sessionId}`, JSON.stringify(data), {
|
|
201
|
+
EX: ttl,
|
|
202
|
+
});
|
|
203
|
+
} catch (err) {
|
|
204
|
+
console.error(
|
|
205
|
+
"[AUTH ERROR] Failed to store session in Redis:",
|
|
206
|
+
err.message,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
158
211
|
res.cookie(key, token, finalCookieOptions);
|
|
159
212
|
return token;
|
|
160
213
|
}
|
|
@@ -164,14 +217,37 @@ class Auth {
|
|
|
164
217
|
* @param {import('express').Response} res - The response object.
|
|
165
218
|
* @param {string} [key="auth"] - The key for the cookie.
|
|
166
219
|
* @param {import('express').CookieOptions} [options={}] - Options for clearing the cookie.
|
|
220
|
+
* @param {import('express').Request} [req=null] - The request object.
|
|
167
221
|
* @returns {Promise<boolean>} True if the cookie was cleared successfully.
|
|
168
222
|
* @example
|
|
169
223
|
* await Auth.logout(res);
|
|
170
224
|
*/
|
|
171
|
-
static async logout(res, key = "auth", options = {}) {
|
|
225
|
+
static async logout(res, key = "auth", options = {}, req = null) {
|
|
172
226
|
const defaultOptions = {
|
|
173
227
|
path: "/",
|
|
174
228
|
};
|
|
229
|
+
|
|
230
|
+
if (req) {
|
|
231
|
+
const token =
|
|
232
|
+
req.cookies?.[key] || req.headers.authorization?.split(" ")[1];
|
|
233
|
+
if (token) {
|
|
234
|
+
const decoded = this.verifyToken(token);
|
|
235
|
+
if (decoded && decoded._sessionId) {
|
|
236
|
+
const redisClient = getRedisClient();
|
|
237
|
+
if (redisClient) {
|
|
238
|
+
try {
|
|
239
|
+
await redisClient.del(`session:${decoded._sessionId}`);
|
|
240
|
+
} catch (err) {
|
|
241
|
+
console.error(
|
|
242
|
+
"[AUTH ERROR] Failed to delete session from Redis:",
|
|
243
|
+
err.message,
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
175
251
|
res.clearCookie(key, { ...defaultOptions, ...options });
|
|
176
252
|
return true;
|
|
177
253
|
}
|
package/core/cache.js
CHANGED
|
@@ -31,6 +31,12 @@ async function initRedis() {
|
|
|
31
31
|
redisClient.on("error", () => {
|
|
32
32
|
isRedisConnected = false;
|
|
33
33
|
});
|
|
34
|
+
redisClient.on("ready", () => {
|
|
35
|
+
isRedisConnected = true;
|
|
36
|
+
});
|
|
37
|
+
redisClient.on("connect", () => {
|
|
38
|
+
isRedisConnected = true;
|
|
39
|
+
});
|
|
34
40
|
await redisClient.connect();
|
|
35
41
|
isRedisConnected = true;
|
|
36
42
|
} catch (err) {
|
|
@@ -157,4 +163,12 @@ class Cache {
|
|
|
157
163
|
}
|
|
158
164
|
}
|
|
159
165
|
|
|
166
|
+
/**
|
|
167
|
+
* Returns the active Redis client if connected.
|
|
168
|
+
* @returns {Object|null} The Redis client instance or null.
|
|
169
|
+
*/
|
|
170
|
+
export function getRedisClient() {
|
|
171
|
+
return isRedisConnected ? redisClient : null;
|
|
172
|
+
}
|
|
173
|
+
|
|
160
174
|
export default Cache;
|
package/core/cli/docker.js
CHANGED
|
@@ -271,6 +271,15 @@ async function pm2Command(pm2Args = []) {
|
|
|
271
271
|
}
|
|
272
272
|
}
|
|
273
273
|
|
|
274
|
+
/**
|
|
275
|
+
* Handles interactive shell connections into the Redis container.
|
|
276
|
+
*/
|
|
277
|
+
async function redisCommand() {
|
|
278
|
+
checkComposeFile();
|
|
279
|
+
const cmdArgs = ["compose", "exec", "redis", "redis-cli"];
|
|
280
|
+
await runCmd("docker", cmdArgs);
|
|
281
|
+
}
|
|
282
|
+
|
|
274
283
|
/**
|
|
275
284
|
* Entry point for Blue Bird CLI Docker subcommands.
|
|
276
285
|
*/
|
|
@@ -325,6 +334,9 @@ async function main() {
|
|
|
325
334
|
case "pm2":
|
|
326
335
|
await pm2Command(args.slice(1));
|
|
327
336
|
break;
|
|
337
|
+
case "redis":
|
|
338
|
+
await redisCommand();
|
|
339
|
+
break;
|
|
328
340
|
case "mysql":
|
|
329
341
|
case "db": {
|
|
330
342
|
let user, password, db, root = false;
|
|
@@ -351,7 +363,7 @@ async function main() {
|
|
|
351
363
|
}
|
|
352
364
|
default:
|
|
353
365
|
console.log(chalk.yellow(`Unknown docker command: ${command}`));
|
|
354
|
-
console.log("Available commands: start, stop, build, ps, logs, pm2, mysql/db, df/disk, prune/clean");
|
|
366
|
+
console.log("Available commands: start, stop, build, ps, logs, pm2, mysql/db, redis, df/disk, prune/clean");
|
|
355
367
|
}
|
|
356
368
|
}
|
|
357
369
|
|
package/core/database.js
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { getRedisClient } from "./cache.js";
|
|
3
|
+
|
|
4
|
+
let mysqlPromise = null;
|
|
5
|
+
try {
|
|
6
|
+
mysqlPromise = await import("mysql2/promise");
|
|
7
|
+
} catch (err) {
|
|
8
|
+
console.error(
|
|
9
|
+
"[DATABASE ERROR] mysql2 package is not installed. Database wrapper is disabled.",
|
|
10
|
+
);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Database class wrapping mysql2 with reconnection retries, connection pool, and query caching.
|
|
15
|
+
*/
|
|
16
|
+
class Database {
|
|
17
|
+
/**
|
|
18
|
+
* Initializes config from DATABASE_URL or DB_* environment variables.
|
|
19
|
+
*/
|
|
20
|
+
constructor(connectionLimit = 10, queueLimit = 0) {
|
|
21
|
+
this.pool = null;
|
|
22
|
+
this.config = {
|
|
23
|
+
host: process.env.DB_HOST || "localhost",
|
|
24
|
+
user: process.env.DB_USER || "root",
|
|
25
|
+
password: process.env.DB_PASSWORD || "root",
|
|
26
|
+
database: process.env.DB_NAME || "blue_bird",
|
|
27
|
+
port: parseInt(process.env.DB_PORT) || 3306,
|
|
28
|
+
charset: "utf8mb4",
|
|
29
|
+
waitForConnections: true,
|
|
30
|
+
connectionLimit: connectionLimit,
|
|
31
|
+
queueLimit: queueLimit,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
if (
|
|
35
|
+
process.env.DATABASE_URL &&
|
|
36
|
+
process.env.DATABASE_URL.startsWith("mysql://")
|
|
37
|
+
) {
|
|
38
|
+
try {
|
|
39
|
+
const url = new URL(process.env.DATABASE_URL);
|
|
40
|
+
this.config.host = url.hostname;
|
|
41
|
+
this.config.port = parseInt(url.port) || 3306;
|
|
42
|
+
this.config.user = url.username;
|
|
43
|
+
this.config.password = url.password;
|
|
44
|
+
this.config.database = url.pathname.substring(1);
|
|
45
|
+
} catch (err) {
|
|
46
|
+
console.error(
|
|
47
|
+
"[DATABASE ERROR] Failed to parse DATABASE_URL:",
|
|
48
|
+
err.message,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Creates the MySQL connection pool with 3 retry attempts on failure.
|
|
56
|
+
* @param {number} [retries=3] - Number of connection attempts.
|
|
57
|
+
* @returns {Promise<boolean>} True if connection pool was created.
|
|
58
|
+
*/
|
|
59
|
+
async init(retries = 3) {
|
|
60
|
+
if (!mysqlPromise) return false;
|
|
61
|
+
if (this.pool) return true;
|
|
62
|
+
|
|
63
|
+
for (let attempt = 1; attempt <= retries; attempt++) {
|
|
64
|
+
try {
|
|
65
|
+
this.pool = mysqlPromise.createPool(this.config);
|
|
66
|
+
await this.pool.query("SELECT 1");
|
|
67
|
+
return true;
|
|
68
|
+
} catch (err) {
|
|
69
|
+
this.pool = null;
|
|
70
|
+
if (attempt === retries) {
|
|
71
|
+
console.error(
|
|
72
|
+
`[DATABASE ERROR] Connection failed after ${retries} attempts:`,
|
|
73
|
+
err.message,
|
|
74
|
+
);
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Runs a SQL query with parameters and formatting options.
|
|
85
|
+
*
|
|
86
|
+
* @param {string} sql - SQL query string.
|
|
87
|
+
* @param {Array} [params=[]] - Query parameter array.
|
|
88
|
+
* @param {Object|string} [options={}] - Query options. Supports 'return_row', 'return_rows', and 'cache' (seconds).
|
|
89
|
+
* @returns {Promise<*>| int | boolean} Formatted query result or false on error, or insert id of insert query.
|
|
90
|
+
* @example select
|
|
91
|
+
* const result = await connection.query("SELECT * FROM users", [], { return_row: true, cache: 60 });
|
|
92
|
+
* @example insert
|
|
93
|
+
* const result = await connection.query("INSERT INTO users (name, email, password) VALUES (?, ?, ?)", ["John Doe", "[EMAIL_ADDRESS]", "123456"]);
|
|
94
|
+
* @example update
|
|
95
|
+
* const result = await connection.query("UPDATE users SET name = ? WHERE id = ?", ["John Doe", 1]);
|
|
96
|
+
* @example delete
|
|
97
|
+
* const result = await connection.query("DELETE FROM users WHERE id = ?", [1]);
|
|
98
|
+
*/
|
|
99
|
+
async query(sql, params = [], options = {}) {
|
|
100
|
+
if (!mysqlPromise) return false;
|
|
101
|
+
if (!this.pool) {
|
|
102
|
+
const initialized = await this.init();
|
|
103
|
+
if (!initialized) return false;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const queryOptions =
|
|
107
|
+
typeof options === "string" ? { [options]: true } : options;
|
|
108
|
+
const cleanSql = sql.trim();
|
|
109
|
+
const isSelect = cleanSql.toLowerCase().startsWith("select");
|
|
110
|
+
const isInsert = cleanSql.toLowerCase().startsWith("insert");
|
|
111
|
+
|
|
112
|
+
const redisClient = getRedisClient();
|
|
113
|
+
let cacheKey = null;
|
|
114
|
+
const isDebug = queryOptions.debug ?? false;
|
|
115
|
+
if (isDebug) {
|
|
116
|
+
console.log("[DATABASE DEBUG] SQL:", sql);
|
|
117
|
+
console.log("[DATABASE DEBUG] PARAMS:", params);
|
|
118
|
+
console.log("[DATABASE DEBUG] OPTIONS:", options);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (isSelect && queryOptions.cache && redisClient) {
|
|
122
|
+
const hash = crypto
|
|
123
|
+
.createHash("md5")
|
|
124
|
+
.update(cleanSql + JSON.stringify(params))
|
|
125
|
+
.digest("hex");
|
|
126
|
+
cacheKey = `db:${hash}`;
|
|
127
|
+
try {
|
|
128
|
+
if (isDebug) {
|
|
129
|
+
console.log("[DATABASE DEBUG ][Redis] CACHE KEY:", cacheKey);
|
|
130
|
+
}
|
|
131
|
+
const cached = await redisClient.get(cacheKey);
|
|
132
|
+
if (cached) {
|
|
133
|
+
if (isDebug) {
|
|
134
|
+
console.log("[DATABASE DEBUG ][Redis] CACHE HIT");
|
|
135
|
+
}
|
|
136
|
+
return JSON.parse(cached);
|
|
137
|
+
} else {
|
|
138
|
+
if (isDebug) {
|
|
139
|
+
console.log("[DATABASE DEBUG ][Redis] CACHE MISS");
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
} catch (err) {
|
|
143
|
+
console.error(
|
|
144
|
+
"[DATABASE ERROR] Failed to get cached data:",
|
|
145
|
+
err.message,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
const [results] = await this.pool.execute(cleanSql, params);
|
|
152
|
+
|
|
153
|
+
if (isSelect) {
|
|
154
|
+
const rows = Array.isArray(results) ? results : [];
|
|
155
|
+
if (cacheKey && queryOptions.cache && redisClient) {
|
|
156
|
+
await redisClient
|
|
157
|
+
.set(cacheKey, JSON.stringify(rows), {
|
|
158
|
+
EX: parseInt(queryOptions.cache),
|
|
159
|
+
})
|
|
160
|
+
.catch(() => {});
|
|
161
|
+
}
|
|
162
|
+
if (queryOptions.return_row) {
|
|
163
|
+
return rows.length > 0 ? rows[0] : null;
|
|
164
|
+
}
|
|
165
|
+
return rows;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (isInsert) {
|
|
169
|
+
return results.insertId || results;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return results;
|
|
173
|
+
} catch (err) {
|
|
174
|
+
console.error("[DATABASE ERROR] Query execution failed:", err.message);
|
|
175
|
+
throw err;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const connection = new Database();
|
|
181
|
+
export default connection;
|
|
182
|
+
export { Database };
|
package/core/logger.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import Config from "./config.js";
|
|
4
|
+
import { getRedisClient } from "./cache.js";
|
|
4
5
|
|
|
5
6
|
const __dirname = Config.dirname();
|
|
6
7
|
|
|
@@ -52,47 +53,62 @@ class Logger {
|
|
|
52
53
|
}
|
|
53
54
|
|
|
54
55
|
/**
|
|
55
|
-
*
|
|
56
|
-
* @
|
|
56
|
+
* Logs a message to the specified log file or Redis list.
|
|
57
|
+
* @private
|
|
58
|
+
* @param {string} file - The file name to log to.
|
|
59
|
+
* @param {string} level - The log level (e.g. info, error, warn, debug).
|
|
60
|
+
* @param {string} message - The log message.
|
|
57
61
|
*/
|
|
58
|
-
|
|
59
|
-
const
|
|
62
|
+
async _log(file, level, message) {
|
|
63
|
+
const redisClient = getRedisClient();
|
|
64
|
+
if (redisClient) {
|
|
65
|
+
try {
|
|
66
|
+
await redisClient.lPush(`bluebird:logs:${level}`, message);
|
|
67
|
+
return;
|
|
68
|
+
} catch (err) {
|
|
69
|
+
console.error(
|
|
70
|
+
`[LOGGER ERROR] Failed to write to Redis logs (${level}):`,
|
|
71
|
+
err.message,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const logFile = path.join(this.nowFolder(), file);
|
|
60
77
|
fs.appendFile(logFile, `${message}\n`, (err) => {
|
|
61
78
|
if (err) console.error("Logger write error:", err.message);
|
|
62
79
|
});
|
|
63
80
|
}
|
|
64
81
|
|
|
65
82
|
/**
|
|
66
|
-
* Appends an
|
|
83
|
+
* Appends an informational message.
|
|
84
|
+
* @param {string} message - The message to log.
|
|
85
|
+
*/
|
|
86
|
+
info(message) {
|
|
87
|
+
this._log("info.log", "info", message);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Appends an error message.
|
|
67
92
|
* @param {string} message - The error message to log.
|
|
68
93
|
*/
|
|
69
94
|
error(message) {
|
|
70
|
-
|
|
71
|
-
fs.appendFile(logFile, `${message}\n`, (err) => {
|
|
72
|
-
if (err) console.error("Logger write error:", err.message);
|
|
73
|
-
});
|
|
95
|
+
this._log("error.log", "error", message);
|
|
74
96
|
}
|
|
75
97
|
|
|
76
98
|
/**
|
|
77
|
-
* Appends a warning message
|
|
99
|
+
* Appends a warning message.
|
|
78
100
|
* @param {string} message - The warning message to log.
|
|
79
101
|
*/
|
|
80
102
|
warning(message) {
|
|
81
|
-
|
|
82
|
-
fs.appendFile(logFile, `${message}\n`, (err) => {
|
|
83
|
-
if (err) console.error("Logger write error:", err.message);
|
|
84
|
-
});
|
|
103
|
+
this._log("warn.log", "warn", message);
|
|
85
104
|
}
|
|
86
105
|
|
|
87
106
|
/**
|
|
88
|
-
* Appends a debug message
|
|
107
|
+
* Appends a debug message.
|
|
89
108
|
* @param {string} message - The debug message to log.
|
|
90
109
|
*/
|
|
91
110
|
debug(message) {
|
|
92
|
-
|
|
93
|
-
fs.appendFile(logFile, `${message}\n`, (err) => {
|
|
94
|
-
if (err) console.error("Logger write error:", err.message);
|
|
95
|
-
});
|
|
111
|
+
this._log("debug.log", "debug", message);
|
|
96
112
|
}
|
|
97
113
|
}
|
|
98
114
|
|
package/docker/nginx.conf
CHANGED
|
@@ -46,6 +46,8 @@ http {
|
|
|
46
46
|
|
|
47
47
|
limit_req_zone $binary_remote_addr zone=bluebird_limit:10m rate=10r/s;
|
|
48
48
|
|
|
49
|
+
proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=astro_cache:10m max_size=1g inactive=60m use_temp_path=off;
|
|
50
|
+
|
|
49
51
|
server {
|
|
50
52
|
listen 80;
|
|
51
53
|
server_name localhost;
|
|
@@ -61,6 +63,18 @@ http {
|
|
|
61
63
|
try_files $uri @node_app;
|
|
62
64
|
}
|
|
63
65
|
|
|
66
|
+
location /api/ {
|
|
67
|
+
limit_req zone=bluebird_limit burst=20 nodelay;
|
|
68
|
+
set $upstream_target http://app:3000;
|
|
69
|
+
proxy_pass $upstream_target;
|
|
70
|
+
proxy_http_version 1.1;
|
|
71
|
+
proxy_set_header Connection "";
|
|
72
|
+
proxy_set_header Host $host;
|
|
73
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
74
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
75
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
76
|
+
}
|
|
77
|
+
|
|
64
78
|
location /_astro/ {
|
|
65
79
|
expires max;
|
|
66
80
|
add_header Cache-Control "public, max-age=31536000, immutable";
|
|
@@ -78,6 +92,15 @@ http {
|
|
|
78
92
|
proxy_set_header X-Real-IP $remote_addr;
|
|
79
93
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
80
94
|
proxy_set_header X-Forwarded-Proto $scheme;
|
|
95
|
+
|
|
96
|
+
proxy_cache astro_cache;
|
|
97
|
+
proxy_cache_valid 200 302 10s;
|
|
98
|
+
proxy_cache_valid 404 1m;
|
|
99
|
+
proxy_cache_use_stale error timeout http_500 http_502 http_503 http_504;
|
|
100
|
+
proxy_cache_lock on;
|
|
101
|
+
proxy_cache_bypass $cookie_auth $http_authorization;
|
|
102
|
+
proxy_no_cache $cookie_auth $http_authorization;
|
|
103
|
+
add_header X-Cache-Status $upstream_cache_status;
|
|
81
104
|
}
|
|
82
105
|
}
|
|
83
106
|
}
|
package/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import App from "
|
|
1
|
+
import App from "@seip/blue-bird/core/app.js";
|
|
2
2
|
import routerApi from "./backend/routes/api.js";
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -16,6 +16,8 @@ const app = new App({
|
|
|
16
16
|
|
|
17
17
|
port: process.env.PORT,
|
|
18
18
|
|
|
19
|
+
logger: true, //In production, set this to false to disable logging and stop writing to Redis
|
|
20
|
+
|
|
19
21
|
astro: {
|
|
20
22
|
server: true,
|
|
21
23
|
serverEntry: "./frontend/dist/server/entry.mjs",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@seip/blue-bird",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.4",
|
|
4
4
|
"description": "Express opinionated framework with HTML rendering, API architecture, built-in JWT auth, validation, caching, and SEO",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -65,6 +65,7 @@
|
|
|
65
65
|
"helmet": "^8.1.0",
|
|
66
66
|
"jsonwebtoken": "^9.0.2",
|
|
67
67
|
"multer": "^2.0.2",
|
|
68
|
+
"mysql2": "^3.22.6",
|
|
68
69
|
"redis": "^4.7.0",
|
|
69
70
|
"xss": "^1.0.15"
|
|
70
71
|
}
|