Initial commit
This commit is contained in:
commit
e1603b54a9
12
.gitignore
vendored
Normal file
12
.gitignore
vendored
Normal 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
132
README.md
Normal 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
11
deno.json
Normal 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
|
||||
}
|
||||
}
|
||||
33
public/host.html
Normal file
33
public/host.html
Normal 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
168
public/host.js
Normal 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
182
public/styles.css
Normal 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
37
public/viewer.html
Normal 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
209
public/viewer.js
Normal 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
564
src/main.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user