-
Notifications
You must be signed in to change notification settings - Fork 2
feat: add ws-signaling-proxy reference implementation #92
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| # Required: Your Decart API key | ||
| DECART_API_KEY=sk-your-api-key-here | ||
|
|
||
| # Optional: Decart WebSocket base URL (default: wss://api3.decart.ai) | ||
| DECART_BASE_URL=wss://api3.decart.ai | ||
|
|
||
| # Optional: Port for the proxy server (default: 8080) | ||
| PORT=8080 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,55 @@ | ||
| # ws-signaling-proxy | ||
|
|
||
| Reference implementation of a WebSocket signaling proxy for Decart's realtime models. Sits between end-user clients and Decart's API, forwarding all signaling messages (SDP offers/answers, ICE candidates, prompts, etc.) while keeping your API key server-side. | ||
|
|
||
| WebRTC media flows directly between the client and Decart — the proxy only handles the control plane. | ||
|
|
||
| ``` | ||
| signaling signaling | ||
| Client <----WebSocket----> Proxy <----WebSocket----> Decart | ||
| | | ||
| Client <----------------WebRTC (direct)--------------> Decart | ||
| audio/video | ||
| ``` | ||
|
|
||
| ## Quick start | ||
|
|
||
| ```bash | ||
| cp .env.example .env # add your DECART_API_KEY | ||
| pnpm install | ||
| pnpm dev # starts proxy on ws://localhost:8080 | ||
| ``` | ||
|
|
||
| Clients connect to: | ||
|
|
||
| ``` | ||
| ws://localhost:8080/v1/stream?model=lucy_2_rt | ||
| ``` | ||
|
|
||
| ## Environment variables | ||
|
|
||
| | Variable | Required | Default | Description | | ||
| |---|---|---|---| | ||
| | `DECART_API_KEY` | Yes | — | Your Decart API key | | ||
| | `DECART_BASE_URL` | No | `wss://api3.decart.ai` | Decart WebSocket endpoint | | ||
| | `PORT` | No | `8080` | Proxy listen port | | ||
|
|
||
| ## Scripts | ||
|
|
||
| | Command | Description | | ||
| |---|---| | ||
| | `pnpm dev` | Start with hot reload | | ||
| | `pnpm build` | Compile TypeScript to `dist/` | | ||
| | `pnpm start` | Run compiled output | | ||
| | `pnpm test:e2e` | Run e2e test (requires `DECART_API_KEY`) | | ||
|
|
||
| ## How it works | ||
|
|
||
| Each client WebSocket connection creates a `ProxySession` that: | ||
|
|
||
| 1. Opens an upstream connection to Decart with the server's API key | ||
| 2. Forwards all messages bidirectionally | ||
| 3. Buffers client messages until the upstream connection is ready | ||
| 4. Propagates close events in both directions | ||
|
|
||
| The proxy does not inspect or modify message contents — it's a transparent pipe with structured logging. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| { | ||
| "name": "ws-signaling-proxy", | ||
| "version": "0.1.0", | ||
| "private": true, | ||
| "type": "module", | ||
| "scripts": { | ||
| "dev": "tsx --watch src/index.ts", | ||
| "build": "tsc", | ||
| "start": "node dist/index.js", | ||
| "typecheck": "tsc --noEmit", | ||
| "lint": "biome check --error-on-warnings", | ||
| "format": "biome check --write --unsafe", | ||
| "format:check": "biome check", | ||
| "test:e2e": "node --test --import tsx test/e2e.ts" | ||
| }, | ||
| "dependencies": { | ||
| "ws": "^8.18.0" | ||
| }, | ||
| "devDependencies": { | ||
| "@biomejs/biome": "2.3.8", | ||
| "@types/node": "^22", | ||
| "@types/ws": "^8", | ||
| "tsx": "^4", | ||
| "typescript": "^5" | ||
| }, | ||
| "packageManager": "pnpm@10.30.1+sha512.3590e550d5384caa39bd5c7c739f72270234b2f6059e13018f975c313b1eb9fefcc09714048765d4d9efe961382c312e624572c0420762bdc5d5940cdf9be73a" | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| import { createServer } from "node:http"; | ||
| import { type WebSocket, WebSocketServer } from "ws"; | ||
| import { ProxySession } from "./proxy-session.js"; | ||
|
|
||
| const DECART_API_KEY = process.env.DECART_API_KEY; | ||
| const DECART_BASE_URL = process.env.DECART_BASE_URL ?? "wss://api3.decart.ai"; | ||
| const PORT = Number(process.env.PORT ?? 8080); | ||
|
|
||
| if (!DECART_API_KEY) { | ||
| console.error("DECART_API_KEY is required"); | ||
| process.exit(1); | ||
| } | ||
|
|
||
| const server = createServer((_req, res) => { | ||
| res.writeHead(200, { "Content-Type": "text/plain" }); | ||
| res.end("ws-signaling-proxy"); | ||
| }); | ||
|
|
||
| const wss = new WebSocketServer({ server }); | ||
|
|
||
| wss.on("connection", (clientWs: WebSocket, req) => { | ||
| // Accept Decart-style URLs: /v1/stream?api_key=...&model=lucy_2_rt | ||
| // The proxy ignores api_key from the client and uses its own. | ||
| const url = new URL(req.url ?? "/", `http://${req.headers.host}`); | ||
| const model = url.searchParams.get("model") ?? "lucy_2_rt"; | ||
|
|
||
| console.log(`[proxy] client connected from ${req.url} (model=${model})`); | ||
|
|
||
| const session = new ProxySession(clientWs, { | ||
| decartApiKey: DECART_API_KEY, | ||
| model, | ||
| decartBaseUrl: DECART_BASE_URL, | ||
| }); | ||
|
|
||
| session.start(); | ||
| }); | ||
|
|
||
| server.listen(PORT, () => { | ||
| console.log(`[proxy] listening on ws://localhost:${PORT}`); | ||
| console.log(`[proxy] connect with: ws://localhost:${PORT}/?model=lucy_2_rt`); | ||
| }); | ||
|
|
||
| const shutdown = () => { | ||
| console.log("\n[proxy] shutting down..."); | ||
| for (const client of wss.clients) { | ||
| client.close(1001, "server shutting down"); | ||
| } | ||
| server.close(() => process.exit(0)); | ||
| }; | ||
|
|
||
| process.on("SIGINT", shutdown); | ||
| process.on("SIGTERM", shutdown); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,158 @@ | ||
| import WebSocket from "ws"; | ||
| import type { IncomingMessage, OutgoingMessage } from "./types.js"; | ||
|
|
||
| export class ProxySession { | ||
| private upstream: WebSocket | null = null; | ||
| private _sessionId: string | null = null; | ||
| private closed = false; | ||
| private upstreamReady = false; | ||
| private pendingMessages: { data: WebSocket.RawData; isBinary: boolean }[] = []; | ||
|
|
||
| constructor( | ||
| private clientWs: WebSocket, | ||
| private config: { | ||
| decartApiKey: string; | ||
| model: string; | ||
| decartBaseUrl: string; | ||
| }, | ||
| ) {} | ||
|
|
||
| get sessionId() { | ||
| return this._sessionId; | ||
| } | ||
|
|
||
| start() { | ||
| const url = `${this.config.decartBaseUrl}/v1/stream?api_key=${this.config.decartApiKey}&model=${this.config.model}`; | ||
| this.upstream = new WebSocket(url); | ||
|
|
||
| this.upstream.on("open", () => { | ||
| console.log(`[proxy] upstream connected (model=${this.config.model})`); | ||
| this.upstreamReady = true; | ||
| for (const { data, isBinary } of this.pendingMessages) { | ||
| this.upstream?.send(data, { binary: isBinary }); | ||
| this.logIncomingMessage(data); | ||
| } | ||
| this.pendingMessages = []; | ||
| }); | ||
|
|
||
| this.upstream.on("error", (err) => { | ||
| console.error(`[proxy] upstream error: ${err.message}`); | ||
| this.close(1011, "upstream connection error"); | ||
| }); | ||
|
|
||
| // Client → Decart (buffer until upstream is open, preserve text/binary frame type) | ||
| this.clientWs.on("message", (data, isBinary) => { | ||
| if (this.upstreamReady && this.upstream?.readyState === WebSocket.OPEN) { | ||
| this.upstream.send(data, { binary: isBinary }); | ||
| this.logIncomingMessage(data); | ||
| } else { | ||
| this.pendingMessages.push({ data, isBinary }); | ||
| } | ||
| }); | ||
|
|
||
| // Decart → Client (preserve text/binary frame type) | ||
| this.upstream.on("message", (data, isBinary) => { | ||
| if (this.clientWs.readyState === WebSocket.OPEN) { | ||
| this.clientWs.send(data, { binary: isBinary }); | ||
| this.logOutgoingMessage(data); | ||
| } | ||
| }); | ||
|
|
||
| // Close propagation | ||
| this.clientWs.on("close", (code, reason) => { | ||
| console.log(`[${this._sessionId ?? "?"}] client disconnected (code=${code})`); | ||
| this.close(code, reason.toString()); | ||
| }); | ||
|
|
||
| this.upstream.on("close", (code, reason) => { | ||
| const reasonStr = reason.toString(); | ||
| console.log( | ||
| `[${this._sessionId ?? "?"}] upstream disconnected (code=${code}${reasonStr ? `, reason=${reasonStr}` : ""})`, | ||
| ); | ||
| this.close(code, reasonStr); | ||
| }); | ||
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Missing client WebSocket error handler crashes processHigh Severity The |
||
|
|
||
| close(code?: number, reason?: string) { | ||
| if (this.closed) return; | ||
| this.closed = true; | ||
|
|
||
| const safeCode = this.sanitizeCloseCode(code); | ||
| if (this.upstream && this.upstream.readyState !== WebSocket.CLOSED) { | ||
| this.upstream.close(safeCode, reason); | ||
| } | ||
| if (this.clientWs.readyState !== WebSocket.CLOSED) { | ||
| this.clientWs.close(safeCode, reason); | ||
| } | ||
| } | ||
|
|
||
| private sanitizeCloseCode(code?: number): number { | ||
| if (code !== undefined && (code === 1000 || code >= 3000)) { | ||
| return code; | ||
| } | ||
| return 1000; | ||
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. sanitizeCloseCode silently converts valid error codes to normalMedium Severity
Additional Locations (1) |
||
|
|
||
| private logIncomingMessage(data: WebSocket.RawData) { | ||
| try { | ||
| const msg = JSON.parse(data.toString()) as IncomingMessage; | ||
| const id = this._sessionId ?? "?"; | ||
| switch (msg.type) { | ||
| case "prompt": | ||
| console.log(`[${id}] → prompt: ${msg.prompt.slice(0, 80)}`); | ||
| break; | ||
| case "set_image": | ||
| console.log(`[${id}] → set_image (has_prompt=${Boolean(msg.prompt)})`); | ||
| break; | ||
| case "offer": | ||
| console.log(`[${id}] → offer`); | ||
| break; | ||
| case "ice-candidate": | ||
| break; // too noisy | ||
| } | ||
| } catch { | ||
| // non-JSON — forwarded as-is | ||
| } | ||
| } | ||
|
|
||
| private logOutgoingMessage(data: WebSocket.RawData) { | ||
| try { | ||
| const msg = JSON.parse(data.toString()) as OutgoingMessage; | ||
| if (msg.type === "session_id") { | ||
| this._sessionId = msg.session_id; | ||
| } | ||
| const id = this._sessionId ?? "?"; | ||
| switch (msg.type) { | ||
| case "session_id": | ||
| console.log(`[${id}] session started (server=${msg.server_ip}:${msg.server_port})`); | ||
| break; | ||
| case "prompt_ack": | ||
| console.log(`[${id}] ← prompt_ack (success=${msg.success})`); | ||
| break; | ||
| case "set_image_ack": | ||
| console.log(`[${id}] ← set_image_ack (success=${msg.success})`); | ||
| break; | ||
| case "generation_started": | ||
| console.log(`[${id}] ← generation started`); | ||
| break; | ||
| case "generation_ended": | ||
| console.log(`[${id}] ← ended: ${msg.reason} (${msg.seconds}s)`); | ||
| break; | ||
| case "error": | ||
| console.error(`[${id}] ← error: ${msg.error}`); | ||
| break; | ||
| case "ice-restart": | ||
| console.log(`[${id}] ← ice-restart`); | ||
| break; | ||
| case "answer": | ||
| console.log(`[${id}] ← answer`); | ||
| break; | ||
| case "generation_tick": | ||
| case "ice-candidate": | ||
| break; // too noisy | ||
| } | ||
| } catch { | ||
| // non-JSON — forwarded as-is | ||
| } | ||
| } | ||
| } | ||


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Unencoded model parameter enables URL injection
High Severity
The
modelvalue comes from client-supplied query parameters viaURLSearchParams.get(), which returns the decoded value. It's then directly interpolated into the upstream URL string without encoding. A malicious client can inject arbitrary query parameters into the upstream Decart API request (e.g.?model=x%26api_key=other) by embedding encoded ampersands. Themodelvalue needs to be passed throughencodeURIComponentbefore interpolation.Additional Locations (1)
packages/ws-signaling-proxy/src/index.ts#L24-L25