Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 39 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,46 @@ jobs:
- name: Test
run: pnpm test

test-ws-signaling-proxy:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./packages/ws-signaling-proxy

steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Install pnpm
uses: pnpm/action-setup@v4.1.0
with:
version: 10.7.1

- name: Set node LTS
uses: actions/setup-node@v4
with:
node-version: lts/*
cache: pnpm

- name: Install
run: pnpm install

- name: Build
run: pnpm build

- name: Lint
run: pnpm lint

- name: Format
run: pnpm format:check

- name: Typecheck
run: pnpm typecheck

release:
needs: [test-sdk, test-proxy]
needs: [test-sdk, test-proxy, test-ws-signaling-proxy]
runs-on: ubuntu-latest
defaults:
run:
Expand Down
8 changes: 8 additions & 0 deletions packages/ws-signaling-proxy/.env.example
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
55 changes: 55 additions & 0 deletions packages/ws-signaling-proxy/README.md
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.
27 changes: 27 additions & 0 deletions packages/ws-signaling-proxy/package.json
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"
}
52 changes: 52 additions & 0 deletions packages/ws-signaling-proxy/src/index.ts
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);
158 changes: 158 additions & 0 deletions packages/ws-signaling-proxy/src/proxy-session.ts
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}`;
Copy link

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 model value comes from client-supplied query parameters via URLSearchParams.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. The model value needs to be passed through encodeURIComponent before interpolation.

Additional Locations (1)

Fix in Cursor Fix in Web

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);
});
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing client WebSocket error handler crashes process

High Severity

The start() method attaches an "error" handler on this.upstream but not on this.clientWs. In Node.js, an EventEmitter that emits an "error" event with no listener throws the error as an uncaught exception, crashing the process. Any client-side network disruption (connection reset, broken pipe, etc.) would bring down the entire proxy for all connected users.

Fix in Cursor Fix in Web


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;
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sanitizeCloseCode silently converts valid error codes to normal

Medium Severity

sanitizeCloseCode only allows code 1000 or >= 3000, but the ws library accepts 1000–1003, 1007–1014, and 3000–4999 as valid close codes. This means the explicit this.close(1011, "upstream connection error") on upstream failure is silently downgraded to 1000 (Normal Closure), so the client never learns the connection was closed due to an error. Additionally, valid forwarded codes like 1001 (Going Away) are lost. The condition also passes through codes >= 5000 which the ws library considers invalid and would throw a RangeError on.

Additional Locations (1)

Fix in Cursor Fix in Web


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
}
}
}
Loading
Loading