voltjs-framework 1.0.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/LICENSE +21 -0
- package/README.md +1265 -0
- package/bin/volt.js +139 -0
- package/package.json +56 -0
- package/src/api/graphql.js +399 -0
- package/src/api/rest.js +204 -0
- package/src/api/websocket.js +285 -0
- package/src/cli/build.js +111 -0
- package/src/cli/create.js +371 -0
- package/src/cli/db.js +106 -0
- package/src/cli/dev.js +114 -0
- package/src/cli/generate.js +278 -0
- package/src/cli/lint.js +172 -0
- package/src/cli/routes.js +118 -0
- package/src/cli/start.js +42 -0
- package/src/cli/test.js +138 -0
- package/src/core/app.js +701 -0
- package/src/core/config.js +232 -0
- package/src/core/middleware.js +133 -0
- package/src/core/plugins.js +88 -0
- package/src/core/react-renderer.js +244 -0
- package/src/core/renderer.js +337 -0
- package/src/core/router.js +183 -0
- package/src/database/index.js +461 -0
- package/src/database/migration.js +192 -0
- package/src/database/model.js +285 -0
- package/src/database/query.js +394 -0
- package/src/database/seeder.js +89 -0
- package/src/index.js +156 -0
- package/src/security/auth.js +425 -0
- package/src/security/cors.js +80 -0
- package/src/security/csrf.js +125 -0
- package/src/security/encryption.js +110 -0
- package/src/security/helmet.js +103 -0
- package/src/security/index.js +75 -0
- package/src/security/rateLimit.js +119 -0
- package/src/security/sanitizer.js +113 -0
- package/src/security/xss.js +110 -0
- package/src/ui/component.js +224 -0
- package/src/ui/reactive.js +503 -0
- package/src/ui/template.js +448 -0
- package/src/utils/cache.js +216 -0
- package/src/utils/collection.js +772 -0
- package/src/utils/cron.js +213 -0
- package/src/utils/date.js +223 -0
- package/src/utils/events.js +181 -0
- package/src/utils/excel.js +482 -0
- package/src/utils/form.js +547 -0
- package/src/utils/hash.js +121 -0
- package/src/utils/http.js +461 -0
- package/src/utils/logger.js +186 -0
- package/src/utils/mail.js +347 -0
- package/src/utils/paginator.js +179 -0
- package/src/utils/pdf.js +417 -0
- package/src/utils/queue.js +199 -0
- package/src/utils/schema.js +985 -0
- package/src/utils/sms.js +243 -0
- package/src/utils/storage.js +348 -0
- package/src/utils/string.js +236 -0
- package/src/utils/validation.js +318 -0
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VoltJS Template Engine
|
|
3
|
+
*
|
|
4
|
+
* Advanced server-side template engine with layouts, partials,
|
|
5
|
+
* conditionals, loops, helpers, and auto-escaping.
|
|
6
|
+
*
|
|
7
|
+
* Syntax:
|
|
8
|
+
* {{ variable }} - Escaped output
|
|
9
|
+
* {{{ variable }}} - Raw output
|
|
10
|
+
* {#if condition} ... {/if}
|
|
11
|
+
* {#if condition} ... {#else} ... {/if}
|
|
12
|
+
* {#each items as item} ... {/each}
|
|
13
|
+
* {#each items as item, index} ... {/each}
|
|
14
|
+
* {> partialName}
|
|
15
|
+
* {#layout "layoutName"} ... {/layout}
|
|
16
|
+
* {#slot name} default {/slot}
|
|
17
|
+
* {#block name} content {/block}
|
|
18
|
+
* {{ helper(arg1, arg2) }}
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* const { TemplateEngine } = require('voltjs');
|
|
22
|
+
*
|
|
23
|
+
* const engine = new TemplateEngine({ views: './views' });
|
|
24
|
+
* engine.addHelper('upper', (str) => str.toUpperCase());
|
|
25
|
+
*
|
|
26
|
+
* const html = engine.render('index', { title: 'Home', user: 'Jane' });
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
'use strict';
|
|
30
|
+
|
|
31
|
+
const fs = require('fs');
|
|
32
|
+
const path = require('path');
|
|
33
|
+
|
|
34
|
+
class TemplateEngine {
|
|
35
|
+
constructor(options = {}) {
|
|
36
|
+
this.viewsDir = options.views || './views';
|
|
37
|
+
this.ext = options.ext || '.volt.html';
|
|
38
|
+
this.cache = new Map();
|
|
39
|
+
this.cacheEnabled = options.cache !== false;
|
|
40
|
+
this.helpers = {};
|
|
41
|
+
this.globals = {};
|
|
42
|
+
this.layouts = {};
|
|
43
|
+
this.partials = {};
|
|
44
|
+
|
|
45
|
+
// Built-in helpers
|
|
46
|
+
this.addHelper('upper', s => String(s).toUpperCase());
|
|
47
|
+
this.addHelper('lower', s => String(s).toLowerCase());
|
|
48
|
+
this.addHelper('capitalize', s => String(s).charAt(0).toUpperCase() + String(s).slice(1));
|
|
49
|
+
this.addHelper('json', v => JSON.stringify(v, null, 2));
|
|
50
|
+
this.addHelper('date', d => new Date(d).toLocaleDateString());
|
|
51
|
+
this.addHelper('time', d => new Date(d).toLocaleTimeString());
|
|
52
|
+
this.addHelper('currency', (n, c = 'USD') => new Intl.NumberFormat('en-US', { style: 'currency', currency: c }).format(n));
|
|
53
|
+
this.addHelper('number', n => new Intl.NumberFormat().format(n));
|
|
54
|
+
this.addHelper('truncate', (s, l = 50) => String(s).length > l ? String(s).substring(0, l) + '...' : String(s));
|
|
55
|
+
this.addHelper('default', (v, d) => v ?? d);
|
|
56
|
+
this.addHelper('length', v => (Array.isArray(v) ? v.length : String(v).length));
|
|
57
|
+
this.addHelper('keys', v => Object.keys(v || {}));
|
|
58
|
+
this.addHelper('values', v => Object.values(v || {}));
|
|
59
|
+
this.addHelper('reverse', v => Array.isArray(v) ? [...v].reverse() : String(v).split('').reverse().join(''));
|
|
60
|
+
this.addHelper('first', v => Array.isArray(v) ? v[0] : v);
|
|
61
|
+
this.addHelper('last', v => Array.isArray(v) ? v[v.length - 1] : v);
|
|
62
|
+
this.addHelper('join', (v, sep = ', ') => Array.isArray(v) ? v.join(sep) : v);
|
|
63
|
+
this.addHelper('range', (start, end) => Array.from({ length: end - start + 1 }, (_, i) => start + i));
|
|
64
|
+
this.addHelper('pluralize', (n, s, p) => n === 1 ? s : (p || s + 's'));
|
|
65
|
+
this.addHelper('ago', d => {
|
|
66
|
+
const ms = Date.now() - new Date(d).getTime();
|
|
67
|
+
const s = Math.floor(ms / 1000);
|
|
68
|
+
if (s < 60) return `${s}s ago`;
|
|
69
|
+
const m = Math.floor(s / 60);
|
|
70
|
+
if (m < 60) return `${m}m ago`;
|
|
71
|
+
const h = Math.floor(m / 60);
|
|
72
|
+
if (h < 24) return `${h}h ago`;
|
|
73
|
+
return `${Math.floor(h / 24)}d ago`;
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Register a custom helper */
|
|
78
|
+
addHelper(name, fn) {
|
|
79
|
+
this.helpers[name] = fn;
|
|
80
|
+
return this;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Set global variables (available in all templates) */
|
|
84
|
+
addGlobal(key, value) {
|
|
85
|
+
this.globals[key] = value;
|
|
86
|
+
return this;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Register a partial template */
|
|
90
|
+
addPartial(name, template) {
|
|
91
|
+
this.partials[name] = template;
|
|
92
|
+
return this;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Render a template file */
|
|
96
|
+
render(templateName, data = {}) {
|
|
97
|
+
const template = this._loadTemplate(templateName);
|
|
98
|
+
return this._compile(template, { ...this.globals, ...data });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Render a raw template string */
|
|
102
|
+
renderString(template, data = {}) {
|
|
103
|
+
return this._compile(template, { ...this.globals, ...data });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Express/Volt engine integration */
|
|
107
|
+
engine() {
|
|
108
|
+
return (filePath, data, callback) => {
|
|
109
|
+
try {
|
|
110
|
+
const template = fs.readFileSync(filePath, 'utf-8');
|
|
111
|
+
const html = this._compile(template, { ...this.globals, ...data });
|
|
112
|
+
callback(null, html);
|
|
113
|
+
} catch (err) {
|
|
114
|
+
callback(err);
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ===== COMPILATION =====
|
|
120
|
+
|
|
121
|
+
_compile(template, data) {
|
|
122
|
+
let html = template;
|
|
123
|
+
|
|
124
|
+
// 1. Process layouts
|
|
125
|
+
html = this._processLayouts(html, data);
|
|
126
|
+
|
|
127
|
+
// 2. Process blocks
|
|
128
|
+
html = this._processBlocks(html, data);
|
|
129
|
+
|
|
130
|
+
// 3. Process partials
|
|
131
|
+
html = this._processPartials(html, data);
|
|
132
|
+
|
|
133
|
+
// 4. Process conditionals
|
|
134
|
+
html = this._processConditionals(html, data);
|
|
135
|
+
|
|
136
|
+
// 5. Process loops
|
|
137
|
+
html = this._processLoops(html, data);
|
|
138
|
+
|
|
139
|
+
// 6. Process raw output {{{ }}}
|
|
140
|
+
html = html.replace(/\{\{\{(.+?)\}\}\}/g, (_, expr) => {
|
|
141
|
+
return this._evaluate(expr.trim(), data) ?? '';
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// 7. Process escaped output {{ }}
|
|
145
|
+
html = html.replace(/\{\{(.+?)\}\}/g, (_, expr) => {
|
|
146
|
+
const value = this._evaluate(expr.trim(), data);
|
|
147
|
+
return this._escape(value ?? '');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
return html;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
_processLayouts(html, data) {
|
|
154
|
+
const layoutRegex = /\{#layout\s+"(.+?)"\s*\}([\s\S]*?)\{\/layout\}/g;
|
|
155
|
+
let match;
|
|
156
|
+
|
|
157
|
+
while ((match = layoutRegex.exec(html)) !== null) {
|
|
158
|
+
const layoutName = match[1];
|
|
159
|
+
const content = match[2];
|
|
160
|
+
const layoutTemplate = this._loadTemplate(layoutName);
|
|
161
|
+
|
|
162
|
+
// Extract slots from content
|
|
163
|
+
const slots = {};
|
|
164
|
+
const slotRegex = /\{#slot\s+(\w+)\}([\s\S]*?)\{\/slot\}/g;
|
|
165
|
+
let slotMatch;
|
|
166
|
+
while ((slotMatch = slotRegex.exec(content)) !== null) {
|
|
167
|
+
slots[slotMatch[1]] = slotMatch[2];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Replace slots in layout
|
|
171
|
+
let compiled = layoutTemplate.replace(
|
|
172
|
+
/\{#slot\s+(\w+)\}([\s\S]*?)\{\/slot\}/g,
|
|
173
|
+
(_, name, defaultContent) => slots[name] || defaultContent
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
// Replace {#slot content} with the main content
|
|
177
|
+
compiled = compiled.replace(
|
|
178
|
+
/\{#slot\s+content\s*\}/g,
|
|
179
|
+
content.replace(/\{#slot\s+\w+\}[\s\S]*?\{\/slot\}/g, '').trim()
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
html = html.replace(match[0], compiled);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return html;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
_processBlocks(html, data) {
|
|
189
|
+
return html.replace(
|
|
190
|
+
/\{#block\s+(\w+)\}([\s\S]*?)\{\/block\}/g,
|
|
191
|
+
(_, name, content) => this._compile(content.trim(), data)
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
_processPartials(html, data) {
|
|
196
|
+
return html.replace(/\{>\s*(\w+)\s*\}/g, (_, name) => {
|
|
197
|
+
// Check registered partials first
|
|
198
|
+
if (this.partials[name]) {
|
|
199
|
+
return this._compile(this.partials[name], data);
|
|
200
|
+
}
|
|
201
|
+
// Try loading from file
|
|
202
|
+
try {
|
|
203
|
+
const template = this._loadTemplate(`partials/${name}`);
|
|
204
|
+
return this._compile(template, data);
|
|
205
|
+
} catch {
|
|
206
|
+
return `<!-- Partial not found: ${name} -->`;
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
_processConditionals(html, data) {
|
|
212
|
+
// Nested-aware processing
|
|
213
|
+
let result = html;
|
|
214
|
+
let changed = true;
|
|
215
|
+
let maxIter = 50;
|
|
216
|
+
|
|
217
|
+
while (changed && maxIter-- > 0) {
|
|
218
|
+
changed = false;
|
|
219
|
+
|
|
220
|
+
// {#if}{#else}{/if}
|
|
221
|
+
result = result.replace(
|
|
222
|
+
/\{#if\s+(.+?)\}([\s\S]*?)\{#else\}([\s\S]*?)\{\/if\}/,
|
|
223
|
+
(match, condition, trueBlock, falseBlock) => {
|
|
224
|
+
changed = true;
|
|
225
|
+
const val = this._evaluate(condition, data);
|
|
226
|
+
return val ? this._compile(trueBlock, data) : this._compile(falseBlock, data);
|
|
227
|
+
}
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
// {#if}{/if} (without else)
|
|
231
|
+
result = result.replace(
|
|
232
|
+
/\{#if\s+(.+?)\}([\s\S]*?)\{\/if\}/,
|
|
233
|
+
(match, condition, block) => {
|
|
234
|
+
changed = true;
|
|
235
|
+
const val = this._evaluate(condition, data);
|
|
236
|
+
return val ? this._compile(block, data) : '';
|
|
237
|
+
}
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
// {#unless}{/unless}
|
|
241
|
+
result = result.replace(
|
|
242
|
+
/\{#unless\s+(.+?)\}([\s\S]*?)\{\/unless\}/,
|
|
243
|
+
(match, condition, block) => {
|
|
244
|
+
changed = true;
|
|
245
|
+
const val = this._evaluate(condition, data);
|
|
246
|
+
return !val ? this._compile(block, data) : '';
|
|
247
|
+
}
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return result;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
_processLoops(html, data) {
|
|
255
|
+
let result = html;
|
|
256
|
+
let changed = true;
|
|
257
|
+
let maxIter = 50;
|
|
258
|
+
|
|
259
|
+
while (changed && maxIter-- > 0) {
|
|
260
|
+
changed = false;
|
|
261
|
+
|
|
262
|
+
result = result.replace(
|
|
263
|
+
/\{#each\s+(\S+)\s+as\s+(\w+)(?:\s*,\s*(\w+))?\}([\s\S]*?)\{\/each\}/,
|
|
264
|
+
(match, listExpr, itemName, indexName, body) => {
|
|
265
|
+
changed = true;
|
|
266
|
+
const list = this._evaluate(listExpr, data);
|
|
267
|
+
if (!Array.isArray(list)) return '';
|
|
268
|
+
|
|
269
|
+
return list.map((item, index) => {
|
|
270
|
+
const loopData = {
|
|
271
|
+
...data,
|
|
272
|
+
[itemName]: item,
|
|
273
|
+
...(indexName ? { [indexName]: index } : {}),
|
|
274
|
+
$index: index,
|
|
275
|
+
$first: index === 0,
|
|
276
|
+
$last: index === list.length - 1,
|
|
277
|
+
$even: index % 2 === 0,
|
|
278
|
+
$odd: index % 2 !== 0,
|
|
279
|
+
$length: list.length,
|
|
280
|
+
};
|
|
281
|
+
return this._compile(body, loopData);
|
|
282
|
+
}).join('');
|
|
283
|
+
}
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return result;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
_evaluate(expr, data) {
|
|
291
|
+
// Helper call: helperName(args)
|
|
292
|
+
const helperMatch = expr.match(/^(\w+)\((.*)?\)$/);
|
|
293
|
+
if (helperMatch) {
|
|
294
|
+
const [, name, argsStr] = helperMatch;
|
|
295
|
+
if (this.helpers[name]) {
|
|
296
|
+
const args = argsStr ? this._parseArgs(argsStr, data) : [];
|
|
297
|
+
return this.helpers[name](...args);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Comparison: a == b, a != b, a > b, etc.
|
|
302
|
+
const compMatch = expr.match(/^(.+?)\s*(===|!==|==|!=|>=|<=|>|<)\s*(.+)$/);
|
|
303
|
+
if (compMatch) {
|
|
304
|
+
const left = this._evaluate(compMatch[1].trim(), data);
|
|
305
|
+
const right = this._evaluate(compMatch[3].trim(), data);
|
|
306
|
+
switch (compMatch[2]) {
|
|
307
|
+
case '===': return left === right;
|
|
308
|
+
case '!==': return left !== right;
|
|
309
|
+
case '==': return left == right;
|
|
310
|
+
case '!=': return left != right;
|
|
311
|
+
case '>': return left > right;
|
|
312
|
+
case '<': return left < right;
|
|
313
|
+
case '>=': return left >= right;
|
|
314
|
+
case '<=': return left <= right;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Logical: a && b, a || b
|
|
319
|
+
if (expr.includes('&&')) {
|
|
320
|
+
const parts = expr.split('&&').map(p => p.trim());
|
|
321
|
+
return parts.every(p => this._evaluate(p, data));
|
|
322
|
+
}
|
|
323
|
+
if (expr.includes('||')) {
|
|
324
|
+
const parts = expr.split('||').map(p => p.trim());
|
|
325
|
+
return parts.some(p => this._evaluate(p, data));
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Negation: !value
|
|
329
|
+
if (expr.startsWith('!')) {
|
|
330
|
+
return !this._evaluate(expr.slice(1).trim(), data);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// String literal
|
|
334
|
+
if ((expr.startsWith('"') && expr.endsWith('"')) || (expr.startsWith("'") && expr.endsWith("'"))) {
|
|
335
|
+
return expr.slice(1, -1);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Number literal
|
|
339
|
+
if (!isNaN(expr)) return Number(expr);
|
|
340
|
+
|
|
341
|
+
// Boolean
|
|
342
|
+
if (expr === 'true') return true;
|
|
343
|
+
if (expr === 'false') return false;
|
|
344
|
+
if (expr === 'null' || expr === 'undefined') return null;
|
|
345
|
+
|
|
346
|
+
// Ternary: condition ? a : b
|
|
347
|
+
const ternary = expr.match(/^(.+?)\s*\?\s*(.+?)\s*:\s*(.+)$/);
|
|
348
|
+
if (ternary) {
|
|
349
|
+
return this._evaluate(ternary[1].trim(), data)
|
|
350
|
+
? this._evaluate(ternary[2].trim(), data)
|
|
351
|
+
: this._evaluate(ternary[3].trim(), data);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Dot-notation property access: user.name.first
|
|
355
|
+
return this._resolvePath(expr, data);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
_resolvePath(path, data) {
|
|
359
|
+
// Handle array access: items[0].name
|
|
360
|
+
const parts = path.replace(/\[(\d+)\]/g, '.$1').split('.');
|
|
361
|
+
let value = data;
|
|
362
|
+
for (const part of parts) {
|
|
363
|
+
if (value === null || value === undefined) return undefined;
|
|
364
|
+
value = value[part];
|
|
365
|
+
}
|
|
366
|
+
return value;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
_parseArgs(argsStr, data) {
|
|
370
|
+
const args = [];
|
|
371
|
+
let current = '';
|
|
372
|
+
let depth = 0;
|
|
373
|
+
let inString = false;
|
|
374
|
+
let stringChar = '';
|
|
375
|
+
|
|
376
|
+
for (let i = 0; i < argsStr.length; i++) {
|
|
377
|
+
const ch = argsStr[i];
|
|
378
|
+
if (inString) {
|
|
379
|
+
current += ch;
|
|
380
|
+
if (ch === stringChar) inString = false;
|
|
381
|
+
} else if (ch === '"' || ch === "'") {
|
|
382
|
+
inString = true;
|
|
383
|
+
stringChar = ch;
|
|
384
|
+
current += ch;
|
|
385
|
+
} else if (ch === '(') {
|
|
386
|
+
depth++;
|
|
387
|
+
current += ch;
|
|
388
|
+
} else if (ch === ')') {
|
|
389
|
+
depth--;
|
|
390
|
+
current += ch;
|
|
391
|
+
} else if (ch === ',' && depth === 0) {
|
|
392
|
+
args.push(this._evaluate(current.trim(), data));
|
|
393
|
+
current = '';
|
|
394
|
+
} else {
|
|
395
|
+
current += ch;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
if (current.trim()) {
|
|
399
|
+
args.push(this._evaluate(current.trim(), data));
|
|
400
|
+
}
|
|
401
|
+
return args;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
_escape(value) {
|
|
405
|
+
const str = String(value);
|
|
406
|
+
const map = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
|
407
|
+
return str.replace(/[&<>"']/g, c => map[c]);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
_loadTemplate(name) {
|
|
411
|
+
const cacheKey = name;
|
|
412
|
+
if (this.cacheEnabled && this.cache.has(cacheKey)) {
|
|
413
|
+
return this.cache.get(cacheKey);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Try multiple extensions
|
|
417
|
+
const extensions = [this.ext, '.html', '.volt', '.hbs', '.ejs'];
|
|
418
|
+
let template = null;
|
|
419
|
+
|
|
420
|
+
for (const ext of extensions) {
|
|
421
|
+
const filePath = path.join(this.viewsDir, name + ext);
|
|
422
|
+
if (fs.existsSync(filePath)) {
|
|
423
|
+
template = fs.readFileSync(filePath, 'utf-8');
|
|
424
|
+
break;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Try exact path
|
|
429
|
+
if (!template) {
|
|
430
|
+
const exactPath = path.join(this.viewsDir, name);
|
|
431
|
+
if (fs.existsSync(exactPath)) {
|
|
432
|
+
template = fs.readFileSync(exactPath, 'utf-8');
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
if (!template) {
|
|
437
|
+
throw new Error(`Template not found: ${name} (searched in ${this.viewsDir})`);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
if (this.cacheEnabled) {
|
|
441
|
+
this.cache.set(cacheKey, template);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
return template;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
module.exports = { TemplateEngine };
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VoltJS Cache
|
|
3
|
+
*
|
|
4
|
+
* High-performance in-memory cache with TTL, LRU eviction, and namespaces.
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* const { Cache } = require('voltjs');
|
|
8
|
+
*
|
|
9
|
+
* const cache = new Cache({ maxSize: 1000, defaultTTL: 300 });
|
|
10
|
+
* cache.set('user:1', { name: 'John' }, 60); // 60s TTL
|
|
11
|
+
* const user = cache.get('user:1');
|
|
12
|
+
*
|
|
13
|
+
* // Or use as middleware
|
|
14
|
+
* app.get('/api/data', Cache.middleware(60), handler);
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
'use strict';
|
|
18
|
+
|
|
19
|
+
class Cache {
|
|
20
|
+
constructor(options = {}) {
|
|
21
|
+
this.maxSize = options.maxSize || 10000;
|
|
22
|
+
this.defaultTTL = options.defaultTTL || 300; // 5 min
|
|
23
|
+
this.store = new Map();
|
|
24
|
+
this.hits = 0;
|
|
25
|
+
this.misses = 0;
|
|
26
|
+
|
|
27
|
+
// Auto-cleanup expired entries
|
|
28
|
+
this._cleanupInterval = setInterval(() => this._cleanup(), 60000);
|
|
29
|
+
if (this._cleanupInterval.unref) this._cleanupInterval.unref();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Set a cache entry */
|
|
33
|
+
set(key, value, ttl) {
|
|
34
|
+
const expiresAt = Date.now() + ((ttl || this.defaultTTL) * 1000);
|
|
35
|
+
|
|
36
|
+
// LRU: Delete and re-insert to move to end
|
|
37
|
+
if (this.store.has(key)) {
|
|
38
|
+
this.store.delete(key);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Evict oldest if at capacity
|
|
42
|
+
if (this.store.size >= this.maxSize) {
|
|
43
|
+
const firstKey = this.store.keys().next().value;
|
|
44
|
+
this.store.delete(firstKey);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
this.store.set(key, { value, expiresAt, createdAt: Date.now() });
|
|
48
|
+
return this;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Get a cache entry */
|
|
52
|
+
get(key) {
|
|
53
|
+
const entry = this.store.get(key);
|
|
54
|
+
|
|
55
|
+
if (!entry) {
|
|
56
|
+
this.misses++;
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (Date.now() > entry.expiresAt) {
|
|
61
|
+
this.store.delete(key);
|
|
62
|
+
this.misses++;
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// LRU: Move to end
|
|
67
|
+
this.store.delete(key);
|
|
68
|
+
this.store.set(key, entry);
|
|
69
|
+
this.hits++;
|
|
70
|
+
|
|
71
|
+
return entry.value;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Get or set (fetch from callback if missing) */
|
|
75
|
+
async getOrSet(key, fetchFn, ttl) {
|
|
76
|
+
const cached = this.get(key);
|
|
77
|
+
if (cached !== null) return cached;
|
|
78
|
+
|
|
79
|
+
const value = await fetchFn();
|
|
80
|
+
this.set(key, value, ttl);
|
|
81
|
+
return value;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Check if key exists and not expired */
|
|
85
|
+
has(key) {
|
|
86
|
+
const entry = this.store.get(key);
|
|
87
|
+
if (!entry) return false;
|
|
88
|
+
if (Date.now() > entry.expiresAt) {
|
|
89
|
+
this.store.delete(key);
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Delete a key */
|
|
96
|
+
delete(key) {
|
|
97
|
+
return this.store.delete(key);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Delete keys matching pattern */
|
|
101
|
+
deletePattern(pattern) {
|
|
102
|
+
const regex = new RegExp(pattern.replace(/\*/g, '.*'));
|
|
103
|
+
let count = 0;
|
|
104
|
+
for (const key of this.store.keys()) {
|
|
105
|
+
if (regex.test(key)) {
|
|
106
|
+
this.store.delete(key);
|
|
107
|
+
count++;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return count;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Clear all entries */
|
|
114
|
+
clear() {
|
|
115
|
+
this.store.clear();
|
|
116
|
+
this.hits = 0;
|
|
117
|
+
this.misses = 0;
|
|
118
|
+
return this;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Clear entries by namespace (prefix) */
|
|
122
|
+
clearNamespace(namespace) {
|
|
123
|
+
const prefix = namespace + ':';
|
|
124
|
+
for (const key of this.store.keys()) {
|
|
125
|
+
if (key.startsWith(prefix)) {
|
|
126
|
+
this.store.delete(key);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return this;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Get cache stats */
|
|
133
|
+
stats() {
|
|
134
|
+
const total = this.hits + this.misses;
|
|
135
|
+
return {
|
|
136
|
+
size: this.store.size,
|
|
137
|
+
maxSize: this.maxSize,
|
|
138
|
+
hits: this.hits,
|
|
139
|
+
misses: this.misses,
|
|
140
|
+
hitRate: total > 0 ? (this.hits / total * 100).toFixed(1) + '%' : '0%',
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Get all keys */
|
|
145
|
+
keys() {
|
|
146
|
+
return [...this.store.keys()];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Increment a numeric value */
|
|
150
|
+
increment(key, amount = 1) {
|
|
151
|
+
const val = this.get(key) || 0;
|
|
152
|
+
this.set(key, val + amount);
|
|
153
|
+
return val + amount;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Decrement a numeric value */
|
|
157
|
+
decrement(key, amount = 1) {
|
|
158
|
+
return this.increment(key, -amount);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Get remaining TTL in seconds */
|
|
162
|
+
ttl(key) {
|
|
163
|
+
const entry = this.store.get(key);
|
|
164
|
+
if (!entry) return -1;
|
|
165
|
+
const remaining = Math.max(0, entry.expiresAt - Date.now());
|
|
166
|
+
return Math.ceil(remaining / 1000);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Express/Volt middleware for response caching */
|
|
170
|
+
static middleware(ttl = 60) {
|
|
171
|
+
const cache = new Cache({ defaultTTL: ttl });
|
|
172
|
+
|
|
173
|
+
return (req, res) => {
|
|
174
|
+
if (req.method !== 'GET') return;
|
|
175
|
+
|
|
176
|
+
const key = `resp:${req.url}`;
|
|
177
|
+
const cached = cache.get(key);
|
|
178
|
+
|
|
179
|
+
if (cached) {
|
|
180
|
+
res.setHeader('X-Cache', 'HIT');
|
|
181
|
+
res.setHeader('Content-Type', cached.contentType || 'application/json');
|
|
182
|
+
res.end(cached.body);
|
|
183
|
+
return false; // Stops middleware chain
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Monkey-patch res.end to cache the response
|
|
187
|
+
const originalEnd = res.end.bind(res);
|
|
188
|
+
res.end = (body) => {
|
|
189
|
+
cache.set(key, {
|
|
190
|
+
body,
|
|
191
|
+
contentType: res.getHeader('content-type'),
|
|
192
|
+
});
|
|
193
|
+
res.setHeader('X-Cache', 'MISS');
|
|
194
|
+
originalEnd(body);
|
|
195
|
+
};
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Destroy cache (stop cleanup timer) */
|
|
200
|
+
destroy() {
|
|
201
|
+
clearInterval(this._cleanupInterval);
|
|
202
|
+
this.store.clear();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** @private */
|
|
206
|
+
_cleanup() {
|
|
207
|
+
const now = Date.now();
|
|
208
|
+
for (const [key, entry] of this.store) {
|
|
209
|
+
if (now > entry.expiresAt) {
|
|
210
|
+
this.store.delete(key);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
module.exports = { Cache };
|