Files
peardock/server/handlers/logs.js
T
Raven Scott b8bd4eb902
Release rolling / release (push) Successful in 8m28s
Strip ANSI sequences from container logs for clean display.
PM2 and similar tools emit VT100 color codes that browsers hide only
partially, leaving artifacts like [32m between table columns. Strip CSI/OSC
escapes when formatting log lines, and harden Docker multiplex demux on the
server so frames are not mis-parsed as text.
2026-07-13 18:53:17 -04:00

212 lines
5.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Container log streaming over protomux-rpc pushes.
* Demuxes Docker multiplexed frames server-side so clients receive plain UTF-8.
*/
import { docker } from '../services/docker.js'
import { Pushes } from '../../shared/protocol.js'
import logger from '../utils/logger.js'
/**
* Incremental demux of docker log multiplex protocol.
* Frame: 1 byte stream type (02) + 3 zero bytes + 4 byte BE size + payload.
*/
function createServerDemuxer() {
let pending = Buffer.alloc(0)
/** Once we see a valid mux frame, stay in mux mode for the stream. */
let muxMode = null // null | true | false
return {
/**
* @param {Buffer} chunk
* @returns {Buffer[]} payloads
*/
push(chunk) {
pending = Buffer.concat([pending, chunk])
const out = []
if (muxMode === false) {
if (pending.length) {
out.push(pending)
pending = Buffer.alloc(0)
}
return out
}
// Detect mode from first full frame or non-mux text
if (muxMode === null && pending.length >= 1) {
const b0 = pending[0]
if (b0 > 2) {
muxMode = false
out.push(pending)
pending = Buffer.alloc(0)
return out
}
// Need header to confirm
if (pending.length >= 8) {
const size = pending.readUInt32BE(4)
// Plausible frame: type 0-2, zeros in 1-3, reasonable size
const zerosOk = pending[1] === 0 && pending[2] === 0 && pending[3] === 0
if (b0 <= 2 && zerosOk && size > 0 && size < 16 * 1024 * 1024) {
muxMode = true
} else if (b0 <= 2 && !zerosOk) {
// Not mux — treat as raw (e.g. TTY)
muxMode = false
out.push(pending)
pending = Buffer.alloc(0)
return out
}
}
}
if (muxMode !== true) {
// Wait for more bytes to decide, or emit if clearly not mux
return out
}
while (pending.length >= 8) {
const size = pending.readUInt32BE(4)
if (size < 0 || size > 16 * 1024 * 1024) {
// Corrupt header — fall back to raw remainder
out.push(pending)
pending = Buffer.alloc(0)
muxMode = false
break
}
if (pending.length < 8 + size) break
out.push(pending.subarray(8, 8 + size))
pending = pending.subarray(8 + size)
}
return out
},
flush() {
if (!pending.length) return []
const rest = pending
pending = Buffer.alloc(0)
return [rest]
},
}
}
async function startLogStream(session, streams, args) {
const containerId = args.id || args.containerId
if (!containerId) throw new Error('container id required')
if (streams.has(containerId)) {
try {
streams.get(containerId).stream?.destroy?.()
} catch {
// ignore
}
streams.delete(containerId)
}
const logOpts = {
stdout: args.stdout !== false,
stderr: args.stderr !== false,
tail: args.tail ?? 200,
follow: args.follow !== false,
timestamps: args.timestamps !== false,
}
if (args.since != null) logOpts.since = args.since
if (args.until != null) logOpts.until = args.until
const logsStream = await docker.getContainer(containerId).logs(logOpts)
const demux = createServerDemuxer()
streams.set(containerId, { stream: logsStream, demux })
logsStream.on('data', (chunk) => {
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
const payloads = demux.push(buf)
for (const payload of payloads) {
if (!payload.length) continue
try {
session.push(Pushes.logs, {
type: 'logs',
containerId,
data: payload.toString('base64'),
encoding: 'base64',
format: 'utf8-demuxed',
})
} catch (e) {
logger.debug('log push failed', { error: e.message })
}
}
})
logsStream.on('end', () => {
for (const payload of demux.flush()) {
if (!payload.length) continue
try {
session.push(Pushes.logs, {
type: 'logs',
containerId,
data: payload.toString('base64'),
encoding: 'base64',
format: 'utf8-demuxed',
})
} catch {
// ignore
}
}
streams.delete(containerId)
})
logsStream.on('error', (err) => {
logger.error('Log stream error', { containerId, error: err.message })
session.push(Pushes.error, {
error: `Log stream error: ${err.message}`,
containerId,
})
streams.delete(containerId)
})
return { success: true, message: `Log stream started for ${containerId}` }
}
export function registerLogsHandlers(session) {
/** @type {Map<string, { stream: import('stream').Readable, demux: ReturnType<typeof createServerDemuxer> }>} */
const streams = new Map()
session.state.set('logsStreams', streams)
session._cleanupLogsForContainer = (containerId) => {
for (const [key, entry] of streams.entries()) {
if (key === containerId) {
try {
entry.stream?.destroy?.()
} catch {
// ignore
}
streams.delete(key)
}
}
}
session.respond('startLogs', (args) => startLogStream(session, streams, args))
session.respond('logs', (args) => startLogStream(session, streams, args))
session.respond('stopLogs', async (args) => {
const containerId = args.id || args.containerId
if (containerId && streams.has(containerId)) {
try {
streams.get(containerId).stream?.destroy?.()
} catch {
// ignore
}
streams.delete(containerId)
}
return { success: true }
})
}
export function cleanupLogsOnClose(session) {
const streams = session.state.get('logsStreams')
if (!streams) return
for (const entry of streams.values()) {
try {
entry.stream?.destroy?.()
} catch {
// ignore
}
}
streams.clear()
}