zerra-core 1.2.0 → 1.2.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.
Files changed (3) hide show
  1. package/index.d.ts +54 -0
  2. package/index.js +622 -108
  3. package/package.json +7 -2
package/index.d.ts ADDED
@@ -0,0 +1,54 @@
1
+ import { IncomingMessage, ServerResponse } from 'http';
2
+
3
+ export interface ZerraRequest extends IncomingMessage {
4
+ query: Record<string, string>;
5
+ path: string;
6
+ body: any;
7
+ files: Array<{
8
+ fieldname: string;
9
+ filename: string;
10
+ encoding: string;
11
+ mimetype: string;
12
+ buffer: Buffer;
13
+ }>;
14
+ params: Record<string, string>;
15
+ cookies: Record<string, string>; // Feature 1: Parsed Cookies
16
+ }
17
+
18
+ export interface ZerraResponse extends ServerResponse {
19
+ status(code: number): ZerraResponse;
20
+ json(data: any): void;
21
+ cors(options?: { origin?: string; methods?: string }): ZerraResponse;
22
+ sendFile(filePath: string): void; // Feature 2: Send File helper
23
+ redirect(url: string, status?: number): void; // Feature 3: Redirect helper
24
+ }
25
+
26
+ export type ZerraHandler = (req: ZerraRequest, res: ZerraResponse) => void | Promise<void>;
27
+
28
+ export type ZerraMiddleware = (req: ZerraRequest, res: ZerraResponse, next: () => Promise<void>) => void | Promise<void>;
29
+
30
+ export interface ZerraConfig {
31
+ features: {
32
+ logging?: boolean;
33
+ dynamicRouting?: boolean;
34
+
35
+ middleware?: boolean;
36
+ dotenv?: boolean;
37
+ validation?: boolean;
38
+ multipart?: boolean;
39
+ errors?: boolean;
40
+ dashboard?: boolean;
41
+ static?: boolean; // Feature 4: Static File Serving
42
+ rateLimiting?: boolean | { max: number; windowMs: number }; // Feature 5: Built-in Rate Limiting
43
+ cron?: boolean; // Feature 6: Built-in Cron Job Scheduler
44
+ };
45
+ plugins?: string[];
46
+ }
47
+
48
+ export interface ZerraApp {
49
+ use(fn: ZerraMiddleware): void;
50
+ decorate(target: 'req' | 'res', name: string, fn: Function): void;
51
+ config: ZerraConfig;
52
+ }
53
+
54
+ export function startServer(port?: number): void;
package/index.js CHANGED
@@ -3,6 +3,8 @@ const fs = require("fs");
3
3
  const path = require("path");
4
4
 
5
5
  function startServer(port = 3000) {
6
+ const isDev = process.env.NODE_ENV !== 'production';
7
+ const jiti = require("jiti")(__filename);
6
8
  const configPath = path.join(process.cwd(), 'zerra.config.json');
7
9
  let config = {
8
10
  features: {
@@ -13,7 +15,10 @@ function startServer(port = 3000) {
13
15
  validation: true,
14
16
  multipart: true,
15
17
  errors: true,
16
- dashboard: true
18
+ dashboard: true,
19
+ static: true, // Feature 4: Static File Serving
20
+ rateLimiting: false, // Feature 5: Built-in Rate Limiting
21
+ cron: true, // Feature 6: Built-in Cron Job Scheduler
17
22
  },
18
23
  plugins: []
19
24
  };
@@ -26,6 +31,27 @@ function startServer(port = 3000) {
26
31
  }
27
32
  }
28
33
 
34
+ // 1. Optimized module caching mechanism
35
+ const moduleCache = new Map();
36
+ const getModule = (modulePath) => {
37
+ if (isDev) {
38
+ try {
39
+ delete require.cache[require.resolve(modulePath)];
40
+ } catch (e) {}
41
+ return jiti(modulePath);
42
+ }
43
+ if (moduleCache.has(modulePath)) {
44
+ return moduleCache.get(modulePath);
45
+ }
46
+ const mod = jiti(modulePath);
47
+ moduleCache.set(modulePath, mod);
48
+ return mod;
49
+ };
50
+
51
+ const customEnvKeys = new Set();
52
+ const recentRequests = [];
53
+ const MAX_LOGS = 20;
54
+
29
55
  // 8. Enhanced DX: Auto-load .env files
30
56
  if (config.features.dotenv) {
31
57
  const envPath = path.join(process.cwd(), '.env');
@@ -39,7 +65,11 @@ function startServer(port = 3000) {
39
65
  // Remove quotes
40
66
  if (value.startsWith('"') && value.endsWith('"')) value = value.slice(1, -1);
41
67
  else if (value.startsWith("'") && value.endsWith("'")) value = value.slice(1, -1);
42
- if (!process.env.hasOwnProperty(key)) process.env[key] = value;
68
+
69
+ if (!process.env.hasOwnProperty(key)) {
70
+ process.env[key] = value;
71
+ }
72
+ customEnvKeys.add(key);
43
73
  }
44
74
  });
45
75
  }
@@ -62,7 +92,7 @@ function startServer(port = 3000) {
62
92
  if (config.plugins && Array.isArray(config.plugins)) {
63
93
  config.plugins.forEach(pluginPath => {
64
94
  try {
65
- const plugin = require(path.isAbsolute(pluginPath) ? pluginPath : path.join(process.cwd(), pluginPath));
95
+ const plugin = getModule(path.isAbsolute(pluginPath) ? pluginPath : path.join(process.cwd(), pluginPath));
66
96
  if (typeof plugin === 'function') plugin(zerra);
67
97
  } catch (e) {
68
98
  console.error(`❌ Failed to load plugin: ${pluginPath}`, e);
@@ -72,6 +102,86 @@ function startServer(port = 3000) {
72
102
 
73
103
  const apiDir = path.join(process.cwd(), "api");
74
104
 
105
+ // In-memory Route Table for fast production route resolution
106
+ let routeTable = [];
107
+
108
+ function buildRouteTable(dir, base = '') {
109
+ let results = [];
110
+ if (!fs.existsSync(dir)) return results;
111
+ const list = fs.readdirSync(dir);
112
+ list.forEach(file => {
113
+ const filePath = path.join(dir, file);
114
+ const stat = fs.statSync(filePath);
115
+ if (stat && stat.isDirectory()) {
116
+ results = results.concat(buildRouteTable(filePath, path.join(base, file)));
117
+ } else if ((file.endsWith('.js') || file.endsWith('.ts')) && !file.startsWith('_')) {
118
+ const relRoute = path.join(base, file).replace(/\\/g, '/').replace(/\.(js|ts)$/, '');
119
+ const segments = relRoute.split('/').filter(Boolean);
120
+
121
+ // Resolve middleware paths for this route
122
+ const middlewarePaths = [];
123
+ if (config.features.middleware) {
124
+ let currentPath = path.dirname(filePath);
125
+ while (currentPath.length >= apiDir.length && currentPath.startsWith(apiDir)) {
126
+ let mwPath = path.join(currentPath, '_middleware.js');
127
+ if (!fs.existsSync(mwPath)) mwPath = path.join(currentPath, '_middleware.ts');
128
+ if (fs.existsSync(mwPath)) {
129
+ middlewarePaths.unshift(mwPath);
130
+ }
131
+ if (currentPath === apiDir) break;
132
+ currentPath = path.dirname(currentPath);
133
+ }
134
+ }
135
+
136
+ results.push({
137
+ filePath,
138
+ segments,
139
+ middlewarePaths
140
+ });
141
+ }
142
+ });
143
+ return results;
144
+ }
145
+
146
+ if (!isDev) {
147
+ routeTable = buildRouteTable(apiDir);
148
+ // Sort exact segments before dynamic parameter segments, and longer paths first
149
+ routeTable.sort((a, b) => {
150
+ const minLen = Math.min(a.segments.length, b.segments.length);
151
+ for (let i = 0; i < minLen; i++) {
152
+ const aSec = a.segments[i];
153
+ const bSec = b.segments[i];
154
+ const aDyn = aSec.startsWith('[');
155
+ const bDyn = bSec.startsWith('[');
156
+ if (aDyn !== bDyn) {
157
+ return aDyn ? 1 : -1;
158
+ }
159
+ }
160
+ return b.segments.length - a.segments.length;
161
+ });
162
+ }
163
+
164
+ // Pre-cached static files set for production static serving
165
+ const publicFilesSet = new Set();
166
+
167
+ function scanPublicFiles(dir) {
168
+ if (!fs.existsSync(dir)) return;
169
+ const list = fs.readdirSync(dir);
170
+ list.forEach(file => {
171
+ const filePath = path.join(dir, file);
172
+ const stat = fs.statSync(filePath);
173
+ if (stat && stat.isDirectory()) {
174
+ scanPublicFiles(filePath);
175
+ } else {
176
+ publicFilesSet.add(filePath);
177
+ }
178
+ });
179
+ }
180
+
181
+ if (!isDev && config.features.static) {
182
+ scanPublicFiles(path.join(process.cwd(), "public"));
183
+ }
184
+
75
185
  const server = http.createServer(async (req, res) => {
76
186
  const { url, method } = req;
77
187
  const startTime = Date.now();
@@ -79,12 +189,28 @@ function startServer(port = 3000) {
79
189
  // 1. Enhanced DX: Beautiful Request Logging
80
190
  const originalEnd = res.end;
81
191
  res.end = function (...args) {
192
+ const duration = Date.now() - startTime;
193
+ const path = req.path || url;
194
+
195
+ // Log to terminal
82
196
  if (config.features.logging) {
83
- const duration = Date.now() - startTime;
84
197
  const statusColor = res.statusCode >= 500 ? '\x1b[31m' : res.statusCode >= 400 ? '\x1b[33m' : '\x1b[32m';
85
198
  const resetColor = '\x1b[0m';
86
- console.log(`${statusColor}[${method}]${resetColor} ${req.path || url} ➜ ${statusColor}${res.statusCode}${resetColor} (${duration}ms)`);
199
+ console.log(`${statusColor}[${method}]${resetColor} ${path} ➜ ${statusColor}${res.statusCode}${resetColor} (${duration}ms)`);
87
200
  }
201
+
202
+ // Store for dashboard (exclude the dashboard itself)
203
+ if (path !== '/__zerra' && path !== '/favicon.ico') {
204
+ recentRequests.unshift({
205
+ method,
206
+ path,
207
+ statusCode: res.statusCode,
208
+ duration,
209
+ timestamp: new Date().toLocaleTimeString()
210
+ });
211
+ if (recentRequests.length > MAX_LOGS) recentRequests.pop();
212
+ }
213
+
88
214
  return originalEnd.apply(this, args);
89
215
  };
90
216
 
@@ -104,6 +230,30 @@ function startServer(port = 3000) {
104
230
  req.query = Object.fromEntries(parsedUrl.searchParams);
105
231
  req.path = parsedUrl.pathname;
106
232
 
233
+ // Feature 1: Auto-parsed Cookies
234
+ req.cookies = {};
235
+ if (req.headers.cookie) {
236
+ req.headers.cookie.split(';').forEach(cookie => {
237
+ const parts = cookie.split('=');
238
+ req.cookies[parts.shift().trim()] = decodeURI(parts.join('='));
239
+ });
240
+ }
241
+
242
+ // Feature 2: res.sendFile helper
243
+ res.sendFile = (filePath) => {
244
+ const absolutePath = path.isAbsolute(filePath) ? filePath : path.join(process.cwd(), filePath);
245
+ if (fs.existsSync(absolutePath)) {
246
+ return fs.createReadStream(absolutePath).pipe(res);
247
+ }
248
+ res.status(404).json({ error: "File not found" });
249
+ };
250
+
251
+ // Feature 3: res.redirect helper
252
+ res.redirect = (redirectUrl, status = 302) => {
253
+ res.writeHead(status, { Location: redirectUrl });
254
+ res.end();
255
+ };
256
+
107
257
  // Apply Decorators
108
258
  Object.entries(resDecorators).forEach(([name, fn]) => { res[name] = fn.bind(res); });
109
259
  Object.entries(reqDecorators).forEach(([name, fn]) => { req[name] = fn.bind(req); });
@@ -166,9 +316,15 @@ function startServer(port = 3000) {
166
316
  });
167
317
  };
168
318
 
169
- const parsedData = await parseBody();
170
- req.body = parsedData.body;
171
- req.files = parsedData.files;
319
+ // Conditional body parsed execution (optimization)
320
+ if (['GET', 'HEAD', 'OPTIONS'].includes(method)) {
321
+ req.body = null;
322
+ req.files = [];
323
+ } else {
324
+ const parsedData = await parseBody();
325
+ req.body = parsedData.body;
326
+ req.files = parsedData.files;
327
+ }
172
328
 
173
329
  // Handle OPTIONS requests automatically for CORS if requested
174
330
  if (method === 'OPTIONS') {
@@ -180,6 +336,45 @@ function startServer(port = 3000) {
180
336
  req.params = {};
181
337
  const cleanPath = req.path === "/" ? "/index" : req.path;
182
338
 
339
+ // Feature 4: Built-in Rate Limiting
340
+ if (config.features.rateLimiting) {
341
+ if (!global.rateLimitStore) global.rateLimitStore = {};
342
+ const ip = req.socket.remoteAddress || 'unknown';
343
+ const now = Date.now();
344
+
345
+ const rlConfig = typeof config.features.rateLimiting === 'object' ? config.features.rateLimiting : { max: 100, windowMs: 60000 };
346
+
347
+ if (!global.rateLimitStore[ip] || now > global.rateLimitStore[ip].resetTime) {
348
+ global.rateLimitStore[ip] = { count: 1, resetTime: now + rlConfig.windowMs };
349
+ } else {
350
+ global.rateLimitStore[ip].count++;
351
+ }
352
+
353
+ if (global.rateLimitStore[ip].count > rlConfig.max) {
354
+ return res.status(429).json({ error: "Too Many Requests", message: "Rate limit exceeded. Try again later." });
355
+ }
356
+ }
357
+
358
+ // Feature 5: Static File Serving (optimized via Set lookup in production)
359
+ if (config.features.static && method === 'GET') {
360
+ const publicDir = path.join(process.cwd(), "public");
361
+ const publicPath = path.join(publicDir, cleanPath === "/index" ? "/" : cleanPath);
362
+
363
+ let isStaticFile = false;
364
+ if (!isDev) {
365
+ isStaticFile = publicFilesSet.has(publicPath);
366
+ } else {
367
+ isStaticFile = fs.existsSync(publicDir) && publicPath.startsWith(publicDir) && fs.existsSync(publicPath) && fs.statSync(publicPath).isFile();
368
+ }
369
+
370
+ if (isStaticFile) {
371
+ const ext = path.extname(publicPath);
372
+ const mimeTypes = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.png': 'image/png', '.jpg': 'image/jpeg', '.svg': 'image/svg+xml' };
373
+ res.setHeader('Content-Type', mimeTypes[ext] || 'application/octet-stream');
374
+ return fs.createReadStream(publicPath).pipe(res);
375
+ }
376
+ }
377
+
183
378
  // 10. Enhanced DX: Dev Dashboard
184
379
  if (config.features.dashboard && cleanPath === '/__zerra') {
185
380
  const getRoutes = (dir, base = '') => {
@@ -191,9 +386,18 @@ function startServer(port = 3000) {
191
386
  const stat = fs.statSync(filePath);
192
387
  if (stat && stat.isDirectory()) {
193
388
  results = results.concat(getRoutes(filePath, path.join(base, file)));
194
- } else if (file.endsWith('.js') && !file.startsWith('_')) {
195
- const route = path.join(base, file).replace(/\\/g, '/').replace('.js', '');
196
- results.push(route === 'index' ? '/' : `/${route}`);
389
+ } else if ((file.endsWith('.js') || file.endsWith('.ts')) && !file.startsWith('_')) {
390
+ const route = path.join(base, file).replace(/\\/g, '/').replace(/\.(js|ts)$/, '');
391
+ const fullPath = `/${route === 'index' ? '' : route}`;
392
+
393
+ // Try to extract schema for playground presets
394
+ let schema = null;
395
+ try {
396
+ const mod = getModule(filePath);
397
+ schema = mod.schema || (mod.default && mod.default.schema);
398
+ } catch (e) {}
399
+
400
+ results.push({ path: fullPath, schema });
197
401
  }
198
402
  });
199
403
  return results;
@@ -202,114 +406,362 @@ function startServer(port = 3000) {
202
406
  const routes = getRoutes(apiDir);
203
407
  const featureList = Object.entries(config.features)
204
408
  .map(([k, v]) => `<li><strong>${k}</strong>: ${v ? '✅' : '❌'}</li>`).join('');
205
- const routeList = routes.map(r => `<li><a href="${r}">${r}</a></li>`).join('');
409
+ const routeList = routes.map(r => `<li><a href="${r.path}">${r.path}</a></li>`).join('');
206
410
 
207
- res.setHeader('Content-Type', 'text/html');
411
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
208
412
  return res.end(`
209
413
  <!DOCTYPE html>
210
- <html>
414
+ <html lang="en">
211
415
  <head>
212
- <title>Zerra Dashboard</title>
416
+ <meta charset="UTF-8">
417
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
418
+ <title>Zerra Dev Dashboard</title>
213
419
  <style>
214
- body { font-family: sans-serif; line-height: 1.6; max-width: 800px; margin: 40px auto; padding: 0 20px; color: #333; background: #f9f9f9; }
215
- h1 { color: #000; border-bottom: 2px solid #eee; padding-bottom: 10px; }
216
- section { background: #fff; padding: 20px; border: 1px solid #ddd; border-radius: 5px; margin-bottom: 20px; }
217
- h2 { margin-top: 0; font-size: 1.2rem; }
218
- ul { padding-left: 20px; }
219
- li { margin-bottom: 5px; }
220
- a { color: #0070f3; text-decoration: none; }
221
- a:hover { text-decoration: underline; }
222
- .badge { font-size: 0.8rem; background: #000; color: #fff; padding: 2px 6px; border-radius: 3px; vertical-align: middle; }
420
+ :root {
421
+ --primary: #0070f3;
422
+ --bg: #fafafa;
423
+ --card-bg: #ffffff;
424
+ --text: #171717;
425
+ --text-light: #666;
426
+ --border: #eaeaea;
427
+ --success: #0070f3;
428
+ --warning: #f5a623;
429
+ --error: #ff0000;
430
+ }
431
+ body {
432
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
433
+ line-height: 1.5;
434
+ background: var(--bg);
435
+ color: var(--text);
436
+ margin: 0;
437
+ padding: 0;
438
+ }
439
+ header {
440
+ background: linear-gradient(135deg, #000000 0%, #1a1a1a 100%);
441
+ color: #fff;
442
+ padding: 16px 40px;
443
+ display: flex;
444
+ align-items: center;
445
+ justify-content: space-between;
446
+ box-shadow: 0 4px 12px rgba(0,0,0,0.08);
447
+ border-bottom: 1px solid rgba(255,255,255,0.1);
448
+ position: sticky;
449
+ top: 0;
450
+ z-index: 100;
451
+ }
452
+ header h1 {
453
+ margin: 0;
454
+ font-size: 1.2rem;
455
+ display: flex;
456
+ align-items: center;
457
+ gap: 12px;
458
+ letter-spacing: 2px;
459
+ font-weight: 800;
460
+ }
461
+ header h1 span.console-text {
462
+ font-weight: 300;
463
+ opacity: 0.6;
464
+ font-size: 0.9rem;
465
+ letter-spacing: 0;
466
+ border-left: 1px solid rgba(255,255,255,0.2);
467
+ padding-left: 12px;
468
+ }
469
+ .badge { font-size: 0.75rem; background: rgba(255,255,255,0.2); padding: 2px 8px; border-radius: 20px; font-weight: normal; }
470
+
471
+ main { max-width: 1300px; margin: 40px auto; padding: 0 20px; }
472
+
473
+ .grid { display: grid; grid-template-columns: 2.5fr 1fr; gap: 25px; margin-bottom: 20px; }
474
+ @media (max-width: 1024px) { .grid { grid-template-columns: 1fr; } }
475
+
476
+ section {
477
+ background: var(--card-bg);
478
+ padding: 24px;
479
+ border-radius: 12px;
480
+ border: 1px solid var(--border);
481
+ box-shadow: 0 4px 6px rgba(0,0,0,0.02);
482
+ }
483
+ h2 { margin-top: 0; font-size: 1.1rem; margin-bottom: 20px; display: flex; align-items: center; gap: 8px; color: var(--text-light); text-transform: uppercase; letter-spacing: 1px; }
484
+
485
+ ul { list-style: none; padding: 0; margin: 0; }
486
+ li { margin-bottom: 10px; display: flex; align-items: center; justify-content: space-between; }
487
+
488
+ .route-link { color: var(--primary); text-decoration: none; font-weight: 500; font-family: monospace; font-size: 1rem; }
489
+ .route-link:hover { text-decoration: underline; }
490
+
491
+ table { width: 100%; border-collapse: collapse; margin-top: 10px; }
492
+ th { text-align: left; padding: 12px 8px; border-bottom: 2px solid var(--border); font-size: 0.85rem; color: var(--text-light); }
493
+ td { padding: 12px 8px; border-bottom: 1px solid var(--border); font-size: 0.9rem; }
494
+
495
+ .status-badge {
496
+ padding: 4px 10px;
497
+ border-radius: 6px;
498
+ font-size: 0.75rem;
499
+ font-weight: bold;
500
+ color: #fff;
501
+ }
502
+
503
+ .env-item { background: #f3f4f6; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 0.85rem; color: #444; }
504
+
505
+ /* Fix for long error messages/stacks */
506
+ pre {
507
+ white-space: pre-wrap !important;
508
+ word-break: break-all !important;
509
+ max-height: 300px !important;
510
+ overflow-y: auto !important;
511
+ margin: 0 !important;
512
+ }
223
513
  </style>
224
514
  </head>
225
515
  <body>
226
- <h1>🚀 Zerra Dev Dashboard <span class="badge">v1.1.1</span></h1>
227
-
228
- <section>
229
- <h2>📂 Active Routes</h2>
230
- <ul>${routeList || '<li>No routes found in /api</li>'}</ul>
231
- </section>
232
-
233
- <section>
234
- <h2>⚙️ Enabled Features</h2>
235
- <ul>${featureList}</ul>
236
- </section>
237
-
238
- <section>
239
- <h2>🔐 Environment Variables</h2>
240
- <ul>${Object.keys(process.env).filter(k => !k.startsWith('npm_') && !k.startsWith('NODE_')).map(k => `<li>${k}</li>`).join('') || '<li>No custom env vars loaded</li>'}</ul>
241
- </section>
242
-
243
- <p><small>Zerra Engine is running in development mode.</small></p>
516
+ <main style="margin-top: 20px;">
517
+ <div class="grid">
518
+ <section>
519
+ <h2>📂 Active Routes & Playground</h2>
520
+ <div style="display: flex; flex-direction: column; gap: 15px;">
521
+ ${routes.length > 0 ? routes.map(r => {
522
+ const sampleBody = r.schema ? JSON.stringify(Object.fromEntries(
523
+ Object.entries(r.schema).map(([k, t]) => [k, t === 'number' ? 0 : t === 'boolean' ? false : 'text'])
524
+ )) : '{}';
525
+
526
+ return `
527
+ <div style="border: 1px solid var(--border); border-radius: 8px; padding: 15px;">
528
+ <div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px;">
529
+ <a href="${r.path}" class="route-link" style="font-size: 1.1rem;">${r.path}</a>
530
+ ${r.schema ? '<span class="badge" style="background:#eee; color:#666;">Has Schema</span>' : ''}
531
+ </div>
532
+
533
+ <div style="display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 10px;">
534
+ <select id="method-${r.path}" style="padding: 4px 8px; border-radius: 4px; border: 1px solid var(--border); background: #fff; font-family: inherit;">
535
+ <option value="GET">GET</option>
536
+ <option value="POST">POST</option>
537
+ <option value="PUT">PUT</option>
538
+ <option value="PATCH">PATCH</option>
539
+ <option value="DELETE">DELETE</option>
540
+ </select>
541
+ <input type="text" id="body-${r.path}" value='${sampleBody}' placeholder='{"key": "value"}' style="flex-grow: 1; padding: 4px 10px; border-radius: 4px; border: 1px solid var(--border); font-family: monospace; font-size: 0.8rem;">
542
+ <button onclick="testRoute('${r.path}')" style="background: var(--primary); color: #fff; border: none; padding: 5px 15px; border-radius: 4px; cursor: pointer; font-weight: bold; font-size: 0.8rem;">SEND</button>
543
+ </div>
544
+
545
+ <div id="res-${r.path}" style="display: none; background: #1e1e1e; color: #d4d4d4; padding: 12px; border-radius: 6px; font-family: monospace; font-size: 0.85rem; overflow-x: auto; margin-top: 10px; position: relative;">
546
+ <div id="status-${r.path}" style="position: absolute; top: 8px; right: 8px; font-size: 0.7rem; font-weight: bold;"></div>
547
+ <pre style="margin: 0;"></pre>
548
+ </div>
549
+ </div>
550
+ `}).join('') : '<div style="color:#999">No routes found in /api</div>'}
551
+ </div>
552
+ </section>
553
+
554
+ <section>
555
+ <h2>⚙️ Features</h2>
556
+ <div style="display: flex; flex-direction: column; gap: 12px;">
557
+ ${Object.entries(config.features).map(([k, v]) => `
558
+ <div style="display: flex; align-items: center; justify-content: space-between; font-size: 0.9rem; border-bottom: 1px solid #f9f9f9; padding-bottom: 8px;">
559
+ <span style="color: ${v ? 'inherit' : '#999'}; font-weight: 500;">${k}</span>
560
+ <span>${v ? '✅' : '❌'}</span>
561
+ </div>
562
+ `).join('')}
563
+ </div>
564
+ </section>
565
+ </div>
566
+
567
+ <section style="margin-bottom: 20px;">
568
+ <h2>📊 Recent Activity</h2>
569
+ <table>
570
+ <thead>
571
+ <tr>
572
+ <th>METHOD</th>
573
+ <th>PATH</th>
574
+ <th>STATUS</th>
575
+ <th>TIME</th>
576
+ <th>DURATION</th>
577
+ </tr>
578
+ </thead>
579
+ <tbody>
580
+ ${recentRequests.map(req => {
581
+ const color = req.statusCode >= 500 ? 'var(--error)' : req.statusCode >= 400 ? 'var(--warning)' : 'var(--success)';
582
+ return `
583
+ <tr>
584
+ <td><strong>${req.method}</strong></td>
585
+ <td style="font-family: monospace; color: #444;">${req.path}</td>
586
+ <td><span class="status-badge" style="background: ${color}">${req.statusCode}</span></td>
587
+ <td style="color: #888;">${req.timestamp}</td>
588
+ <td style="color: #888;">${req.duration}ms</td>
589
+ </tr>
590
+ `;
591
+ }).join('') || '<tr><td colspan="5" style="text-align: center; padding: 40px; color: #999;">Waiting for requests...</td></tr>'}
592
+ </tbody>
593
+ </table>
594
+ </section>
595
+
596
+ <section>
597
+ <h2>🔐 Environment</h2>
598
+ <div style="display: flex; flex-wrap: wrap; gap: 10px;">
599
+ ${Object.keys(process.env).filter(k => {
600
+ const isCustom = customEnvKeys.has(k);
601
+ const isImportant = ['PORT', 'NODE_ENV'].includes(k);
602
+ const isSystem = /^(ALLUSERSPROFILE|APPDATA|COMPUTERNAME|ComSpec|Common|DriverData|HOMEDRIVE|HOMEPATH|LOCALAPPDATA|LOGONSERVER|NUMBER_OF_PROCESSORS|OS|Path|PATHEXT|PROCESSOR|Program|PSModulePath|PUBLIC|System|TEMP|TMP|USER|windir|ZES_|VSCODE_|ANTIGRAVITY_)/i.test(k);
603
+ return (isCustom || isImportant) && !isSystem;
604
+ }).map(k => `
605
+ <div style="background: #f9f9f9; border: 1px solid #eee; padding: 10px 15px; border-radius: 8px;">
606
+ <div style="font-size: 0.7rem; color: #999; margin-bottom: 4px; font-weight: bold;">${k}</div>
607
+ <div class="env-item">${process.env[k]}</div>
608
+ </div>
609
+ `).join('') || '<div style="color:#999">No custom variables loaded</div>'}
610
+ </div>
611
+ </section>
612
+ </main>
613
+
614
+ <script>
615
+ async function testRoute(path) {
616
+ const method = document.getElementById('method-' + path).value;
617
+ const bodyStr = document.getElementById('body-' + path).value;
618
+ const resDiv = document.getElementById('res-' + path);
619
+ const statusDiv = document.getElementById('status-' + path);
620
+ const pre = resDiv.querySelector('pre');
621
+
622
+ resDiv.style.display = 'block';
623
+ pre.innerText = 'Sending request...';
624
+ statusDiv.innerText = '';
625
+
626
+ try {
627
+ const options = { method, headers: {} };
628
+ if (['POST', 'PUT', 'PATCH'].includes(method) && bodyStr) {
629
+ options.headers['Content-Type'] = 'application/json';
630
+ options.body = bodyStr;
631
+ }
632
+
633
+ const start = Date.now();
634
+ const response = await fetch(path, options);
635
+ const duration = Date.now() - start;
636
+ const data = await response.json().catch(() => null);
637
+
638
+ statusDiv.innerText = response.status + ' (' + duration + 'ms)';
639
+ statusDiv.style.color = response.status >= 400 ? '#ff4d4f' : '#52c41a';
640
+ pre.innerText = JSON.stringify(data, null, 2) || 'No response body';
641
+ } catch (err) {
642
+ statusDiv.innerText = 'ERROR';
643
+ statusDiv.style.color = '#ff4d4f';
644
+ pre.innerText = err.message;
645
+ }
646
+ }
647
+
648
+ // Soft refresh every 5 seconds
649
+ let refreshTimeout = setTimeout(() => {
650
+ window.location.reload();
651
+ }, 5000);
652
+
653
+ // Pause refresh if user is interacting with playground
654
+ document.addEventListener('mousedown', () => {
655
+ clearTimeout(refreshTimeout);
656
+ refreshTimeout = setTimeout(() => window.location.reload(), 15000);
657
+ });
658
+ </script>
244
659
  </body>
245
660
  </html>
246
661
  `);
247
662
  }
248
663
 
249
- let filePath = path.join(apiDir, `${cleanPath}.js`);
664
+ let filePath = null;
665
+ let middlewarePaths = [];
250
666
 
251
- // 6. Enhanced DX: Dynamic Routing ([id].js)
252
- if (config.features.dynamicRouting && !fs.existsSync(filePath)) {
253
- const parts = cleanPath.split('/').filter(Boolean);
254
- let currentDir = apiDir;
255
- let matchedFile = null;
667
+ if (!isDev) {
668
+ // 1. Production Mode: Ultra-fast O(1)/O(N) in-memory route lookup
669
+ const reqSegments = cleanPath.split('/').filter(Boolean);
670
+ for (const route of routeTable) {
671
+ if (route.segments.length !== reqSegments.length) continue;
256
672
 
257
- for (let i = 0; i < parts.length; i++) {
258
- const part = parts[i];
259
- const isLast = i === parts.length - 1;
260
-
261
- if (fs.existsSync(currentDir)) {
262
- const files = fs.readdirSync(currentDir);
263
-
264
- // Look for exact match first
265
- let match = files.find(f => isLast ? f === `${part}.js` : f === part && fs.statSync(path.join(currentDir, f)).isDirectory());
673
+ let isMatch = true;
674
+ const tempParams = {};
675
+
676
+ for (let i = 0; i < route.segments.length; i++) {
677
+ const routeSec = route.segments[i];
678
+ const reqSec = reqSegments[i];
679
+
680
+ if (routeSec.startsWith('[') && routeSec.endsWith(']')) {
681
+ const paramName = routeSec.slice(1, -1);
682
+ tempParams[paramName] = reqSec;
683
+ } else if (routeSec !== reqSec) {
684
+ isMatch = false;
685
+ break;
686
+ }
687
+ }
688
+
689
+ if (isMatch) {
690
+ filePath = route.filePath;
691
+ req.params = tempParams;
692
+ middlewarePaths = route.middlewarePaths;
693
+ break;
694
+ }
695
+ }
696
+ } else {
697
+ // 2. Development Mode: Dynamic Filesystem Walk for instant hot-reloading
698
+ filePath = path.join(apiDir, `${cleanPath}.js`);
699
+ if (!fs.existsSync(filePath)) {
700
+ filePath = path.join(apiDir, `${cleanPath}.ts`);
701
+ }
702
+
703
+ if (config.features.dynamicRouting && !fs.existsSync(filePath)) {
704
+ const parts = cleanPath.split('/').filter(Boolean);
705
+ let currentDir = apiDir;
706
+ let matchedFile = null;
707
+
708
+ for (let i = 0; i < parts.length; i++) {
709
+ const part = parts[i];
710
+ const isLast = i === parts.length - 1;
266
711
 
267
- // Look for dynamic parameter [param]
268
- if (!match) {
269
- match = files.find(f => isLast ? (f.startsWith('[') && f.endsWith('].js')) : (f.startsWith('[') && f.endsWith(']') && fs.statSync(path.join(currentDir, f)).isDirectory()));
270
- if (match) {
271
- const paramName = isLast ? match.slice(1, -4) : match.slice(1, -1);
272
- req.params[paramName] = part;
712
+ if (fs.existsSync(currentDir)) {
713
+ const files = fs.readdirSync(currentDir);
714
+
715
+ // Look for exact match first
716
+ let match = files.find(f => isLast ? f === `${part}.js` : f === part && fs.statSync(path.join(currentDir, f)).isDirectory());
717
+
718
+ // Look for dynamic parameter [param]
719
+ if (!match) {
720
+ match = files.find(f => isLast ? (f.startsWith('[') && (f.endsWith('].js') || f.endsWith('].ts'))) : (f.startsWith('[') && f.endsWith(']') && fs.statSync(path.join(currentDir, f)).isDirectory()));
721
+ if (match) {
722
+ const paramName = isLast ? match.slice(1, match.lastIndexOf('].')) : match.slice(1, -1);
723
+ req.params[paramName] = part;
724
+ }
273
725
  }
274
- }
275
726
 
276
- if (match) {
277
- if (isLast) {
278
- matchedFile = path.join(currentDir, match);
727
+ if (match) {
728
+ if (isLast) {
729
+ matchedFile = path.join(currentDir, match);
730
+ } else {
731
+ currentDir = path.join(currentDir, match);
732
+ }
279
733
  } else {
280
- currentDir = path.join(currentDir, match);
734
+ break;
281
735
  }
282
736
  } else {
283
737
  break;
284
738
  }
285
- } else {
286
- break;
739
+ }
740
+
741
+ if (matchedFile) {
742
+ filePath = matchedFile;
287
743
  }
288
744
  }
289
-
290
- if (matchedFile) {
291
- filePath = matchedFile;
292
- }
293
- }
294
745
 
295
- if (fs.existsSync(filePath)) {
296
- try {
297
- // 7. Enhanced DX: Middleware (_middleware.js)
746
+ // Populate middleware chain for dev mode dynamically
747
+ if (filePath && fs.existsSync(filePath) && config.features.middleware) {
298
748
  const targetDir = path.dirname(filePath);
299
749
  let currentPath = targetDir;
300
- const middlewarePaths = [];
301
-
302
- if (config.features.middleware) {
303
- while (currentPath.length >= apiDir.length && currentPath.startsWith(apiDir)) {
304
- const mwPath = path.join(currentPath, '_middleware.js');
305
- if (fs.existsSync(mwPath)) {
306
- middlewarePaths.unshift(mwPath); // Run top-down
307
- }
308
- if (currentPath === apiDir) break;
309
- currentPath = path.dirname(currentPath);
750
+ while (currentPath.length >= apiDir.length && currentPath.startsWith(apiDir)) {
751
+ let mwPath = path.join(currentPath, '_middleware.js');
752
+ if (!fs.existsSync(mwPath)) mwPath = path.join(currentPath, '_middleware.ts');
753
+
754
+ if (fs.existsSync(mwPath)) {
755
+ middlewarePaths.unshift(mwPath);
310
756
  }
757
+ if (currentPath === apiDir) break;
758
+ currentPath = path.dirname(currentPath);
311
759
  }
760
+ }
761
+ }
312
762
 
763
+ if (filePath && fs.existsSync(filePath)) {
764
+ try {
313
765
  let middlewareIndex = 0;
314
766
  let responseSent = false;
315
767
 
@@ -325,31 +777,69 @@ function startServer(port = 3000) {
325
777
 
326
778
  if (middlewareIndex < middlewarePaths.length) {
327
779
  const mwPath = middlewarePaths[middlewareIndex++];
328
- delete require.cache[require.resolve(mwPath)];
329
- const mw = require(mwPath);
330
- if (typeof mw === 'function') {
331
- await mw(req, res, runNext);
780
+ const mw = getModule(mwPath);
781
+ if (typeof mw === 'function' || (mw && typeof mw.default === 'function')) {
782
+ const actualMw = mw.default || mw;
783
+ await actualMw(req, res, runNext);
332
784
  } else {
333
785
  await runNext();
334
786
  }
335
787
  } else {
336
- delete require.cache[require.resolve(filePath)];
337
- const handler = require(filePath);
788
+ const handler = getModule(filePath);
338
789
 
339
790
  if (typeof handler === "function" || (handler && typeof handler.default === "function")) {
340
791
  const actualHandler = handler.default || handler;
341
792
 
342
- // 9. Enhanced DX: Input Validation
343
- const schema = handler.schema;
344
- if (config.features.validation && schema && typeof req.body === 'object' && req.body !== null) {
345
- const errors = [];
346
- for (const [key, type] of Object.entries(schema)) {
347
- if (typeof req.body[key] !== type) {
348
- errors.push(`Expected '${key}' to be '${type}', got '${typeof req.body[key]}'`);
349
- }
793
+ // 9. Enhanced DX: Input Validation (Zod Support)
794
+ const schemaDef = handler.schema;
795
+ if (config.features.validation && schemaDef) {
796
+ let validationErrors = [];
797
+
798
+ const validateZod = (zodSchema, data) => {
799
+ const result = zodSchema.safeParse(data);
800
+ if (!result.success) {
801
+ return { error: true, details: result.error.errors };
802
+ }
803
+ return { error: false, data: result.data };
804
+ };
805
+
806
+ if (schemaDef.safeParse) {
807
+ // Direct Zod schema for body
808
+ const result = validateZod(schemaDef, req.body);
809
+ if (result.error) validationErrors.push(...result.details);
810
+ else req.body = result.data;
811
+ } else if (schemaDef.body || schemaDef.query || schemaDef.params) {
812
+ // Advanced schema object: { body: ZodSchema, query: ZodSchema }
813
+ if (schemaDef.body && schemaDef.body.safeParse) {
814
+ const res = validateZod(schemaDef.body, req.body);
815
+ if (res.error) validationErrors.push({ target: 'body', errors: res.details });
816
+ else req.body = res.data;
817
+ }
818
+ if (schemaDef.query && schemaDef.query.safeParse) {
819
+ const res = validateZod(schemaDef.query, req.query);
820
+ if (res.error) validationErrors.push({ target: 'query', errors: res.details });
821
+ else req.query = res.data;
822
+ }
823
+ if (schemaDef.params && schemaDef.params.safeParse) {
824
+ const res = validateZod(schemaDef.params, req.params);
825
+ if (res.error) validationErrors.push({ target: 'params', errors: res.details });
826
+ else req.params = res.data;
827
+ }
828
+ } else {
829
+ // Fallback to legacy basic typeof validation
830
+ if (!req.body || typeof req.body !== 'object') {
831
+ validationErrors.push("Request body is required for this route.");
832
+ } else {
833
+ for (const [key, type] of Object.entries(schemaDef)) {
834
+ if (typeof req.body[key] !== type) {
835
+ validationErrors.push(`Expected '${key}' to be '${type}', got '${typeof req.body[key]}'`);
836
+ }
837
+ }
838
+ }
350
839
  }
351
- if (errors.length > 0) {
352
- return res.status(400).json({ error: "Validation Failed", details: errors });
840
+
841
+ if (validationErrors.length > 0) {
842
+ return res.status(400).json({ error: "Validation Failed", details: validationErrors });
353
843
  }
354
844
  }
355
845
 
@@ -378,10 +868,10 @@ function startServer(port = 3000) {
378
868
  const errorHandlerPath = path.join(apiDir, '_error.js');
379
869
  if (fs.existsSync(errorHandlerPath)) {
380
870
  try {
381
- delete require.cache[require.resolve(errorHandlerPath)];
382
- const errorHandler = require(errorHandlerPath);
383
- if (typeof errorHandler === 'function') {
384
- return await errorHandler(err, req, res);
871
+ const errorHandler = getModule(errorHandlerPath);
872
+ const actualErrorHandler = errorHandler.default || errorHandler;
873
+ if (typeof actualErrorHandler === 'function') {
874
+ return await actualErrorHandler(err, req, res);
385
875
  }
386
876
  } catch (e) {
387
877
  console.error("❌ Error in custom error handler:", e);
@@ -409,6 +899,30 @@ function startServer(port = 3000) {
409
899
  server.listen(port, () => {
410
900
  console.log(`\n🚀 Zerra Engine started on http://localhost:${port}`);
411
901
  console.log(`📁 Mapping routes from: ${apiDir}\n`);
902
+
903
+ // Feature 6: Built-in Cron Job Scheduler
904
+ if (config.features.cron) {
905
+ try {
906
+ const nodeCron = require('node-cron');
907
+ const jobsDir = path.join(process.cwd(), "jobs");
908
+ if (fs.existsSync(jobsDir)) {
909
+ const jobFiles = fs.readdirSync(jobsDir).filter(f => f.endsWith('.js') || f.endsWith('.ts'));
910
+ jobFiles.forEach(file => {
911
+ const filePath = path.join(jobsDir, file);
912
+ const job = getModule(filePath);
913
+ const schedule = job.schedule || (job.default && job.default.schedule);
914
+ const task = job.task || job.default || (job.default && job.default.task);
915
+
916
+ if (schedule && typeof task === 'function') {
917
+ nodeCron.schedule(schedule, task);
918
+ console.log(`⏰ Scheduled job: ${file} [${schedule}]`);
919
+ }
920
+ });
921
+ }
922
+ } catch (e) {
923
+ console.warn("⚠️ Failed to initialize Cron Jobs. Make sure 'node-cron' is installed.");
924
+ }
925
+ }
412
926
  });
413
927
  }
414
928
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zerra-core",
3
- "version": "1.2.0",
3
+ "version": "1.2.2",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -11,6 +11,11 @@
11
11
  "license": "ISC",
12
12
  "type": "commonjs",
13
13
  "dependencies": {
14
- "busboy": "^1.6.0"
14
+ "busboy": "^1.6.0",
15
+ "jiti": "^2.6.1",
16
+ "node-cron": "^4.2.1"
17
+ },
18
+ "devDependencies": {
19
+ "@types/node-cron": "^3.0.11"
15
20
  }
16
21
  }