@seip/blue-bird 0.7.0 → 0.7.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/.env_example +9 -4
- package/AGENTS.md +32 -3
- package/README.md +42 -10
- 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 +125 -29
- package/core/cli/docker.js +59 -8
- package/core/database.js +182 -0
- package/core/logger.js +35 -19
- package/docker/Dockerfile +3 -12
- package/docker/nginx.conf +83 -0
- package/docker-compose.yml +42 -19
- package/frontend/src/http/api.js +6 -1
- package/frontend/src/pages/index.astro +7 -1
- package/index.js +2 -0
- package/package.json +6 -1
package/.env_example
CHANGED
|
@@ -3,18 +3,23 @@ DEBUG=true
|
|
|
3
3
|
PORT=3000
|
|
4
4
|
HOST="localhost"
|
|
5
5
|
APP_URL="http://localhost"
|
|
6
|
-
STATIC_PATH="frontend/public"
|
|
7
6
|
VERSION="1.0.0"
|
|
8
|
-
BLUEBIRD_PROJECT_NAME="bluebird"
|
|
9
7
|
|
|
10
|
-
#Docker /Swagger
|
|
8
|
+
# Docker / Swagger Config
|
|
11
9
|
TITLE="Blue-Bird"
|
|
12
10
|
DESCRIPTION="Description project"
|
|
13
11
|
|
|
14
|
-
|
|
15
12
|
# Security Configuration
|
|
16
13
|
JWT_SECRET="JWT_SECRET"
|
|
17
14
|
|
|
15
|
+
# Redis Configuration
|
|
16
|
+
REDIS_HOST="localhost"
|
|
17
|
+
REDIS_PORT=6379
|
|
18
|
+
REDIS_PASSWORD=""
|
|
19
|
+
|
|
20
|
+
# PM2 Clustering Instances (integer or 'max')
|
|
21
|
+
PM2_INSTANCES=1
|
|
22
|
+
|
|
18
23
|
# Database Configuration (Used only for local development outside Docker)
|
|
19
24
|
# SQLite (Default)
|
|
20
25
|
DATABASE_URL="file:./dev.db"
|
package/AGENTS.md
CHANGED
|
@@ -131,7 +131,7 @@ router.post("/logout", async (req, res) => {
|
|
|
131
131
|
|
|
132
132
|
## 6. Performance Caching (Cache)
|
|
133
133
|
|
|
134
|
-
If an Express route involves heavy processing or database queries, utilize the `Cache` middleware to cache the REST API JSON payload.
|
|
134
|
+
If an Express route involves heavy processing or database queries, utilize the `Cache` middleware to cache the REST API JSON or HTML payload.
|
|
135
135
|
|
|
136
136
|
```javascript
|
|
137
137
|
import Cache from "@seip/blue-bird/core/cache.js";
|
|
@@ -141,6 +141,8 @@ router.get("/stats", Cache.middleware(60), (req, res) => {
|
|
|
141
141
|
});
|
|
142
142
|
```
|
|
143
143
|
|
|
144
|
+
The Cache module integrates with Redis when `REDIS_HOST` is configured in the environment. If Redis is unavailable or fails, it transparently falls back to an in-memory cache system without interrupting requests.
|
|
145
|
+
|
|
144
146
|
## 7. Security (Helmet)
|
|
145
147
|
|
|
146
148
|
Helmet is **not applied globally** by default. Apply it per-router where needed:
|
|
@@ -156,19 +158,29 @@ apiRouter.use(App.helmet());
|
|
|
156
158
|
|
|
157
159
|
Blue Bird features a built-in Docker Compose CLI wrapper to deploy and manage containerized development databases and production stacks.
|
|
158
160
|
|
|
161
|
+
Production deployments always use Docker for orchestration, running:
|
|
162
|
+
- Nginx: Serves static files directly from `frontend/dist/client/` and blocks common scanner requests (`.env`, `.git`, etc.) with fallback to Express.
|
|
163
|
+
- Node.js App: Managed via PM2 in cluster mode using `PM2_INSTANCES` configuration (defaults to `1`, can be set to `max`).
|
|
164
|
+
- MySQL: Database service.
|
|
165
|
+
- Redis: Memory caching and session store.
|
|
166
|
+
|
|
159
167
|
```bash
|
|
160
168
|
# Manage containers using blue-bird CLI
|
|
161
|
-
npx blue-bird docker start # Starts production app
|
|
169
|
+
npx blue-bird docker start # Starts production app stack (mysql, redis, app, nginx)
|
|
162
170
|
npx blue-bird docker start mysql # Starts MySQL container only (useful for local development)
|
|
171
|
+
npx blue-bird docker start redis # Starts Redis container only
|
|
172
|
+
npx blue-bird docker start dbs # Starts both database containers (MySQL + Redis)
|
|
163
173
|
npx blue-bird docker stop # Stops all running containers
|
|
164
174
|
npx blue-bird docker build # Builds/rebuilds application image
|
|
165
175
|
npx blue-bird docker ps # Shows status of active containers
|
|
166
176
|
npx blue-bird docker logs # Tails Node.js app container logs
|
|
177
|
+
npx blue-bird docker pm2 [args] # Runs PM2 commands inside the app container (e.g. status, monit)
|
|
167
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
|
|
168
180
|
npx blue-bird docker prune # Cleans unused volumes, dangling images, and BuildKit caches
|
|
169
181
|
```
|
|
170
182
|
|
|
171
|
-
The container names and virtual networks are namespaced by the `TITLE` environment variable parsed from `.env` to prevent resource collisions on VPS hosts.
|
|
183
|
+
The container names and virtual networks are namespaced by the `TITLE` environment variable parsed from `.env` to prevent resource collisions on VPS hosts. Alternatively, PM2 and other services can be run manually in standalone server environments.
|
|
172
184
|
|
|
173
185
|
## 9. AI Development Guidelines
|
|
174
186
|
|
|
@@ -177,4 +189,21 @@ The container names and virtual networks are namespaced by the `TITLE` environme
|
|
|
177
189
|
3. **Magic Imports**: Stick to pure relative imports or well-configured aliases (imports natively resolve from `@seip/blue-bird/...` or relative directories like `../../`).
|
|
178
190
|
4. **No inline comments**: Only use JSDoc for documentation.
|
|
179
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
|
+
|
|
180
209
|
_This file can be retrieved by intelligent agents reading its absolute physical path during reasoning._
|
package/README.md
CHANGED
|
@@ -62,8 +62,8 @@ project/
|
|
|
62
62
|
│ │ ├── index.astro
|
|
63
63
|
│ │ └── about.astro
|
|
64
64
|
│ ├── public/ # Static assets mapped to root of Astro build
|
|
65
|
-
│ │ └──
|
|
66
|
-
│ │ └──
|
|
65
|
+
│ │ └── css/
|
|
66
|
+
│ │ └── app.css # Css files
|
|
67
67
|
│ └── astro.config.mjs # Astro configuration file
|
|
68
68
|
├── docker/
|
|
69
69
|
│ └── Dockerfile # Optimized production build file
|
|
@@ -122,8 +122,8 @@ const app = new App({
|
|
|
122
122
|
serverEntry: "./frontend/dist/server/entry.mjs", // Path to compiled Astro server entrypoint
|
|
123
123
|
client: false, // Set to true to serve static files from client build
|
|
124
124
|
clientDir: "./frontend/dist/client", // Path to Astro client static assets
|
|
125
|
-
base: "/" // Mount base path
|
|
126
|
-
}
|
|
125
|
+
base: "/", // Mount base path
|
|
126
|
+
},
|
|
127
127
|
});
|
|
128
128
|
```
|
|
129
129
|
|
|
@@ -201,6 +201,8 @@ router.get("/stats", Cache.middleware(60), (req, res) => {
|
|
|
201
201
|
});
|
|
202
202
|
```
|
|
203
203
|
|
|
204
|
+
Integrates with Redis if `REDIS_HOST` is defined in the environment. Falls back to an in-memory cache automatically if Redis is not configured or not running.
|
|
205
|
+
|
|
204
206
|
---
|
|
205
207
|
|
|
206
208
|
### 6. Security Headers (`Helmet`)
|
|
@@ -216,6 +218,25 @@ webRouter.use(App.helmet());
|
|
|
216
218
|
|
|
217
219
|
---
|
|
218
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
|
+
|
|
219
240
|
## 🐳 Docker CLI Workflow
|
|
220
241
|
|
|
221
242
|
Blue Bird comes with a built-in Docker CLI wrapper that handles both local development database bootstrapping and full-stack VPS production deployments.
|
|
@@ -228,13 +249,17 @@ npx blue-bird docker <command> [options]
|
|
|
228
249
|
|
|
229
250
|
### Supported Actions:
|
|
230
251
|
|
|
231
|
-
- **`npx blue-bird docker start`**: Boots the production stack (Node.js App + MySQL).
|
|
252
|
+
- **`npx blue-bird docker start`**: Boots the production stack (Node.js App + Nginx + MySQL + Redis).
|
|
232
253
|
- **`npx blue-bird docker start mysql`**: Boots the MySQL container only (great for local HTTP development).
|
|
254
|
+
- **`npx blue-bird docker start redis`**: Boots the Redis container only.
|
|
255
|
+
- **`npx blue-bird docker start dbs`**: Boots both database containers (MySQL + Redis).
|
|
233
256
|
- **`npx blue-bird docker stop`**: Stops all active containers.
|
|
234
257
|
- **`npx blue-bird docker build [--no-cache]`**: Builds or updates the Node.js production image.
|
|
235
258
|
- **`npx blue-bird docker ps`**: Lists running project containers and ports.
|
|
236
259
|
- **`npx blue-bird docker logs [app|mysql]`**: Tails logs for the specified container.
|
|
260
|
+
- **`npx blue-bird docker pm2 [args]`**: Runs PM2 commands inside the Node.js application container (e.g. `status`, `monit`, `reload all`).
|
|
237
261
|
- **`npx blue-bird docker db`**: Connects into the container's interactive MySQL shell using credentials from `.env`.
|
|
262
|
+
- **`npx blue-bird docker redis`**: Connects into the container's interactive Redis CLI terminal.
|
|
238
263
|
- **`npx blue-bird docker prune`**: Safely clears orphaned volumes, dangling build caches, and images.
|
|
239
264
|
|
|
240
265
|
---
|
|
@@ -243,9 +268,16 @@ npx blue-bird docker <command> [options]
|
|
|
243
268
|
|
|
244
269
|
You can deploy Blue Bird applications to production using two main workflows:
|
|
245
270
|
|
|
246
|
-
### A. Docker Container Stack (Recommended)
|
|
271
|
+
### A. Docker Container Stack (Highly Recommended)
|
|
272
|
+
|
|
273
|
+
Using the built-in Docker stack is the recommended deployment method because it sets up a complete, hardened production environment automatically:
|
|
274
|
+
- **Nginx Reverse Proxy:** Captures traffic on port 3000 (or custom PORT), serves Astro client-side assets directly from the filesystem to offload the Node.js server, and proxies the rest to Express.
|
|
275
|
+
- **PM2 Clustering:** Launches Node.js in cluster mode inside the container, utilizing all available CPU cores based on `PM2_INSTANCES` configuration (defaulting to 1).
|
|
276
|
+
- **Security Mitigation:** Nginx blocks common malicious scanners (e.g. `/.env`, `/.git`, `/wp-admin`) instantly using a 444 status code and implements a `10r/s` request rate-limit.
|
|
277
|
+
- **Services Stack:** MySQL and Redis are configured in the same bridge network automatically.
|
|
247
278
|
|
|
248
|
-
|
|
279
|
+
To deploy via Docker:
|
|
280
|
+
1. Configure `.env` with production keys, `DEBUG=false` and your custom `TITLE`.
|
|
249
281
|
2. Build the production image:
|
|
250
282
|
```bash
|
|
251
283
|
npx blue-bird docker build
|
|
@@ -255,9 +287,9 @@ You can deploy Blue Bird applications to production using two main workflows:
|
|
|
255
287
|
npx blue-bird docker start prod
|
|
256
288
|
```
|
|
257
289
|
|
|
258
|
-
### B. Standard PM2 / Node.js Runtime
|
|
290
|
+
### B. Standard Standalone PM2 / Node.js Runtime
|
|
259
291
|
|
|
260
|
-
To deploy in a standard Linux environment using PM2
|
|
292
|
+
If you choose to run outside of Docker, you must set up the reverse proxy and databases manually. To deploy in a standard Linux environment using PM2:
|
|
261
293
|
|
|
262
294
|
1. Install PM2 globally:
|
|
263
295
|
```bash
|
|
@@ -265,7 +297,7 @@ To deploy in a standard Linux environment using PM2 process manager:
|
|
|
265
297
|
```
|
|
266
298
|
2. Start the application under PM2:
|
|
267
299
|
```bash
|
|
268
|
-
pm2 start index.js --name "bluebird-app"
|
|
300
|
+
pm2 start index.js --name "bluebird-app" --node-args="--env-file=.env" -i max
|
|
269
301
|
```
|
|
270
302
|
3. Monitor status:
|
|
271
303
|
```bash
|
|
@@ -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
|
@@ -1,38 +1,101 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
|
|
1
3
|
const CACHE = {};
|
|
2
4
|
|
|
5
|
+
let redisClient = null;
|
|
6
|
+
let isRedisConnected = false;
|
|
7
|
+
const redisHost = process.env.REDIS_HOST ?? false;
|
|
8
|
+
const redisPort = process.env.REDIS_PORT ?? 6379;
|
|
9
|
+
const redisPassword = process.env.REDIS_PASSWORD || "";
|
|
10
|
+
const redisUrl = redisPassword
|
|
11
|
+
? `redis://:${redisPassword}@${redisHost}:${redisPort}`
|
|
12
|
+
: `redis://${redisHost}:${redisPort}`;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Initializes the Redis client connection if REDIS_HOST env is set.
|
|
16
|
+
* @returns {Promise<void>}
|
|
17
|
+
*/
|
|
18
|
+
async function initRedis() {
|
|
19
|
+
if (redisClient) return;
|
|
20
|
+
if (!redisHost) return;
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
const { createClient } = await import("redis");
|
|
24
|
+
let host = redisHost;
|
|
25
|
+
if (host === "localhost" && fs.existsSync("/.dockerenv")) {
|
|
26
|
+
host = "redis";
|
|
27
|
+
}
|
|
28
|
+
const url = redisUrl;
|
|
29
|
+
|
|
30
|
+
redisClient = createClient({ url });
|
|
31
|
+
redisClient.on("error", () => {
|
|
32
|
+
isRedisConnected = false;
|
|
33
|
+
});
|
|
34
|
+
await redisClient.connect();
|
|
35
|
+
isRedisConnected = true;
|
|
36
|
+
} catch (err) {
|
|
37
|
+
redisClient = null;
|
|
38
|
+
isRedisConnected = false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
initRedis().catch(() => {});
|
|
43
|
+
|
|
3
44
|
setInterval(() => {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
}
|
|
45
|
+
const now = Date.now();
|
|
46
|
+
for (const key in CACHE) {
|
|
47
|
+
if (CACHE[key].expiry <= now) {
|
|
48
|
+
delete CACHE[key];
|
|
9
49
|
}
|
|
50
|
+
}
|
|
10
51
|
}, 300000).unref();
|
|
52
|
+
|
|
11
53
|
/**
|
|
12
|
-
*
|
|
13
|
-
* Caches JSON responses based on the request URL.
|
|
54
|
+
* High-performance Caching class supporting both local memory and Redis backends.
|
|
14
55
|
*/
|
|
15
56
|
class Cache {
|
|
16
57
|
/**
|
|
17
|
-
*
|
|
18
|
-
* @param {number} [seconds=60] -
|
|
19
|
-
* @returns {Function} Express middleware
|
|
20
|
-
* @example
|
|
21
|
-
* router.get("/stats", Cache.middleware(120), (req, res) => {
|
|
22
|
-
* res.json({ ok: true });
|
|
23
|
-
* });
|
|
58
|
+
* Express middleware to cache route JSON and HTML responses.
|
|
59
|
+
* @param {number} [seconds=60] - Expiry time in seconds.
|
|
60
|
+
* @returns {Function} Express middleware.
|
|
24
61
|
*/
|
|
25
62
|
static middleware(seconds = 60) {
|
|
26
|
-
return (req, res, next) => {
|
|
63
|
+
return async (req, res, next) => {
|
|
27
64
|
const key = req.originalUrl;
|
|
28
65
|
|
|
29
|
-
if (
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
66
|
+
if (redisHost && !redisClient) {
|
|
67
|
+
await initRedis().catch(() => {});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (isRedisConnected && redisClient) {
|
|
71
|
+
try {
|
|
72
|
+
const cachedData = await redisClient.get(key);
|
|
73
|
+
if (cachedData) {
|
|
74
|
+
const cached = JSON.parse(cachedData);
|
|
75
|
+
if (cached.type === "json") {
|
|
76
|
+
return res.json(cached.data);
|
|
77
|
+
} else {
|
|
78
|
+
res.type("text/html");
|
|
79
|
+
res.set("X-Blue-Bird-Cache", "HIT");
|
|
80
|
+
return res.send(cached.data);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
} catch (err) {
|
|
84
|
+
isRedisConnected = false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (!isRedisConnected || !redisClient) {
|
|
89
|
+
if (CACHE[key] && CACHE[key].expiry > Date.now()) {
|
|
90
|
+
const cached = CACHE[key];
|
|
91
|
+
if (cached.type === "json") {
|
|
92
|
+
res.set("X-Blue-Bird-Cache", "HIT");
|
|
93
|
+
return res.json(cached.data);
|
|
94
|
+
} else {
|
|
95
|
+
res.type("text/html");
|
|
96
|
+
res.set("X-Blue-Bird-Cache", "HIT");
|
|
97
|
+
return res.send(cached.data);
|
|
98
|
+
}
|
|
36
99
|
}
|
|
37
100
|
}
|
|
38
101
|
|
|
@@ -40,33 +103,66 @@ class Cache {
|
|
|
40
103
|
const originalSend = res.send.bind(res);
|
|
41
104
|
let cachedInRequest = false;
|
|
42
105
|
|
|
43
|
-
res.json = (body) => {
|
|
106
|
+
res.json = async (body) => {
|
|
44
107
|
if (!cachedInRequest) {
|
|
45
|
-
|
|
108
|
+
cachedInRequest = true;
|
|
109
|
+
const cacheObject = {
|
|
46
110
|
type: "json",
|
|
47
111
|
data: body,
|
|
48
112
|
expiry: Date.now() + seconds * 1000,
|
|
49
113
|
};
|
|
50
|
-
|
|
114
|
+
if (isRedisConnected && redisClient) {
|
|
115
|
+
try {
|
|
116
|
+
await redisClient.set(key, JSON.stringify(cacheObject), {
|
|
117
|
+
EX: seconds,
|
|
118
|
+
});
|
|
119
|
+
} catch (err) {
|
|
120
|
+
CACHE[key] = cacheObject;
|
|
121
|
+
}
|
|
122
|
+
} else {
|
|
123
|
+
CACHE[key] = cacheObject;
|
|
124
|
+
}
|
|
51
125
|
}
|
|
126
|
+
res.set("X-Blue-Bird-Cache", "MISS");
|
|
52
127
|
return originalJson(body);
|
|
53
128
|
};
|
|
54
129
|
|
|
55
|
-
res.send = (body) => {
|
|
130
|
+
res.send = async (body) => {
|
|
56
131
|
if (!cachedInRequest && typeof body === "string") {
|
|
57
|
-
|
|
132
|
+
cachedInRequest = true;
|
|
133
|
+
const cacheObject = {
|
|
58
134
|
type: "html",
|
|
59
135
|
data: body,
|
|
60
136
|
expiry: Date.now() + seconds * 1000,
|
|
61
137
|
};
|
|
62
|
-
|
|
138
|
+
if (isRedisConnected && redisClient) {
|
|
139
|
+
try {
|
|
140
|
+
await redisClient.set(key, JSON.stringify(cacheObject), {
|
|
141
|
+
EX: seconds,
|
|
142
|
+
});
|
|
143
|
+
} catch (err) {
|
|
144
|
+
CACHE[key] = cacheObject;
|
|
145
|
+
}
|
|
146
|
+
} else {
|
|
147
|
+
CACHE[key] = cacheObject;
|
|
148
|
+
}
|
|
63
149
|
}
|
|
150
|
+
res.set("X-Blue-Bird-Cache", "MISS");
|
|
64
151
|
return originalSend(body);
|
|
65
152
|
};
|
|
66
153
|
|
|
154
|
+
res.set("X-Blue-Bird-Cache", "MISS");
|
|
67
155
|
next();
|
|
68
156
|
};
|
|
69
157
|
}
|
|
70
158
|
}
|
|
71
159
|
|
|
72
|
-
|
|
160
|
+
/**
|
|
161
|
+
* Returns the active Redis client if connected.
|
|
162
|
+
* @returns {Object|null} The Redis client instance or null.
|
|
163
|
+
*/
|
|
164
|
+
export function getRedisClient() {
|
|
165
|
+
return isRedisConnected ? redisClient : null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export default Cache;
|
package/core/cli/docker.js
CHANGED
|
@@ -58,27 +58,43 @@ async function startCommand(service) {
|
|
|
58
58
|
checkComposeFile();
|
|
59
59
|
|
|
60
60
|
if (service === "mysql" || service === "--mysql" || service === "dev") {
|
|
61
|
-
console.log(chalk.cyan("Starting MySQL container
|
|
61
|
+
console.log(chalk.cyan("Starting MySQL container..."));
|
|
62
62
|
const code = await runCmd("docker", ["compose", "up", "-d", "mysql"]);
|
|
63
63
|
if (code === 0) {
|
|
64
|
-
console.log(chalk.green("MySQL started.
|
|
65
|
-
console.log(chalk.blue("To also run the app in Docker (production), use: npx blue-bird docker start prod"));
|
|
64
|
+
console.log(chalk.green("MySQL started."));
|
|
66
65
|
} else {
|
|
67
66
|
console.error(chalk.red("Error starting MySQL."));
|
|
68
67
|
process.exit(1);
|
|
69
68
|
}
|
|
69
|
+
} else if (service === "redis" || service === "--redis") {
|
|
70
|
+
console.log(chalk.cyan("Starting Redis container..."));
|
|
71
|
+
const code = await runCmd("docker", ["compose", "up", "-d", "redis"]);
|
|
72
|
+
if (code === 0) {
|
|
73
|
+
console.log(chalk.green("Redis started."));
|
|
74
|
+
} else {
|
|
75
|
+
console.error(chalk.red("Error starting Redis."));
|
|
76
|
+
process.exit(1);
|
|
77
|
+
}
|
|
78
|
+
} else if (service === "dbs" || service === "databases") {
|
|
79
|
+
console.log(chalk.cyan("Starting Database containers (MySQL + Redis)..."));
|
|
80
|
+
const code = await runCmd("docker", ["compose", "up", "-d", "mysql", "redis"]);
|
|
81
|
+
if (code === 0) {
|
|
82
|
+
console.log(chalk.green("Database containers started."));
|
|
83
|
+
} else {
|
|
84
|
+
console.error(chalk.red("Error starting databases."));
|
|
85
|
+
process.exit(1);
|
|
86
|
+
}
|
|
70
87
|
} else if (service === "prod" || service === "app" || service === "--app" || !service) {
|
|
71
|
-
console.log(chalk.cyan("Starting production stack (MySQL +
|
|
88
|
+
console.log(chalk.cyan("Starting production stack (MySQL + Redis + App + Nginx)..."));
|
|
72
89
|
const code = await runCmd("docker", ["compose", "--profile", "prod", "up", "-d"]);
|
|
73
90
|
if (code === 0) {
|
|
74
91
|
console.log(chalk.green("Production stack started."));
|
|
75
|
-
console.log(chalk.blue("View logs with: npx blue-bird docker logs"));
|
|
76
92
|
} else {
|
|
77
93
|
console.error(chalk.red("Error starting production stack."));
|
|
78
94
|
process.exit(1);
|
|
79
95
|
}
|
|
80
96
|
} else {
|
|
81
|
-
console.error(chalk.red(`Unknown service '${service}'. Use: mysql
|
|
97
|
+
console.error(chalk.red(`Unknown service '${service}'. Use: mysql, redis, dbs, prod.`));
|
|
82
98
|
process.exit(1);
|
|
83
99
|
}
|
|
84
100
|
}
|
|
@@ -99,13 +115,18 @@ async function stopCommand(service) {
|
|
|
99
115
|
await runCmd("docker", ["compose", "stop", "mysql"]);
|
|
100
116
|
await runCmd("docker", ["compose", "rm", "-f", "mysql"]);
|
|
101
117
|
console.log(chalk.green("MySQL stopped."));
|
|
118
|
+
} else if (service === "redis" || service === "--redis") {
|
|
119
|
+
console.log(chalk.cyan("Stopping Redis..."));
|
|
120
|
+
await runCmd("docker", ["compose", "stop", "redis"]);
|
|
121
|
+
await runCmd("docker", ["compose", "rm", "-f", "redis"]);
|
|
122
|
+
console.log(chalk.green("Redis stopped."));
|
|
102
123
|
} else if (service === "app" || service === "--app") {
|
|
103
124
|
console.log(chalk.cyan("Stopping Node.js app container..."));
|
|
104
125
|
await runCmd("docker", ["compose", "--profile", "prod", "stop", "app"]);
|
|
105
126
|
await runCmd("docker", ["compose", "--profile", "prod", "rm", "-f", "app"]);
|
|
106
127
|
console.log(chalk.green("App container stopped."));
|
|
107
128
|
} else {
|
|
108
|
-
console.error(chalk.red(`Unknown service '${service}'. Use: all
|
|
129
|
+
console.error(chalk.red(`Unknown service '${service}'. Use: all, mysql, redis, app.`));
|
|
109
130
|
process.exit(1);
|
|
110
131
|
}
|
|
111
132
|
}
|
|
@@ -235,6 +256,30 @@ async function pruneCommand(forceOpt, allOpt) {
|
|
|
235
256
|
await runCmd("docker", ["system", "df"]);
|
|
236
257
|
}
|
|
237
258
|
|
|
259
|
+
/**
|
|
260
|
+
* Executes PM2 commands inside the Node.js application container.
|
|
261
|
+
* @param {string[]} pm2Args - Arguments to pass to PM2.
|
|
262
|
+
*/
|
|
263
|
+
async function pm2Command(pm2Args = []) {
|
|
264
|
+
checkComposeFile();
|
|
265
|
+
const subCommand = pm2Args[0] || "status";
|
|
266
|
+
const cmdArgs = ["compose", "exec", "app", "pm2", subCommand, ...pm2Args.slice(1)];
|
|
267
|
+
const code = await runCmd("docker", cmdArgs);
|
|
268
|
+
if (code !== 0) {
|
|
269
|
+
console.error(chalk.red("Error running PM2 command. Make sure the production stack is started."));
|
|
270
|
+
process.exit(1);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
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
|
+
|
|
238
283
|
/**
|
|
239
284
|
* Entry point for Blue Bird CLI Docker subcommands.
|
|
240
285
|
*/
|
|
@@ -286,6 +331,12 @@ async function main() {
|
|
|
286
331
|
case "logs":
|
|
287
332
|
await logsCommand(args[1], args[2]);
|
|
288
333
|
break;
|
|
334
|
+
case "pm2":
|
|
335
|
+
await pm2Command(args.slice(1));
|
|
336
|
+
break;
|
|
337
|
+
case "redis":
|
|
338
|
+
await redisCommand();
|
|
339
|
+
break;
|
|
289
340
|
case "mysql":
|
|
290
341
|
case "db": {
|
|
291
342
|
let user, password, db, root = false;
|
|
@@ -312,7 +363,7 @@ async function main() {
|
|
|
312
363
|
}
|
|
313
364
|
default:
|
|
314
365
|
console.log(chalk.yellow(`Unknown docker command: ${command}`));
|
|
315
|
-
console.log("Available commands: start, stop, build, ps, logs, mysql/db, df/disk, prune/clean");
|
|
366
|
+
console.log("Available commands: start, stop, build, ps, logs, pm2, mysql/db, redis, df/disk, prune/clean");
|
|
316
367
|
}
|
|
317
368
|
}
|
|
318
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/Dockerfile
CHANGED
|
@@ -1,25 +1,16 @@
|
|
|
1
|
-
# ─────────────────────────────────────────────────────────────────────────────
|
|
2
|
-
# Blue Bird Framework — Production Dockerfile
|
|
3
|
-
# Node.js 24-alpine for lightweight, production-ready environment
|
|
4
|
-
# ─────────────────────────────────────────────────────────────────────────────
|
|
5
1
|
FROM node:24.7.0-alpine3.21
|
|
6
2
|
|
|
7
|
-
# Set environment variables
|
|
8
3
|
ENV NODE_ENV=production
|
|
9
4
|
|
|
10
5
|
WORKDIR /app
|
|
11
6
|
|
|
12
|
-
# Copy package files first (layer cache optimization)
|
|
13
7
|
COPY package*.json ./
|
|
14
8
|
|
|
15
|
-
|
|
16
|
-
RUN npm ci --omit=dev
|
|
9
|
+
RUN npm ci --omit=dev && npm install -g pm2
|
|
17
10
|
|
|
18
|
-
# Copy project source
|
|
19
11
|
COPY . .
|
|
20
12
|
|
|
21
|
-
# Expose the application port
|
|
22
13
|
EXPOSE 3000
|
|
23
14
|
|
|
24
|
-
|
|
25
|
-
|
|
15
|
+
CMD ["sh", "-c", "pm2-runtime start index.js -i ${PM2_INSTANCES:-1}"]
|
|
16
|
+
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
user nginx;
|
|
2
|
+
worker_processes auto;
|
|
3
|
+
|
|
4
|
+
error_log /var/log/nginx/error.log notice;
|
|
5
|
+
pid /var/run/nginx.pid;
|
|
6
|
+
|
|
7
|
+
events {
|
|
8
|
+
worker_connections 1024;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
http {
|
|
12
|
+
include /etc/nginx/mime.types;
|
|
13
|
+
default_type application/octet-stream;
|
|
14
|
+
|
|
15
|
+
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
|
16
|
+
'$status $body_bytes_sent "$http_referer" '
|
|
17
|
+
'"$http_user_agent" "$http_x_forwarded_for"';
|
|
18
|
+
|
|
19
|
+
access_log /var/log/nginx/access.log main;
|
|
20
|
+
|
|
21
|
+
sendfile on;
|
|
22
|
+
tcp_nopush on;
|
|
23
|
+
tcp_nodelay on;
|
|
24
|
+
keepalive_timeout 65;
|
|
25
|
+
types_hash_max_size 2048;
|
|
26
|
+
|
|
27
|
+
gzip on;
|
|
28
|
+
gzip_disable "msie6";
|
|
29
|
+
gzip_vary on;
|
|
30
|
+
gzip_proxied any;
|
|
31
|
+
gzip_comp_level 6;
|
|
32
|
+
gzip_buffers 16 8k;
|
|
33
|
+
gzip_http_version 1.1;
|
|
34
|
+
gzip_min_length 256;
|
|
35
|
+
gzip_types
|
|
36
|
+
text/plain
|
|
37
|
+
text/css
|
|
38
|
+
application/json
|
|
39
|
+
application/javascript
|
|
40
|
+
application/x-javascript
|
|
41
|
+
text/xml
|
|
42
|
+
application/xml
|
|
43
|
+
application/xml+rss
|
|
44
|
+
text/javascript
|
|
45
|
+
image/svg+xml;
|
|
46
|
+
|
|
47
|
+
limit_req_zone $binary_remote_addr zone=bluebird_limit:10m rate=10r/s;
|
|
48
|
+
|
|
49
|
+
server {
|
|
50
|
+
listen 80;
|
|
51
|
+
server_name localhost;
|
|
52
|
+
root /app/frontend/dist/client;
|
|
53
|
+
|
|
54
|
+
resolver 127.0.0.11 valid=5s;
|
|
55
|
+
|
|
56
|
+
location ~* /(\.env|\.git|wp-content|wp-admin|xmlrpc\.php) {
|
|
57
|
+
return 444;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
location / {
|
|
61
|
+
try_files $uri @node_app;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
location /_astro/ {
|
|
65
|
+
expires max;
|
|
66
|
+
add_header Cache-Control "public, max-age=31536000, immutable";
|
|
67
|
+
try_files $uri =404;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
location @node_app {
|
|
71
|
+
limit_req zone=bluebird_limit burst=20 nodelay;
|
|
72
|
+
|
|
73
|
+
set $upstream_target http://app:3000;
|
|
74
|
+
proxy_pass $upstream_target;
|
|
75
|
+
proxy_http_version 1.1;
|
|
76
|
+
proxy_set_header Connection "";
|
|
77
|
+
proxy_set_header Host $host;
|
|
78
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
79
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
80
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
package/docker-compose.yml
CHANGED
|
@@ -1,28 +1,12 @@
|
|
|
1
|
-
# ─────────────────────────────────────────────────────────────────────────────
|
|
2
|
-
# Blue Bird Framework — Docker Compose
|
|
3
|
-
#
|
|
4
|
-
# DEVELOPMENT / DATABASE ONLY:
|
|
5
|
-
# npx blue-bird docker start mysql → Only MySQL container
|
|
6
|
-
#
|
|
7
|
-
# PRODUCTION:
|
|
8
|
-
# npx blue-bird docker start prod → MySQL + Node.js app container
|
|
9
|
-
# npx blue-bird docker stop → Stop all containers
|
|
10
|
-
#
|
|
11
|
-
# Multi-project VPS support:
|
|
12
|
-
# Each project uses TITLE (or sanitised TITLE) to name
|
|
13
|
-
# containers and networks uniquely.
|
|
14
|
-
# ─────────────────────────────────────────────────────────────────────────────
|
|
15
|
-
|
|
16
1
|
services:
|
|
17
|
-
# ── Node.js App (Production only) ─────────────────────────────────────────
|
|
18
2
|
app:
|
|
19
3
|
build:
|
|
20
4
|
context: .
|
|
21
5
|
dockerfile: docker/Dockerfile
|
|
22
6
|
container_name: ${TITLE:-bluebird}-app
|
|
23
7
|
restart: unless-stopped
|
|
24
|
-
|
|
25
|
-
- "
|
|
8
|
+
expose:
|
|
9
|
+
- "3000"
|
|
26
10
|
volumes:
|
|
27
11
|
- .:/app
|
|
28
12
|
- /app/node_modules
|
|
@@ -32,15 +16,36 @@ services:
|
|
|
32
16
|
- NODE_ENV=production
|
|
33
17
|
- DEBUG=false
|
|
34
18
|
- DATABASE_URL=mysql://root:${DB_PASSWORD:-root}@mysql:3306/${DB_NAME:-blue_bird}
|
|
19
|
+
- REDIS_HOST=redis
|
|
20
|
+
- REDIS_PORT=6379
|
|
21
|
+
- PORT=3000
|
|
35
22
|
depends_on:
|
|
36
23
|
mysql:
|
|
37
24
|
condition: service_healthy
|
|
25
|
+
redis:
|
|
26
|
+
condition: service_healthy
|
|
27
|
+
networks:
|
|
28
|
+
- bluebird_net
|
|
29
|
+
profiles:
|
|
30
|
+
- prod
|
|
31
|
+
|
|
32
|
+
nginx:
|
|
33
|
+
image: nginx:1.27-alpine
|
|
34
|
+
container_name: ${TITLE:-bluebird}-nginx
|
|
35
|
+
restart: unless-stopped
|
|
36
|
+
ports:
|
|
37
|
+
- "${PORT:-3000}:80"
|
|
38
|
+
volumes:
|
|
39
|
+
- .:/app:ro
|
|
40
|
+
- ./docker/nginx.conf:/etc/nginx/nginx.conf:ro
|
|
41
|
+
depends_on:
|
|
42
|
+
app:
|
|
43
|
+
condition: service_started
|
|
38
44
|
networks:
|
|
39
45
|
- bluebird_net
|
|
40
46
|
profiles:
|
|
41
47
|
- prod
|
|
42
48
|
|
|
43
|
-
# ── MySQL Database ─────────────────────────────────────────────────────────
|
|
44
49
|
mysql:
|
|
45
50
|
image: mysql:8.0
|
|
46
51
|
container_name: ${TITLE:-bluebird}-mysql
|
|
@@ -61,10 +66,28 @@ services:
|
|
|
61
66
|
networks:
|
|
62
67
|
- bluebird_net
|
|
63
68
|
|
|
69
|
+
redis:
|
|
70
|
+
image: redis:7-alpine
|
|
71
|
+
container_name: ${TITLE:-bluebird}-redis
|
|
72
|
+
restart: unless-stopped
|
|
73
|
+
ports:
|
|
74
|
+
- "${REDIS_PORT:-6379}:6379"
|
|
75
|
+
volumes:
|
|
76
|
+
- redis_data:/data
|
|
77
|
+
networks:
|
|
78
|
+
- bluebird_net
|
|
79
|
+
healthcheck:
|
|
80
|
+
test: ["CMD", "redis-cli", "ping"]
|
|
81
|
+
interval: 5s
|
|
82
|
+
timeout: 3s
|
|
83
|
+
retries: 5
|
|
84
|
+
|
|
64
85
|
volumes:
|
|
65
86
|
mysql_data:
|
|
87
|
+
redis_data:
|
|
66
88
|
|
|
67
89
|
networks:
|
|
68
90
|
bluebird_net:
|
|
69
91
|
name: ${TITLE:-bluebird}_network
|
|
70
92
|
driver: bridge
|
|
93
|
+
|
package/frontend/src/http/api.js
CHANGED
|
@@ -15,5 +15,10 @@ export function apiUrl(path = "", requestUrl) {
|
|
|
15
15
|
if (!requestUrl) {
|
|
16
16
|
throw new Error("apiUrl: requestUrl is required in production (pass Astro.url)");
|
|
17
17
|
}
|
|
18
|
-
|
|
18
|
+
const url = new URL(`/${path}`, requestUrl);
|
|
19
|
+
if (url.hostname === "localhost") {
|
|
20
|
+
url.hostname = "127.0.0.1";
|
|
21
|
+
url.port = port;
|
|
22
|
+
}
|
|
23
|
+
return url.href;
|
|
19
24
|
}
|
|
@@ -4,11 +4,17 @@ import { apiUrl } from "../http/api";
|
|
|
4
4
|
|
|
5
5
|
let apiData = null;
|
|
6
6
|
try {
|
|
7
|
-
const
|
|
7
|
+
const targetUrl = apiUrl("api/", Astro.url);
|
|
8
|
+
console.log("[index.astro] request URL:", Astro.url.href);
|
|
9
|
+
console.log("[index.astro] target URL:", targetUrl);
|
|
10
|
+
const response = await fetch(targetUrl);
|
|
8
11
|
apiData = await response.json();
|
|
9
12
|
console.log("[index.astro] /api/ response:", apiData);
|
|
10
13
|
} catch (err) {
|
|
11
14
|
console.error("[index.astro] fetch error:", err.message);
|
|
15
|
+
if (err.cause) {
|
|
16
|
+
console.error("[index.astro] fetch error cause:", err.cause);
|
|
17
|
+
}
|
|
12
18
|
}
|
|
13
19
|
---
|
|
14
20
|
|
package/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@seip/blue-bird",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.2",
|
|
4
4
|
"description": "Express opinionated framework with HTML rendering, API architecture, built-in JWT auth, validation, caching, and SEO",
|
|
5
5
|
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
"./*": "./*"
|
|
8
|
+
},
|
|
6
9
|
"bin": {
|
|
7
10
|
"blue-bird": "core/cli/init.js"
|
|
8
11
|
},
|
|
@@ -62,6 +65,8 @@
|
|
|
62
65
|
"helmet": "^8.1.0",
|
|
63
66
|
"jsonwebtoken": "^9.0.2",
|
|
64
67
|
"multer": "^2.0.2",
|
|
68
|
+
"mysql2": "^3.22.6",
|
|
69
|
+
"redis": "^4.7.0",
|
|
65
70
|
"xss": "^1.0.15"
|
|
66
71
|
}
|
|
67
72
|
}
|