@seip/blue-bird 0.6.4 → 0.7.1

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.
@@ -1,11 +1,15 @@
1
1
  import Router from "@seip/blue-bird/core/router.js";
2
2
  import Validator from "@seip/blue-bird/core/validate.js";
3
3
  import Cache from "@seip/blue-bird/core/cache.js";
4
- import Auth from "@seip/blue-bird/core/auth.js"
4
+ import Auth from "@seip/blue-bird/core/auth.js";
5
5
 
6
- const routerApiExample = new Router("/api");
6
+ const routerApi = new Router("/api");
7
7
 
8
- routerApiExample.get("/users", (req, res) => {
8
+ routerApi.get("//", (req, res) => {
9
+ res.json({ api: true, message: "Bluebird API", time: Date.now() });
10
+ });
11
+
12
+ routerApi.get("/users", (req, res) => {
9
13
  const users = [
10
14
  {
11
15
  name: "John Doe",
@@ -26,28 +30,28 @@ const loginSchema = {
26
30
 
27
31
  const loginValidator = new Validator(loginSchema);
28
32
 
29
- routerApiExample.post("/login", loginValidator.middleware(), (req, res) => {
33
+ routerApi.post("/login", loginValidator.middleware(), (req, res) => {
30
34
  res.json({ message: "Login successful", body: req.body });
31
35
  });
32
36
 
33
- routerApiExample.get("/cache", Cache.middleware(), async (req, res) => {
34
- await new Promise(resolve => setTimeout(resolve, 2000));
37
+ routerApi.get("/cache", Cache.middleware(), async (req, res) => {
38
+ await new Promise((resolve) => setTimeout(resolve, 2000));
35
39
  res.json({ message: "Cache successful" });
36
- })
40
+ });
37
41
 
38
- routerApiExample.get("/auth_generate", async (req, res) => {
39
- const token = await Auth.login(res, { id: 1, name: "John Doe" })
42
+ routerApi.get("/auth_generate", async (req, res) => {
43
+ const token = await Auth.login(res, { id: 1, name: "John Doe" });
40
44
  res.json({ message: "Auth successful", token });
41
- })
45
+ });
42
46
 
43
- routerApiExample.get("/auth_logout", async (req, res) => {
44
- await Auth.logout(res)
47
+ routerApi.get("/auth_logout", async (req, res) => {
48
+ await Auth.logout(res);
45
49
  res.json({ message: "Auth successful" });
46
- })
50
+ });
47
51
 
48
- routerApiExample.get("/auth_verify", Auth.protect(), (req, res) => {
49
- const userInfo = req.user
52
+ routerApi.get("/auth_verify", Auth.protect(), (req, res) => {
53
+ const userInfo = req.user;
50
54
  res.json({ message: "Auth successful", user: userInfo });
51
- })
55
+ });
52
56
 
53
- export default routerApiExample;
57
+ export default routerApi;
package/core/app.js CHANGED
@@ -10,8 +10,6 @@ import compression from "compression";
10
10
  import Config from "./config.js";
11
11
  import Logger from "./logger.js";
12
12
  import Debug from "./debug.js";
13
- import Template from "./template.js";
14
- import SEO from "./seo.js";
15
13
 
16
14
  const __dirname = Config.dirname();
17
15
  const props = Config.props();
@@ -36,7 +34,8 @@ class App {
36
34
  * @param {boolean} [options.cookieParser=true] - Whether to enable cookie parsing.
37
35
  * @param {boolean|Object} [options.rateLimit=false] - Enable global rate limiting.
38
36
  * @param {boolean|Object} [options.swagger=false] - Enable swagger.
39
- * @param {boolean} [options.compression=true] - Enable Gzip compression.
37
+ * @param {boolean} [options.compression=true] - Enable compression.
38
+ * @param {boolean} [options.astro=true] - Astro handler.
40
39
  * @example
41
40
  * const app = new App({
42
41
  * routes: [],
@@ -55,7 +54,14 @@ class App {
55
54
  * info: { title: "Blue Bird API", version: "1.0.0", description: "API Documentation" },
56
55
  * url: "http://localhost:8000"
57
56
  * },
58
- * compression: true
57
+ * compression:true,
58
+ * astro: {
59
+ * server: true,
60
+ * serverEntry: "./frontend/dist/server/entry.mjs",
61
+ * client: false,
62
+ * clientDir: "./frontend/dist/client",
63
+ * base: "/"
64
+ * }
59
65
  * });
60
66
  */
61
67
  constructor(options = {}) {
@@ -75,6 +81,7 @@ class App {
75
81
  this.rateLimit = options.rateLimit ?? false;
76
82
  this.swagger = options.swagger ?? false;
77
83
  this.compression = options.compression ?? true;
84
+ this.astro = options.astro || false;
78
85
  this.loggerInstance = new Logger();
79
86
  /** @type {Set<import('http').ServerResponse>} */
80
87
  this._hotReloadClients = new Set();
@@ -121,13 +128,16 @@ class App {
121
128
 
122
129
  if (this.static.path)
123
130
  this.app.use(
124
- express.static(
125
- path.join(__dirname, this.static.path),
126
- { ...this.static.options, setHeaders: (res) => {
127
- res.setHeader("X-Powered-By", "Blue Bird");
128
- res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
129
- }},
130
- ),
131
+ express.static(path.join(__dirname, this.static.path), {
132
+ ...this.static.options,
133
+ setHeaders: (res) => {
134
+ res.setHeader("X-Powered-By", "Blue Bird");
135
+ res.setHeader(
136
+ "Cache-Control",
137
+ "public, max-age=31536000, immutable",
138
+ );
139
+ },
140
+ }),
131
141
  );
132
142
 
133
143
  this.app.use(cors(this.cors));
@@ -165,13 +175,12 @@ class App {
165
175
  if (this.logger || props.debug) this._middlewareLogger(this.logger);
166
176
 
167
177
  this.app.use((req, res, next) => {
168
- res.setHeader("X-Powered-By", "Blue Bird");
169
- next();
178
+ res.setHeader("X-Powered-By", "Blue Bird");
179
+ next();
170
180
  });
171
181
 
172
182
  if (props.debug) {
173
183
  Debug.middlewareMetrics(this.app);
174
- this._setupHotReload();
175
184
  }
176
185
 
177
186
  if (this.swagger) {
@@ -196,70 +205,65 @@ class App {
196
205
 
197
206
  this._dispatchRoutes();
198
207
 
199
- SEO.registerEndpoints(this.app);
200
-
201
- if (this.notFound) this._notFoundDefault();
202
-
203
- this._errorHandler();
204
- }
205
-
206
- /**
207
- * Sets up hot-reload using Server-Sent Events (SSE).
208
- * Watches the frontend/ directory for .html, .css, .js file changes and notifies connected browsers.
209
- * Also clears the Template cache on file changes so fresh content is served.
210
- * Only active when DEBUG=true in .env.
211
- * @private
212
- */
213
- _setupHotReload() {
214
- this.app.get("/__hot-reload", (req, res) => {
215
- res.setHeader("x-no-compression", "true");
216
- res.writeHead(200, {
217
- "Content-Type": "text/event-stream",
218
- "Cache-Control": "no-cache",
219
- "Connection": "keep-alive",
220
- "X-Accel-Buffering": "no",
221
- });
222
- res.write("data: connected\n\n");
223
- if (typeof res.flush === "function") {
224
- res.flush();
208
+ if (this.astro) {
209
+ const defaultAstro = {
210
+ server: true,
211
+ serverEntry: "./frontend/dist/server/entry.mjs",
212
+ client: false,
213
+ clientDir: "./frontend/dist/client",
214
+ base: "/",
215
+ };
216
+ const astroConfig =
217
+ typeof this.astro === "object"
218
+ ? { ...defaultAstro, ...this.astro }
219
+ : { ...defaultAstro };
220
+
221
+ if (astroConfig.client) {
222
+ const clientPath = path.resolve(astroConfig.clientDir);
223
+ if (fs.existsSync(clientPath)) {
224
+ this.app.use(astroConfig.base, express.static(clientPath));
225
+ console.log(
226
+ chalk.green(
227
+ `[OK] Astro Static Client Assets registered at ${astroConfig.base}`,
228
+ ),
229
+ );
230
+ } else {
231
+ console.warn(
232
+ chalk.yellow(
233
+ `[WARN] Astro client directory not found at: ${clientPath}`,
234
+ ),
235
+ );
236
+ }
225
237
  }
226
- this._hotReloadClients.add(res);
227
- req.on("close", () => {
228
- this._hotReloadClients.delete(res);
229
- });
230
- });
231
238
 
232
- const frontendPath = path.join(__dirname, "frontend");
233
- let debounceTimer = null;
234
-
235
- const notifyClients = () => {
236
- this._hotReloadClients.forEach((client) => {
237
- try {
238
- client.write("data: reload\n\n");
239
- if (typeof client.flush === "function") {
240
- client.flush();
239
+ if (astroConfig.server) {
240
+ const entryPath = path.resolve(astroConfig.serverEntry);
241
+ if (fs.existsSync(entryPath)) {
242
+ try {
243
+ const { handler: ssrHandler } = await import(entryPath);
244
+ this.app.use(ssrHandler);
245
+ console.log(
246
+ chalk.green("[OK] Astro SSR Handler registered successfully."),
247
+ );
248
+ } catch (error) {
249
+ console.error(
250
+ chalk.red("[ERROR] Failed to load Astro SSR Handler:"),
251
+ error.message,
252
+ );
241
253
  }
242
- } catch (_) {
243
- this._hotReloadClients.delete(client);
244
- }
245
- });
246
- };
247
-
248
- try {
249
- fs.watch(frontendPath, { recursive: true }, (eventType, filename) => {
250
- if (!filename) return;
251
- if (/\.(html|css|js)$/i.test(filename)) {
252
- if (debounceTimer) clearTimeout(debounceTimer);
253
- debounceTimer = setTimeout(() => {
254
- console.log(chalk.magenta(`[Hot Reload] ${filename} changed`));
255
- Template.clearCache();
256
- notifyClients();
257
- }, 200);
254
+ } else {
255
+ console.warn(
256
+ chalk.yellow(
257
+ `[WARN] Astro build entrypoint not found at: ${entryPath}`,
258
+ ),
259
+ );
258
260
  }
259
- });
260
- } catch (_) {
261
- console.log(chalk.yellow("[Hot Reload] Could not watch frontend/ directory"));
261
+ }
262
262
  }
263
+
264
+ if (this.notFound) this._notFoundDefault();
265
+
266
+ this._errorHandler();
263
267
  }
264
268
 
265
269
  /**
@@ -279,6 +283,7 @@ class App {
279
283
  Object.keys(req.params).length > 0
280
284
  ? ` ${JSON.stringify(req.params)}`
281
285
  : "";
286
+
282
287
  const ip = req.ip;
283
288
  const now = new Date().toISOString();
284
289
  const time = `${now.split("T")[0]} ${now.split("T")[1].split(".")[0]}`;
@@ -357,14 +362,14 @@ class App {
357
362
  this.app.listen(this.port, () => {
358
363
  console.log(
359
364
  chalk.bold.blue("Blue Bird Server Online\n") +
360
- chalk.bold.cyan("App URL: ") +
361
- chalk.green(`${this.appUrl}`) +
362
- "\n" +
363
- chalk.bold.cyan("Internal: ") +
364
- chalk.green(`${this.host}:${this.port}`) +
365
- "\n" +
366
- (props.debug ? chalk.bold.magenta("Hot Reload: enabled\n") : "") +
367
- chalk.gray("────────────────────────────────"),
365
+ chalk.bold.cyan("App URL: ") +
366
+ chalk.green(`${this.appUrl}`) +
367
+ "\n" +
368
+ chalk.bold.cyan("Internal: ") +
369
+ chalk.green(`${this.host}:${this.port}`) +
370
+ "\n" +
371
+ (props.debug ? chalk.bold.magenta("Hot Reload: enabled\n") : "") +
372
+ chalk.gray("────────────────────────────────"),
368
373
  );
369
374
  });
370
375
  })
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
- const now = Date.now();
5
- for (const key in CACHE) {
6
- if (CACHE[key].expiry <= now) {
7
- delete CACHE[key];
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
- * Simple in-memory Cache class to provide middleware for Express routes.
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
- * Middleware to cache responses.
18
- * @param {number} [seconds=60] - Number of seconds to cache the response.
19
- * @returns {Function} Express middleware function.
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 (CACHE[key] && CACHE[key].expiry > Date.now()) {
30
- const cached = CACHE[key];
31
- if (cached.type === "json") {
32
- return res.json(cached.data);
33
- } else {
34
- res.type("text/html");
35
- return res.send(cached.data);
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,58 @@ 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
- CACHE[key] = {
108
+ cachedInRequest = true;
109
+ const cacheObject = {
46
110
  type: "json",
47
111
  data: body,
48
112
  expiry: Date.now() + seconds * 1000,
49
113
  };
50
- cachedInRequest = true;
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
- CACHE[key] = {
132
+ cachedInRequest = true;
133
+ const cacheObject = {
58
134
  type: "html",
59
135
  data: body,
60
136
  expiry: Date.now() + seconds * 1000,
61
137
  };
62
- cachedInRequest = true;
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
- export default Cache;
160
+ export default Cache;
@@ -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 (dev mode)..."));
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. Run your app locally with: npm run dev"));
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 + Node.js app)..."));
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 (or --mysql), prod (or --app), or no arguments for both.`));
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 (default), mysql, app.`));
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,21 @@ 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
+
238
274
  /**
239
275
  * Entry point for Blue Bird CLI Docker subcommands.
240
276
  */
@@ -286,6 +322,9 @@ async function main() {
286
322
  case "logs":
287
323
  await logsCommand(args[1], args[2]);
288
324
  break;
325
+ case "pm2":
326
+ await pm2Command(args.slice(1));
327
+ break;
289
328
  case "mysql":
290
329
  case "db": {
291
330
  let user, password, db, root = false;
@@ -312,7 +351,7 @@ async function main() {
312
351
  }
313
352
  default:
314
353
  console.log(chalk.yellow(`Unknown docker command: ${command}`));
315
- console.log("Available commands: start, stop, build, ps, logs, mysql/db, df/disk, prune/clean");
354
+ console.log("Available commands: start, stop, build, ps, logs, pm2, mysql/db, df/disk, prune/clean");
316
355
  }
317
356
  }
318
357