Skip to content

Instantly share code, notes, and snippets.

@ArunSelvan25
Last active June 19, 2025 06:44
Show Gist options
  • Save ArunSelvan25/de914c1e1b4b8ac9d7e6ad69a60ad70d to your computer and use it in GitHub Desktop.
Save ArunSelvan25/de914c1e1b4b8ac9d7e6ad69a60ad70d to your computer and use it in GitHub Desktop.
Minimal WebSocket Tail-F Server for Laravel Logs
import { WebSocketServer } from 'ws';
import fs from 'fs';
import path from 'path';
import readline from 'readline';
/*****************************************************************************************
* Minimal WebSocket‑based “tail‑f” server for Laravel log files
* ------------------------------------------------------------------
* • One WebSocket per browser tab.
* • Client sends { event:'getLumenLog', data:{ date:'DD‑MM‑YYYY' } }.
* • Server:
* 1. Streams the entire file for that date.
* 2. Watches the file and pushes any new lines to the same socket.
* 3. Cleans up watcher on socket close or when the client switches dates.
*****************************************************************************************/
const logDir = path.join(process.cwd(), 'storage', 'logs'); // Laravel log folder
const wss = new WebSocketServer({ port: 3001 }); // WS server
/* Map<ws, { filePath, watcher, lastSize }> keeps per‑client watch state */
const clientWatchers = new Map();
/** Convert DD‑MM‑YYYY → YYYY‑MM‑DD (local timezone safe) */
function parseDateFromDDMMYYYY(dateStr) {
const [day, month, year] = dateStr.split('-').map(Number);
const localDate = new Date(year, month - 1, day);
// Format to YYYY-MM-DD in local time
const yyyy = localDate.getFullYear();
const mm = String(localDate.getMonth() + 1).padStart(2, '0');
const dd = String(localDate.getDate()).padStart(2, '0');
const formattedDate = `${yyyy}-${mm}-${dd}`;
return formattedDate;
}
/** Build full log‑file path from DD‑MM‑YYYY */
function getLogFilePathFromDate(dateStr) {
const date = parseDateFromDDMMYYYY(dateStr);
return path.join(logDir, `laravel-${date}.log`);
}
/*───────────────────────────────────────────────────────────────────────────*
* CORE: start / stop watching a log file for ONE WS client *
*───────────────────────────────────────────────────────────────────────────*/
/**
* Begin streaming `filePath` to a specific WebSocket (`ws`).
* 1. Send the whole file once.
* 2. Watch for new bytes and push each appended line in real‑time.
*/
function startWatchingFile(ws, filePath) {
let lastSize = 0;
// 1. Read and send full file content initially
fs.readFile(filePath, 'utf-8', (err, data) => {
if (!err && data) {
const lines = data.trimEnd().split("\n").reverse();
for (const line of lines) {
if (line.trim()) {
const payload = JSON.stringify({ event: 'log', data: line });
if (ws.readyState === ws.OPEN) ws.send(payload);
}
}
lastSize = Buffer.byteLength(data); // Set lastSize to current file size
}
// 2. Start watching for changes after initial send
const watcher = fs.watch(filePath, (eventType) => {
if (eventType !== 'change') return;
fs.stat(filePath, (err, stats) => {
if (err || stats.size <= lastSize) return;
const start = lastSize;
const end = stats.size - 1;
lastSize = stats.size;
const stream = fs.createReadStream(filePath, { start, end });
/* Stream only the fresh bytes */
const rl = readline.createInterface({ input: stream });
rl.on('line', (line) => {
if (line.trim()) {
const payload = JSON.stringify({ event: 'log', data: line });
if (ws.readyState === ws.OPEN) ws.send(payload);
}
});
});
});
/* Save watcher so we can close it later */
clientWatchers.set(ws, { filePath, watcher, lastSize });
});
}
/** Stop and clean up the watcher associated with a WebSocket client */
function stopWatchingFile(ws) {
const info = clientWatchers.get(ws);
if (info?.watcher) {
info.watcher.close(); // stop fs.watch
}
clientWatchers.delete(ws); // remove from map
}
/*───────────────────────────────────────────────────────────────────────────*
* WEBSOCKET EVENT HANDLERS *
*───────────────────────────────────────────────────────────────────────────*/
wss.on('connection', (ws) => {
ws.on('close', () => {
stopWatchingFile(ws);
});
ws.on('message', async (message) => {
try {
const data = JSON.parse(message.toString());
if (data.event === 'getLumenLog') {
const dateStr = data?.data?.date;
const filePath = getLogFilePathFromDate(dateStr);
if (!filePath) return;
/* Ignore if client already watching this file */
const existingWatcher = clientWatchers.get(ws);
if (existingWatcher?.filePath === filePath) return;
/* Switch file: clean old watcher, ensure new file exists, start watcher */
stopWatchingFile(ws);
try {
await fs.promises.access(filePath, fs.constants.F_OK);
} catch {
// File does not exist — inform client and return
if (ws.readyState === ws.OPEN) {
ws.send(JSON.stringify({
event: 'error',
message: `Log file not found for date ${dateStr}`
}));
}
return;
}
startWatchingFile(ws, filePath);
}
} catch (err) {
}
});
});
@ArunSelvan25
Copy link
Author

🧩 A lightweight Node.js WebSocket server that streams and live-tails Laravel log files (storage/logs/laravel-YYYY-MM-DD.log) to clients in real-time. Supports per-tab watchers, file switching, and automatic cleanup.

🔧 Usage: Client sends { event: "getLumenLog", data: { date: "DD-MM-YYYY" } }, server streams initial file + watches for new lines.

💡 Built for integration with Angular or any frontend via native WebSocket AP

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment