Initial commit

This commit is contained in:
Cloyir 2026-07-07 08:36:01 +01:00
commit bee67543dd
10 changed files with 1351 additions and 0 deletions

12
.gitignore vendored Normal file
View File

@ -0,0 +1,12 @@
# Local Deno cache
.deno/
# Node cache, only needed if you use Node-based local tooling
node_modules/
# Runtime output
media/
*.log
# Build output
dist/

132
README.md Normal file
View File

@ -0,0 +1,132 @@
# Deno Desktop Stream
一个简易的局域网桌面分享命令行应用。主机在浏览器选择桌面、窗口或应用进行分享,客机通过主机端口打开观看页面。
当前传输链路使用 WebRTC服务端只负责页面托管和 WebSocket 信令转发。
## 功能
- 浏览器原生屏幕/窗口/应用采集
- 可选系统音频或标签页音频,取决于浏览器和被分享内容
- 局域网低延迟观看
- 主机页面和观看页面由同一个服务提供
- 可打包为单个 Windows exe
## 依赖
- Deno 2.x
Deno 仅用于开发、检查和打包。打包后的 exe 不需要安装 Deno、Node.js、npm、FFmpeg 或 node-media-server。
观看和分享需要现代浏览器,建议使用 Chrome 或 Edge。
## 开发运行
```powershell
deno task start
```
默认监听 `8080`
- 主机页面:`http://localhost:8080/`
- 观看页面:`http://localhost:8080/viewer`
自定义端口:
```powershell
deno run -A src/main.ts --port 9090
```
检查代码:
```powershell
deno task check
```
## 打包 exe
```powershell
deno task compile
```
输出文件:
```text
dist/media-server.exe
```
打包时会把 `public/` 页面资源嵌入 exe。分发给别人时只需要这个 exe 文件。
运行 exe
```powershell
.\dist\media-server.exe
```
指定端口:
```powershell
.\dist\media-server.exe --port 8081
```
## 使用方式
1. 主机运行服务。
2. 主机打开 `http://localhost:8080/`,点击“选择桌面或应用”。
3. 客机在同一局域网访问 `http://<主机IP>:8080/viewer`
4. 如果 Windows 防火墙弹窗,允许当前网络访问。
主机分享页建议从 `localhost` 打开。浏览器只允许在安全上下文中调用屏幕分享,`localhost` 属于浏览器允许的安全上下文。客机观看页不需要调用屏幕分享,可以用局域网 IP 访问。
## 常见问题
### 端口被占用
如果启动时看到:
```text
Port 8080 is already in use.
```
说明当前端口已经被其他程序占用。可以换端口启动:
```powershell
.\dist\media-server.exe --port 8081
```
也可以关闭占用端口的进程后再启动。
### 客机打不开页面
- 确认主机和客机在同一局域网。
- 确认访问的是主机局域网 IP不是 `localhost`
- 确认 Windows 防火墙允许该程序访问当前网络。
- 确认端口没有被路由器、杀毒软件或公司网络策略拦截。
### 跨公网访问
局域网内通常不需要额外服务。跨公网或复杂 NAT 时WebRTC 可能需要 TURN 服务,本项目默认只配置了公共 STUN。
## 项目结构
```text
.
├── deno.json # Deno 任务和编译配置
├── public/ # 浏览器页面和前端逻辑
├── src/main.ts # HTTP 服务和 WebRTC 信令服务
└── README.md
```
## Git 提交建议
建议提交源码和静态页面,不提交构建产物:
```text
deno.json
README.md
.gitignore
src/
public/
```
`dist/``media/`、本地缓存和日志已经在 `.gitignore` 中忽略。

11
deno.json Normal file
View File

@ -0,0 +1,11 @@
{
"tasks": {
"start": "deno run -A src/main.ts",
"check": "deno check src/main.ts",
"compile": "deno compile -A --include public --output dist/media-server.exe src/main.ts"
},
"compilerOptions": {
"lib": ["deno.ns", "dom", "dom.iterable", "esnext"],
"strict": true
}
}

3
deno.lock generated Normal file
View File

@ -0,0 +1,3 @@
{
"version": "5"
}

33
public/host.html Normal file
View File

@ -0,0 +1,33 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>桌面分享</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<main class="shell">
<section class="panel broadcaster">
<div class="title-row">
<div>
<p class="eyebrow">Host</p>
<h1>桌面分享</h1>
</div>
<span id="state" class="badge idle">未推流</span>
</div>
<video id="preview" autoplay muted playsinline></video>
<div class="actions">
<button id="start" type="button">选择桌面或应用</button>
<button id="stop" class="secondary" type="button" disabled>停止</button>
<a class="secondary link-button" href="/viewer" target="_blank" rel="noreferrer">打开观看页</a>
</div>
<p id="message" class="message">选择一个屏幕、窗口或浏览器标签页后,客机访问本机端口的 /viewer 即可观看。</p>
</section>
</main>
<script src="/host.js?v=12" type="module"></script>
</body>
</html>

168
public/host.js Normal file
View File

@ -0,0 +1,168 @@
const startButton = document.querySelector("#start");
const stopButton = document.querySelector("#stop");
const preview = document.querySelector("#preview");
const state = document.querySelector("#state");
const message = document.querySelector("#message");
const peerConfig = { iceServers: [{ urls: "stun:stun.l.google.com:19302" }] };
let stream;
let signal;
const peers = new Map();
startButton.addEventListener("click", startSharing);
stopButton.addEventListener("click", stopSharing);
async function startSharing() {
console.info("[host] startSharing clicked");
startButton.disabled = true;
setState("connecting", "准备中");
message.textContent = "请选择要分享的桌面、窗口或应用。";
try {
stream = await navigator.mediaDevices.getDisplayMedia({
video: { frameRate: 30 },
audio: {
echoCancellation: false,
noiseSuppression: false,
sampleRate: 48000,
},
});
console.info("[host] getDisplayMedia resolved", describeStream(stream));
preview.srcObject = stream;
signal = openSignalSocket();
stream.getTracks().forEach((track) => {
track.addEventListener("ended", () => {
console.info("[host] track ended", describeTrack(track));
stopSharing();
});
});
stopButton.disabled = false;
setState("live", "推流中");
message.textContent = `观看地址:${location.origin}/viewer`;
} catch (error) {
console.error("[host] startSharing failed", error);
stopSharing();
message.textContent = error instanceof Error ? error.message : "启动分享失败。";
}
}
function openSignalSocket() {
const protocol = location.protocol === "https:" ? "wss" : "ws";
const nextSocket = new WebSocket(`${protocol}://${location.host}/signal?role=host`);
nextSocket.addEventListener("open", () => console.info("[host] signal open"));
nextSocket.addEventListener("message", (event) => void handleSignalMessage(JSON.parse(event.data)));
nextSocket.addEventListener("close", (event) => console.info("[host] signal close", { code: event.code, reason: event.reason }));
nextSocket.addEventListener("error", (event) => console.error("[host] signal error", event));
return nextSocket;
}
async function handleSignalMessage(data) {
console.info("[host] signal", data);
if (data.type === "viewer-join" && typeof data.viewerId === "string") {
await createPeer(data.viewerId);
return;
}
if (data.type === "answer" && typeof data.viewerId === "string") {
const peer = peers.get(data.viewerId);
if (peer) {
await peer.setRemoteDescription(data.sdp);
}
return;
}
if (data.type === "candidate" && typeof data.viewerId === "string") {
const peer = peers.get(data.viewerId);
if (peer && data.candidate) {
await peer.addIceCandidate(data.candidate);
}
return;
}
if (data.type === "viewer-left" && typeof data.viewerId === "string") {
closePeer(data.viewerId);
}
}
async function createPeer(viewerId) {
closePeer(viewerId);
const peer = new RTCPeerConnection(peerConfig);
peers.set(viewerId, peer);
stream.getTracks().forEach((track) => peer.addTrack(track, stream));
peer.addEventListener("icecandidate", (event) => {
if (event.candidate) {
sendSignal({ type: "candidate", to: viewerId, candidate: event.candidate });
}
});
peer.addEventListener("connectionstatechange", () => {
console.info("[host] peer state", { viewerId, state: peer.connectionState });
if (["failed", "closed", "disconnected"].includes(peer.connectionState)) {
closePeer(viewerId);
}
});
const offer = await peer.createOffer();
await peer.setLocalDescription(offer);
sendSignal({ type: "offer", to: viewerId, sdp: peer.localDescription });
}
function sendSignal(data) {
if (signal?.readyState === WebSocket.OPEN) {
signal.send(JSON.stringify(data));
}
}
function closePeer(viewerId) {
const peer = peers.get(viewerId);
if (peer) {
peer.close();
peers.delete(viewerId);
}
}
function stopSharing() {
console.info("[host] stopSharing", { peers: peers.size, signalState: signal?.readyState });
for (const viewerId of peers.keys()) {
closePeer(viewerId);
}
signal?.close();
stream?.getTracks().forEach((track) => track.stop());
signal = undefined;
stream = undefined;
preview.srcObject = null;
startButton.disabled = false;
stopButton.disabled = true;
setState("idle", "未推流");
}
function describeStream(nextStream) {
return {
tracks: nextStream.getTracks().map(describeTrack),
};
}
function describeTrack(track) {
return {
kind: track.kind,
label: track.label,
enabled: track.enabled,
muted: track.muted,
readyState: track.readyState,
settings: track.getSettings(),
};
}
function setState(kind, text) {
state.className = `badge ${kind}`;
state.textContent = text;
}

182
public/styles.css Normal file
View File

@ -0,0 +1,182 @@
:root {
color-scheme: light;
font-family: "Segoe UI", "Microsoft YaHei", sans-serif;
background: #eef3f1;
color: #16211f;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background:
linear-gradient(135deg, rgba(23, 86, 77, 0.12), transparent 38%),
linear-gradient(315deg, rgba(222, 128, 74, 0.16), transparent 44%),
#eef3f1;
}
.shell {
display: grid;
min-height: 100vh;
place-items: center;
padding: 32px;
}
.panel {
width: min(1040px, 100%);
padding: 24px;
border: 1px solid rgba(28, 48, 44, 0.16);
border-radius: 8px;
background: rgba(255, 255, 255, 0.86);
box-shadow: 0 24px 70px rgba(41, 57, 52, 0.16);
}
.title-row {
display: flex;
align-items: start;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
}
.eyebrow {
margin: 0 0 6px;
color: #66736f;
font-size: 13px;
font-weight: 700;
text-transform: uppercase;
}
h1 {
margin: 0;
font-size: clamp(28px, 5vw, 54px);
font-weight: 800;
letter-spacing: 0;
}
.video-stage {
position: relative;
}
video {
display: block;
width: 100%;
aspect-ratio: 16 / 9;
border-radius: 6px;
background: #101817;
object-fit: contain;
}
.play-overlay {
position: absolute;
left: 50%;
top: 50%;
min-width: 132px;
transform: translate(-50%, -50%);
box-shadow: 0 14px 36px rgba(0, 0, 0, 0.28);
}
.play-overlay[hidden] {
display: none;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 18px;
}
button,
.link-button {
min-height: 42px;
padding: 0 18px;
border: 1px solid #1f5f55;
border-radius: 6px;
background: #1f5f55;
color: #fff;
font: inherit;
font-weight: 700;
line-height: 42px;
text-decoration: none;
cursor: pointer;
}
button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.secondary {
border-color: rgba(31, 95, 85, 0.28);
background: #fff;
color: #1f5f55;
}
.metric {
align-items: center;
display: inline-flex;
min-height: 42px;
padding: 0 12px;
border: 1px solid rgba(31, 95, 85, 0.2);
border-radius: 6px;
background: rgba(255, 255, 255, 0.72);
color: #40504c;
font-size: 14px;
font-weight: 700;
}
.badge {
flex: 0 0 auto;
border-radius: 999px;
padding: 8px 12px;
font-size: 13px;
font-weight: 800;
}
.idle {
background: #e5e9e7;
color: #576560;
}
.connecting {
background: #fff2d7;
color: #8c5814;
}
.live {
background: #dff4e8;
color: #17613b;
}
.message {
margin: 14px 0 0;
color: #5f6c68;
line-height: 1.6;
}
@media (max-width: 680px) {
.shell {
padding: 14px;
}
.panel {
padding: 16px;
}
.title-row {
align-items: stretch;
flex-direction: column;
}
button,
.link-button,
.metric {
width: 100%;
text-align: center;
justify-content: center;
}
}

37
public/viewer.html Normal file
View File

@ -0,0 +1,37 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>观看桌面流</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<main class="shell">
<section class="panel viewer">
<div class="title-row">
<div>
<p class="eyebrow">Viewer</p>
<h1>观看桌面流</h1>
</div>
<span id="state" class="badge idle">等待直播</span>
</div>
<div class="video-stage">
<video id="player" autoplay muted playsinline></video>
<button id="play" class="play-overlay" type="button" hidden>播放直播</button>
</div>
<div class="actions">
<button id="audio" type="button">开启声音</button>
<button id="go-live" type="button">回到直播</button>
<button id="reload" type="button">重新连接</button>
<span id="latency" class="metric">延迟 -- ms</span>
</div>
<p id="message" class="message">如果画面未出现,请确认主机已经点击分享并允许浏览器采集屏幕。</p>
</section>
</main>
<script src="/viewer.js?v=12" type="module"></script>
</body>
</html>

209
public/viewer.js Normal file
View File

@ -0,0 +1,209 @@
const player = document.querySelector("#player");
const playButton = document.querySelector("#play");
const audioButton = document.querySelector("#audio");
const reloadButton = document.querySelector("#reload");
const goLiveButton = document.querySelector("#go-live");
const state = document.querySelector("#state");
const message = document.querySelector("#message");
const latency = document.querySelector("#latency");
const peerConfig = { iceServers: [{ urls: "stun:stun.l.google.com:19302" }] };
let signal;
let peer;
let viewerId = crypto.randomUUID();
let retryTimer;
let playPromise;
let remoteStream;
player.muted = true;
player.defaultMuted = true;
audioButton.disabled = true;
audioButton.textContent = "等待声音";
playButton.addEventListener("click", () => void playWhenReady());
audioButton.addEventListener("click", () => {
player.muted = !player.muted;
audioButton.textContent = player.muted ? "开启声音" : "关闭声音";
void playWhenReady();
});
reloadButton.addEventListener("click", connect);
goLiveButton.addEventListener("click", () => void playWhenReady());
player.addEventListener("playing", () => {
console.info("[viewer] media playing");
setPlayButtonVisible(false);
});
player.addEventListener("pause", () => {
console.info("[viewer] media pause");
setPlayButtonVisible(true);
});
player.addEventListener("waiting", () => console.info("[viewer] media waiting"));
player.addEventListener("error", () => {
console.error("[viewer] media error", player.error);
setPlayButtonVisible(true);
});
connect();
async function connect() {
console.info("[viewer] connect start");
destroyConnection();
setState("connecting", "连接中");
latency.textContent = "WebRTC 连接中";
message.textContent = "正在连接主机。";
const status = await fetch("/api/debug").then((response) => response.json());
console.info("[viewer] status", status);
if (!status.hasHostSignal) {
setState("idle", "等待直播");
latency.textContent = "等待主机";
message.textContent = "主机还没有开始分享。";
retryTimer = window.setTimeout(connect, 1000);
return;
}
signal = openSignalSocket();
}
function openSignalSocket() {
const protocol = location.protocol === "https:" ? "wss" : "ws";
const nextSocket = new WebSocket(`${protocol}://${location.host}/signal?role=viewer&viewerId=${viewerId}`);
nextSocket.addEventListener("open", () => console.info("[viewer] signal open"));
nextSocket.addEventListener("message", (event) => void handleSignalMessage(JSON.parse(event.data)));
nextSocket.addEventListener("close", (event) => {
console.info("[viewer] signal close", { code: event.code, reason: event.reason });
setState("idle", "直播断开");
latency.textContent = "连接断开";
retryTimer = window.setTimeout(connect, 1000);
});
nextSocket.addEventListener("error", (event) => {
console.error("[viewer] signal error", event);
setState("idle", "连接错误");
});
return nextSocket;
}
async function handleSignalMessage(data) {
console.info("[viewer] signal", data);
if (data.type === "ready" && typeof data.viewerId === "string") {
viewerId = data.viewerId;
return;
}
if (data.type === "host-left") {
setState("idle", "直播结束");
latency.textContent = "主机已断开";
message.textContent = "主机已停止分享。";
destroyPeer();
return;
}
if (data.type === "offer" && data.sdp) {
await acceptOffer(data.sdp);
return;
}
if (data.type === "candidate" && data.candidate && peer) {
await peer.addIceCandidate(data.candidate);
}
}
async function acceptOffer(offer) {
destroyPeer();
peer = new RTCPeerConnection(peerConfig);
remoteStream = new MediaStream();
player.srcObject = remoteStream;
peer.addEventListener("track", (event) => {
console.info("[viewer] track", { kind: event.track.kind, label: event.track.label });
remoteStream.addTrack(event.track);
if (event.track.kind === "audio") {
audioButton.disabled = false;
audioButton.textContent = player.muted ? "开启声音" : "关闭声音";
}
void playWhenReady();
});
peer.addEventListener("icecandidate", (event) => {
if (event.candidate) {
sendSignal({ type: "candidate", candidate: event.candidate });
}
});
peer.addEventListener("connectionstatechange", () => {
console.info("[viewer] peer state", peer.connectionState);
latency.textContent = `WebRTC ${peer.connectionState}`;
if (peer.connectionState === "connected") {
setState("live", "播放中");
message.textContent = "正在播放主机分享的桌面。";
}
if (["failed", "closed", "disconnected"].includes(peer.connectionState)) {
setState("idle", "直播断开");
}
});
await peer.setRemoteDescription(offer);
const answer = await peer.createAnswer();
await peer.setLocalDescription(answer);
sendSignal({ type: "answer", sdp: peer.localDescription });
}
function sendSignal(data) {
if (signal?.readyState === WebSocket.OPEN) {
signal.send(JSON.stringify(data));
}
}
async function playWhenReady() {
if (playPromise) {
return playPromise;
}
try {
console.info("[viewer] calling player.play", { readyState: player.readyState, muted: player.muted });
playPromise = player.play();
await playPromise;
setPlayButtonVisible(false);
} catch (error) {
console.warn("[viewer] player.play rejected", error);
message.textContent = "浏览器阻止了自动播放,请点击画面上的播放直播。";
setPlayButtonVisible(true);
} finally {
playPromise = undefined;
}
}
function destroyConnection() {
console.info("[viewer] destroyConnection");
window.clearTimeout(retryTimer);
retryTimer = undefined;
signal?.close();
signal = undefined;
destroyPeer();
setPlayButtonVisible(false);
}
function destroyPeer() {
peer?.close();
peer = undefined;
remoteStream?.getTracks().forEach((track) => track.stop());
remoteStream = undefined;
playPromise = undefined;
player.removeAttribute("src");
player.srcObject = null;
player.load();
audioButton.disabled = true;
audioButton.textContent = "等待声音";
}
function setPlayButtonVisible(visible) {
playButton.hidden = !visible;
}
function setState(kind, text) {
state.className = `badge ${kind}`;
state.textContent = text;
}

564
src/main.ts Normal file
View File

@ -0,0 +1,564 @@
import { existsSync, readdirSync } from "node:fs";
import { dirname, isAbsolute, join, normalize, relative } from "node:path";
import { fileURLToPath } from "node:url";
type CliOptions = {
webPort: number;
rtmpPort: number;
nmsHttpPort: number;
streamKey: string;
ffmpegPath: string;
};
type StreamSession = {
startedAt: number;
chunks: number;
bytes: number;
lastChunkSentAt?: number;
lastChunkReceivedAt?: number;
mimeType?: string;
initChunks: RelayChunk[];
recentChunks: RelayChunk[];
nextChunkId: number;
};
type RelayChunk = {
id: number;
data: Uint8Array;
};
const maxRecentChunks = 60;
const rootDir = normalize(join(dirname(fileURLToPath(import.meta.url)), ".."));
const publicDir = join(rootDir, "public");
const mediaRoot = join(rootDir, "media");
const mimeTypes: Record<string, string> = {
".html": "text/html; charset=utf-8",
".css": "text/css; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".m3u8": "application/vnd.apple.mpegurl",
".ts": "video/mp2t",
".ico": "image/x-icon",
};
const options = parseArgs(Deno.args);
clearStreamFiles(options.streamKey);
let currentSession: StreamSession | undefined;
const viewers = new Set<WebSocket>();
let hostSignal: WebSocket | undefined;
const signalViewers = new Map<string, WebSocket>();
try {
Deno.serve({ hostname: "0.0.0.0", port: options.webPort }, handleRequest);
} catch (error) {
if (error instanceof Deno.errors.AddrInUse) {
console.error(`Port ${options.webPort} is already in use.`);
console.error(`Try another port: media-server.exe --port ${options.webPort + 1}`);
console.error(`Or close the process currently using port ${options.webPort}.`);
Deno.exit(1);
}
throw error;
}
async function handleRequest(request: Request) {
const url = new URL(request.url);
if (url.pathname === "/ingest") {
return handleIngest(request, options);
}
if (url.pathname === "/watch") {
return handleWatch(request);
}
if (url.pathname === "/signal") {
return handleSignal(request, url);
}
if (url.pathname === "/api/status") {
return jsonResponse({
publishing: currentSession !== undefined,
hlsPath: `/hls/live/${options.streamKey}/index.m3u8`,
streamKey: options.streamKey,
});
}
if (url.pathname === "/api/debug") {
return jsonResponse(getDebugState(options));
}
if (url.pathname.startsWith("/hls/")) {
return serveFile(mediaRoot, url.pathname.replace(/^\/hls\//, ""));
}
if (url.pathname === "/") {
return serveFile(publicDir, "host.html");
}
if (url.pathname === "/viewer") {
return serveFile(publicDir, "viewer.html");
}
return serveFile(publicDir, url.pathname.slice(1));
}
console.log(`Host page: http://localhost:${options.webPort}/`);
console.log(`Viewer page: http://localhost:${options.webPort}/viewer`);
console.log(`LAN access: http://<this-computer-ip>:${options.webPort}/viewer`);
console.log("Stream mode: WebRTC with WebSocket signaling");
function handleSignal(request: Request, url: URL): Response {
if (request.headers.get("upgrade") !== "websocket") {
return new Response("Expected websocket", { status: 426 });
}
const role = url.searchParams.get("role");
const viewerId = url.searchParams.get("viewerId") ?? crypto.randomUUID();
const { socket, response } = Deno.upgradeWebSocket(request);
socket.onopen = () => {
if (role === "host") {
hostSignal = socket;
console.log("[signal] host connected");
socket.send(JSON.stringify({ type: "ready" }));
for (const id of signalViewers.keys()) {
socket.send(JSON.stringify({ type: "viewer-join", viewerId: id }));
}
return;
}
signalViewers.set(viewerId, socket);
console.log(`[signal] viewer connected id=${viewerId} count=${signalViewers.size}`);
socket.send(JSON.stringify({ type: "ready", viewerId }));
hostSignal?.send(JSON.stringify({ type: "viewer-join", viewerId }));
};
socket.onmessage = (event) => {
if (typeof event.data !== "string") {
return;
}
const message = parseJsonMessage(event.data);
if (!message?.type) {
return;
}
if (role === "host") {
const target = typeof message.to === "string" ? signalViewers.get(message.to) : undefined;
target?.send(JSON.stringify(message));
return;
}
hostSignal?.send(JSON.stringify({ ...message, viewerId }));
};
socket.onclose = () => {
if (role === "host") {
if (hostSignal === socket) {
hostSignal = undefined;
}
console.log("[signal] host disconnected");
broadcastSignalViewers({ type: "host-left" });
return;
}
signalViewers.delete(viewerId);
console.log(`[signal] viewer disconnected id=${viewerId} count=${signalViewers.size}`);
hostSignal?.send(JSON.stringify({ type: "viewer-left", viewerId }));
};
socket.onerror = () => {
socket.close();
};
return response;
}
function broadcastSignalViewers(message: unknown): void {
const text = JSON.stringify(message);
for (const viewer of signalViewers.values()) {
if (viewer.readyState === WebSocket.OPEN) {
viewer.send(text);
}
}
}
async function handleIngest(request: Request, cliOptions: CliOptions): Promise<Response> {
if (request.headers.get("upgrade") !== "websocket") {
return new Response("Expected websocket", { status: 426 });
}
if (currentSession) {
return new Response("Another host is already publishing", { status: 409 });
}
const { socket, response } = Deno.upgradeWebSocket(request);
let session: StreamSession | undefined;
socket.binaryType = "arraybuffer";
socket.onopen = () => {
console.log("[ingest] websocket opened");
session = {
startedAt: Date.now(),
chunks: 0,
bytes: 0,
initChunks: [],
recentChunks: [],
nextChunkId: 1,
};
currentSession = session;
socket.send(JSON.stringify({ type: "ready" }));
};
socket.onmessage = async (event) => {
if (typeof event.data === "string" && session) {
const message = parseJsonMessage(event.data);
if (message?.type === "start" && typeof message.mimeType === "string") {
session.mimeType = message.mimeType;
console.log(`[relay] stream mime=${session.mimeType}`);
broadcastJson({ type: "stream", mimeType: session.mimeType });
}
return;
}
if (!(event.data instanceof ArrayBuffer) || !session) {
return;
}
const framedChunk = new Uint8Array(event.data);
const sentAt = readChunkTimestamp(framedChunk);
const chunk = framedChunk.slice(8);
if (chunk.byteLength < 256) {
console.log(`[relay] skipped tiny chunk bytes=${chunk.byteLength}`);
return;
}
session.chunks += 1;
session.bytes += chunk.byteLength;
session.lastChunkSentAt = sentAt;
session.lastChunkReceivedAt = Date.now();
const relayChunk = { id: session.nextChunkId, data: framedChunk };
session.nextChunkId += 1;
if (session.initChunks.length < 1) {
session.initChunks.push(relayChunk);
}
session.recentChunks.push(relayChunk);
while (session.recentChunks.length > maxRecentChunks) {
session.recentChunks.shift();
}
if (session.chunks === 1 || session.chunks % 40 === 0) {
console.log(`[relay] chunk=${session.chunks} bytes=${chunk.byteLength} total=${session.bytes} viewers=${viewers.size}`);
}
broadcastBinary(framedChunk);
};
socket.onclose = (event) => {
console.log(`[ingest] websocket closed code=${event.code} reason=${event.reason || "<empty>"}`);
if (currentSession === session) {
currentSession = undefined;
}
broadcastJson({ type: "end" });
};
socket.onerror = (event) => {
console.error("[ingest] websocket error", event);
if (currentSession === session) {
currentSession = undefined;
}
broadcastJson({ type: "end" });
};
return response;
}
function handleWatch(request: Request): Response {
if (request.headers.get("upgrade") !== "websocket") {
return new Response("Expected websocket", { status: 426 });
}
const { socket, response } = Deno.upgradeWebSocket(request);
socket.binaryType = "arraybuffer";
socket.onopen = () => {
viewers.add(socket);
console.log(`[watch] viewer connected count=${viewers.size}`);
if (currentSession?.mimeType) {
socket.send(JSON.stringify({ type: "stream", mimeType: currentSession.mimeType }));
const sentIds = new Set<number>();
for (const chunk of currentSession.initChunks) {
socket.send(toArrayBuffer(chunk.data));
sentIds.add(chunk.id);
}
for (const chunk of currentSession.recentChunks) {
if (!sentIds.has(chunk.id)) {
socket.send(toArrayBuffer(chunk.data));
}
}
} else {
socket.send(JSON.stringify({ type: "waiting" }));
}
};
socket.onclose = () => {
viewers.delete(socket);
console.log(`[watch] viewer disconnected count=${viewers.size}`);
};
socket.onerror = () => {
viewers.delete(socket);
console.log(`[watch] viewer error count=${viewers.size}`);
};
return response;
}
function clearStreamFiles(streamKey: string): void {
const streamDir = join(mediaRoot, "live", streamKey);
try {
Deno.removeSync(streamDir, { recursive: true });
console.log(`[hls] cleared ${streamDir}`);
} catch {
// Old HLS files are optional; the directory may not exist on first run.
}
}
function getDebugState(cliOptions: CliOptions): Record<string, unknown> {
const streamDir = join(mediaRoot, "live", cliOptions.streamKey);
const hlsPath = join(streamDir, "index.m3u8");
return {
publishing: currentSession !== undefined,
viewers: viewers.size,
signalViewers: signalViewers.size,
hasHostSignal: hostSignal?.readyState === WebSocket.OPEN,
mode: "webrtc-signaling",
session: currentSession
? {
chunks: currentSession.chunks,
bytes: currentSession.bytes,
ageMs: Date.now() - currentSession.startedAt,
mimeType: currentSession.mimeType,
bufferedChunks: currentSession.recentChunks.length,
lastChunkSentAt: currentSession.lastChunkSentAt,
lastChunkReceivedAt: currentSession.lastChunkReceivedAt,
lastRelayDelayMs: currentSession.lastChunkSentAt && currentSession.lastChunkReceivedAt
? currentSession.lastChunkReceivedAt - currentSession.lastChunkSentAt
: undefined,
}
: undefined,
ffmpegPath: cliOptions.ffmpegPath,
hlsPath,
files: listFiles(streamDir),
manifest: readTextFile(hlsPath),
};
}
function listFiles(dir: string): Array<Record<string, unknown>> {
if (!existsSync(dir)) {
return [];
}
return readdirSync(dir, { withFileTypes: true })
.filter((entry) => entry.isFile())
.map((entry) => {
const path = join(dir, entry.name);
const stat = Deno.statSync(path);
return {
name: entry.name,
size: stat.size,
modified: stat.mtime?.toISOString(),
};
});
}
function readTextFile(path: string): string | undefined {
try {
return new TextDecoder().decode(Deno.readFileSync(path));
} catch {
return undefined;
}
}
function logHlsState(hlsPath: string): void {
const dir = dirname(hlsPath);
const files = listFiles(dir).map((file) => `${String(file.name)}:${String(file.size)}`).join(", ");
console.log(`[hls] files=${files || "<none>"}`);
}
async function logReadableStream(prefix: string, stream: ReadableStream<Uint8Array>): Promise<void> {
const reader = stream.getReader();
const decoder = new TextDecoder();
try {
while (true) {
const { value, done } = await reader.read();
if (done) {
break;
}
const text = decoder.decode(value, { stream: true }).trim();
if (text) {
for (const line of text.split(/\r?\n/)) {
console.log(`[${prefix}] ${line}`);
}
}
}
} catch (error) {
console.error(`[${prefix}] failed to read stderr`, error);
}
}
async function serveFile(baseDir: string, relativePath: string): Promise<Response> {
const safeRelativePath = relativePath === "" ? "host.html" : relativePath;
const filePath = normalize(join(baseDir, safeRelativePath));
const normalizedBase = normalize(baseDir);
const relativeToBase = relative(normalizedBase, filePath);
if (relativeToBase.startsWith("..") || isAbsolute(relativeToBase) || !existsSync(filePath)) {
return new Response("Not found", { status: 404 });
}
const body = await Deno.readFile(filePath);
return new Response(body, {
headers: {
"content-type": mimeTypes[getExtension(filePath)] ?? "application/octet-stream",
"cache-control": filePath.endsWith(".m3u8") ? "no-cache" : "public, max-age=2",
},
});
}
function parseArgs(args: string[]): CliOptions {
const values = new Map<string, string>();
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (!arg.startsWith("--")) {
continue;
}
const [rawKey, inlineValue] = arg.slice(2).split("=", 2);
const value = inlineValue ?? args[index + 1];
if (inlineValue === undefined) {
index += 1;
}
values.set(rawKey, value);
}
return {
webPort: readPort(values.get("port"), 8080, "port"),
rtmpPort: readPort(values.get("rtmp-port"), 1935, "rtmp-port"),
nmsHttpPort: readPort(values.get("nms-http-port"), 8001, "nms-http-port"),
streamKey: values.get("stream-key") ?? "desktop",
ffmpegPath: values.get("ffmpeg") ?? "ffmpeg",
};
}
function readPort(value: string | undefined, fallback: number, name: string): number {
const port = value === undefined ? fallback : Number(value);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error(`--${name} 必须是 1 到 65535 之间的端口号`);
}
return port;
}
async function assertCommand(command: string, args: string[], message: string): Promise<void> {
try {
const status = await new Deno.Command(command, { args, stdout: "null", stderr: "null" }).spawn().status;
if (!status.success) {
throw new Error(message);
}
} catch {
throw new Error(message);
}
}
async function resolveFfmpegPath(candidate: string): Promise<string> {
if (candidate !== "ffmpeg" || await commandExists(candidate)) {
return candidate;
}
const localAppData = Deno.env.get("LOCALAPPDATA");
if (!localAppData) {
return candidate;
}
const wingetPackages = join(localAppData, "Microsoft", "WinGet", "Packages");
return findFile(wingetPackages, "ffmpeg.exe", 6) ?? candidate;
}
async function commandExists(command: string): Promise<boolean> {
try {
const status = await new Deno.Command(command, { args: ["-version"], stdout: "null", stderr: "null" }).spawn().status;
return status.success;
} catch {
return false;
}
}
function findFile(root: string, filename: string, maxDepth: number): string | undefined {
if (maxDepth < 0 || !existsSync(root)) {
return undefined;
}
for (const entry of readdirSync(root, { withFileTypes: true })) {
const entryPath = join(root, entry.name);
if (entry.isFile() && entry.name.toLowerCase() === filename.toLowerCase()) {
return entryPath;
}
if (entry.isDirectory()) {
const found = findFile(entryPath, filename, maxDepth - 1);
if (found) {
return found;
}
}
}
return undefined;
}
function jsonResponse(data: unknown): Response {
return new Response(JSON.stringify(data), {
headers: { "content-type": "application/json; charset=utf-8" },
});
}
function getExtension(path: string): string {
const index = path.lastIndexOf(".");
return index === -1 ? "" : path.slice(index);
}
function broadcastJson(data: unknown): void {
const text = JSON.stringify(data);
for (const viewer of viewers) {
if (viewer.readyState === WebSocket.OPEN) {
viewer.send(text);
}
}
}
function broadcastBinary(chunk: Uint8Array): void {
const payload = toArrayBuffer(chunk);
for (const viewer of viewers) {
if (viewer.readyState === WebSocket.OPEN) {
viewer.send(payload);
}
}
}
function readChunkTimestamp(chunk: Uint8Array): number | undefined {
if (chunk.byteLength < 8) {
return undefined;
}
return Number(new DataView(chunk.buffer, chunk.byteOffset, chunk.byteLength).getBigUint64(0, true));
}
function toArrayBuffer(chunk: Uint8Array): ArrayBuffer {
return chunk.buffer.slice(chunk.byteOffset, chunk.byteOffset + chunk.byteLength) as ArrayBuffer;
}
function parseJsonMessage(text: string): Record<string, unknown> | undefined {
try {
const value = JSON.parse(text);
return typeof value === "object" && value !== null ? value as Record<string, unknown> : undefined;
} catch {
return undefined;
}
}