Files

332 lines
8.1 KiB
JavaScript

'use strict';
const fs = require('node:fs');
const fsp = require('node:fs/promises');
const http = require('node:http');
const path = require('node:path');
const { URL } = require('node:url');
const WebSocket = require('ws');
const PORT = Number.parseInt(process.env.PORT || '3000', 10);
const HOST = process.env.HOST || '127.0.0.1';
const TAIL_LINES = Number.parseInt(process.env.TAIL_LINES || '200', 10);
const WATCH_INTERVAL_MS = Number.parseInt(process.env.WATCH_INTERVAL_MS || '500', 10);
const SHUTDOWN_TIMEOUT_MS = Number.parseInt(process.env.SHUTDOWN_TIMEOUT_MS || '3000', 10);
const PUBLIC_DIR = path.join(__dirname, 'public');
const LOG_FILE = path.resolve(process.env.LOG_FILE || process.argv[2] || 'sample.log');
let offset = 0;
let changeQueue = Promise.resolve();
const mimeTypes = new Map([
['.html', 'text/html; charset=utf-8'],
['.css', 'text/css; charset=utf-8'],
['.js', 'text/javascript; charset=utf-8'],
['.json', 'application/json; charset=utf-8'],
['.txt', 'text/plain; charset=utf-8']
]);
async function assertLogFile(filePath) {
let stat;
try {
stat = await fsp.stat(filePath);
} catch (error) {
if (error.code === 'ENOENT') {
throw new Error(`Log file does not exist: ${filePath}`);
}
throw error;
}
if (!stat.isFile()) {
throw new Error(`Log path is not a file: ${filePath}`);
}
return stat;
}
async function readLastLines(filePath, maxLines) {
const stat = await fsp.stat(filePath);
if (stat.size === 0 || maxLines <= 0) {
return [];
}
const chunkSize = 64 * 1024;
const buffers = [];
let position = stat.size;
let newlineCount = 0;
const file = await fsp.open(filePath, 'r');
try {
while (position > 0 && newlineCount <= maxLines) {
const readSize = Math.min(chunkSize, position);
position -= readSize;
const buffer = Buffer.allocUnsafe(readSize);
await file.read(buffer, 0, readSize, position);
buffers.unshift(buffer);
for (let i = 0; i < buffer.length; i += 1) {
if (buffer[i] === 10) {
newlineCount += 1;
}
}
}
} finally {
await file.close();
}
let text = Buffer.concat(buffers).toString('utf8').replace(/\r\n/g, '\n');
const readFromStart = position === 0;
if (!readFromStart) {
const firstNewline = text.indexOf('\n');
text = firstNewline === -1 ? '' : text.slice(firstNewline + 1);
}
if (text.endsWith('\n')) {
text = text.slice(0, -1);
}
if (!text) {
return [];
}
return text.split('\n').slice(-maxLines);
}
function sendJson(socket, payload) {
if (socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify(payload));
}
}
function broadcast(server, payload) {
const body = JSON.stringify(payload);
for (const client of server.clients) {
if (client.readyState === WebSocket.OPEN) {
client.send(body);
}
}
}
function resolveStaticPath(requestUrl) {
const parsed = new URL(requestUrl, `http://localhost:${PORT}`);
const pathname = decodeURIComponent(parsed.pathname);
const requestedPath = pathname === '/' ? '/index.html' : pathname;
const filePath = path.resolve(PUBLIC_DIR, `.${requestedPath}`);
if (filePath !== PUBLIC_DIR && !filePath.startsWith(`${PUBLIC_DIR}${path.sep}`)) {
return null;
}
return filePath;
}
async function serveStatic(request, response) {
if (request.method !== 'GET' && request.method !== 'HEAD') {
response.writeHead(405, { Allow: 'GET, HEAD' });
response.end('Method not allowed');
return;
}
const filePath = resolveStaticPath(request.url);
if (!filePath) {
response.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
response.end('Bad request');
return;
}
try {
const stat = await fsp.stat(filePath);
if (!stat.isFile()) {
response.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
response.end('Not found');
return;
}
const contentType = mimeTypes.get(path.extname(filePath)) || 'application/octet-stream';
response.writeHead(200, {
'Content-Type': contentType,
'Content-Length': stat.size,
'Cache-Control': 'no-store'
});
if (request.method === 'HEAD') {
response.end();
return;
}
fs.createReadStream(filePath).pipe(response);
} catch (error) {
if (error.code === 'ENOENT') {
response.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
response.end('Not found');
return;
}
console.error(error);
response.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
response.end('Internal server error');
}
}
async function readAppendedBytes(from, to) {
if (to <= from) {
return '';
}
const stream = fs.createReadStream(LOG_FILE, {
encoding: 'utf8',
start: from,
end: to - 1
});
let text = '';
for await (const chunk of stream) {
text += chunk;
}
return text;
}
function queueFileChange(wss, current) {
changeQueue = changeQueue
.then(async () => {
if (current.size < offset) {
offset = current.size;
broadcast(wss, {
type: 'truncate',
message: 'File was truncated or rotated; stream reset.'
});
return;
}
if (current.size === offset) {
return;
}
const text = await readAppendedBytes(offset, current.size);
offset = current.size;
if (text) {
broadcast(wss, { type: 'append', text });
}
})
.catch((error) => {
console.error(error);
broadcast(wss, { type: 'error', message: error.message });
});
}
async function sendInitialState(socket) {
const stat = await fsp.stat(LOG_FILE);
const lines = await readLastLines(LOG_FILE, TAIL_LINES);
sendJson(socket, {
type: 'hello',
file: LOG_FILE,
size: stat.size,
tailLines: TAIL_LINES
});
sendJson(socket, { type: 'snapshot', lines });
}
async function main() {
const stat = await assertLogFile(LOG_FILE);
offset = stat.size;
const server = http.createServer(serveStatic);
const wss = new WebSocket.Server({ noServer: true });
wss.on('connection', (socket) => {
sendInitialState(socket).catch((error) => {
sendJson(socket, { type: 'error', message: error.message });
socket.close(1011, 'Initial read failed');
});
});
server.on('upgrade', (request, socket, head) => {
const parsed = new URL(request.url, `http://localhost:${PORT}`);
if (parsed.pathname !== '/stream') {
socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
socket.destroy();
return;
}
wss.handleUpgrade(request, socket, head, (websocket) => {
wss.emit('connection', websocket, request);
});
});
fs.watchFile(LOG_FILE, { interval: WATCH_INTERVAL_MS }, (current) => {
queueFileChange(wss, current);
});
server.listen(PORT, HOST, () => {
console.log(`Log streamer running at http://${HOST}:${PORT}`);
console.log(`Streaming ${LOG_FILE}`);
});
let shuttingDown = false;
function shutdown(signal) {
if (shuttingDown) {
console.error(`${signal} received again; forcing shutdown.`);
process.exit(1);
}
shuttingDown = true;
console.log(`${signal} received; shutting down.`);
fs.unwatchFile(LOG_FILE);
const shutdownTimer = setTimeout(() => {
console.error(`Shutdown exceeded ${SHUTDOWN_TIMEOUT_MS}ms; forcing exit.`);
process.exit(1);
}, SHUTDOWN_TIMEOUT_MS);
shutdownTimer.unref();
const terminateTimer = setTimeout(() => {
for (const client of wss.clients) {
if (client.readyState !== WebSocket.CLOSED) {
client.terminate();
}
}
}, Math.min(500, SHUTDOWN_TIMEOUT_MS));
terminateTimer.unref();
for (const client of wss.clients) {
client.close(1001, 'Server shutting down');
}
wss.close();
server.close((error) => {
clearTimeout(shutdownTimer);
if (error) {
console.error(error);
process.exit(1);
}
process.exit(0);
});
}
process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGTERM', () => shutdown('SIGTERM'));
}
main().catch((error) => {
console.error(error.message);
process.exit(1);
});