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.
- package/index.d.ts +54 -0
- package/index.js +622 -108
- 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
|
-
|
|
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 =
|
|
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} ${
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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(
|
|
196
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
<
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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 =
|
|
664
|
+
let filePath = null;
|
|
665
|
+
let middlewarePaths = [];
|
|
250
666
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
const
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
258
|
-
const
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
const
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
|
|
734
|
+
break;
|
|
281
735
|
}
|
|
282
736
|
} else {
|
|
283
737
|
break;
|
|
284
738
|
}
|
|
285
|
-
}
|
|
286
|
-
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
if (matchedFile) {
|
|
742
|
+
filePath = matchedFile;
|
|
287
743
|
}
|
|
288
744
|
}
|
|
289
|
-
|
|
290
|
-
if (matchedFile) {
|
|
291
|
-
filePath = matchedFile;
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
745
|
|
|
295
|
-
|
|
296
|
-
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
await
|
|
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
|
-
|
|
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
|
|
344
|
-
if (config.features.validation &&
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
-
|
|
352
|
-
|
|
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
|
-
|
|
382
|
-
const
|
|
383
|
-
if (typeof
|
|
384
|
-
return await
|
|
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.
|
|
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
|
}
|