zerra-core 1.2.1 ā 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.
- package/index.d.ts +7 -0
- package/index.js +341 -86
- package/package.json +6 -2
package/index.d.ts
CHANGED
|
@@ -12,12 +12,15 @@ export interface ZerraRequest extends IncomingMessage {
|
|
|
12
12
|
buffer: Buffer;
|
|
13
13
|
}>;
|
|
14
14
|
params: Record<string, string>;
|
|
15
|
+
cookies: Record<string, string>; // Feature 1: Parsed Cookies
|
|
15
16
|
}
|
|
16
17
|
|
|
17
18
|
export interface ZerraResponse extends ServerResponse {
|
|
18
19
|
status(code: number): ZerraResponse;
|
|
19
20
|
json(data: any): void;
|
|
20
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
|
|
21
24
|
}
|
|
22
25
|
|
|
23
26
|
export type ZerraHandler = (req: ZerraRequest, res: ZerraResponse) => void | Promise<void>;
|
|
@@ -28,12 +31,16 @@ export interface ZerraConfig {
|
|
|
28
31
|
features: {
|
|
29
32
|
logging?: boolean;
|
|
30
33
|
dynamicRouting?: boolean;
|
|
34
|
+
|
|
31
35
|
middleware?: boolean;
|
|
32
36
|
dotenv?: boolean;
|
|
33
37
|
validation?: boolean;
|
|
34
38
|
multipart?: boolean;
|
|
35
39
|
errors?: boolean;
|
|
36
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
|
|
37
44
|
};
|
|
38
45
|
plugins?: string[];
|
|
39
46
|
}
|
package/index.js
CHANGED
|
@@ -3,6 +3,7 @@ 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';
|
|
6
7
|
const jiti = require("jiti")(__filename);
|
|
7
8
|
const configPath = path.join(process.cwd(), 'zerra.config.json');
|
|
8
9
|
let config = {
|
|
@@ -14,7 +15,10 @@ function startServer(port = 3000) {
|
|
|
14
15
|
validation: true,
|
|
15
16
|
multipart: true,
|
|
16
17
|
errors: true,
|
|
17
|
-
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
|
|
18
22
|
},
|
|
19
23
|
plugins: []
|
|
20
24
|
};
|
|
@@ -27,6 +31,23 @@ function startServer(port = 3000) {
|
|
|
27
31
|
}
|
|
28
32
|
}
|
|
29
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
|
+
|
|
30
51
|
const customEnvKeys = new Set();
|
|
31
52
|
const recentRequests = [];
|
|
32
53
|
const MAX_LOGS = 20;
|
|
@@ -71,7 +92,7 @@ function startServer(port = 3000) {
|
|
|
71
92
|
if (config.plugins && Array.isArray(config.plugins)) {
|
|
72
93
|
config.plugins.forEach(pluginPath => {
|
|
73
94
|
try {
|
|
74
|
-
const plugin =
|
|
95
|
+
const plugin = getModule(path.isAbsolute(pluginPath) ? pluginPath : path.join(process.cwd(), pluginPath));
|
|
75
96
|
if (typeof plugin === 'function') plugin(zerra);
|
|
76
97
|
} catch (e) {
|
|
77
98
|
console.error(`ā Failed to load plugin: ${pluginPath}`, e);
|
|
@@ -81,6 +102,86 @@ function startServer(port = 3000) {
|
|
|
81
102
|
|
|
82
103
|
const apiDir = path.join(process.cwd(), "api");
|
|
83
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
|
+
|
|
84
185
|
const server = http.createServer(async (req, res) => {
|
|
85
186
|
const { url, method } = req;
|
|
86
187
|
const startTime = Date.now();
|
|
@@ -129,6 +230,30 @@ function startServer(port = 3000) {
|
|
|
129
230
|
req.query = Object.fromEntries(parsedUrl.searchParams);
|
|
130
231
|
req.path = parsedUrl.pathname;
|
|
131
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
|
+
|
|
132
257
|
// Apply Decorators
|
|
133
258
|
Object.entries(resDecorators).forEach(([name, fn]) => { res[name] = fn.bind(res); });
|
|
134
259
|
Object.entries(reqDecorators).forEach(([name, fn]) => { req[name] = fn.bind(req); });
|
|
@@ -191,9 +316,15 @@ function startServer(port = 3000) {
|
|
|
191
316
|
});
|
|
192
317
|
};
|
|
193
318
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
+
}
|
|
197
328
|
|
|
198
329
|
// Handle OPTIONS requests automatically for CORS if requested
|
|
199
330
|
if (method === 'OPTIONS') {
|
|
@@ -205,6 +336,45 @@ function startServer(port = 3000) {
|
|
|
205
336
|
req.params = {};
|
|
206
337
|
const cleanPath = req.path === "/" ? "/index" : req.path;
|
|
207
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
|
+
|
|
208
378
|
// 10. Enhanced DX: Dev Dashboard
|
|
209
379
|
if (config.features.dashboard && cleanPath === '/__zerra') {
|
|
210
380
|
const getRoutes = (dir, base = '') => {
|
|
@@ -223,7 +393,7 @@ function startServer(port = 3000) {
|
|
|
223
393
|
// Try to extract schema for playground presets
|
|
224
394
|
let schema = null;
|
|
225
395
|
try {
|
|
226
|
-
const mod =
|
|
396
|
+
const mod = getModule(filePath);
|
|
227
397
|
schema = mod.schema || (mod.default && mod.default.schema);
|
|
228
398
|
} catch (e) {}
|
|
229
399
|
|
|
@@ -371,7 +541,7 @@ function startServer(port = 3000) {
|
|
|
371
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;">
|
|
372
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>
|
|
373
543
|
</div>
|
|
374
|
-
|
|
544
|
+
|
|
375
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;">
|
|
376
546
|
<div id="status-${r.path}" style="position: absolute; top: 8px; right: 8px; font-size: 0.7rem; font-weight: bold;"></div>
|
|
377
547
|
<pre style="margin: 0;"></pre>
|
|
@@ -380,7 +550,7 @@ function startServer(port = 3000) {
|
|
|
380
550
|
`}).join('') : '<div style="color:#999">No routes found in /api</div>'}
|
|
381
551
|
</div>
|
|
382
552
|
</section>
|
|
383
|
-
|
|
553
|
+
|
|
384
554
|
<section>
|
|
385
555
|
<h2>āļø Features</h2>
|
|
386
556
|
<div style="display: flex; flex-direction: column; gap: 12px;">
|
|
@@ -393,7 +563,7 @@ function startServer(port = 3000) {
|
|
|
393
563
|
</div>
|
|
394
564
|
</section>
|
|
395
565
|
</div>
|
|
396
|
-
|
|
566
|
+
|
|
397
567
|
<section style="margin-bottom: 20px;">
|
|
398
568
|
<h2>š Recent Activity</h2>
|
|
399
569
|
<table>
|
|
@@ -422,7 +592,7 @@ function startServer(port = 3000) {
|
|
|
422
592
|
</tbody>
|
|
423
593
|
</table>
|
|
424
594
|
</section>
|
|
425
|
-
|
|
595
|
+
|
|
426
596
|
<section>
|
|
427
597
|
<h2>š Environment</h2>
|
|
428
598
|
<div style="display: flex; flex-wrap: wrap; gap: 10px;">
|
|
@@ -440,7 +610,7 @@ function startServer(port = 3000) {
|
|
|
440
610
|
</div>
|
|
441
611
|
</section>
|
|
442
612
|
</main>
|
|
443
|
-
|
|
613
|
+
|
|
444
614
|
<script>
|
|
445
615
|
async function testRoute(path) {
|
|
446
616
|
const method = document.getElementById('method-' + path).value;
|
|
@@ -474,12 +644,12 @@ function startServer(port = 3000) {
|
|
|
474
644
|
pre.innerText = err.message;
|
|
475
645
|
}
|
|
476
646
|
}
|
|
477
|
-
|
|
647
|
+
|
|
478
648
|
// Soft refresh every 5 seconds
|
|
479
649
|
let refreshTimeout = setTimeout(() => {
|
|
480
650
|
window.location.reload();
|
|
481
651
|
}, 5000);
|
|
482
|
-
|
|
652
|
+
|
|
483
653
|
// Pause refresh if user is interacting with playground
|
|
484
654
|
document.addEventListener('mousedown', () => {
|
|
485
655
|
clearTimeout(refreshTimeout);
|
|
@@ -491,75 +661,107 @@ function startServer(port = 3000) {
|
|
|
491
661
|
`);
|
|
492
662
|
}
|
|
493
663
|
|
|
494
|
-
let filePath =
|
|
495
|
-
|
|
496
|
-
filePath = path.join(apiDir, `${cleanPath}.ts`);
|
|
497
|
-
}
|
|
664
|
+
let filePath = null;
|
|
665
|
+
let middlewarePaths = [];
|
|
498
666
|
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
const
|
|
502
|
-
|
|
503
|
-
|
|
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;
|
|
504
672
|
|
|
505
|
-
|
|
506
|
-
const
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
const
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
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;
|
|
514
711
|
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
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
|
+
}
|
|
521
725
|
}
|
|
522
|
-
}
|
|
523
726
|
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
727
|
+
if (match) {
|
|
728
|
+
if (isLast) {
|
|
729
|
+
matchedFile = path.join(currentDir, match);
|
|
730
|
+
} else {
|
|
731
|
+
currentDir = path.join(currentDir, match);
|
|
732
|
+
}
|
|
527
733
|
} else {
|
|
528
|
-
|
|
734
|
+
break;
|
|
529
735
|
}
|
|
530
736
|
} else {
|
|
531
737
|
break;
|
|
532
738
|
}
|
|
533
|
-
}
|
|
534
|
-
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
if (matchedFile) {
|
|
742
|
+
filePath = matchedFile;
|
|
535
743
|
}
|
|
536
744
|
}
|
|
537
|
-
|
|
538
|
-
if (matchedFile) {
|
|
539
|
-
filePath = matchedFile;
|
|
540
|
-
}
|
|
541
|
-
}
|
|
542
745
|
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
// 7. Enhanced DX: Middleware (_middleware.js)
|
|
746
|
+
// Populate middleware chain for dev mode dynamically
|
|
747
|
+
if (filePath && fs.existsSync(filePath) && config.features.middleware) {
|
|
546
748
|
const targetDir = path.dirname(filePath);
|
|
547
749
|
let currentPath = targetDir;
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
if (fs.existsSync(mwPath)) {
|
|
556
|
-
middlewarePaths.unshift(mwPath); // Run top-down
|
|
557
|
-
}
|
|
558
|
-
if (currentPath === apiDir) break;
|
|
559
|
-
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);
|
|
560
756
|
}
|
|
757
|
+
if (currentPath === apiDir) break;
|
|
758
|
+
currentPath = path.dirname(currentPath);
|
|
561
759
|
}
|
|
760
|
+
}
|
|
761
|
+
}
|
|
562
762
|
|
|
763
|
+
if (filePath && fs.existsSync(filePath)) {
|
|
764
|
+
try {
|
|
563
765
|
let middlewareIndex = 0;
|
|
564
766
|
let responseSent = false;
|
|
565
767
|
|
|
@@ -575,8 +777,7 @@ function startServer(port = 3000) {
|
|
|
575
777
|
|
|
576
778
|
if (middlewareIndex < middlewarePaths.length) {
|
|
577
779
|
const mwPath = middlewarePaths[middlewareIndex++];
|
|
578
|
-
|
|
579
|
-
const mw = jiti(mwPath);
|
|
780
|
+
const mw = getModule(mwPath);
|
|
580
781
|
if (typeof mw === 'function' || (mw && typeof mw.default === 'function')) {
|
|
581
782
|
const actualMw = mw.default || mw;
|
|
582
783
|
await actualMw(req, res, runNext);
|
|
@@ -584,30 +785,61 @@ function startServer(port = 3000) {
|
|
|
584
785
|
await runNext();
|
|
585
786
|
}
|
|
586
787
|
} else {
|
|
587
|
-
|
|
588
|
-
const handler = jiti(filePath);
|
|
788
|
+
const handler = getModule(filePath);
|
|
589
789
|
|
|
590
790
|
if (typeof handler === "function" || (handler && typeof handler.default === "function")) {
|
|
591
791
|
const actualHandler = handler.default || handler;
|
|
592
792
|
|
|
593
|
-
// 9. Enhanced DX: Input Validation
|
|
594
|
-
const
|
|
595
|
-
if (config.features.validation &&
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
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
|
+
}
|
|
601
839
|
}
|
|
602
840
|
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
if (typeof req.body[key] !== type) {
|
|
606
|
-
errors.push(`Expected '${key}' to be '${type}', got '${typeof req.body[key]}'`);
|
|
607
|
-
}
|
|
608
|
-
}
|
|
609
|
-
if (errors.length > 0) {
|
|
610
|
-
return res.status(400).json({ error: "Validation Failed", details: errors });
|
|
841
|
+
if (validationErrors.length > 0) {
|
|
842
|
+
return res.status(400).json({ error: "Validation Failed", details: validationErrors });
|
|
611
843
|
}
|
|
612
844
|
}
|
|
613
845
|
|
|
@@ -636,8 +868,7 @@ function startServer(port = 3000) {
|
|
|
636
868
|
const errorHandlerPath = path.join(apiDir, '_error.js');
|
|
637
869
|
if (fs.existsSync(errorHandlerPath)) {
|
|
638
870
|
try {
|
|
639
|
-
|
|
640
|
-
const errorHandler = jiti(errorHandlerPath);
|
|
871
|
+
const errorHandler = getModule(errorHandlerPath);
|
|
641
872
|
const actualErrorHandler = errorHandler.default || errorHandler;
|
|
642
873
|
if (typeof actualErrorHandler === 'function') {
|
|
643
874
|
return await actualErrorHandler(err, req, res);
|
|
@@ -668,6 +899,30 @@ function startServer(port = 3000) {
|
|
|
668
899
|
server.listen(port, () => {
|
|
669
900
|
console.log(`\nš Zerra Engine started on http://localhost:${port}`);
|
|
670
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
|
+
}
|
|
671
926
|
});
|
|
672
927
|
}
|
|
673
928
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zerra-core",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.2",
|
|
4
4
|
"description": "",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -12,6 +12,10 @@
|
|
|
12
12
|
"type": "commonjs",
|
|
13
13
|
"dependencies": {
|
|
14
14
|
"busboy": "^1.6.0",
|
|
15
|
-
"jiti": "^2.6.1"
|
|
15
|
+
"jiti": "^2.6.1",
|
|
16
|
+
"node-cron": "^4.2.1"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@types/node-cron": "^3.0.11"
|
|
16
20
|
}
|
|
17
21
|
}
|