Initial log streamer app
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
*.log
|
||||
!sample.log
|
||||
.DS_Store
|
||||
Generated
+36
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "log-streamer",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "log-streamer",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"ws": "^8.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
|
||||
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "log-streamer",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Small WebSocket log file streamer.",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "node server.js sample.log"
|
||||
},
|
||||
"dependencies": {
|
||||
"ws": "^8.18.0"
|
||||
}
|
||||
}
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
'use strict';
|
||||
|
||||
const MAX_VISIBLE_LINES = 5000;
|
||||
const RECONNECT_DELAY_MS = 1000;
|
||||
|
||||
const output = document.querySelector('#logOutput');
|
||||
const statusBadge = document.querySelector('#status');
|
||||
const filePath = document.querySelector('#filePath');
|
||||
const lineCount = document.querySelector('#lineCount');
|
||||
const pendingCount = document.querySelector('#pendingCount');
|
||||
const autoScroll = document.querySelector('#autoScroll');
|
||||
const pauseButton = document.querySelector('#pauseButton');
|
||||
const clearButton = document.querySelector('#clearButton');
|
||||
|
||||
let socket;
|
||||
let reconnectTimer;
|
||||
let paused = false;
|
||||
let visibleLines = [];
|
||||
let pendingChunks = [];
|
||||
|
||||
function setStatus(state, label) {
|
||||
statusBadge.dataset.state = state;
|
||||
statusBadge.textContent = label;
|
||||
}
|
||||
|
||||
function isNearBottom() {
|
||||
const distance = output.scrollHeight - output.scrollTop - output.clientHeight;
|
||||
return distance < 24;
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
output.scrollTop = output.scrollHeight;
|
||||
}
|
||||
|
||||
function updateCounts() {
|
||||
lineCount.textContent = String(visibleLines.length);
|
||||
|
||||
if (pendingChunks.length === 0) {
|
||||
pendingCount.hidden = true;
|
||||
pendingCount.textContent = '0 buffered';
|
||||
return;
|
||||
}
|
||||
|
||||
pendingCount.hidden = false;
|
||||
pendingCount.textContent = `${pendingChunks.length} buffered`;
|
||||
}
|
||||
|
||||
function appendText(text) {
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldScroll = autoScroll.checked && isNearBottom();
|
||||
const normalized = text.replace(/\r\n/g, '\n');
|
||||
const nextLines = normalized.split('\n');
|
||||
|
||||
if (visibleLines.length === 0) {
|
||||
visibleLines = nextLines;
|
||||
} else {
|
||||
visibleLines[visibleLines.length - 1] += nextLines[0];
|
||||
visibleLines.push(...nextLines.slice(1));
|
||||
}
|
||||
|
||||
if (visibleLines.length > MAX_VISIBLE_LINES) {
|
||||
visibleLines = visibleLines.slice(-MAX_VISIBLE_LINES);
|
||||
}
|
||||
|
||||
output.textContent = visibleLines.join('\n');
|
||||
updateCounts();
|
||||
|
||||
if (shouldScroll) {
|
||||
requestAnimationFrame(scrollToBottom);
|
||||
}
|
||||
}
|
||||
|
||||
function setSnapshot(lines) {
|
||||
visibleLines = Array.isArray(lines) ? lines.slice(-MAX_VISIBLE_LINES) : [];
|
||||
pendingChunks = [];
|
||||
output.textContent = visibleLines.join('\n');
|
||||
updateCounts();
|
||||
|
||||
if (autoScroll.checked) {
|
||||
requestAnimationFrame(scrollToBottom);
|
||||
}
|
||||
}
|
||||
|
||||
function handleStreamText(text) {
|
||||
if (paused) {
|
||||
pendingChunks.push(text);
|
||||
updateCounts();
|
||||
return;
|
||||
}
|
||||
|
||||
appendText(text);
|
||||
}
|
||||
|
||||
function flushPending() {
|
||||
if (pendingChunks.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const chunks = pendingChunks.join('');
|
||||
pendingChunks = [];
|
||||
appendText(chunks);
|
||||
}
|
||||
|
||||
function connect() {
|
||||
clearTimeout(reconnectTimer);
|
||||
setStatus(socket ? 'reconnecting' : 'connecting', socket ? 'Reconnecting' : 'Connecting');
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
socket = new WebSocket(`${protocol}//${window.location.host}/stream`);
|
||||
|
||||
socket.addEventListener('open', () => {
|
||||
setStatus('connected', 'Connected');
|
||||
});
|
||||
|
||||
socket.addEventListener('message', (event) => {
|
||||
let message;
|
||||
|
||||
try {
|
||||
message = JSON.parse(event.data);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'hello') {
|
||||
filePath.textContent = message.file || 'Unknown file';
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'snapshot') {
|
||||
setSnapshot(message.lines);
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'append') {
|
||||
handleStreamText(message.text);
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'truncate') {
|
||||
setSnapshot([]);
|
||||
appendText(`[stream reset] ${message.message}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'error') {
|
||||
setStatus('error', 'Error');
|
||||
appendText(`[stream error] ${message.message}\n`);
|
||||
}
|
||||
});
|
||||
|
||||
socket.addEventListener('close', () => {
|
||||
setStatus('disconnected', 'Disconnected');
|
||||
reconnectTimer = setTimeout(connect, RECONNECT_DELAY_MS);
|
||||
});
|
||||
|
||||
socket.addEventListener('error', () => {
|
||||
setStatus('error', 'Error');
|
||||
});
|
||||
}
|
||||
|
||||
pauseButton.addEventListener('click', () => {
|
||||
paused = !paused;
|
||||
pauseButton.textContent = paused ? 'Resume' : 'Pause';
|
||||
|
||||
if (!paused) {
|
||||
flushPending();
|
||||
}
|
||||
});
|
||||
|
||||
clearButton.addEventListener('click', () => {
|
||||
visibleLines = [];
|
||||
pendingChunks = [];
|
||||
output.textContent = '';
|
||||
updateCounts();
|
||||
});
|
||||
|
||||
autoScroll.addEventListener('change', () => {
|
||||
if (autoScroll.checked) {
|
||||
scrollToBottom();
|
||||
}
|
||||
});
|
||||
|
||||
connect();
|
||||
@@ -0,0 +1,38 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Log Streamer</title>
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell">
|
||||
<section class="toolbar" aria-label="Log stream controls">
|
||||
<div class="identity">
|
||||
<h1>Log Streamer</h1>
|
||||
<div id="filePath" class="file-path">Waiting for file...</div>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<span id="status" class="status" data-state="connecting">Connecting</span>
|
||||
<label class="toggle">
|
||||
<input id="autoScroll" type="checkbox" checked>
|
||||
<span>Auto-scroll</span>
|
||||
</label>
|
||||
<button id="pauseButton" type="button">Pause</button>
|
||||
<button id="clearButton" type="button">Clear</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="meta" aria-live="polite">
|
||||
<span><strong id="lineCount">0</strong> lines visible</span>
|
||||
<span id="pendingCount" hidden>0 buffered</span>
|
||||
</section>
|
||||
|
||||
<pre id="logOutput" class="log-output" aria-label="Live log output"></pre>
|
||||
</main>
|
||||
|
||||
<script src="/app.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,215 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--background: #101112;
|
||||
--surface: #17191b;
|
||||
--surface-2: #202326;
|
||||
--border: #33383d;
|
||||
--text: #eceff1;
|
||||
--muted: #a7adb4;
|
||||
--muted-2: #727982;
|
||||
--accent: #13c07b;
|
||||
--warning: #d59c36;
|
||||
--danger: #e15e5e;
|
||||
--control: #24282c;
|
||||
--control-hover: #2d3237;
|
||||
--mono: "SFMono-Regular", "Cascadia Code", "Liberation Mono", Menlo, Consolas, monospace;
|
||||
--sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Helvetica Neue", sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--background);
|
||||
color: var(--text);
|
||||
font-family: var(--sans);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
button,
|
||||
input {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.shell {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto minmax(0, 1fr);
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
min-height: 72px;
|
||||
padding: 14px 18px;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.identity {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 5px;
|
||||
font-size: 17px;
|
||||
font-weight: 650;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.file-path {
|
||||
max-width: min(74vw, 920px);
|
||||
overflow: hidden;
|
||||
color: var(--muted);
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 32px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 7px;
|
||||
color: var(--muted);
|
||||
background: var(--surface-2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status::before {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
margin-right: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--muted-2);
|
||||
content: "";
|
||||
}
|
||||
|
||||
.status[data-state="connected"]::before {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.status[data-state="reconnecting"]::before,
|
||||
.status[data-state="connecting"]::before {
|
||||
background: var(--warning);
|
||||
}
|
||||
|
||||
.status[data-state="error"]::before,
|
||||
.status[data-state="disconnected"]::before {
|
||||
background: var(--danger);
|
||||
}
|
||||
|
||||
.toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
min-height: 32px;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.toggle input {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
button {
|
||||
min-height: 32px;
|
||||
padding: 0 11px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 7px;
|
||||
color: var(--text);
|
||||
background: var(--control);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: var(--control-hover);
|
||||
}
|
||||
|
||||
button:focus-visible,
|
||||
input:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
min-height: 38px;
|
||||
padding: 0 18px;
|
||||
color: var(--muted);
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.meta strong {
|
||||
color: var(--text);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.log-output {
|
||||
min-height: 0;
|
||||
margin: 0;
|
||||
padding: 16px 18px 28px;
|
||||
overflow: auto;
|
||||
background: #0b0c0d;
|
||||
color: #d7dbdf;
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
tab-size: 2;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.log-output:empty::before {
|
||||
color: var(--muted-2);
|
||||
content: "No log lines yet.";
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.toolbar {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.file-path {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.controls {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.status,
|
||||
button {
|
||||
min-height: 34px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
2026-06-24T15:28:00.000Z sample server booted
|
||||
2026-06-24T15:28:01.000Z waiting for log lines
|
||||
@@ -0,0 +1,290 @@
|
||||
'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 TAIL_LINES = Number.parseInt(process.env.TAIL_LINES || '200', 10);
|
||||
const WATCH_INTERVAL_MS = Number.parseInt(process.env.WATCH_INTERVAL_MS || '500', 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, () => {
|
||||
console.log(`Log streamer running at http://localhost:${PORT}`);
|
||||
console.log(`Streaming ${LOG_FILE}`);
|
||||
});
|
||||
|
||||
function shutdown() {
|
||||
fs.unwatchFile(LOG_FILE);
|
||||
wss.close();
|
||||
server.close(() => process.exit(0));
|
||||
}
|
||||
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error.message);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user