zerra-core 1.0.0 → 1.2.0
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.js +390 -18
- package/package.json +5 -2
package/index.js
CHANGED
|
@@ -3,34 +3,406 @@ const fs = require("fs");
|
|
|
3
3
|
const path = require("path");
|
|
4
4
|
|
|
5
5
|
function startServer(port = 3000) {
|
|
6
|
+
const configPath = path.join(process.cwd(), 'zerra.config.json');
|
|
7
|
+
let config = {
|
|
8
|
+
features: {
|
|
9
|
+
logging: true,
|
|
10
|
+
dynamicRouting: true,
|
|
11
|
+
middleware: true,
|
|
12
|
+
dotenv: true,
|
|
13
|
+
validation: true,
|
|
14
|
+
multipart: true,
|
|
15
|
+
errors: true,
|
|
16
|
+
dashboard: true
|
|
17
|
+
},
|
|
18
|
+
plugins: []
|
|
19
|
+
};
|
|
20
|
+
if (fs.existsSync(configPath)) {
|
|
21
|
+
try {
|
|
22
|
+
const userConfig = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
23
|
+
config.features = { ...config.features, ...(userConfig.features || {}) };
|
|
24
|
+
} catch (e) {
|
|
25
|
+
console.warn("⚠️ Invalid zerra.config.json. Using defaults.");
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// 8. Enhanced DX: Auto-load .env files
|
|
30
|
+
if (config.features.dotenv) {
|
|
31
|
+
const envPath = path.join(process.cwd(), '.env');
|
|
32
|
+
if (fs.existsSync(envPath)) {
|
|
33
|
+
const envFile = fs.readFileSync(envPath, 'utf8');
|
|
34
|
+
envFile.split('\n').forEach(line => {
|
|
35
|
+
const match = line.match(/^\s*([\w.-]+)\s*=\s*(.*)?\s*$/);
|
|
36
|
+
if (match) {
|
|
37
|
+
const key = match[1];
|
|
38
|
+
let value = match[2] || '';
|
|
39
|
+
// Remove quotes
|
|
40
|
+
if (value.startsWith('"') && value.endsWith('"')) value = value.slice(1, -1);
|
|
41
|
+
else if (value.startsWith("'") && value.endsWith("'")) value = value.slice(1, -1);
|
|
42
|
+
if (!process.env.hasOwnProperty(key)) process.env[key] = value;
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const globalMiddleware = [];
|
|
49
|
+
const resDecorators = {};
|
|
50
|
+
const reqDecorators = {};
|
|
51
|
+
|
|
52
|
+
const zerra = {
|
|
53
|
+
use: (fn) => globalMiddleware.push(fn),
|
|
54
|
+
decorate: (target, name, fn) => {
|
|
55
|
+
if (target === 'res') resDecorators[name] = fn;
|
|
56
|
+
if (target === 'req') reqDecorators[name] = fn;
|
|
57
|
+
},
|
|
58
|
+
config
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// Load Plugins
|
|
62
|
+
if (config.plugins && Array.isArray(config.plugins)) {
|
|
63
|
+
config.plugins.forEach(pluginPath => {
|
|
64
|
+
try {
|
|
65
|
+
const plugin = require(path.isAbsolute(pluginPath) ? pluginPath : path.join(process.cwd(), pluginPath));
|
|
66
|
+
if (typeof plugin === 'function') plugin(zerra);
|
|
67
|
+
} catch (e) {
|
|
68
|
+
console.error(`❌ Failed to load plugin: ${pluginPath}`, e);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
6
73
|
const apiDir = path.join(process.cwd(), "api");
|
|
7
74
|
|
|
8
|
-
const server = http.createServer((req, res) => {
|
|
75
|
+
const server = http.createServer(async (req, res) => {
|
|
9
76
|
const { url, method } = req;
|
|
10
|
-
|
|
11
|
-
// Simple path cleaning: remove trailing slashes and get file path
|
|
12
|
-
const cleanPath = url === "/" ? "/index" : url;
|
|
13
|
-
const filePath = path.join(apiDir, `${cleanPath}.js`);
|
|
77
|
+
const startTime = Date.now();
|
|
14
78
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
const
|
|
79
|
+
// 1. Enhanced DX: Beautiful Request Logging
|
|
80
|
+
const originalEnd = res.end;
|
|
81
|
+
res.end = function (...args) {
|
|
82
|
+
if (config.features.logging) {
|
|
83
|
+
const duration = Date.now() - startTime;
|
|
84
|
+
const statusColor = res.statusCode >= 500 ? '\x1b[31m' : res.statusCode >= 400 ? '\x1b[33m' : '\x1b[32m';
|
|
85
|
+
const resetColor = '\x1b[0m';
|
|
86
|
+
console.log(`${statusColor}[${method}]${resetColor} ${req.path || url} ➜ ${statusColor}${res.statusCode}${resetColor} (${duration}ms)`);
|
|
87
|
+
}
|
|
88
|
+
return originalEnd.apply(this, args);
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
// 2. Enhanced DX: Add res.status and res.json helpers
|
|
92
|
+
res.status = (code) => {
|
|
93
|
+
res.statusCode = code;
|
|
94
|
+
return res;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
res.json = (data) => {
|
|
98
|
+
res.setHeader("Content-Type", "application/json");
|
|
99
|
+
res.end(JSON.stringify(data));
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
// 2. Enhanced DX: Parse query parameters
|
|
103
|
+
const parsedUrl = new URL(url, `http://localhost:${port}`);
|
|
104
|
+
req.query = Object.fromEntries(parsedUrl.searchParams);
|
|
105
|
+
req.path = parsedUrl.pathname;
|
|
106
|
+
|
|
107
|
+
// Apply Decorators
|
|
108
|
+
Object.entries(resDecorators).forEach(([name, fn]) => { res[name] = fn.bind(res); });
|
|
109
|
+
Object.entries(reqDecorators).forEach(([name, fn]) => { req[name] = fn.bind(req); });
|
|
110
|
+
|
|
111
|
+
// 3. Enhanced DX: CORS Helper
|
|
112
|
+
res.cors = (options = { origin: '*', methods: 'GET,POST,PUT,DELETE,OPTIONS' }) => {
|
|
113
|
+
res.setHeader('Access-Control-Allow-Origin', options.origin);
|
|
114
|
+
res.setHeader('Access-Control-Allow-Methods', options.methods);
|
|
115
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
|
116
|
+
return res;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// 4. Enhanced DX: Automatic Body & File Parsing
|
|
120
|
+
const parseBody = () => {
|
|
121
|
+
return new Promise((resolve) => {
|
|
122
|
+
if (['GET', 'HEAD', 'OPTIONS'].includes(method)) return resolve({ body: null, files: [] });
|
|
123
|
+
const contentType = req.headers['content-type'] || '';
|
|
124
|
+
|
|
125
|
+
if (config.features.multipart && contentType.includes('multipart/form-data')) {
|
|
126
|
+
const busboy = require('busboy');
|
|
127
|
+
const bb = busboy({ headers: req.headers });
|
|
128
|
+
const body = {};
|
|
129
|
+
const files = [];
|
|
130
|
+
|
|
131
|
+
bb.on('file', (name, file, info) => {
|
|
132
|
+
const chunks = [];
|
|
133
|
+
file.on('data', data => chunks.push(data));
|
|
134
|
+
file.on('end', () => {
|
|
135
|
+
files.push({
|
|
136
|
+
fieldname: name,
|
|
137
|
+
filename: info.filename,
|
|
138
|
+
encoding: info.encoding,
|
|
139
|
+
mimetype: info.mimeType,
|
|
140
|
+
buffer: Buffer.concat(chunks)
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
bb.on('field', (name, val) => {
|
|
146
|
+
body[name] = val;
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
bb.on('close', () => resolve({ body, files }));
|
|
150
|
+
req.pipe(bb);
|
|
151
|
+
} else {
|
|
152
|
+
let rawBody = '';
|
|
153
|
+
req.on('data', chunk => { rawBody += chunk.toString(); });
|
|
154
|
+
req.on('end', () => {
|
|
155
|
+
try {
|
|
156
|
+
if (contentType.includes('application/json') && rawBody) {
|
|
157
|
+
resolve({ body: JSON.parse(rawBody), files: [] });
|
|
158
|
+
} else {
|
|
159
|
+
resolve({ body: rawBody, files: [] });
|
|
160
|
+
}
|
|
161
|
+
} catch (e) {
|
|
162
|
+
resolve({ body: {}, files: [] });
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const parsedData = await parseBody();
|
|
170
|
+
req.body = parsedData.body;
|
|
171
|
+
req.files = parsedData.files;
|
|
172
|
+
|
|
173
|
+
// Handle OPTIONS requests automatically for CORS if requested
|
|
174
|
+
if (method === 'OPTIONS') {
|
|
175
|
+
res.cors();
|
|
176
|
+
res.statusCode = 204;
|
|
177
|
+
return res.end();
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
req.params = {};
|
|
181
|
+
const cleanPath = req.path === "/" ? "/index" : req.path;
|
|
182
|
+
|
|
183
|
+
// 10. Enhanced DX: Dev Dashboard
|
|
184
|
+
if (config.features.dashboard && cleanPath === '/__zerra') {
|
|
185
|
+
const getRoutes = (dir, base = '') => {
|
|
186
|
+
let results = [];
|
|
187
|
+
if (!fs.existsSync(dir)) return results;
|
|
188
|
+
const list = fs.readdirSync(dir);
|
|
189
|
+
list.forEach(file => {
|
|
190
|
+
const filePath = path.join(dir, file);
|
|
191
|
+
const stat = fs.statSync(filePath);
|
|
192
|
+
if (stat && stat.isDirectory()) {
|
|
193
|
+
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}`);
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
return results;
|
|
200
|
+
};
|
|
20
201
|
|
|
21
|
-
|
|
22
|
-
|
|
202
|
+
const routes = getRoutes(apiDir);
|
|
203
|
+
const featureList = Object.entries(config.features)
|
|
204
|
+
.map(([k, v]) => `<li><strong>${k}</strong>: ${v ? '✅' : '❌'}</li>`).join('');
|
|
205
|
+
const routeList = routes.map(r => `<li><a href="${r}">${r}</a></li>`).join('');
|
|
206
|
+
|
|
207
|
+
res.setHeader('Content-Type', 'text/html');
|
|
208
|
+
return res.end(`
|
|
209
|
+
<!DOCTYPE html>
|
|
210
|
+
<html>
|
|
211
|
+
<head>
|
|
212
|
+
<title>Zerra Dashboard</title>
|
|
213
|
+
<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; }
|
|
223
|
+
</style>
|
|
224
|
+
</head>
|
|
225
|
+
<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>
|
|
244
|
+
</body>
|
|
245
|
+
</html>
|
|
246
|
+
`);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
let filePath = path.join(apiDir, `${cleanPath}.js`);
|
|
250
|
+
|
|
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;
|
|
256
|
+
|
|
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());
|
|
266
|
+
|
|
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;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (match) {
|
|
277
|
+
if (isLast) {
|
|
278
|
+
matchedFile = path.join(currentDir, match);
|
|
279
|
+
} else {
|
|
280
|
+
currentDir = path.join(currentDir, match);
|
|
281
|
+
}
|
|
282
|
+
} else {
|
|
283
|
+
break;
|
|
284
|
+
}
|
|
23
285
|
} else {
|
|
24
|
-
|
|
25
|
-
|
|
286
|
+
break;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (matchedFile) {
|
|
291
|
+
filePath = matchedFile;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (fs.existsSync(filePath)) {
|
|
296
|
+
try {
|
|
297
|
+
// 7. Enhanced DX: Middleware (_middleware.js)
|
|
298
|
+
const targetDir = path.dirname(filePath);
|
|
299
|
+
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);
|
|
310
|
+
}
|
|
26
311
|
}
|
|
312
|
+
|
|
313
|
+
let middlewareIndex = 0;
|
|
314
|
+
let responseSent = false;
|
|
315
|
+
|
|
316
|
+
// Track if a response was sent to avoid double execution
|
|
317
|
+
const originalEndM = res.end;
|
|
318
|
+
res.end = function (...args) {
|
|
319
|
+
responseSent = true;
|
|
320
|
+
return originalEndM.apply(this, args);
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
const runNext = async () => {
|
|
324
|
+
if (responseSent) return;
|
|
325
|
+
|
|
326
|
+
if (middlewareIndex < middlewarePaths.length) {
|
|
327
|
+
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);
|
|
332
|
+
} else {
|
|
333
|
+
await runNext();
|
|
334
|
+
}
|
|
335
|
+
} else {
|
|
336
|
+
delete require.cache[require.resolve(filePath)];
|
|
337
|
+
const handler = require(filePath);
|
|
338
|
+
|
|
339
|
+
if (typeof handler === "function" || (handler && typeof handler.default === "function")) {
|
|
340
|
+
const actualHandler = handler.default || handler;
|
|
341
|
+
|
|
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
|
+
}
|
|
350
|
+
}
|
|
351
|
+
if (errors.length > 0) {
|
|
352
|
+
return res.status(400).json({ error: "Validation Failed", details: errors });
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
await actualHandler(req, res);
|
|
357
|
+
} else {
|
|
358
|
+
res.status(500).json({ error: `Handler in ${filePath} must be a function.` });
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
// 11. Enhanced DX: Global Middleware (Plugins)
|
|
364
|
+
let globalIndex = 0;
|
|
365
|
+
const runGlobal = async () => {
|
|
366
|
+
if (globalIndex < globalMiddleware.length) {
|
|
367
|
+
await globalMiddleware[globalIndex++](req, res, runGlobal);
|
|
368
|
+
} else {
|
|
369
|
+
await runNext();
|
|
370
|
+
}
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
await runGlobal();
|
|
374
|
+
|
|
27
375
|
} catch (err) {
|
|
28
|
-
|
|
29
|
-
|
|
376
|
+
if (config.features.errors) {
|
|
377
|
+
// Check for custom _error.js handler
|
|
378
|
+
const errorHandlerPath = path.join(apiDir, '_error.js');
|
|
379
|
+
if (fs.existsSync(errorHandlerPath)) {
|
|
380
|
+
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);
|
|
385
|
+
}
|
|
386
|
+
} catch (e) {
|
|
387
|
+
console.error("❌ Error in custom error handler:", e);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const statusCode = err.status || 500;
|
|
392
|
+
const message = err.message || "Internal Server Error";
|
|
393
|
+
|
|
394
|
+
return res.status(statusCode).json({
|
|
395
|
+
error: statusCode >= 500 ? "Runtime Error" : "Request Error",
|
|
396
|
+
message: message,
|
|
397
|
+
stack: process.env.NODE_ENV === 'development' || !process.env.NODE_ENV ? err.stack : undefined
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Fallback if errors feature is disabled
|
|
402
|
+
res.status(500).json({ error: "Runtime Error", message: err.message });
|
|
30
403
|
}
|
|
31
404
|
} else {
|
|
32
|
-
res.
|
|
33
|
-
res.end(`Route ${url} not found (No file at ${filePath})`);
|
|
405
|
+
res.status(404).json({ error: "Not Found", route: url });
|
|
34
406
|
}
|
|
35
407
|
});
|
|
36
408
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zerra-core",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -9,5 +9,8 @@
|
|
|
9
9
|
"keywords": [],
|
|
10
10
|
"author": "",
|
|
11
11
|
"license": "ISC",
|
|
12
|
-
"type": "commonjs"
|
|
12
|
+
"type": "commonjs",
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"busboy": "^1.6.0"
|
|
15
|
+
}
|
|
13
16
|
}
|