triva 0.0.2 → 0.3.1

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/lib/index.js ADDED
@@ -0,0 +1,655 @@
1
+ /*!
2
+ * Triva
3
+ * Copyright (c) 2026 Kris Powers
4
+ * License MIT
5
+ */
6
+
7
+ 'use strict';
8
+
9
+ import http from 'http';
10
+ import { parse as parseUrl } from 'url';
11
+ import { parse as parseQuery } from 'querystring';
12
+ import { createReadStream, stat } from 'fs';
13
+ import { basename, extname } from 'path';
14
+ import { log } from './log.js';
15
+ import { cache, configCache } from './cache.js';
16
+ import { middleware as createMiddleware } from './middleware.js';
17
+ import { errorTracker } from './error-tracker.js';
18
+ import { cookieParser } from './cookie-parser.js';
19
+
20
+ /* ---------------- Request Context ---------------- */
21
+ class RequestContext {
22
+ constructor(req, res, routeParams = {}) {
23
+ this.req = req;
24
+ this.res = res;
25
+ this.params = routeParams;
26
+ this.query = {};
27
+ this.body = null;
28
+ this.triva = req.triva || {};
29
+
30
+ // Parse query string
31
+ const parsedUrl = parseUrl(req.url, true);
32
+ this.query = parsedUrl.query || {};
33
+ this.pathname = parsedUrl.pathname;
34
+ }
35
+
36
+ async json() {
37
+ if (this.body !== null) return this.body;
38
+
39
+ return new Promise((resolve, reject) => {
40
+ let data = '';
41
+ this.req.on('data', chunk => {
42
+ data += chunk.toString();
43
+ });
44
+ this.req.on('end', () => {
45
+ try {
46
+ this.body = JSON.parse(data);
47
+ resolve(this.body);
48
+ } catch (err) {
49
+ reject(new Error('Invalid JSON'));
50
+ }
51
+ });
52
+ this.req.on('error', reject);
53
+ });
54
+ }
55
+
56
+ async text() {
57
+ if (this.body !== null) return this.body;
58
+
59
+ return new Promise((resolve, reject) => {
60
+ let data = '';
61
+ this.req.on('data', chunk => {
62
+ data += chunk.toString();
63
+ });
64
+ this.req.on('end', () => {
65
+ this.body = data;
66
+ resolve(data);
67
+ });
68
+ this.req.on('error', reject);
69
+ });
70
+ }
71
+ }
72
+
73
+ /* ---------------- Response Helpers ---------------- */
74
+ class ResponseHelpers {
75
+ constructor(res) {
76
+ this.res = res;
77
+ }
78
+
79
+ status(code) {
80
+ this.res.statusCode = code;
81
+ return this;
82
+ }
83
+
84
+ header(name, value) {
85
+ this.res.setHeader(name, value);
86
+ return this;
87
+ }
88
+
89
+ json(data) {
90
+ if (!this.res.writableEnded) {
91
+ this.res.setHeader('Content-Type', 'application/json');
92
+ this.res.end(JSON.stringify(data));
93
+ }
94
+ return this;
95
+ }
96
+
97
+ send(data) {
98
+ if (this.res.writableEnded) {
99
+ return this;
100
+ }
101
+
102
+ // If object, send as JSON
103
+ if (typeof data === 'object') {
104
+ return this.json(data);
105
+ }
106
+
107
+ const stringData = String(data);
108
+
109
+ // Auto-detect HTML content
110
+ if (stringData.trim().startsWith('<') &&
111
+ (stringData.includes('</') || stringData.includes('/>'))) {
112
+ this.res.setHeader('Content-Type', 'text/html');
113
+ } else {
114
+ this.res.setHeader('Content-Type', 'text/plain');
115
+ }
116
+
117
+ this.res.end(stringData);
118
+ return this;
119
+ }
120
+
121
+ html(data) {
122
+ if (!this.res.writableEnded) {
123
+ this.res.setHeader('Content-Type', 'text/html');
124
+ this.res.end(data);
125
+ }
126
+ return this;
127
+ }
128
+
129
+ redirect(url, code = 302) {
130
+ this.res.statusCode = code;
131
+ this.res.setHeader('Location', url);
132
+ this.res.end();
133
+ return this;
134
+ }
135
+
136
+ jsonp(data, callbackParam = 'callback') {
137
+ if (this.res.writableEnded) {
138
+ return this;
139
+ }
140
+
141
+ // Get callback name from query parameter
142
+ const parsedUrl = parseUrl(this.res.req?.url || '', true);
143
+ const callback = parsedUrl.query[callbackParam] || 'callback';
144
+
145
+ // Sanitize callback name (only allow safe characters)
146
+ const safeCallback = callback.replace(/[^\[\]\w$.]/g, '');
147
+
148
+ // Create JSONP response
149
+ const jsonString = JSON.stringify(data);
150
+ const body = `/**/ typeof ${safeCallback} === 'function' && ${safeCallback}(${jsonString});`;
151
+
152
+ this.res.setHeader('Content-Type', 'text/javascript; charset=utf-8');
153
+ this.res.setHeader('X-Content-Type-Options', 'nosniff');
154
+ this.res.end(body);
155
+ return this;
156
+ }
157
+
158
+ download(filepath, filename = null) {
159
+ if (this.res.writableEnded) {
160
+ return this;
161
+ }
162
+
163
+ const downloadName = filename || basename(filepath);
164
+
165
+ stat(filepath, (err, stats) => {
166
+ if (err) {
167
+ this.res.statusCode = 404;
168
+ this.res.end('File not found');
169
+ return;
170
+ }
171
+
172
+ // Set headers for download
173
+ this.res.setHeader('Content-Type', 'application/octet-stream');
174
+ this.res.setHeader('Content-Disposition', `attachment; filename="${downloadName}"`);
175
+ this.res.setHeader('Content-Length', stats.size);
176
+
177
+ // Stream file to response
178
+ const fileStream = createReadStream(filepath);
179
+ fileStream.pipe(this.res);
180
+
181
+ fileStream.on('error', (streamErr) => {
182
+ if (!this.res.writableEnded) {
183
+ this.res.statusCode = 500;
184
+ this.res.end('Error reading file');
185
+ }
186
+ });
187
+ });
188
+
189
+ return this;
190
+ }
191
+
192
+ sendFile(filepath, options = {}) {
193
+ if (this.res.writableEnded) {
194
+ return this;
195
+ }
196
+
197
+ stat(filepath, (err, stats) => {
198
+ if (err) {
199
+ this.res.statusCode = 404;
200
+ this.res.end('File not found');
201
+ return;
202
+ }
203
+
204
+ // Determine content type from extension
205
+ const ext = extname(filepath).toLowerCase();
206
+ const contentTypes = {
207
+ '.html': 'text/html',
208
+ '.css': 'text/css',
209
+ '.js': 'text/javascript',
210
+ '.json': 'application/json',
211
+ '.png': 'image/png',
212
+ '.jpg': 'image/jpeg',
213
+ '.jpeg': 'image/jpeg',
214
+ '.gif': 'image/gif',
215
+ '.svg': 'image/svg+xml',
216
+ '.pdf': 'application/pdf',
217
+ '.txt': 'text/plain',
218
+ '.xml': 'application/xml'
219
+ };
220
+
221
+ const contentType = options.contentType || contentTypes[ext] || 'application/octet-stream';
222
+
223
+ // Set headers
224
+ this.res.setHeader('Content-Type', contentType);
225
+ this.res.setHeader('Content-Length', stats.size);
226
+
227
+ if (options.headers) {
228
+ Object.keys(options.headers).forEach(key => {
229
+ this.res.setHeader(key, options.headers[key]);
230
+ });
231
+ }
232
+
233
+ // Stream file to response
234
+ const fileStream = createReadStream(filepath);
235
+ fileStream.pipe(this.res);
236
+
237
+ fileStream.on('error', (streamErr) => {
238
+ if (!this.res.writableEnded) {
239
+ this.res.statusCode = 500;
240
+ this.res.end('Error reading file');
241
+ }
242
+ });
243
+ });
244
+
245
+ return this;
246
+ }
247
+
248
+ end(data) {
249
+ if (!this.res.writableEnded) {
250
+ this.res.end(data);
251
+ }
252
+ return this;
253
+ }
254
+ }
255
+
256
+ /* ---------------- Route Matcher ---------------- */
257
+ class RouteMatcher {
258
+ constructor() {
259
+ this.routes = {
260
+ GET: [],
261
+ POST: [],
262
+ PUT: [],
263
+ DELETE: [],
264
+ PATCH: [],
265
+ HEAD: [],
266
+ OPTIONS: []
267
+ };
268
+ }
269
+
270
+ _parsePattern(pattern) {
271
+ const paramNames = [];
272
+ const regexPattern = pattern
273
+ .split('/')
274
+ .map(segment => {
275
+ if (segment.startsWith(':')) {
276
+ paramNames.push(segment.slice(1));
277
+ return '([^/]+)';
278
+ }
279
+ if (segment === '*') {
280
+ return '.*';
281
+ }
282
+ return segment;
283
+ })
284
+ .join('/');
285
+
286
+ return {
287
+ regex: new RegExp(`^${regexPattern}$`),
288
+ paramNames
289
+ };
290
+ }
291
+
292
+ addRoute(method, pattern, handler) {
293
+ const parsed = this._parsePattern(pattern);
294
+ this.routes[method.toUpperCase()].push({
295
+ pattern,
296
+ ...parsed,
297
+ handler
298
+ });
299
+ }
300
+
301
+ match(method, pathname) {
302
+ const routes = this.routes[method.toUpperCase()] || [];
303
+
304
+ for (const route of routes) {
305
+ const match = pathname.match(route.regex);
306
+ if (match) {
307
+ const params = {};
308
+ route.paramNames.forEach((name, i) => {
309
+ params[name] = match[i + 1];
310
+ });
311
+ return { handler: route.handler, params };
312
+ }
313
+ }
314
+
315
+ return null;
316
+ }
317
+ }
318
+
319
+ /* ---------------- Server Core ---------------- */
320
+ class TrivaServer {
321
+ constructor(options = {}) {
322
+ this.options = {
323
+ env: options.env || 'production',
324
+ ...options
325
+ };
326
+
327
+ this.server = null;
328
+ this.router = new RouteMatcher();
329
+ this.middlewareStack = [];
330
+ this.errorHandler = this._defaultErrorHandler.bind(this);
331
+ this.notFoundHandler = this._defaultNotFoundHandler.bind(this);
332
+
333
+ // Bind routing methods
334
+ this.get = this.get.bind(this);
335
+ this.post = this.post.bind(this);
336
+ this.put = this.put.bind(this);
337
+ this.delete = this.delete.bind(this);
338
+ this.patch = this.patch.bind(this);
339
+ this.use = this.use.bind(this);
340
+ this.listen = this.listen.bind(this);
341
+ }
342
+
343
+ use(middleware) {
344
+ if (typeof middleware !== 'function') {
345
+ throw new Error('Middleware must be a function');
346
+ }
347
+ this.middlewareStack.push(middleware);
348
+ return this;
349
+ }
350
+
351
+ get(pattern, handler) {
352
+ this.router.addRoute('GET', pattern, handler);
353
+ return this;
354
+ }
355
+
356
+ post(pattern, handler) {
357
+ this.router.addRoute('POST', pattern, handler);
358
+ return this;
359
+ }
360
+
361
+ put(pattern, handler) {
362
+ this.router.addRoute('PUT', pattern, handler);
363
+ return this;
364
+ }
365
+
366
+ delete(pattern, handler) {
367
+ this.router.addRoute('DELETE', pattern, handler);
368
+ return this;
369
+ }
370
+
371
+ patch(pattern, handler) {
372
+ this.router.addRoute('PATCH', pattern, handler);
373
+ return this;
374
+ }
375
+
376
+ setErrorHandler(handler) {
377
+ this.errorHandler = handler;
378
+ return this;
379
+ }
380
+
381
+ setNotFoundHandler(handler) {
382
+ this.notFoundHandler = handler;
383
+ return this;
384
+ }
385
+
386
+ async _runMiddleware(req, res) {
387
+ for (const middleware of this.middlewareStack) {
388
+ try {
389
+ await new Promise((resolve, reject) => {
390
+ try {
391
+ middleware(req, res, (err) => {
392
+ if (err) reject(err);
393
+ else resolve();
394
+ });
395
+ } catch (err) {
396
+ reject(err);
397
+ }
398
+ });
399
+ } catch (err) {
400
+ // Capture middleware errors with context
401
+ await errorTracker.capture(err, {
402
+ req,
403
+ phase: 'middleware',
404
+ handler: middleware.name || 'anonymous',
405
+ pathname: parseUrl(req.url).pathname,
406
+ uaData: req.triva?.throttle?.uaData
407
+ });
408
+ throw err; // Re-throw to be handled by main error handler
409
+ }
410
+ }
411
+ }
412
+
413
+ async _handleRequest(req, res) {
414
+ try {
415
+ // Enhance request and response objects
416
+ req.triva = req.triva || {};
417
+
418
+ // Store req reference in res for methods that need it (like jsonp)
419
+ res.req = req;
420
+
421
+ // Add helper methods directly to res object
422
+ const helpers = new ResponseHelpers(res);
423
+ res.status = helpers.status.bind(helpers);
424
+ res.header = helpers.header.bind(helpers);
425
+ res.json = helpers.json.bind(helpers);
426
+ res.send = helpers.send.bind(helpers);
427
+ res.html = helpers.html.bind(helpers);
428
+ res.redirect = helpers.redirect.bind(helpers);
429
+ res.jsonp = helpers.jsonp.bind(helpers);
430
+ res.download = helpers.download.bind(helpers);
431
+ res.sendFile = helpers.sendFile.bind(helpers);
432
+
433
+ // Run middleware stack
434
+ await this._runMiddleware(req, res);
435
+
436
+ // Check if response was already sent by middleware
437
+ if (res.writableEnded) return;
438
+
439
+ const parsedUrl = parseUrl(req.url, true);
440
+ const pathname = parsedUrl.pathname;
441
+
442
+ // Route matching
443
+ const match = this.router.match(req.method, pathname);
444
+
445
+ if (!match) {
446
+ return this.notFoundHandler(req, res);
447
+ }
448
+
449
+ // Create context
450
+ const context = new RequestContext(req, res, match.params);
451
+
452
+ // Execute route handler with error tracking
453
+ try {
454
+ await match.handler(context, res);
455
+ } catch (handlerError) {
456
+ // Capture route handler errors
457
+ await errorTracker.capture(handlerError, {
458
+ req,
459
+ phase: 'route',
460
+ route: pathname,
461
+ handler: 'route_handler',
462
+ pathname,
463
+ uaData: req.triva?.throttle?.uaData
464
+ });
465
+ throw handlerError;
466
+ }
467
+
468
+ } catch (err) {
469
+ // Capture top-level request errors if not already captured
470
+ if (!err._trivaTracked) {
471
+ await errorTracker.capture(err, {
472
+ req,
473
+ phase: 'request',
474
+ pathname: parseUrl(req.url).pathname,
475
+ uaData: req.triva?.throttle?.uaData
476
+ });
477
+ err._trivaTracked = true; // Mark to avoid double-tracking
478
+ }
479
+
480
+ this.errorHandler(err, req, res);
481
+ }
482
+ }
483
+
484
+ _defaultErrorHandler(err, req, res) {
485
+ console.error('Server Error:', err);
486
+
487
+ if (!res.writableEnded) {
488
+ res.statusCode = 500;
489
+ res.setHeader('Content-Type', 'application/json');
490
+
491
+ const response = {
492
+ error: 'Internal Server Error',
493
+ message: this.options.env === 'development' ? err.message : undefined,
494
+ stack: this.options.env === 'development' ? err.stack : undefined
495
+ };
496
+
497
+ res.end(JSON.stringify(response));
498
+ }
499
+ }
500
+
501
+ _defaultNotFoundHandler(req, res) {
502
+ if (!res.writableEnded) {
503
+ res.statusCode = 404;
504
+ res.setHeader('Content-Type', 'application/json');
505
+ res.end(JSON.stringify({
506
+ error: 'Not Found',
507
+ path: req.url
508
+ }));
509
+ }
510
+ }
511
+
512
+ listen(port, callback) {
513
+ this.server = http.createServer((req, res) => {
514
+ this._handleRequest(req, res);
515
+ });
516
+
517
+ this.server.listen(port, () => {
518
+ console.log(`Triva server listening on port ${port} (${this.options.env})`);
519
+ if (callback) callback();
520
+ });
521
+
522
+ return this.server;
523
+ }
524
+
525
+ close(callback) {
526
+ if (this.server) {
527
+ this.server.close(callback);
528
+ }
529
+ return this;
530
+ }
531
+ }
532
+
533
+ /* ---------------- Factory & Exports ---------------- */
534
+ let globalServer = null;
535
+
536
+ async function build(options = {}) {
537
+ globalServer = new TrivaServer(options);
538
+
539
+ // Centralized configuration
540
+ if (options.cache) {
541
+ await configCache(options.cache);
542
+ }
543
+
544
+ if (options.middleware || options.throttle || options.retention) {
545
+ const middlewareOptions = {
546
+ ...options.middleware,
547
+ throttle: options.throttle || options.middleware?.throttle,
548
+ retention: options.retention || options.middleware?.retention
549
+ };
550
+
551
+ if (middlewareOptions.throttle || middlewareOptions.retention) {
552
+ const mw = createMiddleware(middlewareOptions);
553
+ globalServer.use(mw);
554
+ }
555
+ }
556
+
557
+ if (options.errorTracking) {
558
+ errorTracker.configure(options.errorTracking);
559
+ }
560
+
561
+ return globalServer;
562
+ }
563
+
564
+ function middleware(options = {}) {
565
+ if (!globalServer) {
566
+ throw new Error('Server not initialized. Call build() first.');
567
+ }
568
+
569
+ const mw = createMiddleware(options);
570
+ globalServer.use(mw);
571
+ return globalServer;
572
+ }
573
+
574
+ function get(pattern, handler) {
575
+ if (!globalServer) {
576
+ throw new Error('Server not initialized. Call build() first.');
577
+ }
578
+ return globalServer.get(pattern, handler);
579
+ }
580
+
581
+ function post(pattern, handler) {
582
+ if (!globalServer) {
583
+ throw new Error('Server not initialized. Call build() first.');
584
+ }
585
+ return globalServer.post(pattern, handler);
586
+ }
587
+
588
+ function put(pattern, handler) {
589
+ if (!globalServer) {
590
+ throw new Error('Server not initialized. Call build() first.');
591
+ }
592
+ return globalServer.put(pattern, handler);
593
+ }
594
+
595
+ function del(pattern, handler) {
596
+ if (!globalServer) {
597
+ throw new Error('Server not initialized. Call build() first.');
598
+ }
599
+ return globalServer.delete(pattern, handler);
600
+ }
601
+
602
+ function patch(pattern, handler) {
603
+ if (!globalServer) {
604
+ throw new Error('Server not initialized. Call build() first.');
605
+ }
606
+ return globalServer.patch(pattern, handler);
607
+ }
608
+
609
+ function use(middleware) {
610
+ if (!globalServer) {
611
+ throw new Error('Server not initialized. Call build() first.');
612
+ }
613
+ return globalServer.use(middleware);
614
+ }
615
+
616
+ function listen(port, callback) {
617
+ if (!globalServer) {
618
+ throw new Error('Server not initialized. Call build() first.');
619
+ }
620
+ return globalServer.listen(port, callback);
621
+ }
622
+
623
+ function setErrorHandler(handler) {
624
+ if (!globalServer) {
625
+ throw new Error('Server not initialized. Call build() first.');
626
+ }
627
+ return globalServer.setErrorHandler(handler);
628
+ }
629
+
630
+ function setNotFoundHandler(handler) {
631
+ if (!globalServer) {
632
+ throw new Error('Server not initialized. Call build() first.');
633
+ }
634
+ return globalServer.setNotFoundHandler(handler);
635
+ }
636
+
637
+ export {
638
+ build,
639
+ middleware,
640
+ get,
641
+ post,
642
+ put,
643
+ del as delete,
644
+ patch,
645
+ use,
646
+ listen,
647
+ setErrorHandler,
648
+ setNotFoundHandler,
649
+ TrivaServer,
650
+ log,
651
+ cache,
652
+ configCache,
653
+ errorTracker,
654
+ cookieParser
655
+ };