Files
peardock/server/handlers/logs.js
T
snxraven 77f49beda3 Harden terminals and logs; polish deploy job drawer
Production-grade xterm FitAddon resize, binary-safe I/O, TTY-safe
server streams, demuxed log viewer with line buffering, and auto-dismiss
deploy job panel without the full-screen spinner.
2026-07-10 21:34:51 -04:00

170 lines
4.5 KiB
JavaScript

/**
* 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.
*/
function createServerDemuxer() {
let pending = Buffer.alloc(0)
return {
/**
* @param {Buffer} chunk
* @returns {Buffer[]} payloads
*/
push(chunk) {
pending = Buffer.concat([pending, chunk])
const out = []
// Non-muxed (raw text) — if first byte isn't 0-2 and we have data
if (pending.length && pending[0] > 2) {
out.push(pending)
pending = Buffer.alloc(0)
return out
}
while (pending.length >= 8) {
const size = pending.readUInt32BE(4)
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()
}