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.
Files changed (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1265 -0
  3. package/bin/volt.js +139 -0
  4. package/package.json +56 -0
  5. package/src/api/graphql.js +399 -0
  6. package/src/api/rest.js +204 -0
  7. package/src/api/websocket.js +285 -0
  8. package/src/cli/build.js +111 -0
  9. package/src/cli/create.js +371 -0
  10. package/src/cli/db.js +106 -0
  11. package/src/cli/dev.js +114 -0
  12. package/src/cli/generate.js +278 -0
  13. package/src/cli/lint.js +172 -0
  14. package/src/cli/routes.js +118 -0
  15. package/src/cli/start.js +42 -0
  16. package/src/cli/test.js +138 -0
  17. package/src/core/app.js +701 -0
  18. package/src/core/config.js +232 -0
  19. package/src/core/middleware.js +133 -0
  20. package/src/core/plugins.js +88 -0
  21. package/src/core/react-renderer.js +244 -0
  22. package/src/core/renderer.js +337 -0
  23. package/src/core/router.js +183 -0
  24. package/src/database/index.js +461 -0
  25. package/src/database/migration.js +192 -0
  26. package/src/database/model.js +285 -0
  27. package/src/database/query.js +394 -0
  28. package/src/database/seeder.js +89 -0
  29. package/src/index.js +156 -0
  30. package/src/security/auth.js +425 -0
  31. package/src/security/cors.js +80 -0
  32. package/src/security/csrf.js +125 -0
  33. package/src/security/encryption.js +110 -0
  34. package/src/security/helmet.js +103 -0
  35. package/src/security/index.js +75 -0
  36. package/src/security/rateLimit.js +119 -0
  37. package/src/security/sanitizer.js +113 -0
  38. package/src/security/xss.js +110 -0
  39. package/src/ui/component.js +224 -0
  40. package/src/ui/reactive.js +503 -0
  41. package/src/ui/template.js +448 -0
  42. package/src/utils/cache.js +216 -0
  43. package/src/utils/collection.js +772 -0
  44. package/src/utils/cron.js +213 -0
  45. package/src/utils/date.js +223 -0
  46. package/src/utils/events.js +181 -0
  47. package/src/utils/excel.js +482 -0
  48. package/src/utils/form.js +547 -0
  49. package/src/utils/hash.js +121 -0
  50. package/src/utils/http.js +461 -0
  51. package/src/utils/logger.js +186 -0
  52. package/src/utils/mail.js +347 -0
  53. package/src/utils/paginator.js +179 -0
  54. package/src/utils/pdf.js +417 -0
  55. package/src/utils/queue.js +199 -0
  56. package/src/utils/schema.js +985 -0
  57. package/src/utils/sms.js +243 -0
  58. package/src/utils/storage.js +348 -0
  59. package/src/utils/string.js +236 -0
  60. 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 = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;' };
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 };