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
package/src/utils/pdf.js
ADDED
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VoltJS PDF Generator
|
|
3
|
+
*
|
|
4
|
+
* Generate PDF documents from HTML or programmatically.
|
|
5
|
+
* Uses raw PDF spec — zero external dependencies.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* const { PDF } = require('voltjs');
|
|
9
|
+
*
|
|
10
|
+
* const pdf = new PDF();
|
|
11
|
+
* pdf.addPage()
|
|
12
|
+
* .setFont('Helvetica', 16)
|
|
13
|
+
* .text('Hello VoltJS!', 50, 700)
|
|
14
|
+
* .setFont('Helvetica', 12)
|
|
15
|
+
* .text('Generated with VoltJS PDF', 50, 670)
|
|
16
|
+
* .line(50, 660, 550, 660)
|
|
17
|
+
* .save('output.pdf');
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
'use strict';
|
|
21
|
+
|
|
22
|
+
const fs = require('fs');
|
|
23
|
+
|
|
24
|
+
class PDF {
|
|
25
|
+
constructor(options = {}) {
|
|
26
|
+
this._objects = [];
|
|
27
|
+
this._pages = [];
|
|
28
|
+
this._currentPage = null;
|
|
29
|
+
this._fonts = new Map();
|
|
30
|
+
this._pageWidth = options.width || 612; // US Letter
|
|
31
|
+
this._pageHeight = options.height || 792;
|
|
32
|
+
this._margin = options.margin || 50;
|
|
33
|
+
this._fontSize = 12;
|
|
34
|
+
this._fontName = 'Helvetica';
|
|
35
|
+
|
|
36
|
+
// Register standard fonts
|
|
37
|
+
this._registerStandardFonts();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Add a new page */
|
|
41
|
+
addPage(options = {}) {
|
|
42
|
+
const width = options.width || this._pageWidth;
|
|
43
|
+
const height = options.height || this._pageHeight;
|
|
44
|
+
|
|
45
|
+
this._currentPage = {
|
|
46
|
+
width,
|
|
47
|
+
height,
|
|
48
|
+
content: [],
|
|
49
|
+
};
|
|
50
|
+
this._pages.push(this._currentPage);
|
|
51
|
+
return this;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Set font */
|
|
55
|
+
setFont(name, size) {
|
|
56
|
+
this._fontName = name || 'Helvetica';
|
|
57
|
+
this._fontSize = size || 12;
|
|
58
|
+
if (this._currentPage) {
|
|
59
|
+
this._currentPage.content.push(`/F1 ${this._fontSize} Tf`);
|
|
60
|
+
}
|
|
61
|
+
return this;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Add text to current page */
|
|
65
|
+
text(str, x, y, options = {}) {
|
|
66
|
+
if (!this._currentPage) this.addPage();
|
|
67
|
+
|
|
68
|
+
const escapedStr = this._escapeString(str);
|
|
69
|
+
const alignment = options.align || 'left';
|
|
70
|
+
|
|
71
|
+
this._currentPage.content.push('BT');
|
|
72
|
+
this._currentPage.content.push(`/F1 ${this._fontSize} Tf`);
|
|
73
|
+
|
|
74
|
+
if (options.color) {
|
|
75
|
+
const rgb = this._hexToRgb(options.color);
|
|
76
|
+
this._currentPage.content.push(`${rgb.r} ${rgb.g} ${rgb.b} rg`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
this._currentPage.content.push(`${x} ${y} Td`);
|
|
80
|
+
this._currentPage.content.push(`(${escapedStr}) Tj`);
|
|
81
|
+
this._currentPage.content.push('ET');
|
|
82
|
+
|
|
83
|
+
return this;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Add a line */
|
|
87
|
+
line(x1, y1, x2, y2, options = {}) {
|
|
88
|
+
if (!this._currentPage) this.addPage();
|
|
89
|
+
|
|
90
|
+
const lineWidth = options.width || 1;
|
|
91
|
+
const content = this._currentPage.content;
|
|
92
|
+
|
|
93
|
+
if (options.color) {
|
|
94
|
+
const rgb = this._hexToRgb(options.color);
|
|
95
|
+
content.push(`${rgb.r} ${rgb.g} ${rgb.b} RG`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
content.push(`${lineWidth} w`);
|
|
99
|
+
content.push(`${x1} ${y1} m`);
|
|
100
|
+
content.push(`${x2} ${y2} l`);
|
|
101
|
+
content.push('S');
|
|
102
|
+
|
|
103
|
+
return this;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Add a rectangle */
|
|
107
|
+
rect(x, y, width, height, options = {}) {
|
|
108
|
+
if (!this._currentPage) this.addPage();
|
|
109
|
+
const content = this._currentPage.content;
|
|
110
|
+
|
|
111
|
+
if (options.fill) {
|
|
112
|
+
const rgb = this._hexToRgb(options.fill);
|
|
113
|
+
content.push(`${rgb.r} ${rgb.g} ${rgb.b} rg`);
|
|
114
|
+
}
|
|
115
|
+
if (options.stroke) {
|
|
116
|
+
const rgb = this._hexToRgb(options.stroke);
|
|
117
|
+
content.push(`${rgb.r} ${rgb.g} ${rgb.b} RG`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
content.push(`${x} ${y} ${width} ${height} re`);
|
|
121
|
+
content.push(options.fill && options.stroke ? 'B' : options.fill ? 'f' : 'S');
|
|
122
|
+
|
|
123
|
+
return this;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Add a circle */
|
|
127
|
+
circle(cx, cy, radius, options = {}) {
|
|
128
|
+
if (!this._currentPage) this.addPage();
|
|
129
|
+
const content = this._currentPage.content;
|
|
130
|
+
|
|
131
|
+
if (options.fill) {
|
|
132
|
+
const rgb = this._hexToRgb(options.fill);
|
|
133
|
+
content.push(`${rgb.r} ${rgb.g} ${rgb.b} rg`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Approximate circle with bezier curves
|
|
137
|
+
const k = 0.5523;
|
|
138
|
+
const kr = k * radius;
|
|
139
|
+
|
|
140
|
+
content.push(`${cx + radius} ${cy} m`);
|
|
141
|
+
content.push(`${cx + radius} ${cy + kr} ${cx + kr} ${cy + radius} ${cx} ${cy + radius} c`);
|
|
142
|
+
content.push(`${cx - kr} ${cy + radius} ${cx - radius} ${cy + kr} ${cx - radius} ${cy} c`);
|
|
143
|
+
content.push(`${cx - radius} ${cy - kr} ${cx - kr} ${cy - radius} ${cx} ${cy - radius} c`);
|
|
144
|
+
content.push(`${cx + kr} ${cy - radius} ${cx + radius} ${cy - kr} ${cx + radius} ${cy} c`);
|
|
145
|
+
content.push(options.fill ? 'f' : 'S');
|
|
146
|
+
|
|
147
|
+
return this;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Add a table */
|
|
151
|
+
table(data, x, y, options = {}) {
|
|
152
|
+
if (!this._currentPage) this.addPage();
|
|
153
|
+
|
|
154
|
+
const cellWidth = options.cellWidth || 100;
|
|
155
|
+
const cellHeight = options.cellHeight || 25;
|
|
156
|
+
const headerBg = options.headerBg || '#667eea';
|
|
157
|
+
const headers = options.headers || (data.length > 0 ? Object.keys(data[0]) : []);
|
|
158
|
+
|
|
159
|
+
let currentY = y;
|
|
160
|
+
|
|
161
|
+
// Draw header
|
|
162
|
+
headers.forEach((header, i) => {
|
|
163
|
+
this.rect(x + i * cellWidth, currentY, cellWidth, cellHeight, { fill: headerBg, stroke: '#333333' });
|
|
164
|
+
this.setFont('Helvetica', 10);
|
|
165
|
+
this._currentPage.content.push('1 1 1 rg'); // White text
|
|
166
|
+
this.text(String(header), x + i * cellWidth + 5, currentY + 8);
|
|
167
|
+
this._currentPage.content.push('0 0 0 rg'); // Reset to black
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
currentY -= cellHeight;
|
|
171
|
+
|
|
172
|
+
// Draw data rows
|
|
173
|
+
for (const row of data) {
|
|
174
|
+
headers.forEach((header, i) => {
|
|
175
|
+
this.rect(x + i * cellWidth, currentY, cellWidth, cellHeight, { stroke: '#cccccc' });
|
|
176
|
+
this.setFont('Helvetica', 9);
|
|
177
|
+
this.text(String(row[header] ?? ''), x + i * cellWidth + 5, currentY + 8);
|
|
178
|
+
});
|
|
179
|
+
currentY -= cellHeight;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return this;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Add header section */
|
|
186
|
+
header(title, subtitle = '') {
|
|
187
|
+
if (!this._currentPage) this.addPage();
|
|
188
|
+
|
|
189
|
+
const y = this._pageHeight - this._margin;
|
|
190
|
+
|
|
191
|
+
// Header bar
|
|
192
|
+
this.rect(0, y - 10, this._pageWidth, 60, { fill: '#667eea' });
|
|
193
|
+
this._currentPage.content.push('1 1 1 rg');
|
|
194
|
+
this.setFont('Helvetica', 20);
|
|
195
|
+
this.text(title, this._margin, y + 15);
|
|
196
|
+
|
|
197
|
+
if (subtitle) {
|
|
198
|
+
this.setFont('Helvetica', 10);
|
|
199
|
+
this.text(subtitle, this._margin, y);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
this._currentPage.content.push('0 0 0 rg');
|
|
203
|
+
return this;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Add paragraph (auto-wrapping text) */
|
|
207
|
+
paragraph(str, x, y, maxWidth, options = {}) {
|
|
208
|
+
if (!this._currentPage) this.addPage();
|
|
209
|
+
|
|
210
|
+
const fontSize = options.fontSize || this._fontSize;
|
|
211
|
+
const lineHeight = options.lineHeight || fontSize * 1.5;
|
|
212
|
+
const charWidth = fontSize * 0.5; // Approximate
|
|
213
|
+
const maxChars = Math.floor(maxWidth / charWidth);
|
|
214
|
+
|
|
215
|
+
const words = str.split(' ');
|
|
216
|
+
let line = '';
|
|
217
|
+
let currentY = y;
|
|
218
|
+
|
|
219
|
+
for (const word of words) {
|
|
220
|
+
if ((line + ' ' + word).length > maxChars) {
|
|
221
|
+
this.setFont(this._fontName, fontSize);
|
|
222
|
+
this.text(line.trim(), x, currentY);
|
|
223
|
+
currentY -= lineHeight;
|
|
224
|
+
line = word;
|
|
225
|
+
} else {
|
|
226
|
+
line += (line ? ' ' : '') + word;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
if (line) {
|
|
230
|
+
this.setFont(this._fontName, fontSize);
|
|
231
|
+
this.text(line.trim(), x, currentY);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return this;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Generate PDF buffer */
|
|
238
|
+
toBuffer() {
|
|
239
|
+
return Buffer.from(this._build(), 'binary');
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Save to file */
|
|
243
|
+
save(filePath) {
|
|
244
|
+
fs.writeFileSync(filePath, this.toBuffer());
|
|
245
|
+
return filePath;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ===== INTERNAL PDF GENERATION =====
|
|
249
|
+
|
|
250
|
+
_build() {
|
|
251
|
+
const objects = [];
|
|
252
|
+
let objectNum = 0;
|
|
253
|
+
|
|
254
|
+
const addObj = (content) => {
|
|
255
|
+
objectNum++;
|
|
256
|
+
objects.push({ num: objectNum, content });
|
|
257
|
+
return objectNum;
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
// Catalog
|
|
261
|
+
const catalogNum = addObj('<<\n/Type /Catalog\n/Pages 2 0 R\n>>');
|
|
262
|
+
|
|
263
|
+
// Pages parent (will be filled later)
|
|
264
|
+
const pagesNum = addObj(''); // Placeholder
|
|
265
|
+
|
|
266
|
+
// Font
|
|
267
|
+
const fontNum = addObj('<<\n/Type /Font\n/Subtype /Type1\n/BaseFont /Helvetica\n>>');
|
|
268
|
+
|
|
269
|
+
// Build pages
|
|
270
|
+
const pageNums = [];
|
|
271
|
+
for (const page of this._pages) {
|
|
272
|
+
// Page content stream
|
|
273
|
+
const stream = page.content.join('\n');
|
|
274
|
+
const streamLength = Buffer.byteLength(stream, 'binary');
|
|
275
|
+
const streamNum = addObj(
|
|
276
|
+
`<<\n/Length ${streamLength}\n>>\nstream\n${stream}\nendstream`
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
// Page object
|
|
280
|
+
const pageNum = addObj(
|
|
281
|
+
`<<\n/Type /Page\n/Parent ${pagesNum} 0 R\n/MediaBox [0 0 ${page.width} ${page.height}]\n/Contents ${streamNum} 0 R\n/Resources <<\n/Font <<\n/F1 ${fontNum} 0 R\n>>\n>>\n>>`
|
|
282
|
+
);
|
|
283
|
+
pageNums.push(pageNum);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Update pages parent
|
|
287
|
+
objects[pagesNum - 1].content = `<<\n/Type /Pages\n/Kids [${pageNums.map(n => `${n} 0 R`).join(' ')}]\n/Count ${pageNums.length}\n>>`;
|
|
288
|
+
|
|
289
|
+
// Build PDF
|
|
290
|
+
let pdf = '%PDF-1.4\n';
|
|
291
|
+
const offsets = [];
|
|
292
|
+
|
|
293
|
+
for (const obj of objects) {
|
|
294
|
+
offsets.push(pdf.length);
|
|
295
|
+
pdf += `${obj.num} 0 obj\n${obj.content}\nendobj\n`;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Cross-reference table
|
|
299
|
+
const xrefOffset = pdf.length;
|
|
300
|
+
pdf += `xref\n0 ${objects.length + 1}\n`;
|
|
301
|
+
pdf += '0000000000 65535 f \n';
|
|
302
|
+
for (const offset of offsets) {
|
|
303
|
+
pdf += `${String(offset).padStart(10, '0')} 00000 n \n`;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Trailer
|
|
307
|
+
pdf += `trailer\n<<\n/Size ${objects.length + 1}\n/Root ${catalogNum} 0 R\n>>\nstartxref\n${xrefOffset}\n%%EOF`;
|
|
308
|
+
|
|
309
|
+
return pdf;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
_escapeString(str) {
|
|
313
|
+
return String(str)
|
|
314
|
+
.replace(/\\/g, '\\\\')
|
|
315
|
+
.replace(/\(/g, '\\(')
|
|
316
|
+
.replace(/\)/g, '\\)');
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
_hexToRgb(hex) {
|
|
320
|
+
const cleaned = hex.replace('#', '');
|
|
321
|
+
const r = parseInt(cleaned.substring(0, 2), 16) / 255;
|
|
322
|
+
const g = parseInt(cleaned.substring(2, 4), 16) / 255;
|
|
323
|
+
const b = parseInt(cleaned.substring(4, 6), 16) / 255;
|
|
324
|
+
return { r: r.toFixed(3), g: g.toFixed(3), b: b.toFixed(3) };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
_registerStandardFonts() {
|
|
328
|
+
this._fonts.set('Helvetica', '/Helvetica');
|
|
329
|
+
this._fonts.set('Times', '/Times-Roman');
|
|
330
|
+
this._fonts.set('Courier', '/Courier');
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ===== STATIC HELPERS =====
|
|
334
|
+
|
|
335
|
+
/** Quick PDF from HTML-like content */
|
|
336
|
+
static fromData(data, options = {}) {
|
|
337
|
+
const pdf = new PDF(options);
|
|
338
|
+
const page = pdf.addPage();
|
|
339
|
+
|
|
340
|
+
let y = pdf._pageHeight - pdf._margin - 50;
|
|
341
|
+
const title = options.title || 'Report';
|
|
342
|
+
|
|
343
|
+
pdf.header(title, options.subtitle || new Date().toLocaleString());
|
|
344
|
+
|
|
345
|
+
if (Array.isArray(data) && data.length > 0 && typeof data[0] === 'object') {
|
|
346
|
+
y -= 80;
|
|
347
|
+
pdf.table(data, pdf._margin, y, {
|
|
348
|
+
headers: options.headers || Object.keys(data[0]),
|
|
349
|
+
cellWidth: options.cellWidth || Math.floor((pdf._pageWidth - pdf._margin * 2) / Object.keys(data[0]).length),
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return pdf;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** Generate PDF invoice */
|
|
357
|
+
static invoice(invoiceData) {
|
|
358
|
+
const pdf = new PDF();
|
|
359
|
+
pdf.addPage();
|
|
360
|
+
|
|
361
|
+
const w = pdf._pageWidth;
|
|
362
|
+
const h = pdf._pageHeight;
|
|
363
|
+
const m = pdf._margin;
|
|
364
|
+
let y = h - m;
|
|
365
|
+
|
|
366
|
+
// Header
|
|
367
|
+
pdf.setFont('Helvetica', 24);
|
|
368
|
+
pdf.text('INVOICE', m, y);
|
|
369
|
+
|
|
370
|
+
pdf.setFont('Helvetica', 10);
|
|
371
|
+
y -= 30;
|
|
372
|
+
pdf.text(`Invoice #: ${invoiceData.number || '001'}`, m, y);
|
|
373
|
+
y -= 15;
|
|
374
|
+
pdf.text(`Date: ${invoiceData.date || new Date().toLocaleDateString()}`, m, y);
|
|
375
|
+
y -= 15;
|
|
376
|
+
pdf.text(`Due: ${invoiceData.dueDate || 'On Receipt'}`, m, y);
|
|
377
|
+
|
|
378
|
+
// From
|
|
379
|
+
y -= 30;
|
|
380
|
+
pdf.setFont('Helvetica', 12);
|
|
381
|
+
pdf.text('From:', m, y);
|
|
382
|
+
pdf.setFont('Helvetica', 10);
|
|
383
|
+
y -= 15;
|
|
384
|
+
pdf.text(invoiceData.from?.name || 'Company', m, y);
|
|
385
|
+
y -= 12;
|
|
386
|
+
pdf.text(invoiceData.from?.address || '', m, y);
|
|
387
|
+
|
|
388
|
+
// To
|
|
389
|
+
y -= 30;
|
|
390
|
+
pdf.setFont('Helvetica', 12);
|
|
391
|
+
pdf.text('Bill To:', m, y);
|
|
392
|
+
pdf.setFont('Helvetica', 10);
|
|
393
|
+
y -= 15;
|
|
394
|
+
pdf.text(invoiceData.to?.name || 'Client', m, y);
|
|
395
|
+
y -= 12;
|
|
396
|
+
pdf.text(invoiceData.to?.address || '', m, y);
|
|
397
|
+
|
|
398
|
+
// Items table
|
|
399
|
+
y -= 30;
|
|
400
|
+
if (invoiceData.items && invoiceData.items.length > 0) {
|
|
401
|
+
pdf.table(invoiceData.items, m, y, {
|
|
402
|
+
headers: ['description', 'quantity', 'price', 'total'],
|
|
403
|
+
cellWidth: (w - m * 2) / 4,
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Total
|
|
408
|
+
y -= (invoiceData.items?.length || 0) * 25 + 50;
|
|
409
|
+
pdf.setFont('Helvetica', 14);
|
|
410
|
+
const total = invoiceData.items?.reduce((sum, item) => sum + (item.total || 0), 0) || 0;
|
|
411
|
+
pdf.text(`Total: $${total.toFixed(2)}`, w - m - 150, y);
|
|
412
|
+
|
|
413
|
+
return pdf;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
module.exports = { PDF };
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VoltJS Queue
|
|
3
|
+
*
|
|
4
|
+
* In-process job queue with retries, delays, priorities, and concurrency control.
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* const { Queue } = require('voltjs');
|
|
8
|
+
*
|
|
9
|
+
* const queue = new Queue('emails', { concurrency: 3, retries: 3 });
|
|
10
|
+
*
|
|
11
|
+
* queue.process(async (job) => {
|
|
12
|
+
* await sendEmail(job.data);
|
|
13
|
+
* });
|
|
14
|
+
*
|
|
15
|
+
* queue.add({ to: 'user@example.com', subject: 'Hello' }, { priority: 'high' });
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
'use strict';
|
|
19
|
+
|
|
20
|
+
const crypto = require('crypto');
|
|
21
|
+
const { EventEmitter } = require('events');
|
|
22
|
+
|
|
23
|
+
class Queue extends EventEmitter {
|
|
24
|
+
constructor(name, options = {}) {
|
|
25
|
+
super();
|
|
26
|
+
this.name = name;
|
|
27
|
+
this.concurrency = options.concurrency || 1;
|
|
28
|
+
this.maxRetries = options.retries || 3;
|
|
29
|
+
this.retryDelay = options.retryDelay || 1000; // ms
|
|
30
|
+
this.timeout = options.timeout || 30000; // 30s
|
|
31
|
+
|
|
32
|
+
this.jobs = [];
|
|
33
|
+
this.processing = 0;
|
|
34
|
+
this.handler = null;
|
|
35
|
+
this.paused = false;
|
|
36
|
+
|
|
37
|
+
this.stats = { completed: 0, failed: 0, retried: 0, total: 0 };
|
|
38
|
+
this._delayed = [];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Add a job to the queue */
|
|
42
|
+
add(data, options = {}) {
|
|
43
|
+
const job = {
|
|
44
|
+
id: options.id || crypto.randomBytes(8).toString('hex'),
|
|
45
|
+
data,
|
|
46
|
+
priority: this._priorityValue(options.priority || 'normal'),
|
|
47
|
+
retries: 0,
|
|
48
|
+
maxRetries: options.retries || this.maxRetries,
|
|
49
|
+
delay: options.delay || 0,
|
|
50
|
+
createdAt: Date.now(),
|
|
51
|
+
status: 'pending',
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
this.stats.total++;
|
|
55
|
+
|
|
56
|
+
if (job.delay > 0) {
|
|
57
|
+
const timer = setTimeout(() => {
|
|
58
|
+
this._enqueue(job);
|
|
59
|
+
this._delayed = this._delayed.filter(d => d.id !== job.id);
|
|
60
|
+
}, job.delay);
|
|
61
|
+
if (timer.unref) timer.unref();
|
|
62
|
+
this._delayed.push({ id: job.id, timer });
|
|
63
|
+
} else {
|
|
64
|
+
this._enqueue(job);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
this.emit('added', job);
|
|
68
|
+
return job;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Add multiple jobs */
|
|
72
|
+
addBulk(items, options = {}) {
|
|
73
|
+
return items.map(data => this.add(data, options));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Register job processor */
|
|
77
|
+
process(handler) {
|
|
78
|
+
this.handler = handler;
|
|
79
|
+
this._tick();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Pause the queue */
|
|
83
|
+
pause() {
|
|
84
|
+
this.paused = true;
|
|
85
|
+
this.emit('paused');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Resume the queue */
|
|
89
|
+
resume() {
|
|
90
|
+
this.paused = false;
|
|
91
|
+
this.emit('resumed');
|
|
92
|
+
this._tick();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Get queue size */
|
|
96
|
+
size() {
|
|
97
|
+
return this.jobs.length;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Get queue status */
|
|
101
|
+
status() {
|
|
102
|
+
return {
|
|
103
|
+
name: this.name,
|
|
104
|
+
pending: this.jobs.length,
|
|
105
|
+
processing: this.processing,
|
|
106
|
+
delayed: this._delayed.length,
|
|
107
|
+
paused: this.paused,
|
|
108
|
+
...this.stats,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Clear all pending jobs */
|
|
113
|
+
clear() {
|
|
114
|
+
this.jobs = [];
|
|
115
|
+
this._delayed.forEach(d => clearTimeout(d.timer));
|
|
116
|
+
this._delayed = [];
|
|
117
|
+
this.emit('cleared');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Remove a specific job by ID */
|
|
121
|
+
remove(id) {
|
|
122
|
+
const idx = this.jobs.findIndex(j => j.id === id);
|
|
123
|
+
if (idx >= 0) {
|
|
124
|
+
this.jobs.splice(idx, 1);
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ===== INTERNAL =====
|
|
131
|
+
|
|
132
|
+
_enqueue(job) {
|
|
133
|
+
// Insert sorted by priority (higher priority = processed first)
|
|
134
|
+
let inserted = false;
|
|
135
|
+
for (let i = 0; i < this.jobs.length; i++) {
|
|
136
|
+
if (job.priority > this.jobs[i].priority) {
|
|
137
|
+
this.jobs.splice(i, 0, job);
|
|
138
|
+
inserted = true;
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (!inserted) this.jobs.push(job);
|
|
143
|
+
this._tick();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async _tick() {
|
|
147
|
+
if (this.paused || !this.handler) return;
|
|
148
|
+
if (this.processing >= this.concurrency) return;
|
|
149
|
+
if (this.jobs.length === 0) return;
|
|
150
|
+
|
|
151
|
+
const job = this.jobs.shift();
|
|
152
|
+
job.status = 'processing';
|
|
153
|
+
this.processing++;
|
|
154
|
+
|
|
155
|
+
try {
|
|
156
|
+
// Timeout wrapper
|
|
157
|
+
const result = await Promise.race([
|
|
158
|
+
this.handler(job),
|
|
159
|
+
new Promise((_, reject) =>
|
|
160
|
+
setTimeout(() => reject(new Error(`Job ${job.id} timed out`)), this.timeout)
|
|
161
|
+
),
|
|
162
|
+
]);
|
|
163
|
+
|
|
164
|
+
job.status = 'completed';
|
|
165
|
+
job.result = result;
|
|
166
|
+
this.stats.completed++;
|
|
167
|
+
this.emit('completed', job);
|
|
168
|
+
|
|
169
|
+
} catch (error) {
|
|
170
|
+
job.retries++;
|
|
171
|
+
|
|
172
|
+
if (job.retries <= job.maxRetries) {
|
|
173
|
+
job.status = 'retrying';
|
|
174
|
+
job.lastError = error.message;
|
|
175
|
+
this.stats.retried++;
|
|
176
|
+
this.emit('retrying', job, error);
|
|
177
|
+
|
|
178
|
+
// Exponential backoff
|
|
179
|
+
const delay = this.retryDelay * Math.pow(2, job.retries - 1);
|
|
180
|
+
setTimeout(() => this._enqueue(job), delay);
|
|
181
|
+
} else {
|
|
182
|
+
job.status = 'failed';
|
|
183
|
+
job.error = error.message;
|
|
184
|
+
this.stats.failed++;
|
|
185
|
+
this.emit('failed', job, error);
|
|
186
|
+
}
|
|
187
|
+
} finally {
|
|
188
|
+
this.processing--;
|
|
189
|
+
this._tick(); // Process next
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
_priorityValue(priority) {
|
|
194
|
+
const map = { low: 1, normal: 5, high: 10, critical: 20 };
|
|
195
|
+
return typeof priority === 'number' ? priority : (map[priority] || 5);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
module.exports = { Queue };
|