mohen 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/src/logger.ts ADDED
@@ -0,0 +1,454 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import type { Request, Response, NextFunction } from 'express';
4
+
5
+ // ============================================================================
6
+ // Types
7
+ // ============================================================================
8
+
9
+ interface LogEntry {
10
+ timestamp: string;
11
+ requestId: string;
12
+ type: 'http' | 'trpc';
13
+ method: string;
14
+ path: string;
15
+ statusCode?: number;
16
+ duration: number;
17
+ request?: {
18
+ body?: unknown;
19
+ query?: unknown;
20
+ headers?: Record<string, string>;
21
+ };
22
+ response?: {
23
+ body?: unknown;
24
+ streaming?: boolean;
25
+ chunks?: unknown[];
26
+ };
27
+ error?: {
28
+ message: string;
29
+ stack?: string;
30
+ };
31
+ metadata?: Record<string, unknown>;
32
+ }
33
+
34
+ interface LoggerOptions {
35
+ maxSizeBytes?: number; // Default: 10MB
36
+ includeHeaders?: boolean; // Default: false
37
+ redact?: string[]; // Fields to redact from logs
38
+ }
39
+
40
+ // Extend Express Request to include metadata
41
+ declare global {
42
+ namespace Express {
43
+ interface Request {
44
+ logMetadata?: Record<string, unknown>;
45
+ }
46
+ }
47
+ }
48
+
49
+ // ============================================================================
50
+ // Core Logger Class
51
+ // ============================================================================
52
+
53
+ class UnifiedLogger {
54
+ private filePath: string;
55
+ private maxSizeBytes: number;
56
+ private includeHeaders: boolean;
57
+ private redactFields: Set<string>;
58
+
59
+ constructor(filePath: string, options: LoggerOptions = {}) {
60
+ this.filePath = path.resolve(filePath);
61
+ this.maxSizeBytes = options.maxSizeBytes ?? 10 * 1024 * 1024; // 10MB default
62
+ this.includeHeaders = options.includeHeaders ?? false;
63
+ this.redactFields = new Set(options.redact ?? ['password', 'token', 'authorization', 'cookie']);
64
+
65
+ // Ensure directory exists
66
+ const dir = path.dirname(this.filePath);
67
+ if (!fs.existsSync(dir)) {
68
+ fs.mkdirSync(dir, { recursive: true });
69
+ }
70
+ }
71
+
72
+ private generateRequestId(): string {
73
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 9)}`;
74
+ }
75
+
76
+ private redact(obj: unknown): unknown {
77
+ if (obj === null || obj === undefined) return obj;
78
+ if (typeof obj !== 'object') return obj;
79
+
80
+ if (Array.isArray(obj)) {
81
+ return obj.map((item) => this.redact(item));
82
+ }
83
+
84
+ const result: Record<string, unknown> = {};
85
+ for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
86
+ if (this.redactFields.has(key.toLowerCase())) {
87
+ result[key] = '[REDACTED]';
88
+ } else if (typeof value === 'object') {
89
+ result[key] = this.redact(value);
90
+ } else {
91
+ result[key] = value;
92
+ }
93
+ }
94
+ return result;
95
+ }
96
+
97
+ private checkAndRotate(): void {
98
+ try {
99
+ if (!fs.existsSync(this.filePath)) return;
100
+
101
+ const stats = fs.statSync(this.filePath);
102
+ if (stats.size > this.maxSizeBytes) {
103
+ // Read file, keep last 25% of lines
104
+ const content = fs.readFileSync(this.filePath, 'utf-8');
105
+ const lines = content.trim().split('\n');
106
+ const keepCount = Math.floor(lines.length * 0.25);
107
+ const newContent = lines.slice(-keepCount).join('\n') + '\n';
108
+ fs.writeFileSync(this.filePath, newContent);
109
+ }
110
+ } catch (err) {
111
+ console.error('Logger rotation error:', err);
112
+ }
113
+ }
114
+
115
+ write(entry: LogEntry): void {
116
+ try {
117
+ this.checkAndRotate();
118
+ const redactedEntry = this.redact(entry) as LogEntry;
119
+ const line = JSON.stringify(redactedEntry) + '\n';
120
+ fs.appendFileSync(this.filePath, line);
121
+ } catch (err) {
122
+ console.error('Logger write error:', err);
123
+ }
124
+ }
125
+
126
+ // ===========================================================================
127
+ // Express Middleware
128
+ // ===========================================================================
129
+
130
+ expressMiddleware() {
131
+ return (req: Request, res: Response, next: NextFunction) => {
132
+ const start = Date.now();
133
+ const requestId = this.generateRequestId();
134
+
135
+ // Initialize metadata object on request
136
+ req.logMetadata = {};
137
+
138
+ // Detect SSE - check both request Accept header and response Content-Type
139
+ let isSSE = req.headers.accept === 'text/event-stream';
140
+ const chunks: unknown[] = [];
141
+
142
+ // Intercept setHeader to detect SSE by Content-Type
143
+ const originalSetHeader = res.setHeader.bind(res);
144
+ res.setHeader = ((name: string, value: string | number | readonly string[]): Response => {
145
+ if (name.toLowerCase() === 'content-type' &&
146
+ typeof value === 'string' &&
147
+ value.includes('text/event-stream')) {
148
+ isSSE = true;
149
+ }
150
+ return originalSetHeader(name, value);
151
+ }) as typeof res.setHeader;
152
+
153
+ // Capture request info
154
+ const requestInfo: LogEntry['request'] = {
155
+ body: req.body,
156
+ query: req.query,
157
+ };
158
+
159
+ if (this.includeHeaders) {
160
+ requestInfo.headers = req.headers as Record<string, string>;
161
+ }
162
+
163
+ // Intercept write/end for streaming detection
164
+ const originalWrite = res.write.bind(res);
165
+ const originalEnd = res.end.bind(res);
166
+ const originalJson = res.json.bind(res);
167
+ const originalSend = res.send.bind(res);
168
+ let responseBody: unknown;
169
+ let logged = false;
170
+
171
+ res.write = ((chunk: any, encodingOrCallback?: any, callback?: any): boolean => {
172
+ // If write is called, treat as streaming
173
+ if (chunk && isSSE) {
174
+ const chunkStr = chunk.toString();
175
+ const parsed = this.parseSSEChunk(chunkStr);
176
+ if (parsed) {
177
+ chunks.push(parsed);
178
+ }
179
+ }
180
+ return originalWrite(chunk, encodingOrCallback, callback);
181
+ }) as typeof res.write;
182
+
183
+ res.end = ((chunk?: any, encodingOrCallback?: any, callback?: any): Response => {
184
+ if (logged) return originalEnd(chunk, encodingOrCallback, callback);
185
+ logged = true;
186
+
187
+ if (isSSE) {
188
+ // SSE streaming path
189
+ if (chunk) {
190
+ const chunkStr = chunk.toString();
191
+ const parsed = this.parseSSEChunk(chunkStr);
192
+ if (parsed) {
193
+ chunks.push(parsed);
194
+ }
195
+ }
196
+
197
+ const entry: LogEntry = {
198
+ timestamp: new Date().toISOString(),
199
+ requestId,
200
+ type: 'http',
201
+ method: req.method,
202
+ path: req.originalUrl || req.url,
203
+ statusCode: res.statusCode,
204
+ duration: Date.now() - start,
205
+ request: requestInfo,
206
+ response: {
207
+ streaming: true,
208
+ chunks,
209
+ },
210
+ };
211
+
212
+ if (req.logMetadata && Object.keys(req.logMetadata).length > 0) {
213
+ entry.metadata = req.logMetadata;
214
+ }
215
+
216
+ this.write(entry);
217
+ } else {
218
+ // Regular response path
219
+ const entry: LogEntry = {
220
+ timestamp: new Date().toISOString(),
221
+ requestId,
222
+ type: 'http',
223
+ method: req.method,
224
+ path: req.originalUrl || req.url,
225
+ statusCode: res.statusCode,
226
+ duration: Date.now() - start,
227
+ request: requestInfo,
228
+ response: {
229
+ body: responseBody,
230
+ streaming: false,
231
+ },
232
+ };
233
+
234
+ if (req.logMetadata && Object.keys(req.logMetadata).length > 0) {
235
+ entry.metadata = req.logMetadata;
236
+ }
237
+
238
+ this.write(entry);
239
+ }
240
+
241
+ return originalEnd(chunk, encodingOrCallback, callback);
242
+ }) as typeof res.end;
243
+
244
+ const logResponse = () => {
245
+ if (logged) return;
246
+ logged = true;
247
+
248
+ const entry: LogEntry = {
249
+ timestamp: new Date().toISOString(),
250
+ requestId,
251
+ type: 'http',
252
+ method: req.method,
253
+ path: req.originalUrl || req.url,
254
+ statusCode: res.statusCode,
255
+ duration: Date.now() - start,
256
+ request: requestInfo,
257
+ response: {
258
+ body: responseBody,
259
+ streaming: false,
260
+ },
261
+ };
262
+
263
+ if (req.logMetadata && Object.keys(req.logMetadata).length > 0) {
264
+ entry.metadata = req.logMetadata;
265
+ }
266
+
267
+ this.write(entry);
268
+ };
269
+
270
+ res.json = (body: any) => {
271
+ responseBody = body;
272
+ logResponse();
273
+ return originalJson(body);
274
+ };
275
+
276
+ res.send = (body: any) => {
277
+ if (!logged) {
278
+ try {
279
+ responseBody = typeof body === 'string' ? JSON.parse(body) : body;
280
+ } catch {
281
+ responseBody = body;
282
+ }
283
+ logResponse();
284
+ }
285
+ return originalSend(body);
286
+ };
287
+
288
+ next();
289
+ };
290
+ }
291
+
292
+ private parseSSEChunk(raw: string): unknown {
293
+ const lines = raw.split('\n');
294
+ for (const line of lines) {
295
+ if (line.startsWith('data: ')) {
296
+ const data = line.slice(6).trim();
297
+ if (data === '[DONE]') return { done: true };
298
+ try {
299
+ return JSON.parse(data);
300
+ } catch {
301
+ return { raw: data };
302
+ }
303
+ }
304
+ }
305
+ return null;
306
+ }
307
+
308
+ // ===========================================================================
309
+ // tRPC Middleware
310
+ // ===========================================================================
311
+
312
+ trpcMiddleware<TContext extends Record<string, unknown> = Record<string, unknown>>() {
313
+ const logger = this;
314
+
315
+ return async function loggerMiddleware(opts: {
316
+ path: string;
317
+ type: 'query' | 'mutation' | 'subscription';
318
+ input: unknown;
319
+ ctx: TContext & { logMetadata?: Record<string, unknown> };
320
+ next: () => Promise<{ ok: boolean; data?: unknown; error?: Error }>;
321
+ }) {
322
+ const start = Date.now();
323
+ const requestId = logger.generateRequestId();
324
+
325
+ // Initialize metadata on context if not present
326
+ if (!opts.ctx.logMetadata) {
327
+ opts.ctx.logMetadata = {};
328
+ }
329
+
330
+ try {
331
+ const result = await opts.next();
332
+
333
+ const entry: LogEntry = {
334
+ timestamp: new Date().toISOString(),
335
+ requestId,
336
+ type: 'trpc',
337
+ method: opts.type.toUpperCase(),
338
+ path: opts.path,
339
+ statusCode: result.ok ? 200 : 500,
340
+ duration: Date.now() - start,
341
+ request: {
342
+ body: opts.input,
343
+ },
344
+ response: {
345
+ body: result.data,
346
+ streaming: false,
347
+ },
348
+ };
349
+
350
+ // Attach metadata if present
351
+ if (opts.ctx.logMetadata && Object.keys(opts.ctx.logMetadata).length > 0) {
352
+ entry.metadata = opts.ctx.logMetadata;
353
+ }
354
+
355
+ if (result.error) {
356
+ entry.error = {
357
+ message: result.error.message,
358
+ stack: result.error.stack,
359
+ };
360
+ }
361
+
362
+ logger.write(entry);
363
+
364
+ return result;
365
+ } catch (error) {
366
+ const err = error as Error;
367
+
368
+ const entry: LogEntry = {
369
+ timestamp: new Date().toISOString(),
370
+ requestId,
371
+ type: 'trpc',
372
+ method: opts.type.toUpperCase(),
373
+ path: opts.path,
374
+ statusCode: 500,
375
+ duration: Date.now() - start,
376
+ request: {
377
+ body: opts.input,
378
+ },
379
+ error: {
380
+ message: err.message,
381
+ stack: err.stack,
382
+ },
383
+ };
384
+
385
+ // Attach metadata if present
386
+ if (opts.ctx.logMetadata && Object.keys(opts.ctx.logMetadata).length > 0) {
387
+ entry.metadata = opts.ctx.logMetadata;
388
+ }
389
+
390
+ logger.write(entry);
391
+
392
+ throw error;
393
+ }
394
+ };
395
+ }
396
+ }
397
+
398
+ // ============================================================================
399
+ // Factory Function (Main Export)
400
+ // ============================================================================
401
+
402
+ export function createLogger(filePath: string, options?: LoggerOptions) {
403
+ const logger = new UnifiedLogger(filePath, options);
404
+
405
+ return {
406
+ /** Express middleware - use with app.use() */
407
+ express: () => logger.expressMiddleware(),
408
+
409
+ /** tRPC middleware - use with t.procedure.use() */
410
+ trpc: <TContext extends Record<string, unknown> = Record<string, unknown>>() =>
411
+ logger.trpcMiddleware<TContext>(),
412
+
413
+ /** Direct write access for custom logging */
414
+ write: (entry: Partial<LogEntry>) => logger.write({
415
+ timestamp: new Date().toISOString(),
416
+ requestId: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 9)}`,
417
+ type: 'http',
418
+ method: 'CUSTOM',
419
+ path: '/',
420
+ duration: 0,
421
+ ...entry,
422
+ } as LogEntry),
423
+ };
424
+ }
425
+
426
+ // ============================================================================
427
+ // Helper to attach metadata (for cleaner API)
428
+ // ============================================================================
429
+
430
+ /**
431
+ * Attach metadata to the current request log entry (Express)
432
+ */
433
+ export function attachMetadata(req: Request, metadata: Record<string, unknown>): void {
434
+ if (!req.logMetadata) {
435
+ req.logMetadata = {};
436
+ }
437
+ Object.assign(req.logMetadata, metadata);
438
+ }
439
+
440
+ /**
441
+ * Attach metadata to the current request log entry (tRPC)
442
+ */
443
+ export function attachTrpcMetadata<TContext extends { logMetadata?: Record<string, unknown> }>(
444
+ ctx: TContext,
445
+ metadata: Record<string, unknown>
446
+ ): void {
447
+ if (!ctx.logMetadata) {
448
+ ctx.logMetadata = {};
449
+ }
450
+ Object.assign(ctx.logMetadata, metadata);
451
+ }
452
+
453
+ // Default export for simpler imports
454
+ export default createLogger;