first commit
Some checks failed
Code quality checks / biome (push) Failing after 13s

This commit is contained in:
creations 2025-06-08 17:17:18 -04:00
commit ae3224c18b
Signed by: creations
GPG key ID: 8F553AA4320FC711
30 changed files with 2455 additions and 0 deletions

12
.editorconfig Normal file
View file

@ -0,0 +1,12 @@
# EditorConfig is awesome: https://EditorConfig.org
# top-most EditorConfig file
root = true
[*]
indent_style = tab
indent_size = 4
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true

5
.env.example Normal file
View file

@ -0,0 +1,5 @@
# NODE_ENV=development
HOST=0.0.0.0
PORT=8080
SRS_URL=

View file

@ -0,0 +1,24 @@
name: Code quality checks
on:
push:
pull_request:
jobs:
biome:
runs-on: docker
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Bun
run: |
curl -fsSL https://bun.sh/install | bash
export BUN_INSTALL="$HOME/.bun"
echo "$BUN_INSTALL/bin" >> $GITHUB_PATH
- name: Install Dependencies
run: bun install
- name: Run Biome with verbose output
run: bunx biome ci . --verbose

1
.gitattributes vendored Normal file
View file

@ -0,0 +1 @@
* text=auto eol=lf

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
/node_modules
bun.lock
logs
public/custom
.env

28
LICENSE Normal file
View file

@ -0,0 +1,28 @@
BSD 3-Clause License
Copyright (c) 2025, creations.works
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

50
biome.json Normal file
View file

@ -0,0 +1,50 @@
{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": false
},
"files": {
"ignoreUnknown": true,
"ignore": ["dist", "public/js/flv.min.js", "public/js/flv.min.js.map"]
},
"formatter": {
"enabled": true,
"indentStyle": "tab",
"lineEnding": "lf"
},
"organizeImports": {
"enabled": true
},
"css": {
"formatter": {
"indentStyle": "tab",
"lineEnding": "lf"
}
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"correctness": {
"noUnusedImports": "error",
"noUnusedVariables": "error"
},
"style": {
"useConst": "error",
"noVar": "error"
}
},
"ignore": ["types"]
},
"javascript": {
"formatter": {
"quoteStyle": "double",
"indentStyle": "tab",
"lineEnding": "lf",
"jsxQuoteStyle": "double",
"semicolons": "always"
}
}
}

30
config/index.ts Normal file
View file

@ -0,0 +1,30 @@
import { echo } from "@atums/echo";
const environment: Environment = {
port: Number.parseInt(process.env.PORT || "8080", 10),
host: process.env.HOST || "0.0.0.0",
development:
process.env.NODE_ENV === "development" || process.argv.includes("--dev"),
};
const srsUrl = process.env.SRS_URL;
function verifyRequiredVariables(): void {
const requiredVariables = ["HOST", "PORT", "SRS_URL"];
let hasError = false;
for (const key of requiredVariables) {
const value = process.env[key];
if (value === undefined || value.trim() === "") {
echo.error(`Missing or empty environment variable: ${key}`);
hasError = true;
}
}
if (hasError) {
process.exit(1);
}
}
export { environment, verifyRequiredVariables, srsUrl };

39
logger.json Normal file
View file

@ -0,0 +1,39 @@
{
"directory": "logs",
"level": "debug",
"disableFile": false,
"rotate": true,
"maxFiles": 3,
"console": true,
"consoleColor": true,
"dateFormat": "yyyy-MM-dd HH:mm:ss.SSS",
"timezone": "local",
"silent": false,
"pattern": "{color:gray}{pretty-timestamp}{reset} {color:levelColor}[{level-name}]{reset} {color:gray}({reset}{file-name}:{color:blue}{line}{reset}:{color:blue}{column}{color:gray}){reset} {data}",
"levelColor": {
"debug": "blue",
"info": "green",
"warn": "yellow",
"error": "red",
"fatal": "red"
},
"customPattern": "{color:gray}{pretty-timestamp}{reset} {color:tagColor}[{tag}]{reset} {color:contextColor}({context}){reset} {data}",
"customColors": {
"GET": "green",
"POST": "blue",
"PUT": "yellow",
"DELETE": "red",
"PATCH": "cyan",
"HEAD": "magenta",
"OPTIONS": "white",
"TRACE": "gray"
},
"prettyPrint": true
}

22
package.json Normal file
View file

@ -0,0 +1,22 @@
{
"name": "bun_frontend_template",
"module": "src/index.ts",
"type": "module",
"scripts": {
"start": "bun run src/index.ts",
"dev": "bun run --hot src/index.ts --dev",
"lint": "bunx biome check",
"lint:fix": "bunx biome check --fix",
"cleanup": "rm -rf logs node_modules bun.lockdb"
},
"devDependencies": {
"@types/bun": "latest",
"@biomejs/biome": "latest"
},
"peerDependencies": {
"typescript": "latest"
},
"dependencies": {
"@atums/echo": "latest"
}
}

BIN
public/assets/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

327
public/css/style.css Normal file
View file

@ -0,0 +1,327 @@
:root[data-theme="dark"] {
--bg-primary: #0d1117;
--bg-secondary: #161b22;
--bg-tertiary: #1f2937;
--bg-card: rgba(22, 27, 34, 0.95);
--bg-card-hover: rgba(31, 38, 49, 0.95);
--bg-input: rgba(22, 27, 34, 0.85);
--bg-stat: rgba(31, 38, 49, 0.75);
--border-primary: #30363d;
--border-secondary: #444c56;
--border-accent: #58a6ff;
--text-primary: #c9d1d9;
--text-secondary: #8b949e;
--text-muted: #6e7681;
--text-accent: #58a6ff;
--accent-blue: #58a6ff;
--accent-purple: #a371f7;
--accent-green: #3fb950;
--accent-red: #f85149;
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -1px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.4), 0 4px 6px -2px rgba(0, 0, 0, 0.3);
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.4), 0 10px 10px -5px rgba(0, 0, 0, 0.3);
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
line-height: 1.6;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
header {
text-align: center;
margin-bottom: 2rem;
}
h1 {
font-size: 3rem;
font-weight: 700;
color: var(--accent-blue);
margin-bottom: 1rem;
}
.search-container {
max-width: 400px;
margin: 0 auto;
}
.search-input {
width: 100%;
padding: 0.75rem 1rem;
font-size: 1rem;
border: 2px solid var(--border-primary);
border-radius: 0.5rem;
background: var(--bg-input);
color: var(--text-primary);
backdrop-filter: blur(10px);
transition: all 0.3s ease;
}
.search-input:focus {
outline: none;
border-color: var(--accent-blue);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.search-input::placeholder {
color: var(--text-secondary);
}
.stats {
display: flex;
justify-content: center;
gap: 2rem;
margin-bottom: 2rem;
}
.stat-item {
background: var(--bg-stat);
padding: 1rem 2rem;
border-radius: 0.5rem;
border: 1px solid var(--border-primary);
backdrop-filter: blur(10px);
}
.stat-label {
color: var(--text-secondary);
font-size: 0.875rem;
}
.stat-value {
color: var(--text-accent);
font-size: 1.25rem;
font-weight: 600;
margin-left: 0.5rem;
}
.loading {
text-align: center;
padding: 3rem;
}
.spinner {
width: 40px;
height: 40px;
border: 4px solid var(--border-primary);
border-top: 4px solid var(--accent-blue);
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 1rem;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.error-state {
text-align: center;
padding: 3rem;
}
.error-state p {
color: var(--accent-red);
margin-bottom: 1rem;
}
.retry-btn {
background: var(--accent-blue);
color: var(--text-primary);
border: none;
padding: 0.75rem 1.5rem;
border-radius: 0.5rem;
font-size: 1rem;
cursor: pointer;
transition: all 0.3s ease;
}
.retry-btn:hover {
background: #2563eb;
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
.streams-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1.5rem;
}
.stream-card {
background: var(--bg-card);
border: 1px solid var(--border-primary);
border-radius: 0.75rem;
padding: 1.5rem;
backdrop-filter: blur(15px);
transition: all 0.3s ease;
position: relative;
overflow: hidden;
}
.stream-card:hover {
transform: translateY(-4px);
background: var(--bg-card-hover);
box-shadow: var(--shadow-xl);
border-color: var(--border-accent);
}
.stream-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 1rem;
}
.stream-header-actions {
display: flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
margin-left: 1rem;
}
.watch-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 24px;
background: var(--accent-green);
border: 1px solid rgba(34, 197, 94, 0.3);
border-radius: 0.375rem;
color: white;
text-decoration: none;
transition: all 0.2s ease;
opacity: 0.9;
}
.watch-btn:hover {
opacity: 1;
background: #16a34a;
transform: translateY(-1px);
box-shadow: var(--shadow-sm);
}
.stream-name {
font-size: 1.125rem;
font-weight: 600;
color: var(--text-primary);
margin: 0;
word-break: break-word;
}
.stream-info {
display: grid;
gap: 0.5rem;
margin-bottom: 1rem;
}
.info-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.info-label {
color: var(--text-secondary);
font-size: 0.875rem;
}
.info-value {
color: var(--text-primary);
font-size: 0.875rem;
font-weight: 500;
text-align: right;
word-break: break-all;
}
.stream-url {
background: rgba(59, 130, 246, 0.08);
border: 1px solid rgba(59, 130, 246, 0.2);
border-radius: 0.375rem;
padding: 0.5rem;
font-family: "SF Mono", "Monaco", "Menlo", "Ubuntu Mono", monospace;
font-size: 0.75rem;
color: #93c5fd;
word-break: break-all;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
}
.stream-watch-link {
text-decoration: none;
color: inherit;
text-align: center;
}
.stream-url:hover {
background: rgba(59, 130, 246, 0.12);
border-color: rgba(59, 130, 246, 0.4);
transform: translateY(-1px);
}
.stream-url:active {
transform: translateY(0);
}
.no-results {
text-align: center;
padding: 3rem;
color: var(--text-secondary);
}
@media (max-width: 768px) {
.container {
padding: 1rem;
}
h1 {
font-size: 2rem;
}
.stats {
flex-direction: column;
align-items: center;
gap: 1rem;
}
.streams-grid {
grid-template-columns: 1fr;
}
.stream-header {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.stream-header-actions {
margin-left: 0;
align-self: flex-end;
}
}

346
public/css/watch.css Normal file
View file

@ -0,0 +1,346 @@
.watch-container {
max-width: 1400px;
margin: 0 auto;
padding: 1rem;
}
.watch-header {
margin-bottom: 1.5rem;
}
.header-content {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 1rem;
}
.back-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: var(--bg-card);
border: 1px solid var(--border-primary);
border-radius: 0.5rem;
color: var(--text-primary);
text-decoration: none;
transition: all 0.3s ease;
backdrop-filter: blur(10px);
}
.back-btn:hover {
background: var(--bg-card-hover);
border-color: var(--border-accent);
transform: translateY(-1px);
}
.watch-header h1 {
font-size: 1.75rem;
margin: 0;
text-align: center;
}
.stream-controls {
display: flex;
gap: 0.5rem;
}
.control-btn {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
background: var(--bg-card);
border: 1px solid var(--border-primary);
border-radius: 0.5rem;
color: var(--text-primary);
cursor: pointer;
transition: all 0.3s ease;
backdrop-filter: blur(10px);
}
.control-btn:hover {
background: var(--bg-card-hover);
border-color: var(--border-accent);
transform: translateY(-1px);
}
.video-container {
position: relative;
background: var(--bg-card);
overflow: hidden;
margin-bottom: 1rem;
aspect-ratio: 16 / 9;
padding: 0;
}
.video-player {
width: 100%;
height: 100%;
object-fit: contain;
background: #000;
}
.loading-overlay,
.error-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: var(--bg-card);
backdrop-filter: blur(15px);
z-index: 10;
}
.error-content {
text-align: center;
max-width: 400px;
padding: 2rem;
}
.error-content svg {
color: var(--accent-red);
margin-bottom: 1rem;
}
.error-content h3 {
color: var(--accent-red);
margin-bottom: 0.5rem;
}
.error-content p {
color: var(--text-secondary);
margin-bottom: 1.5rem;
}
.stream-status-container {
display: flex;
justify-content: space-between;
align-items: center;
justify-content: flex-end;
margin-bottom: 2rem;
gap: 1rem;
flex-wrap: wrap;
}
.stream-status {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: var(--bg-card);
border: 1px solid var(--border-primary);
border-radius: 9999px;
backdrop-filter: blur(10px);
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.status-text {
font-size: 0.875rem;
font-weight: 500;
color: var(--text-primary);
}
.stream-stats {
display: flex;
gap: 1rem;
padding: 0.75rem 1rem;
background: var(--bg-card);
border: .5px solid var(--border-primary);
border-radius: 1rem;
backdrop-filter: blur(10px);
}
.stat {
display: flex;
flex-direction: column;
align-items: center;
min-width: 60px;
}
.stat-label {
font-size: 0.75rem;
color: var(--text-secondary);
}
.stat-value {
font-size: 0.875rem;
font-weight: 600;
color: var(--text-primary);
}
.stream-info-panel {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
}
.info-section {
background: var(--bg-card);
border: 1px solid var(--border-primary);
border-radius: 1rem;
padding: 1.5rem;
backdrop-filter: blur(15px);
}
.info-section h3 {
color: var(--text-primary);
margin-bottom: 1rem;
font-size: 1.125rem;
}
.info-grid {
display: grid;
gap: 1rem;
}
.info-item {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--border-primary);
}
.info-item:last-child {
border-bottom: none;
padding-bottom: 0;
}
.info-label {
color: var(--text-secondary);
font-size: 0.875rem;
font-weight: 500;
}
.info-value {
color: var(--text-primary);
font-size: 0.875rem;
font-weight: 600;
text-align: right;
}
.url-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.url-item {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.url-label {
color: var(--text-secondary);
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.url-value {
background: rgba(59, 130, 246, 0.08);
border: 1px solid rgba(59, 130, 246, 0.2);
border-radius: 0.375rem;
padding: 0.5rem;
font-family: "SF Mono", "Monaco", "Menlo", "Ubuntu Mono", monospace;
font-size: 0.75rem;
color: #93c5fd;
word-break: break-all;
cursor: pointer;
transition: all 0.2s ease;
}
.url-value:hover {
background: rgba(59, 130, 246, 0.12);
border-color: rgba(59, 130, 246, 0.4);
transform: translateY(-1px);
}
.url-value:active {
transform: translateY(0);
}
@media (max-width: 768px) {
.watch-container {
padding: 0.5rem;
}
.header-content {
flex-direction: column;
align-items: stretch;
text-align: center;
}
.watch-header h1 {
font-size: 1.5rem;
order: -1;
}
.video-container {
aspect-ratio: 16 / 10;
margin-bottom: 1rem;
}
.stream-status-container {
flex-direction: column;
align-items: stretch;
}
.stream-stats {
justify-content: center;
}
.stat {
flex-direction: row;
gap: 0.5rem;
min-width: auto;
}
.stream-info-panel {
grid-template-columns: 1fr;
gap: 1rem;
}
.info-section {
padding: 1rem;
}
}
@media (max-width: 480px) {
.stream-controls {
width: 100%;
justify-content: center;
}
.stream-stats {
flex-direction: column;
gap: 0.5rem;
align-items: flex-start;
}
.stat {
flex-direction: row;
gap: 0.5rem;
min-width: auto;
}
}

51
public/index.html Normal file
View file

@ -0,0 +1,51 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Stream Dashboard</title>
<meta name="color-scheme" content="dark" />
<link rel="stylesheet" href="/public/css/style.css" />
<script type="module" src="/public/js/index.js" defer></script>
</head>
<body>
<div class="container">
<header>
<h1 class="title">Live Streams</h1>
<div class="search-container">
<input type="text" id="searchInput" class="search-input" placeholder="Search streams..." />
</div>
</header>
<section class="stats">
<div class="stat-item">
<span class="stat-label">Total Streams:</span>
<span id="totalStreams" class="stat-value">0</span>
</div>
<div class="stat-item">
<span class="stat-label">Online:</span>
<span id="onlineStreams" class="stat-value">0</span>
</div>
</section>
<section id="loadingState" class="loading">
<div class="spinner"></div>
<p>Loading streams...</p>
</section>
<section id="errorState" class="error-state" style="display: none;">
<p>Failed to load streams. Please try again.</p>
<button id="retryBtn" class="retry-btn">Retry</button>
</section>
<section id="streamsContainer" class="streams-grid" style="display: none;"></section>
<section id="noResults" class="no-results" style="display: none;">
<p>No streams found matching your search.</p>
</section>
</div>
</body>
</html>

10
public/js/flv.min.js vendored Normal file

File diff suppressed because one or more lines are too long

1
public/js/flv.min.js.map Normal file

File diff suppressed because one or more lines are too long

226
public/js/index.js Normal file
View file

@ -0,0 +1,226 @@
class StreamDashboard {
constructor() {
this.streams = [];
this.filteredStreams = [];
this.searchTimeout = null;
this.refreshInterval = null;
this.initializeElements();
this.bindEvents();
this.loadStreams();
this.startAutoRefresh();
}
initializeElements() {
this.elements = {
searchInput: document.getElementById("searchInput"),
totalStreams: document.getElementById("totalStreams"),
onlineStreams: document.getElementById("onlineStreams"),
loadingState: document.getElementById("loadingState"),
errorState: document.getElementById("errorState"),
streamsContainer: document.getElementById("streamsContainer"),
noResults: document.getElementById("noResults"),
retryBtn: document.getElementById("retryBtn"),
};
}
bindEvents() {
this.elements.searchInput.addEventListener("input", (e) => {
clearTimeout(this.searchTimeout);
this.searchTimeout = setTimeout(() => {
this.filterStreams(e.target.value);
}, 300);
});
this.elements.retryBtn.addEventListener("click", () => {
this.loadStreams();
});
document.addEventListener("visibilitychange", () => {
if (document.hidden) {
this.stopAutoRefresh();
} else {
this.startAutoRefresh();
}
});
}
startAutoRefresh() {
this.refreshInterval = setInterval(() => {
this.loadStreams(true);
}, 10000);
}
stopAutoRefresh() {
if (this.refreshInterval) {
clearInterval(this.refreshInterval);
this.refreshInterval = null;
}
}
async loadStreams(silentRefresh = false) {
if (!silentRefresh) {
this.showLoading();
}
try {
const response = await fetch("/api/streams");
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const result = await response.json();
if (!result.success) {
throw new Error(result.error || "Failed to load streams");
}
const allStreams = result.data.streams || [];
this.streams = allStreams.filter(
(stream) => stream.publish?.active === true,
);
this.filteredStreams = [...this.streams];
this.updateStats();
this.renderStreams();
if (!silentRefresh) {
this.showStreams();
}
} catch {
if (!silentRefresh) {
this.showError();
}
}
}
filterStreams(searchTerm) {
const term = searchTerm.toLowerCase().trim();
if (!term) {
this.filteredStreams = [...this.streams];
} else {
this.filteredStreams = this.streams.filter(
(stream) =>
stream.name?.toLowerCase().includes(term) ||
stream.url?.toLowerCase().includes(term) ||
stream.app?.toLowerCase().includes(term),
);
}
this.renderStreams();
if (this.filteredStreams.length === 0 && term) {
this.showNoResults();
} else {
this.showStreams();
}
}
updateStats() {
const total = this.streams.length;
this.elements.totalStreams.textContent = total;
this.elements.onlineStreams.textContent = total;
}
renderStreams() {
this.elements.streamsContainer.innerHTML = "";
for (const stream of this.filteredStreams) {
const streamCard = this.createStreamCard(stream);
this.elements.streamsContainer.appendChild(streamCard);
}
}
createStreamCard(stream) {
const card = document.createElement("div");
card.className = "stream-card";
const formatBytes = (bytes) => {
if (!bytes) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${Number.parseFloat((bytes / k ** i).toFixed(1))} ${sizes[i]}`;
};
const watchName = stream.name?.replace(/\/live\//, "/");
const watchUrl = watchName
? `${window.location.origin}/watch/${encodeURIComponent(watchName)}`
: "Unknown Stream";
card.innerHTML = `
<div class="stream-header">
<h3 class="stream-name">${this.escapeHtml(stream.name || "Unknown Stream")}</h3>
</div>
<div class="stream-info">
<div class="info-row">
<span class="info-label">Clients:</span>
<span class="info-value">${stream.clients || 0}</span>
</div>
<div class="info-row">
<span class="info-label">Bitrate:</span>
<span class="info-value">${formatBytes((stream.kbps?.recv_30s || 0) * 1024)}/s</span>
</div>
<div class="info-row">
<span class="info-label">Video:</span>
<span class="info-value">${stream.video?.codec || "N/A"} ${stream.video?.profile || ""}</span>
</div>
<div class="info-row">
<span class="info-label">Audio:</span>
<span class="info-value">${stream.audio?.codec || "N/A"}</span>
</div>
</div>
<a href="${watchUrl}" class="stream-watch-link">
<div class="stream-url" onclick="navigator.clipboard.writeText('${this.escapeHtml(watchUrl)}').then(() => this.style.background = 'rgba(34, 197, 94, 0.2)').catch(() => {})" title="Click to copy">
<span class="stream-url-value">${this.escapeHtml(watchUrl)}</span>
</div>
</a>
`;
return card;
}
escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
showLoading() {
this.elements.loadingState.style.display = "block";
this.elements.errorState.style.display = "none";
this.elements.streamsContainer.style.display = "none";
this.elements.noResults.style.display = "none";
}
showError() {
this.elements.loadingState.style.display = "none";
this.elements.errorState.style.display = "block";
this.elements.streamsContainer.style.display = "none";
this.elements.noResults.style.display = "none";
}
showStreams() {
this.elements.loadingState.style.display = "none";
this.elements.errorState.style.display = "none";
this.elements.streamsContainer.style.display = "grid";
this.elements.noResults.style.display = "none";
}
showNoResults() {
this.elements.loadingState.style.display = "none";
this.elements.errorState.style.display = "none";
this.elements.streamsContainer.style.display = "none";
this.elements.noResults.style.display = "block";
}
}
new StreamDashboard();

631
public/js/watch.js Normal file
View file

@ -0,0 +1,631 @@
class StreamWatcher {
constructor() {
this.streamId = this.getStreamId();
this.srsUrl = this.getSrsUrl();
this.streamData = null;
this.refreshInterval = null;
this.isFullscreen = false;
this.player = null;
this.isDestroyed = false;
this.retryCount = 0;
this.maxRetries = 3;
this.userInteracted = false;
this.pendingPlay = false;
this.initializeElements();
this.bindEvents();
this.setupUserInteractionDetection();
this.loadStreamData();
this.startAutoRefresh();
}
getStreamId() {
const metaTag = document.querySelector('meta[name="stream-id"]');
if (!metaTag) {
throw new Error("Stream ID not found in meta tags");
}
return metaTag.content;
}
getSrsUrl() {
const metaTag = document.querySelector('meta[name="srs-url"]');
if (!metaTag) {
throw new Error("SRS URL not found in meta tags");
}
return metaTag.content;
}
initializeElements() {
this.elements = {
streamTitle: document.getElementById("streamTitle"),
loadingState: document.getElementById("loadingState"),
errorState: document.getElementById("errorState"),
errorMessage: document.getElementById("errorMessage"),
retryBtn: document.getElementById("retryBtn"),
videoPlayer: document.getElementById("videoPlayer"),
streamStatus: document.getElementById("streamStatus"),
streamStats: document.getElementById("streamStats"),
viewerCount: document.getElementById("viewerCount"),
bitrate: document.getElementById("bitrate"),
quality: document.getElementById("quality"),
streamId: document.getElementById("streamId"),
streamApp: document.getElementById("streamApp"),
streamDuration: document.getElementById("streamDuration"),
videoCodec: document.getElementById("videoCodec"),
audioCodec: document.getElementById("audioCodec"),
resolution: document.getElementById("resolution"),
rtmpUrl: document.getElementById("rtmpUrl"),
hlsUrl: document.getElementById("hlsUrl"),
flvUrl: document.getElementById("flvUrl"),
webrtcUrl: document.getElementById("webrtcUrl"),
videoContainer: document.querySelector(".video-container"),
};
}
setupUserInteractionDetection() {
const events = ["click", "touchstart", "keydown", "scroll"];
const handleInteraction = () => {
this.userInteracted = true;
if (this.pendingPlay && this.elements.videoPlayer) {
this.attemptAutoplay();
}
for (const event of events) {
document.removeEventListener(event, handleInteraction, {
once: true,
passive: true,
capture: true,
});
}
};
for (const event of events) {
document.addEventListener(event, handleInteraction, {
once: true,
passive: true,
capture: true,
});
}
}
bindEvents() {
this.elements.retryBtn.addEventListener("click", () => {
this.retryCount = 0;
this.userInteracted = true;
this.loadStreamData();
});
this.elements.videoContainer.addEventListener("click", () => {
this.userInteracted = true;
if (this.elements.videoPlayer?.paused) {
this.attemptAutoplay();
}
});
const urlElements = [
this.elements.rtmpUrl,
this.elements.hlsUrl,
this.elements.flvUrl,
this.elements.webrtcUrl,
];
for (const element of urlElements) {
element.addEventListener("click", (e) => {
e.stopPropagation();
const url = element.textContent.trim();
if (url !== "-") {
navigator.clipboard
.writeText(url)
.then(() => {
element.style.background = "rgba(34, 197, 94, 0.2)";
setTimeout(() => {
element.style.background = "";
}, 1000);
})
.catch(() => {});
}
});
}
document.addEventListener("visibilitychange", () => {
if (document.hidden) {
this.stopAutoRefresh();
} else {
this.startAutoRefresh();
setTimeout(() => this.loadStreamData(), 100);
}
});
document.addEventListener("fullscreenchange", () => {
this.isFullscreen = !!document.fullscreenElement;
});
window.addEventListener("beforeunload", () => {
this.cleanup();
});
}
async loadStreamData() {
const wasPlaying =
this.elements.videoPlayer && !this.elements.videoPlayer.paused;
if (!wasPlaying) {
this.showLoading();
}
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 8000);
const response = await fetch("/api/streams", {
method: "GET",
headers: {
Accept: "application/json",
"Cache-Control": "no-cache",
},
credentials: "same-origin",
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const result = await response.json();
if (!result.success) {
throw new Error(result.error || "Failed to load streams");
}
const streams = result.data.streams || [];
const newStreamData = streams.find(
(stream) =>
stream.name === this.streamId || stream.id === this.streamId,
);
if (!newStreamData) {
this.elements.streamTitle.textContent = `No stream found for: ${this.streamId}`;
throw new Error("Stream not found");
}
const wasOnline = this.streamData?.publish?.active === true;
const isOnline = newStreamData.publish?.active === true;
this.streamData = newStreamData;
this.updateStreamInfo();
if (wasOnline !== isOnline || !this.player) {
await this.setupVideoPlayer();
}
this.retryCount = 0;
} catch (error) {
if (error.name === "AbortError") {
this.showError("Request timeout - please check your connection");
} else {
this.showError(error.message);
}
}
}
updateStreamInfo() {
const stream = this.streamData;
const isOnline = stream.publish?.active === true;
this.elements.streamTitle.textContent =
stream.name || `Stream ${this.streamId}`;
this.elements.streamStats.style.display = isOnline ? "flex" : "none";
if (isOnline) {
this.elements.viewerCount.textContent = stream.clients || 0;
this.elements.bitrate.textContent = this.formatBitrate(
stream.kbps?.recv_30s || 0,
);
this.elements.quality.textContent = this.getQualityString(stream);
}
this.elements.streamId.textContent = stream.name || this.streamId;
this.elements.streamApp.textContent = stream.app || "-";
this.elements.streamDuration.textContent = isOnline
? this.formatDuration(stream.publish?.duration)
: "-";
this.elements.videoCodec.textContent = stream.video
? `${stream.video.codec} ${stream.video.profile || ""}`.trim()
: "-";
this.elements.audioCodec.textContent = stream.audio?.codec || "-";
this.elements.resolution.textContent = stream.video
? `${stream.video.width || "?"}x${stream.video.height || "?"}`
: "-";
this.updateStreamUrls(stream);
}
updateStreamUrls(stream) {
const srsHost = new URL(this.srsUrl).host;
const streamPath = `${stream.app}/${stream.name}`;
this.elements.rtmpUrl.textContent = `rtmp://${srsHost}/${streamPath}`;
this.elements.hlsUrl.textContent = `${this.srsUrl}/${streamPath}.m3u8`;
this.elements.flvUrl.textContent = `${this.srsUrl}/${streamPath}.flv`;
this.elements.webrtcUrl.textContent = `webrtc://${srsHost}/${streamPath}`;
}
async setupVideoPlayer() {
const stream = this.streamData;
if (!stream.publish?.active) {
this.showError("Stream is currently offline");
return;
}
if (
this.player &&
this.elements.videoPlayer &&
!this.elements.videoPlayer.paused
) {
this.showVideo();
return;
}
this.cleanupPlayer();
const videoElement = this.elements.videoPlayer;
const hlsUrl = `${this.srsUrl}/${stream.app}/${stream.name}.m3u8`;
const flvUrl = `${this.srsUrl}/${stream.app}/${stream.name}.flv`;
const hasFlvjs = typeof flvjs !== "undefined";
if (hasFlvjs && flvjs.isSupported()) {
await this.setupFlvjsPlayer(videoElement, flvUrl, hlsUrl);
} else {
await this.setupNativePlayer(videoElement, hlsUrl);
}
}
async setupFlvjsPlayer(videoElement, flvUrl, hlsUrl) {
try {
this.player = flvjs.createPlayer(
{
type: "flv",
url: flvUrl,
isLive: true,
cors: true,
},
{
enableWorker: false,
enableStashBuffer: false,
stashInitialSize: 128,
isLive: true,
lazyLoad: false,
lazyLoadMaxDuration: 0,
lazyLoadRecoverDuration: 0,
deferLoadAfterSourceOpen: false,
autoCleanupMaxBackwardDuration: 2,
autoCleanupMinBackwardDuration: 1,
statisticsInfoReportInterval: 1000,
fixAudioTimestampGap: true,
accurateSeek: false,
seekType: "range",
liveBufferLatencyChasing: true,
liveBufferLatencyMaxLatency: 1.5,
liveBufferLatencyMinRemain: 0.3,
},
);
const newVideoElement = this.setupVideoEvents(videoElement);
this.player.on(flvjs.Events.ERROR, (errorType, errorDetail) => {
console.warn(
"FLV.js error, falling back to HLS:",
errorType,
errorDetail,
);
this.cleanupPlayer();
setTimeout(() => this.setupNativePlayer(newVideoElement, hlsUrl), 100);
});
this.player.attachMediaElement(newVideoElement);
const loadPromise = new Promise((resolve, reject) => {
const loadTimeout = setTimeout(() => {
reject(new Error("FLV load timeout"));
}, 3000);
this.player.on(flvjs.Events.MEDIA_INFO, () => {
clearTimeout(loadTimeout);
resolve();
});
});
this.player.load();
try {
await loadPromise;
} catch {
console.warn("FLV.js load timeout, falling back to HLS");
this.cleanupPlayer();
await this.setupNativePlayer(newVideoElement, hlsUrl);
}
} catch (error) {
console.warn("FLV.js setup failed, using native player:", error);
await this.setupNativePlayer(videoElement, hlsUrl);
}
}
async setupNativePlayer(videoElement, url) {
try {
const newVideoElement = this.setupVideoEvents(videoElement);
newVideoElement.crossOrigin = "anonymous";
newVideoElement.preload = "metadata";
newVideoElement.autoplay = true;
newVideoElement.muted = true;
newVideoElement.playsInline = true;
newVideoElement.controls = false;
if (newVideoElement.canPlayType("application/vnd.apple.mpegurl")) {
newVideoElement.setAttribute("playsinline", "");
newVideoElement.setAttribute("webkit-playsinline", "");
}
newVideoElement.src = url;
newVideoElement.load();
} catch (error) {
console.error("Native player setup failed:", error);
this.showError("Failed to load video stream");
}
}
setupVideoEvents(videoElement) {
const newVideoElement = videoElement.cloneNode(true);
videoElement.parentNode.replaceChild(newVideoElement, videoElement);
this.elements.videoPlayer = newVideoElement;
newVideoElement.removeAttribute("src");
newVideoElement.load();
newVideoElement.addEventListener("loadstart", () => {
this.showLoading();
});
newVideoElement.addEventListener("loadedmetadata", () => {
this.attemptAutoplay();
});
newVideoElement.addEventListener("canplay", () => {
this.showVideo();
this.attemptAutoplay();
});
newVideoElement.addEventListener("playing", () => {
this.showVideo();
this.pendingPlay = false;
});
newVideoElement.addEventListener("waiting", () => {
setTimeout(() => {
if (newVideoElement.readyState < 3) {
this.showLoading();
}
}, 500);
});
newVideoElement.addEventListener("error", () => {
const error = newVideoElement.error;
console.error("Video error:", error);
if (this.retryCount < this.maxRetries) {
this.retryCount++;
setTimeout(
() => {
if (!this.isDestroyed) {
console.log(`Retrying video load (attempt ${this.retryCount})`);
const stream = this.streamData;
const hlsUrl = `${this.srsUrl}/${stream.app}/${stream.name}.m3u8`;
this.cleanupPlayer();
this.setupNativePlayer(newVideoElement, hlsUrl);
}
},
Math.min(1000 * this.retryCount, 5000),
);
} else {
this.showError(
`Video failed to load: ${error?.message || "Unknown error"}`,
);
}
});
newVideoElement.addEventListener("pause", () => {
if (!this.isDestroyed && this.streamData?.publish?.active) {
setTimeout(() => {
if (newVideoElement.paused && !this.isDestroyed) {
this.attemptAutoplay();
}
}, 1000);
}
});
return newVideoElement;
}
async attemptAutoplay() {
if (!this.elements.videoPlayer || this.isDestroyed) return;
try {
this.elements.videoPlayer.muted = true;
const playPromise = this.elements.videoPlayer.play();
if (playPromise !== undefined) {
await playPromise;
console.log("Autoplay successful");
}
} catch (error) {
console.warn("Autoplay failed:", error);
if (!this.userInteracted) {
this.pendingPlay = true;
this.showPlayButton();
} else {
setTimeout(() => {
if (!this.elements.videoPlayer.paused) return;
this.elements.videoPlayer.play().catch(() => {});
}, 500);
}
}
}
showPlayButton() {
if (!document.getElementById("playButtonOverlay")) {
const overlay = document.createElement("div");
overlay.id = "playButtonOverlay";
overlay.style.cssText = `
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(0,0,0,0.7);
color: white;
padding: 20px;
border-radius: 50%;
cursor: pointer;
font-size: 24px;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
width: 80px;
height: 80px;
`;
overlay.innerHTML = "▶";
overlay.addEventListener("click", () => {
this.userInteracted = true;
this.attemptAutoplay();
overlay.remove();
});
this.elements.videoContainer.style.position = "relative";
this.elements.videoContainer.appendChild(overlay);
}
}
cleanupPlayer() {
if (this.player) {
try {
this.player.pause();
this.player.unload();
this.player.detachMediaElement();
this.player.destroy();
} catch (e) {
console.warn("Error cleaning up player:", e);
}
this.player = null;
}
if (this.elements.videoPlayer) {
this.elements.videoPlayer.pause();
this.elements.videoPlayer.src = "";
this.elements.videoPlayer.removeAttribute("src");
this.elements.videoPlayer.load();
}
const overlay = document.getElementById("playButtonOverlay");
if (overlay) {
overlay.remove();
}
}
cleanup() {
this.isDestroyed = true;
this.stopAutoRefresh();
this.cleanupPlayer();
}
formatBitrate(kbps) {
if (kbps >= 1000) {
return `${(kbps / 1000).toFixed(1)} Mbps`;
}
return `${kbps} kbps`;
}
formatDuration(ms) {
if (!ms) return "0s";
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
if (hours > 0) {
return `${hours}h ${minutes % 60}m`;
}
if (minutes > 0) {
return `${minutes}m ${seconds % 60}s`;
}
return `${seconds}s`;
}
getQualityString(stream) {
if (!stream.video) return "Unknown";
const width = stream.video.width;
if (width >= 1920) return "1080p";
if (width >= 1280) return "720p";
if (width >= 854) return "480p";
if (width >= 640) return "360p";
return `${width}x${stream.video.height}`;
}
toggleFullscreen() {
if (!this.isFullscreen) {
this.elements.videoContainer.requestFullscreen().catch(() => {});
} else {
document.exitFullscreen().catch(() => {});
}
}
startAutoRefresh() {
if (this.refreshInterval) {
this.stopAutoRefresh();
}
this.refreshInterval = setInterval(() => {
if (!this.isDestroyed) {
this.loadStreamData();
}
}, 15000);
}
stopAutoRefresh() {
if (this.refreshInterval) {
clearInterval(this.refreshInterval);
this.refreshInterval = null;
}
}
showLoading() {
this.elements.loadingState.style.display = "flex";
this.elements.errorState.style.display = "none";
this.elements.videoPlayer.style.display = "none";
}
showError(message) {
this.elements.loadingState.style.display = "none";
this.elements.errorState.style.display = "flex";
this.elements.videoPlayer.style.display = "none";
this.elements.errorMessage.textContent = message;
}
showVideo() {
this.elements.loadingState.style.display = "none";
this.elements.errorState.style.display = "none";
this.elements.videoPlayer.style.display = "block";
}
}
try {
new StreamWatcher();
} catch (error) {
console.error("Failed to initialize StreamWatcher:", error);
}

129
public/watch.html Normal file
View file

@ -0,0 +1,129 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Watch Stream</title>
<script src="/public/js/flv.min.js"></script>
<script type="module" src="/public/js/watch.js" defer></script>
<link rel="stylesheet" href="/public/css/style.css">
<link rel="stylesheet" href="/public/css/watch.css">
<meta name="color-scheme" content="dark">
</head>
<body>
<div class="watch-container">
<header class="watch-header">
<div class="header-content">
<a href="/" class="back-btn">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="m15 18-6-6 6-6" />
</svg>
Back to Dashboard
</a>
<h1 id="streamTitle">Loading Stream...</h1>
</div>
</header>
<div class="video-container">
<div id="loadingState" class="loading-overlay">
<div class="spinner"></div>
<p>Loading stream...</p>
</div>
<div id="errorState" class="error-overlay" style="display: none;">
<div class="error-content">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<line x1="15" y1="9" x2="9" y2="15"></line>
<line x1="9" y1="9" x2="15" y2="15"></line>
</svg>
<h3>Stream Unavailable</h3>
<p id="errorMessage">The stream is currently offline or unavailable.</p>
<button id="retryBtn" class="retry-btn">Try Again</button>
</div>
</div>
<video id="videoPlayer" class="video-player" controls autoplay muted style="display: none;">
Your browser does not support the video tag.
</video>
</div>
<div class="stream-status-container">
<div class="stream-stats" id="streamStats" style="display: none;">
<div class="stat">
<span class="stat-label">Viewers</span>
<span class="stat-value" id="viewerCount">0</span>
</div>
<div class="stat">
<span class="stat-label">Bitrate</span>
<span class="stat-value" id="bitrate">0 kbps</span>
</div>
<div class="stat">
<span class="stat-label">Quality</span>
<span class="stat-value" id="quality">Unknown</span>
</div>
</div>
</div>
<div class="stream-info-panel">
<div class="info-section">
<h3>Stream Information</h3>
<div class="info-grid">
<div class="info-item">
<span class="info-label">Stream ID:</span>
<span class="info-value" id="streamId">-</span>
</div>
<div class="info-item">
<span class="info-label">Application:</span>
<span class="info-value" id="streamApp">-</span>
</div>
<div class="info-item">
<span class="info-label">Duration:</span>
<span class="info-value" id="streamDuration">-</span>
</div>
<div class="info-item">
<span class="info-label">Video Codec:</span>
<span class="info-value" id="videoCodec">-</span>
</div>
<div class="info-item">
<span class="info-label">Audio Codec:</span>
<span class="info-value" id="audioCodec">-</span>
</div>
<div class="info-item">
<span class="info-label">Resolution:</span>
<span class="info-value" id="resolution">-</span>
</div>
</div>
</div>
<div class="info-section">
<h3>Stream URLs</h3>
<div class="url-list">
<div class="url-item">
<span class="url-label">RTMP:</span>
<div class="url-value" id="rtmpUrl" title="Click to copy">-</div>
</div>
<div class="url-item">
<span class="url-label">HLS:</span>
<div class="url-value" id="hlsUrl" title="Click to copy">-</div>
</div>
<div class="url-item">
<span class="url-label">HTTP-FLV:</span>
<div class="url-value" id="flvUrl" title="Click to copy">-</div>
</div>
<div class="url-item">
<span class="url-label">WebRTC:</span>
<div class="url-value" id="webrtcUrl" title="Click to copy">-</div>
</div>
</div>
</div>
</div>
</div>
</body>
</html>

15
src/index.ts Normal file
View file

@ -0,0 +1,15 @@
import { echo } from "@atums/echo";
import { verifyRequiredVariables } from "@config";
import { serverHandler } from "@server";
async function main(): Promise<void> {
verifyRequiredVariables();
serverHandler.initialize();
}
main().catch((error: Error) => {
echo.error({ message: "Error initializing the server:", error });
process.exit(1);
});

42
src/routes/api/streams.ts Normal file
View file

@ -0,0 +1,42 @@
import { srsUrl } from "@config";
const routeDef: RouteDef = {
method: "GET",
accepts: "*/*",
returns: "application/json",
};
async function handler(): Promise<Response> {
try {
const response = await fetch(`${srsUrl}/api/v1/streams`);
if (!response.ok) {
return Response.json(
{
success: false,
code: response.status,
error: `SRS API error: ${response.statusText}`,
},
{ status: response.status },
);
}
const data = await response.json();
return Response.json({
success: true,
data,
});
} catch {
return Response.json(
{
success: false,
code: 500,
error: "Failed to fetch streams from SRS server",
},
{ status: 500 },
);
}
}
export { handler, routeDef };

20
src/routes/index.ts Normal file
View file

@ -0,0 +1,20 @@
import { resolve } from "node:path";
const routeDef: RouteDef = {
method: "GET",
accepts: "*/*",
returns: "text/html",
};
async function handler(): Promise<Response> {
const filePath = resolve("public", "index.html");
const file = Bun.file(filePath);
return new Response(file, {
headers: {
"Content-Type": "text/html",
},
});
}
export { handler, routeDef };

40
src/routes/watch/[id].ts Normal file
View file

@ -0,0 +1,40 @@
import { resolve } from "node:path";
import { srsUrl } from "@config";
const routeDef: RouteDef = {
method: "GET",
accepts: "*/*",
returns: "text/html",
};
async function handler(request: ExtendedRequest): Promise<Response> {
const { id } = request.params;
if (!id) {
return new Response("Stream ID is required", { status: 400 });
}
const filePath = resolve("public", "watch.html");
const bunFile = Bun.file(filePath);
const html = new HTMLRewriter()
.on("head", {
element(head) {
head.append(`<meta name="srs-url" content="${srsUrl}">`, {
html: true,
});
head.append(`<meta name="stream-id" content="${id}">`, {
html: true,
});
},
})
.transform(await bunFile.text());
return new Response(html, {
headers: {
"Content-Type": "text/html",
},
});
}
export { handler, routeDef };

305
src/server.ts Normal file
View file

@ -0,0 +1,305 @@
import { resolve } from "node:path";
import { Echo, echo } from "@atums/echo";
import { environment } from "@config";
import {
type BunFile,
FileSystemRouter,
type MatchedRoute,
type Server,
} from "bun";
import { webSocketHandler } from "@websocket";
class ServerHandler {
private router: FileSystemRouter;
constructor(
private port: number,
private host: string,
) {
this.router = new FileSystemRouter({
style: "nextjs",
dir: resolve("src", "routes"),
fileExtensions: [".ts"],
origin: `http://${this.host}:${this.port}`,
});
}
public initialize(): void {
const server: Server = Bun.serve({
port: this.port,
hostname: this.host,
fetch: this.handleRequest.bind(this),
websocket: {
open: webSocketHandler.handleOpen.bind(webSocketHandler),
message: webSocketHandler.handleMessage.bind(webSocketHandler),
close: webSocketHandler.handleClose.bind(webSocketHandler),
},
});
const echoChild = new Echo({ disableFile: true });
echoChild.info(
`Server running at http://${server.hostname}:${server.port}`,
);
this.logRoutes(echoChild);
}
private logRoutes(echo: Echo): void {
echo.info("Available routes:");
const sortedRoutes: [string, string][] = Object.entries(
this.router.routes,
).sort(([pathA]: [string, string], [pathB]: [string, string]) =>
pathA.localeCompare(pathB),
);
for (const [path, filePath] of sortedRoutes) {
echo.info(`Route: ${path}, File: ${filePath}`);
}
}
private async serveStaticFile(
request: ExtendedRequest,
pathname: string,
ip: string,
): Promise<Response> {
let filePath: string;
let response: Response;
try {
if (pathname === "/favicon.ico") {
filePath = resolve("public", "assets", "favicon.ico");
} else {
filePath = resolve(`.${pathname}`);
}
const file: BunFile = Bun.file(filePath);
if (await file.exists()) {
const fileContent: ArrayBuffer = await file.arrayBuffer();
const contentType: string = file.type ?? "application/octet-stream";
response = new Response(fileContent, {
headers: { "Content-Type": contentType },
});
} else {
echo.warn(`File not found: ${filePath}`);
response = new Response("Not Found", { status: 404 });
}
} catch (error) {
echo.error({
message: `Error serving static file: ${pathname}`,
error: error as Error,
});
response = new Response("Internal Server Error", { status: 500 });
}
this.logRequest(request, response, ip);
return response;
}
private logRequest(
request: ExtendedRequest,
response: Response,
ip: string | undefined,
): void {
const pathname = new URL(request.url).pathname;
const ignoredStartsWith: string[] = ["/public"];
const ignoredPaths: string[] = ["/favicon.ico"];
if (
ignoredStartsWith.some((prefix) => pathname.startsWith(prefix)) ||
ignoredPaths.includes(pathname)
) {
return;
}
echo.custom(`${request.method}`, `${response.status}`, [
request.url,
`${(performance.now() - request.startPerf).toFixed(2)}ms`,
ip || "unknown",
]);
}
private async handleRequest(
request: Request,
server: Server,
): Promise<Response> {
const extendedRequest: ExtendedRequest = request as ExtendedRequest;
extendedRequest.startPerf = performance.now();
const headers = request.headers;
let ip = server.requestIP(request)?.address;
let response: Response;
if (!ip || ip.startsWith("172.") || ip === "127.0.0.1") {
ip =
headers.get("CF-Connecting-IP")?.trim() ||
headers.get("X-Real-IP")?.trim() ||
headers.get("X-Forwarded-For")?.split(",")[0]?.trim() ||
"unknown";
}
const pathname: string = new URL(request.url).pathname;
const baseDir = resolve("public", "custom");
const customPath = resolve(baseDir, pathname.slice(1));
if (!customPath.startsWith(baseDir)) {
response = new Response("Forbidden", { status: 403 });
this.logRequest(extendedRequest, response, ip);
return response;
}
const customFile = Bun.file(customPath);
if (await customFile.exists()) {
const content = await customFile.arrayBuffer();
const type: string = customFile.type ?? "application/octet-stream";
response = new Response(content, {
headers: { "Content-Type": type },
});
this.logRequest(extendedRequest, response, ip);
return response;
}
if (pathname.startsWith("/public") || pathname === "/favicon.ico") {
return await this.serveStaticFile(extendedRequest, pathname, ip);
}
const match: MatchedRoute | null = this.router.match(request);
let requestBody: unknown = {};
if (match) {
const { filePath, params, query } = match;
try {
const routeModule: RouteModule = await import(filePath);
const contentType: string | null = request.headers.get("Content-Type");
const actualContentType: string | null = contentType
? (contentType.split(";")[0]?.trim() ?? null)
: null;
if (
routeModule.routeDef.needsBody === "json" &&
actualContentType === "application/json"
) {
try {
requestBody = await request.json();
} catch {
requestBody = {};
}
} else if (
routeModule.routeDef.needsBody === "multipart" &&
actualContentType === "multipart/form-data"
) {
try {
requestBody = await request.formData();
} catch {
requestBody = {};
}
}
if (
(Array.isArray(routeModule.routeDef.method) &&
!routeModule.routeDef.method.includes(request.method)) ||
(!Array.isArray(routeModule.routeDef.method) &&
routeModule.routeDef.method !== request.method)
) {
response = Response.json(
{
success: false,
code: 405,
error: `Method ${request.method} Not Allowed, expected ${
Array.isArray(routeModule.routeDef.method)
? routeModule.routeDef.method.join(", ")
: routeModule.routeDef.method
}`,
},
{ status: 405 },
);
} else {
const expectedContentType: string | string[] | null =
routeModule.routeDef.accepts;
let matchesAccepts: boolean;
if (Array.isArray(expectedContentType)) {
matchesAccepts =
expectedContentType.includes("*/*") ||
expectedContentType.includes(actualContentType || "");
} else {
matchesAccepts =
expectedContentType === "*/*" ||
actualContentType === expectedContentType;
}
if (!matchesAccepts) {
response = Response.json(
{
success: false,
code: 406,
error: `Content-Type ${actualContentType} Not Acceptable, expected ${
Array.isArray(expectedContentType)
? expectedContentType.join(", ")
: expectedContentType
}`,
},
{ status: 406 },
);
} else {
extendedRequest.params = params;
extendedRequest.query = query;
response = await routeModule.handler(
extendedRequest,
requestBody,
server,
);
if (routeModule.routeDef.returns !== "*/*") {
response.headers.set(
"Content-Type",
routeModule.routeDef.returns,
);
}
}
}
} catch (error: unknown) {
echo.error({
message: `Error handling route ${request.url}`,
error: error,
});
response = Response.json(
{
success: false,
code: 500,
error: "Internal Server Error",
},
{ status: 500 },
);
}
} else {
response = Response.json(
{
success: false,
code: 404,
error: "Not Found",
},
{ status: 404 },
);
}
this.logRequest(extendedRequest, response, ip);
return response;
}
}
const serverHandler: ServerHandler = new ServerHandler(
environment.port,
environment.host,
);
export { serverHandler };

29
src/websocket.ts Normal file
View file

@ -0,0 +1,29 @@
import { echo } from "@atums/echo";
import type { ServerWebSocket } from "bun";
class WebSocketHandler {
public handleMessage(ws: ServerWebSocket, message: string): void {
echo.info(`WebSocket received: ${message}`);
try {
ws.send(`You said: ${message}`);
} catch (error) {
echo.error({ message: "WebSocket send error", error });
}
}
public handleOpen(ws: ServerWebSocket): void {
echo.info("WebSocket connection opened.");
try {
ws.send("Welcome to the WebSocket server!");
} catch (error) {
echo.error({ message: "WebSocket send error", error });
}
}
public handleClose(_ws: ServerWebSocket, code: number, reason: string): void {
echo.warn(`WebSocket closed with code ${code}, reason: ${reason}`);
}
}
const webSocketHandler: WebSocketHandler = new WebSocketHandler();
export { webSocketHandler, WebSocketHandler };

30
tsconfig.json Normal file
View file

@ -0,0 +1,30 @@
{
"compilerOptions": {
"baseUrl": "./",
"paths": {
"@*": ["src/*"],
"@config": ["config/index.ts"],
"@config/*": ["config/*"],
"@types/*": ["types/*"]
},
"typeRoots": ["./types", "./node_modules/@types"],
"lib": ["ESNext", "DOM"],
"target": "ESNext",
"module": "ESNext",
"moduleDetection": "force",
"allowJs": false,
"moduleResolution": "bundler",
"allowImportingTsExtensions": false,
"verbatimModuleSyntax": true,
"noEmit": true,
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"exactOptionalPropertyTypes": true,
"noUncheckedIndexedAccess": true,
"noPropertyAccessFromIndexSignature": false
},
"include": ["src", "types"]
}

5
types/config.d.ts vendored Normal file
View file

@ -0,0 +1,5 @@
type Environment = {
port: number;
host: string;
development: boolean;
};

9
types/logger.d.ts vendored Normal file
View file

@ -0,0 +1,9 @@
type ILogMessagePart = { value: string; color: string };
type ILogMessageParts = {
level: ILogMessagePart;
filename: ILogMessagePart;
readableTimestamp: ILogMessagePart;
message: ILogMessagePart;
[key: string]: ILogMessagePart;
};

15
types/routes.d.ts vendored Normal file
View file

@ -0,0 +1,15 @@
type RouteDef = {
method: string | string[];
accepts: string | null | string[];
returns: string;
needsBody?: "multipart" | "json";
};
type RouteModule = {
handler: (
request: Request | ExtendedRequest,
requestBody: unknown,
server: Server,
) => Promise<Response> | Response;
routeDef: RouteDef;
};

8
types/server.d.ts vendored Normal file
View file

@ -0,0 +1,8 @@
type Query = Record<string, string>;
type Params = Record<string, string>;
interface ExtendedRequest extends Request {
startPerf: number;
query: Query;
params: Params;
}