53 Commits

Author SHA1 Message Date
david_bai ffa9f84c4a fix(sendString):Avoid using the file fragmentation parameter to prevent transmission failure 2025-10-11 10:59:42 +08:00
david_bai 1e22481a00 docs(docker): align commands and access guidance with latest deployment model
- Use "bash ./deploy.sh" consistently across docs
- Prefer "docker compose" (Compose v2) and update examples accordingly
- Public mode: recommend "--with-nginx" for same-origin gateway
- Access: document Nginx (same-origin) vs direct ports; update HTTPS endpoints (lan-tls 8443, full 443)
- Health checks: add same-origin /api examples
- Add notes on NEXT_IMAGE_UNOPTIMIZED in Docker and same-origin behavior when --with-nginx is enabled
- Fix bare-metal docs cross-links to Docker guides
2025-10-10 23:25:12 +08:00
david_bai f0c4364dcd fix(config): lan-tls without --enable-web-https must still use same-origin when Nginx is enabled
- generate-config.sh: in lan-tls without HTTPS, set NEXT_PUBLIC_API_URL empty when WITH_NGINX=true so frontend uses relative /api and /socket.io; widen CORS origins to include http://localhost and http://<LOCAL_IP>
- deploy.sh: pass --with-nginx to config generator for consistency
2025-10-10 20:59:20 +08:00
david_bai 8ef43029d5 fix(deploy+docker+frontend): enforce same-origin via Nginx, disable Next Image optimization in Docker, allow Socket.IO polling fallback, and improve health checks and access info
- generate-config.sh: add --with-nginx flag handling; when enabled, set NEXT_PUBLIC_API_URL empty to use same-origin /api and /socket.io; add BACKEND_INTERNAL_URL for SSR/internal fetch; adjust lan-tls HTTPS (8443) and TLS generation policy
- deploy.sh: show only valid access URLs when Nginx is enabled (gateway URLs), avoid misleading :3002/:3001 entries
- frontend (env/webrtc): return mutable transports [websocket,polling]; use empty signaling server for same-origin; comments in English
- frontend (next.config): support NEXT_IMAGE_UNOPTIMIZED to turn off image optimization in Docker
- frontend (health): prefer BACKEND_INTERNAL_URL for internal health checks, fallback to public URL/localhost
- docker-compose + Dockerfile(frontend): pass NEXT_IMAGE_UNOPTIMIZED and BACKEND_INTERNAL_URL envs
2025-10-10 20:49:17 +08:00
david_bai 975f6e74ad docs: clarify LAN TLS (self-signed) usage — import CA in browser, correct HTTPS endpoints (8443), CORS guidance; fix deploy hints to only show reachable Nginx URLs. Also: trust CA in frontend container and align HTTPS port mapping. 2025-10-09 21:46:03 +08:00
david_bai dec59a12ec docs(readme): align Quick Start and Modes with new deployment model 2025-10-09 15:42:11 +08:00
david_bai 8590eda2c2 refactor(scripts): simplify modes and harden cert automation
- New modes: lan-http, lan-tls (self-signed), public, full
- Add flags: --no-sni443, --enable-web-https (lan-tls), --test-renewal
- generate-config: lan-tls HTTPS on 8443 only when explicitly enabled; HSTS only in full; SNI 443 default in full
- detect-environment: remove interactive prompt; adjust public description to 'HTTP + TURN'
- deploy.sh: pass new flags, add certbot scheduler (systemd timer or cron fallback), add dry-run renewal test
- Docs (EN/zh-CN): update quick start, modes overview, LAN TLS guidance, LE auto-issue/renew section
2025-10-08 23:17:19 +08:00
david_bai 1f4522eeb2 docs+scripts: move domain quick tips to docs; add modes overview; clarify full+SNI defaults; add self-signed domain guidance
- deploy.sh: replace verbose public domain test instructions with a single docs link
- docker/scripts/generate-config.sh: remove 'Intranet with TURN quick tip' from help; add docs pointers
- docs(zh/EN): add 'Modes Overview', add 'Private LAN + TURN' quick start example, add 'Domain + Self-signed' and 'Public Domain Quick Test' sections; note LE auto-issue/renew and SNI 443 default in full mode
2025-10-08 23:07:34 +08:00
david_bai 663082efe1 chore(doc): Replace Chinese comments with English comments 2025-10-08 15:59:50 +08:00
david_bai 2bd09835b1 docs(docker): elevate Docker one-click to top, add LE automation + SNI443, update flags and compose v2 commands
- DEPLOYMENT_docker.md/zh-CN: Add top Quick Start (private/public/full), Let’s Encrypt auto issue/renew (webroot, zero downtime), SNI 443 default for full+domain, common flags (--with-sni443, --turn-port-range, --le-email), replace docker-compose with docker compose.
- README.md/zh-CN: Promote Docker one-click section to top and link to docs.
- DEPLOYMENT.md/zh-CN: Add audience/scope notice; point to Docker docs for recommended path.
- ROADMAP.md/zh-CN: Record recently completed (Docker, LE, SNI, TURN).
2025-10-07 22:48:26 +08:00
david_bai 7809373f88 feat(nginx,sni): enable SNI-based 443 multiplexing (turns:443) and wiring
- docker/scripts/generate-config.sh
	- Add --enable-sni443/--no-sni443 flags; default enable in full+domain.
	- Generate Nginx stream{} with ssl_preread SNI routing: turn.<domain> -> coturn:5349; others -> web:8443.
	- When SNI is enabled, serve HTTPS on 8443 (http layer); otherwise keep 443.
- deploy.sh:
	- Add --with-sni443 and propagate to config generation and LE provisioning.
	- No compose changes required; 8443 remains internal.
- Notes:
	- Backward compatible. SNI is auto-enabled for full+domain, can be toggled with flags.
	- Leverages existing LE automation and TURN cert reuse.
2025-10-07 21:51:28 +08:00
david_bai 85baa97804 feat(ssl,deploy): safe cleanup, TURN auto-start, and HTTPS checks
- deploy.sh:
      - copy real cert lineage (handles -0001) into docker/ssl
      - Ensure TURN auto-start when requested; enhance clean (stop→rm, network fallback)
      - Add HTTPS post-deploy health check
      - Build fallback: parallel → serial on failure
      - Early-exit on clean-only mode
2025-10-07 21:22:51 +08:00
david_bai 246eff196e feat(deploy,ssl): automate Let’s Encrypt (webroot), preserve SSL, and auto-enable HTTPS
- generate-config.sh
      - Add flags: --no-clean, --reset-ssl, --ssl-mode (letsencrypt|self-signed|provided)
      - Stop deleting docker/ssl by default; only wipe on explicit --reset-ssl
      - Inject ACME webroot route into HTTP (80) server; create docker/letsencrypt-www
      - Default SSL_MODE: full=letsencrypt, private/public=self-signed
      - Add enable_https_if_cert_present: append 443 server only when server-cert.pem/server-key.pem exist
      - Keep self-signed path generating HTTPS immediately (non-basic)
  - docker-compose.yml
      - Mount ./docker/letsencrypt-www:/var/www/certbot:ro for Nginx ACME challenges
  - deploy.sh
      - Add --le-email for Let’s Encrypt account email
      - Auto-install certbot once (apt-get) and enable systemd timer if available
      - Install deploy hook at /etc/letsencrypt/renewal-hooks/deploy/privydrop-reload.sh to:
          - Copy renewed certs into docker/ssl
          - Hot-reload Nginx; HUP or restart coturn
      - First-time issuance (webroot) for <domain> and turn.<domain> after Nginx:80 is up; copy certs
      - Re-run generate-config with --no-clean --ssl-mode letsencrypt to enable 443, then reload Nginx
  - Behavior changes
      - Full mode prefers Let’s Encrypt by default; HTTPS gets enabled as soon as certs exist
      - docker/ssl is no longer wiped by config generation
  - Notes
      - SNI-based turns:443 is not implemented yet (planned)
      - Backward compatible with private/public (self-signed)
2025-10-05 12:43:56 +08:00
david_bai a498cc4799 chore(deploy): public output polish and public/full config fixes
- deploy.sh: show public endpoints (domain/public IP only); add TURN info (domain/public IP); prepend logs chmod 777; append HTTPS+Nginx quick-test tips.
- generate-config.sh: fix public/full CORS and NEXT_PUBLIC_API_URL; prefer PUBLIC_IP for TURN host when no domain; update help text.
2025-10-02 22:45:42 +08:00
david_bai 200fc65617 build(docker): Intranet deployment is successfully tested using turn
- Switch all CLI examples to Docker Compose V2 (docker compose) for consistency.
  - Add explicit instruction to grant write permissions to the host logs/ directory (chmod 777 -R logs) to fix coturn/nginx bind-mount logging errors.
  - Parameterize TURN UDP port range via TURN_MIN_PORT/TURN_MAX_PORT and set a safer default 49152-49252 to reduce startup/cleanup overhead and port
  conflicts.
  - Update troubleshooting with coturn log write failure guidance and port conflict hints.
  - Clarify that LAN IP is auto-detected in private mode; --local-ip is no longer needed by default but remains as an override for edge cases.
2025-09-30 14:01:30 +08:00
david_bai 2ee6961634 build(docker): Private mode deployment test successful
Test steps:
bash docker/scripts/generate-config.sh --mode private [--local-ip 192.168.0.113]
bash ./deploy.sh --mode private

Front-end directly inlines NEXT_PUBLIC_API_URL, directly connecting to the backend.
CORS (production) supports comma-separated multiple origins, with localhost and local network IPs included by default.
2025-09-29 18:27:12 +08:00
david_bai cfcd60145a build: refresh docker deployment workflow 2025-09-26 14:02:55 +08:00
david_bai 67b46d0b30 Merge branch 'fea/docker2' into fea/docker 2025-09-21 22:10:30 +08:00
david_bai 3f075c4a97 fix:Fix a Type error 2025-09-14 23:47:42 +08:00
david_bai 55f118be9a chore:Temporarily comment out some debug logs 2025-09-14 23:30:47 +08:00
david_bai 95331cb8e6 chore:Remove the redundant safety save button; Use English comments 2025-09-14 23:25:16 +08:00
david_bai d0ba2eb9c4 chore:Folder resuming transfer is normal 2025-09-14 19:44:11 +08:00
david_bai 79089bed7e chore:Saving folders to disk now works correctly 2025-09-14 16:49:06 +08:00
david_bai 4dcdf0c3a0 chore:The breakpoint resuming file is saved normally 2025-09-14 11:44:35 +08:00
david_bai 327de90f52 chore:Fix the issue where the breakpoint resume receiver is missing one chunk of data 2025-09-14 11:29:51 +08:00
david_bai b5404cea72 chore:Split the fileReceiver.ts 2025-09-14 08:36:20 +08:00
david_bai 33f2f041ac fix:Try to fix the problem of incomplete file size in resumable download 2025-09-14 07:35:34 +08:00
david_bai 8627544946 chore:Exit the room even if it is in transit 2025-09-13 20:01:02 +08:00
david_bai 158433bb0b chore:Initial addition of Docker related content 2025-09-11 06:46:04 +08:00
david_bai 6f8f4f65bb chore:Save directory settings UI tip uses internationalization translation 2025-09-10 23:54:35 +08:00
david_bai 61e7c1db50 chore:The production environment only uses one STUN server to improve connection setup speed 2025-09-10 23:48:08 +08:00
david_bai 526e1b49c1 fix:Fix the issue of downloads failing in certain browsers 2025-09-08 23:59:29 +08:00
david_bai 0747898f3c chore:Use English notes 2025-09-08 00:38:59 +08:00
david_bai 8ff2302c14 code clear up 2025-09-08 00:12:02 +08:00
david_bai 5ca911d1e1 Using a simple backpressure mechanism 2025-09-07 23:38:15 +08:00
david_bai 230a06b3fb fileSender code splitting 2025-09-07 22:52:59 +08:00
david_bai 99c927f5c7 clear up code 2025-09-07 21:21:43 +08:00
david_bai 3f18002cf0 Directly writing to disk was also tested and passed 2025-09-06 23:49:10 +08:00
david_bai e385389e1d Fix the out-of-order file transfer issue for files saved in memory 2025-09-06 22:53:54 +08:00
david_bai 81c2b204f3 It is found that the order of data packets received by Firefox is disordered 2025-09-05 22:58:56 +08:00
david_bai ec6a18dfc0 fix:Adapting to Firefox browser, not yet completed 2025-09-05 00:19:33 +08:00
david_bai a82ca50ee9 fix:Fix the issue of failing to join room with simple IDs
fix:Fix the issue of failing to join room with simple IDs
2025-09-01 00:04:31 +08:00
david_bai 0bcd2c0f97 chore:Random ID button added with simple ID switching function 2025-08-31 23:36:12 +08:00
david_bai 5af2e8db37 chore:Use English comments instead of Chinese 2025-08-31 23:34:52 +08:00
david_bai 1aa738425f chore:remove debug code 2025-08-31 22:22:00 +08:00
david_bai 0562e8a3a8 chore(code):Use the speedCalculator to estimate network quality 2025-08-31 22:09:23 +08:00
david_bai c0317211e7 chore(code):add optimized code, need further debugging 2025-08-31 20:10:31 +08:00
david_bai 7f33064109 chore(code):retryDataSend Add return value,fileSender Remove unnecessary variables. 2025-08-30 23:49:09 +08:00
david_bai ad4a951525 chore:remove debug code 2025-08-30 00:37:32 +08:00
david_bai 4437c70257 fix:Temporarily optimized the speed issue on the mobile 2025-08-30 00:06:59 +08:00
david_bai b38ef84bca chore:fileSender code has been simplified and adjusted 2025-08-29 23:39:35 +08:00
david_bai 9b6e6559fe fix:Temporarily optimized the speed issue on the mobile 2025-08-29 22:51:10 +08:00
david_bai d2153d7630 chore(doc):Update the doc on local development mode 2025-08-26 23:59:09 +08:00
79 changed files with 10846 additions and 1573 deletions
+56
View File
@@ -0,0 +1,56 @@
# Git相关
.git
.gitignore
# 环境变量文件
.env*
!.env.docker.example
# 日志文件
logs/
*.log
# 依赖目录
node_modules/
frontend/node_modules/
backend/node_modules/
# 构建目录
.next/
dist/
build/
# 缓存目录
.npm
.pnpm-store
# 临时文件
*.tmp
*.temp
# 编辑器配置
.vscode/
.idea/
*.swp
*.swo
# 操作系统文件
.DS_Store
Thumbs.db
# Docker相关
Dockerfile
.dockerignore
docker-compose*.yml
# 文档文件
*.md
docs/
# 测试文件
coverage/
.nyc_output/
# 其他
.eslintcache
tsconfig.tsbuildinfo
+7 -1
View File
@@ -60,6 +60,12 @@ next-env.d.ts
# Build output
dist/
# Generated docker assets
docker/ssl/
docker/nginx/
docker/coturn/
logs/
# Temporary files
.temp/
.tmp/
.tmp/
+36 -3
View File
@@ -35,17 +35,50 @@ We believe everyone should have control over their own data. PrivyDrop was creat
- **Backend**: Node.js, Express.js, TypeScript
- **Real-time Communication**: WebRTC, Socket.IO
- **Data Storage**: Redis
- **Deployment**: PM2, Nginx, Docker [WIP]
- **Deployment**: PM2, Nginx, Docker
## 🐳 Docker One-Click Deployment (Recommended)
Deploy in minutes with zero manual configuration. Supports private/public networks and auto HTTPS (Lets Encrypt).
```bash
# Private LAN (no domain/public IP)
bash ./deploy.sh --mode lan-http
# Private LAN + TURN (for complex NAT/LAN)
bash ./deploy.sh --mode lan-http --with-turn
# LAN HTTPS (self-signed; dev/managed env; explicitly enable 8443)
bash ./deploy.sh --mode lan-tls --enable-web-https --with-nginx
# Public IP without domain (with TURN; recommended with Nginx for same-origin)
bash ./deploy.sh --mode public --with-turn --with-nginx
# Public domain (HTTPS + Nginx + TURN + SNI 443, auto-issue/renew)
bash ./deploy.sh --mode full --domain your-domain.com --with-nginx --with-turn --le-email you@domain.com
```
See [Docker Deployment Guide](./docs/DEPLOYMENT_docker.md) (Modes Overview, LAN TLS limitations, Lets Encrypt auto-issue/renew)
Heads-up (LAN TLS, self-signed)
- Import the CA certificate into your browser (or system trust store) on first use: `docker/ssl/ca-cert.pem`. Otherwise the browser shows “certificate not valid/untrusted”.
- Access endpoints (by default):
- Nginx: `http://localhost`
- HTTPS: `https://localhost:8443`, `https://<your LAN IP>:8443`
- Frontend dev ports (optional): `http://localhost:3002`, `http://<your LAN IP>:3002`
- When `--with-nginx` is enabled, the frontend and API are same-origin (`/api`, `/socket.io`) for stability; direct ports `:3002/:3001` are for debugging only and may cause CORS or 404.
- With `--enable-web-https` and the CA trusted, same-origin HTTPS (8443) avoids CORS; common dev origins (`localhost`, `:3002`) are allowed by default.
## 🚀 Quick Start (Full-Stack Local Development)
Before you begin, ensure your development environment has [Node.js](https://nodejs.org/) (v18+), [npm](https://www.npmjs.com/), and a running [Redis](https://redis.io/) instance installed.
1. **Clone the Project**
1. **Clone the Project & install redis**
```bash
git clone https://github.com/david-bai00/PrivyDrop.git
cd PrivyDrop
sudo apt-get install -y redis-server
```
2. **Configure and Start the Backend Service**
@@ -66,7 +99,7 @@ Before you begin, ensure your development environment has [Node.js](https://node
cd frontend
pnpm install
# Copy the development environment file, then modify .env.development as needed
# Copy the development environment file, then modify .env.development as needed, Remove optional items
cp .env_development_example .env.development
pnpm dev # Starts by default at http://localhost:3002
+49 -5
View File
@@ -35,17 +35,61 @@ PrivyDrop (原 SecureShare) 是一个基于 WebRTC 的开源点对点(P2P)
- **后端**: Node.js, Express.js, TypeScript
- **实时通信**: WebRTC, Socket.IO
- **数据存储**: Redis
- **部署**: PM2, Nginx, Docker[暂未支持]
- **部署**: PM2, Nginx, Docker
## 🚀 快速上手 (本地全栈开发)
## 🚀 快速上手
### 🐳 Docker 一键部署 (推荐)
**零配置,一条命令完成部署!支持内网/公网/域名,自动签发/续期 HTTPS。**
```bash
# 内网(无域名/无公网IP
bash ./deploy.sh --mode lan-http
# 内网 + TURN(推荐用于复杂内网/NAT)
bash ./deploy.sh --mode lan-http --with-turn
# 内网 HTTPS(自签,开发/受管环境,显式开启 8443)
bash ./deploy.sh --mode lan-tls --enable-web-https --with-nginx
# 公网IP(无域名),含 TURN(推荐同源经 Nginx)
bash ./deploy.sh --mode public --with-turn --with-nginx
# 公网域名(HTTPS + Nginx + TURN + SNI 443 分流,自动申请/续期证书)
bash ./deploy.sh --mode full --domain your-domain.com --with-nginx --with-turn --le-email you@domain.com
```
完整说明见: docs/DEPLOYMENT_docker.zh-CN.md(模式一览、LAN TLS、自签限制、Lets Encrypt 自动签发/续期)
提示(lan-tls 自签 HTTPS
- 首次访问需导入 CA 证书:`docker/ssl/ca-cert.pem` 到浏览器(或系统信任),否则浏览器会提示“证书无效/不受信任”。
- 访问方式(默认):
- Nginx: `http://localhost`
- HTTPS: `https://localhost:8443``https://<你的局域网IP>:8443`
- 前端开发口(可选): `http://localhost:3002``http://<你的局域网IP>:3002`
- 启用 `--with-nginx` 时,前端与 API 同源(/api、/socket.io)更稳定;直连 `:3002/:3001` 仅用于调试,可能导致 CORS 或 404。
- 若启用了 `--enable-web-https` 并导入 CA,浏览器与 API 走同源 HTTPS(8443)可避免 CORS;我们已默认放开常见开发来源(`localhost``:3002` 等)。
**部署优势**:
- ✅ 部署时间: 60 分钟 → 5 分钟
- ✅ 技术门槛: Linux 运维 → 会用 Docker 即可
- ✅ 环境要求: 公网 IP → 内网即可使用
- ✅ 成功率: 70% → 95%+
详见: [Docker 部署指南](./docs/DEPLOYMENT_docker.zh-CN.md)
### 💻 本地开发环境
在开始之前,请确保你的开发环境已安装 [Node.js](https://nodejs.org/) (v18+), [npm](https://www.npmjs.com/) 以及一个正在运行的 [Redis](https://redis.io/) 实例。
1. **克隆项目**
1. **克隆项目 & 安装 redis**
```bash
git clone https://github.com/david-bai00/PrivyDrop.git
cd privydrop
cd PrivyDrop
sudo apt-get install -y redis-server
```
2. **配置并启动后端服务**
@@ -66,7 +110,7 @@ PrivyDrop (原 SecureShare) 是一个基于 WebRTC 的开源点对点(P2P)
cd frontend
pnpm install
# 复制开发环境变量文件,然后根据需要修改 .env.development
# 复制开发环境变量文件,然后根据需要修改 .env.development,删除可选项
cp .env_development_example .env.development
pnpm dev # 默认启动于 http://localhost:3002
+11 -1
View File
@@ -8,12 +8,22 @@ This roadmap is a living document. We welcome community feedback and contributio
## ✅ Completed
### Architecture optimization
- **Core Architecture Refactor (Q3 2025)**: Successfully refactored the entire frontend codebase to a modern, layered architecture.
- Implemented a framework-agnostic **Service Layer** (`webrtcService`) to encapsulate all WebRTC and business logic.
- Introduced **Zustand** for centralized, predictable state management (`fileTransferStore`).
- Decoupled UI components from business logic, establishing a clear, unidirectional data flow.
- **Resumable File Transfers (Q3 2025):** Implemented robust logic for resuming transfers from the point of interruption. This is enabled by setting a save directory, which allows the receiver to check for partially downloaded files and request only the missing chunks.
### Deployment and Operation
- Docker one-click deployment (Q4 20252)
- Unified container health checks (node health-check.js)
- Lets Encrypt automation (webroot) with zero-downtime renewals and deploy-hook
- TURN improvements (env port range; default 49152-49252)
- SNI 443 multiplexing (turns:443 via Nginx stream; enabled by default in full+domain)
---
## Short-Term Goals (Next 1-3 Months)
@@ -60,4 +70,4 @@ Your contributions are vital to making this roadmap a reality!
2. **Start a Discussion:** If you're interested in a roadmap item, start a discussion to share your ideas.
3. **Submit a PR:** Fork the repo, create a feature branch, and submit a Pull Request.
Thank you for being part of the PrivyDrop community! Let's build the future of private sharing, together.
Thank you for being part of the PrivyDrop community! Let's build the future of private sharing, together.
+13 -3
View File
@@ -8,11 +8,21 @@
## ✅ 已完成
- **核心架构重构 (2025年Q3)**: 成功地将整个前端代码库重构为现代化的分层架构。
### 架构优化
- **核心架构重构 (2025 年 Q3)**: 成功地将整个前端代码库重构为现代化的分层架构。
- 实现了一个与框架无关的**服务层** (`webrtcService`),用于封装所有 WebRTC 和业务逻辑。
- 引入 **Zustand** (`fileTransferStore`) 进行中心化的、可预测的状态管理。
- 将 UI 组件与业务逻辑解耦,建立了清晰的单向数据流。
- **文件断点续传 (2025Q3):** 实现了稳健的断点续传逻辑。通过设置保存目录,接收方能够检查已部分下载的文件,并仅请求缺失的数据块,极大地提升了大文件和不稳定网络下的传输成功率。
- **文件断点续传 (2025Q3):** 实现了稳健的断点续传逻辑。通过设置保存目录,接收方能够检查已部分下载的文件,并仅请求缺失的数据块,极大地提升了大文件和不稳定网络下的传输成功率。
### 部署与运维
- Docker 一键部署(2025 年 Q4
- 容器健康检查统一(node health-check.js
- Lets Encryptwebroot)自动化与续期 deploy-hook(无停机)
- TURN 端口段变量化与默认缩小(49152-49252
- SNI 443 分流(Nginx streamfull+domain 默认开启)
---
@@ -61,4 +71,4 @@
2. **发起讨论:** 如果你对路线图中某个项目感兴趣,欢迎发起一个讨论来分享你的想法。
3. **提交代码:** Fork 仓库,创建你的功能分支,然后提交 Pull Request。
感谢你成为 PrivyDrop 社区的一员!让我们一起共创私人分享的未来。
感谢你成为 PrivyDrop 社区的一员!让我们一起共创私人分享的未来。
+13
View File
@@ -0,0 +1,13 @@
node_modules
npm-debug.log*
.npm
.env*
.git
.gitignore
README.md
Dockerfile
.dockerignore
coverage
.nyc_output
logs
*.log
+41
View File
@@ -0,0 +1,41 @@
# Build stage
FROM node:18-alpine AS builder
ARG HTTP_PROXY
ARG HTTPS_PROXY
ARG NO_PROXY
ENV http_proxy ${HTTP_PROXY} \
https_proxy ${HTTPS_PROXY} \
no_proxy ${NO_PROXY}
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Run stage
FROM node:18-alpine AS runtime
WORKDIR /app
# Copy prebuilt artifacts
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
COPY health-check.js ./
# Create user and set permissions
RUN addgroup -g 1001 -S nodejs && \
adduser -S backend -u 1001 -G nodejs && \
chown -R backend:nodejs /app
USER backend
EXPOSE 3001
# Use a Node.js script for health checks (instead of curl)
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD node health-check.js
CMD ["node", "dist/server.js"]
+2 -2
View File
@@ -88,7 +88,7 @@ server {
}
# Next.js Image Optimization Service (usually handled by the Next.js application)
location /_next/image {
proxy_pass http://localhost:3000; # Point to the Next.js application
proxy_pass http://localhost:3002; # Point to the Next.js application
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
@@ -110,7 +110,7 @@ server {
}
# Named location, used to proxy requests to the Next.js application
location @nextjs_app {
proxy_pass http://localhost:3000; # Point to the Next.js application
proxy_pass http://localhost:3002; # Point to the Next.js application
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env node
const http = require('http');
const options = {
host: 'localhost',
port: process.env.BACKEND_PORT || 3001,
path: '/health',
timeout: 2000,
method: 'GET'
};
const req = http.request(options, (res) => {
if (res.statusCode === 200) {
console.log('Health check passed');
process.exit(0);
} else {
console.log(`Health check failed with status: ${res.statusCode}`);
process.exit(1);
}
});
req.on('error', (err) => {
console.log(`Health check failed: ${err.message}`);
process.exit(1);
});
req.on('timeout', () => {
console.log('Health check timeout');
req.destroy();
process.exit(1);
});
req.end();
+156 -156
View File
@@ -19,7 +19,7 @@ importers:
version: 4.21.2
ioredis:
specifier: ^5.4.1
version: 5.7.0
version: 5.8.0
socket.io:
specifier: ^4.8.1
version: 4.8.1
@@ -32,7 +32,7 @@ importers:
version: 5.0.3
'@types/node':
specifier: ^22.13.4
version: 22.17.2
version: 22.18.6
cross-env:
specifier: ^7.0.3
version: 7.0.3
@@ -41,10 +41,10 @@ importers:
version: 6.0.1
ts-node:
specifier: ^10.9.2
version: 10.9.2(@types/node@22.17.2)(typescript@5.9.2)
version: 10.9.2(@types/node@22.18.6)(typescript@5.9.2)
tsx:
specifier: ^4.19.2
version: 4.20.4
version: 4.20.5
typescript:
specifier: ^5.7.3
version: 5.9.2
@@ -55,164 +55,164 @@ packages:
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
engines: {node: '>=12'}
'@esbuild/aix-ppc64@0.25.9':
resolution: {integrity: sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==}
'@esbuild/aix-ppc64@0.25.10':
resolution: {integrity: sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/android-arm64@0.25.9':
resolution: {integrity: sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg==}
'@esbuild/android-arm64@0.25.10':
resolution: {integrity: sha512-LSQa7eDahypv/VO6WKohZGPSJDq5OVOo3UoFR1E4t4Gj1W7zEQMUhI+lo81H+DtB+kP+tDgBp+M4oNCwp6kffg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.25.9':
resolution: {integrity: sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ==}
'@esbuild/android-arm@0.25.10':
resolution: {integrity: sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.25.9':
resolution: {integrity: sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw==}
'@esbuild/android-x64@0.25.10':
resolution: {integrity: sha512-MiC9CWdPrfhibcXwr39p9ha1x0lZJ9KaVfvzA0Wxwz9ETX4v5CHfF09bx935nHlhi+MxhA63dKRRQLiVgSUtEg==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.25.9':
resolution: {integrity: sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg==}
'@esbuild/darwin-arm64@0.25.10':
resolution: {integrity: sha512-JC74bdXcQEpW9KkV326WpZZjLguSZ3DfS8wrrvPMHgQOIEIG/sPXEN/V8IssoJhbefLRcRqw6RQH2NnpdprtMA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.25.9':
resolution: {integrity: sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ==}
'@esbuild/darwin-x64@0.25.10':
resolution: {integrity: sha512-tguWg1olF6DGqzws97pKZ8G2L7Ig1vjDmGTwcTuYHbuU6TTjJe5FXbgs5C1BBzHbJ2bo1m3WkQDbWO2PvamRcg==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.25.9':
resolution: {integrity: sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q==}
'@esbuild/freebsd-arm64@0.25.10':
resolution: {integrity: sha512-3ZioSQSg1HT2N05YxeJWYR+Libe3bREVSdWhEEgExWaDtyFbbXWb49QgPvFH8u03vUPX10JhJPcz7s9t9+boWg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.25.9':
resolution: {integrity: sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg==}
'@esbuild/freebsd-x64@0.25.10':
resolution: {integrity: sha512-LLgJfHJk014Aa4anGDbh8bmI5Lk+QidDmGzuC2D+vP7mv/GeSN+H39zOf7pN5N8p059FcOfs2bVlrRr4SK9WxA==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.25.9':
resolution: {integrity: sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw==}
'@esbuild/linux-arm64@0.25.10':
resolution: {integrity: sha512-5luJWN6YKBsawd5f9i4+c+geYiVEw20FVW5x0v1kEMWNq8UctFjDiMATBxLvmmHA4bf7F6hTRaJgtghFr9iziQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.25.9':
resolution: {integrity: sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw==}
'@esbuild/linux-arm@0.25.10':
resolution: {integrity: sha512-oR31GtBTFYCqEBALI9r6WxoU/ZofZl962pouZRTEYECvNF/dtXKku8YXcJkhgK/beU+zedXfIzHijSRapJY3vg==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.25.9':
resolution: {integrity: sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A==}
'@esbuild/linux-ia32@0.25.10':
resolution: {integrity: sha512-NrSCx2Kim3EnnWgS4Txn0QGt0Xipoumb6z6sUtl5bOEZIVKhzfyp/Lyw4C1DIYvzeW/5mWYPBFJU3a/8Yr75DQ==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.25.9':
resolution: {integrity: sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ==}
'@esbuild/linux-loong64@0.25.10':
resolution: {integrity: sha512-xoSphrd4AZda8+rUDDfD9J6FUMjrkTz8itpTITM4/xgerAZZcFW7Dv+sun7333IfKxGG8gAq+3NbfEMJfiY+Eg==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.25.9':
resolution: {integrity: sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA==}
'@esbuild/linux-mips64el@0.25.10':
resolution: {integrity: sha512-ab6eiuCwoMmYDyTnyptoKkVS3k8fy/1Uvq7Dj5czXI6DF2GqD2ToInBI0SHOp5/X1BdZ26RKc5+qjQNGRBelRA==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.25.9':
resolution: {integrity: sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w==}
'@esbuild/linux-ppc64@0.25.10':
resolution: {integrity: sha512-NLinzzOgZQsGpsTkEbdJTCanwA5/wozN9dSgEl12haXJBzMTpssebuXR42bthOF3z7zXFWH1AmvWunUCkBE4EA==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.25.9':
resolution: {integrity: sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg==}
'@esbuild/linux-riscv64@0.25.10':
resolution: {integrity: sha512-FE557XdZDrtX8NMIeA8LBJX3dC2M8VGXwfrQWU7LB5SLOajfJIxmSdyL/gU1m64Zs9CBKvm4UAuBp5aJ8OgnrA==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.25.9':
resolution: {integrity: sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA==}
'@esbuild/linux-s390x@0.25.10':
resolution: {integrity: sha512-3BBSbgzuB9ajLoVZk0mGu+EHlBwkusRmeNYdqmznmMc9zGASFjSsxgkNsqmXugpPk00gJ0JNKh/97nxmjctdew==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.25.9':
resolution: {integrity: sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg==}
'@esbuild/linux-x64@0.25.10':
resolution: {integrity: sha512-QSX81KhFoZGwenVyPoberggdW1nrQZSvfVDAIUXr3WqLRZGZqWk/P4T8p2SP+de2Sr5HPcvjhcJzEiulKgnxtA==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-arm64@0.25.9':
resolution: {integrity: sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q==}
'@esbuild/netbsd-arm64@0.25.10':
resolution: {integrity: sha512-AKQM3gfYfSW8XRk8DdMCzaLUFB15dTrZfnX8WXQoOUpUBQ+NaAFCP1kPS/ykbbGYz7rxn0WS48/81l9hFl3u4A==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-x64@0.25.9':
resolution: {integrity: sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g==}
'@esbuild/netbsd-x64@0.25.10':
resolution: {integrity: sha512-7RTytDPGU6fek/hWuN9qQpeGPBZFfB4zZgcz2VK2Z5VpdUxEI8JKYsg3JfO0n/Z1E/6l05n0unDCNc4HnhQGig==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-arm64@0.25.9':
resolution: {integrity: sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ==}
'@esbuild/openbsd-arm64@0.25.10':
resolution: {integrity: sha512-5Se0VM9Wtq797YFn+dLimf2Zx6McttsH2olUBsDml+lm0GOCRVebRWUvDtkY4BWYv/3NgzS8b/UM3jQNh5hYyw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-x64@0.25.9':
resolution: {integrity: sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA==}
'@esbuild/openbsd-x64@0.25.10':
resolution: {integrity: sha512-XkA4frq1TLj4bEMB+2HnI0+4RnjbuGZfet2gs/LNs5Hc7D89ZQBHQ0gL2ND6Lzu1+QVkjp3x1gIcPKzRNP8bXw==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/openharmony-arm64@0.25.9':
resolution: {integrity: sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg==}
'@esbuild/openharmony-arm64@0.25.10':
resolution: {integrity: sha512-AVTSBhTX8Y/Fz6OmIVBip9tJzZEUcY8WLh7I59+upa5/GPhh2/aM6bvOMQySspnCCHvFi79kMtdJS1w0DXAeag==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
'@esbuild/sunos-x64@0.25.9':
resolution: {integrity: sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw==}
'@esbuild/sunos-x64@0.25.10':
resolution: {integrity: sha512-fswk3XT0Uf2pGJmOpDB7yknqhVkJQkAQOcW/ccVOtfx05LkbWOaRAtn5SaqXypeKQra1QaEa841PgrSL9ubSPQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.25.9':
resolution: {integrity: sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ==}
'@esbuild/win32-arm64@0.25.10':
resolution: {integrity: sha512-ah+9b59KDTSfpaCg6VdJoOQvKjI33nTaQr4UluQwW7aEwZQsbMCfTmfEO4VyewOxx4RaDT/xCy9ra2GPWmO7Kw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.25.9':
resolution: {integrity: sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww==}
'@esbuild/win32-ia32@0.25.10':
resolution: {integrity: sha512-QHPDbKkrGO8/cz9LKVnJU22HOi4pxZnZhhA2HYHez5Pz4JeffhDjf85E57Oyco163GnzNCVkZK0b/n4Y0UHcSw==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.25.9':
resolution: {integrity: sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ==}
'@esbuild/win32-x64@0.25.10':
resolution: {integrity: sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
'@ioredis/commands@1.3.0':
resolution: {integrity: sha512-M/T6Zewn7sDaBQEqIZ8Rb+i9y8qfGmq+5SDFSf9sA2lUZTmdDLVdOiQaeDp+Q4wElZ9HG1GAX5KhDaidp6LQsQ==}
'@ioredis/commands@1.4.0':
resolution: {integrity: sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ==}
'@isaacs/balanced-match@4.0.1':
resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==}
@@ -272,8 +272,8 @@ packages:
'@types/mime@1.3.5':
resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==}
'@types/node@22.17.2':
resolution: {integrity: sha512-gL6z5N9Jm9mhY+U2KXZpteb+09zyffliRkZyZOHODGATyC5B1Jt/7TzuuiLkFsSUMLbS1OLmlj/E+/3KF4Q/4w==}
'@types/node@22.18.6':
resolution: {integrity: sha512-r8uszLPpeIWbNKtvWRt/DbVi5zbqZyj1PTmhRMqBMvDnaz1QpmSKujUtJLrqGZeoM8v72MfYggDceY4K1itzWQ==}
'@types/qs@6.14.0':
resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==}
@@ -304,16 +304,16 @@ packages:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'}
ansi-regex@6.2.0:
resolution: {integrity: sha512-TKY5pyBkHyADOPYlRT9Lx6F544mPl0vS5Ew7BJ45hA08Q+t3GjbueLliBWN3sMICk6+y7HdyxSzC4bWS8baBdg==}
ansi-regex@6.2.2:
resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==}
engines: {node: '>=12'}
ansi-styles@4.3.0:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
engines: {node: '>=8'}
ansi-styles@6.2.1:
resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==}
ansi-styles@6.2.3:
resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==}
engines: {node: '>=12'}
arg@4.1.3:
@@ -405,8 +405,8 @@ packages:
supports-color:
optional: true
debug@4.4.1:
resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==}
debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'}
peerDependencies:
supports-color: '*'
@@ -478,8 +478,8 @@ packages:
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
engines: {node: '>= 0.4'}
esbuild@0.25.9:
resolution: {integrity: sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==}
esbuild@0.25.10:
resolution: {integrity: sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ==}
engines: {node: '>=18'}
hasBin: true
@@ -557,8 +557,8 @@ packages:
inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
ioredis@5.7.0:
resolution: {integrity: sha512-NUcA93i1lukyXU+riqEyPtSEkyFq8tX90uL659J+qpCZ3rEdViB/APC58oAhIh3+bJln2hzdlZbBZsGNrlsR8g==}
ioredis@5.8.0:
resolution: {integrity: sha512-AUXbKn9gvo9hHKvk6LbZJQSKn/qIfkWXrnsyL9Yrf+oeXmla9Nmf6XEumOddyhM8neynpK5oAV6r9r99KBuwzA==}
engines: {node: '>=12.22.0'}
ipaddr.js@1.9.1:
@@ -582,8 +582,8 @@ packages:
lodash.isarguments@3.1.0:
resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==}
lru-cache@11.1.0:
resolution: {integrity: sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A==}
lru-cache@11.2.1:
resolution: {integrity: sha512-r8LA6i4LP4EeWOhqBaZZjDWwehd1xUJPCJd9Sv300H0ZmcUER4+JPh7bqqZeqs1o5pgtgvXm+d9UGrB5zZGDiQ==}
engines: {node: 20 || >=22}
make-error@1.3.6:
@@ -772,8 +772,8 @@ packages:
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
engines: {node: '>=8'}
strip-ansi@7.1.0:
resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==}
strip-ansi@7.1.2:
resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==}
engines: {node: '>=12'}
toidentifier@1.0.1:
@@ -794,8 +794,8 @@ packages:
'@swc/wasm':
optional: true
tsx@4.20.4:
resolution: {integrity: sha512-yyxBKfORQ7LuRt/BQKBXrpcq59ZvSW0XxwfjAt3w2/8PmdxaFzijtMhTawprSHhpzeM5BgU2hXHG3lklIERZXg==}
tsx@4.20.5:
resolution: {integrity: sha512-+wKjMNU9w/EaQayHXb7WA7ZaHY6hN8WgfvHNQ3t1PnU91/7O8TcTnIhCDYTZwnt8JsO9IBqZ30Ln1r7pPF52Aw==}
engines: {node: '>=18.0.0'}
hasBin: true
@@ -861,85 +861,85 @@ snapshots:
dependencies:
'@jridgewell/trace-mapping': 0.3.9
'@esbuild/aix-ppc64@0.25.9':
'@esbuild/aix-ppc64@0.25.10':
optional: true
'@esbuild/android-arm64@0.25.9':
'@esbuild/android-arm64@0.25.10':
optional: true
'@esbuild/android-arm@0.25.9':
'@esbuild/android-arm@0.25.10':
optional: true
'@esbuild/android-x64@0.25.9':
'@esbuild/android-x64@0.25.10':
optional: true
'@esbuild/darwin-arm64@0.25.9':
'@esbuild/darwin-arm64@0.25.10':
optional: true
'@esbuild/darwin-x64@0.25.9':
'@esbuild/darwin-x64@0.25.10':
optional: true
'@esbuild/freebsd-arm64@0.25.9':
'@esbuild/freebsd-arm64@0.25.10':
optional: true
'@esbuild/freebsd-x64@0.25.9':
'@esbuild/freebsd-x64@0.25.10':
optional: true
'@esbuild/linux-arm64@0.25.9':
'@esbuild/linux-arm64@0.25.10':
optional: true
'@esbuild/linux-arm@0.25.9':
'@esbuild/linux-arm@0.25.10':
optional: true
'@esbuild/linux-ia32@0.25.9':
'@esbuild/linux-ia32@0.25.10':
optional: true
'@esbuild/linux-loong64@0.25.9':
'@esbuild/linux-loong64@0.25.10':
optional: true
'@esbuild/linux-mips64el@0.25.9':
'@esbuild/linux-mips64el@0.25.10':
optional: true
'@esbuild/linux-ppc64@0.25.9':
'@esbuild/linux-ppc64@0.25.10':
optional: true
'@esbuild/linux-riscv64@0.25.9':
'@esbuild/linux-riscv64@0.25.10':
optional: true
'@esbuild/linux-s390x@0.25.9':
'@esbuild/linux-s390x@0.25.10':
optional: true
'@esbuild/linux-x64@0.25.9':
'@esbuild/linux-x64@0.25.10':
optional: true
'@esbuild/netbsd-arm64@0.25.9':
'@esbuild/netbsd-arm64@0.25.10':
optional: true
'@esbuild/netbsd-x64@0.25.9':
'@esbuild/netbsd-x64@0.25.10':
optional: true
'@esbuild/openbsd-arm64@0.25.9':
'@esbuild/openbsd-arm64@0.25.10':
optional: true
'@esbuild/openbsd-x64@0.25.9':
'@esbuild/openbsd-x64@0.25.10':
optional: true
'@esbuild/openharmony-arm64@0.25.9':
'@esbuild/openharmony-arm64@0.25.10':
optional: true
'@esbuild/sunos-x64@0.25.9':
'@esbuild/sunos-x64@0.25.10':
optional: true
'@esbuild/win32-arm64@0.25.9':
'@esbuild/win32-arm64@0.25.10':
optional: true
'@esbuild/win32-ia32@0.25.9':
'@esbuild/win32-ia32@0.25.10':
optional: true
'@esbuild/win32-x64@0.25.9':
'@esbuild/win32-x64@0.25.10':
optional: true
'@ioredis/commands@1.3.0': {}
'@ioredis/commands@1.4.0': {}
'@isaacs/balanced-match@4.0.1': {}
@@ -951,7 +951,7 @@ snapshots:
dependencies:
string-width: 5.1.2
string-width-cjs: string-width@4.2.3
strip-ansi: 7.1.0
strip-ansi: 7.1.2
strip-ansi-cjs: strip-ansi@6.0.1
wrap-ansi: 8.1.0
wrap-ansi-cjs: wrap-ansi@7.0.0
@@ -978,19 +978,19 @@ snapshots:
'@types/body-parser@1.19.6':
dependencies:
'@types/connect': 3.4.38
'@types/node': 22.17.2
'@types/node': 22.18.6
'@types/connect@3.4.38':
dependencies:
'@types/node': 22.17.2
'@types/node': 22.18.6
'@types/cors@2.8.19':
dependencies:
'@types/node': 22.17.2
'@types/node': 22.18.6
'@types/express-serve-static-core@5.0.7':
dependencies:
'@types/node': 22.17.2
'@types/node': 22.18.6
'@types/qs': 6.14.0
'@types/range-parser': 1.2.7
'@types/send': 0.17.5
@@ -1005,7 +1005,7 @@ snapshots:
'@types/mime@1.3.5': {}
'@types/node@22.17.2':
'@types/node@22.18.6':
dependencies:
undici-types: 6.21.0
@@ -1016,12 +1016,12 @@ snapshots:
'@types/send@0.17.5':
dependencies:
'@types/mime': 1.3.5
'@types/node': 22.17.2
'@types/node': 22.18.6
'@types/serve-static@1.15.8':
dependencies:
'@types/http-errors': 2.0.5
'@types/node': 22.17.2
'@types/node': 22.18.6
'@types/send': 0.17.5
accepts@1.3.8:
@@ -1037,13 +1037,13 @@ snapshots:
ansi-regex@5.0.1: {}
ansi-regex@6.2.0: {}
ansi-regex@6.2.2: {}
ansi-styles@4.3.0:
dependencies:
color-convert: 2.0.1
ansi-styles@6.2.1: {}
ansi-styles@6.2.3: {}
arg@4.1.3: {}
@@ -1125,7 +1125,7 @@ snapshots:
dependencies:
ms: 2.1.3
debug@4.4.1:
debug@4.4.3:
dependencies:
ms: 2.1.3
@@ -1162,7 +1162,7 @@ snapshots:
engine.io@6.6.4:
dependencies:
'@types/cors': 2.8.19
'@types/node': 22.17.2
'@types/node': 22.18.6
accepts: 1.3.8
base64id: 2.0.0
cookie: 0.7.2
@@ -1183,34 +1183,34 @@ snapshots:
dependencies:
es-errors: 1.3.0
esbuild@0.25.9:
esbuild@0.25.10:
optionalDependencies:
'@esbuild/aix-ppc64': 0.25.9
'@esbuild/android-arm': 0.25.9
'@esbuild/android-arm64': 0.25.9
'@esbuild/android-x64': 0.25.9
'@esbuild/darwin-arm64': 0.25.9
'@esbuild/darwin-x64': 0.25.9
'@esbuild/freebsd-arm64': 0.25.9
'@esbuild/freebsd-x64': 0.25.9
'@esbuild/linux-arm': 0.25.9
'@esbuild/linux-arm64': 0.25.9
'@esbuild/linux-ia32': 0.25.9
'@esbuild/linux-loong64': 0.25.9
'@esbuild/linux-mips64el': 0.25.9
'@esbuild/linux-ppc64': 0.25.9
'@esbuild/linux-riscv64': 0.25.9
'@esbuild/linux-s390x': 0.25.9
'@esbuild/linux-x64': 0.25.9
'@esbuild/netbsd-arm64': 0.25.9
'@esbuild/netbsd-x64': 0.25.9
'@esbuild/openbsd-arm64': 0.25.9
'@esbuild/openbsd-x64': 0.25.9
'@esbuild/openharmony-arm64': 0.25.9
'@esbuild/sunos-x64': 0.25.9
'@esbuild/win32-arm64': 0.25.9
'@esbuild/win32-ia32': 0.25.9
'@esbuild/win32-x64': 0.25.9
'@esbuild/aix-ppc64': 0.25.10
'@esbuild/android-arm': 0.25.10
'@esbuild/android-arm64': 0.25.10
'@esbuild/android-x64': 0.25.10
'@esbuild/darwin-arm64': 0.25.10
'@esbuild/darwin-x64': 0.25.10
'@esbuild/freebsd-arm64': 0.25.10
'@esbuild/freebsd-x64': 0.25.10
'@esbuild/linux-arm': 0.25.10
'@esbuild/linux-arm64': 0.25.10
'@esbuild/linux-ia32': 0.25.10
'@esbuild/linux-loong64': 0.25.10
'@esbuild/linux-mips64el': 0.25.10
'@esbuild/linux-ppc64': 0.25.10
'@esbuild/linux-riscv64': 0.25.10
'@esbuild/linux-s390x': 0.25.10
'@esbuild/linux-x64': 0.25.10
'@esbuild/netbsd-arm64': 0.25.10
'@esbuild/netbsd-x64': 0.25.10
'@esbuild/openbsd-arm64': 0.25.10
'@esbuild/openbsd-x64': 0.25.10
'@esbuild/openharmony-arm64': 0.25.10
'@esbuild/sunos-x64': 0.25.10
'@esbuild/win32-arm64': 0.25.10
'@esbuild/win32-ia32': 0.25.10
'@esbuild/win32-x64': 0.25.10
escape-html@1.0.3: {}
@@ -1331,11 +1331,11 @@ snapshots:
inherits@2.0.4: {}
ioredis@5.7.0:
ioredis@5.8.0:
dependencies:
'@ioredis/commands': 1.3.0
'@ioredis/commands': 1.4.0
cluster-key-slot: 1.1.2
debug: 4.4.1
debug: 4.4.3
denque: 2.1.0
lodash.defaults: 4.2.0
lodash.isarguments: 3.1.0
@@ -1359,7 +1359,7 @@ snapshots:
lodash.isarguments@3.1.0: {}
lru-cache@11.1.0: {}
lru-cache@11.2.1: {}
make-error@1.3.6: {}
@@ -1407,7 +1407,7 @@ snapshots:
path-scurry@2.0.0:
dependencies:
lru-cache: 11.1.0
lru-cache: 11.2.1
minipass: 7.1.2
path-to-regexp@0.1.12: {}
@@ -1556,26 +1556,26 @@ snapshots:
dependencies:
eastasianwidth: 0.2.0
emoji-regex: 9.2.2
strip-ansi: 7.1.0
strip-ansi: 7.1.2
strip-ansi@6.0.1:
dependencies:
ansi-regex: 5.0.1
strip-ansi@7.1.0:
strip-ansi@7.1.2:
dependencies:
ansi-regex: 6.2.0
ansi-regex: 6.2.2
toidentifier@1.0.1: {}
ts-node@10.9.2(@types/node@22.17.2)(typescript@5.9.2):
ts-node@10.9.2(@types/node@22.18.6)(typescript@5.9.2):
dependencies:
'@cspotcode/source-map-support': 0.8.1
'@tsconfig/node10': 1.0.11
'@tsconfig/node12': 1.0.11
'@tsconfig/node14': 1.0.3
'@tsconfig/node16': 1.0.4
'@types/node': 22.17.2
'@types/node': 22.18.6
acorn: 8.15.0
acorn-walk: 8.3.4
arg: 4.1.3
@@ -1586,9 +1586,9 @@ snapshots:
v8-compile-cache-lib: 3.0.1
yn: 3.1.1
tsx@4.20.4:
tsx@4.20.5:
dependencies:
esbuild: 0.25.9
esbuild: 0.25.10
get-tsconfig: 4.10.1
optionalDependencies:
fsevents: 2.3.3
@@ -1622,9 +1622,9 @@ snapshots:
wrap-ansi@8.1.0:
dependencies:
ansi-styles: 6.2.1
ansi-styles: 6.2.3
string-width: 5.1.2
strip-ansi: 7.1.0
strip-ansi: 7.1.2
ws@8.17.1: {}
+19 -6
View File
@@ -3,17 +3,30 @@ import { CONFIG } from "./env";
// Define the sources allowed in the development environment
const DEV_ORIGINS = [
CONFIG.CORS_ORIGIN, // http://localhost:3002
'http://localhost:3000', // alternate port
/^http:\/\/192\.168\.\d+\.\d+:3000$/, // LAN addresses
/^http:\/\/192\.168\.\d+\.\d+:3002$/ // LAN addresses with new port
CONFIG.CORS_ORIGIN, // http://localhost:3002
"http://localhost:3002", // alternate port
/^http:\/\/192\.168\.\d+\.\d+:3002$/, // LAN addresses
/^http:\/\/192\.168\.\d+\.\d+:3002$/, // LAN addresses with new port
];
// Parse multi-origin config in production (comma-separated)
const parseProdOrigins = (): string | RegExp | (string | RegExp)[] => {
const v = CONFIG.CORS_ORIGIN?.trim();
if (!v) return DEV_ORIGINS; // Fallback to the development whitelist
if (v.includes(",")) {
return v
.split(",")
.map((s) => s.trim())
.filter(Boolean);
}
return v;
};
// Configure CORS
export const corsOptions: CorsOptions =
CONFIG.NODE_ENV === "production"
? {
origin: CONFIG.CORS_ORIGIN,
origin: parseProdOrigins(),
methods: ["GET", "POST", "OPTIONS"],
credentials: true,
allowedHeaders: ["Content-Type", "Authorization"],
@@ -28,7 +41,7 @@ export const corsOptions: CorsOptions =
export const corsWSOptions =
CONFIG.NODE_ENV === "production"
? {
origin: CONFIG.CORS_ORIGIN, // Allowed origin, replace with your Next.js application's URL
origin: parseProdOrigins(),
methods: ["GET", "POST"],
credentials: true,
}
+191
View File
@@ -0,0 +1,191 @@
import { Router, Request, Response } from 'express';
import { redis } from '../services/redis';
import { CONFIG } from '../config/env';
const router = Router();
// 应用启动时间
const startTime = Date.now();
// 基础健康检查
router.get('/health', async (req: Request, res: Response) => {
try {
const health = {
status: 'healthy',
timestamp: new Date().toISOString(),
uptime: Math.floor((Date.now() - startTime) / 1000),
service: 'privydrop-backend',
version: process.env.npm_package_version || '1.0.0',
environment: CONFIG.NODE_ENV
};
res.status(200).json(health);
} catch (error) {
console.error('Health check error:', error);
res.status(503).json({
status: 'unhealthy',
timestamp: new Date().toISOString(),
service: 'privydrop-backend',
error: error instanceof Error ? error.message : 'Unknown error'
});
}
});
// API路径的健康检查 (兼容性)
router.get('/api/health', async (req: Request, res: Response) => {
try {
const health = {
status: 'healthy',
timestamp: new Date().toISOString(),
uptime: Math.floor((Date.now() - startTime) / 1000),
service: 'privydrop-backend',
version: process.env.npm_package_version || '1.0.0',
environment: CONFIG.NODE_ENV
};
res.status(200).json(health);
} catch (error) {
console.error('Health check error:', error);
res.status(503).json({
status: 'unhealthy',
timestamp: new Date().toISOString(),
service: 'privydrop-backend',
error: error instanceof Error ? error.message : 'Unknown error'
});
}
});
// 详细健康检查
router.get('/health/detailed', async (req: Request, res: Response) => {
const errors: string[] = [];
let status = 'healthy';
try {
// 基础信息
const basicHealth = {
status: 'healthy',
timestamp: new Date().toISOString(),
uptime: Math.floor((Date.now() - startTime) / 1000),
service: 'privydrop-backend',
version: process.env.npm_package_version || '1.0.0',
environment: CONFIG.NODE_ENV
};
// 检查Redis连接
const redisHealth = await checkRedisHealth();
if (redisHealth.status !== 'connected') {
errors.push('Redis connection failed');
status = 'unhealthy';
}
// 检查Socket.IO状态
const io = req.app.get('io');
const socketHealth = {
status: io ? 'running' : 'not_initialized',
connections: io ? io.engine.clientsCount : 0
};
// 获取系统资源信息
const systemInfo = getSystemInfo();
// 检查系统资源
if (systemInfo.memory.percent > 90) {
errors.push('High memory usage (>90%)');
status = status === 'healthy' ? 'degraded' : status;
}
if (systemInfo.cpu.percent > 80) {
errors.push('High CPU usage (>80%)');
status = status === 'healthy' ? 'degraded' : status;
}
const detailedHealth = {
...basicHealth,
status,
dependencies: {
redis: redisHealth,
socketio: socketHealth
},
system: systemInfo,
...(errors.length > 0 && { errors })
};
const httpStatus = status === 'healthy' ? 200 : 503;
res.status(httpStatus).json(detailedHealth);
} catch (error) {
console.error('Detailed health check error:', error);
res.status(503).json({
status: 'unhealthy',
timestamp: new Date().toISOString(),
service: 'privydrop-backend',
error: error instanceof Error ? error.message : 'Unknown error'
});
}
});
// Redis健康检查函数
async function checkRedisHealth() {
try {
const start = Date.now();
await redis.ping();
const responseTime = Date.now() - start;
return {
status: 'connected',
responseTime,
host: CONFIG.REDIS.HOST,
port: CONFIG.REDIS.PORT
};
} catch (error) {
return {
status: 'disconnected',
error: error instanceof Error ? error.message : 'Unknown error',
host: CONFIG.REDIS.HOST,
port: CONFIG.REDIS.PORT
};
}
}
// 系统信息获取函数
function getSystemInfo() {
const memUsage = process.memoryUsage();
const totalMem = memUsage.heapTotal;
const usedMem = memUsage.heapUsed;
const freeMem = totalMem - usedMem;
return {
memory: {
used: formatBytes(usedMem),
free: formatBytes(freeMem),
total: formatBytes(totalMem),
percent: Math.round((usedMem / totalMem) * 100)
},
cpu: {
percent: getCpuUsage()
},
uptime: process.uptime(),
platform: process.platform,
arch: process.arch,
nodeVersion: process.version
};
}
// 格式化字节数
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}
// 简化的CPU使用率 (基于进程CPU时间)
function getCpuUsage(): number {
const cpuUsage = process.cpuUsage();
const totalCpuTime = (cpuUsage.user + cpuUsage.system) / 1000000; // 转换为秒
const uptime = process.uptime();
return Math.round((totalCpuTime / uptime) * 100);
}
export default router;
+5
View File
@@ -5,6 +5,7 @@ import { Server } from "socket.io"; // socket.io: A library for real-time web ap
import { CONFIG } from "./config/env";
import { corsOptions, corsWSOptions } from "./config/server";
import apiRouter from "./routes/api";
import healthRouter from "./routes/health";
import { setupSocketHandlers } from "./socket/handlers";
const app = express(); // Create an Express application
@@ -19,6 +20,10 @@ setupSocketHandlers(io);
// Make io instance available to routes
app.set('io', io);
// Register health check routes first (for Docker health checks)
app.use(healthRouter);
// Register API routes
app.use(apiRouter);
server.listen(CONFIG.BACKEND_PORT, () => {
+762
View File
@@ -0,0 +1,762 @@
#!/bin/bash
set -e # Exit immediately on error
# Color definitions
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
# Script directory
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DOCKER_SCRIPTS_DIR="$SCRIPT_DIR/docker/scripts"
# Logging helpers
log_info() {
echo -e "${BLUE}$1${NC}"
}
log_success() {
echo -e "${GREEN}$1${NC}"
}
log_warning() {
echo -e "${YELLOW}⚠️ $1${NC}"
}
log_error() {
echo -e "${RED}$1${NC}"
}
# Show help
show_help() {
cat << EOF
PrivyDrop Docker Deployment Script
Usage: $0 [options]
Options:
--domain DOMAIN Specify domain (for HTTPS deployments)
--mode MODE Deployment mode: lan-http|lan-tls|public|full
lan-http: Intranet HTTP (fast start; no TLS)
lan-tls: Intranet HTTPS (self-signed; dev/managed env only)
public: Public HTTP + TURN server
full: Domain + HTTPS (Lets Encrypt) + TURN server
--with-nginx Enable Nginx reverse proxy
--with-turn Enable TURN server
--with-sni443 Force enable 443 SNI routing (full; default enabled)
--no-sni443 Disable 443 SNI routing (full; web listens directly on 443)
--enable-web-https In lan-tls mode, enable self-signed HTTPS on 8443 (no HSTS)
--le-email EMAIL Email for Let's Encrypt (recommended in full mode)
--test-renewal Run 'certbot renew --dry-run' and reload services (verification)
--clean Clean existing containers and data
--help Show help
Examples:
$0 --mode lan-http # LAN HTTP quick start
$0 --mode lan-http --with-turn # LAN HTTP with TURN (NAT-friendly)
$0 --mode lan-tls --enable-web-https # LAN HTTPS (self-signed) on 8443 (dev/managed)
$0 --mode public --with-turn # Public deployment + TURN server (no domain)
$0 --mode full --domain example.com \\
--with-nginx --with-turn --le-email you@domain.com # Full HTTPS deployment (LE auto-issue/renew)
$0 --clean # Clean deployment
Requirements:
- Docker Engine and Docker Compose V2 (command `docker compose`)
EOF
}
# Parse command-line arguments
parse_arguments() {
DOMAIN_NAME=""
DEPLOYMENT_MODE=""
WITH_NGINX=false
WITH_TURN=false
CLEAN_MODE=false
LE_EMAIL=""
WITH_SNI443=false
DISABLE_SNI443=false
ENABLE_WEB_HTTPS=false
TEST_RENEWAL=false
while [[ $# -gt 0 ]]; do
case $1 in
--domain)
DOMAIN_NAME="$2"
shift 2
;;
--mode)
DEPLOYMENT_MODE="$2"
shift 2
;;
--with-nginx)
WITH_NGINX=true
shift
;;
--with-turn)
WITH_TURN=true
shift
;;
--with-sni443)
WITH_SNI443=true
shift
;;
--no-sni443)
DISABLE_SNI443=true
shift
;;
--enable-web-https)
ENABLE_WEB_HTTPS=true
shift
;;
--le-email)
LE_EMAIL="$2"
shift 2
;;
--test-renewal)
TEST_RENEWAL=true
shift
;;
--clean)
CLEAN_MODE=true
shift
;;
--help)
show_help
exit 0
;;
*)
log_error "Unknown argument: $1"
show_help
exit 1
;;
esac
done
# Export variables for other scripts
export DOMAIN_NAME
export DEPLOYMENT_MODE
export WITH_NGINX
export WITH_TURN
}
# Check dependencies
check_dependencies() {
log_info "Checking dependencies..."
local missing_deps=()
if ! command -v docker &> /dev/null; then
missing_deps+=("docker")
fi
if ! docker compose version &> /dev/null; then
missing_deps+=("docker compose (V2)")
fi
if ! command -v curl &> /dev/null; then
missing_deps+=("curl")
fi
if ! command -v openssl &> /dev/null; then
missing_deps+=("openssl")
fi
if [[ ${#missing_deps[@]} -gt 0 ]]; then
log_error "Missing dependencies: ${missing_deps[*]}"
echo ""
echo "Please install the missing dependencies:"
for dep in "${missing_deps[@]}"; do
case $dep in
docker)
echo " Docker: https://docs.docker.com/get-docker/"
;;
"docker compose (V2)")
echo " Docker Compose V2 plugin: https://docs.docker.com/compose/install/"
;;
curl)
echo " curl: sudo apt-get install curl (Ubuntu/Debian)"
;;
openssl)
echo " openssl: sudo apt-get install openssl (Ubuntu/Debian)"
;;
esac
done
exit 1
fi
log_success "Dependency checks passed"
}
# Install and prepare Let's Encrypt (certbot)
ensure_certbot() {
if command -v certbot >/dev/null 2>&1; then
return 0
fi
log_info "Installing certbot (requires sudo)..."
if command -v apt-get >/dev/null 2>&1; then
sudo apt-get update -y && sudo apt-get install -y certbot
else
log_error "apt-get not found. Please install certbot manually or run on a supported system"
exit 1
fi
}
# Write certbot deploy hook: copy certs and hot-reload services after renewal
install_certbot_deploy_hook() {
local repo_dir="$SCRIPT_DIR"
local hook_dir="/etc/letsencrypt/renewal-hooks/deploy"
local hook_file="$hook_dir/privydrop-reload.sh"
local compose_file="$repo_dir/docker-compose.yml"
sudo mkdir -p "$hook_dir"
sudo bash -c "cat > '$hook_file'" << EOF
#!/bin/bash
set -e
REPO_DIR="$repo_dir"
COMPOSE_FILE="$compose_file"
# RENEWED_LINEAGE is provided by certbot and points to live/<domain>
if [[ -z "\$RENEWED_LINEAGE" ]]; then
exit 0
fi
cp "\$RENEWED_LINEAGE/fullchain.pem" "\$REPO_DIR/docker/ssl/server-cert.pem"
cp "\$RENEWED_LINEAGE/privkey.pem" "\$REPO_DIR/docker/ssl/server-key.pem"
chmod 600 "\$REPO_DIR/docker/ssl/server-key.pem" || true
# Hot-reload nginx; restart if it fails
docker compose -f "\$COMPOSE_FILE" exec -T nginx nginx -s reload 2>/dev/null || \
docker compose -f "\$COMPOSE_FILE" restart nginx || true
# Prefer sending HUP to coturn; restart if needed (ignore if disabled)
docker compose -f "\$COMPOSE_FILE" exec -T coturn sh -c 'kill -HUP 1' 2>/dev/null || \
docker compose -f "\$COMPOSE_FILE" restart coturn || true
EOF
sudo chmod +x "$hook_file"
# Attempt to enable systemd timer
if command -v systemctl >/dev/null 2>&1; then
sudo systemctl enable --now certbot.timer 2>/dev/null || true
fi
}
# Ensure renewal is scheduled daily: prefer systemd timer; fallback to cron
ensure_renewal_scheduler() {
if command -v systemctl >/dev/null 2>&1; then
sudo systemctl enable --now certbot.timer 2>/dev/null || true
return 0
fi
# Fallback: cron job every 12 hours
if [ -w /etc/cron.d ] || sudo test -d /etc/cron.d; then
sudo bash -c 'cat > /etc/cron.d/certbot' << 'EOF'
# Auto-renew Let's Encrypt certificates for PrivyDrop
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin
0 */12 * * * root certbot renew -q
EOF
fi
}
# Dry-run renewal test
test_renewal() {
log_info "Running certbot dry-run renewal test..."
if ! command -v certbot >/dev/null 2>&1; then
log_error "certbot not installed. Run full mode issuance first or install certbot manually"
return 1
fi
sudo certbot renew --dry-run || {
log_error "certbot dry-run failed"
return 1
}
# Attempt hot-reload of nginx and coturn similar to deploy-hook
docker compose exec -T nginx nginx -s reload 2>/dev/null || docker compose restart nginx || true
docker compose exec -T coturn sh -c 'kill -HUP 1' 2>/dev/null || docker compose restart coturn || true
log_success "Dry-run renewal completed; services reloaded"
}
# Issue via webroot and enable 443 config
provision_letsencrypt_cert() {
# Only in full mode with nginx enabled and domain set
if [[ "$DEPLOYMENT_MODE" != "full" || "$WITH_NGINX" != "true" ]]; then
return 0
fi
if [[ -z "$DOMAIN_NAME" ]]; then
log_warning "Full mode without --domain; skipping Let's Encrypt"
return 0
fi
if [[ -z "$LE_EMAIL" ]]; then
log_warning "No --le-email specified; using --register-unsafely-without-email"
fi
ensure_certbot
install_certbot_deploy_hook
ensure_renewal_scheduler
mkdir -p docker/letsencrypt-www docker/ssl
# If certificates already exist (including -0001 lineage), skip issuance
if [[ -f "/etc/letsencrypt/live/$DOMAIN_NAME/fullchain.pem" ]] || ls -1d /etc/letsencrypt/live/${DOMAIN_NAME}* >/dev/null 2>&1; then
log_info "Detected existing certificates/lineage; skipping initial issuance"
else
log_info "Issuing Let's Encrypt certificate via webroot..."
local email_args="--email $LE_EMAIL"
if [[ -z "$LE_EMAIL" ]]; then
email_args="--register-unsafely-without-email"
fi
# Requires port 80 reachable and nginx running
sudo certbot certonly --webroot -w "$(pwd)/docker/letsencrypt-www" \
-d "$DOMAIN_NAME" -d "turn.$DOMAIN_NAME" \
$email_args --agree-tos --non-interactive || {
log_error "Certificate issuance failed; please check certbot output"
return 1
}
fi
# Resolve lineage directory (supports -0001/-0002 suffixes) and copy to docker/ssl
local lineage_dir
lineage_dir=$(readlink -f "/etc/letsencrypt/live/$DOMAIN_NAME" 2>/dev/null || true)
if [[ -z "$lineage_dir" || ! -d "$lineage_dir" ]]; then
lineage_dir=$(ls -1d /etc/letsencrypt/live/${DOMAIN_NAME}* 2>/dev/null | sort | tail -1)
fi
if [[ -z "$lineage_dir" || ! -f "$lineage_dir/fullchain.pem" ]]; then
log_error "No valid certificate lineage directory found. Check /etc/letsencrypt/live/${DOMAIN_NAME}*"
return 1
fi
sudo cp "$lineage_dir/fullchain.pem" docker/ssl/server-cert.pem
sudo cp "$lineage_dir/privkey.pem" docker/ssl/server-key.pem
sudo chmod 600 docker/ssl/server-key.pem || true
# Enable 443 config (certs ready): append only; pass SNI flag (enabled by default in full)
local gen_args=(--mode full --domain "$DOMAIN_NAME" --no-clean --ssl-mode letsencrypt)
[[ "$WITH_SNI443" == "true" ]] && gen_args+=(--enable-sni443)
[[ "$DISABLE_SNI443" == "true" ]] && gen_args+=(--no-sni443)
bash "$DOCKER_SCRIPTS_DIR/generate-config.sh" "${gen_args[@]}" || true
# Hot-reload nginx to enable 443
docker compose exec -T nginx nginx -s reload || docker compose restart nginx
}
# Clean existing deployment
clean_deployment() {
if [[ "$CLEAN_MODE" == "true" ]]; then
log_warning "Cleaning existing deployment..."
# Stop and remove containers
if [[ -f "docker-compose.yml" ]]; then
docker compose down -v --remove-orphans 2>/dev/null || true
fi
# After graceful stop, force-clean named containers as fallback
docker stop -t 10 privydrop-nginx privydrop-coturn 2>/dev/null || true
docker rm -f privydrop-nginx privydrop-coturn 2>/dev/null || true
# Fallback: remove project network (if present)
docker network rm privydrop_privydrop-network 2>/dev/null || true
# Remove images
docker images | grep privydrop | awk '{print $3}' | xargs -r docker rmi -f 2>/dev/null || true
# Clean configuration files
rm -rf docker/nginx/conf.d/*.conf docker/ssl/* logs/* .env 2>/dev/null || true
log_success "Cleanup complete"
if [[ $# -eq 1 ]]; then # If only --clean parameter
exit 0
fi
fi
}
# Ensure TURN service starts when requested (--with-turn)
ensure_turn_running() {
if [[ "$WITH_TURN" != "true" ]]; then
return 0
fi
# If not running, start coturn via profile
if ! docker compose ps | grep -q "privydrop-coturn"; then
log_info "Starting TURN service (profile: turn)..."
docker compose --profile turn up -d coturn || true
fi
}
# Environment detection and configuration generation
setup_environment() {
log_info "Setting up environment..."
# Ensure scripts are executable
chmod +x "$DOCKER_SCRIPTS_DIR"/*.sh 2>/dev/null || true
# Run environment detection
local detect_args=""
[[ -n "$DOMAIN_NAME" ]] && detect_args="--domain $DOMAIN_NAME"
[[ -n "$DEPLOYMENT_MODE" ]] && detect_args="$detect_args --mode $DEPLOYMENT_MODE"
[[ "$WITH_NGINX" == "true" ]] && detect_args="$detect_args --with-nginx"
[[ "$WITH_SNI443" == "true" ]] && detect_args="$detect_args --enable-sni443"
[[ "$DISABLE_SNI443" == "true" ]] && detect_args="$detect_args --no-sni443"
[[ "$ENABLE_WEB_HTTPS" == "true" ]] && detect_args="$detect_args --enable-web-https"
if ! bash "$DOCKER_SCRIPTS_DIR/detect-environment.sh" $detect_args; then
log_error "Environment detection failed"
exit 1
fi
# Generate configuration files
if ! bash "$DOCKER_SCRIPTS_DIR/generate-config.sh" $detect_args; then
log_error "Configuration generation failed"
exit 1
fi
log_success "Environment setup complete"
}
# Build and start services
deploy_services() {
log_info "Building and starting services..."
# Ensure log directories exist and relax permissions so containers (coturn/nginx etc.) can write logs
mkdir -p logs logs/nginx logs/backend logs/frontend logs/coturn 2>/dev/null || true
chmod 777 -R logs 2>/dev/null || true
log_info "Log directories prepared and permissions set: ./logs (mode 777)"
# Stop existing services
if docker compose ps | grep -q "Up"; then
log_info "Stopping existing services..."
docker compose down
fi
# Determine enabled services (Compose V2 requires --profile before the subcommand)
local profiles=""
if [[ "$WITH_NGINX" == "true" ]]; then
profiles="$profiles --profile nginx"
fi
if [[ "$WITH_TURN" == "true" ]]; then
profiles="$profiles --profile turn"
fi
# Build images (parallel first, fall back to serial on failure)
log_info "Building Docker images..."
set +e
docker compose build --parallel
local build_status=$?
set -e
if [[ $build_status -ne 0 ]]; then
log_warning "Parallel build failed; falling back to serial build..."
docker compose build
fi
# Start services (--profile must precede up)
log_info "Starting services..."
# shellcheck disable=SC2086
docker compose $profiles up -d
log_success "Services started"
}
# Wait for services to be ready
wait_for_services() {
log_info "Waiting for services to be ready..."
local max_attempts=60
local attempt=0
local services_ready=false
while [[ $attempt -lt $max_attempts ]]; do
local backend_ready=false
local frontend_ready=false
# Check backend health
if curl -f http://localhost:3001/health &> /dev/null; then
backend_ready=true
fi
# Check frontend health
if curl -f http://localhost:3002/api/health &> /dev/null; then
frontend_ready=true
fi
if [[ "$backend_ready" == "true" ]] && [[ "$frontend_ready" == "true" ]]; then
services_ready=true
break
fi
attempt=$((attempt + 1))
echo -n "."
sleep 2
done
echo ""
if [[ "$services_ready" == "true" ]]; then
log_success "All services are ready"
return 0
else
log_error "Service startup timed out"
log_info "View service status: docker compose ps"
log_info "View service logs: docker compose logs -f"
return 1
fi
}
# Run post-deployment checks
post_deployment_checks() {
log_info "Running post-deployment checks..."
# Check container status
log_info "Checking container status..."
docker compose ps
# In full+nginx, add HTTPS health check (if domain defined)
if [[ -f ".env" ]]; then
local dep_mode="$(grep "DEPLOYMENT_MODE=" .env | cut -d'=' -f2)"
local dname="$(grep "DOMAIN_NAME=" .env | cut -d'=' -f2)"
if [[ "$dep_mode" == "full" && -n "$dname" ]]; then
log_info "Test: HTTPS health check https://$dname/api/health"
if curl -fsS "https://$dname/api/health" >/dev/null; then
log_success "HTTPS health check passed"
else
log_warning "HTTPS health check failed. If the certificate was just issued, wait a bit or run: bash docker/scripts/generate-config.sh --mode full --domain $dname --no-clean && docker compose exec -T nginx nginx -s reload"
fi
fi
fi
# Run health-check tests
if [[ -f "test-health-apis.sh" ]]; then
log_info "Running health-check tests..."
if bash test-health-apis.sh; then
log_success "Health-check tests passed"
else
log_warning "Health-check tests failed, but services may still be working"
fi
fi
log_success "Post-deployment checks complete"
}
# Show deployment results
show_deployment_info() {
echo ""
echo -e "${GREEN}🎉 PrivyDrop deployment complete!${NC}"
echo ""
# Read configuration
local local_ip=""
local public_ip=""
local frontend_port=""
local backend_port=""
local https_port=""
local deployment_mode=""
local network_mode=""
local domain_name=""
local turn_enabled_env=""
if [[ -f ".env" ]]; then
local_ip=$(grep "LOCAL_IP=" .env | cut -d'=' -f2)
public_ip=$(grep "PUBLIC_IP=" .env | cut -d'=' -f2)
frontend_port=$(grep "FRONTEND_PORT=" .env | cut -d'=' -f2)
backend_port=$(grep "BACKEND_PORT=" .env | cut -d'=' -f2)
https_port=$(grep "HTTPS_PORT=" .env | cut -d'=' -f2)
deployment_mode=$(grep "DEPLOYMENT_MODE=" .env | cut -d'=' -f2)
network_mode=$(grep "NETWORK_MODE=" .env | cut -d'=' -f2)
domain_name=$(grep "DOMAIN_NAME=" .env | cut -d'=' -f2)
turn_enabled_env=$(grep "TURN_ENABLED=" .env | cut -d'=' -f2)
fi
echo -e "${BLUE}📋 Access Info:${NC}"
# Determine if public scenario (public/full)
local is_public="false"
if [[ "$deployment_mode" == "public" || "$deployment_mode" == "full" || "$network_mode" == "public" ]]; then
is_public="true"
fi
if [[ "$is_public" == "true" ]]; then
# For public scenarios, prefer domain, then public IP
if [[ -n "$domain_name" ]]; then
if [[ "$WITH_NGINX" == "true" || "$deployment_mode" == "full" ]]; then
echo " Public access: https://$domain_name"
echo " API: https://$domain_name"
else
echo " Public access: http://$domain_name:${frontend_port:-3002}"
echo " API: http://$domain_name:${backend_port:-3001}"
fi
elif [[ -n "$public_ip" ]]; then
if [[ "$WITH_NGINX" == "true" ]]; then
echo " Public access: http://$public_ip"
echo " API: http://$public_ip"
else
echo " Public access: http://$public_ip:${frontend_port:-3002}"
echo " API: http://$public_ip:${backend_port:-3001}"
fi
else
# Fallback: show LAN and localhost if public IP is unavailable
echo " Frontend: http://localhost:${frontend_port:-3002}"
echo " Backend API: http://localhost:${backend_port:-3001}"
fi
else
# Private/basic: localhost + LAN
if [[ "$WITH_NGINX" == "true" ]]; then
# When Nginx is enabled and frontend uses same-origin API, prefer the gateway as the primary entry
echo " Frontend: http://localhost"
echo " API: http://localhost"
else
echo " Frontend: http://localhost:${frontend_port:-3002}"
echo " Backend API: http://localhost:${backend_port:-3001}"
fi
if [[ -n "$local_ip" ]] && [[ "$local_ip" != "127.0.0.1" ]]; then
echo ""
echo -e "${BLUE}🌐 LAN Access:${NC}"
if [[ "$WITH_NGINX" == "true" ]]; then
echo " Frontend: http://$local_ip"
echo " API: http://$local_ip"
else
echo " Frontend: http://$local_ip:${frontend_port:-3002}"
echo " Backend API: http://$local_ip:${backend_port:-3001}"
fi
fi
fi
if [[ "$WITH_NGINX" == "true" ]]; then
echo ""
echo -e "${BLUE}🔀 Nginx Proxy:${NC}"
if [[ -n "$domain_name" ]]; then
echo " HTTP: http://$domain_name"
if [[ -f "docker/ssl/server-cert.pem" ]]; then
echo " HTTPS: https://$domain_name"
fi
elif [[ -n "$public_ip" ]]; then
echo " HTTP: http://$public_ip"
if [[ -f "docker/ssl/server-cert.pem" ]]; then
# In non-domain cases, show HTTPS with explicit port (e.g., lan-tls uses 8443)
if [[ -n "$https_port" && "$https_port" != "443" ]]; then
echo " HTTPS: https://$public_ip:$https_port"
else
echo " HTTPS: https://$public_ip"
fi
fi
else
echo " HTTP: http://localhost"
if [[ -f "docker/ssl/server-cert.pem" ]]; then
# Show correct HTTPS endpoint based on configured port
if [[ -n "$https_port" && "$https_port" != "443" ]]; then
echo " HTTPS: https://localhost:$https_port"
if [[ -n "$local_ip" && "$local_ip" != "127.0.0.1" ]]; then
echo " HTTPS (LAN): https://$local_ip:$https_port"
fi
else
echo " HTTPS: https://localhost"
fi
fi
fi
fi
echo ""
echo -e "${BLUE}🔧 Management Commands:${NC}"
echo " Status: docker compose ps"
echo " Logs: docker compose logs -f [service]"
echo " Restart: docker compose restart [service]"
echo " Stop: docker compose down"
echo " Full cleanup: $0 --clean"
if [[ -f "docker/ssl/ca-cert.pem" ]]; then
echo ""
echo -e "${BLUE}🔒 SSL Certificates:${NC}"
echo " CA certificate: docker/ssl/ca-cert.pem"
echo " To trust HTTPS, import the CA certificate into your browser"
fi
if [[ "$WITH_TURN" == "true" || "$turn_enabled_env" == "true" ]]; then
local turn_username=""
local turn_realm=""
if [[ -f ".env" ]]; then
turn_username=$(grep "TURN_USERNAME=" .env | cut -d'=' -f2)
turn_realm=$(grep "TURN_REALM=" .env | cut -d'=' -f2)
fi
echo ""
echo -e "${BLUE}🔄 TURN Server:${NC}"
# Prefer domain for TURN info; otherwise show public IP
if [[ -n "$domain_name" ]]; then
echo " STUN: stun:${domain_name}:3478"
echo " TURN (UDP): turn:${domain_name}:3478"
echo " TURN (TLS): turns:turn.${domain_name}:443 (if 443 SNI split is configured)"
elif [[ -n "$public_ip" ]]; then
echo " STUN: stun:${public_ip}:3478"
echo " TURN: turn:${public_ip}:3478"
else
echo " STUN: stun:${local_ip}:3478"
echo " TURN: turn:${local_ip}:3478"
fi
echo " Username: ${turn_username:-privydrop}"
echo " Password: (stored in .env)"
fi
echo ""
echo -e "${YELLOW}💡 Tips:${NC}"
echo " - First run may take several minutes to download and build images"
echo " - If issues occur, check logs: docker compose logs -f"
echo " - More help: $0 --help"
echo ""
# Public scenario: for domain + HTTPS setup steps, see docs
if [[ "$is_public" == "true" && -z "$domain_name" ]]; then
echo -e "${BLUE}🌍 Domain + HTTPS guide:${NC} see docs/DEPLOYMENT_docker.md or docs/DEPLOYMENT_docker.zh-CN.md"
fi
}
# Main function
main() {
echo -e "${BLUE}=== PrivyDrop Docker One-Click Deployment ===${NC}"
echo ""
# Parse command-line arguments
parse_arguments "$@"
# Check dependencies
check_dependencies
echo ""
# If only testing renewal, run and exit
if [[ "$TEST_RENEWAL" == "true" ]]; then
test_renewal && exit 0 || exit 1
fi
# Clean mode
clean_deployment
# If only cleaning (no other args), exit early to skip env detection
if [[ "$CLEAN_MODE" == "true" && -z "$DEPLOYMENT_MODE" && "$WITH_NGINX" == "false" && "$WITH_TURN" == "false" && -z "$DOMAIN_NAME" ]]; then
log_success "Cleanup complete (clean-only mode). Exiting."
exit 0
fi
# Environment setup
setup_environment
echo ""
# Deploy services
deploy_services
echo ""
# If full + nginx, automatically issue certs and enable 443
provision_letsencrypt_cert || true
# Ensure TURN is running (when requested with --with-turn)
ensure_turn_running || true
# Wait for services to be ready
if wait_for_services; then
echo ""
post_deployment_checks
show_deployment_info
else
log_error "Deployment failed. Please check logs: docker compose logs"
exit 1
fi
}
# Trap interrupt signals
trap 'log_warning "Deployment interrupted"; exit 1' INT TERM
# Run main function
main "$@"
+144
View File
@@ -0,0 +1,144 @@
services:
# Redis cache service
redis:
image: redis:7-alpine
container_name: privydrop-redis
restart: unless-stopped
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
volumes:
- redis_data:/data
networks:
- privydrop-network
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 3
start_period: 5s
# Backend signaling service
backend:
build:
context: ./backend
dockerfile: Dockerfile
args:
- HTTP_PROXY=${HTTP_PROXY}
- HTTPS_PROXY=${HTTPS_PROXY}
- NO_PROXY=${NO_PROXY}
container_name: privydrop-backend
restart: unless-stopped
environment:
- NODE_ENV=production
- BACKEND_PORT=3001
- REDIS_HOST=redis
- REDIS_PORT=6379
- CORS_ORIGIN=${CORS_ORIGIN:-http://localhost}
ports:
- "${BACKEND_PORT:-3001}:3001"
depends_on:
redis:
condition: service_healthy
networks:
- privydrop-network
volumes:
- ./logs:/app/logs
healthcheck:
test: ["CMD", "node", "health-check.js"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
# Frontend app
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
args:
- HTTP_PROXY=${HTTP_PROXY}
- HTTPS_PROXY=${HTTPS_PROXY}
- NO_PROXY=${NO_PROXY}
- NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
- NEXT_PUBLIC_TURN_HOST=${NEXT_PUBLIC_TURN_HOST}
- NEXT_PUBLIC_TURN_USERNAME=${NEXT_PUBLIC_TURN_USERNAME}
- NEXT_PUBLIC_TURN_PASSWORD=${NEXT_PUBLIC_TURN_PASSWORD}
- NEXT_IMAGE_UNOPTIMIZED=${NEXT_IMAGE_UNOPTIMIZED:-true}
container_name: privydrop-frontend
restart: unless-stopped
environment:
- NODE_ENV=production
- NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL:-http://localhost:3001}
- BACKEND_INTERNAL_URL=${BACKEND_INTERNAL_URL:-http://backend:3001}
- NEXT_IMAGE_UNOPTIMIZED=${NEXT_IMAGE_UNOPTIMIZED:-true}
- PORT=3002
- HOSTNAME=0.0.0.0
- NODE_EXTRA_CA_CERTS=/opt/privydrop/ssl/ca-cert.pem
ports:
- "${FRONTEND_PORT:-3002}:3002"
depends_on:
backend:
condition: service_healthy
networks:
- privydrop-network
volumes:
- ./docker/ssl:/opt/privydrop/ssl:ro
healthcheck:
test: ["CMD", "node", "health-check.js"]
interval: 30s
timeout: 10s
retries: 3
start_period: 120s
# Nginx reverse proxy
nginx:
image: nginx:alpine
container_name: privydrop-nginx
restart: unless-stopped
ports:
- "${HTTP_PORT:-80}:80"
- "${HTTPS_PORT:-443}:${DOCKER_HTTPS_CONTAINER_PORT:-443}"
volumes:
- ./docker/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./docker/nginx/conf.d:/etc/nginx/conf.d:ro
- ./docker/letsencrypt-www:/var/www/certbot:ro
- ./docker/ssl:/etc/nginx/ssl:ro
- ./logs/nginx:/var/log/nginx
depends_on:
- frontend
- backend
networks:
- privydrop-network
profiles:
- nginx
# TURN/STUN server (optional, for NAT traversal)
coturn:
image: coturn/coturn:4.6.2
container_name: privydrop-coturn
restart: unless-stopped
ports:
- "3478:3478/tcp"
- "3478:3478/udp"
- "5349:5349/tcp"
- "5349:5349/udp"
- "${TURN_MIN_PORT:-49152}-${TURN_MAX_PORT:-49252}:${TURN_MIN_PORT:-49152}-${TURN_MAX_PORT:-49252}/udp"
volumes:
- ./docker/coturn/turnserver.conf:/etc/coturn/turnserver.conf:ro
- ./docker/ssl:/etc/ssl/certs:ro
- ./logs/coturn:/var/log
networks:
- privydrop-network
profiles:
- turn
command: ["-c", "/etc/coturn/turnserver.conf"]
networks:
privydrop-network:
driver: bridge
ipam:
config:
- subnet: 172.20.0.0/16
volumes:
redis_data:
driver: local
+487
View File
@@ -0,0 +1,487 @@
#!/bin/bash
# Color definitions
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Global variables
NETWORK_MODE=""
LOCAL_IP=""
PUBLIC_IP=""
DEPLOYMENT_MODE="basic"
FORCED_MODE=""
LOCAL_IP_OVERRIDE=""
declare -a IP_CANDIDATES=()
declare -A __SEEN_IPS=()
add_ip_candidate() {
local ip="$1"
[[ -z "$ip" ]] && return
[[ "$ip" == "127."* ]] && return
[[ "$ip" == "0.0.0.0" ]] && return
if [[ -z "${__SEEN_IPS[$ip]}" ]]; then
IP_CANDIDATES+=("$ip")
__SEEN_IPS[$ip]=1
fi
}
is_rfc1918_ip() {
local ip="$1"
case "$ip" in
10.*|192.168.*|172.1[6-9].*|172.2[0-9].*|172.3[0-1].*)
return 0
;;
*)
return 1
;;
esac
}
is_cgnat_ip() {
local ip="$1"
case "$ip" in
100.*)
return 0
;;
*)
return 1
;;
esac
}
is_reserved_benchmark_ip() {
local ip="$1"
case "$ip" in
198.18.*|198.19.*)
return 0
;;
*)
return 1
;;
esac
}
is_link_local_ip() {
local ip="$1"
case "$ip" in
169.254.*)
return 0
;;
*)
return 1
;;
esac
}
is_routable_public_ip() {
local ip="$1"
if [[ -z "$ip" ]]; then
return 1
fi
if is_rfc1918_ip "$ip"; then
return 1
fi
if is_cgnat_ip "$ip"; then
return 1
fi
if is_reserved_benchmark_ip "$ip"; then
return 1
fi
case "$ip" in
127.*|169.254.*)
return 1
;;
*)
return 0
;;
esac
}
collect_ip_candidates() {
IP_CANDIDATES=()
unset __SEEN_IPS
declare -A __SEEN_IPS=()
if command -v hostname >/dev/null 2>&1; then
local host_ips
host_ips=$(hostname -I 2>/dev/null || true)
for ip in $host_ips; do
add_ip_candidate "$ip"
done
fi
if command -v ip >/dev/null 2>&1; then
while IFS= read -r ip; do
add_ip_candidate "$ip"
done < <(ip -o -4 addr show scope global 2>/dev/null | awk '{print $4}' | cut -d/ -f1)
fi
if command -v ifconfig >/dev/null 2>&1; then
while IFS= read -r ip; do
add_ip_candidate "$ip"
done < <(ifconfig 2>/dev/null | awk '/inet / {print $2}' | grep -E '^[0-9]+(\.[0-9]+){3}$')
fi
if command -v ip >/dev/null 2>&1; then
local route_ip
route_ip=$(ip route get 1.1.1.1 2>/dev/null | awk '{for (i=1;i<=NF;i++) if ($i=="src") {print $(i+1); exit}}')
add_ip_candidate "$route_ip"
fi
if [[ ${#IP_CANDIDATES[@]} -eq 0 ]]; then
local fallback
fallback=$(hostname -I 2>/dev/null | awk '{print $1}')
add_ip_candidate "$fallback"
fi
}
resolve_local_ip() {
if [[ -n "$LOCAL_IP_OVERRIDE" ]]; then
LOCAL_IP="$LOCAL_IP_OVERRIDE"
return
fi
collect_ip_candidates
if [[ ${#IP_CANDIDATES[@]} -eq 0 ]]; then
LOCAL_IP=""
return
fi
local ip
for ip in "${IP_CANDIDATES[@]}"; do
if is_rfc1918_ip "$ip"; then
LOCAL_IP="$ip"
return
fi
done
for ip in "${IP_CANDIDATES[@]}"; do
if is_cgnat_ip "$ip"; then
LOCAL_IP="$ip"
return
fi
done
for ip in "${IP_CANDIDATES[@]}"; do
if is_reserved_benchmark_ip "$ip"; then
continue
fi
if is_link_local_ip "$ip"; then
continue
fi
LOCAL_IP="$ip"
return
done
LOCAL_IP="${IP_CANDIDATES[0]}"
}
# Logging helpers
log_info() {
echo -e "${BLUE}$1${NC}"
}
log_success() {
echo -e "${GREEN}$1${NC}"
}
log_warning() {
echo -e "${YELLOW}⚠️ $1${NC}"
}
log_error() {
echo -e "${RED}$1${NC}"
}
# Detect network environment
detect_network_environment() {
log_info "Detecting network environment..."
resolve_local_ip
if [[ -z "$LOCAL_IP" ]]; then
LOCAL_IP="127.0.0.1"
log_warning "Unable to detect host IP; using default: $LOCAL_IP"
fi
if [[ "$FORCED_MODE" == "private" ]]; then
NETWORK_MODE="private"
PUBLIC_IP=""
log_info "Network mode set via parameters: $NETWORK_MODE"
echo " Local IP: $LOCAL_IP"
return 0
fi
local mode_guess="private"
local printed_prompt_info="false"
PUBLIC_IP=""
if curl -s --connect-timeout 5 --max-time 10 ifconfig.me > /dev/null 2>&1; then
PUBLIC_IP=$(curl -s --connect-timeout 5 --max-time 10 ifconfig.me 2>/dev/null || echo "")
if [[ -n "$PUBLIC_IP" ]]; then
if is_routable_public_ip "$PUBLIC_IP"; then
mode_guess="public"
else
log_warning "Public IP is test/reserved range; treating as private"
fi
else
log_warning "Public connectivity unstable; treating as private"
fi
fi
if [[ -z "$FORCED_MODE" ]]; then
if [[ "$mode_guess" == "public" ]]; then
NETWORK_MODE="public"
else
NETWORK_MODE="private"
fi
else
NETWORK_MODE="$FORCED_MODE"
if [[ "$FORCED_MODE" == "public" && -z "$PUBLIC_IP" ]]; then
log_warning "Could not detect public IP; continuing as public mode. Please verify network config"
fi
fi
if [[ "$NETWORK_MODE" != "public" ]]; then
PUBLIC_IP=""
fi
if [[ "$FORCED_MODE" == "public" ]]; then
log_info "Network mode set via parameters: $NETWORK_MODE"
elif [[ "$NETWORK_MODE" == "public" ]]; then
log_success "Public network detected"
else
log_success "Private network detected"
fi
if [[ "$printed_prompt_info" == "false" ]]; then
echo " Local IP: $LOCAL_IP"
if [[ "$NETWORK_MODE" == "public" && -n "$PUBLIC_IP" ]]; then
echo " Public IP: $PUBLIC_IP"
fi
fi
}
# Check system resources
check_system_resources() {
log_info "Checking system resources..."
local warnings=0
# Check memory
if command -v free >/dev/null 2>&1; then
TOTAL_MEM=$(free -m | awk 'NR==2{print $2}')
if [[ $TOTAL_MEM -lt 512 ]]; then
log_error "Insufficient memory: ${TOTAL_MEM}MB (512MB+ recommended)"
return 1
elif [[ $TOTAL_MEM -lt 1024 ]]; then
log_warning "Low memory: ${TOTAL_MEM}MB (1GB+ recommended)"
warnings=$((warnings + 1))
else
log_success "Memory OK: ${TOTAL_MEM}MB"
fi
else
log_warning "Unable to read memory usage"
warnings=$((warnings + 1))
fi
# Check disk usage
DISK_USAGE=$(df -h / | awk 'NR==2{print $5}' | sed 's/%//')
if [[ $DISK_USAGE -gt 95 ]]; then
log_error "Insufficient disk space: ${DISK_USAGE}% used"
return 1
elif [[ $DISK_USAGE -gt 80 ]]; then
log_warning "Disk space tight: ${DISK_USAGE}% used"
warnings=$((warnings + 1))
else
log_success "Disk space OK: ${DISK_USAGE}% used"
fi
# Check available disk space
AVAILABLE_SPACE=$(df -BG / | awk 'NR==2{print $4}' | sed 's/G//')
if [[ $AVAILABLE_SPACE -lt 2 ]]; then
log_error "Not enough free disk space: ${AVAILABLE_SPACE}GB (2GB+ recommended)"
return 1
fi
if [[ $warnings -gt 0 ]]; then
log_warning "System resource check passed with $warnings warning(s)"
else
log_success "System resource check passed"
fi
return 0
}
# Validate Docker environment
verify_docker_installation() {
log_info "Checking Docker environment..."
if ! command -v docker &> /dev/null; then
log_error "Docker is not installed"
echo "Please install Docker: https://docs.docker.com/get-docker/"
return 1
fi
if ! command -v docker-compose &> /dev/null && ! docker compose version &> /dev/null; then
log_error "Docker Compose is not installed"
echo "Please install Docker Compose: https://docs.docker.com/compose/install/"
return 1
fi
# Check Docker service status
if ! docker info &> /dev/null; then
log_error "Docker service is not running"
echo "Please start the Docker service"
return 1
fi
# Check Docker version
DOCKER_VERSION=$(docker --version | grep -oE '[0-9]+\.[0-9]+' | head -1)
log_success "Docker version: $DOCKER_VERSION"
# Check Docker Compose version
if command -v docker-compose &> /dev/null; then
COMPOSE_VERSION=$(docker-compose --version | grep -oE '[0-9]+\.[0-9]+' | head -1)
log_success "Docker Compose version: $COMPOSE_VERSION"
else
COMPOSE_VERSION=$(docker compose version --short 2>/dev/null || echo "built-in")
log_success "Docker Compose version: $COMPOSE_VERSION"
fi
return 0
}
# Check port usage
check_port_availability() {
local ports="$1"
log_info "Checking port usage..."
local occupied_ports=()
IFS=',' read -ra PORT_ARRAY <<< "$ports"
for port in "${PORT_ARRAY[@]}"; do
port=$(echo "$port" | xargs) # Trim spaces
if command -v ss >/dev/null 2>&1; then
if ss -tuln | grep -q ":$port "; then
occupied_ports+=("$port")
fi
elif command -v netstat >/dev/null 2>&1; then
if netstat -tuln 2>/dev/null | grep -q ":$port "; then
occupied_ports+=("$port")
fi
else
log_warning "Unable to check port usage (missing ss and netstat)"
return 0
fi
done
if [[ ${#occupied_ports[@]} -gt 0 ]]; then
log_warning "Ports in use: ${occupied_ports[*]}"
log_info "Change ports in .env, or run './deploy.sh --clean' / 'docker-compose down' to clean old containers"
else
log_success "All ports available"
fi
}
# Detect deployment mode
detect_deployment_mode() {
log_info "Determining deployment mode..."
if [[ "$NETWORK_MODE" == "public" ]] && [[ -n "$DOMAIN_NAME" ]]; then
DEPLOYMENT_MODE="full"
log_success "Deployment mode: full (HTTPS + TURN server)"
elif [[ "$NETWORK_MODE" == "public" ]]; then
DEPLOYMENT_MODE="public"
log_success "Deployment mode: public (HTTP + TURN)"
else
DEPLOYMENT_MODE="basic"
log_success "Deployment mode: basic (intranet HTTP)"
fi
}
# Main function
main() {
echo -e "${BLUE}=== PrivyDrop Docker Environment Check ===${NC}\n"
# Parse command-line arguments
while [[ $# -gt 0 ]]; do
case $1 in
--domain)
DOMAIN_NAME="$2"
shift 2
;;
--mode)
DEPLOYMENT_MODE="$2"
case "$2" in
private|basic)
FORCED_MODE="private"
;;
public|full)
FORCED_MODE="public"
;;
*)
FORCED_MODE=""
;;
esac
shift 2
;;
--local-ip)
LOCAL_IP_OVERRIDE="$2"
shift 2
;;
*)
shift
;;
esac
done
# Run checks
detect_network_environment
echo ""
if ! check_system_resources; then
log_error "System resource check failed; resolve resource issues and retry"
exit 1
fi
echo ""
if ! verify_docker_installation; then
log_error "Docker environment check failed; please install and start Docker"
exit 1
fi
echo ""
check_port_availability "80,443,3002,3001,3478,5349,6379"
echo ""
detect_deployment_mode
echo ""
log_success "Environment check complete!"
echo -e "${BLUE}Results:${NC}"
echo " Network mode: $NETWORK_MODE"
echo " Local IP: $LOCAL_IP"
[[ -n "$PUBLIC_IP" ]] && echo " Public IP: $PUBLIC_IP"
echo " Deployment mode: $DEPLOYMENT_MODE"
# Export env vars for other scripts
export NETWORK_MODE
export LOCAL_IP
export PUBLIC_IP
export DEPLOYMENT_MODE
export DOMAIN_NAME
export LOCAL_IP_OVERRIDE
return 0
}
# If the script is executed directly
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
main "$@"
fi
+1086
View File
File diff suppressed because it is too large Load Diff
+420
View File
@@ -0,0 +1,420 @@
#!/bin/bash
# PrivyDrop Docker deployment test script
# Validate deployment integrity and functionality
# Color definitions
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
# Test result counters
TESTS_PASSED=0
TESTS_FAILED=0
TOTAL_TESTS=0
# Logging helpers
log_info() {
echo -e "${BLUE}$1${NC}"
}
log_success() {
echo -e "${GREEN}$1${NC}"
TESTS_PASSED=$((TESTS_PASSED + 1))
}
log_error() {
echo -e "${RED}$1${NC}"
TESTS_FAILED=$((TESTS_FAILED + 1))
}
log_warning() {
echo -e "${YELLOW}⚠️ $1${NC}"
}
# Test functions
run_test() {
local test_name="$1"
local test_command="$2"
TOTAL_TESTS=$((TOTAL_TESTS + 1))
log_info "Test: $test_name"
if eval "$test_command" >/dev/null 2>&1; then
log_success "$test_name"
return 0
else
log_error "$test_name"
return 1
fi
}
# Docker environment tests
test_docker_environment() {
echo -e "${BLUE}=== Docker Environment Tests ===${NC}"
run_test "Docker installed" "command -v docker"
run_test "Docker daemon running" "docker info"
run_test "Docker Compose available" "docker-compose --version || docker compose version"
echo ""
}
# Container status tests
test_container_status() {
echo -e "${BLUE}=== Container Status Tests ===${NC}"
# Check if containers exist and are running
local containers=("privydrop-redis" "privydrop-backend" "privydrop-frontend")
for container in "${containers[@]}"; do
run_test "Container $container is running" "docker ps | grep -q $container"
done
# Check container health
for container in "${containers[@]}"; do
if docker ps --format "table {{.Names}}\t{{.Status}}" | grep -q "$container.*healthy"; then
log_success "Container $container health OK"
TESTS_PASSED=$((TESTS_PASSED + 1))
else
log_warning "Container $container health unknown or unhealthy"
fi
TOTAL_TESTS=$((TOTAL_TESTS + 1))
done
echo ""
}
# Network connectivity tests
test_network_connectivity() {
echo -e "${BLUE}=== Network Connectivity Tests ===${NC}"
# Test port connectivity
local ports=("3002:Frontend" "3001:Backend" "6379:Redis")
for port_info in "${ports[@]}"; do
local port=$(echo "$port_info" | cut -d':' -f1)
local service=$(echo "$port_info" | cut -d':' -f2)
run_test "$service port $port reachable" "nc -z localhost $port"
done
# Test inter-container networking
run_test "Backend can connect to Redis" "docker-compose exec -T backend sh -c 'nc -z redis 6379'"
run_test "Frontend can reach backend" "curl -f http://localhost:3001/health"
echo ""
}
# API functionality tests
test_api_functionality() {
echo -e "${BLUE}=== API Functionality Tests ===${NC}"
# Health check APIs
run_test "Backend health check API" "curl -f http://localhost:3001/health"
run_test "Frontend health check API" "curl -f http://localhost:3002/api/health"
# Backend detailed health check
if curl -f http://localhost:3001/health/detailed >/dev/null 2>&1; then
local redis_status=$(curl -s http://localhost:3001/health/detailed | jq -r '.dependencies.redis.status' 2>/dev/null)
if [[ "$redis_status" == "connected" ]]; then
log_success "Redis connection OK"
TESTS_PASSED=$((TESTS_PASSED + 1))
else
log_error "Redis connection issue"
TESTS_FAILED=$((TESTS_FAILED + 1))
fi
else
log_error "Detailed health check API unavailable"
TESTS_FAILED=$((TESTS_FAILED + 1))
fi
TOTAL_TESTS=$((TOTAL_TESTS + 1))
# Application API tests
run_test "Get room API" "curl -f http://localhost:3001/api/get_room"
run_test "Create room API" "curl -f -X POST -H 'Content-Type: application/json' -d '{\"roomId\":\"test123\"}' http://localhost:3001/api/create_room"
echo ""
}
# WebRTC functionality tests
test_webrtc_functionality() {
echo -e "${BLUE}=== WebRTC Functionality Tests ===${NC}"
# Test frontend page load
if curl -f http://localhost:3002 >/dev/null 2>&1; then
log_success "Frontend page reachable"
TESTS_PASSED=$((TESTS_PASSED + 1))
else
log_error "Frontend page not reachable"
TESTS_FAILED=$((TESTS_FAILED + 1))
fi
TOTAL_TESTS=$((TOTAL_TESTS + 1))
# Test Socket.IO connection (basic)
if curl -f http://localhost:3001/socket.io/socket.io.js >/dev/null 2>&1; then
log_success "Socket.IO client script reachable"
TESTS_PASSED=$((TESTS_PASSED + 1))
else
log_error "Socket.IO client script not reachable"
TESTS_FAILED=$((TESTS_FAILED + 1))
fi
TOTAL_TESTS=$((TOTAL_TESTS + 1))
echo ""
}
# Performance tests
test_performance() {
echo -e "${BLUE}=== Performance Tests ===${NC}"
# Memory usage test
local backend_memory=$(docker stats --no-stream --format "table {{.Container}}\t{{.MemUsage}}" | grep privydrop-backend | awk '{print $2}' | cut -d'/' -f1)
local frontend_memory=$(docker stats --no-stream --format "table {{.Container}}\t{{.MemUsage}}" | grep privydrop-frontend | awk '{print $2}' | cut -d'/' -f1)
if [[ -n "$backend_memory" ]]; then
log_info "Backend memory usage: $backend_memory"
fi
if [[ -n "$frontend_memory" ]]; then
log_info "Frontend memory usage: $frontend_memory"
fi
# Response time test
local response_time=$(curl -o /dev/null -s -w '%{time_total}' http://localhost:3001/health)
if (( $(echo "$response_time < 1.0" | bc -l) )); then
log_success "API response time OK: ${response_time}s"
TESTS_PASSED=$((TESTS_PASSED + 1))
else
log_warning "API response time slow: ${response_time}s"
fi
TOTAL_TESTS=$((TOTAL_TESTS + 1))
echo ""
}
# Security tests
test_security() {
echo -e "${BLUE}=== Security Tests ===${NC}"
# Check container users
local backend_user=$(docker-compose exec -T backend whoami 2>/dev/null || echo "unknown")
local frontend_user=$(docker-compose exec -T frontend whoami 2>/dev/null || echo "unknown")
if [[ "$backend_user" != "root" ]]; then
log_success "Backend container uses non-root user: $backend_user"
TESTS_PASSED=$((TESTS_PASSED + 1))
else
log_warning "Backend container runs as root"
fi
TOTAL_TESTS=$((TOTAL_TESTS + 1))
if [[ "$frontend_user" != "root" ]]; then
log_success "Frontend container uses non-root user: $frontend_user"
TESTS_PASSED=$((TESTS_PASSED + 1))
else
log_warning "Frontend container runs as root"
fi
TOTAL_TESTS=$((TOTAL_TESTS + 1))
# Check for sensitive info leakage
if curl -s http://localhost:3001/health/detailed | grep -q "password\|secret\|key" >/dev/null 2>&1; then
log_warning "Health check API may leak sensitive info"
else
log_success "Health check API does not leak sensitive info"
TESTS_PASSED=$((TESTS_PASSED + 1))
fi
TOTAL_TESTS=$((TOTAL_TESTS + 1))
echo ""
}
# Logging tests
test_logging() {
echo -e "${BLUE}=== Logging Tests ===${NC}"
# Check log directories
if [[ -d "logs" ]]; then
log_success "Log directory exists"
TESTS_PASSED=$((TESTS_PASSED + 1))
else
log_warning "Log directory does not exist"
fi
TOTAL_TESTS=$((TOTAL_TESTS + 1))
# Check log files
local log_files=("logs/backend" "logs/frontend")
for log_dir in "${log_files[@]}"; do
if [[ -d "$log_dir" ]]; then
log_success "Log directory $log_dir exists"
TESTS_PASSED=$((TESTS_PASSED + 1))
else
log_info "Log directory $log_dir not found (may be normal)"
fi
TOTAL_TESTS=$((TOTAL_TESTS + 1))
done
echo ""
}
# Configuration file tests
test_configuration() {
echo -e "${BLUE}=== Configuration File Tests ===${NC}"
# Check env file
if [[ -f ".env" ]]; then
log_success ".env file exists"
TESTS_PASSED=$((TESTS_PASSED + 1))
# Check key configuration entries
local required_vars=("LOCAL_IP" "CORS_ORIGIN" "NEXT_PUBLIC_API_URL")
for var in "${required_vars[@]}"; do
if grep -q "^$var=" .env; then
log_success "Config $var is set"
TESTS_PASSED=$((TESTS_PASSED + 1))
else
log_error "Config $var is not set"
TESTS_FAILED=$((TESTS_FAILED + 1))
fi
TOTAL_TESTS=$((TOTAL_TESTS + 1))
done
else
log_error ".env file not found"
TESTS_FAILED=$((TESTS_FAILED + 1))
fi
TOTAL_TESTS=$((TOTAL_TESTS + 1))
# Check Docker Compose file
if [[ -f "docker-compose.yml" ]]; then
log_success "docker-compose.yml exists"
TESTS_PASSED=$((TESTS_PASSED + 1))
else
log_error "docker-compose.yml not found"
TESTS_FAILED=$((TESTS_FAILED + 1))
fi
TOTAL_TESTS=$((TOTAL_TESTS + 1))
echo ""
}
# Cleanup tests
test_cleanup() {
echo -e "${BLUE}=== Cleanup Tests ===${NC}"
# Verify cleanup commands work
if [[ -f "deploy.sh" ]]; then
log_success "Deployment script exists"
TESTS_PASSED=$((TESTS_PASSED + 1))
# Test help command
if bash deploy.sh --help >/dev/null 2>&1; then
log_success "Deployment script help works"
TESTS_PASSED=$((TESTS_PASSED + 1))
else
log_error "Deployment script help fails"
TESTS_FAILED=$((TESTS_FAILED + 1))
fi
else
log_error "Deployment script not found"
TESTS_FAILED=$((TESTS_FAILED + 1))
fi
TOTAL_TESTS=$((TOTAL_TESTS + 2))
echo ""
}
# Generate test report
generate_report() {
echo -e "${BLUE}=== Test Report ===${NC}"
echo ""
echo "📊 Test stats:"
echo " Total tests: $TOTAL_TESTS"
echo -e " Passed: ${GREEN}$TESTS_PASSED${NC}"
echo -e " Failed: ${RED}$TESTS_FAILED${NC}"
local success_rate=$((TESTS_PASSED * 100 / TOTAL_TESTS))
echo " Success rate: $success_rate%"
echo ""
echo "📋 System info:"
echo " Docker version: $(docker --version)"
echo " Docker Compose version: $(docker-compose --version 2>/dev/null || docker compose version 2>/dev/null || echo 'unknown')"
echo " OS: $(uname -s) $(uname -r)"
echo " Test time: $(date)"
echo ""
if [[ $TESTS_FAILED -eq 0 ]]; then
echo -e "${GREEN}🎉 All tests passed! PrivyDrop deployment successful!${NC}"
echo ""
echo "🔗 Access links:"
echo " Frontend: http://localhost:3002"
echo " Backend API: http://localhost:3001"
# Show LAN access addresses
if [[ -f ".env" ]]; then
local local_ip=$(grep "LOCAL_IP=" .env | cut -d'=' -f2)
if [[ -n "$local_ip" && "$local_ip" != "127.0.0.1" ]]; then
echo ""
echo "🌐 LAN access:"
echo " Frontend: http://$local_ip:3002"
echo " Backend API: http://$local_ip:3001"
fi
fi
return 0
else
echo -e "${RED}$TESTS_FAILED test(s) failed${NC}"
echo ""
echo "🔧 Troubleshooting tips:"
echo " 1. View container status: docker-compose ps"
echo " 2. View container logs: docker-compose logs -f"
echo " 3. Redeploy: bash deploy.sh"
echo " 4. Clean and redeploy: bash deploy.sh --clean"
return 1
fi
}
# Main function
main() {
echo -e "${BLUE}=== PrivyDrop Docker Deployment Tests ===${NC}"
echo ""
# Check required tools
local missing_tools=()
for tool in curl jq bc nc; do
if ! command -v "$tool" >/dev/null 2>&1; then
missing_tools+=("$tool")
fi
done
if [[ ${#missing_tools[@]} -gt 0 ]]; then
log_warning "Missing test tools: ${missing_tools[*]}"
log_info "Suggested install: sudo apt-get install curl jq bc netcat"
echo ""
fi
# Run all tests
test_docker_environment
test_container_status
test_network_connectivity
test_api_functionality
test_webrtc_functionality
test_performance
test_security
test_logging
test_configuration
test_cleanup
# Generate report
generate_report
}
# Trap interrupt signals
trap 'echo -e "\n${YELLOW}Tests interrupted${NC}"; exit 1' INT TERM
# Run main function
main "$@"
+10 -1
View File
@@ -1,4 +1,8 @@
# PrivyDrop Deployment Guide
# PrivyDrop Deployment Guide (Bare-Metal)
> Audience & Scope: This guide targets developers/operators who prefer a non-container (bare-metal) setup.
>
> Recommended: Prefer the one-click Docker deployment for simplicity and robustness, including auto HTTPS and TURN. See [Docker Deployment Guide](./DEPLOYMENT_docker.md).
This guide provides comprehensive instructions for deploying the full-stack PrivyDrop application, including setting up Redis, a TURN server, the backend service, the frontend application, and configuring Nginx as a reverse proxy.
@@ -30,6 +34,7 @@ sudo bash backend/docker/env_install.sh
```
This script will automatically install:
- **Node.js v20** - Runtime environment
- **Redis Server** - Used for room management and caching
- **Coturn** - TURN/STUN server (optional, for NAT traversal)
@@ -38,6 +43,7 @@ This script will automatically install:
- **Certbot** - SSL certificate management
After installation, you can verify the services:
```bash
# Verify Node.js version
node -v
@@ -53,11 +59,13 @@ sudo systemctl status coturn
```
**Configuration Notes:**
- **Redis Configuration:** Default listening on `127.0.0.1:6379`, ensure your backend `.env` file includes correct `REDIS_HOST` and `REDIS_PORT`
- **TURN Service:** Optional configuration, PrivyDrop uses public STUN servers by default, only needed for extremely high NAT traversal requirements
- **Nginx:** Script installs official version and verifies stream module support
**TURN Server Firewall Configuration (if configuring TURN service):**
```bash
# Enable the Coturn service
sudo sed -i 's/#TURNSERVER_ENABLED=1/TURNSERVER_ENABLED=1/' /etc/default/coturn
@@ -68,6 +76,7 @@ sudo ufw reload
```
The ports seen via `sudo ufw app info Turnserver` are as follows:
- `3478,3479,5349,5350,49152:65535/tcp`
- `3478,3479,5349,5350,49152:65535/udp`
+15 -6
View File
@@ -1,4 +1,8 @@
# Privydrop 部署指南
# Privydrop 部署指南(裸机部署)
> 说明与定位:本指南面向具备 Linux 运维能力的开发者,介绍“裸机(非容器)”部署方式。
>
> 推荐方案:优先使用“一键 Docker 部署”,更简单、更稳健,支持自动签发/续期证书与 TURN。详见 [Docker 部署指南](./DEPLOYMENT_docker.zh-CN.md)。
本指南提供部署 Privydrop 全栈应用的全面说明,包括设置 Redis、TURN 服务器、后端服务、前端应用以及配置 Nginx 作为反向代理。
@@ -30,14 +34,16 @@ sudo bash backend/docker/env_install.sh
```
该脚本将自动安装:
- **Node.js v20** - 运行环境
- **Redis Server** - 用于房间管理和缓存
- **Coturn** - TURN/STUN 服务器(可选,用于NAT穿透)
- **Redis Server** - 用于房间管理和缓存
- **Coturn** - TURN/STUN 服务器(可选,用于 NAT 穿透)
- **Nginx** - Web 服务器和反向代理(使用官方仓库)
- **PM2** - Node.js 进程管理器
- **Certbot** - SSL 证书管理
安装完成后,可以验证各服务状态:
```bash
# 验证 Node.js 版本
node -v
@@ -53,11 +59,13 @@ sudo systemctl status coturn
```
**注意事项:**
- **Redis配置:** 默认监听 `127.0.0.1:6379`,请确保后端 `.env` 文件中包含正确的 `REDIS_HOST``REDIS_PORT`
- **TURN服务:** 为可选配置,Privydrop 默认使用公共 STUN 服务器,只有对 NAT 穿透有极高要求时才需要配置
- **Redis 配置:** 默认监听 `127.0.0.1:6379`,请确保后端 `.env` 文件中包含正确的 `REDIS_HOST``REDIS_PORT`
- **TURN 服务:** 为可选配置,Privydrop 默认使用公共 STUN 服务器,只有对 NAT 穿透有极高要求时才需要配置
- **Nginx** 脚本安装官方版本并验证 stream 模块支持
**TURN服务器防火墙配置(如果需要配置TURN服务):**
**TURN 服务器防火墙配置(如果需要配置 TURN 服务):**
```bash
# 启用 Coturn 服务
sudo sed -i 's/#TURNSERVER_ENABLED=1/TURNSERVER_ENABLED=1/' /etc/default/coturn
@@ -68,6 +76,7 @@ sudo ufw reload
```
通过 `sudo ufw app info Turnserver` 看到的端口如下:
- `3478,3479,5349,5350,49152:65535/tcp`
- `3478,3479,5349,5350,49152:65535/udp`
+564
View File
@@ -0,0 +1,564 @@
# PrivyDrop Docker One-Click Deployment (Recommended)
This guide provides a one-click Docker deployment for PrivyDrop. It supports both private and public networks, automates config/build/start, and provisions HTTPS certificates.
## 🚀 Quick Start (Top)
```bash
# Private LAN (no domain/public IP)
bash ./deploy.sh --mode lan-http
# Private LAN + TURN (for complex NAT/LAN)
bash ./deploy.sh --mode lan-http --with-turn
# LAN HTTPS (self-signed; dev/managed env; explicitly enable 8443)
bash ./deploy.sh --mode lan-tls --enable-web-https --with-nginx
# Public IP without domain (with TURN)
bash ./deploy.sh --mode public --with-turn
# Public domain (HTTPS + Nginx + TURN + SNI 443, auto-issue/renew certs)
bash ./deploy.sh --mode full --domain your-domain.com --with-nginx --with-turn --le-email you@domain.com
```
- Requires Docker Compose v2 (command `docker compose`).
- In full mode, Lets Encrypt (webroot) is auto-issued and auto-renewed (no downtime); SNI 443 multiplexing is enabled by default (`turn.your-domain.com` → coturn:5349; others → web:8443).
## Modes Overview
- lan-http: Intranet HTTP; fastest to start; no TLS
- lan-tls: Intranet HTTPS (self-signed; dev/managed env); 8443 disabled by default; enable via `--enable-web-https`; HSTS disabled; turns:443 not guaranteed
- public: Public HTTP + TURN; works without a domain (no HTTPS/turns:443)
- full: Domain + HTTPS (Lets Encrypt auto-issue/renew) + TURN; SNI 443 split enabled by default (use `--no-sni443` to disable)
## 🎯 Deployment Advantages
Compared to traditional deployment methods, Docker deployment offers the following advantages:
| Comparison | Traditional Deployment | Docker Deployment |
| ---------------------------- | ------------------------------- | ------------------------------ |
| **Deploy Time** | 30-60 minutes | 5 minutes |
| **Technical Requirements** | Linux ops experience | Basic Docker knowledge |
| **Environment Requirements** | Public IP + Domain | Works on private networks |
| **Configuration Complexity** | 10+ manual steps | One-click auto configuration |
| **Success Rate** | ~70% | >95% |
| **Maintenance Difficulty** | Manual multi-service management | Automatic container management |
## 📋 System Requirements
### Minimum Configuration
- **CPU**: 1 core
- **Memory**: 512MB
- **Disk**: 2GB available space
- **Network**: Any network environment (private/public)
### Recommended Configuration
- **CPU**: 2+ cores
- **Memory**: 1GB+
- **Disk**: 5GB+ available space
- **Network**: 100Mbps+
### Software Dependencies
- Docker 20.10+
- Docker Compose 2.x (command `docker compose`)
- curl (for health checks, optional)
- openssl (cert tools; the script auto-installs certbot)
## 🚀 Quick Start
### 1. Get the Code
```bash
# Clone the project
git clone https://github.com/david-bai00/PrivyDrop.git
cd PrivyDrop
```
### 2. One-Click Deployment
```bash
# Basic deployment (recommended for beginners)
bash ./deploy.sh
```
That's it! 🎉
## 📚 Deployment Modes
### LAN HTTP (lan-http)
**Use Case**: Private network file transfer, personal use, testing environment
```bash
bash ./deploy.sh --mode lan-http
```
**Features**:
- ✅ HTTP access
- ✅ Private network P2P transfer
- ✅ Uses public STUN servers
- ✅ Zero configuration startup
### Public Mode
**Use Case**: Servers with public IP but no domain
```bash
bash ./deploy.sh --mode public --with-turn --with-nginx
```
**Features**:
- ✅ HTTP access
- ✅ Built-in TURN server
- ✅ Supports complex network environments
- ✅ Automatic NAT traversal configuration
### Full Mode (full)
**Use Case**: Production environment, public servers with domain
```bash
bash ./deploy.sh --mode full --domain your-domain.com --with-nginx --with-turn --le-email you@domain.com
```
**Features**:
- ✅ HTTPS secure access (Lets Encrypt auto-issue/renew, zero downtime)
- ✅ Nginx reverse proxy
- ✅ Built-in TURN server (default port range 49152-49252/udp)
- ✅ SNI 443 multiplexing (turn.<domain> → coturn:5349; others → web:8443)
- ✅ Complete production setup
> Tip: The script no longer auto-detects the deployment mode; always pass `--mode lan-http|lan-tls|public|full`. If the detected LAN IP is not the one you expect, add `--local-ip 192.168.x.x` to override.
## 🔧 Advanced Configuration
### Custom Ports
```bash
# Modify .env file
FRONTEND_PORT=8080
BACKEND_PORT=8081
HTTP_PORT=8000
```
### Build-Time Proxy (optional)
Set the following variables in `.env` (or export them before running `deploy.sh`) when the build needs to go through a proxy. The configuration generator now preserves these fields on subsequent runs.
```bash
HTTP_PROXY=http://your-proxy:7890
HTTPS_PROXY=http://your-proxy:7890
NO_PROXY=localhost,127.0.0.1,backend,frontend,redis,coturn
```
`docker compose` passes these values as build args; the Dockerfiles expose them as environment variables so `npm`/`pnpm` automatically reuse the proxy. Leave them blank if you don't need a proxy.
### Common Flags
```bash
# Enable only Nginx reverse proxy
bash ./deploy.sh --with-nginx
# Enable TURN (recommended in public/full)
bash ./deploy.sh --with-turn
# Explicitly enable SNI 443 (auto-enabled in full+domain; use --no-sni443 to disable)
bash ./deploy.sh --with-sni443
# Adjust TURN port range (default 49152-49252/udp)
bash ./deploy.sh --mode full --with-turn --turn-port-range 55000-55100
```
## 🌐 Access Methods
- With Nginx (recommended, same-origin gateway)
- lan-http/public: `http://localhost` (or `http://<public IP>`)
- lan-tls (with `--enable-web-https`): `https://localhost:8443` (or `https://<LAN IP>:8443`)
- full (with domain): `https://<your-domain>` (443)
- Health checks: `curl -fsS http://localhost/api/health` (lan-http/public), `curl -kfsS https://localhost:8443/api/health` (lan-tls+https), `curl -fsS https://<domain>/api/health` (full)
- Without Nginx (direct ports, for debugging only)
- Frontend: `http://localhost:3002` (or `http://<LAN IP>:3002`)
- API: `http://localhost:3001` (or `http://<LAN IP>:3001`)
- Note: direct ports may cause CORS or 404 in production/public setups and are not recommended for public access.
### HTTPS Access (lan-tls/full)
- lan-tls: with `--enable-web-https`, access via `https://localhost:8443` (certs in `docker/ssl/`). Import `docker/ssl/ca-cert.pem` into your browser or trust store on first use.
- full: after Lets Encrypt issuance, access via `https://<your-domain>` (443). Certs auto-issue/renew; hot-reload is handled via deploy hook.
## 🔍 Management Commands
### View Service Status
```bash
docker compose ps
```
### View Service Logs
```bash
# View all service logs
docker compose logs -f
# View specific service logs
docker compose logs -f backend
docker compose logs -f frontend
docker compose logs -f redis
```
### Restart Services
```bash
# Restart all services
docker compose restart
# Restart specific service
docker compose restart backend
```
### Stop Services
```bash
# Stop services but keep data
docker compose stop
# Stop services and remove containers
docker compose down
```
### Complete Cleanup
```bash
# Clean all containers, images and data
bash ./deploy.sh --clean
```
## 🛠️ Troubleshooting
### Common Issues
#### 1. Port Already in Use
**Symptom**: Deployment shows port occupation warning
```
⚠️ The following ports are already in use: 3002, 3001
```
**Solution**:
```bash
# First try cleaning previous containers
bash ./deploy.sh --clean # or docker compose down
# If the port is still occupied, locate the process
sudo ss -tulpn | grep :3002
sudo kill -9 <PID>
# Finally, adjust the exposed ports in .env if necessary
vim .env # Update FRONTEND_PORT / BACKEND_PORT
```
#### 2. Insufficient Memory
**Symptom**: Containers fail to start or restart frequently
**Solution**:
```bash
# Check memory usage
free -h
# Add swap space (temporary solution)
sudo fallocate -l 1G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
```
#### 3. Docker Permission Issues
**Symptom**: Permission denied errors
**Solution**:
```bash
# Add user to docker group
sudo usermod -aG docker $USER
# Re-login or refresh group permissions
newgrp docker
```
#### 4. Service Inaccessible
**Symptom**: Browser cannot open pages
**Solution**:
```bash
# 1. Check service status
docker compose ps
# 2. Check health status
curl http://localhost:3001/health
curl http://localhost:3002/api/health
# 3. View detailed logs
docker compose logs -f
# 4. Check firewall
sudo ufw status
```
#### 5. WebRTC Connection Failure
**Symptom**: Cannot establish P2P connections
**Solution**:
```bash
# Enable TURN server
bash ./deploy.sh --with-turn
# Check network connectivity
curl -I http://localhost:3001/api/get_room
```
### Health Checks
The project provides comprehensive health check functionality:
```bash
# Run health check tests
bash test-health-apis.sh
# Manual service checks
curl http://localhost:3001/health # Backend basic check
curl http://localhost:3001/health/detailed # Backend detailed check
curl http://localhost:3002/api/health # Frontend check
```
### Performance Monitoring
```bash
# View container resource usage
docker stats
# View disk usage
docker system df
# Clean unused resources
docker system prune -f
```
## 📊 Performance Optimization
### Production Environment Optimization
1. **Enable Nginx Caching**:
```bash
bash deploy.sh --with-nginx
```
2. **Configure Resource Limits**:
```yaml
# Add to docker-compose.yml
services:
backend:
deploy:
resources:
limits:
memory: 256M
reservations:
memory: 128M
```
3. **Enable Log Rotation**:
```bash
# Configure log size limits
echo '{"log-driver":"json-file","log-opts":{"max-size":"10m","max-file":"3"}}' | sudo tee /etc/docker/daemon.json
sudo systemctl restart docker
```
### Network Optimization
1. **Use Dedicated Network**:
```yaml
networks:
privydrop-network:
driver: bridge
ipam:
config:
- subnet: 172.20.0.0/16
```
2. **Enable HTTP/2**:
```bash
# Auto-enabled (requires HTTPS)
bash deploy.sh --mode full --with-nginx
```
## 🔒 Security Configuration
### LAN HTTPS (lan-tls, self-signed, dev/managed env)
- 8443 is disabled by default; explicitly enable with:
```bash
bash ./deploy.sh --mode lan-tls --enable-web-https --with-nginx
```
- For development or managed devices only (internal CA trusted fleet-wide); HSTS disabled; `turns:443` not guaranteed. For restricted networks (443-only), use full (domain + trusted cert + SNI 443).
Usage (strongly recommended)
1) Import the self-signed CA (required)
- Location: `docker/ssl/ca-cert.pem`
- Browser import:
- Chrome/Edge: Settings → Privacy & Security → Security → Manage certificates → “Trusted Root Certification Authorities” → Import `ca-cert.pem`
- macOS: Keychain Access → System → Certificates → Import `ca-cert.pem` → set to “Always Trust”
- Linux (system-wide):
- `sudo cp docker/ssl/ca-cert.pem /usr/local/share/ca-certificates/privydrop-ca.crt`
- `sudo update-ca-certificates`
- Without trusting the CA, browser HTTPS will show untrusted cert warnings and API requests will fail.
2) Access endpoints (default ports and paths)
- Nginx reverse proxy: `http://localhost`
- HTTPS (Web): `https://localhost:8443`, `https://<LAN IP>:8443`
- Frontend direct (optional): `http://localhost:3002`, `http://<LAN IP>:3002`
- Note: In lan-tls, 443 is not open; HTTPS uses 8443.
3) CORS
- For convenience, common dev origins are allowed by default: `https://<LAN IP>:8443`, `https://localhost:8443`, `http://localhost`, `http://<LAN IP>`, `http://localhost:3002`, `http://<LAN IP>:3002`.
- To minimize allowed origins, edit `CORS_ORIGIN` in `.env` and then `docker compose restart backend`.
4) Health checks
- `curl -kfsS https://localhost:8443/api/health` → 200
- `bash ./test-health-apis.sh` → all tests should pass (frontend container trusts the self-signed CA).
5) Deployment hints
- The script prints only reachable Nginx endpoints; in lan-tls it will show `https://localhost:8443` (and `https://<LAN IP>:8443` if available).
### Public Domain Deployment (HTTPS + Nginx) — Quick Test
1) Point your domain A record to the server IP (optional: also `turn.<your-domain>` to the same IP)
2) Run:
```bash
./deploy.sh --mode full --domain <your-domain> --with-nginx --with-turn --le-email you@domain.com
```
3) Open ports: `80`, `443`, `3478/udp`, `5349/tcp`, `5349/udp`
4) Verify: visit `https://<your-domain>`, `/api/health` returns 200; open `chrome://webrtc-internals` and check for `relay` candidates (TURN)
### SSL/TLS Automation (Lets Encrypt)
In full mode, certificates are auto-issued and auto-renewed:
- Initial issuance: webroot (no downtime); system certs live under `/etc/letsencrypt/live/<domain>/`; copied to `docker/ssl/` and 443 is enabled.
- Renewal: `certbot.timer` or `/etc/cron.d/certbot` runs daily; the deploy-hook copies new certs to `docker/ssl/` and hot-reloads Nginx/Coturn.
- Lineage suffixes (-0001/-0002) are handled automatically.
### Network Security
1. **Firewall Configuration**:
```bash
# Ubuntu/Debian
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 3478/udp # TURN server
```
2. **Container Network Isolation**:
- All services run in isolated networks
- Only necessary ports exposed
- Internal services communicate using container names
## 📈 Monitoring and Logging
### Log Management
All service logs are centrally stored in the `logs/` directory:
```
logs/
├── nginx/ # Nginx access and error logs
├── backend/ # Backend application logs
├── frontend/ # Frontend application logs
└── coturn/ # TURN server logs
```
## 🔄 Updates and Maintenance
### Update Application
```bash
# Pull latest code
git pull origin main
# Redeploy
bash deploy.sh
```
### Data Backup
```bash
# Backup Redis data
docker compose exec redis redis-cli BGSAVE
# Backup SSL certificates
tar -czf ssl-backup.tar.gz docker/ssl/
# Backup configuration files
cp .env .env.backup
```
### Regular Maintenance
```bash
# Clean unused images and containers
docker system prune -f
# Update base images
docker compose pull
docker compose up -d
```
## 🆘 Getting Help
### Command Line Help
```bash
bash ./deploy.sh --help
### Additional Notes
- In Docker environments, Next.js Image optimization is disabled by default (`NEXT_IMAGE_UNOPTIMIZED=true`) to avoid container loopback fetch failures on `/_next/image`. To enable it, set the variable to `false` and rebuild.
- With `--with-nginx`, the frontend is built to use same-origin API (`/api`, `/socket.io/`). Use the gateway URLs printed by the script; direct ports `:3002/:3001` are not recommended in production.
```
### Online Resources
- [Project Homepage](https://github.com/david-bai00/PrivyDrop)
- [Live Demo](https://www.privydrop.app/)
- [Issue Reporting](https://github.com/david-bai00/PrivyDrop/issues)
### Community Support
- GitHub Issues: Technical questions and bug reports
+566
View File
@@ -0,0 +1,566 @@
# PrivyDrop Docker 一键部署(推荐)
本指南提供 PrivyDrop 的 Docker 一键部署方案,支持内网与公网,一次命令完成配置、构建、启动与证书自动化。
## 🚀 快速开始(置顶)
```bash
# 内网(无域名/无公网IP
bash ./deploy.sh --mode lan-http
# 内网 + TURN(推荐用于复杂内网/NAT)
bash ./deploy.sh --mode lan-http --with-turn
# 内网 HTTPS(自签,开发/受管环境,需显式开启 8443)
bash ./deploy.sh --mode lan-tls --enable-web-https --with-nginx
# 公网IP(无域名),含 TURN
bash ./deploy.sh --mode public --with-turn
# 公网域名(HTTPS + Nginx + TURN + SNI 443 分流,自动申请/续期证书)
bash ./deploy.sh --mode full --domain your-domain.com --with-nginx --with-turn --le-email you@domain.com
```
- 使用 Docker Compose V2(命令 `docker compose`)。
- full 模式自动申请 Lets Encrypt 证书(webroot,无停机)并自动续期;默认启用 SNI 443 分流(`turn.your-domain.com` → coturn:5349,其余 → web:8443)。
## 模式一览
- lan-http:内网 HTTP;最快上手,默认不启用 TLS
- lan-tls:内网 HTTPS(自签,仅开发/受管环境);默认不启 8443,需 `--enable-web-https` 显式开启;禁用 HSTS;不保证 turns:443
- public:公网 HTTP;开启 TURN;无域名也可使用(不提供 HTTPS/turns:443
- full:域名 + HTTPSLets Encrypt 自动签发/续期)+ TURN;默认启用 SNI 443 分流(可 `--no-sni443` 关闭)
## 🎯 部署优势
相比传统部署方式,Docker 部署具有以下优势:
| 对比项目 | 传统部署 | Docker 部署 |
| -------------- | -------------------- | ---------------- |
| **部署时间** | 30-60 分钟 | 5 分钟 |
| **技术要求** | Linux 运维经验 | 会用 Docker 即可 |
| **环境要求** | 公网 IP + 域名 | 内网即可使用 |
| **配置复杂度** | 10+个手动步骤 | 一键自动配置 |
| **成功率** | ~70% | >95% |
| **维护难度** | 需要手动管理多个服务 | 容器自动管理 |
## 📋 系统要求
### 最低配置
- **CPU**: 1 核
- **内存**: 512MB
- **磁盘**: 2GB 可用空间
- **网络**: 任意网络环境(内网/公网均可)
### 推荐配置
- **CPU**: 2 核及以上
- **内存**: 1GB 及以上
- **磁盘**: 5GB 及以上可用空间
- **网络**: 100Mbps 及以上
### 软件依赖
- Docker 20.10+
- Docker Compose 2.x(命令 `docker compose`
- curl(用于健康检查,可选)
- openssl(用于证书工具,脚本会自动安装 certbot)
## 🚀 快速开始
### 1. 获取代码
```bash
# 克隆项目
git clone https://github.com/david-bai00/PrivyDrop.git
cd PrivyDrop
```
### 2. 一键部署(示例)
```bash
# 示例:公网域名(HTTPS + Nginx + TURN
bash ./deploy.sh --mode full --domain your-domain.com --with-nginx --with-turn --le-email you@domain.com
```
## 📚 部署模式详解
### lan-http(内网 HTTP
**适用场景**: 内网文件传输、个人使用、测试环境
```bash
bash ./deploy.sh --mode lan-http
```
**特性**:
- ✅ HTTP 访问
- ✅ 内网 P2P 传输
- ✅ 使用公共 STUN 服务器
- ✅ 零配置启动
### 公网模式
**适用场景**: 有公网 IP 但无域名的服务器
```bash
bash ./deploy.sh --mode public --with-turn --with-nginx
```
**特性**:
- ✅ HTTP 访问
- ✅ 内置 TURN 服务器
- ✅ 支持复杂网络环境
- ✅ 自动配置 NAT 穿透
### 完整模式(full
**适用场景**: 生产环境、有域名的公网服务器
```bash
bash ./deploy.sh --mode full --domain your-domain.com --with-nginx --with-turn --le-email you@domain.com
```
**特性**:
- ✅ HTTPS 安全访问(Lets Encrypt 自动签发/续期,无停机)
- ✅ Nginx 反向代理
- ✅ 内置 TURN 服务器(默认端口段 49152-49252/udp,可覆盖)
- ✅ SNI 443 分流(turn.<domain> → coturn:5349,其余 → web:8443
- ✅ 完整生产环境配置
> 提示:脚本不再自动判断部署模式,请显式传递 `--mode lan-http|lan-tls|public|full`。若自动检测到的局域网 IP 与预期不符,可使用 `--local-ip 192.168.x.x` 进行覆盖。
## 🔧 高级配置
### 自定义端口
```bash
# 修改 .env 文件
FRONTEND_PORT=8080
BACKEND_PORT=8081
HTTP_PORT=8000
```
### 构建阶段代理(可选)
若需要在 Docker 构建时走网络代理,可在 `.env` 中设置以下变量,或者在执行 `deploy.sh` 之前通过环境变量导出。重新运行配置脚本时,这些字段会被保留:
```bash
HTTP_PROXY=http://你的代理:7890
HTTPS_PROXY=http://你的代理:7890
NO_PROXY=localhost,127.0.0.1,backend,frontend,redis,coturn
```
`docker compose` 会把这些变量作为 build args 传递给前后端镜像,Dockerfile 中会自动设置为环境变量,从而让 `npm`/`pnpm` 使用代理。若无需代理,保持为空即可。
### 常用开关
```bash
# 仅启用 Nginx
bash ./deploy.sh --with-nginx
# 启用 TURNpublic/full 建议)
bash ./deploy.sh --with-turn
# 显式启用 SNI 443full+domain 默认开启,可用 --no-sni443 关闭)
bash ./deploy.sh --with-sni443
# 调整 TURN 端口段(默认 49152-49252/udp
bash ./deploy.sh --mode full --with-turn --turn-port-range 55000-55100
```
## 🌐 访问方式
- 启用 Nginx(推荐,同源网关)
- lan-http/public`http://localhost`(或 `http://<公网IP>`
- lan-tls(启用 `--enable-web-https`):`https://localhost:8443`(或 `https://<LAN IP>:8443`
- full(有域名):`https://<your-domain>`443
- 健康检查:`curl -fsS http://localhost/api/health`lan-http/public),`curl -kfsS https://localhost:8443/api/health`lan-tls+https),`curl -fsS https://<domain>/api/health`full
- 未启用 Nginx(直连端口,仅调试)
- 前端:`http://localhost:3002`(或 `http://<LAN IP>:3002`
- API`http://localhost:3001`(或 `http://<LAN IP>:3001`
- 注意:直连端口在生产/公网可能导致 CORS 或 404,不推荐对外使用。
### HTTPS 访问(lan-tls/full
- lan-tls:开启 `--enable-web-https` 后通过 `https://localhost:8443` 访问(证书在 `docker/ssl/`)。首次访问需导入 `docker/ssl/ca-cert.pem` 到浏览器或系统信任。
- full:签发 Lets Encrypt 后通过 `https://<your-domain>` 访问(443)。
## 🔍 管理命令
### 查看服务状态
```bash
docker compose ps
```
### 查看服务日志
```bash
# 查看所有服务日志
docker compose logs -f
# 查看特定服务日志
docker compose logs -f backend
docker compose logs -f frontend
docker compose logs -f redis
```
### 重启服务
```bash
# 重启所有服务
docker compose restart
# 重启特定服务
docker compose restart backend
```
### 停止服务
```bash
# 停止服务但保留数据
docker compose stop
# 停止服务并删除容器
docker compose down
```
### 完全清理
```bash
# 清理所有容器、镜像和数据
bash ./deploy.sh --clean
```
## 🛠️ 故障排除
### 常见问题
#### 1. 端口被占用
**现象**: 部署时提示端口已被占用
```
⚠️ 以下端口已被占用: 3002, 3001
```
**解决方案**:
```bash
# 方法1: 清理旧容器
bash ./deploy.sh --clean # 或 docker compose down
# 方法2: 查找并结束占用进程
sudo ss -tulpn | grep :3002
sudo kill -9 <PID>
# 方法3: 如仍冲突,再调整端口
vim .env # 修改 FRONTEND_PORT / BACKEND_PORT
```
#### 2. 内存不足
**现象**: 容器启动失败或频繁重启
**解决方案**:
```bash
# 检查内存使用
free -h
# 添加交换空间 (临时解决)
sudo fallocate -l 1G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
```
#### 3. Docker 权限问题
**现象**: 提示权限不足
**解决方案**:
```bash
# 将用户添加到docker组
sudo usermod -aG docker $USER
# 重新登录或刷新组权限
newgrp docker
```
#### 4. 服务无法访问
**现象**: 浏览器无法打开页面
**解决方案**:
```bash
# 1. 检查服务状态
docker compose ps
# 2. 检查健康状态
curl http://localhost:3001/health
curl http://localhost:3002/api/health
# 3. 查看详细日志
docker compose logs -f
# 4. 检查防火墙
sudo ufw status
```
#### 5. WebRTC 连接失败
**现象**: 无法建立 P2P 连接
**解决方案**:
```bash
# 启用TURN服务器
bash ./deploy.sh --with-turn
# 检查网络连接
curl -I http://localhost:3001/api/get_room
```
### 健康检查
项目提供了完整的健康检查功能:
```bash
# 运行健康检查测试
bash ./test-health-apis.sh
# 手动检查各服务(直连端口)
curl http://localhost:3001/health # 后端基础检查(直连)
curl http://localhost:3001/health/detailed # 后端详细检查(直连)
curl http://localhost:3002/api/health # 前端检查(直连)
# 同源网关(启用 Nginx
curl -fsS http://localhost/api/health # lan-http/public
curl -kfsS https://localhost:8443/api/health # lan-tls (https)
```
### 性能监控
```bash
# 查看容器资源使用
docker stats
# 查看磁盘使用
docker system df
# 清理未使用的资源
docker system prune -f
```
## 📊 性能优化
### 生产环境优化
1. **启用 Nginx 缓存**:
```bash
bash ./deploy.sh --with-nginx
```
2. **配置资源限制**:
```yaml
# 在 docker-compose.yml 中添加
services:
backend:
deploy:
resources:
limits:
memory: 256M
reservations:
memory: 128M
```
3. **启用日志轮转**:
```bash
# 配置日志大小限制
echo '{"log-driver":"json-file","log-opts":{"max-size":"10m","max-file":"3"}}' | sudo tee /etc/docker/daemon.json
sudo systemctl restart docker
```
### 网络优化
1. **使用专用网络**:
```yaml
networks:
privydrop-network:
driver: bridge
ipam:
config:
- subnet: 172.20.0.0/16
```
2. **启用 HTTP/2**:
```bash
# 自动启用 (需要 HTTPS)
bash ./deploy.sh --mode full --with-nginx
```
## 🔒 HTTPS 与安全
### 内网 HTTPSlan-tls,自签,开发/受管环境)
- 默认不启 8443;需 `--enable-web-https` 显式开启:
```bash
bash ./deploy.sh --mode lan-tls --enable-web-https --with-nginx
```
- 仅用于开发或受管终端(全员导入内部 CA);禁用 HSTS;不保证 `turns:443`;受限网络(仅 443 出口)应使用 full(域名 + 受信证书 + SNI 443)。
使用说明(强烈建议逐条完成)
1) 导入自签 CA 证书(必做)
- 证书位置:`docker/ssl/ca-cert.pem`
- 浏览器导入:
- Chrome/Edge:设置 → 隐私与安全 → 安全 → 管理证书 → “受信任的根证书颁发机构” → 导入 `ca-cert.pem`
- macOS:钥匙串访问 → System → 证书 → 导入 `ca-cert.pem` → 设置“始终信任”
- Linux(系统层):
- `sudo cp docker/ssl/ca-cert.pem /usr/local/share/ca-certificates/privydrop-ca.crt`
- `sudo update-ca-certificates`
- 未导入时,浏览器访问 HTTPS 会提示“证书无效/不受信任”,API 请求也会失败。
2) 访问方式(默认端口与路径)
- Nginx 反代:`http://localhost`
- HTTPSWeb):`https://localhost:8443``https://<局域网IP>:8443`
- 前端直连(可选):`http://localhost:3002``http://<局域网IP>:3002`
- 说明:lan-tls 下未开启 443HTTPS 统一走 8443。
3) 跨域(CORS)说明
- 为方便开发与调试,脚本已默认放开常见来源:`https://<局域网IP>:8443``https://localhost:8443``http://localhost``http://<局域网IP>``http://localhost:3002``http://<局域网IP>:3002`
- 若仍需最小化来源,请在 `.env` 中精准收敛 `CORS_ORIGIN`,并 `docker compose restart backend`
4) 健康检查
- `curl -kfsS https://localhost:8443/api/health` → 200
- `bash ./test-health-apis.sh` → 所有测试应通过(前端 detailed 健康已在容器内信任自签 CA)。
5) 部署提示
- 脚本会只显示可访问的 Nginx 入口;lan-tls 下将显示明确的 `https://localhost:8443`(如存在局域网 IP 也将显示 `https://<IP>:8443`)。
### 公网域名部署(HTTPS + Nginx)快速测试
1) 将域名 A 记录解析至服务器 IP(可选:`turn.<your-domain>` 指向相同 IP
2) 运行:
```bash
./deploy.sh --mode full --domain <your-domain> --with-nginx --with-turn --le-email you@domain.com
```
3) 放行端口:`80`, `443`, `3478/udp`, `5349/tcp`, `5349/udp`
4) 验证:访问 `https://<your-domain>``/api/health` 返回 200;打开浏览器 `webrtc-internals` 观察是否出现 `relay` 候选(TURN
### 证书自动化(Lets Encrypt
full 模式自动申请并续期证书:
- 首次签发:webroot 模式(无停机),系统证书在 `/etc/letsencrypt/live/<domain>/`,脚本复制到 `docker/ssl/` 并启用 443
- 续期:`certbot.timer``/etc/cron.d/certbot` 每日尝试 `certbot renew`deploy-hook 自动复制新证书并热重载 Nginx/Coturn
- 证书谱系(-0001/-0002)已自动适配,无需手动处理。
### 网络安全
1. **防火墙配置**:
```bash
# Ubuntu/Debian
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 3478/udp # TURN服务器
```
2. **容器网络隔离**:
- 所有服务运行在独立网络中
- 仅暴露必要端口
- 内部服务使用容器名通信
## 📈 监控和日志
### 日志管理
所有服务日志统一存储在 `logs/` 目录:
```
logs/
├── nginx/ # Nginx访问和错误日志
├── backend/ # 后端应用日志
├── frontend/ # 前端应用日志
└── coturn/ # TURN服务器日志
```
## 🔄 更新和维护
### 更新应用
```bash
# 拉取最新代码
git pull origin main
# 重新部署
bash ./deploy.sh
```
### 数据备份
```bash
# 备份Redis数据
docker compose exec redis redis-cli BGSAVE
# 备份SSL证书
tar -czf ssl-backup.tar.gz docker/ssl/
# 备份配置文件
cp .env .env.backup
```
### 定期维护
```bash
# 清理未使用的镜像和容器
docker system prune -f
# 更新基础镜像
docker compose pull
docker compose up -d
```
## 🆘 获取帮助
### 命令行帮助
```bash
bash ./deploy.sh --help
```
### 在线资源
- [项目主页](https://github.com/david-bai00/PrivyDrop)
- [在线演示](https://www.privydrop.app/)
- [问题反馈](https://github.com/david-bai00/PrivyDrop/issues)
### 社区支持
- GitHub Issues: 技术问题和 bug 报告
### 其他提示
- Docker 环境默认关闭 Next.js Image 优化(`NEXT_IMAGE_UNOPTIMIZED=true`),以避免容器内对公网 IP 回环抓取导致 `/_next/image` 502;如需开启,将其设为 `false` 并重新构建。
- 启用 `--with-nginx` 时,前端会构建为同源 API(相对路径 `/api``/socket.io/`);请优先使用脚本输出的网关入口,不要直接使用 `:3002/:3001` 对外访问,否则可能触发 CORS 或 404。
+15
View File
@@ -0,0 +1,15 @@
node_modules
.next
.git
.gitignore
README.md
Dockerfile
.dockerignore
.env*
npm-debug.log*
.npm
coverage
.nyc_output
*.log
public/sw.js
public/workbox-*.js
+1 -1
View File
@@ -1,4 +1,4 @@
NEXT_PUBLIC_API_URL=http://43.142.81.156:3001
NEXT_PUBLIC_API_URL=http://localhost:3001
# Option,Delete if not needed
NEXT_PUBLIC_TURN_HOST=43.142.81.156
+91
View File
@@ -0,0 +1,91 @@
# Multi-stage build — build stage
FROM node:18-alpine AS builder
ARG HTTP_PROXY
ARG HTTPS_PROXY
ARG NO_PROXY
ENV http_proxy ${HTTP_PROXY} \
https_proxy ${HTTPS_PROXY} \
no_proxy ${NO_PROXY}
WORKDIR /app
# Copy package files
COPY package*.json ./
COPY pnpm-lock.yaml ./
# Install pnpm
RUN npm install -g pnpm --no-audit --no-fund
# Install dependencies
RUN pnpm install --frozen-lockfile
# Copy source code
COPY . .
# Declare and use build-time public vars after deps installation to avoid cache invalidation when only API/TURN change
ARG NEXT_PUBLIC_API_URL
ARG NEXT_PUBLIC_TURN_HOST
ARG NEXT_PUBLIC_TURN_USERNAME
ARG NEXT_PUBLIC_TURN_PASSWORD
ARG NEXT_IMAGE_UNOPTIMIZED
# Inject public env vars during frontend build (for client direct access to backend and TURN)
ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
ENV NEXT_PUBLIC_TURN_HOST=${NEXT_PUBLIC_TURN_HOST}
ENV NEXT_PUBLIC_TURN_USERNAME=${NEXT_PUBLIC_TURN_USERNAME}
ENV NEXT_PUBLIC_TURN_PASSWORD=${NEXT_PUBLIC_TURN_PASSWORD}
ENV NEXT_IMAGE_UNOPTIMIZED=${NEXT_IMAGE_UNOPTIMIZED}
# Set environment variables
ENV NEXT_TELEMETRY_DISABLED 1
ENV NODE_ENV production
# Build the app
RUN pnpm build
# Production stage
FROM node:18-alpine AS runner
WORKDIR /app
# Create a non-root user
RUN addgroup -g 1001 -S nodejs && \
adduser -S nextjs -u 1001 -G nodejs
# Copy build artifacts
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY health-check.js ./
# Set environment variables
ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1
ENV PORT 3002
ENV HOSTNAME "0.0.0.0"
USER nextjs
# Expose ports
EXPOSE 3002
# Use a Node.js script for health checks (instead of curl)
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD node health-check.js
# Start the app
CMD ["node", "server.js"]
# Keep public env vars at runtime (optional; helps SSR read them)
# Re-declare ARGs in this stage so they can expand into ENV
ARG NEXT_PUBLIC_API_URL
ARG NEXT_PUBLIC_TURN_HOST
ARG NEXT_PUBLIC_TURN_USERNAME
ARG NEXT_PUBLIC_TURN_PASSWORD
ARG NEXT_IMAGE_UNOPTIMIZED
ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
ENV NEXT_PUBLIC_TURN_HOST=${NEXT_PUBLIC_TURN_HOST}
ENV NEXT_PUBLIC_TURN_USERNAME=${NEXT_PUBLIC_TURN_USERNAME}
ENV NEXT_PUBLIC_TURN_PASSWORD=${NEXT_PUBLIC_TURN_PASSWORD}
ENV NEXT_IMAGE_UNOPTIMIZED=${NEXT_IMAGE_UNOPTIMIZED}
+118
View File
@@ -0,0 +1,118 @@
import { NextRequest, NextResponse } from 'next/server';
const startTime = Date.now();
export async function GET(request: NextRequest) {
try {
const errors: string[] = [];
let status = 'healthy';
// 基础健康信息
const basicHealth = {
status: 'healthy',
timestamp: new Date().toISOString(),
uptime: Math.floor((Date.now() - startTime) / 1000),
service: 'privydrop-frontend',
version: process.env.npm_package_version || '1.0.0',
environment: process.env.NODE_ENV || 'development'
};
// Check backend API connectivity
const backendHealth = await checkBackendHealth();
if (backendHealth.status !== 'connected') {
errors.push('Backend API connection failed');
status = 'degraded';
}
// System info snapshot
const systemInfo = {
runtime: process.env.NEXT_RUNTIME || 'nodejs',
nextjs: {
version: process.version,
platform: process.platform,
arch: process.arch
},
memory: process.memoryUsage ? {
used: formatBytes(process.memoryUsage().heapUsed),
total: formatBytes(process.memoryUsage().heapTotal),
percent: Math.round((process.memoryUsage().heapUsed / process.memoryUsage().heapTotal) * 100)
} : null
};
const detailedHealth = {
...basicHealth,
status,
dependencies: {
backend: backendHealth
},
system: systemInfo,
...(errors.length > 0 && { errors })
};
const httpStatus = status === 'healthy' ? 200 : 503;
return NextResponse.json(detailedHealth, { status: httpStatus });
} catch (error) {
console.error('Detailed frontend health check error:', error);
return NextResponse.json({
status: 'unhealthy',
timestamp: new Date().toISOString(),
service: 'privydrop-frontend',
error: error instanceof Error ? error.message : 'Unknown error'
}, { status: 503 });
}
}
// Check backend API health
async function checkBackendHealth() {
try {
// Prefer container-internal URL, then public URL, then localhost fallback
const backendUrl = process.env.BACKEND_INTERNAL_URL || process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
const start = Date.now();
const response = await fetch(`${backendUrl}/health`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
// Timeout to avoid long-hanging connections in degraded networks
signal: AbortSignal.timeout(5000)
});
const responseTime = Date.now() - start;
if (response.ok) {
const data = await response.json();
return {
status: 'connected',
responseTime,
backendUrl,
backendService: data.service || 'unknown'
};
} else {
return {
status: 'error',
responseTime,
backendUrl,
httpStatus: response.status,
error: `HTTP ${response.status}`
};
}
} catch (error) {
return {
status: 'disconnected',
backendUrl: process.env.BACKEND_INTERNAL_URL || process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001',
error: error instanceof Error ? error.message : 'Unknown error'
};
}
}
// 格式化字节数
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}
+30
View File
@@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from 'next/server';
const startTime = Date.now();
export async function GET(request: NextRequest) {
try {
const health = {
status: 'healthy',
timestamp: new Date().toISOString(),
uptime: Math.floor((Date.now() - startTime) / 1000),
service: 'privydrop-frontend',
version: process.env.npm_package_version || '1.0.0',
environment: process.env.NODE_ENV || 'development',
nextjs: {
version: process.env.NEXT_RUNTIME || 'nodejs'
}
};
return NextResponse.json(health, { status: 200 });
} catch (error) {
console.error('Frontend health check error:', error);
return NextResponse.json({
status: 'unhealthy',
timestamp: new Date().toISOString(),
service: 'privydrop-frontend',
error: error instanceof Error ? error.message : 'Unknown error'
}, { status: 503 });
}
}
+44 -30
View File
@@ -1,3 +1,5 @@
import { ManagerOptions, SocketOptions } from "socket.io-client";
export const config = {
API_URL: process.env.NEXT_PUBLIC_API_URL!,
USE_HTTPS: process.env.NODE_ENV !== "development",
@@ -11,43 +13,55 @@ export const config = {
};
export const getIceServers = () => {
// Default public STUN server
const iceServers: RTCIceServer[] = [
{
const iceServers: RTCIceServer[] = [];
if (config.USE_HTTPS) {
// Check if TURN server configuration is complete
if (!config.TURN_HOST || !config.TURN_USERNAME || !config.TURN_CREDENTIAL) {
console.warn(
"TURN server configuration incomplete in HTTPS environment. " +
"Please set NEXT_PUBLIC_TURN_HOST, NEXT_PUBLIC_TURN_USERNAME, and NEXT_PUBLIC_TURN_PASSWORD " +
"environment variables for better connectivity. Falling back to Google STUN server."
);
// Fallback to Google STUN server
iceServers.push({
urls: "stun:stun.l.google.com:19302",
});
} else {
// Add self-hosted STUN and TURN servers
iceServers.push(
{
urls: `stun:${config.TURN_HOST}:3478`,
},
{
urls: `turns:${config.TURN_HOST}:443`,
username: config.TURN_USERNAME,
credential: config.TURN_CREDENTIAL,
},
{
urls: `turn:${config.TURN_HOST}:3478`,
username: config.TURN_USERNAME,
credential: config.TURN_CREDENTIAL,
}
);
}
} else {
// Development environment uses Google's public STUN server
iceServers.push({
urls: "stun:stun.l.google.com:19302",
},
];
// Add self-hosted TURN/STUN server if configured through environment variables
if (config.TURN_HOST && config.TURN_USERNAME && config.TURN_CREDENTIAL) {
const turnUrls = config.USE_HTTPS
? [`turns:${config.TURN_HOST}:443`, `turn:${config.TURN_HOST}:3478`]
: [`turn:${config.TURN_HOST}:3478`];
// Add STUN from the self-hosted server
iceServers.push({
urls: `stun:${config.TURN_HOST}:3478`,
});
// Add TURN from the self-hosted server
iceServers.push({
urls: turnUrls,
username: config.TURN_USERNAME,
credential: config.TURN_CREDENTIAL,
});
}
return iceServers;
};
export const getSocketOptions = () => {
return config.USE_HTTPS
? {
secure: true,
path: "/socket.io/",
transports: ["websocket"],
}
: undefined;
export const getSocketOptions = (): Partial<ManagerOptions & SocketOptions> => {
// Allow polling fallback; do not force "secure" here — protocol will be inferred
return {
path: "/socket.io/",
transports: ["websocket", "polling"],
};
};
export const getFetchOptions = (options: RequestInit = {}): RequestInit => {
+2 -28
View File
@@ -49,43 +49,18 @@ const ClipboardApp = () => {
handleDownloadFile,
} = useFileTransferHandler({ messages, putMessageInMs });
// 简化的 WebRTC 连接初始化
// Simplified WebRTC connection initialization
const {
sharePeerCount,
retrievePeerCount,
broadcastDataToAllPeers,
requestFile,
requestFolder,
setReceiverDirectoryHandle,
getReceiverSaveType,
senderDisconnected,
resetReceiverConnection,
resetSenderConnection,
manualSafeSave,
} = useWebRTCConnection({
messages,
putMessageInMs,
});
const resetAppState = useCallback(async () => {
try {
// Reset file transfer state
useFileTransferStore.getState().resetReceiverState();
// Reset WebRTC connection state
await resetReceiverConnection();
// Reset room input
setRetrieveRoomIdInput("");
console.log("Application state reset successfully");
} catch (error) {
console.error("Error during state reset:", error);
window.location.reload();
}
}, [resetReceiverConnection, setRetrieveRoomIdInput]);
// 大大简化的房间管理 - 不再需要传递任何 WebRTC 依赖
// Greatly simplified room management - No longer need to pass any WebRTC dependencies
const {
processRoomIdInput,
joinRoom,
@@ -243,7 +218,6 @@ const ClipboardApp = () => {
getReceiverSaveType={getReceiverSaveType}
retrieveMessage={retrieveMessage}
handleLeaveRoom={handleLeaveReceiverRoom}
manualSafeSave={manualSafeSave}
/>
)}
</CardContent>
@@ -11,6 +11,9 @@ import { getDictionary } from "@/lib/dictionary";
import { useLocale } from "@/hooks/useLocale";
import type { Messages } from "@/types/messages";
import { useFileTransferStore } from "@/stores/fileTransferStore";
import { supportsAutoDownload } from "@/lib/browserUtils";
import { postLogToBackend } from "@/app/config/api";
const developmentEnv = process.env.NODE_ENV;
function formatFolderDis(template: string, num: number, size: string) {
return template.replace("{num}", num.toString()).replace("{size}", size);
@@ -41,7 +44,6 @@ interface FileListDisplayProps {
onRequest?: (item: FileMeta) => void; // Request file
onDelete?: (item: FileMeta) => void;
onLocationPick?: () => Promise<boolean>;
onSafeSave?: () => void; // New prop for safe save functionality
saveType?: { [fileId: string]: boolean }; // File stored on disk or in memory
largeFileThreshold?: number;
}
@@ -63,14 +65,13 @@ const FileListDisplay: React.FC<FileListDisplayProps> = ({
onRequest,
onDelete,
onLocationPick,
onSafeSave,
saveType,
largeFileThreshold = 500 * 1024 * 1024, // 500MB default
}) => {
const locale = useLocale();
const [messages, setMessages] = useState<Messages | null>(null);
// 获取store的清理方法
// Get the cleaning method of the store
const { clearSendProgress, clearReceiveProgress } = useFileTransferStore();
const [showFinished, setShowFinished] = useState<{
[fileId: string]: boolean;
@@ -78,6 +79,11 @@ const FileListDisplay: React.FC<FileListDisplayProps> = ({
// Add a ref to store the previous showFinished state
const prevShowFinishedRef = useRef<{ [fileId: string]: boolean }>({});
// Add save pending status - Used for manual saving on non-Chrome browsers
const [pendingSave, setPendingSave] = useState<{
[fileId: string]: boolean;
}>({});
const [pickedLocation, setPickedLocation] = useState<boolean>(false); // Whether a save directory has been selected
const [needPickLocation, setNeedPickLocation] = useState<boolean>(false); // Whether a save directory needs to be selected -- for large files, folders, or user choice
@@ -92,6 +98,19 @@ const FileListDisplay: React.FC<FileListDisplayProps> = ({
[fileId: string]: number;
}>({});
// Handling manual save - for non-Chrome browsers
const handleManualSave = (item: FileMeta) => {
if (onDownload) {
onDownload(item);
// Clear the pending save state to display UI as "Completed"
setPendingSave((prev) => {
const updated = { ...prev };
delete updated[item.fileId];
return updated;
});
}
};
useEffect(() => {
getDictionary(locale)
.then((dict) => setMessages(dict))
@@ -185,7 +204,7 @@ const FileListDisplay: React.FC<FileListDisplayProps> = ({
delete updated[fileId];
return updated;
});
// 根据模式清理对应的progress数据
// Clean the corresponding progress data according to the pattern
if (mode === "sender") {
clearSendProgress(fileId, activePeerId);
} else {
@@ -206,7 +225,7 @@ const FileListDisplay: React.FC<FileListDisplayProps> = ({
}
return updated;
});
}, 3000);
}, 3002);
}
}
});
@@ -228,7 +247,6 @@ const FileListDisplay: React.FC<FileListDisplayProps> = ({
const prevShowFinished = prevShowFinishedRef.current[item.fileId];
const isSaveToDisk = saveType ? saveType[item.fileId] : false;
// 添加详细调试信息
const fileProgress = fileProgresses[item.fileId];
const activePeerId = activeTransfers[item.fileId];
const currentProgress = activePeerId
@@ -237,10 +255,37 @@ const FileListDisplay: React.FC<FileListDisplayProps> = ({
// Detecting false -> true transitions
if (!prevShowFinished && currentShowFinished) {
if (!isSaveToDisk && onDownload) {
onDownload(item);
const isAutoDownloadSupported = supportsAutoDownload();
if (isAutoDownloadSupported) {
// Browsers that support automatic downloads like Chrome: Download directly
if (developmentEnv === "development") {
postLogToBackend(
`[Download Debug] Auto-downloading file: ${item.name}`
);
}
onDownload(item);
} else {
// Non-Chrome browsers: Set to save status, wait for user manual click
if (developmentEnv === "development") {
postLogToBackend(
`[Download Debug] Setting pendingSave for non-Chrome browser: ${item.name}`
);
}
setPendingSave((prev) => ({
...prev,
[item.fileId]: true,
}));
}
} else {
if (developmentEnv === "development") {
postLogToBackend(
`Skipping download logic - isSaveToDisk: ${isSaveToDisk}, onDownload: ${!!onDownload}`
);
}
}
// Increase download count - 文件传输完成时增加下载次数 (只计算一次)
// Increase download count - Increment download count upon completion of file transfer (counted only once)
setDownloadCounts((prevCounts) => ({
...prevCounts,
[item.fileId]: (prevCounts[item.fileId] || 0) + 1,
@@ -250,22 +295,17 @@ const FileListDisplay: React.FC<FileListDisplayProps> = ({
// Update the last status
prevShowFinishedRef.current[item.fileId] = currentShowFinished;
});
}, [
showFinished,
singleFiles,
folders,
saveType,
onDownload,
downloadCounts,
]);
}, [showFinished, singleFiles, folders, saveType, onDownload]);
//Actions corresponding to each file - progress, download, delete
const renderItemActions = (item: FileMeta) => {
const fileProgress = fileProgresses[item.fileId];
const activePeerId = activeTransfers[item.fileId];
const progress = activePeerId ? fileProgress?.[activePeerId] : null;
const showCompletion = showFinished[item.fileId];
const showCompletion =
showFinished[item.fileId] && !pendingSave[item.fileId]; // Only display completed when the transfer is finished and not in the save pending state
const isSaveToDisk = saveType ? saveType[item.fileId] : false;
const isPendingSave = pendingSave[item.fileId] || false;
// Get download count
const downloadCount = downloadCounts[item.fileId] || 0;
@@ -297,6 +337,7 @@ const FileListDisplay: React.FC<FileListDisplayProps> = ({
onDownload && ( //Request && Download
<FileTransferButton
onRequest={() => onRequest(item)}
onSave={() => handleManualSave(item)}
isCurrentFileTransferring={
progress
? progress.progress > 0 && progress.progress < 1
@@ -304,6 +345,7 @@ const FileListDisplay: React.FC<FileListDisplayProps> = ({
}
isOtherFileTransferring={isAnyFileTransferring && !progress}
isSavedToDisk={saveType ? saveType[item.fileId] : false}
isPendingSave={isPendingSave}
/>
)}
{/* display download Num*/}
@@ -415,29 +457,6 @@ const FileListDisplay: React.FC<FileListDisplayProps> = ({
{messages.text.FileListDisplay.chooseSavePath_dis}
</Button>
)}
{/* Safe Save Button - only show when location is picked and files are saved to disk */}
{onSafeSave &&
pickedLocation &&
(isAnyFileTransferring ||
(saveType &&
Object.values(saveType).some(
(isSavedToDisk) => isSavedToDisk
))) && (
<Tooltip
content={messages.text.FileListDisplay.safeSave_tooltip}
>
<Button
onClick={() => {
onSafeSave();
}}
variant="outline"
size="sm"
className="mr-2 text-green-600 border-green-600 hover:bg-green-50"
>
{messages.text.FileListDisplay.safeSave_dis}
</Button>
</Tooltip>
)}
</div>
</div>
)}
@@ -13,22 +13,28 @@ import type { Messages } from "@/types/messages";
interface FileTransferButtonProps {
onRequest: () => void;
onSave?: () => void; // 新增:处理手动保存
isCurrentFileTransferring: boolean;
isOtherFileTransferring: boolean;
isSavedToDisk: boolean;
isPendingSave?: boolean; // 新增:是否待保存状态
}
// Manage buttons for different download statuses
const FileTransferButton = ({
onRequest,
onSave,
isCurrentFileTransferring,
isOtherFileTransferring,
isSavedToDisk,
isPendingSave = false,
}: FileTransferButtonProps) => {
const locale = useLocale();
const [messages, setMessages] = useState<Messages | null>(null);
// Button status judgment
// Button status judgment - 待保存状态时按钮应该可点击
const isDisabled =
isCurrentFileTransferring || isSavedToDisk || isOtherFileTransferring;
isCurrentFileTransferring ||
isSavedToDisk ||
(isOtherFileTransferring && !isPendingSave);
useEffect(() => {
getDictionary(locale)
@@ -41,6 +47,8 @@ const FileTransferButton = ({
return messages!.text.FileTransferButton.SavedToDisk_tips;
if (isCurrentFileTransferring)
return messages!.text.FileTransferButton.CurrentFileTransferring_tips;
if (isPendingSave)
return messages!.text.FileTransferButton.PendingSave_tips;
if (isOtherFileTransferring)
return messages!.text.FileTransferButton.OtherFileTransferring_tips;
return messages!.text.FileTransferButton.download_tips;
@@ -60,6 +68,12 @@ const FileTransferButton = ({
className: "mr-2 cursor-not-allowed",
};
}
if (isPendingSave) {
return {
variant: "default" as const, // 使用更明显的样式
className: "mr-2 bg-green-600 hover:bg-green-700 text-white",
};
}
if (isOtherFileTransferring) {
return {
variant: "outline" as const,
@@ -83,7 +97,7 @@ const FileTransferButton = ({
<TooltipTrigger asChild>
<span className="inline-block">
<Button
onClick={onRequest}
onClick={isPendingSave && onSave ? onSave : onRequest}
variant={buttonStyles.variant}
size="sm"
className={buttonStyles.className}
@@ -91,11 +105,13 @@ const FileTransferButton = ({
>
<Download
className={`mr-2 h-4 w-4 ${
isOtherFileTransferring ? "opacity-50" : ""
isOtherFileTransferring && !isPendingSave ? "opacity-50" : ""
}`}
/>
{isSavedToDisk
? messages.text.FileTransferButton.Saved_dis
: isPendingSave
? messages.text.FileTransferButton.Save_dis
: isOtherFileTransferring
? messages.text.FileTransferButton.Waiting_dis
: messages.text.FileTransferButton.Download_dis}
@@ -1,7 +1,6 @@
import React, { useCallback } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import RichTextEditor from "@/components/Editor/RichTextEditor";
import {
ReadClipboardButton,
WriteClipboardButton,
@@ -9,7 +8,6 @@ import {
import FileListDisplay from "@/components/ClipboardApp/FileListDisplay";
import type { Messages } from "@/types/messages";
import type { FileMeta } from "@/types/webrtc";
import type { ProgressState } from "@/hooks/useWebRTCConnection"; // Assuming this type is exported
import { useFileTransferStore } from "@/stores/fileTransferStore";
@@ -32,7 +30,6 @@ interface RetrieveTabPanelProps {
directoryHandle: FileSystemDirectoryHandle
) => Promise<void>;
getReceiverSaveType: () => { [fileId: string]: boolean } | undefined;
manualSafeSave: () => void; // Add manual safe save function
retrieveMessage: string;
handleLeaveRoom: () => void;
}
@@ -49,11 +46,10 @@ export function RetrieveTabPanel({
requestFolder,
setReceiverDirectoryHandle,
getReceiverSaveType,
manualSafeSave,
retrieveMessage,
handleLeaveRoom,
}: RetrieveTabPanelProps) {
// 从 store 中获取状态
// Get the status from the store
const {
retrieveRoomStatusText,
retrieveRoomIdInput,
@@ -61,38 +57,25 @@ export function RetrieveTabPanel({
retrievedFileMetas,
receiveProgress,
isAnyFileTransferring,
senderDisconnected,
isReceiverInRoom,
} = useFileTransferStore();
const onLocationPick = useCallback(async (): Promise<boolean> => {
if (!messages) return false; // Should not happen if panel is rendered
if (!window.showDirectoryPicker) {
putMessageInMs(
// messages.text.ClipboardApp.pickSaveUnsupported ||
"Directory picker not supported.",
false
);
putMessageInMs(messages.text.ClipboardApp.pickSaveUnsupported, false);
return false;
}
if (!window.confirm(messages.text.ClipboardApp.pickSaveMsg)) return false;
try {
const directoryHandle = await window.showDirectoryPicker();
await setReceiverDirectoryHandle(directoryHandle);
putMessageInMs(
// messages.text.ClipboardApp.pickSaveSuccess ||
"Save location set.",
false
);
putMessageInMs(messages.text.ClipboardApp.pickSaveSuccess, false);
return true;
} catch (err: any) {
if (err.name !== "AbortError") {
console.error("Failed to set up folder receive:", err);
putMessageInMs(
// messages.text.ClipboardApp.pickSaveError ||
"Could not set save location.",
false
);
putMessageInMs(messages.text.ClipboardApp.pickSaveError, false);
}
return false;
}
@@ -151,12 +134,14 @@ export function RetrieveTabPanel({
{messages.text.ClipboardApp.html.joinRoom_dis}
</Button>
<Button
variant="outline"
variant={isAnyFileTransferring ? "destructive" : "outline"}
onClick={handleLeaveRoom}
disabled={!isReceiverInRoom || isAnyFileTransferring}
disabled={!isReceiverInRoom}
className="w-full sm:w-auto px-4 order-2"
>
{messages.text.ClipboardApp.roomStatus.leaveRoomBtn}
{isAnyFileTransferring
? messages.text.ClipboardApp.roomStatus.leaveRoomBtn + " ⚠️"
: messages.text.ClipboardApp.roomStatus.leaveRoomBtn}
</Button>
</div>
</div>
@@ -181,7 +166,6 @@ export function RetrieveTabPanel({
onDownload={handleDownloadFile}
onRequest={handleFileRequestFromPanel} // Use the panel's own handler
onLocationPick={onLocationPick} // Use the panel's own handler
onSafeSave={manualSafeSave} // Add safe save handler
saveType={getReceiverSaveType()}
/>
{retrieveMessage && (
@@ -11,7 +11,6 @@ import FileListDisplay from "@/components/ClipboardApp/FileListDisplay";
import AnimatedButton from "@/components/ui/AnimatedButton";
import type { Messages } from "@/types/messages";
import type { CustomFile, FileMeta } from "@/types/webrtc";
import type { ProgressState } from "@/hooks/useWebRTCConnection";
import { useFileTransferStore } from "@/stores/fileTransferStore";
@@ -55,7 +54,7 @@ export function SendTabPanel({
currentValidatedShareRoomId,
handleLeaveSenderRoom,
}: SendTabPanelProps) {
// 从 store 中获取状态
// Get the status from the store
const {
shareContent,
sendFiles,
@@ -68,6 +67,8 @@ export function SendTabPanel({
const [inputFieldValue, setInputFieldValue] = useState<string>(
currentValidatedShareRoomId
);
// State to track ID generation mode (false = will show simple next, true = will show random next)
const [isSimpleIdMode, setIsSimpleIdMode] = useState<boolean>(true);
// When the validatedShareRoomId from the parent component changes (e.g., after initial fetch), synchronize the local input field's value
useEffect(() => {
@@ -92,6 +93,40 @@ export function SendTabPanel({
[processRoomIdInput]
);
// Handle ID generation toggle
const handleIdGeneration = useCallback(async () => {
if (isSimpleIdMode) {
// Generate random UUID
const randomId = crypto.randomUUID();
processRoomIdInput(randomId);
} else {
// Generate simple 4-digit ID by calling backend API
try {
const { fetchRoom } = await import("@/app/config/api");
const simpleRoomId = await fetchRoom();
if (simpleRoomId) {
// fetchRoom() already created the room, so set it as initial room ID
// This prevents joinRoom() from trying to create it again
setInputFieldValue(simpleRoomId);
const { useFileTransferStore } = await import(
"@/stores/fileTransferStore"
);
const store = useFileTransferStore.getState();
store.setShareRoomId(simpleRoomId);
// IMPORTANT: Set as initial room ID to prevent duplicate creation
store.setInitShareRoomId(simpleRoomId);
} else {
processRoomIdInput(crypto.randomUUID());
}
} catch (error) {
processRoomIdInput(crypto.randomUUID());
}
}
// Toggle mode for next click
setIsSimpleIdMode(!isSimpleIdMode);
}, [isSimpleIdMode, processRoomIdInput, setInputFieldValue]);
return (
<div id="send-panel" role="tabpanel" aria-labelledby="send-tab">
<div className="mb-3 text-sm text-gray-600">
@@ -141,10 +176,12 @@ export function SendTabPanel({
<Button
variant="outline"
className="w-full sm:w-auto px-4"
onClick={() => processRoomIdInput(crypto.randomUUID())}
onClick={handleIdGeneration}
disabled={isSenderInRoom}
>
{messages.text.ClipboardApp.html.generateRoomId_tips}
{isSimpleIdMode
? messages.text.ClipboardApp.html.generateRandomId_tips
: messages.text.ClipboardApp.html.generateSimpleId_tips}
</Button>
<Button
className="w-full sm:w-auto px-4"
@@ -174,12 +211,14 @@ export function SendTabPanel({
{messages.text.ClipboardApp.html.SyncSending_dis}
</AnimatedButton>
<Button
variant="outline"
variant={isAnyFileTransferring ? "destructive" : "outline"}
onClick={handleLeaveSenderRoom}
disabled={!isSenderInRoom || isAnyFileTransferring}
disabled={!isSenderInRoom}
className="w-full sm:w-auto px-4 order-2"
>
{messages.text.ClipboardApp.roomStatus.leaveRoomBtn}
{isAnyFileTransferring
? messages.text.ClipboardApp.roomStatus.leaveRoomBtn + " ⚠️"
: messages.text.ClipboardApp.roomStatus.leaveRoomBtn}
</Button>
</div>
</div>
+10 -6
View File
@@ -235,9 +235,11 @@ export const de: Messages = {
OtherFileTransferring_tips:
"Bitte warten Sie, bis die aktuelle Übertragung abgeschlossen ist",
download_tips: "Klicken Sie, um die Datei herunterzuladen",
PendingSave_tips: "Klicken Sie, um die Datei lokal zu speichern", // 新增
Saved_dis: "Gespeichert",
Waiting_dis: "Warten",
Download_dis: "Herunterladen",
Save_dis: "Speichern", // 新增
},
FileListDisplay: {
sending_dis: "Senden",
@@ -254,11 +256,6 @@ export const de: Messages = {
chooseSavePath_tips:
"Speichern Sie große Dateien oder Ordner direkt in einem ausgewählten Verzeichnis. 👉",
chooseSavePath_dis: "Speicherort auswählen",
safeSave_dis: "Sicheres Speichern",
safeSave_tooltip:
"Keine Angst vor Verbindungsunterbrechung, klicken Sie hier, um Dateien sicher zu speichern für die nächste Fortsetzung",
safeSaveSuccessMsg:
"Dateien wurden sicher auf der Festplatte gespeichert, sicher die Seite zu schließen, unterstützt Wiederaufnahme der Übertragung!",
},
RetrieveMethod: {
P: "Glückwunsch 🎉 Freigegebene Inhalte warten darauf, abgerufen zu werden:",
@@ -296,6 +293,9 @@ export const de: Messages = {
failMsg: "Fehler beim Beitreten zum Raum:",
},
pickSaveMsg: "Direkt auf Festplatte speichern?",
pickSaveUnsupported: "Verzeichnisauswahl nicht unterstützt.",
pickSaveSuccess: "Speicherort festgelegt.",
pickSaveError: "Speicherort konnte nicht festgelegt werden.",
roomStatus: {
senderEmptyMsg: "Raum ist leer",
receiverEmptyMsg:
@@ -311,6 +311,9 @@ export const de: Messages = {
noFilesForFolderMsg: "Keine Dateien im Ordner '{folderName}' gefunden.",
zipError: "Fehler beim Erstellen der ZIP-Datei.",
fileNotFoundMsg: "Datei '{fileName}' zum Herunterladen nicht gefunden.",
confirmLeaveWhileTransferring:
"Dateien werden derzeit übertragen. Das Verlassen wird die Übertragung unterbrechen. Sind Sie sicher?",
leaveWhileTransferringSuccess: "Raum verlassen, Übertragung unterbrochen",
html: {
senderTab: "Senden",
retrieveTab: "Abrufen",
@@ -321,7 +324,8 @@ export const de: Messages = {
Copy_dis: "Kopieren",
inputRoomIdprompt: "Ihre Raum-ID (bearbeitbar):",
joinRoomBtn: "Raum beitreten",
generateRoomId_tips: "Zufällige ID",
generateSimpleId_tips: "Einfache ID",
generateRandomId_tips: "Zufällige ID",
readClipboardToRoomId: "Raum-ID einfügen",
enterRoomID_placeholder: "Raum-ID eingeben",
retrieveMethod: "Abrufmethode",
+9 -6
View File
@@ -232,9 +232,11 @@ export const en: Messages = {
OtherFileTransferring_tips:
"Please wait for current transfer to complete",
download_tips: "Click to download file",
PendingSave_tips: "Click to save file locally",
Saved_dis: "Saved",
Waiting_dis: "Waiting",
Download_dis: "Download",
Save_dis: "Save",
},
FileListDisplay: {
sending_dis: "Sending",
@@ -251,11 +253,6 @@ export const en: Messages = {
chooseSavePath_tips:
"Save large files or folders directly to a selected directory. 👉",
chooseSavePath_dis: "Choose save location",
safeSave_dis: "Safe Save",
safeSave_tooltip:
"Don't worry about connection interruption, click here to safely save files for next resume",
safeSaveSuccessMsg:
"Files have been safely saved to disk, safe to close page, supports resume transfer!",
},
RetrieveMethod: {
P: "Congrats 🎉 Share content is waiting to be retrieved:",
@@ -289,6 +286,9 @@ export const en: Messages = {
failMsg: "Failed to join room:",
},
pickSaveMsg: "Save Directly to Disk ?",
pickSaveUnsupported: "Directory picker not supported.",
pickSaveSuccess: "Save location set.",
pickSaveError: "Could not set save location.",
roomStatus: {
senderEmptyMsg: "Room is empty",
receiverEmptyMsg: "You can accept an invitation to join the room",
@@ -303,6 +303,8 @@ export const en: Messages = {
noFilesForFolderMsg: "No files found for folder '{folderName}'.",
zipError: "Error creating ZIP.",
fileNotFoundMsg: "File '{fileName}' not found for download.",
confirmLeaveWhileTransferring: "Files are currently transferring. Leaving will interrupt the transfer. Are you sure?",
leaveWhileTransferringSuccess: "Left room, transfer interrupted",
html: {
senderTab: "Send",
retrieveTab: "Retrieve",
@@ -313,7 +315,8 @@ export const en: Messages = {
Copy_dis: "Copy",
inputRoomIdprompt: "Your RoomID (Editable):",
joinRoomBtn: "Join room",
generateRoomId_tips: "Random ID",
generateSimpleId_tips: "Simple ID",
generateRandomId_tips: "Random ID",
readClipboardToRoomId: "Paste RoomID",
enterRoomID_placeholder: "enter RoomID",
retrieveMethod: "Retrieve method",
+11 -6
View File
@@ -233,9 +233,11 @@ export const es: Messages = {
OtherFileTransferring_tips:
"Por favor espera a que se complete la transferencia actual",
download_tips: "Haz clic para descargar el archivo",
PendingSave_tips: "Haz clic para guardar el archivo localmente", // 新增
Saved_dis: "Guardado",
Waiting_dis: "Esperando",
Download_dis: "Descargar",
Save_dis: "Guardar", // 新增
},
FileListDisplay: {
sending_dis: "Enviando",
@@ -252,11 +254,6 @@ export const es: Messages = {
chooseSavePath_tips:
"Guarda archivos grandes o carpetas directamente en un directorio seleccionado. 👉",
chooseSavePath_dis: "Elegir ubicación de guardado",
safeSave_dis: "Guardar Seguro",
safeSave_tooltip:
"No te preocupes por la interrupción de la conexión, haz clic aquí para guardar archivos de forma segura para la próxima reanudación",
safeSaveSuccessMsg:
"Los archivos se han guardado de forma segura en el disco, es seguro cerrar la página, ¡admite transferencia de reanudación!",
},
RetrieveMethod: {
P: "¡Felicitaciones 🎉 El contenido compartido está esperando ser recuperado:",
@@ -290,6 +287,9 @@ export const es: Messages = {
failMsg: "Error al unirse a la sala:",
},
pickSaveMsg: "¿Guardar Directamente en Disco?",
pickSaveUnsupported: "Selector de directorio no compatible.",
pickSaveSuccess: "Ubicación de guardado establecida.",
pickSaveError: "No se pudo establecer la ubicación de guardado.",
roomStatus: {
senderEmptyMsg: "La sala está vacía",
receiverEmptyMsg: "Puedes aceptar una invitación para unirte a la sala",
@@ -305,6 +305,10 @@ export const es: Messages = {
"No se encontraron archivos en la carpeta '{folderName}'.",
zipError: "Error al crear el archivo ZIP.",
fileNotFoundMsg: "Archivo '{fileName}' no encontrado para descargar.",
confirmLeaveWhileTransferring:
"Los archivos se están transfiriendo actualmente. Salir interrumpirá la transferencia. ¿Estás seguro?",
leaveWhileTransferringSuccess:
"Saliste de la sala, transferencia interrumpida",
html: {
senderTab: "Enviar",
retrieveTab: "Recuperar",
@@ -315,7 +319,8 @@ export const es: Messages = {
Copy_dis: "Copiar",
inputRoomIdprompt: "Tu ID de Sala (Editable):",
joinRoomBtn: "Unirse a sala",
generateRoomId_tips: "ID Aleatorio",
generateSimpleId_tips: "ID Simple",
generateRandomId_tips: "ID Aleatorio",
readClipboardToRoomId: "Pegar ID de Sala",
enterRoomID_placeholder: "ingresa ID de Sala",
retrieveMethod: "Método de recuperación",
+10 -6
View File
@@ -236,9 +236,11 @@ export const fr: Messages = {
OtherFileTransferring_tips:
"Veuillez attendre que le transfert actuel soit terminé",
download_tips: "Cliquez pour télécharger le fichier",
PendingSave_tips: "Cliquez pour enregistrer le fichier localement", // 新增
Saved_dis: "Enregistré",
Waiting_dis: "En attente",
Download_dis: "Télécharger",
Save_dis: "Enregistrer", // 新增
},
FileListDisplay: {
sending_dis: "Envoi",
@@ -255,11 +257,6 @@ export const fr: Messages = {
chooseSavePath_tips:
"Enregistrez des fichiers volumineux ou des dossiers directement dans un répertoire sélectionné. 👉",
chooseSavePath_dis: "Choisir l'emplacement de sauvegarde",
safeSave_dis: "Sauvegarde Sécurisée",
safeSave_tooltip:
"N'ayez pas peur de l'interruption de connexion, cliquez ici pour sauvegarder les fichiers en toute sécurité pour la prochaine reprise",
safeSaveSuccessMsg:
"Les fichiers ont été sauvegardés en toute sécurité sur le disque, sûr de fermer la page, prend en charge la reprise du transfert !",
},
RetrieveMethod: {
P: "Félicitations 🎉 Le contenu partagé attend d'être récupéré :",
@@ -296,6 +293,9 @@ export const fr: Messages = {
failMsg: "Échec de la connexion à la salle :",
},
pickSaveMsg: "Enregistrer directement sur le disque ?",
pickSaveUnsupported: "Sélecteur de répertoire non pris en charge.",
pickSaveSuccess: "Emplacement de sauvegarde défini.",
pickSaveError: "Impossible de définir l'emplacement de sauvegarde.",
roomStatus: {
senderEmptyMsg: "La salle est vide",
receiverEmptyMsg:
@@ -313,6 +313,9 @@ export const fr: Messages = {
zipError: "Erreur lors de la création du fichier ZIP.",
fileNotFoundMsg:
"Fichier '{fileName}' introuvable pour le téléchargement.",
confirmLeaveWhileTransferring:
"Des fichiers sont actuellement en cours de transfert. Quitter interrompra le transfert. Êtes-vous sûr?",
leaveWhileTransferringSuccess: "Salle quittée, transfert interrompu",
html: {
senderTab: "Envoyer",
retrieveTab: "Récupérer",
@@ -323,7 +326,8 @@ export const fr: Messages = {
Copy_dis: "Copier",
inputRoomIdprompt: "Votre ID de salle (modifiable) :",
joinRoomBtn: "Rejoindre la salle",
generateRoomId_tips: "ID Aléatoire",
generateSimpleId_tips: "ID Simple",
generateRandomId_tips: "ID Aléatoire",
readClipboardToRoomId: "Coller l'ID de salle",
enterRoomID_placeholder: "entrez l'ID de salle",
retrieveMethod: "Méthode de récupération",
+9 -4
View File
@@ -229,9 +229,11 @@ export const ja: Messages = {
CurrentFileTransferring_tips: "ファイルが転送中です",
OtherFileTransferring_tips: "現在の転送が完了するまでお待ちください",
download_tips: "クリックしてファイルをダウンロード",
PendingSave_tips: "クリックしてファイルをローカルに保存", // 新增
Saved_dis: "保存済み",
Waiting_dis: "待機中",
Download_dis: "ダウンロード",
Save_dis: "保存", // 新增
},
FileListDisplay: {
sending_dis: "送信中",
@@ -247,9 +249,6 @@ export const ja: Messages = {
chooseSavePath_tips:
"大きなファイルやフォルダを選択したディレクトリに直接保存します。👉",
chooseSavePath_dis: "保存場所を選択",
safeSave_dis: "安全保存",
safeSave_tooltip: "接続の中断を恐れる必要はありません。ここをクリックして、次回の再開のためにファイルを安全に保存してください",
safeSaveSuccessMsg: "ファイルが安全にディスクに保存されました。ページを安全に閉じることができ、転送の再開をサポートします!",
},
RetrieveMethod: {
P: "おめでとう 🎉 共有コンテンツが取得待ちです:",
@@ -283,6 +282,9 @@ export const ja: Messages = {
failMsg: "ルームへの参加に失敗しました:",
},
pickSaveMsg: "ディスクに直接保存しますか?",
pickSaveUnsupported: "ディレクトリピッカーはサポートされていません。",
pickSaveSuccess: "保存場所が設定されました。",
pickSaveError: "保存場所を設定できませんでした。",
roomStatus: {
senderEmptyMsg: "ルームは空です",
receiverEmptyMsg: "招待を受けてルームに参加できます",
@@ -297,6 +299,8 @@ export const ja: Messages = {
noFilesForFolderMsg: "フォルダ '{folderName}' にファイルが見つかりません。",
zipError: "ZIP の作成中にエラーが発生しました。",
fileNotFoundMsg: "ダウンロードするファイル '{fileName}' が見つかりません。",
confirmLeaveWhileTransferring: "現在ファイルが転送中です。退出すると転送が中断されます。よろしいですか?",
leaveWhileTransferringSuccess: "ルームを退出しました。転送が中断されました",
html: {
senderTab: "送信",
retrieveTab: "取得",
@@ -307,7 +311,8 @@ export const ja: Messages = {
Copy_dis: "コピー",
inputRoomIdprompt: "ルームID(編集可能):",
joinRoomBtn: "ルームに参加",
generateRoomId_tips: "ランダムID",
generateSimpleId_tips: "シンプルID",
generateRandomId_tips: "ランダムID",
readClipboardToRoomId: "ルームIDを貼り付け",
enterRoomID_placeholder: "ルームIDを入力",
retrieveMethod: "取得方法",
+9 -4
View File
@@ -227,9 +227,11 @@ export const ko: Messages = {
CurrentFileTransferring_tips: "파일 전송 중",
OtherFileTransferring_tips: "현재 전송이 완료될 때까지 기다려주세요",
download_tips: "파일을 다운로드하려면 클릭하세요",
PendingSave_tips: "로컬에 파일을 저장하려면 클릭하세요", // 新增
Saved_dis: "저장됨",
Waiting_dis: "대기 중",
Download_dis: "다운로드",
Save_dis: "저장", // 新增
},
FileListDisplay: {
sending_dis: "전송 중",
@@ -245,9 +247,6 @@ export const ko: Messages = {
chooseSavePath_tips:
"큰 파일이나 폴더를 선택한 디렉터리에 직접 저장합니다. 👉",
chooseSavePath_dis: "저장 위치 선택",
safeSave_dis: "안전 저장",
safeSave_tooltip: "연결 중단을 두려워하지 마세요. 다음 재개를 위해 파일을 안전하게 저장하려면 여기를 클릭하세요",
safeSaveSuccessMsg: "파일이 디스크에 안전하게 저장되었습니다. 페이지를 안전하게 닫을 수 있으며 전송 재개를 지원합니다!",
},
RetrieveMethod: {
P: "축하합니다 🎉 공유된 콘텐츠가 검색을 기다리고 있습니다:",
@@ -281,6 +280,9 @@ export const ko: Messages = {
failMsg: "방 참여 실패:",
},
pickSaveMsg: "직접 디스크에 저장하시겠습니까?",
pickSaveUnsupported: "디렉토리 선택기가 지원되지 않습니다.",
pickSaveSuccess: "저장 위치가 설정되었습니다.",
pickSaveError: "저장 위치를 설정할 수 없습니다.",
roomStatus: {
senderEmptyMsg: "방이 비어 있습니다",
receiverEmptyMsg: "초대를 수락하여 방에 참여할 수 있습니다",
@@ -295,6 +297,8 @@ export const ko: Messages = {
noFilesForFolderMsg: "폴더 '{folderName}'에서 파일을 찾을 수 없습니다.",
zipError: "ZIP 파일 생성 중 오류가 발생했습니다.",
fileNotFoundMsg: "다운로드할 파일 '{fileName}'을(를) 찾을 수 없습니다.",
confirmLeaveWhileTransferring: "현재 파일이 전송 중입니다. 나가면 전송이 중단됩니다. 확실합니까?",
leaveWhileTransferringSuccess: "방을 나갔습니다. 전송이 중단되었습니다",
html: {
senderTab: "보내기",
retrieveTab: "검색",
@@ -305,7 +309,8 @@ export const ko: Messages = {
Copy_dis: "복사",
inputRoomIdprompt: "방 ID (편집 가능):",
joinRoomBtn: "방 참여",
generateRoomId_tips: "랜덤 ID",
generateSimpleId_tips: "간단 ID",
generateRandomId_tips: "랜덤 ID",
readClipboardToRoomId: "방 ID 붙여넣기",
enterRoomID_placeholder: "방 ID 입력",
retrieveMethod: "검색 방법",
+9 -5
View File
@@ -216,9 +216,11 @@ export const zh: Messages = {
CurrentFileTransferring_tips: "文件正在传输中",
OtherFileTransferring_tips: "请等待当前传输完成",
download_tips: "点击下载文件",
PendingSave_tips: "点击保存文件到本地", // 新增
Saved_dis: "已保存",
Waiting_dis: "等待中",
Download_dis: "下载",
Save_dis: "保存", // 新增
},
FileListDisplay: {
sending_dis: "发送中",
@@ -233,10 +235,6 @@ export const zh: Messages = {
"我们建议选择一个保存目录来直接将文件保存到磁盘。这样可以更方便地传输大文件和同步文件夹。",
chooseSavePath_tips: "大文件或文件夹可直接保存到指定目录 👉",
chooseSavePath_dis: "选择保存位置",
safeSave_dis: "安全保存",
safeSave_tooltip: "连接中断不要怕,点击这里安全保存文件,方便下次续传",
safeSaveSuccessMsg:
"文件已安全保存到磁盘,可以安全关闭页面,支持断点续传!",
},
RetrieveMethod: {
P: "恭喜 🎉 共享内容等待接收:",
@@ -268,6 +266,9 @@ export const zh: Messages = {
failMsg: "加入房间失败:",
},
pickSaveMsg: "直接保存到磁盘?",
pickSaveUnsupported: "不支持目录选择器。",
pickSaveSuccess: "保存位置已设置。",
pickSaveError: "无法设置保存位置。",
roomStatus: {
senderEmptyMsg: "房间为空",
receiverEmptyMsg: "您可以接受邀请加入房间",
@@ -282,6 +283,8 @@ export const zh: Messages = {
noFilesForFolderMsg: "在文件夹 '{folderName}' 中未找到文件。",
zipError: "创建 ZIP 文件时出错。",
fileNotFoundMsg: "未找到要下载的文件 '{fileName}'。",
confirmLeaveWhileTransferring: "当前有文件正在传输,退出将中断传输。确定要退出吗?",
leaveWhileTransferringSuccess: "已退出房间,传输已中断",
html: {
senderTab: "发送",
retrieveTab: "接收",
@@ -292,7 +295,8 @@ export const zh: Messages = {
Copy_dis: "复制",
inputRoomIdprompt: "您的房间ID(可编辑):",
joinRoomBtn: "加入房间",
generateRoomId_tips: "随机ID",
generateSimpleId_tips: "简单ID",
generateRandomId_tips: "随机ID",
readClipboardToRoomId: "粘贴房间ID",
enterRoomID_placeholder: "输入房间ID",
retrieveMethod: "接收方式",
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env node
const http = require('http');
const options = {
host: 'localhost',
port: process.env.PORT || 3002,
path: '/api/health',
timeout: 2000,
method: 'GET'
};
const req = http.request(options, (res) => {
if (res.statusCode === 200) {
console.log('Frontend health check passed');
process.exit(0);
} else {
console.log(`Frontend health check failed with status: ${res.statusCode}`);
process.exit(1);
}
});
req.on('error', (err) => {
console.log(`Frontend health check failed: ${err.message}`);
process.exit(1);
});
req.on('timeout', () => {
console.log('Frontend health check timeout');
req.destroy();
process.exit(1);
});
req.end();
+36 -12
View File
@@ -1,9 +1,10 @@
import { useCallback } from "react";
import { useCallback, useEffect } from "react";
import { CustomFile, FileMeta, fileMetadata } from "@/types/webrtc";
import { Messages } from "@/types/messages";
import JSZip from "jszip";
import { downloadAs } from "@/lib/fileUtils";
import { useFileTransferStore } from "@/stores/fileTransferStore";
import { postLogToBackend } from "@/app/config/api";
interface UseFileTransferHandlerProps {
messages: Messages | null;
@@ -18,7 +19,7 @@ export function useFileTransferHandler({
messages,
putMessageInMs,
}: UseFileTransferHandlerProps) {
// 从 store 中获取状态
// Get state from store
const {
shareContent,
sendFiles,
@@ -26,7 +27,6 @@ export function useFileTransferHandler({
retrievedFiles,
retrievedFileMetas,
setShareContent,
setSendFiles,
addSendFiles,
removeSendFile,
setRetrievedContent,
@@ -68,7 +68,9 @@ export function useFileTransferHandler({
const handleDownloadFile = useCallback(
async (meta: FileMeta) => {
if (!messages) return;
if (!messages) {
return;
}
if (meta.folderName && meta.folderName !== "") {
const { retrievedFiles: latestRetrievedFiles } =
@@ -100,11 +102,11 @@ export function useFileTransferHandler({
}
} else {
let retryCount = 0;
const maxRetries = 3; // 重试次数
const maxRetries = 3; // Retry count
const findAndDownload = async (): Promise<boolean> => {
retryCount++;
// 🔧 关键修复:使用最新的Store状态,而不是闭包中的旧状态
// 🔧 Key fix: Use the latest Store state instead of the old state in the closure
const { retrievedFiles: latestRetrievedFiles } =
useFileTransferStore.getState();
const fileToDownload = latestRetrievedFiles.find(
@@ -112,27 +114,49 @@ export function useFileTransferHandler({
);
if (fileToDownload) {
// Check if file is empty
if (fileToDownload.size === 0) {
postLogToBackend(
`ERROR: File has 0 size! This explains the 0-byte download.`
);
}
// Check if file is a valid Blob
if (!(fileToDownload instanceof Blob)) {
postLogToBackend(
`WARNING: File is not a Blob object, type: ${typeof fileToDownload}`
);
}
downloadAs(fileToDownload, fileToDownload.name);
return true;
} else {
// Debug log: Record the case where file is not found
const availableFileNames = latestRetrievedFiles.map((f) => f.name);
postLogToBackend(
`File NOT found! Looking for: "${
meta.name
}", Available files: [${availableFileNames.join(", ")}]`
);
}
return false;
};
// 首次尝试
// First attempt
const found = await findAndDownload();
if (!found) {
// 如果没找到,启动重试机制
// If not found, start retry mechanism
const retryWithDelay = async (): Promise<void> => {
while (retryCount < maxRetries) {
await new Promise((resolve) => setTimeout(resolve, 50)); // 固定50ms延迟,因为现在状态应该很快同步
await new Promise((resolve) => setTimeout(resolve, 50)); // Fixed 50ms delay, as the state should sync quickly now
const foundInRetry = await findAndDownload();
if (foundInRetry) {
return;
}
}
// 所有重试都失败了
// All retries failed
putMessageInMs(
messages.text.ClipboardApp.fileNotFoundMsg ||
`File '${meta.name}' not found for download.`,
@@ -140,12 +164,12 @@ export function useFileTransferHandler({
);
};
// 异步执行重试,不阻塞主线程
// Execute retry asynchronously without blocking the main thread
retryWithDelay().catch(console.error);
}
}
},
[messages, putMessageInMs] // 🔧 移除retrievedFiles依赖,因为我们现在直接从Store获取最新状态
[messages, putMessageInMs] // 🔧 Remove retrievedFiles dependency as we now get the latest state directly from Store
);
// Reset function specifically for receiver state (for leave room functionality)
+64 -44
View File
@@ -9,7 +9,7 @@ function format_peopleMsg(template: string, peerCount: number) {
return template.replace("{peerCount}", peerCount.toString());
}
// 移除所有 WebRTC 相关的 props 依赖
// Remove all WebRTC related props dependencies
interface UseRoomManagerProps {
messages: Messages | null;
putMessageInMs: (
@@ -23,7 +23,7 @@ export function useRoomManager({
messages,
putMessageInMs,
}: UseRoomManagerProps) {
// store 获取状态
// Get state from store
const {
shareRoomId,
initShareRoomId,
@@ -36,6 +36,7 @@ export function useRoomManager({
senderDisconnected,
isSenderInRoom,
isReceiverInRoom,
isAnyFileTransferring,
setShareRoomId,
setInitShareRoomId,
setShareLink,
@@ -45,13 +46,13 @@ export function useRoomManager({
resetSenderApp,
} = useFileTransferStore();
// 加入房间方法 - 直接使用 webrtcService
// Join room method - directly use webrtcService
const joinRoom = useCallback(
async (isSenderSide: boolean, roomId: string) => {
if (!messages) return;
try {
// 如果是发送方且房间ID不是初始ID,需要先创建房间
// If it's the sender side and the room ID is not the initial ID, need to create the room first
if (
isSenderSide &&
activeTab === "send" &&
@@ -77,7 +78,7 @@ export function useRoomManager({
}
}
// 确定实际要加入的房间ID
// Determine the actual room ID to join
const actualRoomId =
isSenderSide && roomId !== initShareRoomId
? roomId
@@ -85,7 +86,7 @@ export function useRoomManager({
? shareRoomId
: roomId;
// 直接调用 service 方法,无需依赖注入
// Directly call the service method without dependency injection
await webrtcService.joinRoom(actualRoomId, isSenderSide);
putMessageInMs(
@@ -94,7 +95,7 @@ export function useRoomManager({
6000
);
// 更新分享链接
// Update share link
if (isSenderSide) {
const link = `${window.location.origin}${window.location.pathname}?roomId=${actualRoomId}`;
setShareLink(link);
@@ -103,7 +104,7 @@ export function useRoomManager({
}
}
} catch (error) {
console.error("[RoomManager] 加入房间失败:", error);
console.error("[RoomManager] Failed to join room:", error);
let errorMsg = messages.text.ClipboardApp.joinRoom.failMsg;
if (error instanceof Error) {
errorMsg =
@@ -125,7 +126,7 @@ export function useRoomManager({
]
);
// 生成分享链接并广播
// Generate share link and broadcast
const generateShareLinkAndBroadcast = useCallback(async () => {
if (!messages || !shareRoomId) return;
@@ -133,25 +134,31 @@ export function useRoomManager({
if (sharePeerCount === 0) {
putMessageInMs(messages.text.ClipboardApp.waitting_tips, true);
} else {
// 直接调用 service 的广播方法
// Directly call the service's broadcast method
await webrtcService.broadcastDataToAllPeers();
}
// 更新分享链接
// Update share link
const link = `${window.location.origin}${window.location.pathname}?roomId=${shareRoomId}`;
setShareLink(link);
} catch (error) {
console.error("[RoomManager] 生成分享链接失败:", error);
putMessageInMs("生成分享链接失败", true);
console.error("[RoomManager] Failed to generate share link:", error);
putMessageInMs("Failed to generate share link", true);
}
}, [messages, putMessageInMs, shareRoomId, sharePeerCount, setShareLink]);
// 接收方离开房间
// Receiver leave room
const handleLeaveReceiverRoom = useCallback(async () => {
if (!messages) return;
// Check if files are transferring and show confirmation
if (isAnyFileTransferring) {
const confirmed = window.confirm(messages.text.ClipboardApp.confirmLeaveWhileTransferring);
if (!confirmed) return;
}
try {
// 调用后端 API 离开房间
// Call backend API to leave room
if (webrtcService.receiver.roomId && webrtcService.receiver.peerId) {
await leaveRoom(
webrtcService.receiver.roomId,
@@ -159,44 +166,53 @@ export function useRoomManager({
);
}
putMessageInMs(messages.text.ClipboardApp.roomStatus.leftRoomMsg, false);
const message = isAnyFileTransferring
? messages.text.ClipboardApp.leaveWhileTransferringSuccess
: messages.text.ClipboardApp.roomStatus.leftRoomMsg;
putMessageInMs(message, false);
// 重置接收方状态
// Reset receiver state (clears all as per requirement)
resetReceiverState();
// 清理 WebRTC 连接
// Clean up WebRTC connection
await webrtcService.leaveRoom(false);
} catch (error) {
console.error("[RoomManager] 接收方离开房间失败:", error);
putMessageInMs("离开房间失败", true);
console.error("[RoomManager] Receiver failed to leave room:", error);
putMessageInMs("Failed to leave room", true);
}
}, [messages, putMessageInMs, resetReceiverState]);
}, [messages, putMessageInMs, resetReceiverState, isAnyFileTransferring]);
// 发送方重置应用状态
// Sender reset app state
const resetSenderAppState = useCallback(async () => {
try {
// 1. 清理 WebRTC 连接
// 1. Clean up WebRTC connection
await webrtcService.leaveRoom(true);
// 2. 清除分享链接和进度
// 2. Clear share link and progress
resetSenderApp();
// 3. 从后端获取新的房间ID
// 3. Fetch new room ID from backend
const newRoomId = await fetchRoom();
setShareRoomId(newRoomId || "");
setInitShareRoomId(newRoomId || "");
} catch (error) {
console.error("[RoomManager] 重置发送方状态失败:", error);
putMessageInMs("重置发送方状态失败", true);
console.error("[RoomManager] Failed to reset sender state:", error);
putMessageInMs("Failed to reset sender state", true);
}
}, [putMessageInMs, resetSenderApp, setShareRoomId, setInitShareRoomId]);
// 发送方离开房间
// Sender leave room
const handleLeaveSenderRoom = useCallback(async () => {
if (!messages) return;
// Check if files are transferring and show confirmation
if (isAnyFileTransferring) {
const confirmed = window.confirm(messages.text.ClipboardApp.confirmLeaveWhileTransferring);
if (!confirmed) return;
}
try {
// 调用后端 API 离开房间
// Call backend API to leave room
if (webrtcService.sender.roomId && webrtcService.sender.peerId) {
await leaveRoom(
webrtcService.sender.roomId,
@@ -204,18 +220,21 @@ export function useRoomManager({
);
}
putMessageInMs(messages.text.ClipboardApp.roomStatus.leftRoomMsg, true);
const message = isAnyFileTransferring
? messages.text.ClipboardApp.leaveWhileTransferringSuccess
: messages.text.ClipboardApp.roomStatus.leftRoomMsg;
putMessageInMs(message, true);
// 重置发送方状态并获取新房间ID
// Reset sender state and get new room ID (keeps files as per requirement)
await resetSenderAppState();
} catch (error) {
console.error("[RoomManager] 发送方离开房间失败:", error);
putMessageInMs("离开房间失败", true);
console.error("[RoomManager] Sender failed to leave room:", error);
putMessageInMs("Failed to leave room", true);
}
}, [messages, putMessageInMs, resetSenderAppState]);
}, [messages, putMessageInMs, resetSenderAppState, isAnyFileTransferring]);
// 房间ID输入处理
const processRoomIdInput = useCallback(
// Room ID input processing
const processRoomIdInput = useCallback(
debounce(async (input: string) => {
if (!input.trim() || !messages) return;
@@ -234,14 +253,14 @@ export function useRoomManager({
);
}
} catch (error) {
console.error("[RoomManager] 验证房间失败:", error);
putMessageInMs("验证房间失败", true);
console.error("[RoomManager] Failed to validate room:", error);
putMessageInMs("Failed to validate room", true);
}
}, 750),
[messages, putMessageInMs, setShareRoomId]
);
// 初始化发送方房间ID
// Initialize sender room ID
useEffect(() => {
if (
messages &&
@@ -255,9 +274,10 @@ export function useRoomManager({
setShareRoomId(newRoomId || "");
setInitShareRoomId(newRoomId || "");
} catch (err) {
console.error("[RoomManager] 获取初始房间失败:", err);
console.error("[RoomManager] Failed to fetch initial room:", err);
const errorMsg =
messages.text?.ClipboardApp?.fetchRoom_err || "获取房间ID失败";
messages.text?.ClipboardApp?.fetchRoom_err ||
"Failed to fetch room ID";
putMessageInMs(errorMsg, true);
}
};
@@ -272,7 +292,7 @@ export function useRoomManager({
setInitShareRoomId,
]);
// 房间状态文本更新
// Room status text update
useEffect(() => {
if (!messages) {
if (activeTab === "send") setShareRoomStatusText("");
@@ -317,7 +337,7 @@ export function useRoomManager({
]);
return {
// 状态
// State
shareRoomId,
initShareRoomId,
shareLink,
@@ -329,7 +349,7 @@ export function useRoomManager({
isSenderInRoom,
isReceiverInRoom,
// 方法
// Methods
processRoomIdInput,
joinRoom,
generateShareLinkAndBroadcast,
+8 -10
View File
@@ -3,7 +3,7 @@ import { webrtcService } from "@/lib/webrtcService";
import { useFileTransferStore } from "@/stores/fileTransferStore";
import type { Messages } from "@/types/messages";
// 保留类型定义以保持兼容性
// Retain type definitions for compatibility
export type PeerProgressDetails = { progress: number; speed: number };
export type FileProgressPeers = { [peerId: string]: PeerProgressDetails };
export type ProgressState = { [fileId: string]: FileProgressPeers };
@@ -18,10 +18,9 @@ interface UseWebRTCConnectionProps {
}
export function useWebRTCConnection({
messages,
putMessageInMs,
// Retaining interface compatibility but these are no longer used
}: UseWebRTCConnectionProps) {
// store 获取状态
// Get state from store
const {
sharePeerCount,
retrievePeerCount,
@@ -31,7 +30,7 @@ export function useWebRTCConnection({
setIsAnyFileTransferring,
} = useFileTransferStore();
// 计算是否有文件正在传输
// Calculate if any file is being transferred
const isAnyFileTransferring = useMemo(() => {
const allProgress = [
...Object.values(sendProgress),
@@ -49,14 +48,14 @@ export function useWebRTCConnection({
}, [isAnyFileTransferring, setIsAnyFileTransferring]);
return {
// 状态从 store 获取
// State obtained from store
sharePeerCount,
retrievePeerCount,
senderDisconnected,
sendProgress,
receiveProgress,
// 方法直接从 service 暴露
// Methods exposed directly from service
broadcastDataToAllPeers:
webrtcService.broadcastDataToAllPeers.bind(webrtcService),
requestFile: webrtcService.requestFile.bind(webrtcService),
@@ -64,13 +63,12 @@ export function useWebRTCConnection({
setReceiverDirectoryHandle:
webrtcService.setReceiverDirectoryHandle.bind(webrtcService),
getReceiverSaveType: webrtcService.getReceiverSaveType.bind(webrtcService),
manualSafeSave: webrtcService.manualSafeSave.bind(webrtcService),
// 重置连接方法
// Reset connection methods
resetSenderConnection: () => webrtcService.leaveRoom(true),
resetReceiverConnection: () => webrtcService.leaveRoom(false),
// 为了兼容性,保留这些属性(但实际上不再需要)
// For compatibility, retain these properties (but they are no longer needed)
sender: webrtcService.sender,
receiver: webrtcService.receiver,
};
+27
View File
@@ -0,0 +1,27 @@
/**
* Browser detection utility functions
* Extended to support Firefox WebRTC compatibility handling
*/
/**
* Detect if the browser is Chrome
* @returns {boolean} Returns true if it's Chrome, otherwise false
*/
export const isChrome = (): boolean => {
// Detect Chrome browser, excluding Chromium-based Edge
const userAgent = navigator.userAgent;
return (
userAgent.includes("Chrome") && !userAgent.includes("Edg") // Exclude Edge
);
};
/**
* Detect if programmatic download is supported
* Chrome supports automatic download after long transfers, other browsers may have limitations
* @returns {boolean} Returns true if automatic download is supported
*/
export const supportsAutoDownload = (): boolean => {
return isChrome();
};
+142 -563
View File
@@ -1,83 +1,138 @@
// Flow for receiving file(s)/folder(s): First, receive file metadata in batch, [decide if the user needs to select a save directory],
// then click to request, receive the file content, and after receiving endMeta, send an ack to finish.
// Flow for receiving a folder (same as above): Receive a batch file request.
import { SpeedCalculator } from "@/lib/speedCalculator";
// 🚀 Modernized FileReceiver using modular architecture
// This file now serves as a compatibility layer for the new modular receive system
import WebRTC_Recipient from "./webrtc_Recipient";
import {
CustomFile,
fileMetadata,
WebRTCMessage,
FolderProgress,
CurrentString,
StringMetadata,
StringChunk,
FileEnd,
FileHandlers,
FileMeta,
FileRequest,
FolderComplete,
} from "@/types/webrtc";
import { CustomFile, fileMetadata } from "@/types/webrtc";
import { createFileReceiveService, FileReceiveOrchestrator } from "./receive";
/**
* Manages the state of an active file reception.
* 🚀 FileReceiver - Compatibility wrapper for the new modular architecture
*
* This class maintains backward compatibility while using the new modular receive system.
* All heavy lifting is now done by the FileReceiveOrchestrator and its specialized modules.
*/
interface ActiveFileReception {
meta: fileMetadata; // If meta is present, it means this file is currently being received; null means no file is being received.
chunks: (ArrayBuffer | null)[]; // Received file chunks (stored in memory).
receivedSize: number;
initialOffset: number; // For resuming downloads
fileHandle: FileSystemFileHandle | null; // Object related to writing to disk -- current file.
writeStream: FileSystemWritableFileStream | null; // Object related to writing to disk.
completionNotifier: {
resolve: () => void;
reject: (reason?: any) => void;
};
}
class FileReceiver {
// region Private Properties
private readonly webrtcConnection: WebRTC_Recipient;
private readonly largeFileThreshold: number = 1 * 1024 * 1024 * 1024; // 1 GB, larger files will prompt the user to select a directory for direct disk saving.
private readonly speedCalculator: SpeedCalculator;
private fileHandlers: FileHandlers;
private orchestrator: FileReceiveOrchestrator;
private peerId: string = "";
private saveDirectory: FileSystemDirectoryHandle | null = null;
// Public properties for backward compatibility
public saveType: Record<string, boolean> = {};
// State Management
private pendingFilesMeta = new Map<string, fileMetadata>(); // Stores file metadata, fileId: meta
private folderProgresses: Record<string, FolderProgress> = {}; // Folder progress information, fileId: {totalSize: 0, receivedSize: 0, fileIds: []};
public saveType: Record<string, boolean> = {}; // fileId or folderName -> isSavedToDisk
// Callbacks - these are forwarded to the orchestrator
public onFileMetaReceived: ((meta: fileMetadata) => void) | undefined =
undefined;
public onStringReceived: ((str: string) => void) | undefined = undefined;
public onFileReceived: ((file: CustomFile) => Promise<void>) | undefined =
undefined;
// Active transfer state
private activeFileReception: ActiveFileReception | null = null;
private activeStringReception: CurrentString | null = null;
private currentFolderName: string | null = null; // The name of the folder currently being received, or null if not receiving a folder.
constructor(webrtcRecipient: WebRTC_Recipient) {
// Create the orchestrator using the factory function
this.orchestrator = createFileReceiveService(webrtcRecipient);
// Callbacks
public onFileMetaReceived: ((meta: fileMetadata) => void) | null = null;
public onStringReceived: ((str: string) => void) | null = null;
public onFileReceived: ((file: CustomFile) => Promise<void>) | null = null;
private progressCallback:
| ((id: string, progress: number, speed: number) => void)
| null = null;
// endregion
// Set up callback forwarding
this.setupCallbackForwarding();
constructor(WebRTC_recipient: WebRTC_Recipient) {
this.webrtcConnection = WebRTC_recipient;
this.speedCalculator = new SpeedCalculator();
this.fileHandlers = {
string: this.handleReceivedStringChunk.bind(this),
stringMetadata: this.handleStringMetadata.bind(this),
fileMeta: this.handleFileMetadata.bind(this),
fileEnd: this.handleFileEnd.bind(this),
};
this.setupDataHandler();
this.log("log", "FileReceiver initialized with modular architecture");
}
// region Logging and Error Handling
/**
* Set up callback forwarding to the orchestrator
*/
private setupCallbackForwarding(): void {
// Forward file metadata callback
this.orchestrator.onFileMetaReceived = (meta: fileMetadata) => {
// Update saveType for backward compatibility
this.saveType = this.orchestrator.getSaveType();
if (this.onFileMetaReceived) {
this.onFileMetaReceived(meta);
}
};
// Forward string received callback
this.orchestrator.onStringReceived = (str: string) => {
if (this.onStringReceived) {
this.onStringReceived(str);
}
};
// Forward file received callback
this.orchestrator.onFileReceived = async (file: CustomFile) => {
if (this.onFileReceived) {
await this.onFileReceived(file);
}
};
}
/**
* Set progress callback
*/
public setProgressCallback(
callback: (fileId: string, progress: number, speed: number) => void
): void {
this.orchestrator.setProgressCallback(callback);
}
/**
* Set save directory
*/
public setSaveDirectory(directory: FileSystemDirectoryHandle): Promise<void> {
return this.orchestrator.setSaveDirectory(directory);
}
/**
* Request a single file from the peer
*/
public async requestFile(fileId: string, singleFile = true): Promise<void> {
return this.orchestrator.requestFile(fileId, singleFile);
}
/**
* Request all files belonging to a folder from the peer
*/
public async requestFolder(folderName: string): Promise<void> {
return this.orchestrator.requestFolder(folderName);
}
/**
* Graceful shutdown
*/
public gracefulShutdown(reason: string = "CONNECTION_LOST"): void {
this.orchestrator.gracefulShutdown(reason);
// Update saveType for backward compatibility
this.saveType = {};
}
/**
* Force reset all internal states
*/
public forceReset(): void {
this.orchestrator.forceReset();
// Update saveType for backward compatibility
this.saveType = {};
}
/**
* Get transfer statistics (for debugging and monitoring)
*/
public getTransferStats() {
return this.orchestrator.getTransferStats();
}
/**
* Clean up all resources
*/
public cleanup(): void {
this.orchestrator.cleanup();
this.saveType = {};
}
// ===== Private Methods =====
/**
* Logging utility
*/
private log(
level: "log" | "warn" | "error",
message: string,
@@ -87,512 +142,36 @@ class FileReceiver {
console[level](prefix, message, context || "");
}
private fireError(message: string, context?: Record<string, any>) {
if (this.webrtcConnection.fireError) {
// @ts-ignore
this.webrtcConnection.fireError(message, {
...context,
component: "FileReceiver",
});
} else {
this.log("error", message, context);
}
// ===== Backward Compatibility Getters =====
if (this.activeFileReception) {
this.activeFileReception.completionNotifier.reject(new Error(message));
this.activeFileReception = null;
}
}
// endregion
// region Setup and Public API
private setupDataHandler(): void {
this.webrtcConnection.onDataReceived = this.handleReceivedData.bind(this);
}
public setProgressCallback(
callback: (fileId: string, progress: number, speed: number) => void
): void {
this.progressCallback = callback;
}
public setSaveDirectory(directory: FileSystemDirectoryHandle): Promise<void> {
this.saveDirectory = directory;
return Promise.resolve();
/**
* Get pending files metadata (for backward compatibility)
*/
public getPendingFilesMeta(): Map<string, fileMetadata> {
return this.orchestrator.getPendingFilesMeta();
}
/**
* Requests a single file from the peer.
* Get folder progresses (for backward compatibility)
*/
public async requestFile(fileId: string, singleFile = true): Promise<void> {
if (this.activeFileReception) {
this.log("warn", "Another file reception is already in progress.");
return;
}
if (singleFile) this.currentFolderName = null;
const fileInfo = this.pendingFilesMeta.get(fileId);
if (!fileInfo) {
this.fireError("File info not found for the requested fileId", {
fileId,
});
return;
}
const shouldSaveToDisk =
!!this.saveDirectory || fileInfo.size >= this.largeFileThreshold;
// Set saveType at the beginning of the request to prevent race conditions in the UI
this.saveType[fileInfo.fileId] = shouldSaveToDisk;
if (this.currentFolderName) {
this.saveType[this.currentFolderName] = shouldSaveToDisk;
}
let offset = 0;
if (shouldSaveToDisk && this.saveDirectory) {
try {
const folderHandle = await this.createFolderStructure(
fileInfo.fullName
);
const fileHandle = await folderHandle.getFileHandle(fileInfo.name, {
create: false,
});
const file = await fileHandle.getFile();
offset = file.size;
if (offset === fileInfo.size) {
this.log("log", "File already fully downloaded.", { fileId });
// Optionally, trigger a "completed" state in the UI directly
this.progressCallback?.(fileId, 1, 0);
return; // Skip the request
}
this.log("log", `Resuming file from offset: ${offset}`, { fileId });
} catch (e) {
// File does not exist, starting from scratch
this.log("log", "Partial file not found, starting from scratch.", {
fileId,
});
offset = 0;
}
}
const receptionPromise = new Promise<void>((resolve, reject) => {
this.activeFileReception = {
meta: fileInfo,
chunks: [],
receivedSize: 0,
initialOffset: offset,
fileHandle: null,
writeStream: null,
completionNotifier: { resolve, reject },
};
});
if (shouldSaveToDisk) {
await this.createDiskWriteStream(fileInfo, offset);
}
const request: FileRequest = { type: "fileRequest", fileId, offset };
if (this.peerId) {
this.webrtcConnection.sendData(JSON.stringify(request), this.peerId);
this.log("log", "Sent fileRequest", { request });
}
return receptionPromise;
public getFolderProgresses(): Record<string, any> {
return this.orchestrator.getFolderProgresses();
}
/**
* Requests all files belonging to a folder from the peer.
* Check if there's an active file reception
*/
public async requestFolder(folderName: string): Promise<void> {
const folderProgress = this.folderProgresses[folderName];
if (!folderProgress || folderProgress.fileIds.length === 0) {
this.log("warn", "No files found for the requested folder.", {
folderName,
});
return;
}
// Pre-calculate total size of already downloaded parts of the folder
let initialFolderReceivedSize = 0;
if (this.saveDirectory) {
for (const fileId of folderProgress.fileIds) {
const fileInfo = this.pendingFilesMeta.get(fileId);
if (fileInfo) {
try {
const folderHandle = await this.createFolderStructure(
fileInfo.fullName
);
const fileHandle = await folderHandle.getFileHandle(fileInfo.name, {
create: false,
});
const file = await fileHandle.getFile();
initialFolderReceivedSize += file.size;
} catch (e) {
// File doesn't exist, so its size is 0.
}
}
}
}
folderProgress.receivedSize = initialFolderReceivedSize;
this.log(
"log",
`Requesting to receive folder, initial received size: ${initialFolderReceivedSize}`,
{ folderName }
);
this.currentFolderName = folderName;
for (const fileId of folderProgress.fileIds) {
try {
await this.requestFile(fileId, false);
} catch (error) {
this.fireError(
`Failed to receive file ${fileId} in folder ${folderName}`,
{ error }
);
// Stop receiving other files in the folder on error
break;
}
}
this.currentFolderName = null;
// After the loop, the receiver has requested all necessary files.
// Send a completion message to the sender to sync the final state.
const folderComplete: FolderComplete = {
type: "FolderComplete",
folderName: folderName,
};
if (this.peerId) {
this.webrtcConnection.sendData(
JSON.stringify(folderComplete),
this.peerId
);
this.log(
"log",
`Sent folderComplete message for ${folderName} to peer ${this.peerId}`
);
}
}
// endregion
// region WebRTC Data Handlers
private async handleReceivedData(
data: string | ArrayBuffer,
peerId: string
): Promise<void> {
this.peerId = peerId;
if (typeof data === "string") {
try {
const parsedData = JSON.parse(data) as WebRTCMessage;
const handler =
this.fileHandlers[parsedData.type as keyof FileHandlers];
if (handler) {
await handler(parsedData, peerId);
} else {
console.warn(
`[DEBUG] ⚠️ FileReceiver 未找到处理器: ${parsedData.type}`
);
}
} catch (error) {
this.fireError("Error parsing received JSON data", { error });
}
} else if (data instanceof ArrayBuffer) {
if (!this.activeFileReception) {
this.fireError(
"Received a file chunk without an active file reception.",
{ peerId }
);
return;
}
this.updateProgress(data.byteLength);
await this.handleFileChunk(data);
}
public hasActiveFileReception(): boolean {
const stats = this.orchestrator.getTransferStats();
return stats.stateManager.hasActiveFileReception;
}
private handleFileMetadata(metadata: fileMetadata): void {
if (this.pendingFilesMeta.has(metadata.fileId)) {
console.log(
`[DEBUG] 📥 FileReceiver 文件元数据已存在,忽略: ${metadata.fileId}`
);
return; // Ignore if already received.
}
this.pendingFilesMeta.set(metadata.fileId, metadata);
if (this.onFileMetaReceived) {
this.onFileMetaReceived(metadata);
} else {
console.error(`[DEBUG] ❌ FileReceiver onFileMetaReceived 回调不存在!`);
}
// Record the file size for folder progress calculation.
if (metadata.folderName) {
const folderId = metadata.folderName;
if (!(folderId in this.folderProgresses)) {
this.folderProgresses[folderId] = {
totalSize: 0,
receivedSize: 0,
fileIds: [],
};
}
const folderProgress = this.folderProgresses[folderId];
if (!folderProgress.fileIds.includes(metadata.fileId)) {
// Prevent duplicate calculation
folderProgress.totalSize += metadata.size;
folderProgress.fileIds.push(metadata.fileId);
}
}
}
private handleStringMetadata(metadata: StringMetadata): void {
this.activeStringReception = {
length: metadata.length,
chunks: [],
receivedChunks: 0,
};
}
private handleReceivedStringChunk(data: StringChunk): void {
if (!this.activeStringReception) return;
this.activeStringReception.chunks[data.index] = data.chunk;
this.activeStringReception.receivedChunks++;
if (this.activeStringReception.receivedChunks === data.total) {
const fullString = this.activeStringReception.chunks.join("");
this.onStringReceived?.(fullString);
this.activeStringReception = null;
}
}
private async handleFileEnd(metadata: FileEnd): Promise<void> {
this.log("log", "File transmission ended", { metadata });
const reception = this.activeFileReception;
if (!reception || reception.meta.fileId !== metadata.fileId) {
this.log("warn", "Received fileEnd for unexpected file", { metadata });
return;
}
// 🔧 关键修复:先完成文件处理,确保文件添加到Store
await this.finalizeFileReceive();
// 🏗️ 架构重构:确保Store状态完全同步后再触发进度回调
if (!this.currentFolderName) {
// 🔧 优化的异步确保机制 - 确保Store状态完全同步
await Promise.resolve(); // 确保当前执行栈完成
await new Promise<void>((resolve) => {
// 使用更长的延迟确保Store状态完全更新
setTimeout(() => {
this.progressCallback?.(reception.meta.fileId, 1, 0);
resolve();
}, 10); // 增加到10ms确保Store状态完全同步
});
}
this.sendFileAck(reception.meta.fileId);
this.log("log", "Sent file-finish ack", { fileId: reception.meta.fileId });
reception.completionNotifier.resolve();
this.activeFileReception = null;
}
// endregion
// region File and Folder Processing
private async handleFileChunk(chunk: ArrayBuffer): Promise<void> {
if (!this.activeFileReception) return;
if (this.activeFileReception.writeStream) {
await this.writeLargeFileChunk(chunk);
} else {
this.activeFileReception.chunks.push(chunk);
}
}
private async finalizeFileReceive(): Promise<void> {
if (!this.activeFileReception) return;
if (this.activeFileReception.writeStream) {
await this.finalizeLargeFileReceive();
} else {
await this.finalizeMemoryFileReceive();
}
}
private updateProgress(byteLength: number): void {
if (!this.peerId || !this.activeFileReception) return;
this.activeFileReception.receivedSize += byteLength;
const reception = this.activeFileReception;
const totalReceived = reception.initialOffset + reception.receivedSize;
if (this.currentFolderName) {
const folderProgress = this.folderProgresses[this.currentFolderName];
if (!folderProgress) return;
// This is tricky: folder progress needs to sum up individual file progresses.
// For simplicity, we'll estimate based on total received for the active file.
// A more accurate implementation would track offsets for all files in the folder.
folderProgress.receivedSize += byteLength; // This is an approximation
this.speedCalculator.updateSendSpeed(
this.peerId,
folderProgress.receivedSize
);
const speed = this.speedCalculator.getSendSpeed(this.peerId);
const progress =
folderProgress.totalSize > 0
? folderProgress.receivedSize / folderProgress.totalSize
: 0;
this.progressCallback?.(this.currentFolderName, progress, speed);
} else {
this.speedCalculator.updateSendSpeed(this.peerId, totalReceived);
const speed = this.speedCalculator.getSendSpeed(this.peerId);
const progress =
reception.meta.size > 0 ? totalReceived / reception.meta.size : 0;
this.progressCallback?.(reception.meta.fileId, progress, speed);
}
}
// endregion
// region Disk Operations
private async createDiskWriteStream(
meta: FileMeta,
offset: number
): Promise<void> {
if (!this.saveDirectory || !this.activeFileReception) {
this.log("warn", "Save directory not set, falling back to in-memory.");
return;
}
try {
const folderHandle = await this.createFolderStructure(meta.fullName);
const fileHandle = await folderHandle.getFileHandle(meta.name, {
create: true,
});
// Use keepExistingData: true to append
const writeStream = await fileHandle.createWritable({
keepExistingData: true,
});
// Seek to the offset to start writing from there
await writeStream.seek(offset);
this.activeFileReception.fileHandle = fileHandle;
this.activeFileReception.writeStream = writeStream;
} catch (err) {
this.fireError("Failed to create file on disk", {
err,
fileName: meta.name,
});
}
}
private async createFolderStructure(
fullName: string
): Promise<FileSystemDirectoryHandle> {
if (!this.saveDirectory) {
throw new Error("Save directory not set");
}
const parts = fullName.split("/");
parts.pop(); // Remove filename
let currentDir = this.saveDirectory;
for (const part of parts) {
if (part) {
currentDir = await currentDir.getDirectoryHandle(part, {
create: true,
});
}
}
return currentDir;
}
private async writeLargeFileChunk(chunk: ArrayBuffer): Promise<void> {
const stream = this.activeFileReception?.writeStream;
if (!stream) {
// Fallback to memory if stream is not available for some reason
this.activeFileReception?.chunks.push(chunk);
return;
}
try {
await stream.write(chunk);
this.activeFileReception?.chunks.push(null); // Keep track of chunk count
} catch (error) {
this.fireError("Error writing chunk to disk", { error });
}
}
private async finalizeLargeFileReceive(): Promise<void> {
const reception = this.activeFileReception;
if (!reception?.writeStream || !reception.fileHandle) return;
try {
await reception.writeStream.close();
} catch (error) {
this.fireError("Error closing write stream", { error });
}
}
// endregion
// region In-Memory Operations
private async finalizeMemoryFileReceive(): Promise<void> {
const reception = this.activeFileReception;
if (!reception) return;
const fileBlob = new Blob(reception.chunks as ArrayBuffer[], {
type: reception.meta.fileType,
});
const file = new File([fileBlob], reception.meta.name, {
type: reception.meta.fileType,
});
const customFile = Object.assign(file, {
fullName: reception.meta.fullName,
folderName: this.currentFolderName,
}) as CustomFile;
if (this.onFileReceived) {
// 🔧 关键修复:确保 onFileReceived 回调完全同步执行完成
await this.onFileReceived(customFile);
// 🔧 多重确认机制:确保 Store 状态完全同步
await Promise.resolve(); // 第一层确认
await new Promise<void>((resolve) => setTimeout(() => resolve(), 0)); // 第二层确认
}
}
// endregion
// region Communication
private sendFileAck(fileId: string): void {
if (!this.peerId) return;
const confirmation = JSON.stringify({ type: "fileAck", fileId });
this.webrtcConnection.sendData(confirmation, this.peerId);
}
// endregion
public gracefulShutdown(): void {
if (this.activeFileReception?.writeStream) {
this.log(
"log",
"Attempting to gracefully close write stream on page unload."
);
// We don't await this, as beforeunload does not wait for promises.
// This is a "best effort" attempt to flush the buffer to disk.
this.activeFileReception.writeStream.close().catch((err) => {
this.log("error", "Error closing stream during graceful shutdown", {
err,
});
});
}
// 🔧 清理所有内部状态,确保重新连接时能正确接收文件元数据
this.pendingFilesMeta.clear();
this.folderProgresses = {};
this.saveType = {};
this.activeFileReception = null;
this.activeStringReception = null;
this.currentFolderName = null;
/**
* Get current peer ID
*/
public getCurrentPeerId(): string {
const stats = this.orchestrator.getTransferStats();
return stats.stateManager.currentPeerId;
}
}
+30 -426
View File
@@ -1,449 +1,53 @@
// Flow for sending file(s)/folder(s): First, send file metadata, wait for the receiver's request, then send the file content.
// After the file is sent, send an endMeta, wait for the receiver's ack, and finish.
// Flow for sending a folder (same as above): Receive a batch file request.
// Loop through and send the metadata for all files, then record the file size information for the folder part to calculate progress.
// The receiving display end distinguishes between single files and folders.
import { generateFileId } from "@/lib/fileUtils";
import { SpeedCalculator } from "@/lib/speedCalculator";
// 🚀 New process - Receiver-initiated file transfer
// Refactored FileSender - Using modular architecture
import WebRTC_Initiator from "./webrtc_Initiator";
import {
CustomFile,
fileMetadata,
WebRTCMessage,
PeerState,
FolderMeta,
FileAck,
FileRequest,
FolderComplete,
} from "@/types/webrtc";
import { CustomFile } from "@/types/webrtc";
import { FileTransferOrchestrator } from "./transfer/FileTransferOrchestrator";
/**
* 🚀 FileSender - Backward compatible wrapper layer
*
* Refactoring notes:
* - Original 875-line monolithic class refactored into modular architecture
* - Internally uses FileTransferOrchestrator for unified orchestration
* - Maintains 100% backward compatible public API
* - Gains advantages such as high-performance file reading and intelligent backpressure control
*/
class FileSender {
private webrtcConnection: WebRTC_Initiator;
private peerStates: Map<string, PeerState>;
private readonly chunkSize: number;
private readonly maxBufferSize: number;
private pendingFiles: Map<string, CustomFile>;
private pendingFolerMeta: Record<string, FolderMeta>;
private speedCalculator: SpeedCalculator;
private orchestrator: FileTransferOrchestrator;
constructor(WebRTC_initiator: WebRTC_Initiator) {
this.webrtcConnection = WebRTC_initiator;
// Maintain independent sending states for each receiver
this.peerStates = new Map(); // Map<peerId, PeerState>
this.chunkSize = 65536; // 64 KB chunks
this.maxBufferSize = 10; // Number of chunks to pre-read
this.pendingFiles = new Map(); // All files pending to be sent (by reference) {fileId: CustomFile}
this.pendingFolerMeta = {}; // Metadata for folders (total size, total file count), used for tracking transfer progress
// Create a SpeedCalculator instance
this.speedCalculator = new SpeedCalculator();
this.setupDataHandler();
constructor(webrtcConnection: WebRTC_Initiator) {
this.orchestrator = new FileTransferOrchestrator(webrtcConnection);
console.log("[FileSender] ✅ Initialized with modular architecture");
}
// region Logging and Error Handling
private log(
level: "log" | "warn" | "error",
message: string,
context?: Record<string, any>
) {
const prefix = `[FileSender]`;
console[level](prefix, message, context || "");
public sendFileMeta(files: CustomFile[], peerId?: string): void {
return this.orchestrator.sendFileMeta(files, peerId);
}
private fireError(message: string, context?: Record<string, any>) {
this.webrtcConnection.fireError(message, {
...context,
component: "FileSender",
});
}
// endregion
// Initialize state for a new receiver
private getPeerState(peerId: string): PeerState {
if (!this.peerStates.has(peerId)) {
this.peerStates.set(peerId, {
isSending: false, // Used to determine if a file is successfully sent. True before sending, false after receiving ack.
bufferQueue: [], // Pre-read buffer to improve sending efficiency.
readOffset: 0, // Read position, used by the sending function.
isReading: false, // Whether reading is in progress, used by the sending function to avoid duplicate reads.
currentFolderName: "", // If the current file belongs to a folder, assign the folder name here.
totalBytesSent: {}, // Bytes sent for a file/folder, used for progress calculation; {fileId: 0}
progressCallback: null, // Progress callback.
});
}
return this.peerStates.get(peerId)!; // ! Non-Null Assertion Operator
}
private setupDataHandler(): void {
this.webrtcConnection.onDataReceived = (data, peerId) => {
if (typeof data === "string") {
try {
const parsedData = JSON.parse(data) as WebRTCMessage;
this.handleSignalingMessage(parsedData, peerId);
} catch (error) {
this.fireError("Error parsing received JSON data", { error, peerId });
}
}
};
}
private handleSignalingMessage(message: WebRTCMessage, peerId: string): void {
const peerState = this.getPeerState(peerId);
switch (message.type) {
case "fileRequest":
this.handleFileRequest(message as FileRequest, peerId);
break;
case "fileAck":
peerState.isSending = false;
this.log("log", `Received file-finish ack from peer ${peerId}`, {
fileId: (message as FileAck).fileId,
});
break;
case "folderComplete":
const folderName = (message as FolderComplete).folderName;
this.log(
"log",
`Received folderComplete message for ${folderName} from peer ${peerId}`
);
// The receiver has confirmed the folder is complete.
// Force the progress to 100% for the sender's UI.
if (this.pendingFolerMeta[folderName]) {
peerState.progressCallback?.(folderName, 1, 0);
}
break;
default:
this.log("warn", `Unknown signaling message type received`, {
type: message.type,
peerId,
});
}
public async sendString(content: string, peerId: string): Promise<void> {
return this.orchestrator.sendString(content, peerId);
}
public setProgressCallback(
callback: (fileId: string, progress: number, speed: number) => void,
peerId: string
): void {
this.getPeerState(peerId).progressCallback = callback;
}
// Respond to a file request by sending the file
private async handleFileRequest(
request: FileRequest,
peerId: string
): Promise<void> {
const file = this.pendingFiles.get(request.fileId);
const offset = request.offset || 0;
this.log(
"log",
`Handling file request for ${request.fileId} from ${peerId} with offset ${offset}`
);
if (file) {
await this.sendSingleFile(file, peerId, offset);
} else {
this.fireError(`File not found for request`, {
fileId: request.fileId,
peerId,
});
}
}
// Modify the sendString method to be asynchronous
public async sendString(content: string, peerId: string): Promise<void> {
const chunks: string[] = [];
for (let i = 0; i < content.length; i += this.chunkSize) {
chunks.push(content.slice(i, i + this.chunkSize));
}
// First, send the metadata
await this.sendWithBackpressure(
JSON.stringify({
type: "stringMetadata",
length: content.length,
}),
peerId
);
// Send each chunk sequentially, using backpressure control
for (let i = 0; i < chunks.length; i++) {
const data = JSON.stringify({
type: "string",
chunk: chunks[i],
index: i,
total: chunks.length,
});
await this.sendWithBackpressure(data, peerId);
}
return this.orchestrator.setProgressCallback(callback, peerId);
}
public sendFileMeta(files: CustomFile[], peerId?: string): void {
// Record the size of files belonging to a folder for progress calculation
files.forEach((file) => {
if (file.folderName) {
const folderId = file.folderName;
// folderName: {totalSize: 0, fileIds: []}
if (!this.pendingFolerMeta[folderId]) {
this.pendingFolerMeta[folderId] = { totalSize: 0, fileIds: [] };
}
const folderMeta = this.pendingFolerMeta[folderId];
const fileId = generateFileId(file);
if (!folderMeta.fileIds.includes(fileId)) {
// If the file has not been added yet
folderMeta.fileIds.push(fileId);
folderMeta.totalSize += file.size;
}
}
});
// Loop through and send the metadata for all files
const peers = peerId
? [peerId]
: Array.from(this.webrtcConnection.peerConnections.keys());
peers.forEach((pId) => {
files.forEach((file) => {
const fileId = generateFileId(file);
this.pendingFiles.set(fileId, file);
const fileMeta = this.getFileMeta(file);
const metaDataString = JSON.stringify(fileMeta);
const sendResult = this.webrtcConnection.sendData(metaDataString, pId);
if (!sendResult) {
this.fireError("Failed to send file metadata", {
fileMeta,
peerId: pId,
});
}
});
});
public getTransferStats(peerId?: string) {
return this.orchestrator.getTransferStats(peerId);
}
// Send a single file
private async sendSingleFile(
file: CustomFile,
peerId: string,
offset: number = 0
): Promise<void> {
const fileId = generateFileId(file);
const peerState = this.getPeerState(peerId);
if (peerState.isSending) {
this.log(
"warn",
`Already sending a file to peer ${peerId}, request for ${file.name} ignored.`
);
return;
}
this.log(
"log",
`Starting to send single file: ${file.name} to ${peerId} from offset ${offset}`
);
// Reset state for the new transfer
peerState.isSending = true;
peerState.currentFolderName = file.folderName;
peerState.readOffset = offset; // Start reading from the given offset
peerState.bufferQueue = [];
peerState.isReading = false;
peerState.totalBytesSent[fileId] = offset; // Start counting sent bytes from the offset
try {
await this.processSendQueue(file, peerId);
this.finalizeSendFile(fileId, peerId);
await this.waitForTransferComplete(peerId); // Wait for transfer completion -- receiver confirmation
} catch (error: any) {
this.fireError(`Error sending file ${file.name}`, {
error: error.message,
fileId,
peerId,
});
this.abortFileSend(fileId, peerId);
}
public handlePeerReconnection(peerId: string): void {
this.orchestrator.handlePeerReconnection(peerId);
console.log(`[FileSender] Handled peer reconnection for ${peerId}`);
}
private async waitForTransferComplete(peerId: string): Promise<void> {
const peerState = this.getPeerState(peerId);
while (peerState?.isSending) {
await new Promise((resolve) => setTimeout(resolve, 50));
}
}
private getFileMeta(file: CustomFile): fileMetadata {
const fileId = generateFileId(file);
return {
type: "fileMeta",
fileId,
name: file.name,
size: file.size,
fileType: file.type,
fullName: file.fullName,
folderName: file.folderName,
};
}
private async updateProgress(
byteLength: number,
fileId: string,
fileSize: number,
peerId: string
): Promise<void> {
const peerState = this.getPeerState(peerId);
if (!peerState) return;
// Always update the individual file's progress first.
if (!peerState.totalBytesSent[fileId]) {
// This case should be handled by sendSingleFile's initialization
peerState.totalBytesSent[fileId] = 0;
}
peerState.totalBytesSent[fileId] += byteLength;
let progressFileId = fileId;
let currentBytes = peerState.totalBytesSent[fileId];
let totalSize = fileSize;
// If the file is part of a folder, recalculate the folder's progress.
if (peerState.currentFolderName) {
const folderId = peerState.currentFolderName;
const folderMeta = this.pendingFolerMeta[folderId];
progressFileId = folderId;
totalSize = folderMeta?.totalSize || 0;
// Recalculate folder progress from the sum of its files' progresses.
// This is more robust and correct for resumed transfers.
let folderTotalSent = 0;
if (folderMeta) {
folderMeta.fileIds.forEach((fId) => {
folderTotalSent += peerState.totalBytesSent[fId] || 0;
});
}
currentBytes = folderTotalSent;
}
this.speedCalculator.updateSendSpeed(peerId, currentBytes);
const speed = this.speedCalculator.getSendSpeed(peerId);
const progress = totalSize > 0 ? currentBytes / totalSize : 0;
peerState.progressCallback?.(progressFileId, progress, speed);
}
private async sendWithBackpressure(
data: string | ArrayBuffer,
peerId: string
): Promise<void> {
const dataChannel = this.webrtcConnection.dataChannels.get(peerId);
if (!dataChannel) {
throw new Error("Data channel not found");
}
if (dataChannel.bufferedAmount > dataChannel.bufferedAmountLowThreshold) {
await new Promise<void>((resolve) => {
const listener = () => {
dataChannel.removeEventListener("bufferedamountlow", listener);
resolve();
};
dataChannel.addEventListener("bufferedamountlow", listener);
});
}
if (!this.webrtcConnection.sendData(data, peerId)) {
throw new Error("sendData failed");
}
}
//start sending file content
private async processSendQueue(
file: CustomFile,
peerId: string
): Promise<void> {
const fileId = generateFileId(file);
const peerState = this.getPeerState(peerId);
const fileReader = new FileReader();
// The file object itself is the full file. Slicing happens here.
const fileToSend = file.slice(peerState.readOffset);
let relativeOffset = 0;
while (relativeOffset < fileToSend.size) {
if (!peerState.isSending) {
throw new Error("File sending was aborted.");
}
// Read chunks into buffer if not already reading and buffer is not full
if (
!peerState.isReading &&
peerState.bufferQueue.length < this.maxBufferSize
) {
peerState.isReading = true;
const slice = fileToSend.slice(
relativeOffset,
relativeOffset + this.chunkSize
);
try {
const chunk = await this.readChunkAsArrayBuffer(fileReader, slice);
peerState.bufferQueue.push(chunk);
relativeOffset += chunk.byteLength;
peerState.readOffset += chunk.byteLength; // Also update the main offset
} catch (error: any) {
throw new Error(`File chunk reading failed: ${error.message}`);
} finally {
peerState.isReading = false;
}
}
// Send chunks from buffer
if (peerState.bufferQueue.length > 0) {
const chunk = peerState.bufferQueue.shift()!;
await this.sendWithBackpressure(chunk, peerId);
await this.updateProgress(chunk.byteLength, fileId, file.size, peerId);
} else if (peerState.isReading) {
// If buffer is empty but we are still reading, wait a bit
await new Promise((resolve) => setTimeout(resolve, 50));
} else if (relativeOffset < fileToSend.size) {
// Buffer is empty, not reading, but not done, so trigger a read
continue;
}
}
// Final progress update to 100%
if (!peerState.currentFolderName) {
this.getPeerState(peerId).progressCallback?.(fileId, 1, 0);
}
}
private readChunkAsArrayBuffer(
fileReader: FileReader,
blob: Blob
): Promise<ArrayBuffer> {
return new Promise((resolve, reject) => {
fileReader.onload = (e) => {
// Ensure e.target.result is an ArrayBuffer
if (e.target?.result instanceof ArrayBuffer) {
resolve(e.target.result);
} else {
reject(new Error("Failed to read blob as ArrayBuffer"));
}
};
fileReader.onerror = () =>
reject(fileReader.error || new Error("Unknown FileReader error"));
fileReader.onabort = () => reject(new Error("File reading was aborted"));
fileReader.readAsArrayBuffer(blob);
});
}
//send fileEnd signal
private finalizeSendFile(fileId: string, peerId: string): void {
// this.log("log", `Finalizing file send for ${fileId} to ${peerId}`);
const endMessage = JSON.stringify({ type: "fileEnd", fileId });
if (!this.webrtcConnection.sendData(endMessage, peerId)) {
this.log("warn", `Failed to send fileEnd message for ${fileId}`);
}
// The isSending flag will be set to false upon receiving fileAck
}
private abortFileSend(fileId: string, peerId: string): void {
this.log("warn", `Aborting file send for ${fileId} to ${peerId}`);
const peerState = this.getPeerState(peerId);
peerState.isSending = false;
peerState.readOffset = 0;
peerState.bufferQueue = [];
peerState.isReading = false;
// Optionally, send an abort message to the receiver
public cleanup(): void {
return this.orchestrator.cleanup();
}
}
+38 -7
View File
@@ -1,4 +1,5 @@
import { CustomFile } from "@/types/webrtc";
import { postLogToBackend } from "@/app/config/api";
// Adaptively format the file size with units
export const formatFileSize = (sizeInBytes: number): string => {
@@ -25,15 +26,45 @@ export const generateFileId = (file: CustomFile): string => {
export const downloadAs = async (
file: Blob | File,
saveName: string
): Promise<void> => {
// Check if file is empty
if (file.size === 0) {
postLogToBackend(
`[Download Debug] CRITICAL ERROR: downloadAs received a file with 0 size! This is the root cause of the 0-byte download issue.`
);
throw new Error("Cannot download file with 0 size");
}
try {
return await standardDownload(file, saveName);
} catch (error) {
postLogToBackend(`[Download Debug] ERROR in downloadAs: ${error}`);
throw error;
}
};
/**
* Standard download mechanism for all browsers
*/
const standardDownload = async (
file: Blob | File,
saveName: string
): Promise<void> => {
const url = URL.createObjectURL(file);
const a = document.createElement("a");
a.href = url;
a.download = saveName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
try {
const a = document.createElement("a");
a.href = url;
a.download = saveName;
a.style.display = "none";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
} finally {
// Clean up the object URL after a delay to ensure download starts
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
};
export const traverseFileTree = async (
+329
View File
@@ -0,0 +1,329 @@
import { EmbeddedChunkMeta } from "@/types/webrtc";
import { ReceptionConfig } from "./ReceptionConfig";
import { postLogToBackend } from "@/app/config/api";
/**
* 🚀 Chunk processing result interface
*/
export interface ChunkProcessingResult {
chunkMeta: EmbeddedChunkMeta;
chunkData: ArrayBuffer;
absoluteChunkIndex: number;
relativeChunkIndex: number;
}
/**
* 🚀 Chunk processor
* Handles all data chunk processing, format conversion, and parsing
*/
export class ChunkProcessor {
/**
* Convert various binary data formats to ArrayBuffer
* Supports Blob, Uint8Array, and other formats for Firefox compatibility
*/
async convertToArrayBuffer(data: any): Promise<ArrayBuffer | null> {
const originalType = Object.prototype.toString.call(data);
if (data instanceof ArrayBuffer) {
return data;
} else if (data instanceof Blob) {
try {
const arrayBuffer = await data.arrayBuffer();
if (data.size !== arrayBuffer.byteLength) {
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) {
postLogToBackend(
`[DEBUG] ⚠️ Blob size mismatch: ${data.size}${arrayBuffer.byteLength}`
);
}
}
return arrayBuffer;
} catch (error) {
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) {
postLogToBackend(`[DEBUG] ❌ Blob conversion failed: ${error}`);
}
return null;
}
} else if (data instanceof Uint8Array || ArrayBuffer.isView(data)) {
try {
const uint8Array =
data instanceof Uint8Array
? data
: new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
const newArrayBuffer = new ArrayBuffer(uint8Array.length);
new Uint8Array(newArrayBuffer).set(uint8Array);
return newArrayBuffer;
} catch (error) {
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) {
postLogToBackend(`[DEBUG] ❌ TypedArray conversion failed: ${error}`);
}
return null;
}
} else {
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) {
postLogToBackend(
`[DEBUG] ❌ Unknown data type: ${Object.prototype.toString.call(
data
)}`
);
}
return null;
}
}
/**
* Parse embedded chunk packet
* Format: [4 bytes length] + [JSON metadata] + [actual chunk data]
*/
parseEmbeddedChunkPacket(arrayBuffer: ArrayBuffer): {
chunkMeta: EmbeddedChunkMeta;
chunkData: ArrayBuffer;
} | null {
try {
// 1. Check minimum packet length
if (arrayBuffer.byteLength < ReceptionConfig.VALIDATION_CONFIG.MIN_PACKET_SIZE) {
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) {
postLogToBackend(
`[DEBUG] ❌ Invalid embedded packet - too small: ${arrayBuffer.byteLength}`
);
}
return null;
}
// 2. Read metadata length (4 bytes)
const lengthView = new Uint32Array(arrayBuffer, 0, 1);
const metaLength = lengthView[0];
// 3. Verify packet integrity
const expectedTotalLength = 4 + metaLength;
if (arrayBuffer.byteLength < expectedTotalLength) {
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) {
postLogToBackend(
`[DEBUG] ❌ Incomplete embedded packet - expected: ${expectedTotalLength}, got: ${arrayBuffer.byteLength}`
);
}
return null;
}
// 4. Extract metadata section
const metaBytes = new Uint8Array(arrayBuffer, 4, metaLength);
const metaJson = new TextDecoder().decode(metaBytes);
const chunkMeta: EmbeddedChunkMeta = JSON.parse(metaJson);
// 5. Extract actual chunk data section
const chunkDataStart = 4 + metaLength;
const chunkData = arrayBuffer.slice(chunkDataStart);
// 6. Verify chunk data size
if (chunkData.byteLength !== chunkMeta.chunkSize) {
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) {
postLogToBackend(
`[DEBUG] ⚠️ Chunk size mismatch - meta: ${chunkMeta.chunkSize}, actual: ${chunkData.byteLength}`
);
}
}
return { chunkMeta, chunkData };
} catch (error) {
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) {
postLogToBackend(
`[DEBUG] ❌ Failed to parse embedded packet: ${error}`
);
}
return null;
}
}
/**
* Process received chunk and calculate indices
*/
processReceivedChunk(
chunkMeta: EmbeddedChunkMeta,
chunkData: ArrayBuffer,
initialOffset: number
): ChunkProcessingResult | null {
// Calculate indices
const absoluteChunkIndex = chunkMeta.chunkIndex; // Sender's absolute index
const startChunkIndex = ReceptionConfig.getChunkIndexFromOffset(initialOffset); // Resume start index
const relativeChunkIndex = absoluteChunkIndex - startChunkIndex; // Relative index in chunks array
// 🎯 Simplify debugging: Only record index mapping when boundary chunk
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING && (absoluteChunkIndex <= 2 || relativeChunkIndex <= 2)) {
postLogToBackend(
`[INDEX-MAP] abs:${absoluteChunkIndex}, start:${startChunkIndex}, rel:${relativeChunkIndex}`
);
}
return {
chunkMeta,
chunkData,
absoluteChunkIndex,
relativeChunkIndex,
};
}
/**
* Validate chunk against expected parameters
*/
validateChunk(
chunkMeta: EmbeddedChunkMeta,
expectedFileId: string,
expectedChunksCount: number,
initialOffset: number
): {
isValid: boolean;
errors: string[];
} {
const errors: string[] = [];
// Verify fileId match
if (chunkMeta.fileId !== expectedFileId) {
errors.push(`FileId mismatch - expected: ${expectedFileId}, got: ${chunkMeta.fileId}`);
}
// Validate chunk size
if (chunkMeta.chunkSize <= 0) {
errors.push(`Invalid chunk size: ${chunkMeta.chunkSize}`);
}
// Check if chunk index is reasonable
if (chunkMeta.chunkIndex < 0) {
errors.push(`Invalid chunk index: ${chunkMeta.chunkIndex}`);
}
// Validate total chunks (with resume consideration)
const startChunkIndex = ReceptionConfig.getChunkIndexFromOffset(initialOffset);
const calculatedExpected = chunkMeta.totalChunks - startChunkIndex;
// 🎯 Simplify logging: Only record critical information when the number does not match
if (chunkMeta.totalChunks !== expectedChunksCount && calculatedExpected !== expectedChunksCount) {
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) {
postLogToBackend(
`[CHUNK-COUNT-MISMATCH] fileTotal:${chunkMeta.totalChunks}, expected:${expectedChunksCount}, calculated:${calculatedExpected}`
);
}
}
return {
isValid: errors.length === 0,
errors,
};
}
/**
* Check if chunk index is within valid range
*/
isChunkIndexValid(
relativeChunkIndex: number,
expectedChunksCount: number
): boolean {
return relativeChunkIndex >= 0 && relativeChunkIndex < expectedChunksCount;
}
/**
* Log chunk processing details (for debugging)
*/
logChunkDetails(
result: ChunkProcessingResult,
expectedChunksCount: number,
writerExpectedIndex?: number
): void {
if (!ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) {
return;
}
// 🎯 Simplify logging: Only record boundary chunk and abnormal cases
const { chunkMeta, absoluteChunkIndex, relativeChunkIndex } = result;
const isFirstFew = absoluteChunkIndex <= 3;
const isLastFew = relativeChunkIndex >= expectedChunksCount - 3;
// 🔧 Fix: SequencedWriter expects absolute index, not relative index
const hasIndexMismatch = writerExpectedIndex !== undefined && absoluteChunkIndex !== writerExpectedIndex;
if (isFirstFew || isLastFew || hasIndexMismatch) {
postLogToBackend(
`[CHUNK-DETAIL] #${absoluteChunkIndex} rel:${relativeChunkIndex}${
hasIndexMismatch ? ` MISMATCH(writer expects:${writerExpectedIndex})` : ''
} size:${chunkMeta.chunkSize}`
);
}
}
/**
* Calculate completion statistics
*/
calculateCompletionStats(
chunks: (ArrayBuffer | null)[],
expectedChunksCount: number,
expectedSize: number
): {
sequencedCount: number;
currentTotalSize: number;
isSequencedComplete: boolean;
sizeComplete: boolean;
isDataComplete: boolean;
} {
// Calculate current actual total received size
const currentTotalSize = chunks.reduce((sum, chunk) => {
return sum + (chunk instanceof ArrayBuffer ? chunk.byteLength : 0);
}, 0);
// Count sequentially received chunks
let sequencedCount = 0;
for (let i = 0; i < expectedChunksCount; i++) {
if (chunks[i] instanceof ArrayBuffer) {
sequencedCount++;
}
}
const isSequencedComplete = sequencedCount === expectedChunksCount;
const sizeComplete = currentTotalSize >= expectedSize;
const isDataComplete = isSequencedComplete && sizeComplete;
return {
sequencedCount,
currentTotalSize,
isSequencedComplete,
sizeComplete,
isDataComplete,
};
}
/**
* Log completion check details (for debugging)
*/
logCompletionCheck(
fileName: string,
stats: {
sequencedCount: number;
expectedChunksCount: number;
currentTotalSize: number;
expectedSize: number;
isDataComplete: boolean;
},
chunks: (ArrayBuffer | null)[],
initialOffset: number
): void {
if (!ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) {
return;
}
const { sequencedCount, expectedChunksCount, currentTotalSize, expectedSize, isDataComplete } = stats;
// 🎯 Critical log 3: Only print final check results when complete
if (isDataComplete) {
const startChunkIndex = ReceptionConfig.getChunkIndexFromOffset(initialOffset);
const missingChunks = [];
for (let i = 0; i < expectedChunksCount; i++) {
if (!chunks[i]) {
const absoluteIndex = startChunkIndex + i;
missingChunks.push(absoluteIndex);
}
}
postLogToBackend(
`[FINAL-CHECK] File: ${fileName}, received: ${sequencedCount}/${expectedChunksCount}, sizeDiff: ${expectedSize - currentTotalSize}, missing: [${missingChunks.join(',')}]`
);
}
}
}
+280
View File
@@ -0,0 +1,280 @@
import { CustomFile, fileMetadata } from "@/types/webrtc";
import { ReceptionConfig } from "./ReceptionConfig";
import { postLogToBackend } from "@/app/config/api";
const developmentEnv = process.env.NODE_ENV;
/**
* 🚀 File assembly result interface
*/
export interface FileAssemblyResult {
file: CustomFile;
totalChunkSize: number;
validChunks: number;
storeUpdated: boolean;
}
/**
* 🚀 File assembler
* Handles in-memory file assembly and validation
*/
export class FileAssembler {
/**
* Assemble file from chunks in memory
*/
async assembleFileFromChunks(
chunks: (ArrayBuffer | null)[],
meta: fileMetadata,
currentFolderName: string | null,
onFileReceived?: (file: CustomFile) => Promise<void>
): Promise<FileAssemblyResult> {
// Validate and count chunks
let totalChunkSize = 0;
let validChunks = 0;
chunks.forEach((chunk, index) => {
if (chunk instanceof ArrayBuffer) {
validChunks++;
totalChunkSize += chunk.byteLength;
}
});
// Final verification
const sizeDifference = meta.size - totalChunkSize;
if (Math.abs(sizeDifference) > ReceptionConfig.VALIDATION_CONFIG.MAX_SIZE_DIFFERENCE_BYTES) {
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) {
postLogToBackend(
`[DEBUG] ❌ SIZE_MISMATCH - difference: ${sizeDifference} bytes (threshold: ${ReceptionConfig.VALIDATION_CONFIG.MAX_SIZE_DIFFERENCE_BYTES})`
);
}
}
// Create file blob from valid chunks
const validChunkBuffers = chunks.filter(
(chunk) => chunk instanceof ArrayBuffer
) as ArrayBuffer[];
const fileBlob = new Blob(validChunkBuffers, {
type: meta.fileType,
});
// Create File object
const file = new File([fileBlob], meta.name, {
type: meta.fileType,
});
// Create CustomFile with additional properties
const customFile = Object.assign(file, {
fullName: meta.fullName,
folderName: currentFolderName,
}) as CustomFile;
// Store the file if callback is provided
let storeUpdated = false;
if (onFileReceived) {
await onFileReceived(customFile);
await Promise.resolve();
await new Promise<void>((resolve) => setTimeout(() => resolve(), 0));
storeUpdated = true;
}
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) {
postLogToBackend(
`[DEBUG] ✅ File assembled - ${meta.name}, chunks: ${validChunks}/${chunks.length}, size: ${totalChunkSize}/${meta.size}, stored: ${storeUpdated}`
);
}
return {
file: customFile,
totalChunkSize,
validChunks,
storeUpdated,
};
}
/**
* Validate file assembly completeness
*/
validateAssembly(
chunks: (ArrayBuffer | null)[],
expectedSize: number,
expectedChunksCount: number
): {
isComplete: boolean;
validChunks: number;
totalSize: number;
missingChunks: number[];
sizeDifference: number;
} {
let totalSize = 0;
let validChunks = 0;
const missingChunks: number[] = [];
chunks.forEach((chunk, index) => {
if (chunk instanceof ArrayBuffer) {
validChunks++;
totalSize += chunk.byteLength;
} else {
missingChunks.push(index);
}
});
const sizeDifference = expectedSize - totalSize;
const isComplete =
validChunks === expectedChunksCount &&
Math.abs(sizeDifference) <= ReceptionConfig.VALIDATION_CONFIG.MAX_SIZE_DIFFERENCE_BYTES;
return {
isComplete,
validChunks,
totalSize,
missingChunks,
sizeDifference,
};
}
/**
* Get assembly statistics for debugging
*/
getAssemblyStats(chunks: (ArrayBuffer | null)[]): {
totalChunks: number;
validChunks: number;
nullChunks: number;
totalSize: number;
averageChunkSize: number;
firstNullIndex: number | null;
lastValidIndex: number | null;
} {
let validChunks = 0;
let totalSize = 0;
let firstNullIndex: number | null = null;
let lastValidIndex: number | null = null;
chunks.forEach((chunk, index) => {
if (chunk instanceof ArrayBuffer) {
validChunks++;
totalSize += chunk.byteLength;
lastValidIndex = index;
} else {
if (firstNullIndex === null) {
firstNullIndex = index;
}
}
});
const averageChunkSize = validChunks > 0 ? totalSize / validChunks : 0;
return {
totalChunks: chunks.length,
validChunks,
nullChunks: chunks.length - validChunks,
totalSize,
averageChunkSize,
firstNullIndex,
lastValidIndex,
};
}
/**
* Create file download URL for in-memory files
*/
createDownloadUrl(file: File): string {
return URL.createObjectURL(file);
}
/**
* Revoke file download URL to free memory
*/
revokeDownloadUrl(url: string): void {
URL.revokeObjectURL(url);
}
/**
* Get file type information
*/
getFileTypeInfo(file: File): {
mimeType: string;
extension: string;
category: 'image' | 'video' | 'audio' | 'document' | 'archive' | 'other';
} {
const mimeType = file.type || 'application/octet-stream';
const extension = file.name.split('.').pop()?.toLowerCase() || '';
let category: 'image' | 'video' | 'audio' | 'document' | 'archive' | 'other' = 'other';
if (mimeType.startsWith('image/')) {
category = 'image';
} else if (mimeType.startsWith('video/')) {
category = 'video';
} else if (mimeType.startsWith('audio/')) {
category = 'audio';
} else if (
mimeType.includes('text/') ||
mimeType.includes('application/pdf') ||
mimeType.includes('application/msword') ||
mimeType.includes('application/vnd.openxmlformats')
) {
category = 'document';
} else if (
mimeType.includes('zip') ||
mimeType.includes('rar') ||
mimeType.includes('tar') ||
mimeType.includes('gzip')
) {
category = 'archive';
}
return {
mimeType,
extension,
category,
};
}
/**
* Estimate memory usage for file assembly
*/
estimateMemoryUsage(chunks: (ArrayBuffer | null)[]): {
chunkMemoryUsage: number;
estimatedBlobMemory: number;
totalEstimatedMemory: number;
} {
const chunkMemoryUsage = chunks.reduce((sum, chunk) => {
return sum + (chunk instanceof ArrayBuffer ? chunk.byteLength : 0);
}, 0);
// Blob creation might temporarily double memory usage
const estimatedBlobMemory = chunkMemoryUsage;
const totalEstimatedMemory = chunkMemoryUsage + estimatedBlobMemory;
return {
chunkMemoryUsage,
estimatedBlobMemory,
totalEstimatedMemory,
};
}
/**
* Check if file should be assembled in memory or streamed to disk
*/
shouldAssembleInMemory(
fileSize: number,
hasSaveDirectory: boolean,
availableMemory?: number
): boolean {
// If we have a save directory and file is large, prefer disk
if (hasSaveDirectory && fileSize >= ReceptionConfig.FILE_CONFIG.LARGE_FILE_THRESHOLD) {
return false;
}
// If available memory is provided, check if we have enough
if (availableMemory !== undefined) {
// Need roughly 2x file size for assembly process
const requiredMemory = fileSize * 2;
return availableMemory > requiredMemory;
}
// Default: assemble in memory for smaller files
return fileSize < ReceptionConfig.FILE_CONFIG.LARGE_FILE_THRESHOLD;
}
}
@@ -0,0 +1,713 @@
import WebRTC_Recipient from "../webrtc_Recipient";
import { CustomFile, fileMetadata } from "@/types/webrtc";
import { ReceptionStateManager } from "./ReceptionStateManager";
import { MessageProcessor, MessageProcessorDelegate } from "./MessageProcessor";
import { ChunkProcessor } from "./ChunkProcessor";
import {
StreamingFileWriter
} from "./StreamingFileWriter";
import { FileAssembler } from "./FileAssembler";
import { ProgressReporter, ProgressCallback } from "./ProgressReporter";
import { ReceptionConfig } from "./ReceptionConfig";
import { ChunkRangeCalculator } from "@/lib/utils/ChunkRangeCalculator";
import { postLogToBackend } from "@/app/config/api";
/**
* 🚀 File receive orchestrator
* Main coordinator that integrates all reception modules
*/
export class FileReceiveOrchestrator implements MessageProcessorDelegate {
private stateManager: ReceptionStateManager;
private messageProcessor: MessageProcessor;
private chunkProcessor: ChunkProcessor;
private streamingFileWriter: StreamingFileWriter;
private fileAssembler: FileAssembler;
private progressReporter: ProgressReporter;
// Callbacks
public onFileMetaReceived: ((meta: fileMetadata) => void) | undefined =
undefined;
public onStringReceived: ((str: string) => void) | undefined = undefined;
public onFileReceived: ((file: CustomFile) => Promise<void>) | undefined =
undefined;
constructor(private webrtcConnection: WebRTC_Recipient) {
// Initialize all components
this.stateManager = new ReceptionStateManager();
this.chunkProcessor = new ChunkProcessor();
this.streamingFileWriter = new StreamingFileWriter();
this.fileAssembler = new FileAssembler();
this.progressReporter = new ProgressReporter(this.stateManager);
this.messageProcessor = new MessageProcessor(
this.stateManager,
webrtcConnection,
{
onFileMetaReceived: (meta: fileMetadata) => {
if (this.onFileMetaReceived) {
this.onFileMetaReceived(meta);
}
},
onStringReceived: (str: string) => {
if (this.onStringReceived) {
this.onStringReceived(str);
}
},
log: this.log.bind(this),
}
);
// Set up data handler
this.setupDataHandler();
this.log("log", "FileReceiveOrchestrator initialized");
}
// ===== Public API =====
/**
* Set progress callback
*/
public setProgressCallback(callback: ProgressCallback): void {
this.progressReporter.setProgressCallback(callback);
}
/**
* Set save directory
*/
public setSaveDirectory(directory: FileSystemDirectoryHandle): Promise<void> {
this.stateManager.setSaveDirectory(directory);
this.streamingFileWriter.setSaveDirectory(directory);
return Promise.resolve();
}
/**
* Request a single file from the peer
*/
public async requestFile(fileId: string, singleFile = true): Promise<void> {
const activeReception = this.stateManager.getActiveFileReception();
if (activeReception) {
this.log("warn", "Another file reception is already in progress.");
return;
}
if (singleFile) {
this.stateManager.setCurrentFolderName(null);
}
const fileInfo = this.stateManager.getFileMetadata(fileId);
if (!fileInfo) {
this.fireError("File info not found for the requested fileId", {
fileId,
});
return;
}
const shouldSaveToDisk = ReceptionConfig.shouldSaveToDisk(
fileInfo.size,
this.streamingFileWriter.hasSaveDirectory()
);
// Set save type at the beginning to prevent race conditions
this.stateManager.setSaveType(fileInfo.fileId, shouldSaveToDisk);
const currentFolderName = this.stateManager.getCurrentFolderName();
if (currentFolderName) {
this.stateManager.setSaveType(currentFolderName, shouldSaveToDisk);
}
let offset = 0;
if (shouldSaveToDisk && this.streamingFileWriter.hasSaveDirectory()) {
try {
offset = await this.streamingFileWriter.getPartialFileSize(
fileInfo.name,
fileInfo.fullName
);
if (offset === fileInfo.size) {
this.log("log", "File already fully downloaded.", { fileId });
this.progressReporter.reportFileComplete(fileId);
return;
}
this.log("log", `Resuming file from offset: ${offset}`, { fileId });
} catch (e) {
this.log("log", "Partial file not found, starting from scratch.", {
fileId,
});
offset = 0;
}
}
const expectedChunksCount = ReceptionConfig.calculateExpectedChunks(
fileInfo.size,
offset
);
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) {
// 🎯 Critical log 2: Summary information for receiver - using unified chunk range calculation logic
const chunkRange = ChunkRangeCalculator.getChunkRange(
fileInfo.size,
offset,
ReceptionConfig.FILE_CONFIG.CHUNK_SIZE
);
postLogToBackend(
`[RECV-SUMMARY] File: ${fileInfo.name}, expected: ${expectedChunksCount}, calculated: ${chunkRange.totalChunks}, startChunk: ${chunkRange.startChunk}, endChunk: ${chunkRange.endChunk}, absoluteTotal: ${chunkRange.absoluteTotalChunks}`
);
}
const receptionPromise = this.stateManager.startFileReception(
fileInfo,
expectedChunksCount,
offset
);
if (shouldSaveToDisk) {
await this.createDiskWriteStream(fileInfo, offset);
}
// Send file request
const success = this.messageProcessor.sendFileRequest(fileId, offset);
if (!success) {
this.stateManager.failFileReception(
new Error("Failed to send file request")
);
return;
}
return receptionPromise;
}
/**
* Request all files belonging to a folder from the peer
*/
public async requestFolder(folderName: string): Promise<void> {
const folderProgress = this.stateManager.getFolderProgress(folderName);
if (!folderProgress || folderProgress.fileIds.length === 0) {
this.log("warn", "No files found for the requested folder.", {
folderName,
});
return;
}
// Pre-calculate total size of already downloaded parts
let initialFolderReceivedSize = 0;
if (this.streamingFileWriter.hasSaveDirectory()) {
for (const fileId of folderProgress.fileIds) {
const fileInfo = this.stateManager.getFileMetadata(fileId);
if (fileInfo) {
try {
const partialSize =
await this.streamingFileWriter.getPartialFileSize(
fileInfo.name,
fileInfo.fullName
);
initialFolderReceivedSize += partialSize;
} catch (e) {
// File doesn't exist, so its size is 0
}
}
}
}
this.stateManager.setFolderReceivedSize(
folderName,
initialFolderReceivedSize
);
this.log(
"log",
`Requesting folder, initial received size: ${initialFolderReceivedSize}`,
{ folderName }
);
this.stateManager.setCurrentFolderName(folderName);
for (const fileId of folderProgress.fileIds) {
try {
await this.requestFile(fileId, false);
} catch (error) {
this.fireError(
`Failed to receive file ${fileId} in folder ${folderName}`,
{ error }
);
break;
}
}
this.stateManager.setCurrentFolderName(null);
// Send folder completion message
const completedFileIds = folderProgress.fileIds.filter(() => true); // Assume all succeeded
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) {
postLogToBackend(
`[DEBUG] 📁 All files in folder completed - ${folderName}, files: ${completedFileIds.length}/${folderProgress.fileIds.length}`
);
}
this.messageProcessor.sendFolderReceiveComplete(
folderName,
completedFileIds,
true
);
}
// ===== MessageProcessorDelegate Implementation =====
// Note: These are implemented as properties, not methods, to avoid infinite recursion
public log(
level: "log" | "warn" | "error",
message: string,
context?: Record<string, any>
): void {
const prefix = `[FileReceiveOrchestrator]`;
console[level](prefix, message, context || "");
}
// ===== Internal Methods =====
/**
* Set up data handler
*/
private setupDataHandler(): void {
this.webrtcConnection.onDataReceived = async (data, peerId) => {
const binaryData = await this.messageProcessor.handleReceivedMessage(
data,
peerId
);
if (binaryData) {
// Handle binary chunk data
await this.handleBinaryChunkData(binaryData);
}
};
}
/**
* Handle binary chunk data
*/
private async handleBinaryChunkData(data: any): Promise<void> {
const activeReception = this.stateManager.getActiveFileReception();
if (!activeReception) {
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) {
postLogToBackend(
`[DEBUG] ERROR: Received file chunk but no active file reception!`
);
}
this.fireError("Received a file chunk without an active file reception.");
return;
}
// Convert to ArrayBuffer
const arrayBuffer = await this.chunkProcessor.convertToArrayBuffer(data);
if (!arrayBuffer) {
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) {
postLogToBackend(
`[DEBUG] ERROR: Failed to convert binary data to ArrayBuffer`
);
}
this.fireError("Received unsupported binary data format", {
dataType: Object.prototype.toString.call(data),
});
return;
}
await this.handleEmbeddedChunkPacket(arrayBuffer);
}
/**
* Handle embedded chunk packet
*/
private async handleEmbeddedChunkPacket(
arrayBuffer: ArrayBuffer
): Promise<void> {
const parsed = this.chunkProcessor.parseEmbeddedChunkPacket(arrayBuffer);
if (!parsed) {
this.fireError("Failed to parse embedded chunk packet");
return;
}
const { chunkMeta, chunkData } = parsed;
const reception = this.stateManager.getActiveFileReception();
if (!reception) {
console.log(
`[FileReceiveOrchestrator] Ignoring chunk ${chunkMeta.chunkIndex} - file reception already closed`
);
return;
}
// Validate chunk
const validation = this.chunkProcessor.validateChunk(
chunkMeta,
reception.meta.fileId,
reception.expectedChunksCount,
reception.initialOffset
);
if (!validation.isValid) {
this.log("warn", "Chunk validation failed", {
errors: validation.errors,
chunkIndex: chunkMeta.chunkIndex,
});
return;
}
// Process chunk indices
const result = this.chunkProcessor.processReceivedChunk(
chunkMeta,
chunkData,
reception.initialOffset
);
if (!result) {
this.fireError("Failed to process received chunk");
return;
}
// Check if chunk index is valid
if (
!this.chunkProcessor.isChunkIndexValid(
result.relativeChunkIndex,
reception.expectedChunksCount
)
) {
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) {
postLogToBackend(
`[DEBUG-CHUNKS] ❌ Invalid relative chunk index - absolute:${result.absoluteChunkIndex}, relative:${result.relativeChunkIndex}, arraySize:${reception.chunks.length}`
);
}
return;
}
// Store chunk
reception.chunks[result.relativeChunkIndex] = result.chunkData;
reception.chunkSequenceMap.set(result.absoluteChunkIndex, true);
reception.receivedChunksCount++;
// Update progress
this.progressReporter.updateFileProgress(
result.chunkData.byteLength,
reception.meta.fileId,
reception.meta.size
);
// Handle disk writing if needed
if (reception.sequencedWriter) {
// 🔧 Fix: SequencedWriter uses absolute index, ensuring correct index is passed
this.chunkProcessor.logChunkDetails(
result,
reception.expectedChunksCount,
reception.sequencedWriter.expectedIndex
);
// ✅ Correctly use absolute index for disk writing
await reception.sequencedWriter.writeChunk(
result.absoluteChunkIndex,
result.chunkData
);
}
await this.checkAndAutoFinalize();
}
/**
* Check and auto-finalize file reception
*/
private async checkAndAutoFinalize(): Promise<void> {
const reception = this.stateManager.getActiveFileReception();
if (!reception || reception.isFinalized) return;
const expectedSize = reception.meta.size - reception.initialOffset;
const stats = this.chunkProcessor.calculateCompletionStats(
reception.chunks,
reception.expectedChunksCount,
expectedSize
);
// Log completion check details
this.chunkProcessor.logCompletionCheck(
reception.meta.name,
{
sequencedCount: stats.sequencedCount,
expectedChunksCount: reception.expectedChunksCount,
currentTotalSize: stats.currentTotalSize,
expectedSize,
isDataComplete: stats.isDataComplete,
},
reception.chunks,
reception.initialOffset
);
if (stats.isDataComplete) {
reception.isFinalized = true;
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) {
postLogToBackend(
`[DEBUG-COMPLETE] ✅ Starting finalization - isDataComplete:${stats.isDataComplete}`
);
}
try {
await this.finalizeFileReceive();
this.stateManager.completeFileReception();
} catch (error) {
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) {
postLogToBackend(`[DEBUG] ❌ Auto-finalize ERROR: ${error}`);
}
this.stateManager.failFileReception(error);
}
}
}
/**
* Finalize file reception
*/
private async finalizeFileReceive(): Promise<void> {
const reception = this.stateManager.getActiveFileReception();
if (!reception) return;
if (reception.writeStream) {
await this.finalizeLargeFileReceive();
} else {
await this.finalizeMemoryFileReceive();
}
}
/**
* Finalize large file reception (disk-based)
*/
private async finalizeLargeFileReceive(): Promise<void> {
const reception = this.stateManager.getActiveFileReception();
if (!reception?.writeStream || !reception.fileHandle) {
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) {
postLogToBackend(
`[DEBUG-FINALIZE] ❌ Cannot finalize - missing writeStream:${!!reception?.writeStream} or fileHandle:${!!reception?.fileHandle}`
);
}
return;
}
try {
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) {
postLogToBackend(
`[DEBUG-FINALIZE] 🚀 Starting finalization for ${reception.meta.name}`
);
}
// Finalize using StreamingFileWriter
if (reception.sequencedWriter && reception.writeStream) {
await this.streamingFileWriter.finalizeWrite(
reception.sequencedWriter,
reception.writeStream,
reception.meta.name
);
}
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) {
postLogToBackend(
`[DEBUG-FINALIZE] ✅ LARGE_FILE finalized successfully - ${reception.meta.name}`
);
}
// 🆕 Send completion confirmation for large files
const stats = this.chunkProcessor.calculateCompletionStats(
reception.chunks,
reception.expectedChunksCount,
reception.meta.size - reception.initialOffset
);
this.messageProcessor.sendFileReceiveComplete(
reception.meta.fileId,
stats.currentTotalSize,
stats.sequencedCount,
true
);
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) {
postLogToBackend(
`[DEBUG-FINALIZE] 📤 LARGE_FILE completion confirmation sent - ${reception.meta.fileId}, size: ${stats.currentTotalSize}, chunks: ${stats.sequencedCount}`
);
}
} catch (error) {
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) {
postLogToBackend(
`[DEBUG-FINALIZE] ❌ Error during finalization: ${error}`
);
}
this.fireError("Error finalizing large file", { error });
}
}
/**
* Finalize memory file reception
*/
private async finalizeMemoryFileReceive(): Promise<void> {
const reception = this.stateManager.getActiveFileReception();
if (!reception) return;
const currentFolderName = this.stateManager.getCurrentFolderName();
const result = await this.fileAssembler.assembleFileFromChunks(
reception.chunks,
reception.meta,
currentFolderName,
this.onFileReceived
);
// Send completion confirmation
this.messageProcessor.sendFileReceiveComplete(
reception.meta.fileId,
result.totalChunkSize,
result.validChunks,
result.storeUpdated
);
}
/**
* Create disk write stream
*/
private async createDiskWriteStream(
meta: fileMetadata,
offset: number
): Promise<void> {
try {
const { fileHandle, writeStream, sequencedWriter } =
await this.streamingFileWriter.createWriteStream(
meta.name,
meta.fullName,
offset
);
this.stateManager.updateActiveFileReception({
fileHandle,
writeStream,
sequencedWriter,
});
} catch (err) {
this.fireError("Failed to create file on disk", {
err,
fileName: meta.name,
});
}
}
/**
* Error handling
*/
private fireError(message: string, context?: Record<string, any>) {
if (this.webrtcConnection.fireError) {
// @ts-ignore
this.webrtcConnection.fireError(message, {
...context,
component: "FileReceiveOrchestrator",
});
} else {
this.log("error", message, context);
}
const reception = this.stateManager.getActiveFileReception();
if (reception) {
// Clean up resources on error
if (reception.sequencedWriter) {
reception.sequencedWriter.close().catch((err: any) => {
this.log(
"error",
"Error closing sequenced writer during error cleanup",
{ err }
);
});
}
this.stateManager.failFileReception(new Error(message));
}
}
// ===== Lifecycle Management =====
/**
* Graceful shutdown
*/
public gracefulShutdown(reason: string = "CONNECTION_LOST"): void {
this.log("log", `Graceful shutdown initiated: ${reason}`);
const reception = this.stateManager.getActiveFileReception();
if (reception?.sequencedWriter && reception?.writeStream) {
this.log("log", "Attempting to gracefully close streams on shutdown.");
// Close sequenced writer and write stream
reception.sequencedWriter.close().catch((err: any) => {
this.log(
"error",
"Error closing sequenced writer during graceful shutdown",
{ err }
);
});
reception.writeStream.close().catch((err: any) => {
this.log("error", "Error closing stream during graceful shutdown", {
err,
});
});
}
this.stateManager.gracefulCleanup();
this.log("log", "Graceful shutdown completed");
}
/**
* Force reset all internal states
*/
public forceReset(): void {
this.log("log", "Force resetting FileReceiveOrchestrator state");
const reception = this.stateManager.getActiveFileReception();
if (reception?.sequencedWriter && reception?.writeStream) {
reception.sequencedWriter.close().catch(console.error);
reception.writeStream.close().catch(console.error);
}
this.stateManager.forceReset();
this.progressReporter.resetAllProgress();
this.log("log", "FileReceiveOrchestrator state force reset completed");
}
/**
* Get transfer statistics
*/
public getTransferStats() {
return {
stateManager: this.stateManager.getStateStats(),
progressReporter: this.progressReporter.getProgressStats(),
messageProcessor: this.messageProcessor.getMessageStats(),
};
}
/**
* Get save type information (for backward compatibility)
*/
public getSaveType(): Record<string, boolean> {
return this.stateManager.saveType;
}
/**
* Get pending files metadata (for backward compatibility)
*/
public getPendingFilesMeta(): Map<string, fileMetadata> {
return this.stateManager.getAllFileMetadata();
}
/**
* Get folder progresses (for backward compatibility)
*/
public getFolderProgresses(): Record<string, any> {
return this.stateManager.getAllFolderProgresses();
}
/**
* Clean up all resources
*/
public cleanup(): void {
this.stateManager.gracefulCleanup();
this.progressReporter.cleanup();
this.messageProcessor.cleanup();
this.log("log", "FileReceiveOrchestrator cleaned up");
}
}
+302
View File
@@ -0,0 +1,302 @@
import {
WebRTCMessage,
fileMetadata,
StringMetadata,
StringChunk,
FileRequest,
FileReceiveComplete,
FolderReceiveComplete,
FileHandlers,
} from "@/types/webrtc";
import { ReceptionStateManager } from "./ReceptionStateManager";
import { ReceptionConfig } from "./ReceptionConfig";
import { postLogToBackend } from "@/app/config/api";
import WebRTC_Recipient from "../webrtc_Recipient";
const developmentEnv = process.env.NODE_ENV;
/**
* 🚀 Message processor delegate interface
*/
export interface MessageProcessorDelegate {
onFileMetaReceived?: (meta: fileMetadata) => void;
onStringReceived?: (str: string) => void;
log(level: "log" | "warn" | "error", message: string, context?: Record<string, any>): void;
}
/**
* 🚀 Message processor
* Handles WebRTC message routing, processing, and communication
*/
export class MessageProcessor {
private fileHandlers: FileHandlers;
constructor(
private stateManager: ReceptionStateManager,
private webrtcConnection: WebRTC_Recipient,
private delegate: MessageProcessorDelegate
) {
this.fileHandlers = {
string: this.handleReceivedStringChunk.bind(this),
stringMetadata: this.handleStringMetadata.bind(this),
fileMeta: this.handleFileMetadata.bind(this),
};
}
/**
* Handle received WebRTC message
*/
async handleReceivedMessage(
data: string | ArrayBuffer | any,
peerId: string
): Promise<ArrayBuffer | null> {
this.stateManager.setCurrentPeerId(peerId);
if (typeof data === "string") {
try {
const parsedData = JSON.parse(data) as WebRTCMessage;
const handler = this.fileHandlers[parsedData.type as keyof FileHandlers];
if (handler) {
await handler(parsedData as any, peerId);
} else {
this.delegate.log(
"warn",
`Handler not found for message type: ${parsedData.type}`,
{ peerId }
);
}
return null; // String messages don't return binary data
} catch (error) {
this.delegate.log("error", "Error parsing received JSON data", { error, peerId });
return null;
}
} else {
// Return binary data for chunk processing
return data;
}
}
/**
* Handle file metadata message
*/
private handleFileMetadata(metadata: fileMetadata): void {
const isNewMetadata = this.stateManager.addFileMetadata(metadata);
if (!isNewMetadata) {
return; // Ignore if already received
}
if (this.delegate.onFileMetaReceived) {
this.delegate.onFileMetaReceived(metadata);
} else {
this.delegate.log(
"error",
"onFileMetaReceived callback not set",
{ fileId: metadata.fileId }
);
}
}
/**
* Handle string metadata message
*/
private handleStringMetadata(metadata: StringMetadata): void {
this.stateManager.startStringReception(metadata.length);
}
/**
* Handle received string chunk message
*/
private handleReceivedStringChunk(data: StringChunk): void {
const activeStringReception = this.stateManager.getActiveStringReception();
if (!activeStringReception) {
this.delegate.log("warn", "Received string chunk without active reception");
return;
}
this.stateManager.updateStringReceptionChunk(data.index, data.chunk);
// Check if string reception is complete
if (activeStringReception.receivedChunks === data.total) {
const fullString = this.stateManager.completeStringReception();
if (fullString && this.delegate.onStringReceived) {
this.delegate.onStringReceived(fullString);
}
}
}
/**
* Send file request message
*/
sendFileRequest(fileId: string, offset: number = 0): boolean {
const peerId = this.stateManager.getCurrentPeerId();
if (!peerId) {
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) {
postLogToBackend(
`[DEBUG] ERROR: Cannot send fileRequest - no peerId available!`
);
}
return false;
}
const request: FileRequest = { type: "fileRequest", fileId, offset };
const success = this.webrtcConnection.sendData(JSON.stringify(request), peerId);
if (success) {
this.delegate.log("log", "Sent fileRequest", { request, peerId });
} else {
this.delegate.log("error", "Failed to send fileRequest", { request, peerId });
}
return success;
}
/**
* Send file receive complete message
*/
sendFileReceiveComplete(
fileId: string,
receivedSize: number,
receivedChunks: number,
storeUpdated: boolean
): boolean {
const peerId = this.stateManager.getCurrentPeerId();
if (!peerId) {
this.delegate.log("warn", "Cannot send file receive complete - no peer ID");
return false;
}
const completeMessage: FileReceiveComplete = {
type: "fileReceiveComplete",
fileId,
receivedSize,
receivedChunks,
storeUpdated,
};
const success = this.webrtcConnection.sendData(
JSON.stringify(completeMessage),
peerId
);
if (success) {
this.delegate.log("log", "Sent file receive complete", {
fileId,
receivedSize,
receivedChunks,
storeUpdated,
});
} else {
this.delegate.log("error", "Failed to send file receive complete", {
fileId,
peerId,
});
}
return success;
}
/**
* Send folder receive complete message
*/
sendFolderReceiveComplete(
folderName: string,
completedFileIds: string[],
allStoreUpdated: boolean
): boolean {
const peerId = this.stateManager.getCurrentPeerId();
if (!peerId) {
this.delegate.log("warn", "Cannot send folder receive complete - no peer ID");
return false;
}
const completeMessage: FolderReceiveComplete = {
type: "folderReceiveComplete",
folderName,
completedFileIds,
allStoreUpdated,
};
const success = this.webrtcConnection.sendData(
JSON.stringify(completeMessage),
peerId
);
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) {
postLogToBackend(
`[DEBUG] 📤 Sent folderReceiveComplete - folderName: ${folderName}, completedFiles: ${completedFileIds.length}, allStoreUpdated: ${allStoreUpdated}, success: ${success}`
);
}
if (success) {
this.delegate.log("log", "Sent folder receive complete", {
folderName,
completedFiles: completedFileIds.length,
allStoreUpdated,
});
} else {
this.delegate.log("error", "Failed to send folder receive complete", {
folderName,
peerId,
});
}
return success;
}
/**
* Add Firefox compatibility delay
*/
async addFirefoxDelay(): Promise<void> {
await new Promise((resolve) =>
setTimeout(resolve, ReceptionConfig.NETWORK_CONFIG.FIREFOX_COMPATIBILITY_DELAY)
);
}
/**
* Get message processing statistics
*/
getMessageStats(): {
handledMessages: number;
lastMessageTime: number | null;
currentPeerId: string;
} {
return {
handledMessages: 0, // TODO: Implement message counting if needed
lastMessageTime: null, // TODO: Record last message time if needed
currentPeerId: this.stateManager.getCurrentPeerId(),
};
}
/**
* Check if connection is available
*/
isConnectionAvailable(): boolean {
const peerId = this.stateManager.getCurrentPeerId();
return !!peerId && !!this.webrtcConnection;
}
/**
* Get current peer connection info
*/
getPeerConnectionInfo(): {
peerId: string;
isConnected: boolean;
} {
const peerId = this.stateManager.getCurrentPeerId();
return {
peerId,
isConnected: this.isConnectionAvailable(),
};
}
/**
* Clean up resources
*/
cleanup(): void {
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) {
postLogToBackend("[DEBUG] 🧹 MessageProcessor cleaned up");
}
}
}
+309
View File
@@ -0,0 +1,309 @@
import { SpeedCalculator } from "@/lib/speedCalculator";
import { ReceptionStateManager } from "./ReceptionStateManager";
import { ReceptionConfig } from "./ReceptionConfig";
import { postLogToBackend } from "@/app/config/api";
const developmentEnv = process.env.NODE_ENV;
/**
* 🚀 Progress callback type
*/
export type ProgressCallback = (fileId: string, progress: number, speed: number) => void;
/**
* 🚀 Progress statistics interface
*/
export interface ProgressStats {
fileProgress: Record<string, number>;
folderProgress: Record<string, number>;
currentSpeed: number;
averageSpeed: number;
totalBytesReceived: number;
estimatedTimeRemaining: number | null;
}
/**
* 🚀 Progress reporter
* Handles progress calculation, speed tracking, and progress callback management
*/
export class ProgressReporter {
private speedCalculator: SpeedCalculator;
private progressCallback: ProgressCallback | null = null;
// Progress tracking
private fileProgressMap = new Map<string, number>();
private folderProgressMap = new Map<string, number>();
private lastProgressUpdate = new Map<string, number>();
constructor(private stateManager: ReceptionStateManager) {
this.speedCalculator = new SpeedCalculator();
}
/**
* Set progress callback
*/
setProgressCallback(callback: ProgressCallback): void {
this.progressCallback = callback;
}
/**
* Update file reception progress
*/
updateFileProgress(
byteLength: number,
fileId: string,
fileSize: number
): void {
const peerId = this.stateManager.getCurrentPeerId();
if (!peerId) return;
const activeReception = this.stateManager.getActiveFileReception();
if (!activeReception) return;
// Update received size
activeReception.receivedSize += byteLength;
const totalReceived = activeReception.initialOffset + activeReception.receivedSize;
const currentFolderName = this.stateManager.getCurrentFolderName();
if (currentFolderName) {
// Update folder progress
this.updateFolderProgress(currentFolderName, byteLength, peerId);
} else {
// Update individual file progress
this.speedCalculator.updateSendSpeed(peerId, totalReceived);
const speed = this.speedCalculator.getSendSpeed(peerId);
const progress = fileSize > 0 ? totalReceived / fileSize : 0;
// Store progress for statistics
this.fileProgressMap.set(fileId, progress);
// Throttle progress callbacks to avoid overwhelming the UI
const now = Date.now();
const lastUpdate = this.lastProgressUpdate.get(fileId) || 0;
const shouldUpdate = now - lastUpdate > 100; // Update at most every 100ms
if (shouldUpdate || progress >= 1) {
this.progressCallback?.(fileId, progress, speed);
this.lastProgressUpdate.set(fileId, now);
}
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_PROGRESS_LOGGING && progress >= 1) {
postLogToBackend(
`[DEBUG] 📈 File progress 100% - ${fileId}, speed: ${(speed / 1024 / 1024).toFixed(1)}MB/s`
);
}
}
}
/**
* Update folder reception progress
*/
private updateFolderProgress(
folderName: string,
byteLength: number,
peerId: string
): void {
// Update folder received size in state manager
this.stateManager.updateFolderReceivedSize(folderName, byteLength);
const folderProgress = this.stateManager.getFolderProgress(folderName);
if (!folderProgress) return;
this.speedCalculator.updateSendSpeed(peerId, folderProgress.receivedSize);
const speed = this.speedCalculator.getSendSpeed(peerId);
const progress = folderProgress.totalSize > 0
? folderProgress.receivedSize / folderProgress.totalSize
: 0;
// Store progress for statistics
this.folderProgressMap.set(folderName, progress);
// Throttle folder progress callbacks
const now = Date.now();
const lastUpdate = this.lastProgressUpdate.get(folderName) || 0;
const shouldUpdate = now - lastUpdate > 200; // Update folders less frequently
if (shouldUpdate || progress >= 1) {
this.progressCallback?.(folderName, progress, speed);
this.lastProgressUpdate.set(folderName, now);
}
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_PROGRESS_LOGGING && progress >= 1) {
postLogToBackend(
`[DEBUG] 📈 Folder progress 100% - ${folderName}, speed: ${(speed / 1024 / 1024).toFixed(1)}MB/s`
);
}
}
/**
* Report file completion (100% progress)
*/
reportFileComplete(fileId: string): void {
if (!this.progressCallback) return;
const peerId = this.stateManager.getCurrentPeerId();
if (!peerId) return;
// Get final speed and report 100% progress
const speed = this.speedCalculator.getSendSpeed(peerId);
this.progressCallback(fileId, 1, speed);
this.fileProgressMap.set(fileId, 1);
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_PROGRESS_LOGGING) {
postLogToBackend(
`[DEBUG] ✅ File completion reported - ${fileId}, final speed: ${(speed / 1024 / 1024).toFixed(1)}MB/s`
);
}
}
/**
* Report folder completion (100% progress)
*/
reportFolderComplete(folderName: string): void {
if (!this.progressCallback) return;
const peerId = this.stateManager.getCurrentPeerId();
if (!peerId) return;
// Get final speed and report 100% progress
const speed = this.speedCalculator.getSendSpeed(peerId);
this.progressCallback(folderName, 1, speed);
this.folderProgressMap.set(folderName, 1);
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_PROGRESS_LOGGING) {
postLogToBackend(
`[DEBUG] ✅ Folder completion reported - ${folderName}, final speed: ${(speed / 1024 / 1024).toFixed(1)}MB/s`
);
}
}
/**
* Get current progress for a file or folder
*/
getCurrentProgress(id: string): number {
return this.fileProgressMap.get(id) || this.folderProgressMap.get(id) || 0;
}
/**
* Get current speed for peer
*/
getCurrentSpeed(): number {
const peerId = this.stateManager.getCurrentPeerId();
return peerId ? this.speedCalculator.getSendSpeed(peerId) : 0;
}
/**
* Get detailed progress statistics
*/
getProgressStats(): ProgressStats {
const peerId = this.stateManager.getCurrentPeerId();
const currentSpeed = peerId ? this.speedCalculator.getSendSpeed(peerId) : 0;
const averageSpeed = currentSpeed; // SpeedCalculator doesn't have getAverageSpeed method
// Calculate total bytes received
let totalBytesReceived = 0;
const activeReception = this.stateManager.getActiveFileReception();
if (activeReception) {
totalBytesReceived = activeReception.initialOffset + activeReception.receivedSize;
}
// Estimate time remaining
let estimatedTimeRemaining: number | null = null;
if (activeReception && currentSpeed > 0) {
const remainingBytes = activeReception.meta.size - totalBytesReceived;
if (remainingBytes > 0) {
estimatedTimeRemaining = remainingBytes / currentSpeed; // seconds
}
}
const fileProgress: Record<string, number> = {};
this.fileProgressMap.forEach((progress, fileId) => {
fileProgress[fileId] = progress;
});
const folderProgress: Record<string, number> = {};
this.folderProgressMap.forEach((progress, folderName) => {
folderProgress[folderName] = progress;
});
return {
fileProgress,
folderProgress,
currentSpeed,
averageSpeed,
totalBytesReceived,
estimatedTimeRemaining,
};
}
/**
* Reset progress for a specific file or folder
*/
resetProgress(id: string): void {
this.fileProgressMap.delete(id);
this.folderProgressMap.delete(id);
this.lastProgressUpdate.delete(id);
}
/**
* Reset all progress data
*/
resetAllProgress(): void {
this.fileProgressMap.clear();
this.folderProgressMap.clear();
this.lastProgressUpdate.clear();
// Reset speed calculator for current peer
// Note: SpeedCalculator doesn't have resetSpeed method, so we create a new instance
this.speedCalculator = new SpeedCalculator();
}
/**
* Get progress update frequency (for debugging)
*/
getUpdateFrequency(id: string): number {
const lastUpdate = this.lastProgressUpdate.get(id);
return lastUpdate ? Date.now() - lastUpdate : 0;
}
/**
* Check if progress should be throttled
*/
shouldThrottleProgress(id: string, isFolder: boolean = false): boolean {
const now = Date.now();
const lastUpdate = this.lastProgressUpdate.get(id) || 0;
const threshold = isFolder ? 200 : 100; // Folders update less frequently
return now - lastUpdate < threshold;
}
/**
* Force progress update (bypass throttling)
*/
forceProgressUpdate(id: string, progress: number): void {
if (!this.progressCallback) return;
const speed = this.getCurrentSpeed();
this.progressCallback(id, progress, speed);
this.lastProgressUpdate.set(id, Date.now());
// Update internal maps
if (this.fileProgressMap.has(id)) {
this.fileProgressMap.set(id, progress);
} else if (this.folderProgressMap.has(id)) {
this.folderProgressMap.set(id, progress);
}
}
/**
* Clean up resources
*/
cleanup(): void {
this.resetAllProgress();
this.progressCallback = null;
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_PROGRESS_LOGGING) {
postLogToBackend("[DEBUG] 🧹 ProgressReporter cleaned up");
}
}
}
+82
View File
@@ -0,0 +1,82 @@
/**
* 🚀 Reception configuration management
* Centralized configuration for file reception parameters
*/
export class ReceptionConfig {
// File size thresholds
static readonly FILE_CONFIG = {
LARGE_FILE_THRESHOLD: 1 * 1024 * 1024 * 1024, // 1GB - files larger than this will be saved to disk
CHUNK_SIZE: 65536, // 64KB standard chunk size
};
// Buffer management
static readonly BUFFER_CONFIG = {
MAX_BUFFER_SIZE: 100, // Buffer up to 100 chunks (approximately 6.4MB)
SEQUENTIAL_FLUSH_THRESHOLD: 10, // Start flushing when this many sequential chunks are available
};
// Performance and debugging
static readonly DEBUG_CONFIG = {
ENABLE_CHUNK_LOGGING: process.env.NODE_ENV === "development",
ENABLE_PROGRESS_LOGGING: process.env.NODE_ENV === "development",
PROGRESS_LOG_INTERVAL: 500, // Log progress every N chunks
COMPLETION_CHECK_INTERVAL: 100, // Check completion every N ms
};
// Network and timing
static readonly NETWORK_CONFIG = {
FIREFOX_COMPATIBILITY_DELAY: 10, // ms delay for Firefox compatibility
FINALIZATION_TIMEOUT: 30020, // 30s timeout for file finalization
GRACEFUL_SHUTDOWN_TIMEOUT: 5000, // 5s timeout for graceful shutdown
};
// Validation thresholds
static readonly VALIDATION_CONFIG = {
MAX_SIZE_DIFFERENCE_BYTES: 1024, // Allow up to 1KB size difference for validation
MIN_PACKET_SIZE: 4, // Minimum embedded packet size (4 bytes for length header)
};
/**
* Get chunk index from file offset
*/
static getChunkIndexFromOffset(offset: number): number {
return Math.floor(offset / this.FILE_CONFIG.CHUNK_SIZE);
}
/**
* Get file offset from chunk index
*/
static getOffsetFromChunkIndex(chunkIndex: number): number {
return chunkIndex * this.FILE_CONFIG.CHUNK_SIZE;
}
/**
* Calculate expected chunks count for file size and offset
*/
static calculateExpectedChunks(
fileSize: number,
startOffset: number = 0
): number {
return Math.ceil((fileSize - startOffset) / this.FILE_CONFIG.CHUNK_SIZE);
}
/**
* Calculate total chunks in file
*/
static calculateTotalChunks(fileSize: number): number {
return Math.ceil(fileSize / this.FILE_CONFIG.CHUNK_SIZE);
}
/**
* Check if file should be saved to disk
*/
static shouldSaveToDisk(
fileSize: number,
hasSaveDirectory: boolean
): boolean {
return (
hasSaveDirectory || fileSize >= this.FILE_CONFIG.LARGE_FILE_THRESHOLD
);
}
}
@@ -0,0 +1,358 @@
import {
fileMetadata,
FolderProgress,
CurrentString,
CustomFile,
} from "@/types/webrtc";
/**
* 🚀 Active file reception state interface
*/
export interface ActiveFileReception {
meta: fileMetadata;
chunks: (ArrayBuffer | null)[];
receivedSize: number;
initialOffset: number;
fileHandle: FileSystemFileHandle | null;
writeStream: FileSystemWritableFileStream | null;
sequencedWriter: any | null; // Will be typed properly when StreamingFileWriter is implemented
completionNotifier: {
resolve: () => void;
reject: (reason?: any) => void;
};
receivedChunksCount: number;
expectedChunksCount: number;
chunkSequenceMap: Map<number, boolean>;
isFinalized?: boolean;
}
/**
* 🚀 Reception state management
* Centrally manages all file reception state data
*/
export class ReceptionStateManager {
// File metadata management
private pendingFilesMeta = new Map<string, fileMetadata>();
// Folder progress tracking
private folderProgresses: Record<string, FolderProgress> = {};
// Save type configuration (fileId/folderName -> isSavedToDisk)
public saveType: Record<string, boolean> = {};
// Active transfer states
private activeFileReception: ActiveFileReception | null = null;
private activeStringReception: CurrentString | null = null;
private currentFolderName: string | null = null;
// Peer information
private currentPeerId: string = "";
private saveDirectory: FileSystemDirectoryHandle | null = null;
// ===== File Metadata Management =====
/**
* Add file metadata
*/
public addFileMetadata(metadata: fileMetadata): boolean {
if (this.pendingFilesMeta.has(metadata.fileId)) {
return false; // Already exists
}
this.pendingFilesMeta.set(metadata.fileId, metadata);
// Update folder progress if this file belongs to a folder
if (metadata.folderName) {
this.addFileToFolder(metadata.folderName, metadata.fileId, metadata.size);
}
return true; // New metadata added
}
/**
* Get file metadata by ID
*/
public getFileMetadata(fileId: string): fileMetadata | undefined {
return this.pendingFilesMeta.get(fileId);
}
/**
* Get all pending file metadata
*/
public getAllFileMetadata(): Map<string, fileMetadata> {
return new Map(this.pendingFilesMeta);
}
/**
* Remove file metadata
*/
public removeFileMetadata(fileId: string): void {
this.pendingFilesMeta.delete(fileId);
}
// ===== Folder Progress Management =====
/**
* Add file to folder progress tracking
*/
private addFileToFolder(folderName: string, fileId: string, fileSize: number): void {
if (!this.folderProgresses[folderName]) {
this.folderProgresses[folderName] = {
totalSize: 0,
receivedSize: 0,
fileIds: [],
};
}
const folderProgress = this.folderProgresses[folderName];
if (!folderProgress.fileIds.includes(fileId)) {
folderProgress.fileIds.push(fileId);
folderProgress.totalSize += fileSize;
}
}
/**
* Get folder progress
*/
public getFolderProgress(folderName: string): FolderProgress | undefined {
return this.folderProgresses[folderName];
}
/**
* Update folder received size
*/
public updateFolderReceivedSize(folderName: string, additionalBytes: number): void {
const folderProgress = this.folderProgresses[folderName];
if (folderProgress) {
folderProgress.receivedSize += additionalBytes;
}
}
/**
* Set folder received size (for resume scenarios)
*/
public setFolderReceivedSize(folderName: string, totalReceivedSize: number): void {
const folderProgress = this.folderProgresses[folderName];
if (folderProgress) {
folderProgress.receivedSize = totalReceivedSize;
}
}
/**
* Get all folder progresses
*/
public getAllFolderProgresses(): Record<string, FolderProgress> {
return { ...this.folderProgresses };
}
// ===== Active File Reception Management =====
/**
* Start active file reception
*/
public startFileReception(
meta: fileMetadata,
expectedChunksCount: number,
initialOffset: number = 0
): Promise<void> {
if (this.activeFileReception) {
throw new Error("Another file reception is already in progress");
}
return new Promise<void>((resolve, reject) => {
this.activeFileReception = {
meta,
chunks: new Array(expectedChunksCount).fill(null),
receivedSize: 0,
initialOffset,
fileHandle: null,
writeStream: null,
sequencedWriter: null,
completionNotifier: { resolve, reject },
receivedChunksCount: 0,
expectedChunksCount,
chunkSequenceMap: new Map<number, boolean>(),
isFinalized: false,
};
});
}
/**
* Get active file reception
*/
public getActiveFileReception(): ActiveFileReception | null {
return this.activeFileReception;
}
/**
* Update active file reception
*/
public updateActiveFileReception(updates: Partial<ActiveFileReception>): void {
if (this.activeFileReception) {
Object.assign(this.activeFileReception, updates);
}
}
/**
* Complete active file reception
*/
public completeFileReception(): void {
if (this.activeFileReception?.completionNotifier) {
this.activeFileReception.completionNotifier.resolve();
}
this.activeFileReception = null;
}
/**
* Fail active file reception
*/
public failFileReception(reason: any): void {
if (this.activeFileReception?.completionNotifier) {
this.activeFileReception.completionNotifier.reject(reason);
}
this.activeFileReception = null;
}
// ===== String Reception Management =====
/**
* Start string reception
*/
public startStringReception(length: number): void {
this.activeStringReception = {
length,
chunks: [],
receivedChunks: 0,
};
}
/**
* Get active string reception
*/
public getActiveStringReception(): CurrentString | null {
return this.activeStringReception;
}
/**
* Update string reception chunk
*/
public updateStringReceptionChunk(index: number, chunk: string): void {
if (this.activeStringReception) {
this.activeStringReception.chunks[index] = chunk;
this.activeStringReception.receivedChunks++;
}
}
/**
* Complete string reception
*/
public completeStringReception(): string | null {
if (!this.activeStringReception) return null;
const fullString = this.activeStringReception.chunks.join("");
this.activeStringReception = null;
return fullString;
}
// ===== Current Context Management =====
/**
* Set current folder name
*/
public setCurrentFolderName(folderName: string | null): void {
this.currentFolderName = folderName;
}
/**
* Get current folder name
*/
public getCurrentFolderName(): string | null {
return this.currentFolderName;
}
/**
* Set current peer ID
*/
public setCurrentPeerId(peerId: string): void {
this.currentPeerId = peerId;
}
/**
* Get current peer ID
*/
public getCurrentPeerId(): string {
return this.currentPeerId;
}
/**
* Set save directory
*/
public setSaveDirectory(directory: FileSystemDirectoryHandle | null): void {
this.saveDirectory = directory;
}
/**
* Get save directory
*/
public getSaveDirectory(): FileSystemDirectoryHandle | null {
return this.saveDirectory;
}
// ===== Save Type Management =====
/**
* Set save type for file or folder
*/
public setSaveType(id: string, saveToDisk: boolean): void {
this.saveType[id] = saveToDisk;
}
/**
* Get save type for file or folder
*/
public getSaveType(id: string): boolean {
return this.saveType[id] || false;
}
// ===== State Reset and Cleanup =====
/**
* Force reset all states (for reconnection scenarios)
*/
public forceReset(): void {
this.pendingFilesMeta.clear();
this.folderProgresses = {};
this.saveType = {};
this.activeFileReception = null;
this.activeStringReception = null;
this.currentFolderName = null;
this.currentPeerId = "";
// Note: saveDirectory is preserved
}
/**
* Graceful cleanup (preserve some state for potential resume)
*/
public gracefulCleanup(): void {
this.activeFileReception = null;
this.activeStringReception = null;
this.currentFolderName = null;
// Note: preserve pendingFilesMeta, folderProgresses, saveType for potential resume
}
/**
* Get state statistics (for debugging)
*/
public getStateStats() {
return {
pendingFilesCount: this.pendingFilesMeta.size,
folderCount: Object.keys(this.folderProgresses).length,
hasActiveFileReception: !!this.activeFileReception,
hasActiveStringReception: !!this.activeStringReception,
currentFolderName: this.currentFolderName,
currentPeerId: this.currentPeerId,
hasSaveDirectory: !!this.saveDirectory,
saveTypeCount: Object.keys(this.saveType).length,
};
}
}
+430
View File
@@ -0,0 +1,430 @@
import { ReceptionConfig } from "./ReceptionConfig";
import { postLogToBackend } from "@/app/config/api";
const developmentEnv = process.env.NODE_ENV;
/**
* 🚀 Strict Sequential Buffering Writer - Optimizes large file disk I/O performance
*/
export class SequencedDiskWriter {
private writeQueue = new Map<number, ArrayBuffer>();
private nextWriteIndex = 0;
private readonly maxBufferSize: number;
private readonly stream: FileSystemWritableFileStream;
private totalWritten = 0;
constructor(stream: FileSystemWritableFileStream, startIndex: number = 0) {
this.stream = stream;
this.nextWriteIndex = startIndex;
this.maxBufferSize = ReceptionConfig.BUFFER_CONFIG.MAX_BUFFER_SIZE;
}
/**
* Write a chunk, automatically managing order and buffering
*/
async writeChunk(chunkIndex: number, chunk: ArrayBuffer): Promise<void> {
// Debug writeChunk calls
if (
ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING &&
(chunkIndex <= 5 || chunkIndex === this.nextWriteIndex)
) {
postLogToBackend(
`[DEBUG-RESUME] 🎯 WriteChunk called - received:${chunkIndex}, expected:${
this.nextWriteIndex
}, match:${chunkIndex === this.nextWriteIndex}`
);
}
// 1. If it is the expected next chunk, write immediately
if (chunkIndex === this.nextWriteIndex) {
await this.flushSequentialChunks(chunk);
return;
}
// 2. If it's a future chunk, buffer it
if (chunkIndex > this.nextWriteIndex) {
if (this.writeQueue.size < this.maxBufferSize) {
this.writeQueue.set(chunkIndex, chunk);
} else {
// Buffer full, forcing processing of the earliest chunk to free up space
await this.forceFlushOldest();
this.writeQueue.set(chunkIndex, chunk);
}
return;
}
// 3. If the chunk is expired, log a warning but ignore (already written)
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) {
postLogToBackend(
`[DEBUG] ⚠️ DUPLICATE chunk #${chunkIndex} ignored (already written #${this.nextWriteIndex})`
);
}
}
/**
* Write current chunk and attempt to sequentially write subsequent chunks
*/
private async flushSequentialChunks(firstChunk: ArrayBuffer): Promise<void> {
let flushCount = 0;
try {
// Write current chunk
await this.stream.write(firstChunk);
this.totalWritten += firstChunk.byteLength;
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) {
postLogToBackend(
`[DEBUG] ✓ DISK_WRITE chunk #${this.nextWriteIndex} - ${firstChunk.byteLength} bytes, total: ${this.totalWritten}`
);
}
this.nextWriteIndex++;
// Try to sequentially write chunks from buffer
while (this.writeQueue.has(this.nextWriteIndex)) {
const chunk = this.writeQueue.get(this.nextWriteIndex)!;
await this.stream.write(chunk);
this.totalWritten += chunk.byteLength;
this.writeQueue.delete(this.nextWriteIndex);
flushCount++;
this.nextWriteIndex++;
}
} catch (error) {
// Defensive handling: If stream is closed, silently ignore
const errorMessage =
error instanceof Error ? error.message : String(error);
if (
errorMessage.includes("closing writable stream") ||
errorMessage.includes("stream is closed")
) {
console.log(
`[SequencedDiskWriter] Stream closed during write - ignoring remaining chunks`
);
return;
}
// Re-throw other types of errors
throw error;
}
if (flushCount > 0 && ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) {
postLogToBackend(
`[DEBUG] 🔥 SEQUENTIAL_FLUSH ${flushCount} chunks, now at #${this.nextWriteIndex}, queue: ${this.writeQueue.size}`
);
}
}
/**
* Get the next expected write index
*/
get expectedIndex(): number {
return this.nextWriteIndex;
}
/**
* Force flush the earliest chunk to release buffer space
*/
private async forceFlushOldest(): Promise<void> {
try {
if (this.writeQueue.size === 0) return;
const oldestIndex = Math.min(...Array.from(this.writeQueue.keys()));
const chunk = this.writeQueue.get(oldestIndex)!;
// Use seek to write at the correct position (fallback handling)
const fileOffset = ReceptionConfig.getOffsetFromChunkIndex(oldestIndex);
await this.stream.seek(fileOffset);
await this.stream.write(chunk);
this.writeQueue.delete(oldestIndex);
// Return to current position
const currentOffset = ReceptionConfig.getOffsetFromChunkIndex(this.nextWriteIndex);
await this.stream.seek(currentOffset);
} catch (error) {
// Defensive handling: If stream is closed, silently ignore
const errorMessage =
error instanceof Error ? error.message : String(error);
if (
errorMessage.includes("closing writable stream") ||
errorMessage.includes("stream is closed")
) {
console.log(
`[SequencedDiskWriter] Stream closed during write - ignoring remaining chunks`
);
return;
}
// Re-throw other types of errors
throw error;
}
}
/**
* Get buffer status
*/
getBufferStatus(): {
queueSize: number;
nextIndex: number;
totalWritten: number;
} {
return {
queueSize: this.writeQueue.size,
nextIndex: this.nextWriteIndex,
totalWritten: this.totalWritten,
};
}
/**
* Close and clean up resources
*/
async close(): Promise<void> {
try {
// 🔧 修复:确保以正确的WriteParams格式写入剩余chunks
const remainingIndexes = Array.from(this.writeQueue.keys()).sort(
(a, b) => a - b
);
if (remainingIndexes.length > 0) {
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) {
postLogToBackend(
`[DEBUG-FINALIZE] 💾 Flushing ${remainingIndexes.length} remaining chunks: [${remainingIndexes.join(',')}]`
);
}
for (const chunkIndex of remainingIndexes) {
const chunk = this.writeQueue.get(chunkIndex)!;
const fileOffset = ReceptionConfig.getOffsetFromChunkIndex(chunkIndex);
// 🔧 修复:使用正确的WriteParams格式
await this.stream.seek(fileOffset);
// 确保chunk是有效的ArrayBuffer
if (!(chunk instanceof ArrayBuffer) || chunk.byteLength === 0) {
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) {
postLogToBackend(
`[DEBUG-FINALIZE] ⚠️ Skipping invalid chunk #${chunkIndex}: ${Object.prototype.toString.call(chunk)}, size: ${chunk.byteLength}`
);
}
continue;
}
// 使用标准WriteParams格式写入
await this.stream.write({
type: "write",
data: chunk
});
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) {
postLogToBackend(
`[DEBUG-FINALIZE] ✅ FINAL_FLUSH chunk #${chunkIndex} (${chunk.byteLength} bytes)`
);
}
}
}
} catch (error) {
// Enhanced error handling with specific error types
const errorMessage = error instanceof Error ? error.message : String(error);
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) {
postLogToBackend(
`[DEBUG-FINALIZE] ❌ Error during final flush: ${errorMessage}`
);
}
if (
errorMessage.includes("closing writable stream") ||
errorMessage.includes("stream is closed") ||
errorMessage.includes("The stream is not in a state that permits this operation")
) {
console.log(
`[SequencedDiskWriter] Stream closed during final flush - completing gracefully`
);
} else {
console.warn(`[SequencedDiskWriter] Unexpected error during final flush:`, errorMessage);
throw error;
}
} finally {
// 无论如何都要清理队列
this.writeQueue.clear();
}
}
}
/**
* 🚀 Streaming file writer
* Manages disk file creation, directory structure, and streaming writes
*/
export class StreamingFileWriter {
private saveDirectory: FileSystemDirectoryHandle | null = null;
constructor(saveDirectory?: FileSystemDirectoryHandle) {
this.saveDirectory = saveDirectory || null;
}
/**
* Set save directory
*/
setSaveDirectory(directory: FileSystemDirectoryHandle): void {
this.saveDirectory = directory;
}
/**
* Create disk write stream for a file
*/
async createWriteStream(
fileName: string,
fullPath: string,
offset: number = 0
): Promise<{
fileHandle: FileSystemFileHandle;
writeStream: FileSystemWritableFileStream;
sequencedWriter: SequencedDiskWriter;
}> {
if (!this.saveDirectory) {
throw new Error("Save directory not set");
}
try {
const folderHandle = await this.createFolderStructure(fullPath);
const fileHandle = await folderHandle.getFileHandle(fileName, {
create: true,
});
// Use keepExistingData: true to append
const writeStream = await fileHandle.createWritable({
keepExistingData: true,
});
// Seek to the offset to start writing from there
await writeStream.seek(offset);
// Create strictly sequential write manager
const startChunkIndex = ReceptionConfig.getChunkIndexFromOffset(offset);
const sequencedWriter = new SequencedDiskWriter(writeStream, startChunkIndex);
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) {
postLogToBackend(
`[DEBUG] 📢 SEQUENCED_WRITER created - startIndex: ${startChunkIndex}, offset: ${offset}`
);
postLogToBackend(
`[DEBUG-RESUME] 🎯 SequencedWriter expects - startIndex:${startChunkIndex}, offset:${offset}, calculatedFrom:${offset}/65536`
);
}
return { fileHandle, writeStream, sequencedWriter };
} catch (err) {
throw new Error(`Failed to create file on disk: ${err}`);
}
}
/**
* Check if partial file exists and get its size
*/
async getPartialFileSize(fileName: string, fullPath: string): Promise<number> {
if (!this.saveDirectory) {
return 0;
}
try {
const folderHandle = await this.createFolderStructure(fullPath);
const fileHandle = await folderHandle.getFileHandle(fileName, {
create: false,
});
const file = await fileHandle.getFile();
return file.size;
} catch {
// File does not exist
return 0;
}
}
/**
* Create folder structure based on full path
*/
private async createFolderStructure(
fullPath: string
): Promise<FileSystemDirectoryHandle> {
if (!this.saveDirectory) {
throw new Error("Save directory not set");
}
const parts = fullPath.split("/");
parts.pop(); // Remove filename
let currentDir = this.saveDirectory;
for (const part of parts) {
if (part) {
currentDir = await currentDir.getDirectoryHandle(part, {
create: true,
});
}
}
return currentDir;
}
/**
* Finalize file write and close streams
*/
async finalizeWrite(
sequencedWriter: SequencedDiskWriter,
writeStream: FileSystemWritableFileStream,
fileName: string
): Promise<void> {
try {
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) {
postLogToBackend(
`[DEBUG-FINALIZE] 🚀 Starting finalization for ${fileName}`
);
}
// First close the strict sequential writing manager (flush all buffers)
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) {
postLogToBackend(`[DEBUG-FINALIZE] Closing SequencedWriter...`);
}
await sequencedWriter.close();
const status = sequencedWriter.getBufferStatus();
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) {
postLogToBackend(
`[DEBUG-FINALIZE] 💾 SEQUENCED_WRITER closed - totalWritten: ${status.totalWritten}, finalQueue: ${status.queueSize}`
);
}
// Then close the file stream
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) {
postLogToBackend(
`[DEBUG-FINALIZE] About to close writeStream for ${fileName}`
);
}
await writeStream.close();
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) {
postLogToBackend(`[DEBUG-FINALIZE] ✅ WriteStream closed successfully`);
}
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) {
postLogToBackend(
`[DEBUG-FINALIZE] ✅ LARGE_FILE finalized successfully - ${fileName}`
);
}
} catch (error) {
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) {
postLogToBackend(
`[DEBUG-FINALIZE] ❌ Error during finalization: ${error}`
);
}
throw new Error(`Error finalizing large file: ${error}`);
}
}
/**
* Check if save directory is available
*/
hasSaveDirectory(): boolean {
return !!this.saveDirectory;
}
/**
* Get save directory
*/
getSaveDirectory(): FileSystemDirectoryHandle | null {
return this.saveDirectory;
}
}
+45
View File
@@ -0,0 +1,45 @@
/**
* 🚀 File receive module unified export
* Provides modular file reception services
*/
// Configuration management
export { ReceptionConfig } from "./ReceptionConfig";
// State management
export { ReceptionStateManager } from "./ReceptionStateManager";
export type { ActiveFileReception } from "./ReceptionStateManager";
// Data processing
export { ChunkProcessor } from "./ChunkProcessor";
export type { ChunkProcessingResult } from "./ChunkProcessor";
// File writing
export { StreamingFileWriter, SequencedDiskWriter } from "./StreamingFileWriter";
// File assembly
export { FileAssembler } from "./FileAssembler";
export type { FileAssemblyResult } from "./FileAssembler";
// Message processing
export { MessageProcessor } from "./MessageProcessor";
export type { MessageProcessorDelegate } from "./MessageProcessor";
// Progress reporting
export { ProgressReporter } from "./ProgressReporter";
export type { ProgressCallback, ProgressStats } from "./ProgressReporter";
// Main orchestrator
export { FileReceiveOrchestrator } from "./FileReceiveOrchestrator";
/**
* 🎯 Convenience creation function - Quick initialization of file receive services
*/
import WebRTC_Recipient from "../webrtc_Recipient";
import { FileReceiveOrchestrator } from "./FileReceiveOrchestrator";
export function createFileReceiveService(
webrtcConnection: WebRTC_Recipient
): FileReceiveOrchestrator {
return new FileReceiveOrchestrator(webrtcConnection);
}
+1 -1
View File
@@ -4,7 +4,7 @@ export const trackReferrer = async () => {
// Get URL parameters
const urlParams = new URLSearchParams(window.location.search);
let ref = urlParams.get("ref");
if (process.env.NEXT_PUBLIC_development === "false") {
if (process.env.NODE_ENV === "production") {
ref = urlParams.get("ref") || "noRef"; // Production environment, count daily active users, record as noRef if there is no ref
}
@@ -0,0 +1,463 @@
import { generateFileId } from "@/lib/fileUtils";
import {
CustomFile,
fileMetadata,
WebRTCMessage,
FileRequest,
EmbeddedChunkMeta,
} from "@/types/webrtc";
import { StateManager } from "./StateManager";
import { MessageHandler, MessageHandlerDelegate } from "./MessageHandler";
import { NetworkTransmitter } from "./NetworkTransmitter";
import { ProgressTracker, ProgressCallback } from "./ProgressTracker";
import { StreamingFileReader } from "./StreamingFileReader";
import { TransferConfig } from "./TransferConfig";
import WebRTC_Initiator from "../webrtc_Initiator";
import { postLogToBackend } from "@/app/config/api";
const developmentEnv = process.env.NODE_ENV;
/**
* 🚀 File transfer orchestrator
* Integrates all components to provide unified file transfer services
*/
export class FileTransferOrchestrator implements MessageHandlerDelegate {
private stateManager: StateManager;
private messageHandler: MessageHandler;
private networkTransmitter: NetworkTransmitter;
private progressTracker: ProgressTracker;
constructor(private webrtcConnection: WebRTC_Initiator) {
// Initialize all components
this.stateManager = new StateManager();
this.networkTransmitter = new NetworkTransmitter(
webrtcConnection,
this.stateManager
);
this.progressTracker = new ProgressTracker(this.stateManager);
this.messageHandler = new MessageHandler(this.stateManager, this);
// Set up data handler
this.setupDataHandler();
this.log("log", "FileTransferOrchestrator initialized");
}
/**
* 🎯 Send file metadata
*/
public sendFileMeta(files: CustomFile[], peerId?: string): void {
// Record file sizes belonging to folders for progress calculation
files.forEach((file) => {
if (file.folderName) {
const fileId = generateFileId(file);
this.stateManager.addFileToFolder(file.folderName, fileId, file.size);
}
});
// Loop to send metadata for all files
const peers = peerId
? [peerId]
: Array.from(this.webrtcConnection.peerConnections.keys());
peers.forEach((pId) => {
files.forEach((file) => {
const fileId = generateFileId(file);
this.stateManager.addPendingFile(fileId, file);
const fileMeta = this.getFileMeta(file);
const metaDataString = JSON.stringify(fileMeta);
const sendResult = this.webrtcConnection.sendData(metaDataString, pId);
if (!sendResult) {
this.fireError("Failed to send file metadata", {
fileMeta,
peerId: pId,
});
}
});
});
}
/**
* 🎯 Send string content
*/
public async sendString(content: string, peerId: string): Promise<void> {
const chunkSize = 65000;
const chunks: string[] = [];
for (let i = 0; i < content.length; i += chunkSize) {
chunks.push(content.slice(i, i + chunkSize));
}
// First send metadata
await this.networkTransmitter.sendWithBackpressure(
JSON.stringify({
type: "stringMetadata",
length: content.length,
}),
peerId
);
// Send chunks one by one using backpressure control
for (let i = 0; i < chunks.length; i++) {
const data = JSON.stringify({
type: "string",
chunk: chunks[i],
index: i,
total: chunks.length,
});
await this.networkTransmitter.sendWithBackpressure(data, peerId);
}
this.log(
"log",
`String sent successfully - length: ${content.length}, chunks: ${chunks.length}`,
{ peerId }
);
}
/**
* 🎯 Set progress callback
*/
public setProgressCallback(callback: ProgressCallback, peerId: string): void {
this.progressTracker.setProgressCallback(callback, peerId);
}
// ===== MessageHandlerDelegate Implementation =====
/**
* 📄 Handle file request (delegated from MessageHandler)
*/
async handleFileRequest(request: FileRequest, peerId: string): Promise<void> {
const file = this.stateManager.getPendingFile(request.fileId);
const offset = request.offset || 0;
if (!file) {
this.fireError(`File not found for request`, {
fileId: request.fileId,
peerId,
});
return;
}
await this.sendSingleFile(file, peerId, offset);
}
/**
* 📝 Logging (delegated from MessageHandler)
*/
public log(
level: "log" | "warn" | "error",
message: string,
context?: Record<string, any>
): void {
const prefix = `[FileTransferOrchestrator]`;
console[level](prefix, message, context || "");
}
// ===== Internal Orchestration Methods =====
/**
* 🎯 Send single file
*/
private async sendSingleFile(
file: CustomFile,
peerId: string,
offset: number = 0
): Promise<void> {
const fileId = generateFileId(file);
const peerState = this.stateManager.getPeerState(peerId);
if (peerState.isSending) {
this.log("warn", `Already sending file to peer ${peerId}`, { fileId });
return;
}
// Initialize sending state
this.stateManager.updatePeerState(peerId, {
isSending: true,
currentFolderName: file.folderName,
readOffset: offset,
bufferQueue: [],
isReading: false,
});
// Initialize progress statistics
const currentSent = this.stateManager.getFileBytesSent(peerId, fileId);
this.stateManager.updateFileBytesSent(peerId, fileId, offset - currentSent);
try {
await this.processSendQueue(file, peerId);
await this.waitForTransferComplete(peerId);
} catch (error: any) {
this.fireError(`Error sending file ${file.name}: ${error.message}`, {
fileId,
peerId,
});
this.abortFileSend(fileId, peerId);
}
}
/**
* 🚀 Process send queue - Using StreamingFileReader
*/
private async processSendQueue(
file: CustomFile,
peerId: string
): Promise<void> {
const fileId = generateFileId(file);
const peerState = this.stateManager.getPeerState(peerId);
const transferStartTime = performance.now();
// 🔧 Fix: Record initial offset at the start of transmission, used for subsequent statistics calculation
const initialReadOffset = peerState.readOffset || 0;
// 1. Initialize streaming file reader
const streamReader = new StreamingFileReader(file, initialReadOffset);
if (developmentEnv === "development") {
postLogToBackend(
`[DEBUG] 🚀 Starting transfer - file: ${file.name}, size: ${(
file.size /
1024 /
1024
).toFixed(1)}MB`
);
}
try {
let totalBytesSent = 0;
let networkChunkIndex = 0;
let totalReadTime = 0;
let totalSendTime = 0;
let totalProgressTime = 0;
let lastProgressTime = performance.now();
// 2. Stream processing: Get 64KB network chunks one by one and send
while (peerState.isSending) {
// Get next network chunk
const readStartTime = performance.now();
const chunkInfo = await streamReader.getNextNetworkChunk();
const readTime = performance.now() - readStartTime;
totalReadTime += readTime;
// Check if completed
if (chunkInfo.chunk === null) {
break;
}
// Build embedded metadata
const embeddedMeta: EmbeddedChunkMeta = {
chunkIndex: chunkInfo.chunkIndex,
totalChunks: chunkInfo.totalChunks,
chunkSize: chunkInfo.chunk.byteLength,
isLastChunk: chunkInfo.isLastChunk,
fileOffset: chunkInfo.fileOffset,
fileId,
};
// Send network chunk with embedded metadata
let sendSuccessful = false;
const sendStartTime = performance.now();
try {
sendSuccessful = await this.networkTransmitter.sendEmbeddedChunk(
chunkInfo.chunk,
embeddedMeta,
peerId
);
if (sendSuccessful) {
totalBytesSent += chunkInfo.chunk.byteLength;
}
} catch (error) {
this.log(
"warn",
`Chunk send failed #${chunkInfo.chunkIndex}: ${error}`
);
sendSuccessful = false;
}
const sendTime = performance.now() - sendStartTime;
totalSendTime += sendTime;
// Update state and progress
if (sendSuccessful) {
this.stateManager.updatePeerState(peerId, {
readOffset: chunkInfo.fileOffset + chunkInfo.chunk.byteLength,
});
const progressStartTime = performance.now();
await this.progressTracker.updateFileProgress(
chunkInfo.chunk.byteLength,
fileId,
file.size,
peerId,
true
);
const progressTime = performance.now() - progressStartTime;
totalProgressTime += progressTime;
}
networkChunkIndex++;
// Check if it's the last chunk
if (chunkInfo.isLastChunk) {
break;
}
}
if (developmentEnv === "development") {
const totalTime = performance.now() - transferStartTime;
const avgSpeedMBps = totalBytesSent / 1024 / 1024 / (totalTime / 1000);
// 🔧 Fix: Use correct initial offset instead of current readOffset for log statistics
const initialOffset = initialReadOffset || 0; // Initial offset at the start of transmission
const expectedTotalChunks = Math.ceil(file.size / 65536);
const startChunkIndex = Math.floor(initialOffset / 65536);
const expectedChunksSent = expectedTotalChunks - startChunkIndex;
postLogToBackend(
`[DEBUG-CHUNKS] ✅ Transfer complete - file: ${file.name}, time: ${(
totalTime / 1000
).toFixed(1)}s, speed: ${avgSpeedMBps.toFixed(1)}MB/s`
);
postLogToBackend(
`[DEBUG-CHUNKS] Chunks sent: ${networkChunkIndex}, expected: ${expectedChunksSent}, startChunk: ${startChunkIndex}, totalFileChunks: ${expectedTotalChunks}, initialOffset: ${initialOffset}`
);
if (networkChunkIndex !== expectedChunksSent) {
postLogToBackend(
`[DEBUG-CHUNKS] ⚠️ CHUNK MISMATCH: sent ${networkChunkIndex} but expected ${expectedChunksSent}`
);
}
}
} catch (error: any) {
const errorMessage = `Streaming send error: ${error.message}`;
if (developmentEnv === "development") {
postLogToBackend(`[DEBUG] ❌ Transfer error: ${errorMessage}`);
}
this.fireError(errorMessage, {
fileId,
peerId,
offset: peerState.readOffset,
});
throw error;
} finally {
// Clean up resources
streamReader.cleanup();
}
}
/**
* ⏳ Wait for transfer completion confirmation
*/
private async waitForTransferComplete(peerId: string): Promise<void> {
while (true) {
const currentPeerState = this.stateManager.getPeerState(peerId);
// Check if it has been cleaned up or does not exist
if (!currentPeerState || !currentPeerState.isSending) {
this.log("log", `Transfer completed or peer disconnected: ${peerId}`);
break;
}
// Check if the WebRTC connection is still valid
if (!this.webrtcConnection.peerConnections.has(peerId)) {
this.log("log", `Peer connection lost: ${peerId}, stopping transfer`);
this.stateManager.updatePeerState(peerId, { isSending: false });
break;
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
/**
* 📋 Get file metadata
*/
private getFileMeta(file: CustomFile): fileMetadata {
const fileId = generateFileId(file);
return {
type: "fileMeta",
fileId,
name: file.name,
size: file.size,
fileType: file.type,
fullName: file.fullName,
folderName: file.folderName,
};
}
/**
* ❌ Abort file sending
*/
private abortFileSend(fileId: string, peerId: string): void {
this.log("warn", `Aborting file send for ${fileId} to ${peerId}`);
this.stateManager.resetPeerState(peerId);
}
/**
* 🔧 Set up data handler
*/
private setupDataHandler(): void {
this.webrtcConnection.onDataReceived = (data, peerId) => {
if (typeof data === "string") {
try {
const parsedData = JSON.parse(data) as WebRTCMessage;
this.messageHandler.handleSignalingMessage(parsedData, peerId);
} catch (error) {
this.fireError("Error parsing received JSON data", { error, peerId });
}
}
};
}
/**
* 🔥 Error handling
*/
private fireError(message: string, context?: Record<string, any>) {
this.webrtcConnection.fireError(message, {
...context,
component: "FileTransferOrchestrator",
});
}
// ===== State Query and Debugging =====
/**
* 📊 Get transfer statistics
*/
public getTransferStats(peerId?: string) {
const stats = {
stateManager: this.stateManager.getStateStats(),
progressTracker: peerId
? this.progressTracker.getProgressStats(peerId)
: null,
networkTransmitter: peerId
? this.networkTransmitter.getTransmissionStats(peerId)
: null,
};
return stats;
}
/**
* 🔄 Handle peer reconnection
*/
public handlePeerReconnection(peerId: string): void {
// Clear all transfer states for this peer
this.stateManager.clearPeerState(peerId);
if (developmentEnv === "development")
this.log(
"log",
`Successfully reset transfer state for reconnected peer ${peerId}`
);
}
/**
* 🧹 Clean up all resources
*/
public cleanup(): void {
this.stateManager.cleanup();
this.networkTransmitter.cleanup();
this.progressTracker.cleanup();
this.messageHandler.cleanup();
if (developmentEnv === "development")
this.log("log", "FileTransferOrchestrator cleaned up");
}
}
+178
View File
@@ -0,0 +1,178 @@
import {
WebRTCMessage,
FileRequest,
FileReceiveComplete,
FolderReceiveComplete
} from "@/types/webrtc";
import { StateManager } from "./StateManager";
import { postLogToBackend } from "@/app/config/api";
const developmentEnv = process.env.NODE_ENV;
/**
* 🚀 Message handling interface - Communicate with main orchestrator
*/
export interface MessageHandlerDelegate {
handleFileRequest(request: FileRequest, peerId: string): Promise<void>;
log(
level: "log" | "warn" | "error",
message: string,
context?: Record<string, any>
): void;
}
/**
* 🚀 Message handler
* Responsible for WebRTC message routing and processing logic
*/
export class MessageHandler {
constructor(
private stateManager: StateManager,
private delegate: MessageHandlerDelegate
) {}
/**
* 🎯 Handle received signaling message
*/
handleSignalingMessage(message: WebRTCMessage, peerId: string): void {
// Delete frequent message reception logs
switch (message.type) {
case "fileRequest":
this.handleFileRequest(message as FileRequest, peerId);
break;
case "fileReceiveComplete":
this.handleFileReceiveComplete(message as FileReceiveComplete, peerId);
break;
case "folderReceiveComplete":
this.handleFolderReceiveComplete(
message as FolderReceiveComplete,
peerId
);
break;
default:
this.delegate.log("warn", `Unknown signaling message type received`, {
type: message.type,
peerId,
});
}
}
/**
* 📄 Handle file request message
*/
private async handleFileRequest(
request: FileRequest,
peerId: string
): Promise<void> {
const offset = request.offset || 0;
this.delegate.log(
"log",
`Handling file request for ${request.fileId} from ${peerId} with offset ${offset}`
);
// Firefox compatibility fix: Add slightly longer delay to ensure receiver is fully ready
await new Promise((resolve) => setTimeout(resolve, 10));
// Delegate to main orchestrator for specific file transfer
try {
await this.delegate.handleFileRequest(request, peerId);
} catch (error) {
this.delegate.log("error", `Error handling file request`, {
fileId: request.fileId,
peerId,
error: error instanceof Error ? error.message : String(error),
});
}
}
/**
* ✅ Handle file receive completion confirmation message
*/
private handleFileReceiveComplete(
message: FileReceiveComplete,
peerId: string
): void {
// Clean up sending state
this.stateManager.updatePeerState(peerId, { isSending: false });
// Get peer state to trigger progress callback
const peerState = this.stateManager.getPeerState(peerId);
// Trigger single file 100% progress (only for non-folder cases)
if (!peerState.currentFolderName) {
// Delete frequent progress logs
peerState.progressCallback?.(message.fileId, 1, 0);
} else {
// Delete frequent folder progress logs
}
this.delegate.log("log", `File reception confirmed by peer ${peerId}`, {
fileId: message.fileId,
receivedSize: message.receivedSize,
storeUpdated: message.storeUpdated,
});
}
/**
* 📁 Handle folder receive completion confirmation message
*/
private handleFolderReceiveComplete(
message: FolderReceiveComplete,
peerId: string
): void {
if (developmentEnv === "development") {
postLogToBackend(
`[DEBUG] 📥 Folder complete - folderName: ${message.folderName}, files: ${message.completedFileIds.length}`
);
}
// Get peer state to trigger progress callback
const peerState = this.stateManager.getPeerState(peerId);
// Trigger folder 100% progress
const folderMeta = this.stateManager.getFolderMeta(message.folderName);
if (folderMeta) {
postLogToBackend(
`[DEBUG] 🎯 Setting folder progress to 100% - ${message.folderName}`
);
peerState.progressCallback?.(message.folderName, 1, 0);
} else {
this.delegate.log(
"warn",
`Folder metadata not found for completed folder`,
{
folderName: message.folderName,
peerId,
}
);
}
this.delegate.log("log", `Folder reception confirmed by peer ${peerId}`, {
folderName: message.folderName,
completedFiles: message.completedFileIds.length,
allStoreUpdated: message.allStoreUpdated,
});
}
/**
* 📊 Get message handling statistics
*/
public getMessageStats(): {
handledMessages: number;
lastMessageTime: number | null;
} {
// Message statistics logic can be added here if needed
return {
handledMessages: 0, // TODO: Implement message counting
lastMessageTime: null, // TODO: Record last message time
};
}
/**
* 🧹 Clean up resources
*/
public cleanup(): void {
if (developmentEnv === "development")
postLogToBackend("[DEBUG] 🧹 MessageHandler cleaned up");
}
}
+246
View File
@@ -0,0 +1,246 @@
import { EmbeddedChunkMeta } from "@/types/webrtc";
import { StateManager } from "./StateManager";
import WebRTC_Initiator from "../webrtc_Initiator";
import { postLogToBackend } from "@/app/config/api";
const developmentEnv = process.env.NODE_ENV;
/**
* 🚀 Network transmitter - Simplified version
* Uses WebRTC native bufferedAmountLowThreshold for backpressure control
*/
export class NetworkTransmitter {
constructor(
private webrtcConnection: WebRTC_Initiator,
private stateManager: StateManager
) {}
/**
* 🎯 Send embedded chunk packet with sequence number
*/
async sendEmbeddedChunk(
chunkData: ArrayBuffer,
metadata: EmbeddedChunkMeta,
peerId: string
): Promise<boolean> {
try {
// 1. Build fused data packet
const embeddedPacket = this.createEmbeddedChunkPacket(
chunkData,
metadata
);
// 2. Send complete fused data packet (no fragmentation)
await this.sendSingleData(embeddedPacket, peerId);
// Key node logs (development environment only)
// if (
// developmentEnv === "development" &&
// (metadata.chunkIndex % 100 === 0 || metadata.isLastChunk)
// ) {
// postLogToBackend(
// `[DEBUG] ✓ CHUNK #${metadata.chunkIndex}/${
// metadata.totalChunks
// } sent, size: ${(chunkData.byteLength / 1024).toFixed(
// 1
// )}KB, isLast: ${metadata.isLastChunk}`
// );
// }
return true;
} catch (error) {
if (developmentEnv === "development") {
postLogToBackend(
`[DEBUG] ❌ CHUNK #${metadata.chunkIndex} send failed: ${error}`
);
}
return false;
}
}
/**
* 🚀 Build data packet with embedded metadata
*/
private createEmbeddedChunkPacket(
chunkData: ArrayBuffer,
chunkMeta: EmbeddedChunkMeta
): ArrayBuffer {
// 1. Serialize metadata to JSON
const metaJson = JSON.stringify(chunkMeta);
const metaBytes = new TextEncoder().encode(metaJson);
// 2. Metadata length (4 bytes)
const metaLengthBuffer = new ArrayBuffer(4);
const metaLengthView = new Uint32Array(metaLengthBuffer);
metaLengthView[0] = metaBytes.length;
// 3. Build final fused packet
const totalLength = 4 + metaBytes.length + chunkData.byteLength;
const finalPacket = new Uint8Array(totalLength);
// Concatenate: [4-byte length] + [metadata] + [original chunk data]
finalPacket.set(new Uint8Array(metaLengthBuffer), 0);
finalPacket.set(metaBytes, 4);
finalPacket.set(new Uint8Array(chunkData), 4 + metaBytes.length);
return finalPacket.buffer;
}
/**
* 🚀 Send single data packet (no fragmentation)
*/
private async sendSingleData(
data: string | ArrayBuffer,
peerId: string
): Promise<void> {
const dataChannel = this.webrtcConnection.dataChannels.get(peerId);
if (!dataChannel) {
throw new Error("Data channel not found");
}
// Simplified backpressure control
await this.simpleBufferControl(dataChannel, peerId);
// Send directly, no fragmentation
const sendResult = this.webrtcConnection.sendData(data, peerId);
if (!sendResult) {
const errorMessage = `sendData failed`;
if (developmentEnv === "development") {
postLogToBackend(`[DEBUG] ❌ ${errorMessage}`);
}
throw new Error(errorMessage);
}
}
/**
* 🎯 Native backpressure control - Using WebRTC standard mechanism
*/
private async simpleBufferControl(
dataChannel: RTCDataChannel,
peerId: string
): Promise<void> {
const maxBuffer = 3 * 1024 * 1024; // 3MB maximum buffer
const lowThreshold = 512 * 1024; // 512KB low threshold
// Set native low threshold
if (dataChannel.bufferedAmountLowThreshold !== lowThreshold) {
dataChannel.bufferedAmountLowThreshold = lowThreshold;
}
// If buffer exceeds maximum, wait until it drops to low threshold
if (dataChannel.bufferedAmount > maxBuffer) {
const startTime = performance.now();
const initialBuffered = dataChannel.bufferedAmount;
await new Promise<void>((resolve) => {
const onLow = () => {
dataChannel.removeEventListener("bufferedamountlow", onLow);
resolve();
};
dataChannel.addEventListener("bufferedamountlow", onLow);
// Add timeout protection to avoid infinite waiting
setTimeout(() => {
dataChannel.removeEventListener("bufferedamountlow", onLow);
resolve();
}, 5000); // 5 second timeout
});
// Only output backpressure logs in development environment
// if (developmentEnv === "development") {
// const waitTime = performance.now() - startTime;
// postLogToBackend(
// `[DEBUG] 🚀 BACKPRESSURE - wait: ${waitTime.toFixed(
// 1
// )}ms, buffered: ${(initialBuffered / 1024).toFixed(0)}KB -> ${(
// dataChannel.bufferedAmount / 1024
// ).toFixed(0)}KB`
// );
// }
}
}
/**
* 🚀 Send data with backpressure control
*/
async sendWithBackpressure(
data: string | ArrayBuffer,
peerId: string
): Promise<void> {
const dataChannel = this.webrtcConnection.dataChannels.get(peerId);
if (!dataChannel) {
throw new Error("Data channel not found");
}
try {
// For ArrayBuffer, if larger than 64KB, needs to be fragmented (fix sendData failed)
if (data instanceof ArrayBuffer) {
await this.sendLargeArrayBuffer(data, peerId);
} else {
await this.sendSingleData(data, peerId);
}
} catch (error) {
const errorMessage = `sendWithBackpressure failed: ${error}`;
if (developmentEnv === "development") {
postLogToBackend(`[DEBUG] ❌ ${errorMessage}`);
}
throw new Error(errorMessage);
}
}
/**
* 🚀 Send large ArrayBuffer (fragmentation processing)
*/
private async sendLargeArrayBuffer(
data: ArrayBuffer,
peerId: string
): Promise<void> {
const networkChunkSize = 65536; // 64KB
const totalSize = data.byteLength;
// If data is less than 64KB, send directly
if (totalSize <= networkChunkSize) {
await this.sendSingleData(data, peerId);
return;
}
// Fragment large data for sending
let offset = 0;
let fragmentIndex = 0;
while (offset < totalSize) {
const chunkSize = Math.min(networkChunkSize, totalSize - offset);
const chunk = data.slice(offset, offset + chunkSize);
// Send fragment
await this.sendSingleData(chunk, peerId);
offset += chunkSize;
fragmentIndex++;
}
}
/**
* 📊 Get transmission statistics
*/
public getTransmissionStats(peerId: string) {
const dataChannel = this.webrtcConnection.dataChannels.get(peerId);
return {
peerId,
currentBufferedAmount: dataChannel?.bufferedAmount || 0,
bufferedAmountLowThreshold: dataChannel?.bufferedAmountLowThreshold || 0,
channelState: dataChannel?.readyState || "unknown",
};
}
/**
* 🧹 Clean up resources
*/
public cleanup(): void {
if (developmentEnv === "development") {
postLogToBackend("[DEBUG] 🧹 NetworkTransmitter cleaned up");
}
}
}
+231
View File
@@ -0,0 +1,231 @@
import { SpeedCalculator } from "@/lib/speedCalculator";
import { StateManager } from "./StateManager";
import { postLogToBackend } from "@/app/config/api";
const developmentEnv = process.env.NODE_ENV;
/**
* 🚀 Progress callback type definition
*/
export type ProgressCallback = (
fileId: string,
progress: number,
speed: number
) => void;
/**
* 🚀 Progress tracker
* Responsible for file and folder progress calculation, speed statistics, and callback triggering
*/
export class ProgressTracker {
private speedCalculator = new SpeedCalculator();
constructor(private stateManager: StateManager) {}
/**
* 🎯 Update file transfer progress
*/
async updateFileProgress(
byteLength: number,
fileId: string,
fileSize: number,
peerId: string,
wasActuallySent: boolean = true
): Promise<void> {
const peerState = this.stateManager.getPeerState(peerId);
if (!peerState) return;
// Important fix: Only update statistics for successfully sent data
if (!wasActuallySent) {
return;
}
// Update file sent bytes
this.stateManager.updateFileBytesSent(peerId, fileId, byteLength);
// Calculate progress ID and statistics
let progressFileId = fileId;
let currentBytes = this.stateManager.getFileBytesSent(peerId, fileId);
let totalSize = fileSize;
// If file belongs to a folder, recalculate folder progress
if (peerState.currentFolderName) {
const folderName = peerState.currentFolderName;
const folderMeta = this.stateManager.getFolderMeta(folderName);
progressFileId = folderName;
totalSize = folderMeta?.totalSize || 0;
// Recalculate folder progress (sum of progress from all its files)
// This is more robust and correct for resume downloads
currentBytes = this.stateManager.getFolderBytesSent(peerId, folderName);
// Delete frequent folder progress logs
}
// Update speed calculator
this.speedCalculator.updateSendSpeed(peerId, currentBytes);
const speed = this.speedCalculator.getSendSpeed(peerId);
const progress = totalSize > 0 ? currentBytes / totalSize : 0;
// Trigger progress callback
this.triggerProgressCallback(peerId, progressFileId, progress, speed);
}
/**
* 🎯 Update folder transfer progress
*/
async updateFolderProgress(
folderName: string,
fileProgress: Record<string, number>,
peerId: string
): Promise<void> {
const folderMeta = this.stateManager.getFolderMeta(folderName);
if (!folderMeta) {
postLogToBackend(`[DEBUG] ⚠️ Folder metadata not found: ${folderName}`);
return;
}
// Calculate total folder progress
let totalSentBytes = 0;
folderMeta.fileIds.forEach((fileId) => {
totalSentBytes += this.stateManager.getFileBytesSent(peerId, fileId);
});
const progress =
folderMeta.totalSize > 0 ? totalSentBytes / folderMeta.totalSize : 0;
const speed = this.speedCalculator.getSendSpeed(peerId);
// Trigger folder progress callback
this.triggerProgressCallback(peerId, folderName, progress, speed);
postLogToBackend(
`[DEBUG] 📁 Folder progress - ${folderName}: ${(progress * 100).toFixed(
2
)}%, speed: ${speed.toFixed(2)} KB/s, bytes: ${totalSentBytes}/${
folderMeta.totalSize
}`
);
}
/**
* 🎯 Set progress callback function
*/
setProgressCallback(callback: ProgressCallback, peerId: string): void {
this.stateManager.updatePeerState(peerId, { progressCallback: callback });
}
/**
* 🎯 Trigger progress callback
*/
private triggerProgressCallback(
peerId: string,
fileId: string,
progress: number,
speed: number
): void {
const peerState = this.stateManager.getPeerState(peerId);
if (peerState.progressCallback) {
try {
peerState.progressCallback(fileId, progress, speed);
} catch (error) {
postLogToBackend(
`[DEBUG] ❌ Progress callback error - fileId: ${fileId}, error: ${error}`
);
}
}
}
/**
* 🎯 Calculate current transfer speed
*/
getCurrentSpeed(peerId: string): number {
return this.speedCalculator.getSendSpeed(peerId);
}
/**
* 🎯 Complete file transfer progress (set to 100%)
*/
completeFileProgress(fileId: string, peerId: string): void {
this.triggerProgressCallback(peerId, fileId, 1.0, 0);
postLogToBackend(`[DEBUG] ✅ File progress completed: ${fileId}`);
}
/**
* 🎯 Complete folder transfer progress (set to 100%)
*/
completeFolderProgress(folderName: string, peerId: string): void {
this.triggerProgressCallback(peerId, folderName, 1.0, 0);
postLogToBackend(`[DEBUG] ✅ Folder progress completed: ${folderName}`);
}
/**
* 📊 Get detailed progress statistics
*/
getProgressStats(peerId: string) {
const peerState = this.stateManager.getPeerState(peerId);
const currentSpeed = this.getCurrentSpeed(peerId);
// Calculate total sent bytes
let totalBytesSent = 0;
Object.values(peerState.totalBytesSent).forEach((bytes) => {
totalBytesSent += bytes;
});
return {
peerId,
currentSpeed,
totalBytesSent,
activeTransfers: Object.keys(peerState.totalBytesSent).length,
currentFolderName: peerState.currentFolderName,
isSending: peerState.isSending,
hasProgressCallback: !!peerState.progressCallback,
};
}
/**
* 📊 Get detailed folder progress information
*/
getFolderProgressDetails(folderName: string, peerId: string) {
const folderMeta = this.stateManager.getFolderMeta(folderName);
if (!folderMeta) return null;
const fileProgresses: Record<
string,
{ sent: number; total: number; progress: number }
> = {};
let totalSent = 0;
folderMeta.fileIds.forEach((fileId) => {
const sent = this.stateManager.getFileBytesSent(peerId, fileId);
// Note: Need to get file size from pendingFiles, temporarily using 0
const total = 0; // TODO: Need to get file size from StateManager
totalSent += sent;
fileProgresses[fileId] = {
sent,
total,
progress: total > 0 ? sent / total : 0,
};
});
return {
folderName,
totalSize: folderMeta.totalSize,
totalSent,
overallProgress:
folderMeta.totalSize > 0 ? totalSent / folderMeta.totalSize : 0,
fileCount: folderMeta.fileIds.length,
fileProgresses,
};
}
/**
* 🧹 Clean up progress tracking resources
*/
cleanup(): void {
// SpeedCalculator internally automatically cleans up expired data
if (developmentEnv === "development")
postLogToBackend("[DEBUG] 🧹 ProgressTracker cleaned up");
}
}
+187
View File
@@ -0,0 +1,187 @@
import { PeerState, CustomFile, FolderMeta } from "@/types/webrtc";
// Simplified version no longer depends on TransferConfig's complex configuration
/**
* 🚀 State management class
* Centrally manages all transfer-related state data
*/
export class StateManager {
private peerStates = new Map<string, PeerState>();
private pendingFiles = new Map<string, CustomFile>();
private pendingFolderMeta: Record<string, FolderMeta> = {};
// ===== Peer state management =====
/**
* Get or create peer state
*/
public getPeerState(peerId: string): PeerState {
if (!this.peerStates.has(peerId)) {
this.peerStates.set(peerId, {
isSending: false,
bufferQueue: [],
readOffset: 0,
isReading: false,
currentFolderName: "",
totalBytesSent: {},
progressCallback: null,
});
}
return this.peerStates.get(peerId)!;
}
/**
* Update peer state
*/
public updatePeerState(peerId: string, updates: Partial<PeerState>): void {
const currentState = this.getPeerState(peerId);
Object.assign(currentState, updates);
}
/**
* Reset peer state (when transfer is complete or error occurs)
*/
public resetPeerState(peerId: string): void {
const peerState = this.getPeerState(peerId);
peerState.isSending = false;
peerState.readOffset = 0;
peerState.bufferQueue = [];
peerState.isReading = false;
// Preserve currentFolderName, totalBytesSent, progressCallback
}
/**
* Clear peer state immediately (for graceful disconnect)
*/
public clearPeerState(peerId: string): void {
this.peerStates.delete(peerId);
}
// ===== File management =====
/**
* Add pending file to send
*/
public addPendingFile(fileId: string, file: CustomFile): void {
this.pendingFiles.set(fileId, file);
}
/**
* Get pending file to send
*/
public getPendingFile(fileId: string): CustomFile | undefined {
return this.pendingFiles.get(fileId);
}
/**
* Remove pending file to send
*/
public removePendingFile(fileId: string): void {
this.pendingFiles.delete(fileId);
}
/**
* Get all pending files to send
*/
public getAllPendingFiles(): Map<string, CustomFile> {
return new Map(this.pendingFiles);
}
// ===== Folder metadata management =====
/**
* Add or update folder metadata
*/
public addFileToFolder(
folderName: string,
fileId: string,
fileSize: number
): void {
if (!this.pendingFolderMeta[folderName]) {
this.pendingFolderMeta[folderName] = { totalSize: 0, fileIds: [] };
}
const folderMeta = this.pendingFolderMeta[folderName];
if (!folderMeta.fileIds.includes(fileId)) {
folderMeta.fileIds.push(fileId);
folderMeta.totalSize += fileSize;
}
}
/**
* Get folder metadata
*/
public getFolderMeta(folderName: string): FolderMeta | undefined {
return this.pendingFolderMeta[folderName];
}
/**
* Get all folder metadata
*/
public getAllFolderMeta(): Record<string, FolderMeta> {
return { ...this.pendingFolderMeta };
}
// ===== Progress tracking related state =====
/**
* Update file sent bytes
*/
public updateFileBytesSent(
peerId: string,
fileId: string,
bytes: number
): void {
const peerState = this.getPeerState(peerId);
if (!peerState.totalBytesSent[fileId]) {
peerState.totalBytesSent[fileId] = 0;
}
peerState.totalBytesSent[fileId] += bytes;
}
/**
* Get file sent bytes
*/
public getFileBytesSent(peerId: string, fileId: string): number {
const peerState = this.peerStates.get(peerId);
return peerState?.totalBytesSent[fileId] || 0;
}
/**
* Calculate folder total sent bytes
*/
public getFolderBytesSent(peerId: string, folderName: string): number {
const folderMeta = this.getFolderMeta(folderName);
const peerState = this.peerStates.get(peerId);
if (!folderMeta || !peerState) return 0;
let totalSent = 0;
folderMeta.fileIds.forEach((fileId) => {
totalSent += peerState.totalBytesSent[fileId] || 0;
});
return totalSent;
}
// ===== Cleanup and reset =====
/**
* Clean up all states (when system resets)
*/
public cleanup(): void {
this.peerStates.clear();
this.pendingFiles.clear();
this.pendingFolderMeta = {};
}
/**
* Get state statistics (for debugging)
*/
public getStateStats() {
return {
peerCount: this.peerStates.size,
pendingFileCount: this.pendingFiles.size,
folderCount: Object.keys(this.pendingFolderMeta).length,
};
}
}
@@ -0,0 +1,399 @@
import { CustomFile } from "@/types/webrtc";
import { TransferConfig } from "./TransferConfig";
import { ChunkRangeCalculator } from "@/lib/utils/ChunkRangeCalculator";
import { postLogToBackend } from "@/app/config/api";
const developmentEnv = process.env.NODE_ENV;
/**
* 🚀 Network chunk interface
*/
export interface NetworkChunk {
chunk: ArrayBuffer | null;
chunkIndex: number;
totalChunks: number;
fileOffset: number;
isLastChunk: boolean;
}
/**
* 🚀 High-performance streaming file reader
* Uses a two-layer buffering architecture: large batch reading + small network chunk sending
* Solves file reading performance bottleneck issues
*/
export class StreamingFileReader {
// Configuration parameters
private readonly BATCH_SIZE =
TransferConfig.FILE_CONFIG.CHUNK_SIZE *
TransferConfig.FILE_CONFIG.BATCH_SIZE; // 32MB batches
private readonly NETWORK_CHUNK_SIZE =
TransferConfig.FILE_CONFIG.NETWORK_CHUNK_SIZE; // 64KB network chunks
private readonly CHUNKS_PER_BATCH = this.BATCH_SIZE / this.NETWORK_CHUNK_SIZE; // 512 chunks
// File state
private file: File;
private fileReader: FileReader;
private totalFileSize: number;
// Batch buffering state
private currentBatch: ArrayBuffer | null = null; // Current 32MB batch data
private currentBatchStartOffset = 0; // Starting position of current batch in file
private currentChunkIndexInBatch = 0; // Index of current network chunk in batch
// Global state
private totalFileOffset = 0; // Current position in the entire file
private startChunkIndex = 0; // 🔧 Record the chunk index at the start of transmission
private isFinished = false;
private isReading = false; // Prevent concurrent reading
constructor(file: CustomFile, startOffset: number = 0) {
this.file = file;
this.totalFileSize = file.size;
this.totalFileOffset = startOffset;
// 🔧 Fix: When resuming, currentBatchStartOffset should start from startOffset
this.currentBatchStartOffset = startOffset;
this.fileReader = new FileReader();
// 🔧 Record the starting chunk index of the transfer, used for boundary detection
this.startChunkIndex = Math.floor(startOffset / this.NETWORK_CHUNK_SIZE);
if (developmentEnv === "development") {
const chunkRange = ChunkRangeCalculator.getChunkRange(
this.totalFileSize,
startOffset,
this.NETWORK_CHUNK_SIZE
);
postLogToBackend(
`[SEND-SUMMARY] File: ${file.name}, offset: ${startOffset}, startChunk: ${chunkRange.startChunk}, endChunk: ${chunkRange.endChunk}, willSend: ${chunkRange.totalChunks}, absoluteTotal: ${chunkRange.absoluteTotalChunks}`
);
}
}
/**
* 🎯 Core method: Get next 64KB network chunk
*/
async getNextNetworkChunk(): Promise<NetworkChunk> {
// 1. Check if new batch needs to be loaded
if (this.needsNewBatch()) {
await this.loadNextBatch();
}
// 2. Check if end of file has been reached
if (this.isFinished || !this.currentBatch) {
return {
chunk: null,
chunkIndex: this.calculateGlobalChunkIndex(),
totalChunks: this.calculateTotalNetworkChunks(),
fileOffset: this.totalFileOffset,
isLastChunk: true,
};
}
// 3. Slice 64KB network chunk from current batch
const networkChunk = this.sliceNetworkChunkFromBatch();
const globalChunkIndex = this.calculateGlobalChunkIndex();
const isLast = this.isLastNetworkChunk(networkChunk);
// 4. Update state
this.updateChunkState(networkChunk);
// if (developmentEnv === "development") {
// const totalChunks = this.calculateTotalNetworkChunks();
// const isFirst = globalChunkIndex === this.startChunkIndex;
// const expectedLastChunk = Math.floor(
// (this.totalFileSize - 1) / this.NETWORK_CHUNK_SIZE
// );
// const isRealLast = isLast && globalChunkIndex === expectedLastChunk;
// if (isFirst || isRealLast) {
// postLogToBackend(
// `[BOUNDARY] Chunk #${globalChunkIndex}/${totalChunks}, isFirst: ${isFirst}, isLast: ${isRealLast}, startIdx: ${this.startChunkIndex}, expectedLastIdx: ${expectedLastChunk}, size: ${networkChunk.byteLength}`
// );
// }
// }
return {
chunk: networkChunk,
chunkIndex: globalChunkIndex,
totalChunks: this.calculateTotalNetworkChunks(),
fileOffset: this.totalFileOffset - networkChunk.byteLength,
isLastChunk: isLast,
};
}
/**
* 🔍 Determine if new batch needs to be loaded
*/
private needsNewBatch(): boolean {
return (
this.currentBatch === null || // No batch loaded yet
this.currentChunkIndexInBatch >= this.CHUNKS_PER_BATCH || // Current batch exhausted
this.isCurrentBatchEmpty() // Current batch has no data
);
}
/**
* 🔍 Check if current batch is empty
*/
private isCurrentBatchEmpty(): boolean {
if (!this.currentBatch) return true;
const usedBytes = this.currentChunkIndexInBatch * this.NETWORK_CHUNK_SIZE;
return usedBytes >= this.currentBatch.byteLength;
}
/**
* 📥 Load next 32MB batch into memory
*/
private async loadNextBatch(): Promise<void> {
if (this.isReading) {
// Prevent concurrent reading
while (this.isReading) {
await new Promise((resolve) => setTimeout(resolve, 10));
}
return;
}
this.isReading = true;
const startTime = performance.now();
try {
// 1. Clean up old batch memory
this.currentBatch = null;
// 2. Calculate size to read this time
const remainingFileSize = this.totalFileSize - this.totalFileOffset;
const batchSize = Math.min(this.BATCH_SIZE, remainingFileSize);
if (batchSize <= 0) {
this.isFinished = true;
return;
}
// 3. Perform large chunk file reading
const sliceStartTime = performance.now();
const fileSlice = this.file.slice(
this.totalFileOffset,
this.totalFileOffset + batchSize
);
const sliceTime = performance.now() - sliceStartTime;
// 4. Asynchronously read file data
const readStartTime = performance.now();
this.currentBatch = await this.readFileSlice(fileSlice);
const readTime = performance.now() - readStartTime;
const batchStartOffset = this.totalFileOffset;
this.currentBatchStartOffset = batchStartOffset;
// 🔧 Fix: Simplify index calculation logic within batch
// Since calculateGlobalChunkIndex now directly calculates based on totalFileOffset
// Indexing within a batch only needs to be calculated based on the starting position of the current batch
const chunkOffsetInBatch =
batchStartOffset -
Math.floor(batchStartOffset / this.BATCH_SIZE) * this.BATCH_SIZE;
this.currentChunkIndexInBatch = Math.floor(
chunkOffsetInBatch / this.NETWORK_CHUNK_SIZE
);
// Only output essential batch reading logs in development environment
if (developmentEnv === "development" && batchSize > this.BATCH_SIZE / 2) {
const totalTime = performance.now() - startTime;
const speedMBps = batchSize / 1024 / 1024 / (totalTime / 1000);
postLogToBackend(
`[BATCH-READ] 📖 size: ${(batchSize / 1024 / 1024).toFixed(
1
)}MB, speed: ${speedMBps.toFixed(1)}MB/s`
);
}
} catch (error) {
if (developmentEnv === "development") {
postLogToBackend(`[DEBUG] ❌ BATCH_READ failed: ${error}`);
}
throw new Error(`Failed to load file batch: ${error}`);
} finally {
this.isReading = false;
}
}
/**
* 📄 Perform file reading operation
*/
private async readFileSlice(fileSlice: Blob): Promise<ArrayBuffer> {
return new Promise((resolve, reject) => {
this.fileReader.onload = () => {
const result = this.fileReader.result as ArrayBuffer;
if (result) {
resolve(result);
} else {
reject(new Error("FileReader result is null"));
}
};
this.fileReader.onerror = () => {
reject(
new Error(
`File reading failed: ${
this.fileReader.error?.message || "Unknown error"
}`
)
);
};
this.fileReader.readAsArrayBuffer(fileSlice);
});
}
/**
* ✂️ Slice 64KB network chunk from 32MB batch
* 🆕 Fix: Calculate directly based on the position of offset in the batch, avoiding complex batch internal index calculations
*/
private sliceNetworkChunkFromBatch(): ArrayBuffer {
if (!this.currentBatch) {
throw new Error("No current batch available for slicing");
}
// 🆕 Calculated directly based on the position of offset in the batch to avoid index calculation errors within the batch
const offsetInBatch = this.totalFileOffset - this.currentBatchStartOffset;
const remainingInBatch = this.currentBatch.byteLength - offsetInBatch;
const chunkSize = Math.min(this.NETWORK_CHUNK_SIZE, remainingInBatch);
if (chunkSize <= 0) {
throw new Error("Invalid chunk size calculated");
}
const networkChunk = this.currentBatch.slice(
offsetInBatch,
offsetInBatch + chunkSize
);
// Delete frequent slice logs, only output when needed
return networkChunk;
}
/**
* 📊 Calculate global network chunk index
* 🔧 Simplified logic: directly calculate based on file offset to avoid batch boundary errors
*/
private calculateGlobalChunkIndex(): number {
// Calculate chunk index directly based on current file offset, avoiding complex batch calculations, consistent with receiver
return Math.floor(this.totalFileOffset / this.NETWORK_CHUNK_SIZE);
}
/**
* 📈 Calculate total network chunk count
*/
private calculateTotalNetworkChunks(): number {
return Math.ceil(this.totalFileSize / this.NETWORK_CHUNK_SIZE);
}
/**
* ⏭️ Update current processing state
*/
private updateChunkState(chunk: ArrayBuffer): void {
this.currentChunkIndexInBatch++;
this.totalFileOffset += chunk.byteLength;
// Check if end of file has been reached
if (this.totalFileOffset >= this.totalFileSize) {
this.isFinished = true;
}
}
/**
* 🏁 Check if this is the last network chunk
*/
private isLastNetworkChunk(chunk: ArrayBuffer): boolean {
return this.totalFileOffset + chunk.byteLength >= this.totalFileSize;
}
/**
* 📊 Get reading progress information
*/
public getProgress(): {
readBytes: number;
totalBytes: number;
progressPercent: number;
currentBatchInfo?: {
batchStartOffset: number;
batchSize: number;
chunkIndex: number;
totalChunks: number;
};
} {
const progressPercent =
this.totalFileSize > 0
? (this.totalFileOffset / this.totalFileSize) * 100
: 0;
const result = {
readBytes: this.totalFileOffset,
totalBytes: this.totalFileSize,
progressPercent,
} as any;
if (this.currentBatch) {
result.currentBatchInfo = {
batchStartOffset: this.currentBatchStartOffset,
batchSize: this.currentBatch.byteLength,
chunkIndex: this.currentChunkIndexInBatch,
totalChunks: Math.ceil(
this.currentBatch.byteLength / this.NETWORK_CHUNK_SIZE
),
};
}
return result;
}
/**
* 🔄 Reset reader state (for restarting reading)
*/
public reset(startOffset: number = 0): void {
this.totalFileOffset = startOffset;
this.isFinished = false;
this.isReading = false;
this.currentBatch = null;
// 🔧 Fix: Reset also needs to correctly set currentBatchStartOffset
this.currentBatchStartOffset = startOffset;
this.currentChunkIndexInBatch = 0; // Reset to 0, loadNextBatch will recalculate
if (developmentEnv === "development") {
postLogToBackend(
`[DEBUG] 🔄 StreamingFileReader reset - startOffset:${startOffset}`
);
}
}
/**
* 🧹 Cleanup and release resources
*/
public cleanup(): void {
// Abort ongoing file reading
if (this.isReading) {
this.fileReader.abort();
}
// Clean up memory
this.currentBatch = null;
this.isFinished = true;
this.isReading = false;
}
/**
* 🔍 Get debug information
*/
public getDebugInfo() {
return {
fileName: this.file.name,
fileSize: this.totalFileSize,
currentOffset: this.totalFileOffset,
isFinished: this.isFinished,
isReading: this.isReading,
hasBatch: !!this.currentBatch,
batchOffset: this.currentBatchStartOffset,
chunkInBatch: this.currentChunkIndexInBatch,
globalChunkIndex: this.calculateGlobalChunkIndex(),
totalChunks: this.calculateTotalNetworkChunks(),
};
}
}
+12
View File
@@ -0,0 +1,12 @@
/**
* 🚀 Transfer configuration management class
* Centrally manages all file transfer related configuration parameters
*/
export class TransferConfig {
// File I/O related configuration
static readonly FILE_CONFIG = {
CHUNK_SIZE: 4194304, // 4MB - File reading chunk size, reduces FileReader calls
BATCH_SIZE: 8, // 8 chunks batch processing - 32MB batch processing improves performance
NETWORK_CHUNK_SIZE: 65536, // 64KB - WebRTC safe sending size, fixes sendData failed
} as const;
}
+40
View File
@@ -0,0 +1,40 @@
/**
* 🚀 File transfer module unified export
* Provides modular file transfer services
*/
// Configuration management
export { TransferConfig } from "./TransferConfig";
// State management
export { StateManager } from "./StateManager";
// High-performance file reading
export { StreamingFileReader } from "./StreamingFileReader";
export type { NetworkChunk } from "./StreamingFileReader";
// Network transmission
export { NetworkTransmitter } from "./NetworkTransmitter";
// Message handling
export { MessageHandler } from "./MessageHandler";
export type { MessageHandlerDelegate } from "./MessageHandler";
// Progress tracking
export { ProgressTracker } from "./ProgressTracker";
export type { ProgressCallback } from "./ProgressTracker";
// Main orchestrator
export { FileTransferOrchestrator } from "./FileTransferOrchestrator";
/**
* 🎯 Convenience creation function - Quick initialization of file transfer services
*/
import WebRTC_Initiator from "../webrtc_Initiator";
import { FileTransferOrchestrator } from "./FileTransferOrchestrator";
export function createFileTransferService(
webrtcConnection: WebRTC_Initiator
): FileTransferOrchestrator {
return new FileTransferOrchestrator(webrtcConnection);
}
@@ -0,0 +1,82 @@
/**
* 🚀 Chunk range calculation utilities
* Provides unified chunk calculation logic to ensure consistency between sender and receiver
*/
export class ChunkRangeCalculator {
/**
* Calculate chunk range for a file with given parameters
* This method ensures both sender and receiver use identical calculation logic
*/
static getChunkRange(fileSize: number, startOffset: number, chunkSize: number) {
// Calculate starting chunk index
const startChunk = Math.floor(startOffset / chunkSize);
// Calculate ending chunk index based on the last byte of the file
const lastByteIndex = fileSize - 1;
const endChunk = Math.floor(lastByteIndex / chunkSize);
// Calculate total chunks to be sent/received (from startChunk to endChunk inclusive)
const totalChunks = endChunk - startChunk + 1;
// Calculate absolute total chunks in the entire file
const absoluteTotalChunks = Math.ceil(fileSize / chunkSize);
return {
startChunk, // First chunk index to process
endChunk, // Last chunk index to process
totalChunks, // Number of chunks to process (for resume transfers)
absoluteTotalChunks // Total chunks in the entire file
};
}
/**
* Calculate expected chunks count for resume transfer
* Identical to ReceptionConfig.calculateExpectedChunks()
*/
static calculateExpectedChunks(fileSize: number, startOffset: number, chunkSize: number): number {
return Math.ceil((fileSize - startOffset) / chunkSize);
}
/**
* Get chunk index from file offset
* Identical to ReceptionConfig.getChunkIndexFromOffset()
*/
static getChunkIndexFromOffset(offset: number, chunkSize: number): number {
return Math.floor(offset / chunkSize);
}
/**
* Get file offset from chunk index
* Identical to ReceptionConfig.getOffsetFromChunkIndex()
*/
static getOffsetFromChunkIndex(chunkIndex: number, chunkSize: number): number {
return chunkIndex * chunkSize;
}
/**
* Validate chunk index within expected range
*/
static isChunkIndexValid(
chunkIndex: number,
startOffset: number,
fileSize: number,
chunkSize: number
): boolean {
const range = this.getChunkRange(fileSize, startOffset, chunkSize);
return chunkIndex >= range.startChunk && chunkIndex <= range.endChunk;
}
/**
* Calculate relative chunk index from absolute chunk index
* Used by receiver to map sender's absolute index to local array index
*/
static getRelativeChunkIndex(
absoluteChunkIndex: number,
startOffset: number,
chunkSize: number
): number {
const startChunkIndex = this.getChunkIndexFromOffset(startOffset, chunkSize);
return absoluteChunkIndex - startChunkIndex;
}
}
+149 -59
View File
@@ -8,7 +8,6 @@ import {
config,
} from "@/app/config/environment";
import { useFileTransferStore } from "@/stores/fileTransferStore";
import type { CustomFile } from "@/types/webrtc";
class WebRTCService {
public sender: WebRTC_Initiator;
@@ -19,10 +18,13 @@ class WebRTCService {
private static instance: WebRTCService;
private constructor() {
const apiUrl = (config.API_URL || "").trim();
// Use same-origin when API_URL is empty string — socket.io accepts empty string for same-origin
const signalingServer: string = apiUrl.length > 0 ? apiUrl : "";
const webRTCConfig = {
iceServers: getIceServers(),
socketOptions: getSocketOptions() || {},
signalingServer: config.API_URL,
signalingServer,
};
this.sender = new WebRTC_Initiator(webRTCConfig);
@@ -41,72 +43,73 @@ class WebRTCService {
}
private initializeEventHandlers(): void {
// 发送方事件处理
// Sender event handling
this.sender.onConnectionStateChange = (state, peerId) => {
console.log(`[WebRTC Service] Sender connection state: ${state} for peer ${peerId}`);
useFileTransferStore.getState().setShareConnectionState(state as any);
useFileTransferStore
.getState()
.setSharePeerCount(this.sender.peerConnections.size);
if (state === "connected") {
// update share peer count
useFileTransferStore.getState().setSharePeerCount(this.sender.peerConnections.size);
console.log(`[WebRTC Service] Sender connected, peer count: ${this.sender.peerConnections.size}`);
this.fileSender.setProgressCallback((fileId, progress, speed) => {
useFileTransferStore
.getState()
.updateSendProgress(fileId, peerId, { progress, speed });
}, peerId);
} else if (state === "failed" || state === "closed") {
this.handleConnectionDisconnect(peerId, true, `CONNECTION_${state.toUpperCase()}`);
}
};
this.sender.onDataChannelOpen = (peerId) => {
this.sender.onDataChannelOpen = (_peerId) => {
useFileTransferStore.getState().setIsSenderInRoom(true);
// 自动广播当前内容
// Automatically broadcast current content
this.broadcastDataToAllPeers();
};
this.sender.onPeerDisconnected = (peerId) => {
setTimeout(() => {
useFileTransferStore
.getState()
.setSharePeerCount(this.sender.peerConnections.size);
}, 0);
console.log(`[WebRTC Service] Sender peer disconnected: ${peerId}`);
this.handleConnectionDisconnect(peerId, true, "PEER_DISCONNECTED");
};
this.sender.onError = (error) => {
console.error("[WebRTC Service] 发送方错误:", error.message);
console.error("[WebRTC Service] Sender error:", error.message);
// Clear all states on error
this.clearAllTransferProgress();
};
// 接收方事件处理
// Receiver event handling
this.receiver.onConnectionStateChange = (state, peerId) => {
console.log(`[WebRTC Service] Receiver connection state: ${state} for peer ${peerId}`);
useFileTransferStore.getState().setRetrieveConnectionState(state as any);
useFileTransferStore
.getState()
.setRetrievePeerCount(this.receiver.peerConnections.size);
if (state === "connected") {
// update retrieve peer count
useFileTransferStore.getState().setRetrievePeerCount(this.receiver.peerConnections.size);
console.log(`[WebRTC Service] Receiver connected, peer count: ${this.receiver.peerConnections.size}`);
this.fileReceiver.setProgressCallback((fileId, progress, speed) => {
useFileTransferStore
.getState()
.updateReceiveProgress(fileId, peerId, { progress, speed });
});
} else if (state === "failed" || state === "disconnected") {
const { isAnyFileTransferring } = useFileTransferStore.getState();
if (isAnyFileTransferring) {
this.fileReceiver.gracefulShutdown();
}
} else if (state === "failed" || state === "closed") {
this.handleConnectionDisconnect(peerId, false, `CONNECTION_${state.toUpperCase()}`);
}
};
this.receiver.onConnectionEstablished = (peerId) => {
const store = useFileTransferStore.getState();
this.fileSender.handlePeerReconnection(peerId);
useFileTransferStore.getState().setSenderDisconnected(false);
useFileTransferStore.getState().setIsReceiverInRoom(true);
};
this.receiver.onPeerDisconnected = (peerId) => {
const store = useFileTransferStore.getState();
useFileTransferStore.getState().setSenderDisconnected(true);
useFileTransferStore.getState().setRetrievePeerCount(0);
console.log(`[WebRTC Service] Receiver peer disconnected: ${peerId}`);
this.handleConnectionDisconnect(peerId, false, "PEER_DISCONNECTED");
};
this.fileReceiver.onStringReceived = (data) => {
@@ -123,10 +126,10 @@ class WebRTCService {
};
this.fileReceiver.onFileReceived = async (file) => {
// 🔧 增强修复:确保Store状态更新完全同步,使用多重验证
// 🔧 Enhanced fix: Ensure Store state updates are fully synchronized with multiple verifications
const store = useFileTransferStore.getState();
// 检查文件是否已经存在,避免重复添加
// Check if file already exists to avoid duplicates
const existingFile = store.retrievedFiles.find(
(f) => f.name === file.name && f.size === file.size
);
@@ -134,31 +137,17 @@ class WebRTCService {
if (!existingFile) {
store.addRetrievedFile(file);
}
// 🔧 额外确保:立即验证状态更新是否成功,并重试机制
let verificationAttempts = 0;
const maxVerificationAttempts = 3;
const verifyFileAdded = () => {
verificationAttempts++;
const updatedStore = useFileTransferStore.getState();
const fileExists = updatedStore.retrievedFiles.some(
(f) => f.name === file.name && f.size === file.size
);
if (!fileExists && verificationAttempts < maxVerificationAttempts) {
updatedStore.addRetrievedFile(file);
setTimeout(verifyFileAdded, 10);
}
};
// 立即进行第一次验证
verifyFileAdded();
};
}
// 业务方法
// Business methods
public async joinRoom(roomId: string, isSender: boolean): Promise<void> {
// Ensure clean state before joining
if (!isSender) {
// Force reset FileReceiver state to prevent "already in progress" errors
this.fileReceiver.forceReset();
}
const peer = isSender ? this.sender : this.receiver;
await peer.joinRoom(roomId, isSender);
@@ -168,12 +157,17 @@ class WebRTCService {
setInRoom(true);
}
public async leaveRoom(isSender: boolean): Promise<void> {
if (isSender) {
// Clean up sender
this.fileSender.cleanup();
await this.sender.leaveRoomAndCleanup();
useFileTransferStore.getState().setIsSenderInRoom(false);
useFileTransferStore.getState().setSharePeerCount(0);
} else {
// Clean up receiver - force reset to ensure complete cleanup
this.fileReceiver.forceReset();
await this.receiver.leaveRoomAndCleanup();
useFileTransferStore.getState().setIsReceiverInRoom(false);
useFileTransferStore.getState().setRetrievePeerCount(0);
@@ -184,7 +178,7 @@ class WebRTCService {
const { shareContent, sendFiles } = useFileTransferStore.getState();
const peerIds = Array.from(this.sender.peerConnections.keys());
if (peerIds.length === 0) {
console.warn("[WebRTC Service] 没有连接的对等端进行广播");
console.warn("[WebRTC Service] No connected peers to broadcast to");
return false;
}
@@ -201,7 +195,7 @@ class WebRTCService {
);
return true;
} catch (error) {
console.error("[WebRTC Service] 广播失败:", error);
console.error("[WebRTC Service] Broadcast failed:", error);
return false;
}
}
@@ -224,20 +218,116 @@ class WebRTCService {
return this.fileReceiver.saveType;
}
public manualSafeSave(): void {
this.fileReceiver.gracefulShutdown();
private handleConnectionDisconnect(peerId: string, isSender: boolean, reason: string): void {
console.log(`[WebRTC Service] Connection disconnect: ${reason}, peer: ${peerId}, sender: ${isSender}`);
// Immediately clean up the transfer status to avoid UI freezing
this.immediateTransferCleanup(peerId, isSender, reason);
// update connection state
this.updateConnectionState(peerId, isSender);
}
// Immediately clean up the transfer status
private immediateTransferCleanup(peerId: string, isSender: boolean, reason: string): void {
const store = useFileTransferStore.getState();
if (isSender) {
// Sender disconnected: clean up the sender related status
this.clearPeerTransferProgress(peerId, true);
} else {
// Receiver side: sender disconnected, need to clean up the receiver status
const { isAnyFileTransferring } = store;
if (isAnyFileTransferring) {
console.log(`[WebRTC Service] Force cleaning receiver due to sender disconnect: ${reason}`);
// Catch the error that gracefulShutdown may throw
try {
this.fileReceiver.gracefulShutdown(`SENDER_${reason}`);
} catch (error) {
console.log(`[WebRTC Service] Expected error during graceful shutdown:`, error instanceof Error ? error.message : String(error));
}
}
this.clearPeerTransferProgress(peerId, false);
}
}
// update connection state
private updateConnectionState(_peerId: string, isSender: boolean): void {
const store = useFileTransferStore.getState();
if (isSender) {
// Sender disconnected: clean up the sender related status
const currentShareCount = store.sharePeerCount;
store.setSharePeerCount(Math.max(0, currentShareCount - 1));
console.log(`[WebRTC Service] Sender peer count: ${currentShareCount}${Math.max(0, currentShareCount - 1)}`);
} else {
// Receiver side: sender disconnected, need to clean up the receiver status
store.setRetrievePeerCount(0);
store.setSenderDisconnected(true);
console.log(`[WebRTC Service] Receiver peer count set to 0`);
}
}
// Clear all transfer progress
private clearAllTransferProgress(): void {
const store = useFileTransferStore.getState();
store.setSendProgress({});
store.setReceiveProgress({});
store.setIsAnyFileTransferring(false);
console.log(`[WebRTC Service] Cleared all transfer progress`);
}
private clearPeerTransferProgress(peerId: string, isSender: boolean): void {
const store = useFileTransferStore.getState();
const progressState = isSender ? store.sendProgress : store.receiveProgress;
// Clear transfer progress for this peer
const newProgress = { ...progressState };
Object.keys(newProgress).forEach((fileId) => {
if (newProgress[fileId][peerId]) {
delete newProgress[fileId][peerId];
// If no other peers are transferring this file, remove the file record
if (Object.keys(newProgress[fileId]).length === 0) {
delete newProgress[fileId];
}
}
});
if (isSender) {
store.setSendProgress(newProgress);
} else {
store.setReceiveProgress(newProgress);
}
// Recalculate isAnyFileTransferring status
const allProgress = [
...Object.values(isSender ? newProgress : store.sendProgress),
...Object.values(isSender ? store.receiveProgress : newProgress),
];
const hasActiveTransfers = allProgress.some((fileProgress: any) => {
return Object.values(fileProgress).some((progress: any) => {
return progress.progress > 0 && progress.progress < 1;
});
});
if (!hasActiveTransfers) {
store.setIsAnyFileTransferring(false);
}
}
public async cleanup(): Promise<void> {
console.log("[WebRTC Service] 开始清理...");
console.log("[WebRTC Service] Starting cleanup...");
try {
await Promise.all([
this.sender.cleanUpBeforeExit(),
this.receiver.cleanUpBeforeExit(),
]);
console.log("[WebRTC Service] 清理完成");
console.log("[WebRTC Service] Cleanup completed");
} catch (error) {
console.error("[WebRTC Service] 清理过程中出错:", error);
console.error("[WebRTC Service] Error during cleanup:", error);
}
}
}
+10 -8
View File
@@ -1,7 +1,7 @@
// Initiator flow: Join room; receive 'ready' event (this event is triggered by the socket server after a new recipient enters) -> createPeerConnection + createDataChannel -> createAndSendOffer
import BaseWebRTC, { WebRTCConfig } from "./webrtc_base";
import { postLogToBackend } from "@/app/config/api";
const developmentEnv = process.env.NEXT_PUBLIC_development!; // Development environment
const developmentEnv = process.env.NODE_ENV; // Development environment
export default class WebRTC_Initiator extends BaseWebRTC {
constructor(config: WebRTCConfig) {
@@ -16,7 +16,7 @@ export default class WebRTC_Initiator extends BaseWebRTC {
});
// Add listener for recipient's response
this.socket.on("recipient-ready", ({ peerId }) => {
if (developmentEnv === "true")
if (developmentEnv === "development")
postLogToBackend(
`[Initiator] Received recipient-ready from: ${peerId}`
);
@@ -32,7 +32,7 @@ export default class WebRTC_Initiator extends BaseWebRTC {
private async handleReady({ peerId }: { peerId: string }): Promise<void> {
// Recipient peerId
// this.log('log',`Received ready signal from peer ${peerId}`);
if (developmentEnv === "true")
if (developmentEnv === "development")
postLogToBackend(`Received ready signal from peer ${peerId}`);
await this.createPeerConnection(peerId);
await this.createDataChannel(peerId);
@@ -48,7 +48,7 @@ export default class WebRTC_Initiator extends BaseWebRTC {
from: string;
}): Promise<void> {
// this.log('log',`Handling answer from peer ${from}`);
if (developmentEnv === "true")
if (developmentEnv === "development")
postLogToBackend(`Handling answer from peer ${from}`);
const peerConnection = this.peerConnections.get(from);
if (!peerConnection) {
@@ -77,14 +77,16 @@ export default class WebRTC_Initiator extends BaseWebRTC {
try {
const dataChannel = peerConnection.createDataChannel("dataChannel", {
ordered: true,
// reliable: true
});
// this.log('log', `Created data channel for peer ${peerId}`);
dataChannel.bufferedAmountLowThreshold = 262144; //256 KB -- 可以根据需要调整
dataChannel.bufferedAmountLowThreshold = 262144; // 256KB for others
this.setupDataChannel(dataChannel, peerId);
this.dataChannels.set(peerId, dataChannel);
} catch (error) {
postLogToBackend(
`Error creating DataChannel - peer: ${peerId}, error: ${error}`
);
this.fireError(`Error creating data channel for peer ${peerId}`, {
error,
peerId,
@@ -94,7 +96,7 @@ export default class WebRTC_Initiator extends BaseWebRTC {
// If it is the initiator, create and send an offer to the signaling server to negotiate a connection with the recipient.
private async createAndSendOffer(peerId: string): Promise<void> {
// this.log('log', `Creating and sending offer to ${peerId}`);
if (developmentEnv === "true")
if (developmentEnv === "development")
postLogToBackend(`createAndSendOffer for peerId: ${peerId}`);
const peerConnection = this.peerConnections.get(peerId);
if (!peerConnection) {
-3
View File
@@ -1,7 +1,5 @@
// Recipient flow: Join room; receive 'offer' event -> createPeerConnection + createDataChannel -> send answer
import BaseWebRTC, { WebRTCConfig } from "./webrtc_base";
import { postLogToBackend } from "@/app/config/api";
const developmentEnv = process.env.NEXT_PUBLIC_development!; // Development environment
interface AnswerPayload {
answer: RTCSessionDescriptionInit;
@@ -106,7 +104,6 @@ export default class WebRTC_Recipient extends BaseWebRTC {
}
peerConnection.ondatachannel = (event) => {
// this.log('log', `Received data channel from peer ${peerId}`);
this.setupDataChannel(event.channel, peerId);
this.dataChannels.set(peerId, event.channel);
};
+126 -15
View File
@@ -2,7 +2,7 @@
import io, { Socket, ManagerOptions, SocketOptions } from "socket.io-client";
import { WakeLockManager } from "./wakeLockManager";
import { postLogToBackend } from "@/app/config/api";
const developmentEnv = process.env.NEXT_PUBLIC_development!; // Development environment
const developmentEnv = process.env.NODE_ENV; // Development environment
export class WebRTCError extends Error {
constructor(message: string, public context?: Record<string, any>) {
@@ -64,6 +64,8 @@ export default class BaseWebRTC {
protected isPeerDisconnected: boolean; // Tracks P2P connection status
protected reconnectionInProgress: boolean; // Prevents duplicate reconnections
protected wakeLockManager: WakeLockManager;
// Graceful disconnect tracking
protected gracefullyDisconnectedPeers: Set<string>;
constructor(config: WebRTCConfig) {
this.iceServers = config.iceServers;
@@ -83,6 +85,7 @@ export default class BaseWebRTC {
this.roomId = null;
this.peerId = null; // Own ID
this.isInRoom = false; // Whether the user has already joined a room
this.gracefullyDisconnectedPeers = new Set(); // Track peers that disconnected gracefully
this.setupCommonSocketListeners();
this.isInitiator = false;
@@ -128,7 +131,7 @@ export default class BaseWebRTC {
this.socket.on("disconnect", () => {
this.isInRoom = false;
this.isSocketDisconnected = true;
if (developmentEnv === "true")
if (developmentEnv === "development")
postLogToBackend(
`${this.peerId} disconnect on socket,isInitiator:${this.isInitiator},isInRoom:${this.isInRoom}`
);
@@ -150,7 +153,7 @@ export default class BaseWebRTC {
if (this.isSocketDisconnected && this.isPeerDisconnected && this.roomId) {
// Start reconnection only after both socket and P2P connections are disconnected
this.reconnectionInProgress = true;
if (developmentEnv === "true") {
if (developmentEnv === "development") {
postLogToBackend(
`Starting reconnection, socket and peer both disconnected. isInitiator:${this.isInitiator}`
);
@@ -311,7 +314,7 @@ export default class BaseWebRTC {
disconnected: async () => {
await this.cleanupExistingConnection(peerId);
this.isPeerDisconnected = true;
if (developmentEnv === "true")
if (developmentEnv === "development")
postLogToBackend(`p2p disconnected, isInitiator:${this.isInitiator}`);
// Attempt to reconnect
this.attemptReconnection();
@@ -352,11 +355,60 @@ export default class BaseWebRTC {
};
dataChannel.onmessage = (event) => {
this.onDataReceived?.(event.data, peerId);
// Enhanced data type detection - supports multiple binary data formats in Firefox
let dataType = "Unknown";
let dataSize = 0;
if (typeof event.data === "string") {
dataType = "String";
dataSize = event.data.length;
} else if (event.data instanceof ArrayBuffer) {
dataType = "ArrayBuffer";
dataSize = event.data.byteLength;
} else if (event.data instanceof Blob) {
dataType = "Blob";
dataSize = event.data.size;
} else if (event.data instanceof Uint8Array) {
dataType = "Uint8Array";
dataSize = event.data.byteLength;
} else if (ArrayBuffer.isView(event.data)) {
dataType = "TypedArray";
dataSize = event.data.byteLength;
} else {
// Detailed unknown type debug information
dataType = `Unknown(${Object.prototype.toString.call(event.data)})`;
dataSize =
event.data?.length || event.data?.size || event.data?.byteLength || 0;
}
if (this.onDataReceived) {
this.onDataReceived(event.data, peerId);
}
};
dataChannel.onclose = () =>
dataChannel.onerror = (error) => {
// Check if this is a user-initiated disconnect (not a real error)
// The error parameter is an Event object, not an Error object
const errorTarget = error.target as RTCDataChannel;
const isUserDisconnect =
errorTarget?.readyState === "closed" ||
error.type === "error";
if (isUserDisconnect) {
this.log("log", `Data channel closed by user for peer ${peerId}`, {
error,
});
} else {
this.log("error", `Data channel error for peer ${peerId}`, { error });
}
};
dataChannel.onclose = () => {
if (developmentEnv === "development") {
postLogToBackend(`DataChannel closed for peer: ${peerId}`);
}
this.log("log", `Data channel with ${peerId} closed.`);
};
}
// Join a room. sendInitiatorOnline indicates whether to send "initiator online" message after joining.
public async joinRoom(
@@ -390,7 +442,7 @@ export default class BaseWebRTC {
roomId: this.roomId,
});
}
if (developmentEnv === "true")
if (developmentEnv === "development")
postLogToBackend(
`peerId:${this.socket.id} Successfully joined room: ${response.roomId},isInitiator:${this.isInitiator},isInRoom:${this.isInRoom}`
);
@@ -398,7 +450,7 @@ export default class BaseWebRTC {
} else {
this.isInRoom = false;
this.roomId = null;
if (developmentEnv === "true")
if (developmentEnv === "development")
postLogToBackend(`Failed to join room,message:${response.message}`);
this.fireError("Failed to join room", { message: response.message });
reject(new Error(response.message));
@@ -431,26 +483,75 @@ export default class BaseWebRTC {
}
}
// Send to a specific peer
protected sendToPeer(data: any, peerId: string): boolean {
public sendToPeer(data: any, peerId: string): boolean {
const dataChannel = this.dataChannels.get(peerId);
if (dataChannel?.readyState === "open") {
dataChannel.send(data);
return true;
try {
// Firefox compatibility debugging: Log sending details
const _dataType =
typeof data === "string"
? "string"
: data instanceof ArrayBuffer
? "ArrayBuffer"
: typeof data;
const _dataSize =
typeof data === "string"
? data.length
: data instanceof ArrayBuffer
? data.byteLength
: 0;
// if (developmentEnv === "development")
// postLogToBackend(
// `sendToPeer - type: ${dataType}, size: ${dataSize}, bufferedAmount: ${dataChannel.bufferedAmount}`
// );
dataChannel.send(data);
return true;
} catch (error) {
postLogToBackend(`sendToPeer error: ${error}`);
this.log("error", `Error sending data to peer ${peerId}`, { error });
return false;
}
}
postLogToBackend(
`DataChannel not ready - peerId: ${peerId}, state: ${
dataChannel?.readyState || "undefined"
}`
);
this.log("warn", `Data channel not ready for peer ${peerId}. Retrying...`);
this.retryDataSend(data, peerId);
return false;
return this.retryDataSend(data, peerId);
}
protected retryDataSend(data: any, peerId: string): void {
protected retryDataSend(data: any, peerId: string): boolean {
// Check if peer has gracefully disconnected - no need to retry
if (this.gracefullyDisconnectedPeers.has(peerId)) {
this.log(
"log",
`Peer ${peerId} has gracefully disconnected, skipping retry`
);
return false;
}
const maxRetries = 5;
let retryCount = 0;
let ret = false;
const attemptSend = () => {
// Check again in case peer disconnected during retry
if (this.gracefullyDisconnectedPeers.has(peerId)) {
this.log(
"log",
`Peer ${peerId} gracefully disconnected during retry, stopping`
);
return;
}
const dataChannel = this.dataChannels.get(peerId);
if (dataChannel?.readyState === "open") {
dataChannel.send(data);
ret = true;
} else if (retryCount < maxRetries) {
retryCount++;
this.log(
@@ -466,6 +567,15 @@ export default class BaseWebRTC {
};
setTimeout(attemptSend, 100);
return ret;
}
/**
* Mark a peer as gracefully disconnected to prevent unnecessary retries
*/
public markPeerGracefullyDisconnected(peerId: string): void {
this.gracefullyDisconnectedPeers.add(peerId);
this.log("log", `Marked peer ${peerId} as gracefully disconnected`);
}
protected async closeDataChannel(peerId: string): Promise<void> {
@@ -512,6 +622,7 @@ export default class BaseWebRTC {
this.isPeerDisconnected = false;
this.isSocketDisconnected = false;
this.reconnectionInProgress = false;
this.gracefullyDisconnectedPeers.clear(); // Clear graceful disconnect tracking
this.log(
"log",
@@ -519,7 +630,7 @@ export default class BaseWebRTC {
);
}
// Abstract method declaration
protected createDataChannel(peerId: string) {
protected createDataChannel(_peerId: string) {
throw new Error("createDataChannel must be implemented by subclass");
}
}
+9 -2
View File
@@ -12,6 +12,8 @@ const withMDX = createMDX({
const nextConfig = {
pageExtensions: ['ts', 'tsx', 'js', 'jsx', 'md', 'mdx'],
images: {
// Disable optimization inside Docker to avoid container loopback fetch failures (502)
unoptimized: process.env.NEXT_IMAGE_UNOPTIMIZED === 'true',
remotePatterns: [
{
protocol: 'https',
@@ -20,7 +22,12 @@ const nextConfig = {
},
]
},
// 启用standalone输出模式,用于Docker部署
output: 'standalone',
// 禁用telemetry
experimental: {
instrumentationHook: true,
},
}
export default withMDX(nextConfig);
export default withMDX(nextConfig);
+10 -10
View File
@@ -5,7 +5,7 @@
"scripts": {
"dev": "next dev -H 0.0.0.0 -p 3002",
"build": "next build",
"start": "next start -p 3000",
"start": "next start -p 3002",
"lint": "next lint"
},
"dependencies": {
@@ -24,8 +24,8 @@
"@types/hast": "^3.0.4",
"@types/mdast": "^4.0.4",
"@types/negotiator": "^0.6.3",
"@types/node": "^20",
"@types/react-dom": "^18",
"@types/node": "^20.14.13",
"@types/react-dom": "^18.3.0",
"@types/unist": "^3.0.3",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
@@ -39,8 +39,8 @@
"next-mdx-remote": "^5.0.0",
"next-themes": "^0.3.0",
"qrcode.react": "^4.0.1",
"react": "^18",
"react-dom": "^18",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-intersection-observer": "^9.16.0",
"remark-gfm": "^4.0.0",
"sharp": "^0.33.5",
@@ -52,11 +52,11 @@
"zustand": "^5.0.7"
},
"devDependencies": {
"@types/react": "^18.3.18",
"eslint": "^8",
"@types/react": "^18.3.22",
"eslint": "^8.57.0",
"eslint-config-next": "14.2.5",
"postcss": "^8",
"tailwindcss": "^3.4.1",
"typescript": "^5"
"postcss": "^8.4.40",
"tailwindcss": "^3.4.7",
"typescript": "^5.5.4"
}
}
Generated Executable → Regular
+13 -13
View File
@@ -54,10 +54,10 @@ importers:
specifier: ^0.6.3
version: 0.6.3
'@types/node':
specifier: ^20
specifier: ^20.14.13
version: 20.14.13
'@types/react-dom':
specifier: ^18
specifier: ^18.3.0
version: 18.3.0
'@types/unist':
specifier: ^3.0.3
@@ -99,10 +99,10 @@ importers:
specifier: ^4.0.1
version: 4.0.1(react@18.3.1)
react:
specifier: ^18
specifier: ^18.3.1
version: 18.3.1
react-dom:
specifier: ^18
specifier: ^18.3.1
version: 18.3.1(react@18.3.1)
react-intersection-observer:
specifier: ^9.16.0
@@ -133,22 +133,22 @@ importers:
version: 5.0.7(@types/react@18.3.22)(react@18.3.1)
devDependencies:
'@types/react':
specifier: ^18.3.18
specifier: ^18.3.22
version: 18.3.22
eslint:
specifier: ^8
specifier: ^8.57.0
version: 8.57.0
eslint-config-next:
specifier: 14.2.5
version: 14.2.5(eslint@8.57.0)(typescript@5.5.4)
postcss:
specifier: ^8
specifier: ^8.4.40
version: 8.4.40
tailwindcss:
specifier: ^3.4.1
specifier: ^3.4.7
version: 3.4.7
typescript:
specifier: ^5
specifier: ^5.5.4
version: 5.5.4
packages:
@@ -1367,8 +1367,8 @@ packages:
resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==}
engines: {node: '>= 6'}
caniuse-lite@1.0.30001735:
resolution: {integrity: sha512-EV/laoX7Wq2J9TQlyIXRxTJqIw4sxfXS4OYgudGxBYRuTv0q7AM6yMEpU/Vo1I94thg9U6EZ2NfZx9GJq83u7w==}
caniuse-lite@1.0.30001745:
resolution: {integrity: sha512-ywt6i8FzvdgrrrGbr1jZVObnVv6adj+0if2/omv9cmR2oiZs30zL4DIyaptKcbOrBdOIc74QTMoJvSE2QHh5UQ==}
ccount@2.0.1:
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
@@ -4730,7 +4730,7 @@ snapshots:
camelcase-css@2.0.1: {}
caniuse-lite@1.0.30001735: {}
caniuse-lite@1.0.30001745: {}
ccount@2.0.1: {}
@@ -6552,7 +6552,7 @@ snapshots:
'@next/env': 14.2.5
'@swc/helpers': 0.5.5
busboy: 1.6.0
caniuse-lite: 1.0.30001735
caniuse-lite: 1.0.30001745
graceful-fs: 4.2.11
postcss: 8.4.31
react: 18.3.1
+19 -26
View File
@@ -2,14 +2,14 @@ import { create } from "zustand";
import { CustomFile, FileMeta } from "@/types/webrtc";
interface FileTransferState {
// 房间相关状态
// Room-related state
shareRoomId: string;
initShareRoomId: string;
shareLink: string;
shareRoomStatusText: string;
retrieveRoomStatusText: string;
// WebRTC 连接状态 - 发送方
// WebRTC connection state - Sender
shareConnectionState:
| "idle"
| "connecting"
@@ -19,7 +19,7 @@ interface FileTransferState {
isSenderInRoom: boolean;
sharePeerCount: number;
// WebRTC 连接状态 - 接收方
// WebRTC connection state - Receiver
retrieveConnectionState:
| "idle"
| "connecting"
@@ -30,36 +30,36 @@ interface FileTransferState {
retrievePeerCount: number;
senderDisconnected: boolean;
// 文件传输状态
// File transfer state
shareContent: string;
sendFiles: CustomFile[];
retrievedContent: string;
retrievedFiles: CustomFile[];
retrievedFileMetas: FileMeta[];
// 传输进度状态
// Transfer progress state
sendProgress: Record<string, any>;
receiveProgress: Record<string, any>;
isAnyFileTransferring: boolean;
// UI 状态
// UI state
activeTab: "send" | "retrieve";
retrieveRoomIdInput: string;
isDragging: boolean;
// 消息状态
// Message state
shareMessage: string;
retrieveMessage: string;
// Actions
// 房间相关 actions
// Room-related actions
setShareRoomId: (id: string) => void;
setInitShareRoomId: (id: string) => void;
setShareLink: (link: string) => void;
setShareRoomStatusText: (text: string) => void;
setRetrieveRoomStatusText: (text: string) => void;
// WebRTC 连接相关 actions
// WebRTC connection-related actions
setShareConnectionState: (
state: "idle" | "connecting" | "connected" | "disconnected" | "failed"
) => void;
@@ -72,7 +72,7 @@ interface FileTransferState {
setRetrievePeerCount: (count: number) => void;
setSenderDisconnected: (disconnected: boolean) => void;
// 文件传输相关 actions
// File transfer-related actions
setShareContent: (content: string) => void;
setSendFiles: (files: CustomFile[]) => void;
addSendFiles: (files: CustomFile[]) => void;
@@ -82,7 +82,7 @@ interface FileTransferState {
setRetrievedFileMetas: (metas: FileMeta[]) => void;
addRetrievedFile: (file: CustomFile) => void;
// 传输进度相关 actions
// Transfer progress-related actions
setSendProgress: (progress: Record<string, any>) => void;
setReceiveProgress: (progress: Record<string, any>) => void;
updateSendProgress: (
@@ -99,23 +99,23 @@ interface FileTransferState {
clearReceiveProgress: (fileId: string, peerId: string) => void;
setIsAnyFileTransferring: (transferring: boolean) => void;
// UI 状态相关 actions
// UI state-related actions
setActiveTab: (tab: "send" | "retrieve") => void;
setRetrieveRoomIdInput: (input: string) => void;
setIsDragging: (dragging: boolean) => void;
// 消息相关 actions
// Message-related actions
setShareMessage: (message: string) => void;
setRetrieveMessage: (message: string) => void;
setRetrieveRoomId: (input: string) => void;
// 重置相关 actions
// Reset-related actions
resetReceiverState: () => void;
resetSenderApp: () => void;
}
export const useFileTransferStore = create<FileTransferState>()((set, get) => ({
// 初始状态
// Initial state
shareRoomId: "",
initShareRoomId: "",
shareLink: "",
@@ -142,14 +142,14 @@ export const useFileTransferStore = create<FileTransferState>()((set, get) => ({
shareMessage: "",
retrieveMessage: "",
// Actions 实现
// Actions implementation
setShareRoomId: (id) => set({ shareRoomId: id }),
setInitShareRoomId: (id) => set({ initShareRoomId: id }),
setShareLink: (link) => set({ shareLink: link }),
setShareRoomStatusText: (text) => set({ shareRoomStatusText: text }),
setRetrieveRoomStatusText: (text) => set({ retrieveRoomStatusText: text }),
// WebRTC 连接相关 actions
// WebRTC connection-related actions
setShareConnectionState: (state) => set({ shareConnectionState: state }),
setIsSenderInRoom: (isInRoom) => set({ isSenderInRoom: isInRoom }),
setSharePeerCount: (count) => set({ sharePeerCount: count }),
@@ -238,18 +238,11 @@ export const useFileTransferStore = create<FileTransferState>()((set, get) => ({
setRetrieveMessage: (message) => set({ retrieveMessage: message }),
resetReceiverState: () => {
// 🔧 清理 FileReceiver 的内部状态(通过 Service 层)
try {
const { webrtcService } = require("@/lib/webrtcService");
webrtcService.fileReceiver.gracefulShutdown();
} catch (error) {
console.warn(`[DEBUG] ⚠️ 清理 FileReceiver 状态失败:`, error);
}
// 🔧 Only reset Store state - FileReceiver cleanup is handled by webrtcService.leaveRoom()
set({
retrievedContent: "",
retrievedFiles: [],
retrievedFileMetas: [], // 清空 Store 中的文件元数据
retrievedFileMetas: [], // Clear file metadata in Store
retrievePeerCount: 0,
senderDisconnected: false,
receiveProgress: {},
+10 -5
View File
@@ -174,9 +174,11 @@ export type FileTransferButton = {
CurrentFileTransferring_tips: string;
OtherFileTransferring_tips: string;
download_tips: string;
PendingSave_tips: string;
Saved_dis: string;
Waiting_dis: string;
Download_dis: string;
Save_dis: string;
};
export type FileListDisplay = {
@@ -191,9 +193,6 @@ export type FileListDisplay = {
PopupDialog_description: string;
chooseSavePath_tips: string;
chooseSavePath_dis: string;
safeSave_dis: string;
safeSave_tooltip: string;
safeSaveSuccessMsg: string;
};
export type RetrieveMethod = {
@@ -243,7 +242,8 @@ export type ClipboardAppHtml = {
Copy_dis: string;
inputRoomIdprompt: string;
joinRoomBtn: string;
generateRoomId_tips: string;
generateSimpleId_tips: string;
generateRandomId_tips: string;
readClipboardToRoomId: string;
enterRoomID_placeholder: string;
retrieveMethod: string;
@@ -263,12 +263,17 @@ export type ClipboardApp = {
waitting_tips: string;
joinRoom: JoinRoom;
pickSaveMsg: string;
pickSaveUnsupported: string;
pickSaveSuccess: string;
pickSaveError: string;
roomStatus: RoomStatus;
html: ClipboardAppHtml;
fileExistMsg?: string;
noFilesForFolderMsg?: string;
zipError?: string;
fileNotFoundMsg?: string;
confirmLeaveWhileTransferring: string;
leaveWhileTransferringSuccess: string;
};
export type Home = {
@@ -305,4 +310,4 @@ export type Text = {
export type Messages = {
meta: Meta;
text: Text;
};
};
+27 -13
View File
@@ -29,11 +29,6 @@ export interface FileRequest {
offset?: number; // Optional: byte offset to resume from
}
export interface FileAck {
type: "fileAck";
fileId: string;
}
export interface StringMetadata {
type: "stringMetadata";
length: number;
@@ -46,24 +41,44 @@ export interface StringChunk {
total: number;
}
export interface FileEnd {
type: "fileEnd";
// Receiver-initiated completion confirmation message
export interface FileReceiveComplete {
type: "fileReceiveComplete";
fileId: string;
receivedSize: number;
receivedChunks: number;
storeUpdated: boolean; // Confirm Store has been updated
}
export interface FolderComplete {
type: "FolderComplete";
export interface FolderReceiveComplete {
type: "folderReceiveComplete";
folderName: string;
completedFileIds: string[];
allStoreUpdated: boolean; // Confirm all files have been added to Store
}
// 🚀 New: Chunk metadata structure embedded in data packets
export interface EmbeddedChunkMeta {
chunkIndex: number; // Data chunk index, starting from 0
totalChunks: number; // Total number of data chunks
chunkSize: number; // Data chunk size (excluding metadata portion)
isLastChunk: boolean; // Whether this is the last data chunk
fileOffset: number; // Offset in the file
fileId: string; // File ID, used for matching
}
// Note: EmbeddedChunkMeta is not in WebRTCMessage as it is embedded within binary data
// 🚀 Binary structure of fused packets:
// [4 bytes: metadata length] + [JSON metadata] + [actual chunk data]
// All file transfers use this format uniformly to completely solve Firefox out-of-order issues
export type WebRTCMessage =
| fileMetadata
| FileRequest
| FileAck
| StringMetadata
| StringChunk
| FileEnd
| FolderComplete;
| FileReceiveComplete
| FolderReceiveComplete;
export interface FolderMeta {
totalSize: number;
@@ -96,5 +111,4 @@ export interface FileHandlers {
string: (data: any, peerId: string) => void;
stringMetadata: (data: any, peerId: string) => void;
fileMeta: (data: any, peerId: string) => void;
fileEnd: (data: any) => Promise<void>;
}
+218
View File
@@ -0,0 +1,218 @@
#!/bin/bash
# Color definitions
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Test result counters
TESTS_PASSED=0
TESTS_FAILED=0
TOTAL_TESTS=0
# Logging helpers
log_info() {
echo -e "${BLUE}$1${NC}"
}
log_success() {
echo -e "${GREEN}$1${NC}"
TESTS_PASSED=$((TESTS_PASSED + 1))
}
log_error() {
echo -e "${RED}$1${NC}"
TESTS_FAILED=$((TESTS_FAILED + 1))
}
log_warning() {
echo -e "${YELLOW}⚠️ $1${NC}"
}
# Test functions
test_api() {
local url="$1"
local description="$2"
local expected_status="${3:-200}"
TOTAL_TESTS=$((TOTAL_TESTS + 1))
echo ""
log_info "Test: $description"
log_info "URL: $url"
# Send request and capture response
response=$(curl -s -w "\n%{http_code}" "$url" 2>/dev/null)
if [ $? -ne 0 ]; then
log_error "Request failed - unable to connect to service"
return 1
fi
# Split response body and status code
http_code=$(echo "$response" | tail -n1)
response_body=$(echo "$response" | head -n -1)
# Check HTTP status code
if [ "$http_code" -eq "$expected_status" ]; then
log_success "HTTP status code OK: $http_code"
else
log_error "HTTP status code mismatch: expected $expected_status, got $http_code"
return 1
fi
# Validate JSON format
if echo "$response_body" | jq . >/dev/null 2>&1; then
log_success "Response is valid JSON"
# Pretty-print JSON response
echo -e "${BLUE}Response body:${NC}"
echo "$response_body" | jq .
# Verify required fields
status=$(echo "$response_body" | jq -r '.status // empty')
service=$(echo "$response_body" | jq -r '.service // empty')
timestamp=$(echo "$response_body" | jq -r '.timestamp // empty')
if [ -n "$status" ] && [ -n "$service" ] && [ -n "$timestamp" ]; then
log_success "Contains required fields: status, service, timestamp"
else
log_error "Missing required fields"
return 1
fi
else
log_error "Response is not valid JSON"
echo "Response body: $response_body"
return 1
fi
return 0
}
# Check if service is running
check_service() {
local port="$1"
local service_name="$2"
if nc -z localhost "$port" 2>/dev/null; then
log_success "$service_name is running (port $port)"
return 0
else
log_error "$service_name is not running (port $port)"
return 1
fi
}
# Wait for service to start
wait_for_service() {
local port="$1"
local service_name="$2"
local max_attempts=30
local attempt=0
log_info "Waiting for $service_name to start..."
while [ $attempt -lt $max_attempts ]; do
if nc -z localhost "$port" 2>/dev/null; then
log_success "$service_name started"
return 0
fi
attempt=$((attempt + 1))
echo -n "."
sleep 2
done
log_error "$service_name startup timed out"
return 1
}
# Main test function
main() {
echo -e "${BLUE}=== PrivyDrop Health Check API Tests ===${NC}"
echo ""
# Check required tools
if ! command -v curl &> /dev/null; then
log_error "curl is not installed; please install curl"
exit 1
fi
if ! command -v jq &> /dev/null; then
log_error "jq is not installed; please install jq for JSON parsing"
exit 1
fi
if ! command -v nc &> /dev/null; then
log_error "netcat is not installed; please install nc for port checks"
exit 1
fi
# Check service status
echo -e "${BLUE}=== Check Service Status ===${NC}"
backend_running=false
frontend_running=false
if check_service 3001 "Backend"; then
backend_running=true
fi
if check_service 3002 "Frontend"; then
frontend_running=true
fi
# Show startup hints if services are not running
if [ "$backend_running" = false ]; then
echo ""
log_warning "Backend is not running; please start it:"
echo " cd backend && npm run dev"
echo ""
fi
if [ "$frontend_running" = false ]; then
echo ""
log_warning "Frontend is not running; please start it:"
echo " cd frontend && pnpm dev"
echo ""
fi
# Test backend health check APIs
if [ "$backend_running" = true ]; then
echo -e "${BLUE}=== Test Backend Health Check APIs ===${NC}"
test_api "http://localhost:3001/health" "Backend basic health check"
test_api "http://localhost:3001/api/health" "Backend API path health check"
test_api "http://localhost:3001/health/detailed" "Backend detailed health check"
fi
# Test frontend health check APIs
if [ "$frontend_running" = true ]; then
echo -e "${BLUE}=== Test Frontend Health Check APIs ===${NC}"
test_api "http://localhost:3002/api/health" "Frontend basic health check"
test_api "http://localhost:3002/api/health/detailed" "Frontend detailed health check"
fi
# Test results summary
echo ""
echo -e "${BLUE}=== Test Results Summary ===${NC}"
echo "Total tests: $TOTAL_TESTS"
echo -e "Passed: ${GREEN}$TESTS_PASSED${NC}"
echo -e "Failed: ${RED}$TESTS_FAILED${NC}"
if [ $TESTS_FAILED -eq 0 ]; then
echo -e "${GREEN}🎉 All tests passed!${NC}"
exit 0
else
echo -e "${RED}$TESTS_FAILED test(s) failed${NC}"
exit 1
fi
}
# Trap interrupt signals
trap 'echo -e "\n${YELLOW}Tests interrupted${NC}"; exit 1' INT TERM
# Run main function
main "$@"