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/log.js ADDED
@@ -0,0 +1,261 @@
1
+ /*!
2
+ * Triva - Logging System
3
+ * Copyright (c) 2026 Kris Powers
4
+ * License MIT
5
+ */
6
+
7
+ 'use strict';
8
+
9
+ import { parse as parseUrl } from 'url';
10
+ import { parseUA } from './ua-parser.js';
11
+ import { parseCookies } from './cookie-parser.js';
12
+ import { writeFile } from 'fs/promises';
13
+ import { join } from 'path';
14
+
15
+ /* ---------------- Log Entry Structure ---------------- */
16
+ class LogEntry {
17
+ constructor(req) {
18
+ const parsedUrl = parseUrl(req.url, true);
19
+
20
+ this.timestamp = Date.now();
21
+ this.datetime = new Date().toISOString();
22
+ this.method = req.method;
23
+ this.url = req.url;
24
+ this.pathname = parsedUrl.pathname;
25
+ this.query = parsedUrl.query || {};
26
+ this.headers = { ...req.headers };
27
+ this.ip = req.socket?.remoteAddress || req.connection?.remoteAddress || 'unknown';
28
+ this.userAgent = req.headers['user-agent'] || 'unknown';
29
+
30
+ // Parse and include cookies
31
+ this.cookies = req.cookies || parseCookies(req.headers.cookie);
32
+
33
+ this.statusCode = null;
34
+ this.responseTime = null;
35
+ this.throttle = req.triva?.throttle || null;
36
+
37
+ // Include parsed UA data from throttle if available, otherwise null
38
+ // Will be populated by LogStorage.push() if not present
39
+ this.uaData = req.triva?.throttle?.uaData || null;
40
+
41
+ this.metadata = {};
42
+ }
43
+
44
+ setResponse(statusCode, responseTime) {
45
+ this.statusCode = statusCode;
46
+ this.responseTime = responseTime;
47
+ return this;
48
+ }
49
+
50
+ addMetadata(key, value) {
51
+ this.metadata[key] = value;
52
+ return this;
53
+ }
54
+ }
55
+
56
+ /* ---------------- Log Storage ---------------- */
57
+ class LogStorage {
58
+ constructor() {
59
+ this.entries = [];
60
+ this.retention = {
61
+ enabled: true,
62
+ maxEntries: 100000
63
+ };
64
+ this.stats = {
65
+ total: 0,
66
+ evicted: 0,
67
+ methods: {},
68
+ statusCodes: {}
69
+ };
70
+ }
71
+
72
+ _setRetention(config) {
73
+ this.retention = {
74
+ enabled: Boolean(config.enabled),
75
+ maxEntries: Number(config.maxEntries) || 100000
76
+ };
77
+ }
78
+
79
+ _enforceRetention() {
80
+ if (!this.retention.enabled) return;
81
+
82
+ if (this.entries.length > this.retention.maxEntries) {
83
+ const toRemove = this.entries.length - this.retention.maxEntries;
84
+ this.entries.splice(0, toRemove);
85
+ this.stats.evicted += toRemove;
86
+ }
87
+ }
88
+
89
+ async push(req) {
90
+ const entry = new LogEntry(req);
91
+
92
+ // Parse UA data if not already available from throttle
93
+ if (!entry.uaData && entry.userAgent && entry.userAgent !== 'unknown') {
94
+ entry.uaData = await parseUA(entry.userAgent);
95
+ }
96
+
97
+ // Update stats
98
+ this.stats.total++;
99
+ this.stats.methods[entry.method] = (this.stats.methods[entry.method] || 0) + 1;
100
+
101
+ this.entries.push(entry);
102
+ this._enforceRetention();
103
+
104
+ return entry;
105
+ }
106
+
107
+ async get(filter = {}) {
108
+ if (filter === 'all' || filter.all) {
109
+ return this.entries;
110
+ }
111
+
112
+ let results = [...this.entries];
113
+
114
+ // Filter by method
115
+ if (filter.method) {
116
+ const methods = Array.isArray(filter.method) ? filter.method : [filter.method];
117
+ results = results.filter(e => methods.includes(e.method));
118
+ }
119
+
120
+ // Filter by status code
121
+ if (filter.status) {
122
+ const statuses = Array.isArray(filter.status) ? filter.status : [filter.status];
123
+ results = results.filter(e => e.statusCode && statuses.includes(e.statusCode));
124
+ }
125
+
126
+ // Filter by IP
127
+ if (filter.ip) {
128
+ results = results.filter(e => e.ip === filter.ip);
129
+ }
130
+
131
+ // Filter by pathname pattern
132
+ if (filter.pathname) {
133
+ const pattern = new RegExp(filter.pathname);
134
+ results = results.filter(e => pattern.test(e.pathname));
135
+ }
136
+
137
+ // Filter by date range
138
+ if (filter.from) {
139
+ const fromTime = typeof filter.from === 'number' ? filter.from : new Date(filter.from).getTime();
140
+ results = results.filter(e => e.timestamp >= fromTime);
141
+ }
142
+
143
+ if (filter.to) {
144
+ const toTime = typeof filter.to === 'number' ? filter.to : new Date(filter.to).getTime();
145
+ results = results.filter(e => e.timestamp <= toTime);
146
+ }
147
+
148
+ // Filter by throttle status
149
+ if (filter.throttled !== undefined) {
150
+ results = results.filter(e => {
151
+ if (!e.throttle) return false;
152
+ return filter.throttled ? e.throttle.restricted : !e.throttle.restricted;
153
+ });
154
+ }
155
+
156
+ // Limit results
157
+ if (filter.limit) {
158
+ results = results.slice(-filter.limit);
159
+ }
160
+
161
+ return results;
162
+ }
163
+
164
+ async getStats() {
165
+ const recent = this.entries.slice(-1000);
166
+
167
+ const statusCodeDist = {};
168
+ const methodDist = {};
169
+ const throttledCount = recent.filter(e => e.throttle?.restricted).length;
170
+
171
+ recent.forEach(entry => {
172
+ if (entry.statusCode) {
173
+ statusCodeDist[entry.statusCode] = (statusCodeDist[entry.statusCode] || 0) + 1;
174
+ }
175
+ methodDist[entry.method] = (methodDist[entry.method] || 0) + 1;
176
+ });
177
+
178
+ const responseTimes = recent
179
+ .filter(e => e.responseTime !== null)
180
+ .map(e => e.responseTime);
181
+
182
+ const avgResponseTime = responseTimes.length > 0
183
+ ? responseTimes.reduce((a, b) => a + b, 0) / responseTimes.length
184
+ : 0;
185
+
186
+ return {
187
+ total: this.stats.total,
188
+ stored: this.entries.length,
189
+ evicted: this.stats.evicted,
190
+ retention: this.retention,
191
+ recent: {
192
+ count: recent.length,
193
+ throttled: throttledCount,
194
+ throttleRate: recent.length > 0 ? (throttledCount / recent.length) * 100 : 0,
195
+ avgResponseTime: Math.round(avgResponseTime),
196
+ statusCodes: statusCodeDist,
197
+ methods: methodDist
198
+ }
199
+ };
200
+ }
201
+
202
+ async clear() {
203
+ const count = this.entries.length;
204
+ this.entries = [];
205
+ return { cleared: count };
206
+ }
207
+
208
+ async search(query) {
209
+ const lowerQuery = query.toLowerCase();
210
+
211
+ return this.entries.filter(entry => {
212
+ return (
213
+ entry.pathname.toLowerCase().includes(lowerQuery) ||
214
+ entry.url.toLowerCase().includes(lowerQuery) ||
215
+ entry.userAgent.toLowerCase().includes(lowerQuery) ||
216
+ entry.ip.includes(query) ||
217
+ entry.method.toLowerCase().includes(lowerQuery)
218
+ );
219
+ });
220
+ }
221
+
222
+ async export(filter = 'all', filename = null) {
223
+ // Get logs based on filter
224
+ const logsToExport = filter === 'all' || !filter ? this.entries : await this.get(filter);
225
+
226
+ // Generate filename if not provided
227
+ if (!filename) {
228
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
229
+ filename = `triva-logs-${timestamp}.json`;
230
+ }
231
+
232
+ // Ensure .json extension
233
+ if (!filename.endsWith('.json')) {
234
+ filename += '.json';
235
+ }
236
+
237
+ // Prepare export data
238
+ const exportData = {
239
+ exportedAt: new Date().toISOString(),
240
+ totalLogs: logsToExport.length,
241
+ filter: typeof filter === 'object' ? filter : { type: filter },
242
+ logs: logsToExport
243
+ };
244
+
245
+ // Write to file in current working directory
246
+ const filepath = join(process.cwd(), filename);
247
+ await writeFile(filepath, JSON.stringify(exportData, null, 2), 'utf-8');
248
+
249
+ return {
250
+ success: true,
251
+ filename,
252
+ filepath,
253
+ count: logsToExport.length
254
+ };
255
+ }
256
+ }
257
+
258
+ /* ---------------- Export Singleton ---------------- */
259
+ const log = new LogStorage();
260
+
261
+ export { log, LogEntry };
@@ -0,0 +1,237 @@
1
+ /*!
2
+ * Triva
3
+ * Copyright (c) 2026 Kris Powers
4
+ * License MIT
5
+ */
6
+
7
+ 'use strict';
8
+
9
+ import { log } from "./log.js";
10
+ import { cache } from './cache.js'
11
+ import { parseUA } from './ua-parser.js';
12
+ import crypto from "crypto"; // Needed for throttle internally
13
+
14
+ /* ---------------- Throttle Class ---------------- */
15
+ class Throttle {
16
+ constructor(options = {}) {
17
+ if (!options.limit || !options.window_ms) {
18
+ throw new Error("limit and window_ms are required");
19
+ }
20
+
21
+ this.baseConfig = {
22
+ limit: options.limit,
23
+ windowMs: options.window_ms,
24
+ burstLimit: options.burst_limit || 20,
25
+ burstWindowMs: options.burst_window_ms || 1000,
26
+ banThreshold: options.ban_threshold || 5,
27
+ banMs: options.ban_ms || 24 * 60 * 60 * 1000,
28
+ violationDecayMs: options.violation_decay_ms || 60 * 60 * 1000,
29
+ uaRotationThreshold: options.ua_rotation_threshold || 5,
30
+ };
31
+
32
+ this.policies =
33
+ typeof options.policies === "function" ? options.policies : null;
34
+
35
+ this.namespace = options.namespace || "throttle";
36
+
37
+ // Normalize retention config
38
+ const retention = {
39
+ enabled: Boolean(options.retention?.enabled),
40
+ maxEntries: Number(options.retention?.maxEntries) || 0
41
+ };
42
+
43
+ // Inject retention policy into logger
44
+ log._setRetention(retention);
45
+ }
46
+
47
+ _now() {
48
+ return Date.now();
49
+ }
50
+
51
+ _hashUA(ua) {
52
+ return crypto.createHash("sha256").update(ua).digest("hex");
53
+ }
54
+
55
+ _key(ip, uaHash) {
56
+ return `${this.namespace}:${ip}:${uaHash}`;
57
+ }
58
+
59
+ _ipKey(ip) {
60
+ return `${this.namespace}:ip:${ip}`;
61
+ }
62
+
63
+ _banKey(ip) {
64
+ return `${this.namespace}:ban:${ip}`;
65
+ }
66
+
67
+ _weightFromUA(ua) {
68
+ const l = ua.toLowerCase();
69
+ if (
70
+ l.includes("bot") ||
71
+ l.includes("crawler") ||
72
+ l.includes("spider") ||
73
+ l.includes("scrapy") ||
74
+ l.includes("curl") ||
75
+ l.includes("wget")
76
+ )
77
+ return 5;
78
+ if (
79
+ l.includes("openai") ||
80
+ l.includes("gpt") ||
81
+ l.includes("anthropic") ||
82
+ l.includes("claude") ||
83
+ l.includes("ai")
84
+ )
85
+ return 10;
86
+ return 1;
87
+ }
88
+
89
+ _resolveConfig(context, ip, ua) {
90
+ if (!this.policies) return this.baseConfig;
91
+ const override = this.policies({ ip, ua, context }) || {};
92
+ return { ...this.baseConfig, ...override };
93
+ }
94
+
95
+ async check(ip, ua, context = {}) {
96
+ if (!ip || !ua) return { restricted: true, reason: "invalid_identity", uaData: null };
97
+
98
+ const now = this._now();
99
+ const uaHash = this._hashUA(ua);
100
+ const config = this._resolveConfig(context, ip, ua);
101
+
102
+ // Parse user agent for detailed information
103
+ const uaData = await parseUA(ua);
104
+
105
+ const key = this._key(ip, uaHash);
106
+ const ipKey = this._ipKey(ip);
107
+ const banKey = this._banKey(ip);
108
+
109
+ const ban = await cache.get(banKey);
110
+ if (ban && ban.banned_until > now) {
111
+ return { restricted: true, reason: "auto_ban", uaData };
112
+ }
113
+
114
+ let ipRecord = (await cache.get(ipKey)) || { uas: [] };
115
+
116
+ if (!ipRecord.uas.includes(uaHash)) {
117
+ ipRecord.uas.push(uaHash);
118
+ if (ipRecord.uas.length > config.uaRotationThreshold) {
119
+ return { restricted: true, reason: "ua_rotation", uaData };
120
+ }
121
+ await cache.set(ipKey, ipRecord);
122
+ }
123
+
124
+ let record = (await cache.get(key)) || {
125
+ hits: [],
126
+ burst: [],
127
+ violations: 0,
128
+ last_violation: 0,
129
+ };
130
+
131
+ if (record.violations > 0 && now - record.last_violation > config.violationDecayMs) {
132
+ record.violations--;
133
+ record.last_violation = now;
134
+ }
135
+
136
+ // Use UA parsed data to determine weight
137
+ let baseWeight = 1;
138
+ if (uaData.bot.isBot) {
139
+ baseWeight = uaData.bot.isAI ? 10 : 5;
140
+ }
141
+
142
+ const weight =
143
+ typeof config.weight_multiplier === "number"
144
+ ? Math.ceil(baseWeight * config.weight_multiplier)
145
+ : baseWeight;
146
+
147
+ record.hits = record.hits.filter((ts) => ts >= now - config.windowMs);
148
+ record.burst = record.burst.filter((ts) => ts >= now - config.burstWindowMs);
149
+
150
+ if (record.burst.length + weight > config.burstLimit) {
151
+ await this._violation(record, key, banKey, config);
152
+ return { restricted: true, reason: "burst_limit", uaData };
153
+ }
154
+
155
+ if (record.hits.length + weight > config.limit) {
156
+ await this._violation(record, key, banKey, config);
157
+ return { restricted: true, reason: "sliding_window", uaData };
158
+ }
159
+
160
+ for (let i = 0; i < weight; i++) {
161
+ record.hits.push(now);
162
+ record.burst.push(now);
163
+ }
164
+
165
+ await cache.set(key, record);
166
+ return { restricted: false, reason: "ok", uaData };
167
+ }
168
+
169
+ async _violation(record, key, banKey, config) {
170
+ record.violations++;
171
+ record.last_violation = this._now();
172
+ if (record.violations >= config.banThreshold) {
173
+ await cache.set(banKey, { banned_until: this._now() + config.banMs });
174
+ }
175
+ await cache.set(key, record);
176
+ }
177
+ }
178
+
179
+ /* ---------------- Middleware Core ---------------- */
180
+ class MiddlewareCore {
181
+ constructor(options = {}) {
182
+ this.options = options;
183
+
184
+ if (options.throttle) this.throttle = new Throttle(options.throttle);
185
+
186
+ const retention = {
187
+ enabled: options.retention?.enabled !== false,
188
+ maxEntries: Number(options.retention?.maxEntries) || 100_000,
189
+ };
190
+
191
+ log._setRetention(retention);
192
+ }
193
+
194
+ async handle(req, res, next) {
195
+ try {
196
+ if (this.throttle) {
197
+ const ip = req.socket?.remoteAddress || req.connection?.remoteAddress;
198
+ const ua = req.headers["user-agent"];
199
+ const result = await this.throttle.check(ip, ua);
200
+
201
+ req.triva = req.triva || {};
202
+ req.triva.throttle = result;
203
+
204
+ if (result.restricted) {
205
+ res.statusCode = 429;
206
+ res.setHeader("Content-Type", "application/json");
207
+ res.end(JSON.stringify({ error: "throttled", reason: result.reason }));
208
+ return;
209
+ }
210
+ }
211
+
212
+ if (typeof next === "function") next();
213
+ queueMicrotask(() => this.processSnapshot(req, res));
214
+ } catch (err) {
215
+ if (typeof next === "function") return next(err);
216
+ throw err;
217
+ }
218
+ }
219
+
220
+ processSnapshot(req) {
221
+ this.buildLog(req);
222
+ }
223
+
224
+ async buildLog(req) {
225
+ await log.push(req);
226
+ }
227
+ }
228
+
229
+ /* ---------------- Export Factory ---------------- */
230
+ function middleware(options = {}) {
231
+ const core = new MiddlewareCore(options);
232
+ return function middleware(req, res, next) {
233
+ core.handle(req, res, next);
234
+ };
235
+ }
236
+
237
+ export { middleware };
@@ -0,0 +1,130 @@
1
+ /*!
2
+ * Triva - User Agent Parser
3
+ * Copyright (c) 2026 Kris Powers
4
+ * License MIT
5
+ */
6
+
7
+ "use strict";
8
+
9
+ async function parseUA(input) {
10
+ const ua =
11
+ typeof input === "string"
12
+ ? input
13
+ : input?.["user-agent"] || "";
14
+
15
+ const lowerUA = ua.toLowerCase();
16
+
17
+ const result = {
18
+ ua,
19
+ browser: { name: null, version: null, major: null },
20
+ engine: { name: null },
21
+ os: { name: null, version: null },
22
+ device: { type: "desktop", model: null },
23
+ cpu: { architecture: null },
24
+ bot: {
25
+ isBot: false,
26
+ isAI: false,
27
+ isCrawler: false,
28
+ name: null,
29
+ category: null
30
+ }
31
+ };
32
+
33
+ /* ---------------------------------------------
34
+ * BOT / AI DETECTION
35
+ * ------------------------------------------- */
36
+
37
+ const botSignatures = [
38
+ { name: "GPTBot", match: /gptbot/, category: "ai-crawler" },
39
+ { name: "ClaudeBot", match: /claudebot/, category: "ai-crawler" },
40
+ { name: "PerplexityBot", match: /perplexity/, category: "ai-crawler" },
41
+ { name: "Googlebot", match: /googlebot/, category: "search" },
42
+ { name: "Bingbot", match: /bingbot/, category: "search" },
43
+ { name: "Generic Bot", match: /bot|crawler|spider|curl|wget|python/i, category: "generic" }
44
+ ];
45
+
46
+ for (const bot of botSignatures) {
47
+ if (bot.match.test(lowerUA)) {
48
+ result.bot.isBot = true;
49
+ result.bot.isCrawler = true;
50
+ result.bot.isAI = bot.category.includes("ai");
51
+ result.bot.name = bot.name;
52
+ result.bot.category = bot.category;
53
+ break;
54
+ }
55
+ }
56
+
57
+ /* ---------------------------------------------
58
+ * BROWSER DETECTION (ORDER MATTERS)
59
+ * ------------------------------------------- */
60
+
61
+ const browserRules = [
62
+ { name: "Edge", regex: /edg\/([\d.]+)/i },
63
+ { name: "Opera", regex: /opr\/([\d.]+)/i },
64
+ { name: "Chrome", regex: /chrome\/([\d.]+)/i },
65
+ { name: "Firefox", regex: /firefox\/([\d.]+)/i },
66
+ { name: "Safari", regex: /version\/([\d.]+).*safari/i }
67
+ ];
68
+
69
+ for (const rule of browserRules) {
70
+ const match = ua.match(rule.regex);
71
+ if (match) {
72
+ result.browser.name = rule.name;
73
+ result.browser.version = match[1];
74
+ result.browser.major = match[1].split(".")[0];
75
+ break;
76
+ }
77
+ }
78
+
79
+ /* ---------------------------------------------
80
+ * ENGINE DETECTION (FIXED)
81
+ * ------------------------------------------- */
82
+
83
+ if (/chrome|chromium|crios/i.test(ua)) {
84
+ result.engine.name = "Blink";
85
+ } else if (/gecko/i.test(ua) && !/like gecko/i.test(ua)) {
86
+ result.engine.name = "Gecko";
87
+ } else if (/applewebkit/i.test(ua)) {
88
+ result.engine.name = "WebKit";
89
+ }
90
+
91
+ /* ---------------------------------------------
92
+ * OS DETECTION (FIXED)
93
+ * ------------------------------------------- */
94
+
95
+ const osRules = [
96
+ { name: "Windows", regex: /windows nt ([\d.]+)/i },
97
+ { name: "macOS", regex: /mac os x ([\d_]+)/i },
98
+ { name: "Android", regex: /android ([\d.]+)/i },
99
+ { name: "iOS", regex: /iphone os ([\d_]+)/i },
100
+ { name: "Linux", regex: /linux/i }
101
+ ];
102
+
103
+ for (const rule of osRules) {
104
+ const match = ua.match(rule.regex);
105
+ if (match) {
106
+ result.os.name = rule.name;
107
+ result.os.version = match[1]?.replace(/_/g, ".") || null;
108
+ break;
109
+ }
110
+ }
111
+
112
+ /* ---------------------------------------------
113
+ * DEVICE TYPE
114
+ * ------------------------------------------- */
115
+
116
+ if (/mobile/i.test(ua)) result.device.type = "mobile";
117
+ else if (/tablet/i.test(ua)) result.device.type = "tablet";
118
+
119
+ /* ---------------------------------------------
120
+ * CPU ARCH
121
+ * ------------------------------------------- */
122
+
123
+ if (/arm|aarch64/i.test(ua)) result.cpu.architecture = "arm";
124
+ else if (/x64|win64|amd64/i.test(ua)) result.cpu.architecture = "amd64";
125
+ else if (/x86/i.test(ua)) result.cpu.architecture = "x86";
126
+
127
+ return result;
128
+ }
129
+
130
+ export { parseUA };
package/package.json CHANGED
@@ -1,12 +1,33 @@
1
1
  {
2
2
  "name": "triva",
3
- "version": "0.0.2",
4
- "main": "index.js",
5
- "scripts": {
6
- "test": "echo \"Error: no test specified\" && exit 1"
3
+ "version": "0.3.1",
4
+ "description": "Enterprise Node.js HTTP framework with middleware, throttling, logging, caching, error tracking, and cookie support",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "exports": {
8
+ ".": "./lib/index.js"
7
9
  },
8
- "keywords": [],
9
- "author": "",
10
- "license": "ISC",
11
- "description": ""
10
+ "keywords": [
11
+ "http",
12
+ "server",
13
+ "framework",
14
+ "middleware",
15
+ "throttle",
16
+ "rate-limit",
17
+ "logging",
18
+ "cache",
19
+ "cookies",
20
+ "error-tracking"
21
+ ],
22
+ "author": "Kris Powers",
23
+ "license": "MIT",
24
+ "engines": {
25
+ "node": ">=18.0.0"
26
+ },
27
+ "files": [
28
+ "lib/",
29
+ "README.md",
30
+ "LICENSE",
31
+ "index.d.ts"
32
+ ]
12
33
  }