- Updated README.md to reflect changes in load testing commands and added usage examples for Bash scripts. - Converted PowerShell load testing scripts to Bash equivalents for better cross-platform compatibility. - Added new Bash scripts: run-loadtests.sh, summarize-artillery.sh, and summarize-k6.sh to mirror existing PowerShell functionality. - Updated admin-api.js and ws-chat.yml for consistency and improved performance metrics reporting. - Enhanced error handling and output formatting in summary scripts for better readability.
520 lines
17 KiB
TypeScript
520 lines
17 KiB
TypeScript
import "./style.css";
|
|
|
|
type HlsClass = (typeof import("hls.js"))["default"];
|
|
|
|
declare global {
|
|
interface Window {
|
|
switchRoom: (room: string) => void;
|
|
sendMessage: () => void;
|
|
togglePrivateDM: () => void;
|
|
toggleChatPanel: () => void;
|
|
}
|
|
}
|
|
|
|
let hls: InstanceType<HlsClass> | null = null;
|
|
let ws: WebSocket | null = null;
|
|
let currentRoom = "main";
|
|
let username = "viewer" + Math.floor(Math.random() * 1000);
|
|
let authenticated = false;
|
|
let chatVisible = true;
|
|
let typingUser = "";
|
|
let lastTypingSent = 0;
|
|
let typingTimeout: number | undefined;
|
|
let flashTimeout: number | undefined;
|
|
let flashFadeTimeout: number | undefined;
|
|
const onlineUsers = new Set<string>();
|
|
|
|
// Player error recovery state (STREAMING.md contract)
|
|
let playerErrorCount = 0;
|
|
const PLAYER_MAX_RETRIES = 5;
|
|
const PLAYER_BASE_DELAY_MS = 2000;
|
|
|
|
export function resetViewerStateForTests() {
|
|
hls = null;
|
|
ws = null;
|
|
currentRoom = "main";
|
|
username = "viewer";
|
|
authenticated = false;
|
|
chatVisible = true;
|
|
typingUser = "";
|
|
lastTypingSent = 0;
|
|
onlineUsers.clear();
|
|
playerErrorCount = 0;
|
|
}
|
|
|
|
export function resolveWebSocketBaseUrl() {
|
|
const configuredUrl = import.meta.env.VITE_PUBLIC_WS_URL?.trim();
|
|
|
|
if (configuredUrl) {
|
|
return configuredUrl;
|
|
}
|
|
|
|
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
|
return `${protocol}//${window.location.host}/ws`;
|
|
}
|
|
|
|
export function resolveManifestUrl() {
|
|
const configuredUrl = import.meta.env.VITE_PUBLIC_HLS_URL?.trim();
|
|
const cacheBust = `stream=${Date.now()}`;
|
|
const sameOriginManifestUrl = new URL("/hls/playlist.m3u8", window.location.origin);
|
|
sameOriginManifestUrl.searchParams.set("stream", cacheBust);
|
|
|
|
const allowCrossOriginManifest = import.meta.env.VITE_PUBLIC_HLS_CROSS_ORIGIN?.trim().toLowerCase() === "true";
|
|
|
|
if (configuredUrl) {
|
|
const manifestUrl = new URL(configuredUrl);
|
|
manifestUrl.searchParams.set("stream", cacheBust);
|
|
|
|
if (!allowCrossOriginManifest && manifestUrl.origin !== window.location.origin) {
|
|
return sameOriginManifestUrl.toString();
|
|
}
|
|
|
|
return manifestUrl.toString();
|
|
}
|
|
|
|
return sameOriginManifestUrl.toString();
|
|
}
|
|
|
|
function connectWebSocket(room: string) {
|
|
// Clear previous messages so reconnects render a fresh history instead of
|
|
// appending duplicates on top of the old log.
|
|
clearMessages();
|
|
|
|
// Detach the old socket's close handler before closing it. Otherwise an
|
|
// intentional reconnect (login/logout/room switch/session refresh) would
|
|
// have the replaced socket schedule yet another reconnect, and each new
|
|
// connection re-pulls the room's history — filling the panel with old
|
|
// messages on a loop.
|
|
if (ws) {
|
|
ws.onclose = null;
|
|
ws.close();
|
|
}
|
|
|
|
const socketUrl = new URL(resolveWebSocketBaseUrl());
|
|
socketUrl.searchParams.set("username", username);
|
|
socketUrl.searchParams.set("room", room);
|
|
const socket = new WebSocket(socketUrl);
|
|
ws = socket;
|
|
|
|
socket.onopen = () => {
|
|
console.log(`Connected to chat room: ${room}`);
|
|
addMessage("system", "Connected to " + room);
|
|
};
|
|
|
|
socket.onmessage = (event) => {
|
|
const msg = JSON.parse(event.data);
|
|
if (msg.type === "typing") {
|
|
showTyping(msg.username);
|
|
return;
|
|
}
|
|
hideTyping(msg.username);
|
|
|
|
// Presence join/leave announcements become transient flashes, not chat rows.
|
|
if (msg.type === "system" && msg.username === "system") {
|
|
const joined = msg.text?.match(/^(\S+) joined /);
|
|
const left = msg.text?.match(/^(\S+) left /);
|
|
if (joined) {
|
|
onlineUsers.add(joined[1]);
|
|
renderPresence();
|
|
showFlash(`${joined[1]} joined`);
|
|
return;
|
|
} else if (left) {
|
|
onlineUsers.delete(left[1]);
|
|
renderPresence();
|
|
showFlash(`${left[1]} left`);
|
|
return;
|
|
}
|
|
}
|
|
|
|
addMessage(msg.type || "public", msg.text, msg.username, msg.timestamp);
|
|
};
|
|
|
|
socket.onclose = () => {
|
|
// Ignore close events from sockets that were intentionally replaced.
|
|
if (ws !== socket) return;
|
|
ws = null;
|
|
console.log("Chat disconnected. Reconnecting in 3s...");
|
|
setTimeout(() => connectWebSocket(room), 3000);
|
|
};
|
|
}
|
|
|
|
function clearMessages() {
|
|
const messages = document.getElementById("messages");
|
|
if (messages) messages.innerHTML = "";
|
|
}
|
|
|
|
function addMessage(type: string, text: string, sender = "", timestamp = new Date()) {
|
|
const messages = document.getElementById("messages");
|
|
if (!messages) return;
|
|
const div = document.createElement("div");
|
|
div.className = "message-row";
|
|
|
|
if (type === "system") {
|
|
div.classList.add("message-system-row");
|
|
div.innerHTML = `<div class="message-system">${text}</div>`;
|
|
} else {
|
|
div.classList.add(sender === username ? "message-self-row" : "message-other-row");
|
|
div.innerHTML = `
|
|
<article class="message-bubble ${sender === username ? "message-self" : "message-other"}">
|
|
${sender && sender !== username ? `<div class="message-sender">${sender}</div>` : ""}
|
|
<div class="message-text">${text}</div>
|
|
<time class="message-time">${new Date(timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}</time>
|
|
</article>
|
|
`;
|
|
}
|
|
|
|
messages.appendChild(div);
|
|
messages.scrollTop = messages.scrollHeight;
|
|
}
|
|
|
|
function showFlash(text: string) {
|
|
const el = document.getElementById("chat-flash");
|
|
if (!el) return;
|
|
el.textContent = text;
|
|
el.hidden = false;
|
|
el.classList.remove("fade-out");
|
|
window.clearTimeout(flashTimeout);
|
|
window.clearTimeout(flashFadeTimeout);
|
|
flashTimeout = window.setTimeout(() => {
|
|
el.classList.add("fade-out");
|
|
flashFadeTimeout = window.setTimeout(() => {
|
|
el.hidden = true;
|
|
el.classList.remove("fade-out");
|
|
}, 500);
|
|
}, 3000);
|
|
}
|
|
|
|
function renderPresence() {
|
|
const list = document.getElementById("presence-list");
|
|
if (!list) return;
|
|
const sorted = [...onlineUsers].sort();
|
|
list.innerHTML = sorted
|
|
.map((u) => `<span class="presence-dot" title="${u}"><span class="presence-indicator"></span>${u}</span>`)
|
|
.join("");
|
|
}
|
|
|
|
async function refreshSchedule() {
|
|
try {
|
|
const apiUrl = import.meta.env.VITE_PUBLIC_APP_URL?.trim() || window.location.origin;
|
|
const res = await fetch(`${apiUrl}/api/schedule`);
|
|
if (!res.ok) return;
|
|
const data = await res.json();
|
|
const entries = data.entries as Array<{ title: string; status: string }>;
|
|
if (entries.length > 0) {
|
|
const np = document.getElementById("np-title");
|
|
const nn = document.getElementById("np-next");
|
|
if (np) np.textContent = `Now: ${entries[0].title}`;
|
|
if (nn && entries.length > 1) {
|
|
nn.textContent = `Up next: ${entries[1].title}`;
|
|
}
|
|
}
|
|
} catch {
|
|
// Schedule unavailable — non-critical
|
|
}
|
|
}
|
|
|
|
export async function initPlayerAndChat(loadHlsModule: () => Promise<{ default: HlsClass }> = () => import("hls.js")) {
|
|
const video = document.getElementById("player") as HTMLVideoElement;
|
|
const manifestUrl = resolveManifestUrl();
|
|
|
|
if (video.canPlayType("application/vnd.apple.mpegurl")) {
|
|
video.src = manifestUrl;
|
|
video.addEventListener("loadedmetadata", () => video.play());
|
|
} else {
|
|
try {
|
|
const { default: Hls } = await loadHlsModule();
|
|
if (Hls.isSupported()) {
|
|
hls = new Hls({
|
|
liveDurationInfinity: true,
|
|
liveSyncDurationCount: 3,
|
|
});
|
|
hls.loadSource(manifestUrl);
|
|
hls.attachMedia(video);
|
|
hls.on(Hls.Events.MANIFEST_PARSED, () => video.play());
|
|
|
|
// Player error recovery (STREAMING.md contract)
|
|
hls.on(Hls.Events.ERROR, (_event, data) => {
|
|
if (!data.fatal) return;
|
|
|
|
playerErrorCount++;
|
|
|
|
switch (data.type) {
|
|
case Hls.ErrorTypes.NETWORK_ERROR:
|
|
if (playerErrorCount <= PLAYER_MAX_RETRIES) {
|
|
const delay = PLAYER_BASE_DELAY_MS * Math.pow(2, playerErrorCount - 1);
|
|
console.warn(
|
|
`HLS network error (attempt ${playerErrorCount}/${PLAYER_MAX_RETRIES}), retrying in ${delay}ms...`,
|
|
);
|
|
showReconnecting("Reconnecting...");
|
|
setTimeout(() => {
|
|
hls?.startLoad();
|
|
}, delay);
|
|
} else {
|
|
console.error("HLS network error: max retries exhausted");
|
|
showReconnecting("Stream temporarily unavailable");
|
|
}
|
|
break;
|
|
|
|
case Hls.ErrorTypes.MEDIA_ERROR:
|
|
if (playerErrorCount <= 1) {
|
|
console.warn("HLS media error, attempting recovery...");
|
|
showReconnecting("Recovering...");
|
|
hls!.recoverMediaError();
|
|
} else {
|
|
console.error("HLS media error: recovery failed");
|
|
hls!.destroy();
|
|
showReconnecting("Stream temporarily unavailable");
|
|
}
|
|
break;
|
|
|
|
default:
|
|
console.error("HLS fatal error:", data.type, data.details);
|
|
hls!.destroy();
|
|
showReconnecting("Stream temporarily unavailable");
|
|
break;
|
|
}
|
|
});
|
|
|
|
// Reset error count on successful manifest parse
|
|
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
|
playerErrorCount = 0;
|
|
hideReconnecting();
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to load HLS player", error);
|
|
addMessage("system", "Unable to initialize video player.");
|
|
}
|
|
}
|
|
|
|
// Resolve the user session first so we connect once with the correct username
|
|
// (avoids pushing chat history twice on initial load).
|
|
await sessionReady;
|
|
connectWebSocket("main");
|
|
|
|
// Start schedule polling
|
|
refreshSchedule();
|
|
setInterval(refreshSchedule, 30000);
|
|
|
|
console.log("Linear Void Frontend initialized - HLS Player + Chat Sidebar ready");
|
|
}
|
|
|
|
export function showReconnecting(message = "Reconnecting...") {
|
|
const overlay = document.getElementById("reconnecting-overlay");
|
|
const text = document.getElementById("reconnecting-text");
|
|
if (overlay) {
|
|
overlay.classList.remove("hidden");
|
|
}
|
|
if (text) {
|
|
text.textContent = message;
|
|
}
|
|
}
|
|
|
|
export function hideReconnecting() {
|
|
const overlay = document.getElementById("reconnecting-overlay");
|
|
if (overlay) {
|
|
overlay.classList.add("hidden");
|
|
}
|
|
}
|
|
|
|
function toggleChatPanel() {
|
|
const shell = document.querySelector(".viewer-shell");
|
|
const toggle = document.getElementById("chat-toggle");
|
|
if (!shell || !toggle) return;
|
|
|
|
chatVisible = !chatVisible;
|
|
shell.classList.toggle("chat-collapsed", !chatVisible);
|
|
toggle.textContent = chatVisible ? "Hide Chat" : "Show Chat";
|
|
toggle.setAttribute("aria-expanded", String(chatVisible));
|
|
}
|
|
|
|
function sendMessage() {
|
|
const input = document.getElementById("message-input") as HTMLInputElement;
|
|
const text = input.value.trim();
|
|
if (!text || !ws) return;
|
|
|
|
const msg = {
|
|
room: currentRoom,
|
|
username: username,
|
|
text: text,
|
|
type: currentRoom.startsWith("dm:") ? "private" : "public",
|
|
};
|
|
|
|
ws.send(JSON.stringify(msg));
|
|
input.value = "";
|
|
}
|
|
|
|
function switchRoom(room: string) {
|
|
currentRoom = room;
|
|
document.getElementById("room-name")!.textContent =
|
|
room === "main" ? "Main Channel" : room.charAt(0).toUpperCase() + room.slice(1) + " Room";
|
|
connectWebSocket(room);
|
|
}
|
|
|
|
function togglePrivateDM() {
|
|
const target = prompt("Enter username to start private chat with:");
|
|
if (target && target !== username) {
|
|
const dmRoom = `dm:${[username, target].sort().join("-")}`;
|
|
switchRoom(dmRoom);
|
|
}
|
|
}
|
|
|
|
window.switchRoom = switchRoom;
|
|
window.sendMessage = sendMessage;
|
|
window.togglePrivateDM = togglePrivateDM;
|
|
window.toggleChatPanel = toggleChatPanel;
|
|
|
|
function openAuthModal() {
|
|
const panel = document.getElementById("auth-panel");
|
|
if (panel) panel.hidden = false;
|
|
}
|
|
|
|
function closeAuthModal() {
|
|
const panel = document.getElementById("auth-panel");
|
|
if (panel) panel.hidden = true;
|
|
}
|
|
|
|
function updateAuthUI() {
|
|
const label = document.getElementById("chat-user-label");
|
|
const toggle = document.getElementById("auth-toggle-btn");
|
|
const logoutBtn = document.getElementById("auth-logout-btn");
|
|
const panel = document.getElementById("auth-panel");
|
|
if (label) {
|
|
label.textContent = authenticated ? username : "";
|
|
label.hidden = !authenticated;
|
|
}
|
|
if (toggle) toggle.hidden = authenticated;
|
|
if (logoutBtn) logoutBtn.hidden = !authenticated;
|
|
if (panel) panel.hidden = true;
|
|
}
|
|
|
|
let sessionReady: Promise<void> = Promise.resolve();
|
|
|
|
function refreshUserSession(): void {
|
|
sessionReady = (async () => {
|
|
try {
|
|
const apiUrl = import.meta.env.VITE_PUBLIC_APP_URL?.trim() || window.location.origin;
|
|
const res = await fetch(`${apiUrl}/api/auth/session`, { credentials: "include" });
|
|
if (!res.ok) return;
|
|
const data = await res.json();
|
|
if (data.authenticated && data.username) {
|
|
authenticated = true;
|
|
username = data.username;
|
|
} else {
|
|
authenticated = false;
|
|
}
|
|
updateAuthUI();
|
|
} catch {
|
|
/* non-critical */
|
|
}
|
|
})();
|
|
}
|
|
|
|
async function submitAuth(path: string) {
|
|
const u = (document.getElementById("auth-username") as HTMLInputElement).value.trim();
|
|
const p = (document.getElementById("auth-password") as HTMLInputElement).value;
|
|
const status = document.getElementById("auth-status");
|
|
if (!u || !p) {
|
|
if (status) {
|
|
status.textContent = "Username and password required.";
|
|
status.className = "status-line status-error";
|
|
}
|
|
return;
|
|
}
|
|
try {
|
|
const apiUrl = import.meta.env.VITE_PUBLIC_APP_URL?.trim() || window.location.origin;
|
|
const res = await fetch(`${apiUrl}${path}`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ username: u, password: p }),
|
|
});
|
|
if (!res.ok) {
|
|
const text = (await res.text()).trim();
|
|
throw new Error(text || `HTTP ${res.status}`);
|
|
}
|
|
const data = await res.json();
|
|
username = data.username || u;
|
|
authenticated = true;
|
|
updateAuthUI();
|
|
if (status) {
|
|
status.textContent = `Signed in as ${username}.`;
|
|
status.className = "status-line";
|
|
}
|
|
connectWebSocket(currentRoom);
|
|
} catch (err) {
|
|
if (status) {
|
|
status.textContent = `Failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
status.className = "status-line status-error";
|
|
}
|
|
}
|
|
}
|
|
|
|
function login() {
|
|
void submitAuth("/api/auth/login");
|
|
}
|
|
|
|
function register() {
|
|
void submitAuth("/api/auth/register");
|
|
}
|
|
|
|
async function logout() {
|
|
try {
|
|
const apiUrl = import.meta.env.VITE_PUBLIC_APP_URL?.trim() || window.location.origin;
|
|
await fetch(`${apiUrl}/api/auth/logout`, { method: "POST", credentials: "include" });
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
authenticated = false;
|
|
username = "viewer" + Math.floor(Math.random() * 1000);
|
|
updateAuthUI();
|
|
connectWebSocket(currentRoom);
|
|
}
|
|
|
|
function showTyping(name: string) {
|
|
if (name === username) return;
|
|
const el = document.getElementById("typing-indicator");
|
|
if (!el) return;
|
|
typingUser = name;
|
|
el.textContent = `${name} is typing...`;
|
|
el.hidden = false;
|
|
window.clearTimeout(typingTimeout);
|
|
typingTimeout = window.setTimeout(() => {
|
|
typingUser = "";
|
|
el.hidden = true;
|
|
el.textContent = "";
|
|
}, 3000);
|
|
}
|
|
|
|
function hideTyping(name: string) {
|
|
if (name !== typingUser) return;
|
|
const el = document.getElementById("typing-indicator");
|
|
typingUser = "";
|
|
window.clearTimeout(typingTimeout);
|
|
if (el) {
|
|
el.hidden = true;
|
|
el.textContent = "";
|
|
}
|
|
}
|
|
|
|
function sendTyping() {
|
|
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
const now = Date.now();
|
|
if (now - lastTypingSent < 2000) return;
|
|
lastTypingSent = now;
|
|
ws.send(JSON.stringify({ room: currentRoom, username, text: "", type: "typing" }));
|
|
}
|
|
|
|
document.addEventListener("DOMContentLoaded", () => {
|
|
void initPlayerAndChat();
|
|
refreshUserSession();
|
|
document.getElementById("auth-toggle-btn")?.addEventListener("click", () => {
|
|
if (!authenticated) openAuthModal();
|
|
});
|
|
document.getElementById("auth-close-btn")?.addEventListener("click", closeAuthModal);
|
|
document.getElementById("auth-backdrop")?.addEventListener("click", closeAuthModal);
|
|
document.getElementById("auth-login-btn")?.addEventListener("click", login);
|
|
document.getElementById("auth-register-btn")?.addEventListener("click", register);
|
|
document.getElementById("auth-logout-btn")?.addEventListener("click", logout);
|
|
document.getElementById("message-input")?.addEventListener("input", sendTyping);
|
|
});
|