triva 0.0.2 → 0.3.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 +416 -0
- package/index.d.ts +91 -0
- package/lib/cache.js +112 -0
- package/lib/cookie-parser.js +114 -0
- package/lib/db-adapters.js +580 -0
- package/lib/error-tracker.js +353 -0
- package/lib/index.js +655 -0
- package/lib/log.js +261 -0
- package/lib/middleware.js +237 -0
- package/lib/ua-parser.js +130 -0
- package/package.json +29 -8
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 };
|
package/lib/ua-parser.js
ADDED
|
@@ -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
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.3.0",
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
}
|