Spaces:
Running
Running
Fix hf by merging both service
Browse files- DOCKER_README.md +63 -15
- Dockerfile +7 -20
- docker-compose.yml +6 -6
- src-python/src/main.py +101 -4
- src-python/start_server.py +13 -10
- src/lib/utils/config.ts +24 -13
DOCKER_README.md
CHANGED
@@ -2,35 +2,83 @@
|
|
2 |
|
3 |
This Docker setup provides a containerized environment that runs both the Python backend and Svelte frontend for LeRobot Arena using modern tools like Bun, uv, and box-packager.
|
4 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
5 |
## π Quick Start
|
6 |
|
7 |
-
###
|
8 |
|
9 |
```bash
|
10 |
-
# Build
|
11 |
-
docker-
|
|
|
|
|
|
|
12 |
|
13 |
-
#
|
14 |
-
|
15 |
```
|
16 |
|
17 |
-
### Using Docker
|
18 |
|
19 |
```bash
|
20 |
-
#
|
21 |
-
docker
|
22 |
|
23 |
-
#
|
24 |
-
|
25 |
```
|
26 |
|
27 |
-
## π
|
|
|
|
|
|
|
|
|
28 |
|
29 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
30 |
|
31 |
-
|
32 |
-
- **Backend (Python/FastAPI)**: http://localhost:8080
|
33 |
-
- **Backend API Docs**: http://localhost:8080/docs
|
34 |
|
35 |
## π What's Included
|
36 |
|
|
|
2 |
|
3 |
This Docker setup provides a containerized environment that runs both the Python backend and Svelte frontend for LeRobot Arena using modern tools like Bun, uv, and box-packager.
|
4 |
|
5 |
+
## ποΈ Architecture
|
6 |
+
|
7 |
+
The application consists of:
|
8 |
+
- **Svelte Frontend**: Built as static files and served by FastAPI
|
9 |
+
- **FastAPI Backend**: WebSocket server with HTTP API for robot control
|
10 |
+
- **Single Port**: Both frontend and backend served on port 7860
|
11 |
+
|
12 |
## π Quick Start
|
13 |
|
14 |
+
### Build and Run with Docker
|
15 |
|
16 |
```bash
|
17 |
+
# Build the image
|
18 |
+
docker build -t lerobot-arena .
|
19 |
+
|
20 |
+
# Run the container
|
21 |
+
docker run -p 7860:7860 lerobot-arena
|
22 |
|
23 |
+
# Access the application
|
24 |
+
open http://localhost:7860
|
25 |
```
|
26 |
|
27 |
+
### Using Docker Compose
|
28 |
|
29 |
```bash
|
30 |
+
# Start the application
|
31 |
+
docker-compose up
|
32 |
|
33 |
+
# Access the application
|
34 |
+
open http://localhost:7860
|
35 |
```
|
36 |
|
37 |
+
## π Deployment
|
38 |
+
|
39 |
+
### Hugging Face Spaces
|
40 |
+
|
41 |
+
This project is configured for deployment on Hugging Face Spaces:
|
42 |
|
43 |
+
1. Push to a Hugging Face Space repository
|
44 |
+
2. The Dockerfile will automatically build and serve the application
|
45 |
+
3. Both frontend and backend run on port 7860 (HF Spaces default)
|
46 |
+
4. Environment detection automatically configures URLs for HF Spaces
|
47 |
+
|
48 |
+
### Local Development vs Production
|
49 |
+
|
50 |
+
- **Local Development**: Frontend dev server on 5173, backend on 7860
|
51 |
+
- **Docker/Production**: Both frontend and backend served by FastAPI on 7860
|
52 |
+
- **HF Spaces**: Single FastAPI server on 7860 with automatic HTTPS and domain detection
|
53 |
+
|
54 |
+
## π§ Environment Variables
|
55 |
+
|
56 |
+
- `SPACE_HOST`: Set automatically by Hugging Face Spaces for proper URL configuration
|
57 |
+
- `PORT`: Override the default port (7860)
|
58 |
+
|
59 |
+
## π‘ API Endpoints
|
60 |
+
|
61 |
+
- **Frontend**: `http://localhost:7860/` (Served by FastAPI)
|
62 |
+
- **API**: `http://localhost:7860/api/*`
|
63 |
+
- **WebSocket**: `ws://localhost:7860/ws/*`
|
64 |
+
- **Status**: `http://localhost:7860/status`
|
65 |
+
|
66 |
+
## π οΈ Development
|
67 |
+
|
68 |
+
For local development without Docker:
|
69 |
+
|
70 |
+
```bash
|
71 |
+
# Start backend
|
72 |
+
cd src-python
|
73 |
+
uv sync
|
74 |
+
uv run python start_server.py
|
75 |
+
|
76 |
+
# Start frontend (in another terminal)
|
77 |
+
bun install
|
78 |
+
bun run dev
|
79 |
+
```
|
80 |
|
81 |
+
The frontend dev server (port 5173) will automatically proxy API requests to the backend (port 7860).
|
|
|
|
|
82 |
|
83 |
## π What's Included
|
84 |
|
Dockerfile
CHANGED
@@ -51,24 +51,11 @@ RUN uv sync
|
|
51 |
# Copy built frontend from previous stage with proper ownership
|
52 |
COPY --chown=user --from=frontend-builder /app/build $HOME/app/static-frontend
|
53 |
|
54 |
-
#
|
55 |
WORKDIR $HOME/app
|
56 |
-
|
57 |
-
|
58 |
-
|
59 |
-
|
60 |
-
|
61 |
-
|
62 |
-
echo "β
Both services started!"\n\
|
63 |
-
echo "Backend: http://localhost:8080"\n\
|
64 |
-
echo "Frontend: http://localhost:7860"\n\
|
65 |
-
if [ ! -z "$SPACE_HOST" ]; then\n\
|
66 |
-
echo "π Hugging Face Space: https://$SPACE_HOST"\n\
|
67 |
-
fi\n\
|
68 |
-
wait' > start_services.sh && chmod +x start_services.sh
|
69 |
-
|
70 |
-
# Expose both ports (7860 is default for HF Spaces)
|
71 |
-
EXPOSE 7860 8080
|
72 |
-
|
73 |
-
# Start both services
|
74 |
-
CMD ["./start_services.sh"]
|
|
|
51 |
# Copy built frontend from previous stage with proper ownership
|
52 |
COPY --chown=user --from=frontend-builder /app/build $HOME/app/static-frontend
|
53 |
|
54 |
+
# Set working directory back to app root
|
55 |
WORKDIR $HOME/app
|
56 |
+
|
57 |
+
# Expose port 7860 (HF Spaces default)
|
58 |
+
EXPOSE 7860
|
59 |
+
|
60 |
+
# Start the FastAPI server (serves both frontend and backend)
|
61 |
+
CMD ["sh", "-c", "cd src-python && uv run python start_server.py"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
docker-compose.yml
CHANGED
@@ -2,13 +2,13 @@ version: '3.8'
|
|
2 |
|
3 |
services:
|
4 |
lerobot-arena:
|
5 |
-
build:
|
6 |
-
context: .
|
7 |
-
dockerfile: Dockerfile
|
8 |
ports:
|
9 |
-
- "
|
10 |
-
- "7860:7860" # Svelte frontend (HF Spaces default)
|
11 |
environment:
|
12 |
- NODE_ENV=production
|
13 |
-
|
|
|
|
|
|
|
14 |
restart: unless-stopped
|
|
|
2 |
|
3 |
services:
|
4 |
lerobot-arena:
|
5 |
+
build: .
|
|
|
|
|
6 |
ports:
|
7 |
+
- "7860:7860" # FastAPI server serving both frontend and backend
|
|
|
8 |
environment:
|
9 |
- NODE_ENV=production
|
10 |
+
# volumes:
|
11 |
+
# # Optional: Mount local development files for debugging
|
12 |
+
# - ./src-python:/home/user/app/src-python
|
13 |
+
# - ./static-frontend:/home/user/app/static-frontend
|
14 |
restart: unless-stopped
|
src-python/src/main.py
CHANGED
@@ -6,6 +6,8 @@ from datetime import UTC, datetime
|
|
6 |
|
7 |
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
|
8 |
from fastapi.middleware.cors import CORSMiddleware
|
|
|
|
|
9 |
|
10 |
from .connection_manager import ConnectionManager
|
11 |
from .models import (
|
@@ -60,6 +62,51 @@ app.add_middleware(
|
|
60 |
connection_manager = ConnectionManager()
|
61 |
robot_manager = RobotManager()
|
62 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
63 |
|
64 |
@app.on_event("startup")
|
65 |
async def startup_event():
|
@@ -73,15 +120,65 @@ async def startup_event():
|
|
73 |
|
74 |
|
75 |
@app.get("/")
|
76 |
-
async def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
77 |
return {
|
78 |
"message": "LeRobot Arena Server",
|
79 |
"version": "1.0.0",
|
80 |
"robots_connected": len(robot_manager.robots),
|
81 |
"active_connections": connection_manager.get_connection_count(),
|
|
|
82 |
}
|
83 |
|
84 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
85 |
# ============= ROBOT MANAGEMENT API =============
|
86 |
|
87 |
|
@@ -563,11 +660,11 @@ if __name__ == "__main__":
|
|
563 |
logger = logging.getLogger("lerobot-arena")
|
564 |
logger.info("π Starting LeRobot Arena WebSocket Server...")
|
565 |
|
566 |
-
# Start the server
|
567 |
uvicorn.run(
|
568 |
app,
|
569 |
host="0.0.0.0",
|
570 |
-
port=
|
571 |
log_level="info",
|
572 |
-
reload=False,
|
573 |
)
|
|
|
6 |
|
7 |
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
|
8 |
from fastapi.middleware.cors import CORSMiddleware
|
9 |
+
from fastapi.responses import FileResponse
|
10 |
+
from fastapi.staticfiles import StaticFiles
|
11 |
|
12 |
from .connection_manager import ConnectionManager
|
13 |
from .models import (
|
|
|
62 |
connection_manager = ConnectionManager()
|
63 |
robot_manager = RobotManager()
|
64 |
|
65 |
+
# Mount static files for the frontend
|
66 |
+
# Try different possible locations for the static frontend
|
67 |
+
static_dir_candidates = [
|
68 |
+
os.path.join(
|
69 |
+
os.path.dirname(os.path.dirname(__file__)), "..", "static-frontend"
|
70 |
+
), # From src-python/src
|
71 |
+
os.path.join(
|
72 |
+
os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "static-frontend"
|
73 |
+
), # From src-python
|
74 |
+
"/home/user/app/static-frontend", # HF Spaces absolute path
|
75 |
+
"static-frontend", # Relative to working directory
|
76 |
+
]
|
77 |
+
|
78 |
+
static_dir = None
|
79 |
+
for candidate in static_dir_candidates:
|
80 |
+
if os.path.exists(candidate):
|
81 |
+
static_dir = candidate
|
82 |
+
break
|
83 |
+
|
84 |
+
if static_dir:
|
85 |
+
app.mount("/static", StaticFiles(directory=static_dir), name="static")
|
86 |
+
logger.info(f"π Serving static files from: {static_dir}")
|
87 |
+
else:
|
88 |
+
logger.warning(f"β οΈ Static directory not found in any of: {static_dir_candidates}")
|
89 |
+
|
90 |
+
|
91 |
+
def get_static_dir() -> str | None:
|
92 |
+
"""Get the static directory path"""
|
93 |
+
static_dir_candidates = [
|
94 |
+
os.path.join(
|
95 |
+
os.path.dirname(os.path.dirname(__file__)), "..", "static-frontend"
|
96 |
+
), # From src-python/src
|
97 |
+
os.path.join(
|
98 |
+
os.path.dirname(os.path.dirname(os.path.dirname(__file__))),
|
99 |
+
"static-frontend",
|
100 |
+
), # From src-python
|
101 |
+
"/home/user/app/static-frontend", # HF Spaces absolute path
|
102 |
+
"static-frontend", # Relative to working directory
|
103 |
+
]
|
104 |
+
|
105 |
+
for candidate in static_dir_candidates:
|
106 |
+
if os.path.exists(candidate):
|
107 |
+
return candidate
|
108 |
+
return None
|
109 |
+
|
110 |
|
111 |
@app.on_event("startup")
|
112 |
async def startup_event():
|
|
|
120 |
|
121 |
|
122 |
@app.get("/")
|
123 |
+
async def serve_frontend():
|
124 |
+
"""Serve the main frontend page"""
|
125 |
+
static_dir = get_static_dir()
|
126 |
+
if not static_dir:
|
127 |
+
return {
|
128 |
+
"message": "Frontend not built. Run 'bun run build' to build the frontend."
|
129 |
+
}
|
130 |
+
|
131 |
+
index_file = os.path.join(static_dir, "index.html")
|
132 |
+
if os.path.exists(index_file):
|
133 |
+
return FileResponse(index_file)
|
134 |
+
return {"message": "Frontend not built. Run 'bun run build' to build the frontend."}
|
135 |
+
|
136 |
+
|
137 |
+
@app.get("/status")
|
138 |
+
async def get_status():
|
139 |
+
"""Get server status for health checks"""
|
140 |
return {
|
141 |
"message": "LeRobot Arena Server",
|
142 |
"version": "1.0.0",
|
143 |
"robots_connected": len(robot_manager.robots),
|
144 |
"active_connections": connection_manager.get_connection_count(),
|
145 |
+
"status": "healthy",
|
146 |
}
|
147 |
|
148 |
|
149 |
+
# Serve static assets from the _app directory
|
150 |
+
@app.get("/_app/{path:path}")
|
151 |
+
async def serve_app_assets(path: str):
|
152 |
+
"""Serve Svelte app assets"""
|
153 |
+
static_dir = get_static_dir()
|
154 |
+
if not static_dir:
|
155 |
+
raise HTTPException(status_code=404, detail="Frontend not found")
|
156 |
+
|
157 |
+
file_path = os.path.join(static_dir, "_app", path)
|
158 |
+
if os.path.exists(file_path) and os.path.isfile(file_path):
|
159 |
+
return FileResponse(file_path)
|
160 |
+
raise HTTPException(status_code=404, detail="File not found")
|
161 |
+
|
162 |
+
|
163 |
+
# Catch-all route for client-side routing (SPA fallback)
|
164 |
+
@app.get("/{path:path}")
|
165 |
+
async def serve_spa_fallback(path: str):
|
166 |
+
"""Serve the frontend for client-side routing"""
|
167 |
+
# If it's an API or WebSocket path, let it pass through to other handlers
|
168 |
+
if path.startswith(("api/", "ws/")):
|
169 |
+
raise HTTPException(status_code=404, detail="Not found")
|
170 |
+
|
171 |
+
# For all other paths, serve the index.html (SPA)
|
172 |
+
static_dir = get_static_dir()
|
173 |
+
if not static_dir:
|
174 |
+
raise HTTPException(status_code=404, detail="Frontend not found")
|
175 |
+
|
176 |
+
index_file = os.path.join(static_dir, "index.html")
|
177 |
+
if os.path.exists(index_file):
|
178 |
+
return FileResponse(index_file)
|
179 |
+
raise HTTPException(status_code=404, detail="Frontend not found")
|
180 |
+
|
181 |
+
|
182 |
# ============= ROBOT MANAGEMENT API =============
|
183 |
|
184 |
|
|
|
660 |
logger = logging.getLogger("lerobot-arena")
|
661 |
logger.info("π Starting LeRobot Arena WebSocket Server...")
|
662 |
|
663 |
+
# Start the server on port 7860 for HF Spaces compatibility
|
664 |
uvicorn.run(
|
665 |
app,
|
666 |
host="0.0.0.0",
|
667 |
+
port=7860,
|
668 |
log_level="info",
|
669 |
+
reload=False,
|
670 |
)
|
src-python/start_server.py
CHANGED
@@ -5,34 +5,37 @@ LeRobot Arena WebSocket Server
|
|
5 |
Run with: python start_server.py
|
6 |
"""
|
7 |
|
8 |
-
import uvicorn
|
9 |
import logging
|
10 |
import sys
|
11 |
from pathlib import Path
|
12 |
|
|
|
|
|
13 |
# Add src to path
|
14 |
sys.path.insert(0, str(Path(__file__).parent / "src"))
|
15 |
|
16 |
from src.main import app
|
17 |
|
|
|
18 |
def main():
|
19 |
"""Start the LeRobot Arena server"""
|
20 |
logging.basicConfig(
|
21 |
level=logging.INFO,
|
22 |
-
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
23 |
)
|
24 |
-
|
25 |
logger = logging.getLogger("lerobot-arena")
|
26 |
logger.info("π Starting LeRobot Arena WebSocket Server...")
|
27 |
-
|
28 |
-
# Start the server
|
29 |
uvicorn.run(
|
30 |
-
app,
|
31 |
-
host="0.0.0.0",
|
32 |
-
port=
|
33 |
log_level="info",
|
34 |
-
reload=False # Auto-reload on code changes
|
35 |
)
|
36 |
|
|
|
37 |
if __name__ == "__main__":
|
38 |
-
main()
|
|
|
5 |
Run with: python start_server.py
|
6 |
"""
|
7 |
|
|
|
8 |
import logging
|
9 |
import sys
|
10 |
from pathlib import Path
|
11 |
|
12 |
+
import uvicorn
|
13 |
+
|
14 |
# Add src to path
|
15 |
sys.path.insert(0, str(Path(__file__).parent / "src"))
|
16 |
|
17 |
from src.main import app
|
18 |
|
19 |
+
|
20 |
def main():
|
21 |
"""Start the LeRobot Arena server"""
|
22 |
logging.basicConfig(
|
23 |
level=logging.INFO,
|
24 |
+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
25 |
)
|
26 |
+
|
27 |
logger = logging.getLogger("lerobot-arena")
|
28 |
logger.info("π Starting LeRobot Arena WebSocket Server...")
|
29 |
+
|
30 |
+
# Start the server on port 7860 for HF Spaces compatibility
|
31 |
uvicorn.run(
|
32 |
+
app,
|
33 |
+
host="0.0.0.0",
|
34 |
+
port=7860,
|
35 |
log_level="info",
|
36 |
+
reload=False, # Auto-reload on code changes
|
37 |
)
|
38 |
|
39 |
+
|
40 |
if __name__ == "__main__":
|
41 |
+
main()
|
src/lib/utils/config.ts
CHANGED
@@ -29,7 +29,7 @@ function getSpaceHost(): string | undefined {
|
|
29 |
* Get the base URL for API requests
|
30 |
*/
|
31 |
export function getApiBaseUrl(): string {
|
32 |
-
if (!isBrowser) return 'http://localhost:
|
33 |
|
34 |
// Check for Hugging Face Spaces
|
35 |
const spaceHost = getSpaceHost();
|
@@ -38,22 +38,27 @@ export function getApiBaseUrl(): string {
|
|
38 |
}
|
39 |
|
40 |
// In browser, check current location
|
41 |
-
const { protocol, hostname } = window.location;
|
42 |
|
43 |
-
// If we're on
|
44 |
-
if (hostname === 'localhost' || hostname === '127.0.0.1'
|
45 |
-
|
|
|
|
|
|
|
|
|
|
|
46 |
}
|
47 |
|
48 |
-
//
|
49 |
-
return `${protocol}//${hostname}:
|
50 |
}
|
51 |
|
52 |
/**
|
53 |
* Get the WebSocket URL for real-time connections
|
54 |
*/
|
55 |
export function getWebSocketBaseUrl(): string {
|
56 |
-
if (!isBrowser) return 'ws://localhost:
|
57 |
|
58 |
// Check for Hugging Face Spaces
|
59 |
const spaceHost = getSpaceHost();
|
@@ -61,18 +66,24 @@ export function getWebSocketBaseUrl(): string {
|
|
61 |
return `wss://${spaceHost}`;
|
62 |
}
|
63 |
|
64 |
-
const { protocol, hostname } = window.location;
|
65 |
|
66 |
-
// If we're on localhost
|
67 |
if (hostname === 'localhost' || hostname === '127.0.0.1') {
|
68 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
69 |
}
|
70 |
|
71 |
// For HTTPS sites, use WSS; for HTTP sites, use WS
|
72 |
const wsProtocol = protocol === 'https:' ? 'wss:' : 'ws:';
|
73 |
|
74 |
-
//
|
75 |
-
return `${wsProtocol}//${hostname}:
|
76 |
}
|
77 |
|
78 |
/**
|
|
|
29 |
* Get the base URL for API requests
|
30 |
*/
|
31 |
export function getApiBaseUrl(): string {
|
32 |
+
if (!isBrowser) return 'http://localhost:7860';
|
33 |
|
34 |
// Check for Hugging Face Spaces
|
35 |
const spaceHost = getSpaceHost();
|
|
|
38 |
}
|
39 |
|
40 |
// In browser, check current location
|
41 |
+
const { protocol, hostname, port } = window.location;
|
42 |
|
43 |
+
// If we're on the same host and port, use same origin (both frontend and backend on same server)
|
44 |
+
if (hostname === 'localhost' || hostname === '127.0.0.1') {
|
45 |
+
// In development, frontend might be on 5173 and backend on 7860
|
46 |
+
if (port === '5173') {
|
47 |
+
return 'http://localhost:7860';
|
48 |
+
}
|
49 |
+
// If frontend is served from backend (port 7860), use same origin
|
50 |
+
return `${protocol}//${hostname}:${port}`;
|
51 |
}
|
52 |
|
53 |
+
// For production, use same origin (both served from same FastAPI server)
|
54 |
+
return `${protocol}//${hostname}${port ? `:${port}` : ''}`;
|
55 |
}
|
56 |
|
57 |
/**
|
58 |
* Get the WebSocket URL for real-time connections
|
59 |
*/
|
60 |
export function getWebSocketBaseUrl(): string {
|
61 |
+
if (!isBrowser) return 'ws://localhost:7860';
|
62 |
|
63 |
// Check for Hugging Face Spaces
|
64 |
const spaceHost = getSpaceHost();
|
|
|
66 |
return `wss://${spaceHost}`;
|
67 |
}
|
68 |
|
69 |
+
const { protocol, hostname, port } = window.location;
|
70 |
|
71 |
+
// If we're on localhost
|
72 |
if (hostname === 'localhost' || hostname === '127.0.0.1') {
|
73 |
+
// In development, frontend might be on 5173 and backend on 7860
|
74 |
+
if (port === '5173') {
|
75 |
+
return 'ws://localhost:7860';
|
76 |
+
}
|
77 |
+
// If frontend is served from backend (port 7860), use same origin
|
78 |
+
const wsProtocol = protocol === 'https:' ? 'wss:' : 'ws:';
|
79 |
+
return `${wsProtocol}//${hostname}:${port}`;
|
80 |
}
|
81 |
|
82 |
// For HTTPS sites, use WSS; for HTTP sites, use WS
|
83 |
const wsProtocol = protocol === 'https:' ? 'wss:' : 'ws:';
|
84 |
|
85 |
+
// For production, use same origin (both served from same FastAPI server)
|
86 |
+
return `${wsProtocol}//${hostname}${port ? `:${port}` : ''}`;
|
87 |
}
|
88 |
|
89 |
/**
|