feat: implement togglable debug log streaming for server stdout/stderr and client console logs
- Created server/utils/logCapture.js to intercept and buffer server output, stripping ANSI escape codes. - Created server/middleware/logStreamAuth.js enforcing subnet IP filtering (LOG_ALLOW_SUBNETS), Emby session cookie, Basic Auth fallback, and X-Webhook-Secret header bypass. - Created server/routes/debug.js with SSE streams /api/debug/server-logs, /api/debug/client-logs and batched POST /api/debug/client-logs. Exposes public configuration status at /api/debug/status. - Integrated log capture and mounted debug routes in server/app.js and server/index.js. - Implemented client/src/utils/clientLogCapture.js in the frontend SPA to hook console log/warn/error and flush batched console events. - Documented all endpoints in OpenAPI server/openapi.yaml, ARCHITECTURE.md, and README.md. - Wrote route integration tests and frontend console capture tests, with full validation in swagger-coverage.
This commit is contained in:
@@ -26,6 +26,7 @@ const historyRoutes = require('./routes/history');
|
||||
const authRoutes = require('./routes/auth');
|
||||
const webhookRoutes = require('./routes/webhook');
|
||||
const ombiRoutes = require('./routes/ombi');
|
||||
const debugRoutes = require('./routes/debug');
|
||||
const verifyCsrf = require('./middleware/verifyCsrf');
|
||||
|
||||
function createApp({ skipRateLimits = false } = {}) {
|
||||
@@ -213,6 +214,7 @@ function createApp({ skipRateLimits = false } = {}) {
|
||||
app.use('/api', apiLimiter);
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/webhook', webhookRoutes);
|
||||
app.use('/api/debug', debugRoutes);
|
||||
|
||||
// CSRF protection for all state-changing API requests below
|
||||
app.use('/api', verifyCsrf);
|
||||
|
||||
@@ -13,6 +13,8 @@ const swaggerJsdoc = require('swagger-jsdoc');
|
||||
const YAML = require('yamljs');
|
||||
require('dotenv').config();
|
||||
require('./utils/loadSecrets')();
|
||||
const logCapture = require('./utils/logCapture');
|
||||
logCapture.init();
|
||||
const { version } = require('../package.json');
|
||||
|
||||
// Setup logging with levels
|
||||
@@ -90,6 +92,7 @@ const historyRoutes = require('./routes/history');
|
||||
const authRoutes = require('./routes/auth');
|
||||
const webhookRoutes = require('./routes/webhook');
|
||||
const ombiRoutes = require('./routes/ombi');
|
||||
const debugRoutes = require('./routes/debug');
|
||||
const verifyCsrf = require('./middleware/verifyCsrf');
|
||||
const { startPoller, POLL_INTERVAL, POLLING_ENABLED } = require('./utils/poller');
|
||||
const { validateInstanceUrl } = require('./utils/config');
|
||||
@@ -367,6 +370,7 @@ function serveIndex(req, res) {
|
||||
app.use('/api', apiLimiter);
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/webhook', webhookRoutes);
|
||||
app.use('/api/debug', debugRoutes);
|
||||
|
||||
// All routes below this point require CSRF validation on mutating methods
|
||||
app.use('/api', verifyCsrf);
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
const axios = require('axios');
|
||||
const crypto = require('crypto');
|
||||
const ipaddr = require('ipaddr.js');
|
||||
|
||||
function getEmbyUrl() {
|
||||
return process.env.EMBY_URL;
|
||||
}
|
||||
|
||||
function isIpAllowed(clientIp, allowedSubnetsStr) {
|
||||
if (!allowedSubnetsStr) return true;
|
||||
try {
|
||||
const clientIpParsed = ipaddr.parse(clientIp);
|
||||
const subnets = allowedSubnetsStr.split(',').map(s => s.trim()).filter(Boolean);
|
||||
|
||||
for (const subnet of subnets) {
|
||||
let rangeStr = subnet;
|
||||
let bits = null;
|
||||
if (subnet.includes('/')) {
|
||||
const parts = subnet.split('/');
|
||||
rangeStr = parts[0];
|
||||
bits = parseInt(parts[1], 10);
|
||||
}
|
||||
|
||||
const rangeIpParsed = ipaddr.parse(rangeStr);
|
||||
|
||||
if (bits === null) {
|
||||
// Exact IP match
|
||||
if (clientIpParsed.toString() === rangeIpParsed.toString()) {
|
||||
return true;
|
||||
}
|
||||
// Handle IPv4 mapped IPv6 address case
|
||||
if (clientIpParsed.kind() === 'ipv6' && clientIpParsed.isIPv4MappedAddress() && rangeIpParsed.kind() === 'ipv4') {
|
||||
if (clientIpParsed.toIPv4Address().toString() === rangeIpParsed.toString()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Match with subnet bits
|
||||
if (clientIpParsed.kind() === rangeIpParsed.kind()) {
|
||||
if (clientIpParsed.match(rangeIpParsed, bits)) {
|
||||
return true;
|
||||
}
|
||||
} else if (clientIpParsed.kind() === 'ipv6' && clientIpParsed.isIPv4MappedAddress() && rangeIpParsed.kind() === 'ipv4') {
|
||||
// Handle IPv4 mapped IPv6 address case matching IPv4 range
|
||||
if (clientIpParsed.toIPv4Address().match(rangeIpParsed, bits)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[logStreamAuth] IP parsing error for IP ${clientIp}:`, err.message);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function logStreamAuth(req, res, next) {
|
||||
// 1. Subnet IP Filtering (First Priority)
|
||||
const allowedSubnets = process.env.LOG_ALLOW_SUBNETS;
|
||||
if (allowedSubnets && !isIpAllowed(req.ip, allowedSubnets)) {
|
||||
console.warn(`[logStreamAuth] Access denied from unauthorized IP: ${req.ip}`);
|
||||
return res.status(403).json({ error: `Access denied from IP: ${req.ip}` });
|
||||
}
|
||||
|
||||
// 2. Webhook Secret Bypass (High Priority)
|
||||
const secretHeader = req.headers['x-webhook-secret'];
|
||||
const configuredSecret = process.env.SOFARR_WEBHOOK_SECRET;
|
||||
if (configuredSecret && secretHeader === configuredSecret) {
|
||||
return next();
|
||||
}
|
||||
|
||||
// 3. Session Cookie
|
||||
const signed = !!process.env.COOKIE_SECRET;
|
||||
const rawCookie = signed ? req.signedCookies.emby_user : req.cookies.emby_user;
|
||||
if (rawCookie && rawCookie !== false) {
|
||||
try {
|
||||
const u = JSON.parse(rawCookie);
|
||||
if (u && typeof u.id === 'string' && u.id && u.isAdmin === true) {
|
||||
req.user = u;
|
||||
return next();
|
||||
}
|
||||
} catch {
|
||||
// Ignore JSON parse errors, fallback to basic auth
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Basic Authentication Fallback
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader && authHeader.startsWith('Basic ')) {
|
||||
try {
|
||||
const credentialsBase64 = authHeader.substring(6);
|
||||
const credentialsStr = Buffer.from(credentialsBase64, 'base64').toString('utf8');
|
||||
const colonIdx = credentialsStr.indexOf(':');
|
||||
|
||||
if (colonIdx !== -1) {
|
||||
const username = credentialsStr.substring(0, colonIdx).trim();
|
||||
const password = credentialsStr.substring(colonIdx + 1);
|
||||
|
||||
if (username && password) {
|
||||
const embyUrl = getEmbyUrl();
|
||||
if (!embyUrl) {
|
||||
console.error('[logStreamAuth] Emby auth fallback failed: EMBY_URL is not configured');
|
||||
res.setHeader('WWW-Authenticate', 'Basic realm="sofarr log stream debug"');
|
||||
return res.status(401).json({ error: 'Authentication service unavailable' });
|
||||
}
|
||||
|
||||
// Authenticate with Emby using stable DeviceId derived from username
|
||||
const stableDeviceId = 'sofarr-' + crypto.createHash('sha256').update(username.toLowerCase()).digest('hex').slice(0, 16);
|
||||
|
||||
console.log(`[logStreamAuth] Attempting Basic Auth login for: ${username}`);
|
||||
const authResponse = await axios.post(`${embyUrl}/Users/authenticatebyname`, {
|
||||
Username: username,
|
||||
Pw: password
|
||||
}, {
|
||||
headers: {
|
||||
'X-Emby-Authorization': `MediaBrowser Client="sofarr", Device="sofarr", DeviceId="${stableDeviceId}", Version="1.0.0"`
|
||||
},
|
||||
timeout: 5000
|
||||
});
|
||||
|
||||
const authData = authResponse.data;
|
||||
const userId = authData.User.Id || authData.User.id;
|
||||
|
||||
// Fetch detailed profile to verify administrator status
|
||||
const userResponse = await axios.get(`${embyUrl}/Users/${userId}`, {
|
||||
headers: {
|
||||
'X-MediaBrowser-Token': authData.AccessToken
|
||||
},
|
||||
timeout: 5000
|
||||
});
|
||||
|
||||
const user = userResponse.data;
|
||||
const isAdmin = !!(user.Policy && user.Policy.IsAdministrator);
|
||||
|
||||
if (isAdmin) {
|
||||
console.log(`[logStreamAuth] Basic Auth successful for administrator: ${user.Name}`);
|
||||
req.user = { id: user.Id, name: user.Name, isAdmin: true };
|
||||
return next();
|
||||
} else {
|
||||
console.warn(`[logStreamAuth] Basic Auth rejected: user ${user.Name} is not an administrator`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[logStreamAuth] Emby authentication error:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Unauthorized / Access Denied
|
||||
res.setHeader('WWW-Authenticate', 'Basic realm="sofarr log stream debug"');
|
||||
return res.status(401).json({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
module.exports = logStreamAuth;
|
||||
@@ -1752,3 +1752,172 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
|
||||
/api/debug/status:
|
||||
get:
|
||||
tags: [Debug]
|
||||
summary: Check if log streaming is enabled
|
||||
description: Returns whether the log streaming feature is enabled at runtime. No authentication required.
|
||||
security: []
|
||||
responses:
|
||||
'200':
|
||||
description: Feature status returned successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
enabled:
|
||||
type: boolean
|
||||
example: true
|
||||
|
||||
/api/debug/server-logs:
|
||||
get:
|
||||
tags: [Debug]
|
||||
summary: Stream server logs in real-time
|
||||
description: |
|
||||
Streams server-side standard output (stdout/stderr) logs via Server-Sent Events (SSE).
|
||||
|
||||
**Security Policies:**
|
||||
- **Subnet IP Filtering**: IP must match subnet ranges configured in `LOG_ALLOW_SUBNETS` (if set).
|
||||
- **Bypass Header**: Direct bypass if `X-Webhook-Secret` header matches the configured `SOFARR_WEBHOOK_SECRET`.
|
||||
- **Session Auth**: Emby session cookie `emby_user` where user is an administrator.
|
||||
- **Basic Auth Fallback**: `Authorization` header containing credentials of a valid Emby administrator.
|
||||
security:
|
||||
- CookieAuth: []
|
||||
parameters:
|
||||
- name: X-Webhook-Secret
|
||||
in: header
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
description: Fast-track webhook secret bypass token
|
||||
responses:
|
||||
'200':
|
||||
description: Event stream established
|
||||
content:
|
||||
text/event-stream:
|
||||
schema:
|
||||
type: string
|
||||
'401':
|
||||
description: Unauthorized
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'403':
|
||||
description: Forbidden (feature disabled or IP not in subnet allowlist)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
|
||||
/api/debug/client-logs:
|
||||
get:
|
||||
tags: [Debug]
|
||||
summary: Stream client console logs in real-time
|
||||
description: |
|
||||
Streams client-side console logs via Server-Sent Events (SSE).
|
||||
|
||||
**Security Policies:**
|
||||
- **Subnet IP Filtering**: IP must match subnet ranges configured in `LOG_ALLOW_SUBNETS` (if set).
|
||||
- **Bypass Header**: Direct bypass if `X-Webhook-Secret` header matches the configured `SOFARR_WEBHOOK_SECRET`.
|
||||
- **Session Auth**: Emby session cookie `emby_user` where user is an administrator.
|
||||
- **Basic Auth Fallback**: `Authorization` header containing credentials of a valid Emby administrator.
|
||||
security:
|
||||
- CookieAuth: []
|
||||
parameters:
|
||||
- name: X-Webhook-Secret
|
||||
in: header
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
description: Fast-track webhook secret bypass token
|
||||
responses:
|
||||
'200':
|
||||
description: Event stream established
|
||||
content:
|
||||
text/event-stream:
|
||||
schema:
|
||||
type: string
|
||||
'401':
|
||||
description: Unauthorized
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'403':
|
||||
description: Forbidden (feature disabled or IP not in subnet allowlist)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
post:
|
||||
tags: [Debug]
|
||||
summary: Ingest client console logs
|
||||
description: |
|
||||
Ingests a batch of client-side console logs into the server-side rolling clientLogBuffer.
|
||||
|
||||
**Security Policies:**
|
||||
- **Subnet IP Filtering**: IP must match subnet ranges configured in `LOG_ALLOW_SUBNETS` (if set).
|
||||
- **Bypass Header**: Direct bypass if `X-Webhook-Secret` header matches the configured `SOFARR_WEBHOOK_SECRET`.
|
||||
- **Session Auth**: Emby session cookie `emby_user` where user is an administrator.
|
||||
- **Basic Auth Fallback**: `Authorization` header containing credentials of a valid Emby administrator.
|
||||
security:
|
||||
- CookieAuth: []
|
||||
parameters:
|
||||
- name: X-Webhook-Secret
|
||||
in: header
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
description: Fast-track webhook secret bypass token
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
required: [level, message]
|
||||
properties:
|
||||
timestamp:
|
||||
type: string
|
||||
format: date-time
|
||||
level:
|
||||
type: string
|
||||
enum: [info, warn, error]
|
||||
message:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Logs ingested successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
count:
|
||||
type: integer
|
||||
'400':
|
||||
description: Invalid JSON body
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'401':
|
||||
description: Unauthorized
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'403':
|
||||
description: Forbidden (feature disabled or IP not in subnet allowlist)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const logStreamAuth = require('../middleware/logStreamAuth');
|
||||
const {
|
||||
logEmitter,
|
||||
logBuffer,
|
||||
clientLogBuffer,
|
||||
ingestClientLogs
|
||||
} = require('../utils/logCapture');
|
||||
|
||||
// Public status check (no auth, no 403 block, returns standard config state)
|
||||
router.get('/status', (req, res) => {
|
||||
res.json({ enabled: process.env.ENABLE_LOG_STREAM === 'true' });
|
||||
});
|
||||
|
||||
// Global toggle check
|
||||
router.use((req, res, next) => {
|
||||
if (process.env.ENABLE_LOG_STREAM !== 'true') {
|
||||
return res.status(403).json({ error: 'Log streaming feature is disabled' });
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
// Enforce subnet and authentication validations on all debug routes
|
||||
router.use(logStreamAuth);
|
||||
|
||||
/**
|
||||
* GET /api/debug/server-logs
|
||||
* Exposes a real-time SSE stream of intercepted server stdout/stderr logs.
|
||||
*/
|
||||
router.get('/server-logs', (req, res) => {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
'X-Accel-Buffering': 'no'
|
||||
});
|
||||
|
||||
// Send historical server logs buffer first
|
||||
for (const line of logBuffer) {
|
||||
res.write(`data: ${line}\n\n`);
|
||||
}
|
||||
|
||||
// Gracefully close for integration testing
|
||||
if (req.query.testClose === 'true') {
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const sendLog = (line) => {
|
||||
try {
|
||||
res.write(`data: ${line}\n\n`);
|
||||
} catch (err) {
|
||||
console.error('[debugRoutes] Error sending server log line:', err.message);
|
||||
}
|
||||
};
|
||||
|
||||
logEmitter.on('server-log', sendLog);
|
||||
|
||||
// 25s heartbeat comment to prevent proxy timeouts
|
||||
const heartbeat = setInterval(() => {
|
||||
try {
|
||||
res.write(': heartbeat\n\n');
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}, 25000);
|
||||
|
||||
req.on('close', () => {
|
||||
clearInterval(heartbeat);
|
||||
logEmitter.off('server-log', sendLog);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/debug/client-logs
|
||||
* Exposes a real-time SSE stream of ingested client-side console logs.
|
||||
*/
|
||||
router.get('/client-logs', (req, res) => {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
'X-Accel-Buffering': 'no'
|
||||
});
|
||||
|
||||
// Send historical client logs buffer first
|
||||
for (const line of clientLogBuffer) {
|
||||
res.write(`data: ${line}\n\n`);
|
||||
}
|
||||
|
||||
// Gracefully close for integration testing
|
||||
if (req.query.testClose === 'true') {
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const sendClientLog = (line) => {
|
||||
try {
|
||||
res.write(`data: ${line}\n\n`);
|
||||
} catch (err) {
|
||||
console.error('[debugRoutes] Error sending client log line:', err.message);
|
||||
}
|
||||
};
|
||||
|
||||
logEmitter.on('client-log', sendClientLog);
|
||||
|
||||
// 25s heartbeat comment to prevent proxy timeouts
|
||||
const heartbeat = setInterval(() => {
|
||||
try {
|
||||
res.write(': heartbeat\n\n');
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}, 25000);
|
||||
|
||||
req.on('close', () => {
|
||||
clearInterval(heartbeat);
|
||||
logEmitter.off('client-log', sendClientLog);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/debug/client-logs
|
||||
* Receives batches of frontend console logs to store in buffer and emit.
|
||||
*/
|
||||
router.post('/client-logs', (req, res) => {
|
||||
const logs = req.body;
|
||||
if (!Array.isArray(logs)) {
|
||||
return res.status(400).json({ error: 'Body must be a JSON array of log entries' });
|
||||
}
|
||||
|
||||
try {
|
||||
ingestClientLogs(logs);
|
||||
return res.status(200).json({ success: true, count: logs.length });
|
||||
} catch (err) {
|
||||
console.error('[debugRoutes] Ingestion failed:', err.message);
|
||||
return res.status(500).json({ error: 'Internal server error during ingestion' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,131 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
const EventEmitter = require('events');
|
||||
|
||||
class LogEmitter extends EventEmitter {}
|
||||
const logEmitter = new LogEmitter();
|
||||
|
||||
const logBuffer = [];
|
||||
const clientLogBuffer = [];
|
||||
const MAX_BUFFER_SIZE = 1000;
|
||||
|
||||
// ANSI escape code regular expression for stripping terminal colors
|
||||
const ansiRegex = /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g;
|
||||
|
||||
function stripAnsi(str) {
|
||||
return typeof str === 'string' ? str.replace(ansiRegex, '') : str;
|
||||
}
|
||||
|
||||
// Keep track of original stdout/stderr write functions
|
||||
const originalStdoutWrite = process.stdout.write;
|
||||
const originalStderrWrite = process.stderr.write;
|
||||
|
||||
// Buffer to accumulate partial lines from stdout and stderr
|
||||
let stdoutLineBuffer = '';
|
||||
let stderrLineBuffer = '';
|
||||
|
||||
function processStreamData(data, encoding, callback, streamName, lineAccumulator) {
|
||||
let str = '';
|
||||
if (Buffer.isBuffer(data)) {
|
||||
str = data.toString(encoding || 'utf8');
|
||||
} else if (typeof data === 'string') {
|
||||
str = data;
|
||||
}
|
||||
|
||||
// Delegate writing to the original stream first
|
||||
callback.call(this, data, encoding);
|
||||
|
||||
// Append new data to the accumulator
|
||||
const accumulated = lineAccumulator.buffer + str;
|
||||
const lines = accumulated.split(/\r?\n/);
|
||||
|
||||
// The last element is either empty (if str ended with \n) or a partial line
|
||||
lineAccumulator.buffer = lines.pop();
|
||||
|
||||
for (const line of lines) {
|
||||
const cleanLine = stripAnsi(line);
|
||||
if (!cleanLine) continue;
|
||||
|
||||
// Prepend timestamp if not present (format: [ISO] Message)
|
||||
const timestampedLine = cleanLine.startsWith('[')
|
||||
? cleanLine
|
||||
: `[${new Date().toISOString()}] [${streamName.toUpperCase()}] ${cleanLine}`;
|
||||
|
||||
logBuffer.push(timestampedLine);
|
||||
if (logBuffer.length > MAX_BUFFER_SIZE) {
|
||||
logBuffer.shift();
|
||||
}
|
||||
|
||||
logEmitter.emit('server-log', timestampedLine);
|
||||
}
|
||||
}
|
||||
|
||||
// Accumulator objects to allow updating string buffers by reference
|
||||
const stdoutAccumulator = { buffer: '' };
|
||||
const stderrAccumulator = { buffer: '' };
|
||||
|
||||
let isHooked = false;
|
||||
|
||||
function init() {
|
||||
if (isHooked) return;
|
||||
|
||||
// Intercept stdout
|
||||
process.stdout.write = function(data, encoding, callback) {
|
||||
processStreamData.call(
|
||||
process.stdout,
|
||||
data,
|
||||
encoding,
|
||||
originalStdoutWrite,
|
||||
'stdout',
|
||||
stdoutAccumulator
|
||||
);
|
||||
if (typeof callback === 'function') callback();
|
||||
return true;
|
||||
};
|
||||
|
||||
// Intercept stderr
|
||||
process.stderr.write = function(data, encoding, callback) {
|
||||
processStreamData.call(
|
||||
process.stderr,
|
||||
data,
|
||||
encoding,
|
||||
originalStderrWrite,
|
||||
'stderr',
|
||||
stderrAccumulator
|
||||
);
|
||||
if (typeof callback === 'function') callback();
|
||||
return true;
|
||||
};
|
||||
|
||||
isHooked = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ingests a list of client-side logs into the rolling clientLogBuffer.
|
||||
* Each client log is expected to have structure: { timestamp, level, message }
|
||||
*/
|
||||
function ingestClientLogs(logs) {
|
||||
if (!Array.isArray(logs)) return;
|
||||
|
||||
for (const log of logs) {
|
||||
const timestamp = log.timestamp || new Date().toISOString();
|
||||
const level = (log.level || 'info').toUpperCase();
|
||||
const msg = typeof log.message === 'string' ? log.message : JSON.stringify(log.message);
|
||||
|
||||
const formattedLog = `[${timestamp}] [CLIENT] [${level}] ${stripAnsi(msg)}`;
|
||||
clientLogBuffer.push(formattedLog);
|
||||
|
||||
if (clientLogBuffer.length > MAX_BUFFER_SIZE) {
|
||||
clientLogBuffer.shift();
|
||||
}
|
||||
|
||||
logEmitter.emit('client-log', formattedLog);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
init,
|
||||
logEmitter,
|
||||
logBuffer,
|
||||
clientLogBuffer,
|
||||
ingestClientLogs
|
||||
};
|
||||
Reference in New Issue
Block a user