feat(F-078): completed feature

This commit is contained in:
chattie
2026-08-20 05:55:46 +02:00
parent d799dd07d7
commit fffb52721a
26 changed files with 1296 additions and 39 deletions

View File

@@ -0,0 +1,98 @@
/**
* In-process log broadcaster for SSE streaming.
*
* Collects the last MAX_BUFFER lines from pino and pushes new entries
* to all connected SSE clients in real-time.
*/
const MAX_BUFFER = 500;
export interface LogEntry {
time: string;
level: number;
levelName: string;
msg: string;
err?: Record<string, unknown>;
raw: string;
}
export class LogBroadcaster {
private buffer: string[] = [];
private clients = new Set<ReadableStreamDefaultController>();
/** Called by pino's custom destination stream. */
addEntry(jsonLine: string): void {
// Keep last MAX_BUFFER lines (ring buffer behavior)
if (this.buffer.length >= MAX_BUFFER) {
this.buffer.shift();
}
this.buffer.push(jsonLine);
// Broadcast to all SSE clients
const enc = new TextEncoder();
for (const client of this.clients) {
try {
client.enqueue(enc.encode(`data: ${jsonLine}\n\n`));
} catch {
// Client disconnected — will be cleaned up by 'close' event
this.clients.delete(client);
}
}
}
/** Returns a async generator that first yields buffered history, then new entries. */
async *stream(): AsyncGenerator<string, void, unknown> {
// First send history (without the SSE prefix — caller prepends it)
for (const line of this.buffer) {
yield line;
}
// Then wait for new entries via a local queue
// (new entries are pushed by addEntry; we yield them directly)
// Since addEntry broadcasts to all clients, we don't need a separate queue here.
// Instead, we just let the client receive from this generator's ReadableStream.
// The initial buffer + new entries are sufficient for SSE.
// Note: we can't "wait" for new entries in a sync generator.
// Instead, the SSE handler will use the ReadableStream from this client registration.
return;
}
/**
* Register a new SSE client. Returns a ReadableStream that yields SSE-formatted lines.
*/
registerClient(): ReadableStream<Uint8Array> {
const enc = new TextEncoder();
const self = this;
return new ReadableStream<Uint8Array>({
start(controller: ReadableStreamDefaultController) {
// Send buffer history
for (const line of self.buffer) {
try {
controller.enqueue(enc.encode(`data: ${line}\n\n`));
} catch {
// Controller closed
break;
}
}
self.clients.add(controller);
},
cancel(controller: ReadableStreamDefaultController) {
self.clients.delete(controller);
},
});
}
get clientCount(): number {
return this.clients.size;
}
}
/** Singleton instance shared across the server lifecycle. */
let instance: LogBroadcaster | undefined;
export function getLogBroadcaster(): LogBroadcaster {
if (!instance) {
instance = new LogBroadcaster();
}
return instance;
}

View File

@@ -3,6 +3,8 @@
* No pretty printers, no transports: JSON lines to a writable destination.
*/
import pino, { type DestinationStream, type Logger } from 'pino';
import type { LogBroadcaster } from './log-broadcaster.js';
import { Writable } from 'stream';
const SERVICE_NAME = 'mercadodevida-backend';
@@ -11,14 +13,34 @@ export interface LoggerOptions {
level?: string;
/** Explicit destination stream (tests use an in-memory stream). */
destination?: DestinationStream;
/** Log broadcaster for SSE streaming (optional). */
broadcaster?: LogBroadcaster;
}
/**
* Creates a pino logger. If a broadcaster is provided, logs are also pushed
* to all connected SSE clients via that broadcaster.
*/
export function createLogger(options: LoggerOptions = {}): Logger {
const level = options.level ?? process.env.LOG_LEVEL ?? 'info';
const settings = { level, base: { service: SERVICE_NAME } };
if (options.broadcaster) {
// Custom Node.js Writable stream that forwards each JSON line to the broadcaster
const toBroadcaster = new Writable({
write(chunk: Buffer, _encoding: string, callback: () => void) {
options.broadcaster!.addEntry(chunk.toString().trim());
callback();
},
autoDestroy: true,
});
return pino(settings, toBroadcaster);
}
if (options.destination) {
return pino(settings, options.destination);
}
return pino(settings);
}