wilooper commited on
Commit
87cb242
Β·
1 Parent(s): 708041f

initial commit

Browse files
Dockerfile ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM node:20-alpine AS builder
2
+ WORKDIR /app
3
+
4
+ # Install deps first (layer cache friendly)
5
+ COPY package*.json ./
6
+ RUN npm ci --only=production=false
7
+
8
+ COPY . .
9
+ RUN npm run build
10
+
11
+ # ── Production image ──────────────────────────────────────────────────────────
12
+ FROM node:20-alpine AS runner
13
+ WORKDIR /app
14
+ ENV NODE_ENV=production
15
+
16
+ # Non-root user for HF container security
17
+ RUN addgroup -S appgroup && adduser -S appuser -G appgroup
18
+
19
+ # Copy only what we need
20
+ COPY --from=builder /app/dist ./dist
21
+ COPY --from=builder /app/node_modules ./node_modules
22
+ COPY --from=builder /app/package.json ./package.json
23
+
24
+ USER appuser
25
+
26
+ # HF Spaces requires port 7860
27
+ EXPOSE 7860
28
+
29
+ HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
30
+ CMD wget -qO- http://localhost:7860/health || exit 1
31
+
32
+ CMD ["node", "dist/index.js"]
README.md CHANGED
@@ -1,11 +1,194 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
- title: Chatapi
3
- emoji: πŸ†
4
- colorFrom: yellow
5
- colorTo: yellow
6
- sdk: docker
7
- pinned: false
8
- short_description: A simple chatting api
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  ---
10
 
11
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # P2P Signal Server
2
+
3
+ Real-time WebRTC signaling server for 10,000 concurrent users.
4
+ Runs on **Hugging Face Spaces Free Tier** (Docker, port 7860).
5
+
6
+ ---
7
+
8
+ ## Architecture
9
+
10
+ ```
11
+ Client A ──WS──► HF Space (Signal Server)
12
+ β”‚
13
+ β”œβ”€ Redis Bucket 1 (Presence) uidβ†’socketId, online/offline
14
+ β”œβ”€ Redis Bucket 2 (Msg Queue) offline_msgs:{uid} list
15
+ β”œβ”€ MongoDB Atlas user profiles, friend lists
16
+ └─ Supabase Storage media files (server sees 0 bytes)
17
+
18
+ Client A ◄──────────────────────────────► Client B (WebRTC P2P after handshake)
19
+ ◄── WS relay fallback if ICE fails ─────────►
20
+ ```
21
+
22
+ ---
23
+
24
+ ## Quick Start
25
+
26
+ ### 1. Clone & install
27
+
28
+ ```bash
29
+ git clone https://github.com/you/p2p-signal-server
30
+ cd p2p-signal-server
31
+ npm install
32
+ cp .env.example .env
33
+ # Fill in all values in .env
34
+ npm run dev
35
+ ```
36
+
37
+ ### 2. Build & run (Docker)
38
+
39
+ ```bash
40
+ docker build -t p2p-signal .
41
+ docker run --env-file .env -p 7860:7860 p2p-signal
42
+ ```
43
+
44
  ---
45
+
46
+ ## Required Secrets
47
+
48
+ Add all of these to **HF Spaces β†’ Settings β†’ Repository Secrets**.
49
+
50
+ | Variable | How to get it |
51
+ |---|---|
52
+ | `JWT_SECRET` | `node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"` |
53
+ | `REDIS_PRESENCE_URL` | [Upstash](https://upstash.com) β†’ Create Redis DB #1 β†’ Copy Redis URL |
54
+ | `REDIS_MESSAGE_URL` | Upstash β†’ Create Redis DB **#2** (separate!) β†’ Copy Redis URL |
55
+ | `MONGO_URI` | [MongoDB Atlas](https://cloud.mongodb.com) β†’ M0 Free β†’ Connect β†’ Driver URL |
56
+ | `SUPABASE_URL` | [Supabase](https://supabase.com) β†’ Project Settings β†’ API β†’ Project URL |
57
+ | `SUPABASE_SERVICE_KEY` | Supabase β†’ Project Settings β†’ API β†’ `service_role` key |
58
+ | `SUPABASE_BUCKET` | Create a bucket named `media` in Supabase Storage, set to **public** |
59
+ | `TURN_URL` | [metered.ca/tools/openrelay](https://www.metered.ca/tools/openrelay/) free TURN |
60
+ | `TURN_USERNAME` | From the same TURN provider |
61
+ | `TURN_CREDENTIAL` | From the same TURN provider |
62
+ | `ALLOWED_ORIGINS` | Your frontend domain(s), comma-separated e.g. `https://myapp.vercel.app` |
63
+
64
+ Optional (have defaults):
65
+
66
+ | Variable | Default | Notes |
67
+ |---|---|---|
68
+ | `PORT` | `7860` | Must stay 7860 for HF Spaces |
69
+ | `STUN_SERVERS` | Google STUN | Comma-separated stun: URIs |
70
+ | `PRESENCE_TTL_S` | `60` | Redis TTL for online status |
71
+ | `OFFLINE_MSG_TTL_S` | `604800` | 7 days β€” how long to hold queued msgs |
72
+ | `HEARTBEAT_INTERVAL_MS` | `30000` | Client sends ping every 30s |
73
+ | `MAX_FILE_SIZE_MB` | `99` | Max single file size |
74
+ | `MAX_FILE_COUNT` | `10` | Max files per upload request |
75
+
76
  ---
77
 
78
+ ## HF Spaces Deploy
79
+
80
+ 1. Create a new Space β†’ **Docker** SDK
81
+ 2. Push this repo to the Space's git remote
82
+ 3. Add all secrets in Space settings
83
+ 4. The Space will auto-build via the Dockerfile
84
+
85
+ Health check: `GET /health` β†’ `{ status: 'ok', connections: N, ts: epoch }`
86
+
87
+ ---
88
+
89
+ ## WebSocket Protocol
90
+
91
+ Connect: `wss://your-space.hf.space?token=<JWT>`
92
+
93
+ ### Client β†’ Server
94
+
95
+ ```jsonc
96
+ // Heartbeat (every 30s)
97
+ { "type": "heartbeat", "payload": {} }
98
+
99
+ // Send a text message
100
+ { "type": "message", "payload": { "toUid": "bob", "type": "text", "content": "Hello" } }
101
+
102
+ // Send a media message (after uploading to Supabase)
103
+ { "type": "message", "payload": { "toUid": "bob", "type": "media", "content": "https://...", "mediaType": "image/jpeg" } }
104
+
105
+ // Request signed upload URLs
106
+ { "type": "get-upload-url", "requestId": "abc123",
107
+ "payload": { "files": [{ "fileName": "photo.jpg", "mimeType": "image/jpeg", "sizeBytes": 1048576 }] } }
108
+
109
+ // WebRTC offer
110
+ { "type": "offer", "payload": { "toUid": "bob", "sdp": { ...RTCSessionDescription } } }
111
+
112
+ // WebRTC answer
113
+ { "type": "answer", "payload": { "toUid": "alice", "sdp": { ...RTCSessionDescription } } }
114
+
115
+ // ICE candidate
116
+ { "type": "ice-candidate", "payload": { "toUid": "bob", "candidate": { ...RTCIceCandidate } } }
117
+
118
+ // WS relay fallback (when ICE fails)
119
+ { "type": "relay", "payload": { "toUid": "bob", "data": { ...anything } } }
120
+
121
+ // Friend actions
122
+ { "type": "friend", "payload": { "action": "send-request", "targetUid": "bob" } }
123
+ { "type": "friend", "payload": { "action": "accept", "targetUid": "alice" } }
124
+ { "type": "friend", "payload": { "action": "reject", "targetUid": "alice" } }
125
+ { "type": "friend", "payload": { "action": "remove", "targetUid": "bob" } }
126
+ { "type": "friend", "payload": { "action": "list" } }
127
+ { "type": "friend", "payload": { "action": "status", "targetUid": "bob" } }
128
+ ```
129
+
130
+ ### Server β†’ Client
131
+
132
+ ```jsonc
133
+ { "type": "connected", "payload": { "uid": "...", "socketId": "..." } }
134
+ { "type": "ice-servers", "payload": { "iceServers": [...] } }
135
+ { "type": "offline-flush", "payload": { "messages": [...], "count": N } }
136
+ { "type": "message", "payload": { "fromUid": "...", "content": "...", ... } }
137
+ { "type": "peer-online", "payload": { "uid": "bob" } }
138
+ { "type": "peer-offline", "payload": { "uid": "bob" } }
139
+ { "type": "upload-url", "payload": { "urls": [{ "signedUrl", "objectPath", "publicUrl" }] } }
140
+ { "type": "offer", "payload": { "fromUid": "...", "sdp": {...} } }
141
+ { "type": "answer", "payload": { "fromUid": "...", "sdp": {...} } }
142
+ { "type": "ice-candidate", "payload": { "fromUid": "...", "candidate": {...} } }
143
+ { "type": "relay", "payload": { "fromUid": "...", "data": {...} } }
144
+ { "type": "error", "payload": { "message": "..." } }
145
+ ```
146
+
147
+ ---
148
+
149
+ ## File Upload Flow
150
+
151
+ ```
152
+ 1. Client β†’ server: { type: 'get-upload-url', payload: { files: [...] } }
153
+ 2. Server β†’ Supabase: createSignedUploadUrl (no bytes transferred)
154
+ 3. Server β†’ client: { type: 'upload-url', payload: { urls: [...] } }
155
+ 4. Client β†’ Supabase: PUT file directly (server sees 0 bytes, HF RAM unaffected)
156
+ 5. Client β†’ server: { type: 'message', payload: { type: 'media', content: publicUrl } }
157
+ 6. Server β†’ recipient: deliver or queue offline
158
+ ```
159
+
160
+ ---
161
+
162
+ ## File Structure
163
+
164
+ ```
165
+ src/
166
+ β”œβ”€β”€ index.ts # Entry point, HTTP+WS server
167
+ β”œβ”€β”€ config.ts # All env vars, ICE server builder
168
+ β”œβ”€β”€ db/
169
+ β”‚ └── mongo.ts # MongoDB connection, User + Message models
170
+ β”œβ”€β”€ redis/
171
+ β”‚ β”œβ”€β”€ presenceClient.ts # Bucket 1: uidβ†’socketId, online/offline
172
+ β”‚ └── messageClient.ts # Bucket 2: offline message queue
173
+ β”œβ”€β”€ services/
174
+ β”‚ β”œβ”€β”€ connectionRegistry.ts # In-process socket map + zombie pruner
175
+ β”‚ β”œβ”€β”€ supabaseService.ts # Signed URL generation, file validation
176
+ β”‚ └── friendService.ts # Friend request/accept/remove logic
177
+ β”œβ”€β”€ handlers/
178
+ β”‚ β”œβ”€β”€ connectionHandler.ts # WS connect/disconnect, message router
179
+ β”‚ β”œβ”€β”€ signalingHandler.ts # WebRTC SDP/ICE forwarding
180
+ β”‚ β”œβ”€β”€ messageHandler.ts # Text/media delivery + offline queue
181
+ β”‚ β”œβ”€β”€ fileHandler.ts # Upload URL generation
182
+ β”‚ └── friendHandler.ts # Friend CRUD over WS
183
+ β”œβ”€β”€ middleware/
184
+ β”‚ └── rateLimiter.ts # Token bucket, 20 conn/IP/60s
185
+ β”œβ”€β”€ models/
186
+ β”‚ └── friend.ts # FriendRequest mongoose model
187
+ └── utils/
188
+ β”œβ”€β”€ jwt.ts # Token verification (uid always from JWT)
189
+ β”œβ”€β”€ logger.ts # Structured JSON logger
190
+ └── protocol.ts # WS message types + safe serialization
191
+
192
+ client-reference/
193
+ └── client.ts # Browser client (copy into your frontend)
194
+ ```
client-reference/client.ts ADDED
@@ -0,0 +1,429 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * P2P Signal Client β€” Browser Reference Implementation
3
+ *
4
+ * Drop this into your frontend (React/Vue/Vanilla).
5
+ * It handles:
6
+ * - WS connection + JWT auth
7
+ * - Heartbeat loop
8
+ * - WebRTC offer/answer/ICE
9
+ * - Auto-fallback to WS relay on ICE failure
10
+ * - File upload via Supabase signed URL
11
+ * - Offline message receipt on reconnect
12
+ *
13
+ * Usage:
14
+ * const client = new P2PClient({ serverUrl, token, onMessage, onPeerOnline, onPeerOffline });
15
+ * await client.connect();
16
+ * await client.sendMessage({ toUid, type: 'text', content: 'Hello!' });
17
+ * await client.callPeer(peerUid); // initiates WebRTC
18
+ */
19
+
20
+ export interface P2PClientOptions {
21
+ serverUrl: string; // wss://your-space.hf.space
22
+ token: string; // JWT from your auth system
23
+ onMessage?: (msg: EnvelopeMessage) => void;
24
+ onPeerOnline?: (uid: string) => void;
25
+ onPeerOffline?: (uid: string) => void;
26
+ onOfflineFlush?: (messages: EnvelopeMessage[]) => void;
27
+ onP2PData?: (fromUid: string, data: unknown) => void; // P2P channel data
28
+ heartbeatIntervalMs?: number;
29
+ }
30
+
31
+ export interface EnvelopeMessage {
32
+ fromUid: string;
33
+ toUid: string;
34
+ type: 'text' | 'media';
35
+ content: string;
36
+ mediaType?: string;
37
+ sentAt: number;
38
+ }
39
+
40
+ interface IceServerConfig {
41
+ urls: string;
42
+ username?: string;
43
+ credential?: string;
44
+ }
45
+
46
+ export class P2PClient {
47
+ private ws: WebSocket | null = null;
48
+ private myUid: string | null = null;
49
+ private socketId: string | null = null;
50
+ private iceServers: IceServerConfig[] = [];
51
+ private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
52
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
53
+ private reconnectAttempts = 0;
54
+
55
+ // uid β†’ RTCPeerConnection
56
+ private peerConnections = new Map<string, RTCPeerConnection>();
57
+ // uid β†’ RTCDataChannel (P2P)
58
+ private dataChannels = new Map<string, RTCDataChannel>();
59
+ // uid β†’ 'p2p' | 'relay' (fallback mode)
60
+ private peerMode = new Map<string, 'p2p' | 'relay'>();
61
+
62
+ private opts: Required<P2PClientOptions>;
63
+
64
+ constructor(opts: P2PClientOptions) {
65
+ this.opts = {
66
+ heartbeatIntervalMs: 30_000,
67
+ onMessage: () => {},
68
+ onPeerOnline: () => {},
69
+ onPeerOffline: () => {},
70
+ onOfflineFlush: () => {},
71
+ onP2PData: () => {},
72
+ ...opts,
73
+ };
74
+ }
75
+
76
+ // ── Connection ─────────────────────────────────────────────────────────────
77
+
78
+ connect(): Promise<void> {
79
+ return new Promise((resolve, reject) => {
80
+ const url = `${this.opts.serverUrl}?token=${encodeURIComponent(this.opts.token)}`;
81
+ this.ws = new WebSocket(url);
82
+ this.ws.binaryType = 'arraybuffer';
83
+
84
+ this.ws.onopen = () => {
85
+ this.reconnectAttempts = 0;
86
+ this.startHeartbeat();
87
+ };
88
+
89
+ this.ws.onmessage = (evt) => {
90
+ const msg = this.parseServer(evt.data as string);
91
+ if (!msg) return;
92
+ this.handleServer(msg, resolve, reject);
93
+ };
94
+
95
+ this.ws.onclose = () => {
96
+ this.stopHeartbeat();
97
+ this.scheduleReconnect();
98
+ };
99
+
100
+ this.ws.onerror = () => {
101
+ reject(new Error('WebSocket connection failed'));
102
+ };
103
+ });
104
+ }
105
+
106
+ disconnect(): void {
107
+ this.stopHeartbeat();
108
+ if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
109
+ this.ws?.close();
110
+ }
111
+
112
+ // ── Heartbeat ──────────────────────────────────────────────────────────────
113
+
114
+ private startHeartbeat(): void {
115
+ this.heartbeatTimer = setInterval(() => {
116
+ this.send('heartbeat', {});
117
+ }, this.opts.heartbeatIntervalMs);
118
+ }
119
+
120
+ private stopHeartbeat(): void {
121
+ if (this.heartbeatTimer) {
122
+ clearInterval(this.heartbeatTimer);
123
+ this.heartbeatTimer = null;
124
+ }
125
+ }
126
+
127
+ // ── Reconnect ──────────────────────────────────────────────────────────────
128
+
129
+ private scheduleReconnect(): void {
130
+ const delay = Math.min(1000 * 2 ** this.reconnectAttempts, 30_000);
131
+ this.reconnectAttempts++;
132
+ this.reconnectTimer = setTimeout(() => {
133
+ this.connect().catch(() => {}); // Will schedule again on failure
134
+ }, delay);
135
+ }
136
+
137
+ // ── Sending ────────────────────────────────────────────────────────────────
138
+
139
+ private send(type: string, payload: Record<string, unknown>, requestId?: string): void {
140
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
141
+ this.ws.send(JSON.stringify({ type, payload, requestId }));
142
+ }
143
+
144
+ /** Send a text or media message to a peer */
145
+ sendMessage(opts: {
146
+ toUid: string;
147
+ type: 'text' | 'media';
148
+ content: string;
149
+ mediaType?: string;
150
+ }): void {
151
+ // If P2P channel is open and healthy, use it
152
+ const dc = this.dataChannels.get(opts.toUid);
153
+ const mode = this.peerMode.get(opts.toUid);
154
+
155
+ if (mode === 'p2p' && dc?.readyState === 'open') {
156
+ dc.send(JSON.stringify({ ...opts, fromUid: this.myUid, sentAt: Date.now() }));
157
+ return;
158
+ }
159
+
160
+ // Otherwise use WS signaling server (relay or direct delivery)
161
+ this.send('message', {
162
+ toUid: opts.toUid,
163
+ type: opts.type,
164
+ content: opts.content,
165
+ mediaType: opts.mediaType ?? null,
166
+ clientMsgId: crypto.randomUUID(),
167
+ });
168
+ }
169
+
170
+ // ── File upload ────────────────────────────────────────────────────────────
171
+
172
+ /**
173
+ * Request signed upload URLs and upload files directly to Supabase.
174
+ * Returns array of public URLs for use in sendMessage({ type: 'media' }).
175
+ */
176
+ async uploadFiles(files: File[]): Promise<string[]> {
177
+ const requestId = crypto.randomUUID();
178
+
179
+ // Request signed URLs from server
180
+ const urlsPromise = this.waitForRequestId<{ urls: Array<{ signedUrl: string; publicUrl: string; fileName: string }> }>(
181
+ requestId
182
+ );
183
+
184
+ this.send('get-upload-url', {
185
+ files: files.map(f => ({
186
+ fileName: f.name,
187
+ mimeType: f.type,
188
+ sizeBytes: f.size,
189
+ })),
190
+ }, requestId);
191
+
192
+ const { urls } = await urlsPromise;
193
+
194
+ // Upload each file directly to Supabase (server sees 0 bytes)
195
+ const publicUrls = await Promise.all(
196
+ urls.map(async ({ signedUrl, publicUrl, fileName }, i) => {
197
+ const file = files.find(f => f.name === fileName) ?? files[i];
198
+ await fetch(signedUrl, {
199
+ method: 'PUT',
200
+ headers: { 'Content-Type': file.type },
201
+ body: file,
202
+ });
203
+ return publicUrl;
204
+ })
205
+ );
206
+
207
+ return publicUrls;
208
+ }
209
+
210
+ // ── WebRTC ─────────────────────────────────────────────────────────────────
211
+
212
+ /** Initiate a WebRTC connection to peerUid */
213
+ async callPeer(peerUid: string): Promise<void> {
214
+ const pc = this.createPeerConnection(peerUid);
215
+
216
+ // Create data channel (caller side)
217
+ const dc = pc.createDataChannel('chat', { ordered: true });
218
+ this.setupDataChannel(peerUid, dc);
219
+
220
+ const offer = await pc.createOffer();
221
+ await pc.setLocalDescription(offer);
222
+
223
+ this.send('offer', {
224
+ toUid: peerUid,
225
+ sdp: pc.localDescription,
226
+ });
227
+ }
228
+
229
+ private createPeerConnection(peerUid: string): RTCPeerConnection {
230
+ // Close existing if any
231
+ this.peerConnections.get(peerUid)?.close();
232
+
233
+ const pc = new RTCPeerConnection({ iceServers: this.iceServers });
234
+ this.peerConnections.set(peerUid, pc);
235
+ this.peerMode.set(peerUid, 'p2p');
236
+
237
+ // ICE candidate forwarding
238
+ pc.onicecandidate = (evt) => {
239
+ if (evt.candidate) {
240
+ this.send('ice-candidate', { toUid: peerUid, candidate: evt.candidate });
241
+ }
242
+ };
243
+
244
+ // ── Fallback: if ICE fails, switch to WS relay ────────────────────────
245
+ pc.oniceconnectionstatechange = () => {
246
+ if (pc.iceConnectionState === 'failed' || pc.iceConnectionState === 'disconnected') {
247
+ console.warn(`[P2P] ICE failed for ${peerUid} β€” switching to WS relay`);
248
+ this.peerMode.set(peerUid, 'relay');
249
+ // Close the dead connection
250
+ pc.close();
251
+ this.peerConnections.delete(peerUid);
252
+ this.dataChannels.delete(peerUid);
253
+ }
254
+ };
255
+
256
+ // Callee side: accept incoming data channel
257
+ pc.ondatachannel = (evt) => {
258
+ this.setupDataChannel(peerUid, evt.channel);
259
+ };
260
+
261
+ return pc;
262
+ }
263
+
264
+ private setupDataChannel(peerUid: string, dc: RTCDataChannel): void {
265
+ this.dataChannels.set(peerUid, dc);
266
+
267
+ dc.onopen = () => {
268
+ console.info(`[P2P] Data channel open with ${peerUid}`);
269
+ this.peerMode.set(peerUid, 'p2p');
270
+ };
271
+
272
+ dc.onmessage = (evt) => {
273
+ try {
274
+ const data = JSON.parse(evt.data as string) as unknown;
275
+ this.opts.onP2PData(peerUid, data);
276
+ } catch {
277
+ this.opts.onP2PData(peerUid, evt.data);
278
+ }
279
+ };
280
+
281
+ dc.onclose = () => {
282
+ console.info(`[P2P] Data channel closed with ${peerUid}`);
283
+ this.dataChannels.delete(peerUid);
284
+ // Fall back to WS relay
285
+ this.peerMode.set(peerUid, 'relay');
286
+ };
287
+ }
288
+
289
+ // ── Server message handler ────────────────────────────────────────────────
290
+
291
+ private requestCallbacks = new Map<string, (payload: unknown) => void>();
292
+
293
+ private waitForRequestId<T>(requestId: string): Promise<T> {
294
+ return new Promise((resolve, reject) => {
295
+ const timer = setTimeout(() => {
296
+ this.requestCallbacks.delete(requestId);
297
+ reject(new Error(`Request ${requestId} timed out`));
298
+ }, 15_000);
299
+
300
+ this.requestCallbacks.set(requestId, (payload) => {
301
+ clearTimeout(timer);
302
+ resolve(payload as T);
303
+ });
304
+ });
305
+ }
306
+
307
+ private parseServer(raw: string): Record<string, unknown> | null {
308
+ try { return JSON.parse(raw) as Record<string, unknown>; }
309
+ catch { return null; }
310
+ }
311
+
312
+ private async handleServer(
313
+ msg: Record<string, unknown>,
314
+ onConnected: () => void,
315
+ onConnectError: (e: Error) => void
316
+ ): Promise<void> {
317
+ const type = msg.type as string;
318
+ const payload = (msg.payload ?? {}) as Record<string, unknown>;
319
+ const requestId = msg.requestId as string | undefined;
320
+
321
+ // Resolve pending request callbacks
322
+ if (requestId && this.requestCallbacks.has(requestId)) {
323
+ this.requestCallbacks.get(requestId)!(payload);
324
+ this.requestCallbacks.delete(requestId);
325
+ }
326
+
327
+ switch (type) {
328
+ case 'connected':
329
+ this.myUid = payload.uid as string;
330
+ this.socketId = payload.socketId as string;
331
+ onConnected();
332
+ break;
333
+
334
+ case 'ice-servers':
335
+ this.iceServers = payload.iceServers as IceServerConfig[];
336
+ break;
337
+
338
+ case 'message':
339
+ this.opts.onMessage(payload as unknown as EnvelopeMessage);
340
+ break;
341
+
342
+ case 'offline-flush': {
343
+ const messages = payload.messages as EnvelopeMessage[];
344
+ this.opts.onOfflineFlush(messages);
345
+ break;
346
+ }
347
+
348
+ case 'peer-online':
349
+ this.opts.onPeerOnline(payload.uid as string);
350
+ break;
351
+
352
+ case 'peer-offline': {
353
+ const offlineUid = payload.uid as string;
354
+ this.opts.onPeerOffline(offlineUid);
355
+ // Switch to relay mode for this peer
356
+ this.peerMode.set(offlineUid, 'relay');
357
+ break;
358
+ }
359
+
360
+ case 'offer': {
361
+ const fromUid = payload.fromUid as string;
362
+ const pc = this.createPeerConnection(fromUid);
363
+ await pc.setRemoteDescription(payload.sdp as RTCSessionDescriptionInit);
364
+ const answer = await pc.createAnswer();
365
+ await pc.setLocalDescription(answer);
366
+ this.send('answer', { toUid: fromUid, sdp: pc.localDescription });
367
+ break;
368
+ }
369
+
370
+ case 'answer': {
371
+ const fromUid = payload.fromUid as string;
372
+ const pc = this.peerConnections.get(fromUid);
373
+ if (pc) await pc.setRemoteDescription(payload.sdp as RTCSessionDescriptionInit);
374
+ break;
375
+ }
376
+
377
+ case 'ice-candidate': {
378
+ const fromUid = payload.fromUid as string;
379
+ const pc = this.peerConnections.get(fromUid);
380
+ if (pc && payload.candidate) {
381
+ await pc.addIceCandidate(payload.candidate as RTCIceCandidateInit);
382
+ }
383
+ break;
384
+ }
385
+
386
+ case 'relay':
387
+ // Message arrived via WS relay fallback
388
+ this.opts.onMessage(payload as unknown as EnvelopeMessage);
389
+ break;
390
+
391
+ case 'error':
392
+ console.error('[P2P Server Error]', payload.message);
393
+ break;
394
+ }
395
+ }
396
+
397
+ // ── Friend helpers ────────────────────────────────────────────────────────
398
+
399
+ sendFriendRequest(toUid: string): void {
400
+ this.send('friend', { action: 'send-request', targetUid: toUid });
401
+ }
402
+
403
+ acceptFriendRequest(fromUid: string): void {
404
+ this.send('friend', { action: 'accept', targetUid: fromUid });
405
+ }
406
+
407
+ rejectFriendRequest(fromUid: string): void {
408
+ this.send('friend', { action: 'reject', targetUid: fromUid });
409
+ }
410
+
411
+ removeFriend(uid: string): void {
412
+ this.send('friend', { action: 'remove', targetUid: uid });
413
+ }
414
+
415
+ async getFriends(): Promise<Array<{ uid: string; online: boolean }>> {
416
+ const requestId = crypto.randomUUID();
417
+ const result = await this.waitForRequestId<{ friends: Array<{ uid: string; online: boolean }> }>(requestId);
418
+ this.send('friend', { action: 'list' }, requestId);
419
+ return result.friends;
420
+ }
421
+
422
+ // ── Getters ───────────────────────────────────────────────────────────────
423
+
424
+ get uid(): string | null { return this.myUid; }
425
+ get connected(): boolean { return this.ws?.readyState === WebSocket.OPEN; }
426
+ getPeerMode(peerUid: string): 'p2p' | 'relay' | 'none' {
427
+ return this.peerMode.get(peerUid) ?? 'none';
428
+ }
429
+ }
package.json ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "p2p-signal-server",
3
+ "version": "1.0.0",
4
+ "description": "P2P WebRTC signaling server with offline queuing",
5
+ "main": "dist/index.js",
6
+ "scripts": {
7
+ "build": "tsc",
8
+ "start": "node dist/index.js",
9
+ "dev": "ts-node-dev --respawn --transpile-only src/index.ts",
10
+ "lint": "eslint src/**/*.ts"
11
+ },
12
+ "dependencies": {
13
+ "@supabase/supabase-js": "^2.39.0",
14
+ "dotenv": "^16.3.1",
15
+ "ioredis": "^5.3.2",
16
+ "jsonwebtoken": "^9.0.2",
17
+ "mongoose": "^8.1.0",
18
+ "uuid": "^9.0.0",
19
+ "ws": "^8.16.0"
20
+ },
21
+ "devDependencies": {
22
+ "@types/jsonwebtoken": "^9.0.5",
23
+ "@types/node": "^20.10.0",
24
+ "@types/uuid": "^9.0.7",
25
+ "@types/ws": "^8.5.10",
26
+ "ts-node-dev": "^2.0.0",
27
+ "typescript": "^5.3.2"
28
+ }
29
+ }
src/config.ts ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import dotenv from 'dotenv';
2
+ dotenv.config();
3
+
4
+ function require_env(key: string): string {
5
+ const val = process.env[key];
6
+ if (!val) throw new Error(`Missing required env variable: ${key}`);
7
+ return val;
8
+ }
9
+
10
+ function optional_env(key: string, fallback: string): string {
11
+ return process.env[key] || fallback;
12
+ }
13
+
14
+ export const CONFIG = {
15
+ // Server
16
+ PORT: parseInt(optional_env('PORT', '7860'), 10),
17
+ NODE_ENV: optional_env('NODE_ENV', 'production'),
18
+ ALLOWED_ORIGINS: optional_env('ALLOWED_ORIGINS', '*').split(',').map(s => s.trim()),
19
+
20
+ // Auth
21
+ JWT_SECRET: require_env('JWT_SECRET'),
22
+
23
+ // Redis β€” Two separate buckets
24
+ REDIS_PRESENCE_URL: require_env('REDIS_PRESENCE_URL'),
25
+ REDIS_MESSAGE_URL: require_env('REDIS_MESSAGE_URL'),
26
+
27
+ // MongoDB
28
+ MONGO_URI: require_env('MONGO_URI'),
29
+
30
+ // Supabase
31
+ SUPABASE_URL: require_env('SUPABASE_URL'),
32
+ SUPABASE_SERVICE_KEY: require_env('SUPABASE_SERVICE_KEY'),
33
+ SUPABASE_BUCKET: optional_env('SUPABASE_BUCKET', 'media'),
34
+
35
+ // WebRTC ICE Servers
36
+ STUN_SERVERS: optional_env(
37
+ 'STUN_SERVERS',
38
+ 'stun:stun.l.google.com:19302,stun:stun1.l.google.com:19302'
39
+ )
40
+ .split(',')
41
+ .map(s => s.trim()),
42
+ TURN_URL: optional_env('TURN_URL', ''),
43
+ TURN_USERNAME: optional_env('TURN_USERNAME', ''),
44
+ TURN_CREDENTIAL: optional_env('TURN_CREDENTIAL', ''),
45
+
46
+ // File limits
47
+ MAX_FILE_SIZE_BYTES: parseInt(optional_env('MAX_FILE_SIZE_MB', '99'), 10) * 1024 * 1024,
48
+ MAX_FILE_COUNT: parseInt(optional_env('MAX_FILE_COUNT', '10'), 10),
49
+
50
+ // TTLs
51
+ PRESENCE_TTL_S: parseInt(optional_env('PRESENCE_TTL_S', '60'), 10),
52
+ OFFLINE_MSG_TTL_S: parseInt(optional_env('OFFLINE_MSG_TTL_S', '604800'), 10),
53
+ HEARTBEAT_INTERVAL_MS: parseInt(optional_env('HEARTBEAT_INTERVAL_MS', '30000'), 10),
54
+ } as const;
55
+
56
+ /** Build the ICE server config array sent to clients */
57
+ export function buildIceServers(): RTCIceServerConfig[] {
58
+ const servers: RTCIceServerConfig[] = CONFIG.STUN_SERVERS.map(url => ({ urls: url }));
59
+
60
+ if (CONFIG.TURN_URL) {
61
+ servers.push({
62
+ urls: CONFIG.TURN_URL,
63
+ username: CONFIG.TURN_USERNAME,
64
+ credential: CONFIG.TURN_CREDENTIAL,
65
+ });
66
+ }
67
+
68
+ return servers;
69
+ }
70
+
71
+ // We type this loosely β€” WebRTC types are not in Node by default
72
+ interface RTCIceServerConfig {
73
+ urls: string;
74
+ username?: string;
75
+ credential?: string;
76
+ }
src/db/mongo.ts ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import mongoose, { Schema, Document, Model } from 'mongoose';
2
+ import { CONFIG } from '../config';
3
+ import { logger } from '../utils/logger';
4
+
5
+ // ── Connection ────────────────────────────────────────────────────────────────
6
+
7
+ let isConnected = false;
8
+
9
+ export async function connectMongo(): Promise<void> {
10
+ if (isConnected) return;
11
+
12
+ mongoose.set('strictQuery', true);
13
+
14
+ await mongoose.connect(CONFIG.MONGO_URI, {
15
+ maxPoolSize: 10, // Conserve connections on free tier
16
+ serverSelectionTimeoutMS: 5000,
17
+ socketTimeoutMS: 30000,
18
+ });
19
+
20
+ isConnected = true;
21
+ logger.info('MongoDB connected');
22
+
23
+ mongoose.connection.on('error', (err: Error) =>
24
+ logger.error('MongoDB connection error', { error: err.message })
25
+ );
26
+ mongoose.connection.on('disconnected', () => {
27
+ isConnected = false;
28
+ logger.warn('MongoDB disconnected');
29
+ });
30
+ }
31
+
32
+ // ── User Profile Model ────────────────────────────────────────────────────────
33
+
34
+ export interface IUser extends Document {
35
+ uid: string;
36
+ displayName: string;
37
+ avatarUrl?: string;
38
+ friends: string[]; // Array of UIDs
39
+ blockedUsers: string[];
40
+ settings: {
41
+ notifications: boolean;
42
+ theme: 'light' | 'dark';
43
+ };
44
+ createdAt: Date;
45
+ updatedAt: Date;
46
+ }
47
+
48
+ const UserSchema = new Schema<IUser>(
49
+ {
50
+ uid: { type: String, required: true, unique: true, index: true },
51
+ displayName: { type: String, required: true, maxlength: 64 },
52
+ avatarUrl: { type: String, default: null },
53
+ friends: { type: [String], default: [] },
54
+ blockedUsers: { type: [String], default: [] },
55
+ settings: {
56
+ notifications: { type: Boolean, default: true },
57
+ theme: { type: String, enum: ['light', 'dark'], default: 'dark' },
58
+ },
59
+ },
60
+ {
61
+ timestamps: true,
62
+ // Lean queries by default for read-heavy paths
63
+ }
64
+ );
65
+
66
+ // Compound index for friend-list lookups
67
+ UserSchema.index({ uid: 1, friends: 1 });
68
+
69
+ export const UserModel: Model<IUser> = mongoose.model<IUser>('User', UserSchema);
70
+
71
+ // ── Persistent Message Log (optional, for history) ────────────────────────────
72
+
73
+ export interface IMessage extends Document {
74
+ fromUid: string;
75
+ toUid: string;
76
+ type: 'text' | 'media';
77
+ content: string; // Text body or Supabase URL
78
+ mediaType?: string; // MIME type for media messages
79
+ delivered: boolean;
80
+ createdAt: Date;
81
+ }
82
+
83
+ const MessageSchema = new Schema<IMessage>(
84
+ {
85
+ fromUid: { type: String, required: true, index: true },
86
+ toUid: { type: String, required: true, index: true },
87
+ type: { type: String, enum: ['text', 'media'], required: true },
88
+ content: { type: String, required: true, maxlength: 4096 },
89
+ mediaType: { type: String, default: null },
90
+ delivered: { type: Boolean, default: false, index: true },
91
+ },
92
+ {
93
+ timestamps: true,
94
+ // TTL index: auto-delete messages older than 30 days to save Atlas space
95
+ expireAfterSeconds: 60 * 60 * 24 * 30,
96
+ }
97
+ );
98
+
99
+ MessageSchema.index({ fromUid: 1, toUid: 1, createdAt: -1 });
100
+
101
+ export const MessageModel: Model<IMessage> = mongoose.model<IMessage>(
102
+ 'Message',
103
+ MessageSchema
104
+ );
src/handlers/connectionHandler.ts ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import WebSocket from 'ws';
2
+ import { IncomingMessage } from 'http';
3
+ import { v4 as uuidv4 } from 'uuid';
4
+ import { verifyToken, extractToken } from '../utils/jwt';
5
+ import { safeSend, parseClientMessage, ClientMessage } from '../utils/protocol';
6
+ import { setOnline, setOffline, refreshPresence } from '../redis/presenceClient';
7
+ import { flushOfflineQueue } from '../redis/messageClient';
8
+ import { registry } from '../services/connectionRegistry';
9
+ import { buildIceServers } from '../config';
10
+ import { logger } from '../utils/logger';
11
+ import { handleSignaling } from './signalingHandler';
12
+ import { handleMessage } from './messageHandler';
13
+ import { handleFileRequest } from './fileHandler';
14
+ import { handleFriend } from './friendHandler';
15
+
16
+ export async function onConnection(
17
+ ws: WebSocket,
18
+ req: IncomingMessage
19
+ ): Promise<void> {
20
+ // ── 1. Authenticate ──────────────────────────────────────────────────────
21
+ let uid: string;
22
+ try {
23
+ const url = new URL(req.url ?? '/', `http://${req.headers.host}`);
24
+ const token = extractToken(
25
+ req.headers.authorization,
26
+ url.searchParams.get('token') ?? undefined
27
+ );
28
+ const payload = verifyToken(token);
29
+ uid = payload.uid;
30
+ } catch (err) {
31
+ const msg = err instanceof Error ? err.message : 'Auth failed';
32
+ ws.send(JSON.stringify({ type: 'error', payload: { message: msg }, ts: Date.now() }));
33
+ ws.terminate();
34
+ return;
35
+ }
36
+
37
+ const socketId = uuidv4();
38
+
39
+ // ── 2. Register in process registry + Redis Bucket 1 ────────────────────
40
+ registry.add(socketId, uid, ws);
41
+
42
+ // Redis errors must NOT crash the connection handler
43
+ try {
44
+ await setOnline(uid, socketId);
45
+ } catch (err) {
46
+ logger.error('setOnline failed', { uid, error: String(err) });
47
+ }
48
+
49
+ logger.info('Client connected', { uid, socketId, total: registry.size() });
50
+
51
+ // ── 3. Send welcome + ICE server config ─────────────────────────────────
52
+ safeSend(ws, 'connected', { uid, socketId });
53
+ safeSend(ws, 'ice-servers', { iceServers: buildIceServers() });
54
+
55
+ // ── 4. Flush offline message queue (Bucket 2) ────────────────────────────
56
+ try {
57
+ const pending = await flushOfflineQueue(uid);
58
+ if (pending.length > 0) {
59
+ safeSend(ws, 'offline-flush', { messages: pending, count: pending.length });
60
+ logger.info('Flushed offline queue', { uid, count: pending.length });
61
+ }
62
+ } catch (err) {
63
+ logger.error('Offline flush failed', { uid, error: String(err) });
64
+ }
65
+
66
+ // ── 5. Message router ────────────────────────────────────────────────────
67
+ ws.on('message', (raw) => {
68
+ const msg = parseClientMessage(raw);
69
+ if (!msg) return; // Malformed β€” silently drop
70
+
71
+ registry.heartbeat(socketId); // Any message counts as a heartbeat
72
+
73
+ routeMessage(ws, uid, socketId, msg).catch((err: unknown) => {
74
+ logger.error('Message handler error', { uid, type: msg.type, error: String(err) });
75
+ });
76
+ });
77
+
78
+ // ── 6. Cleanup on disconnect ─────────────────────────────────────────────
79
+ ws.on('close', () => {
80
+ registry.remove(socketId);
81
+ setOffline(uid).catch((err: unknown) =>
82
+ logger.error('setOffline failed', { uid, error: String(err) })
83
+ );
84
+ logger.info('Client disconnected', { uid, socketId, total: registry.size() });
85
+ });
86
+
87
+ ws.on('error', (err) => {
88
+ logger.error('WebSocket error', { uid, socketId, error: err.message });
89
+ ws.terminate();
90
+ });
91
+ }
92
+
93
+ // ── Message router ────────────────────────────────────────────────────────────
94
+
95
+ async function routeMessage(
96
+ ws: WebSocket,
97
+ uid: string,
98
+ socketId: string,
99
+ msg: ClientMessage
100
+ ): Promise<void> {
101
+ switch (msg.type) {
102
+ case 'heartbeat':
103
+ await refreshPresence(uid).catch(() => {}); // Best effort
104
+ break;
105
+
106
+ case 'offer':
107
+ case 'answer':
108
+ case 'ice-candidate':
109
+ await handleSignaling(ws, uid, msg);
110
+ break;
111
+
112
+ case 'message':
113
+ await handleMessage(ws, uid, msg);
114
+ break;
115
+
116
+ case 'get-upload-url':
117
+ await handleFileRequest(ws, uid, msg);
118
+ break;
119
+
120
+ case 'relay':
121
+ await handleRelay(ws, uid, msg);
122
+ break;
123
+
124
+ case 'friend':
125
+ await handleFriend(ws, uid, msg);
126
+ break;
127
+
128
+ default:
129
+ safeSend(ws, 'error', { message: `Unknown message type: ${msg.type}` });
130
+ }
131
+ }
132
+
133
+ // ── WebSocket relay fallback (when WebRTC ICE fails) ─────────────────────────
134
+
135
+ async function handleRelay(
136
+ _senderWs: WebSocket,
137
+ fromUid: string,
138
+ msg: ClientMessage
139
+ ): Promise<void> {
140
+ const toUid = msg.payload.toUid as string | undefined;
141
+ if (!toUid || typeof toUid !== 'string') return;
142
+
143
+ const target = registry.getByUid(toUid);
144
+ if (!target) return;
145
+
146
+ safeSend(target.ws, 'relay', {
147
+ fromUid,
148
+ data: msg.payload.data,
149
+ }, msg.requestId);
150
+ }
src/handlers/fileHandler.ts ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import WebSocket from 'ws';
2
+ import { ClientMessage, safeSend } from '../utils/protocol';
3
+ import {
4
+ generateUploadUrl,
5
+ validateFileRequest,
6
+ validateFileCount,
7
+ FileUploadRequest,
8
+ } from '../services/supabaseService';
9
+ import { logger } from '../utils/logger';
10
+
11
+ /**
12
+ * Handles type: 'get-upload-url'
13
+ *
14
+ * Client sends:
15
+ * {
16
+ * files: Array<{ fileName: string, mimeType: string, sizeBytes: number }>
17
+ * }
18
+ *
19
+ * Server responds with:
20
+ * {
21
+ * urls: Array<{ signedUrl, objectPath, publicUrl, fileName }>
22
+ * }
23
+ *
24
+ * The client then PUTs each file directly to Supabase using the signedUrl.
25
+ * HF server never sees any file bytes β€” memory usage stays near 0.
26
+ *
27
+ * After upload, the client sends a type: 'message' with type: 'media' and
28
+ * the publicUrl as the content field.
29
+ */
30
+ export async function handleFileRequest(
31
+ ws: WebSocket,
32
+ uid: string,
33
+ msg: ClientMessage
34
+ ): Promise<void> {
35
+ const files = msg.payload.files as FileUploadRequest[] | undefined;
36
+
37
+ // ── Validate count ────────────────────────────────────────────────────────
38
+ if (!Array.isArray(files)) {
39
+ safeSend(ws, 'error', { message: 'files must be an array' }, msg.requestId);
40
+ return;
41
+ }
42
+
43
+ const countError = validateFileCount(files.length);
44
+ if (countError) {
45
+ safeSend(ws, 'error', { message: countError }, msg.requestId);
46
+ return;
47
+ }
48
+
49
+ // ── Validate each file ────────────────────────────────────────────────────
50
+ for (const f of files) {
51
+ const fileError = validateFileRequest(f);
52
+ if (fileError) {
53
+ safeSend(ws, 'error', { message: fileError }, msg.requestId);
54
+ return;
55
+ }
56
+ }
57
+
58
+ // ── Generate signed URLs ──────────────────────────────────────────────────
59
+ try {
60
+ const results = await Promise.all(
61
+ files.map(async (f) => {
62
+ const result = await generateUploadUrl(uid, f);
63
+ return {
64
+ fileName: f.fileName,
65
+ signedUrl: result.signedUrl,
66
+ objectPath: result.objectPath,
67
+ publicUrl: result.publicUrl,
68
+ };
69
+ })
70
+ );
71
+
72
+ safeSend(ws, 'upload-url', { urls: results }, msg.requestId);
73
+ logger.info('Signed URLs generated', { uid, count: results.length });
74
+ } catch (err) {
75
+ logger.error('generateUploadUrl failed', { uid, error: String(err) });
76
+ safeSend(ws, 'error', { message: 'Failed to generate upload URL' }, msg.requestId);
77
+ }
78
+ }
src/handlers/friendHandler.ts ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import WebSocket from 'ws';
2
+ import { ClientMessage, safeSend } from '../utils/protocol';
3
+ import {
4
+ sendFriendRequest,
5
+ acceptFriendRequest,
6
+ rejectFriendRequest,
7
+ removeFriend,
8
+ getFriendList,
9
+ areFriends,
10
+ } from '../services/friendService';
11
+ import { isOnline } from '../redis/presenceClient';
12
+ import { registry } from '../services/connectionRegistry';
13
+ import { logger } from '../utils/logger';
14
+
15
+ /**
16
+ * Routes friend-related WS actions.
17
+ * Called from connectionHandler's routeMessage for type: 'friend'
18
+ *
19
+ * Payload shape: { action, targetUid, ...extras }
20
+ * Actions: send-request | accept | reject | remove | list | status
21
+ */
22
+ export async function handleFriend(
23
+ ws: WebSocket,
24
+ uid: string,
25
+ msg: ClientMessage
26
+ ): Promise<void> {
27
+ const { action, targetUid } = msg.payload as { action?: string; targetUid?: string };
28
+
29
+ if (!action) {
30
+ safeSend(ws, 'error', { message: 'action is required' }, msg.requestId);
31
+ return;
32
+ }
33
+
34
+ switch (action) {
35
+ case 'send-request': {
36
+ if (!targetUid) { safeSend(ws, 'error', { message: 'targetUid required' }, msg.requestId); return; }
37
+ if (targetUid === uid) { safeSend(ws, 'error', { message: 'Cannot friend yourself' }, msg.requestId); return; }
38
+ await sendFriendRequest(uid, targetUid);
39
+ safeSend(ws, 'message', { ack: true, action: 'send-request', targetUid }, msg.requestId);
40
+
41
+ // Notify the target if online
42
+ const targetMeta = registry.getByUid(targetUid);
43
+ if (targetMeta) {
44
+ safeSend(targetMeta.ws, 'message', { type: 'friend-request', fromUid: uid });
45
+ }
46
+ break;
47
+ }
48
+
49
+ case 'accept': {
50
+ if (!targetUid) { safeSend(ws, 'error', { message: 'targetUid required' }, msg.requestId); return; }
51
+ const ok = await acceptFriendRequest(targetUid, uid); // targetUid sent the request
52
+ safeSend(ws, 'message', { ack: true, action: 'accept', success: ok }, msg.requestId);
53
+
54
+ if (ok) {
55
+ const targetMeta = registry.getByUid(targetUid);
56
+ if (targetMeta) {
57
+ safeSend(targetMeta.ws, 'message', { type: 'friend-accepted', fromUid: uid });
58
+ }
59
+ }
60
+ break;
61
+ }
62
+
63
+ case 'reject': {
64
+ if (!targetUid) { safeSend(ws, 'error', { message: 'targetUid required' }, msg.requestId); return; }
65
+ await rejectFriendRequest(targetUid, uid);
66
+ safeSend(ws, 'message', { ack: true, action: 'reject' }, msg.requestId);
67
+ break;
68
+ }
69
+
70
+ case 'remove': {
71
+ if (!targetUid) { safeSend(ws, 'error', { message: 'targetUid required' }, msg.requestId); return; }
72
+ await removeFriend(uid, targetUid);
73
+ safeSend(ws, 'message', { ack: true, action: 'remove' }, msg.requestId);
74
+ break;
75
+ }
76
+
77
+ case 'list': {
78
+ const friends = await getFriendList(uid);
79
+ // Bulk check online status from Redis
80
+ const statusChecks = await Promise.allSettled(friends.map(f => isOnline(f)));
81
+ const withStatus = friends.map((f, i) => ({
82
+ uid: f,
83
+ online: statusChecks[i].status === 'fulfilled' && statusChecks[i].value === true,
84
+ }));
85
+ safeSend(ws, 'message', { action: 'list', friends: withStatus }, msg.requestId);
86
+ break;
87
+ }
88
+
89
+ case 'status': {
90
+ if (!targetUid) { safeSend(ws, 'error', { message: 'targetUid required' }, msg.requestId); return; }
91
+ // Only allow checking status of friends
92
+ const isFriend = await areFriends(uid, targetUid);
93
+ if (!isFriend) {
94
+ safeSend(ws, 'error', { message: 'Not friends with this user' }, msg.requestId);
95
+ return;
96
+ }
97
+ const online = await isOnline(targetUid);
98
+ safeSend(ws, 'message', { action: 'status', uid: targetUid, online }, msg.requestId);
99
+ break;
100
+ }
101
+
102
+ default:
103
+ safeSend(ws, 'error', { message: `Unknown friend action: ${action}` }, msg.requestId);
104
+ }
105
+ }
src/handlers/messageHandler.ts ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import WebSocket from 'ws';
2
+ import { ClientMessage, safeSend } from '../utils/protocol';
3
+ import { isOnline } from '../redis/presenceClient';
4
+ import { enqueueOfflineMessage } from '../redis/messageClient';
5
+ import { registry } from '../services/connectionRegistry';
6
+ import { MessageModel } from '../db/mongo';
7
+ import { logger } from '../utils/logger';
8
+
9
+ const MAX_TEXT_LENGTH = 4096;
10
+
11
+ /**
12
+ * Handles type: 'message'
13
+ *
14
+ * Payload shape:
15
+ * {
16
+ * toUid: string,
17
+ * type: 'text' | 'media',
18
+ * content: string, // text body or Supabase media URL
19
+ * mediaType?: string, // MIME type for media
20
+ * clientMsgId?: string, // Client-generated idempotency ID
21
+ * }
22
+ *
23
+ * Flow:
24
+ * If recipient online β†’ push directly to their WS
25
+ * If recipient offline β†’ enqueue in Redis Bucket 2
26
+ * Always β†’ persist to MongoDB for history
27
+ */
28
+ export async function handleMessage(
29
+ senderWs: WebSocket,
30
+ fromUid: string,
31
+ msg: ClientMessage
32
+ ): Promise<void> {
33
+ const { toUid, type, content, mediaType, clientMsgId } = msg.payload as {
34
+ toUid?: string;
35
+ type?: string;
36
+ content?: string;
37
+ mediaType?: string;
38
+ clientMsgId?: string;
39
+ };
40
+
41
+ // ── Validate ──────────────────────────────────────────────────────────────
42
+ if (!toUid || typeof toUid !== 'string') {
43
+ safeSend(senderWs, 'error', { message: 'toUid is required' }, msg.requestId);
44
+ return;
45
+ }
46
+ if (toUid === fromUid) {
47
+ safeSend(senderWs, 'error', { message: 'Cannot message yourself' }, msg.requestId);
48
+ return;
49
+ }
50
+ if (!content || typeof content !== 'string') {
51
+ safeSend(senderWs, 'error', { message: 'content is required' }, msg.requestId);
52
+ return;
53
+ }
54
+ if (type !== 'text' && type !== 'media') {
55
+ safeSend(senderWs, 'error', { message: 'type must be text or media' }, msg.requestId);
56
+ return;
57
+ }
58
+ if (type === 'text' && content.length > MAX_TEXT_LENGTH) {
59
+ safeSend(senderWs, 'error', { message: `Text exceeds ${MAX_TEXT_LENGTH} chars` }, msg.requestId);
60
+ return;
61
+ }
62
+
63
+ const envelope: Record<string, unknown> = {
64
+ fromUid,
65
+ toUid,
66
+ type,
67
+ content,
68
+ mediaType: mediaType ?? null,
69
+ clientMsgId: clientMsgId ?? null,
70
+ sentAt: Date.now(),
71
+ };
72
+
73
+ // ── Persist to MongoDB ────────────────────────────────────────────────────
74
+ // Fire-and-forget β€” do not block message delivery on DB write
75
+ MessageModel.create({
76
+ fromUid,
77
+ toUid,
78
+ type,
79
+ content,
80
+ mediaType: mediaType ?? undefined,
81
+ delivered: false,
82
+ }).catch((err: unknown) =>
83
+ logger.error('MongoDB message persist failed', { error: String(err) })
84
+ );
85
+
86
+ // ── Deliver ───────────────────────────────────────────────────────────────
87
+ const targetMeta = registry.getByUid(toUid);
88
+
89
+ if (targetMeta) {
90
+ // Online β€” deliver directly
91
+ safeSend(targetMeta.ws, 'message', envelope);
92
+ logger.debug('Message delivered live', { fromUid, toUid, type });
93
+ } else {
94
+ // Double-check Redis in case peer is on a different process (future multi-node)
95
+ const online = await isOnline(toUid).catch(() => false);
96
+
97
+ if (!online) {
98
+ // Enqueue in Redis Bucket 2
99
+ await enqueueOfflineMessage(toUid, envelope).catch((err: unknown) =>
100
+ logger.error('enqueueOfflineMessage failed', { toUid, error: String(err) })
101
+ );
102
+ logger.debug('Message queued offline', { fromUid, toUid });
103
+ }
104
+ }
105
+
106
+ // Ack the sender
107
+ safeSend(senderWs, 'message', { ack: true, clientMsgId: clientMsgId ?? null }, msg.requestId);
108
+ }
src/handlers/signalingHandler.ts ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import WebSocket from 'ws';
2
+ import { ClientMessage } from '../utils/protocol';
3
+ import { safeSend, ServerMessageType } from '../utils/protocol';
4
+ import { isOnline } from '../redis/presenceClient';
5
+ import { enqueueOfflineMessage } from '../redis/messageClient';
6
+ import { registry } from '../services/connectionRegistry';
7
+ import { logger } from '../utils/logger';
8
+
9
+ /**
10
+ * Handles WebRTC signaling:
11
+ * - offer / answer / ice-candidate are forwarded to the target peer.
12
+ * - If the peer is offline, the signal is dropped (SDP signals don't
13
+ * queue β€” they are ephemeral). Only chat messages queue offline.
14
+ *
15
+ * WebRTC fallback to WS relay:
16
+ * When a client detects iceConnectionState === 'failed' or 'disconnected',
17
+ * it switches to type: 'relay' messages instead.
18
+ * The server relays those via handleRelay in connectionHandler.ts.
19
+ * This handler is only responsible for SDP/ICE forwarding.
20
+ */
21
+ export async function handleSignaling(
22
+ _senderWs: WebSocket,
23
+ fromUid: string,
24
+ msg: ClientMessage
25
+ ): Promise<void> {
26
+ const toUid = msg.payload.toUid as string | undefined;
27
+ if (!toUid || typeof toUid !== 'string') {
28
+ logger.warn('Signaling message missing toUid', { fromUid, type: msg.type });
29
+ return;
30
+ }
31
+
32
+ // Security: block self-signaling
33
+ if (toUid === fromUid) return;
34
+
35
+ const target = registry.getByUid(toUid);
36
+
37
+ if (!target) {
38
+ // Peer not on this process β€” check Redis
39
+ const online = await isOnline(toUid).catch(() => false);
40
+ if (!online) {
41
+ // Peer offline β€” signal is ephemeral, inform the sender
42
+ safeSend(_senderWs, 'peer-offline', { uid: toUid });
43
+ }
44
+ // In a multi-node deployment: publish to Redis pub/sub here
45
+ // for cross-node delivery. Single-node (HF): nothing to do.
46
+ return;
47
+ }
48
+
49
+ // Forward the signal verbatim to the peer
50
+ const forwardType = msg.type as ServerMessageType;
51
+ safeSend(
52
+ target.ws,
53
+ forwardType,
54
+ {
55
+ fromUid,
56
+ sdp: msg.payload.sdp,
57
+ candidate: msg.payload.candidate,
58
+ },
59
+ msg.requestId
60
+ );
61
+
62
+ logger.debug('Signal forwarded', { type: msg.type, fromUid, toUid });
63
+ }
64
+
65
+ /**
66
+ * Notify a user that a specific peer has come online.
67
+ * Called from connectionHandler after the user connects.
68
+ */
69
+ export function notifyPeerOnline(targetUid: string, peerUid: string): void {
70
+ const target = registry.getByUid(targetUid);
71
+ if (!target) return;
72
+ safeSend(target.ws, 'peer-online', { uid: peerUid });
73
+ }
74
+
75
+ /**
76
+ * Notify a user that a specific peer has gone offline.
77
+ * Called from connectionHandler on disconnect.
78
+ */
79
+ export function notifyPeerOffline(targetUid: string, peerUid: string): void {
80
+ const target = registry.getByUid(targetUid);
81
+ if (!target) return;
82
+ safeSend(target.ws, 'peer-offline', { uid: peerUid });
83
+ }
src/index.ts ADDED
@@ -0,0 +1,148 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import http from 'http';
2
+ import WebSocket from 'ws';
3
+ import { CONFIG } from './config';
4
+ import { connectMongo } from './db/mongo';
5
+ import { presenceClient } from './redis/presenceClient';
6
+ import { messageClient } from './redis/messageClient';
7
+ import { onConnection } from './handlers/connectionHandler';
8
+ import { registry } from './services/connectionRegistry';
9
+ import { isRateLimited } from './middleware/rateLimiter';
10
+ import { logger } from './utils/logger';
11
+
12
+ // ── Graceful startup ─────────────────────────────────────────────────────────
13
+
14
+ async function bootstrap(): Promise<void> {
15
+ logger.info('Starting P2P Signal Server', {
16
+ port: CONFIG.PORT,
17
+ env: CONFIG.NODE_ENV,
18
+ });
19
+
20
+ // Connect dependencies β€” fail fast if they are unreachable
21
+ await connectMongo();
22
+
23
+ // Redis clients connect automatically on instantiation;
24
+ // wait for 'ready' event before accepting traffic
25
+ await Promise.all([
26
+ waitRedisReady(presenceClient, 'Presence'),
27
+ waitRedisReady(messageClient, 'Message'),
28
+ ]);
29
+
30
+ // ── HTTP health endpoint ─────────────────────────────────────────────────
31
+ // HF Spaces probes / to check the container is alive
32
+ const httpServer = http.createServer((req, res) => {
33
+ if (req.method === 'GET' && req.url === '/health') {
34
+ res.writeHead(200, { 'Content-Type': 'application/json' });
35
+ res.end(
36
+ JSON.stringify({
37
+ status: 'ok',
38
+ connections: registry.size(),
39
+ ts: Date.now(),
40
+ })
41
+ );
42
+ return;
43
+ }
44
+ // All other HTTP requests: 426 Upgrade Required
45
+ res.writeHead(426, { 'Content-Type': 'text/plain' });
46
+ res.end('This server only accepts WebSocket connections.');
47
+ });
48
+
49
+ // ── WebSocket server ─────────────────────────────────────────────────────
50
+ const wss = new WebSocket.Server({
51
+ server: httpServer,
52
+ // No per-message deflate β€” compresses well but costs CPU on HF free tier
53
+ perMessageDeflate: false,
54
+ // Limit frame size to 64 KB to protect against memory exhaustion
55
+ maxPayload: 65536,
56
+ // Manual upgrade handling for rate limiting
57
+ handleProtocols: () => false,
58
+ });
59
+
60
+ wss.on('connection', async (ws: WebSocket, req: http.IncomingMessage) => {
61
+ // Rate limit check
62
+ if (isRateLimited(req)) {
63
+ ws.close(1008, 'Rate limit exceeded');
64
+ return;
65
+ }
66
+
67
+ // CORS check β€” only allow configured origins
68
+ const origin = req.headers.origin ?? '';
69
+ if (
70
+ CONFIG.ALLOWED_ORIGINS[0] !== '*' &&
71
+ !CONFIG.ALLOWED_ORIGINS.includes(origin)
72
+ ) {
73
+ ws.close(1008, 'Origin not allowed');
74
+ return;
75
+ }
76
+
77
+ await onConnection(ws, req);
78
+ });
79
+
80
+ wss.on('error', (err) => logger.error('WSS error', { error: err.message }));
81
+
82
+ // ── Zombie socket pruner ─────────────────────────────────────────────────
83
+ // Kill sockets that missed 3Γ— the heartbeat window (90s default)
84
+ const ZOMBIE_TIMEOUT_MS = CONFIG.HEARTBEAT_INTERVAL_MS * 3;
85
+ setInterval(() => {
86
+ const pruned = registry.pruneZombies(ZOMBIE_TIMEOUT_MS);
87
+ if (pruned > 0) logger.warn('Pruned zombie sockets', { pruned });
88
+ }, ZOMBIE_TIMEOUT_MS);
89
+
90
+ // ── Metrics logger ───────────────────────────────────────────────────────
91
+ setInterval(() => {
92
+ logger.info('Server metrics', {
93
+ connections: registry.size(),
94
+ memMB: Math.round(process.memoryUsage().rss / 1024 / 1024),
95
+ });
96
+ }, 60_000);
97
+
98
+ // ── Listen ───────────────────────────────────────────────────────────────
99
+ httpServer.listen(CONFIG.PORT, '0.0.0.0', () => {
100
+ logger.info(`Server listening on port ${CONFIG.PORT}`);
101
+ });
102
+ }
103
+
104
+ // ── Helpers ──────────────────────────────────────────────────────────────────
105
+
106
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
107
+ async function waitRedisReady(client: any, name: string): Promise<void> {
108
+ return new Promise((resolve, reject) => {
109
+ if (client.status === 'ready') { resolve(); return; }
110
+ client.once('ready', resolve);
111
+ client.once('error', (err: Error) =>
112
+ reject(new Error(`${name} Redis failed: ${err.message}`))
113
+ );
114
+ // Timeout after 10s
115
+ setTimeout(() => reject(new Error(`${name} Redis connection timeout`)), 10_000);
116
+ });
117
+ }
118
+
119
+ // ── Global error boundaries ���─────────────────────────────────────────────────
120
+ // Unhandled rejections must not crash the HF container
121
+
122
+ process.on('unhandledRejection', (reason) => {
123
+ logger.error('Unhandled rejection', { reason: String(reason) });
124
+ });
125
+
126
+ process.on('uncaughtException', (err) => {
127
+ logger.error('Uncaught exception', { error: err.message, stack: err.stack });
128
+ // Do NOT exit β€” HF container restart is slow
129
+ });
130
+
131
+ // ── Graceful shutdown ────────────────────────────────────────────────────────
132
+
133
+ async function shutdown(signal: string): Promise<void> {
134
+ logger.info(`Received ${signal}, shutting down gracefully`);
135
+ presenceClient.disconnect();
136
+ messageClient.disconnect();
137
+ process.exit(0);
138
+ }
139
+
140
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
141
+ process.on('SIGINT', () => shutdown('SIGINT'));
142
+
143
+ // ── Boot ─────────────────────────────────────────────────────────────────────
144
+
145
+ bootstrap().catch((err) => {
146
+ logger.error('Bootstrap failed', { error: String(err) });
147
+ process.exit(1);
148
+ });
src/middleware/rateLimiter.ts ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { IncomingMessage } from 'http';
2
+
3
+ /**
4
+ * Simple in-process token bucket rate limiter for WS upgrade requests.
5
+ * Prevents a single IP from opening thousands of connections on HF free tier.
6
+ *
7
+ * Limits: 20 connections per IP per 60 seconds.
8
+ */
9
+
10
+ interface Bucket {
11
+ tokens: number;
12
+ lastRefill: number;
13
+ }
14
+
15
+ const CAPACITY = 20;
16
+ const REFILL_RATE = 20; // tokens per window
17
+ const WINDOW_MS = 60_000; // 60 seconds
18
+
19
+ const buckets = new Map<string, Bucket>();
20
+
21
+ // Prune old entries every 5 minutes to prevent memory leak
22
+ setInterval(() => {
23
+ const cutoff = Date.now() - WINDOW_MS * 2;
24
+ for (const [ip, bucket] of buckets) {
25
+ if (bucket.lastRefill < cutoff) buckets.delete(ip);
26
+ }
27
+ }, 300_000).unref();
28
+
29
+ export function isRateLimited(req: IncomingMessage): boolean {
30
+ const ip =
31
+ (req.headers['x-forwarded-for'] as string | undefined)?.split(',')[0]?.trim() ??
32
+ req.socket.remoteAddress ??
33
+ 'unknown';
34
+
35
+ const now = Date.now();
36
+ let bucket = buckets.get(ip);
37
+
38
+ if (!bucket) {
39
+ bucket = { tokens: CAPACITY - 1, lastRefill: now };
40
+ buckets.set(ip, bucket);
41
+ return false;
42
+ }
43
+
44
+ // Refill based on elapsed time
45
+ const elapsed = now - bucket.lastRefill;
46
+ if (elapsed >= WINDOW_MS) {
47
+ bucket.tokens = CAPACITY - 1;
48
+ bucket.lastRefill = now;
49
+ return false;
50
+ }
51
+
52
+ if (bucket.tokens <= 0) return true;
53
+
54
+ bucket.tokens--;
55
+ return false;
56
+ }
src/models/friend.ts ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import mongoose, { Schema, Document, Model } from 'mongoose';
2
+
3
+ /**
4
+ * FriendRequest model β€” tracks pending/accepted/rejected friend requests.
5
+ * Used by REST-style HTTP upgrade calls (future) or future REST API layer.
6
+ */
7
+ export interface IFriendRequest extends Document {
8
+ fromUid: string;
9
+ toUid: string;
10
+ status: 'pending' | 'accepted' | 'rejected';
11
+ createdAt: Date;
12
+ updatedAt: Date;
13
+ }
14
+
15
+ const FriendRequestSchema = new Schema<IFriendRequest>(
16
+ {
17
+ fromUid: { type: String, required: true, index: true },
18
+ toUid: { type: String, required: true, index: true },
19
+ status: {
20
+ type: String,
21
+ enum: ['pending', 'accepted', 'rejected'],
22
+ default: 'pending',
23
+ },
24
+ },
25
+ { timestamps: true }
26
+ );
27
+
28
+ // Prevent duplicate requests
29
+ FriendRequestSchema.index({ fromUid: 1, toUid: 1 }, { unique: true });
30
+
31
+ export const FriendRequestModel: Model<IFriendRequest> = mongoose.model<IFriendRequest>(
32
+ 'FriendRequest',
33
+ FriendRequestSchema
34
+ );
src/redis/messageClient.ts ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import Redis from 'ioredis';
2
+ import { CONFIG } from '../config';
3
+ import { logger } from '../utils/logger';
4
+
5
+ /**
6
+ * Redis Bucket 2 β€” Offline Message Queue
7
+ *
8
+ * Key schema:
9
+ * offline_msgs:{uid} β†’ Redis List of JSON-encoded message strings
10
+ * Each list key has OFFLINE_MSG_TTL_S TTL.
11
+ *
12
+ * Entirely separate Redis instance from Bucket 1.
13
+ */
14
+ const messageClient = new Redis(CONFIG.REDIS_MESSAGE_URL, {
15
+ maxRetriesPerRequest: 2,
16
+ connectTimeout: 5000,
17
+ lazyConnect: false,
18
+ enableReadyCheck: true,
19
+ keepAlive: 10000,
20
+ });
21
+
22
+ messageClient.on('connect', () => logger.info('Message Redis connected'));
23
+ messageClient.on('error', (err: Error) =>
24
+ logger.error('Message Redis error', { error: err.message })
25
+ );
26
+ messageClient.on('reconnecting', () => logger.warn('Message Redis reconnecting'));
27
+
28
+ export { messageClient };
29
+
30
+ // ── Key builder ──────────────────────────────────────────────────────────────
31
+
32
+ const queueKey = (uid: string) => `offline_msgs:${uid}`;
33
+
34
+ // Max messages to queue per user β€” prevents memory abuse at 10k scale
35
+ const MAX_QUEUE_LENGTH = 200;
36
+
37
+ // ── Operations ───────────────────────────────────────────────────────────────
38
+
39
+ /** Push a serialized message onto the offline queue for a user. */
40
+ export async function enqueueOfflineMessage(
41
+ toUid: string,
42
+ message: Record<string, unknown>
43
+ ): Promise<void> {
44
+ const key = queueKey(toUid);
45
+ const serialized = JSON.stringify(message);
46
+
47
+ // RPUSH then cap the list, then refresh TTL β€” 3 commands in a pipeline
48
+ const pipeline = messageClient.pipeline();
49
+ pipeline.rpush(key, serialized);
50
+ // Trim to MAX_QUEUE_LENGTH (drop oldest if overflow)
51
+ pipeline.ltrim(key, -MAX_QUEUE_LENGTH, -1);
52
+ pipeline.expire(key, CONFIG.OFFLINE_MSG_TTL_S);
53
+ await pipeline.exec();
54
+ }
55
+
56
+ /**
57
+ * Atomically pop all queued messages for a uid.
58
+ * Uses LRANGE + DEL so no message is double-delivered.
59
+ * Returns parsed message objects.
60
+ */
61
+ export async function flushOfflineQueue(
62
+ toUid: string
63
+ ): Promise<Array<Record<string, unknown>>> {
64
+ const key = queueKey(toUid);
65
+
66
+ // LRANGE 0 -1 gets everything; DEL removes the key atomically-ish
67
+ // For true atomicity at high scale, this could be a Lua script β€”
68
+ // but for HF free tier the risk of race is negligible.
69
+ const pipeline = messageClient.pipeline();
70
+ pipeline.lrange(key, 0, -1);
71
+ pipeline.del(key);
72
+ const results = await pipeline.exec();
73
+
74
+ if (!results || !results[0] || results[0][0]) return [];
75
+
76
+ const rawList = results[0][1] as string[];
77
+ const parsed: Array<Record<string, unknown>> = [];
78
+
79
+ for (const raw of rawList) {
80
+ try {
81
+ parsed.push(JSON.parse(raw) as Record<string, unknown>);
82
+ } catch {
83
+ // Corrupted entry β€” skip
84
+ }
85
+ }
86
+
87
+ return parsed;
88
+ }
89
+
90
+ /** How many messages are queued for a user. Useful for dashboards. */
91
+ export async function getQueueLength(uid: string): Promise<number> {
92
+ return messageClient.llen(queueKey(uid));
93
+ }
src/redis/presenceClient.ts ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import Redis from 'ioredis';
2
+ import { CONFIG } from '../config';
3
+ import { logger } from '../utils/logger';
4
+
5
+ /**
6
+ * Redis Bucket 1 β€” Presence
7
+ *
8
+ * Key schema:
9
+ * presence:uid:{uid} β†’ socket_id (TTL = PRESENCE_TTL_S)
10
+ * status:uid:{uid} β†’ "online" (TTL = PRESENCE_TTL_S)
11
+ *
12
+ * Separate client instance so Bucket 1 errors never affect Bucket 2.
13
+ */
14
+ const presenceClient = new Redis(CONFIG.REDIS_PRESENCE_URL, {
15
+ maxRetriesPerRequest: 2,
16
+ connectTimeout: 5000,
17
+ lazyConnect: false,
18
+ enableReadyCheck: true,
19
+ // Keep-alive so the HF container connection stays warm
20
+ keepAlive: 10000,
21
+ });
22
+
23
+ presenceClient.on('connect', () => logger.info('Presence Redis connected'));
24
+ presenceClient.on('error', (err: Error) =>
25
+ logger.error('Presence Redis error', { error: err.message })
26
+ );
27
+ presenceClient.on('reconnecting', () => logger.warn('Presence Redis reconnecting'));
28
+
29
+ export { presenceClient };
30
+
31
+ // ── Key builders ─────────────────────────────────────────────────────────────
32
+
33
+ const presenceKey = (uid: string) => `presence:uid:${uid}`;
34
+ const statusKey = (uid: string) => `status:uid:${uid}`;
35
+
36
+ // ── Operations ───────────────────────────────────────────────────────────────
37
+
38
+ /** Map uid β†’ socketId, mark user online. Called on WS connect. */
39
+ export async function setOnline(uid: string, socketId: string): Promise<void> {
40
+ const ttl = CONFIG.PRESENCE_TTL_S;
41
+ // MULTI/EXEC keeps it atomic
42
+ await presenceClient
43
+ .multi()
44
+ .set(presenceKey(uid), socketId, 'EX', ttl)
45
+ .set(statusKey(uid), 'online', 'EX', ttl)
46
+ .exec();
47
+ }
48
+
49
+ /** Refresh TTL on heartbeat. Does not overwrite the socket id. */
50
+ export async function refreshPresence(uid: string): Promise<void> {
51
+ const ttl = CONFIG.PRESENCE_TTL_S;
52
+ await presenceClient
53
+ .multi()
54
+ .expire(presenceKey(uid), ttl)
55
+ .expire(statusKey(uid), ttl)
56
+ .exec();
57
+ }
58
+
59
+ /** Remove user from presence. Called on WS close. */
60
+ export async function setOffline(uid: string): Promise<void> {
61
+ await presenceClient
62
+ .multi()
63
+ .del(presenceKey(uid))
64
+ .del(statusKey(uid))
65
+ .exec();
66
+ }
67
+
68
+ /** Returns the socket id for a uid, or null if offline. */
69
+ export async function getSocketId(uid: string): Promise<string | null> {
70
+ return presenceClient.get(presenceKey(uid));
71
+ }
72
+
73
+ /** Returns true if the user is online. */
74
+ export async function isOnline(uid: string): Promise<boolean> {
75
+ const status = await presenceClient.get(statusKey(uid));
76
+ return status === 'online';
77
+ }
src/services/connectionRegistry.ts ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import WebSocket from 'ws';
2
+
3
+ /**
4
+ * In-process map of uid β†’ WebSocket.
5
+ * This is NOT distributed state (that lives in Redis Bucket 1).
6
+ * This map is only valid for THIS process instance.
7
+ *
8
+ * On HF Spaces (single container), this is sufficient.
9
+ * For multi-instance horizontal scale: replace with Redis pub/sub routing.
10
+ */
11
+
12
+ interface SocketMeta {
13
+ ws: WebSocket;
14
+ uid: string;
15
+ socketId: string;
16
+ connectedAt: number;
17
+ lastHeartbeat: number;
18
+ }
19
+
20
+ // Two-way map for O(1) lookups in both directions
21
+ const byUid = new Map<string, SocketMeta>();
22
+ const bySocketId = new Map<string, SocketMeta>();
23
+
24
+ export const registry = {
25
+ /** Register a new connection */
26
+ add(socketId: string, uid: string, ws: WebSocket): void {
27
+ const meta: SocketMeta = {
28
+ ws,
29
+ uid,
30
+ socketId,
31
+ connectedAt: Date.now(),
32
+ lastHeartbeat: Date.now(),
33
+ };
34
+ // Remove any stale entry for this uid (reconnect scenario)
35
+ const existing = byUid.get(uid);
36
+ if (existing) {
37
+ bySocketId.delete(existing.socketId);
38
+ // Close the old zombie socket if it is still "open"
39
+ if (existing.ws.readyState === WebSocket.OPEN) {
40
+ existing.ws.terminate();
41
+ }
42
+ }
43
+ byUid.set(uid, meta);
44
+ bySocketId.set(socketId, meta);
45
+ },
46
+
47
+ /** Remove a connection by socketId */
48
+ remove(socketId: string): SocketMeta | undefined {
49
+ const meta = bySocketId.get(socketId);
50
+ if (!meta) return undefined;
51
+ bySocketId.delete(socketId);
52
+ // Only remove from byUid if this socket is still the current one
53
+ if (byUid.get(meta.uid)?.socketId === socketId) {
54
+ byUid.delete(meta.uid);
55
+ }
56
+ return meta;
57
+ },
58
+
59
+ /** Get WebSocket for a uid */
60
+ getByUid(uid: string): SocketMeta | undefined {
61
+ return byUid.get(uid);
62
+ },
63
+
64
+ /** Get WebSocket by its socketId */
65
+ getBySocketId(socketId: string): SocketMeta | undefined {
66
+ return bySocketId.get(socketId);
67
+ },
68
+
69
+ /** Update heartbeat timestamp */
70
+ heartbeat(socketId: string): void {
71
+ const meta = bySocketId.get(socketId);
72
+ if (meta) meta.lastHeartbeat = Date.now();
73
+ },
74
+
75
+ /** How many sockets are active right now */
76
+ size(): number {
77
+ return bySocketId.size;
78
+ },
79
+
80
+ /**
81
+ * Zombie pruner β€” iterates all connections and terminates any that
82
+ * haven't sent a heartbeat within 3Γ— the expected interval.
83
+ * Called by a setInterval in index.ts.
84
+ */
85
+ pruneZombies(maxSilenceMs: number): number {
86
+ const now = Date.now();
87
+ let pruned = 0;
88
+ for (const [socketId, meta] of bySocketId) {
89
+ if (now - meta.lastHeartbeat > maxSilenceMs) {
90
+ meta.ws.terminate();
91
+ bySocketId.delete(socketId);
92
+ if (byUid.get(meta.uid)?.socketId === socketId) {
93
+ byUid.delete(meta.uid);
94
+ }
95
+ pruned++;
96
+ }
97
+ }
98
+ return pruned;
99
+ },
100
+ };
src/services/friendService.ts ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { UserModel } from '../db/mongo';
2
+ import { FriendRequestModel } from '../models/friend';
3
+ import { logger } from '../utils/logger';
4
+
5
+ /**
6
+ * Check if two users are friends. Used before allowing signaling to prevent
7
+ * stranger signaling attacks β€” only friends can initiate WebRTC offers.
8
+ */
9
+ export async function areFriends(uidA: string, uidB: string): Promise<boolean> {
10
+ const user = await UserModel.findOne({ uid: uidA, friends: uidB }, '_id').lean();
11
+ return !!user;
12
+ }
13
+
14
+ /** Send a friend request (fromUid β†’ toUid). Idempotent. */
15
+ export async function sendFriendRequest(fromUid: string, toUid: string): Promise<void> {
16
+ await FriendRequestModel.findOneAndUpdate(
17
+ { fromUid, toUid },
18
+ { status: 'pending' },
19
+ { upsert: true, new: true }
20
+ );
21
+ }
22
+
23
+ /** Accept a friend request. Adds each user to the other's friends list. */
24
+ export async function acceptFriendRequest(fromUid: string, toUid: string): Promise<boolean> {
25
+ const req = await FriendRequestModel.findOneAndUpdate(
26
+ { fromUid, toUid, status: 'pending' },
27
+ { status: 'accepted' }
28
+ );
29
+ if (!req) return false;
30
+
31
+ // Add to both users' friend lists atomically
32
+ await Promise.all([
33
+ UserModel.updateOne({ uid: toUid }, { $addToSet: { friends: fromUid } }),
34
+ UserModel.updateOne({ uid: fromUid }, { $addToSet: { friends: toUid } }),
35
+ ]);
36
+
37
+ return true;
38
+ }
39
+
40
+ /** Reject a friend request. */
41
+ export async function rejectFriendRequest(fromUid: string, toUid: string): Promise<void> {
42
+ await FriendRequestModel.findOneAndUpdate(
43
+ { fromUid, toUid, status: 'pending' },
44
+ { status: 'rejected' }
45
+ );
46
+ }
47
+
48
+ /** Remove a friend (unfriend). Removes from both sides. */
49
+ export async function removeFriend(uidA: string, uidB: string): Promise<void> {
50
+ await Promise.all([
51
+ UserModel.updateOne({ uid: uidA }, { $pull: { friends: uidB } }),
52
+ UserModel.updateOne({ uid: uidB }, { $pull: { friends: uidA } }),
53
+ ]);
54
+ logger.info('Friendship removed', { uidA, uidB });
55
+ }
56
+
57
+ /** Get a user's friend list. Returns array of UIDs. */
58
+ export async function getFriendList(uid: string): Promise<string[]> {
59
+ const user = await UserModel.findOne({ uid }, 'friends').lean();
60
+ return (user?.friends as string[]) ?? [];
61
+ }
src/services/supabaseService.ts ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createClient, SupabaseClient } from '@supabase/supabase-js';
2
+ import { v4 as uuidv4 } from 'uuid';
3
+ import { CONFIG } from '../config';
4
+ import { logger } from '../utils/logger';
5
+
6
+ // ── Singleton client ──────────────────────────────────────────────────────────
7
+
8
+ let _client: SupabaseClient | null = null;
9
+
10
+ function getClient(): SupabaseClient {
11
+ if (!_client) {
12
+ _client = createClient(CONFIG.SUPABASE_URL, CONFIG.SUPABASE_SERVICE_KEY, {
13
+ auth: { persistSession: false },
14
+ });
15
+ }
16
+ return _client;
17
+ }
18
+
19
+ // ── Allowed MIME types ────────────────────────────────────────────────────────
20
+
21
+ const ALLOWED_MIME: Set<string> = new Set([
22
+ 'image/jpeg',
23
+ 'image/png',
24
+ 'image/gif',
25
+ 'image/webp',
26
+ 'video/mp4',
27
+ 'video/webm',
28
+ 'audio/mpeg',
29
+ 'audio/ogg',
30
+ 'audio/webm',
31
+ 'application/pdf',
32
+ ]);
33
+
34
+ // ── File validation ───────────────────────────────────────────────────────────
35
+
36
+ export interface FileUploadRequest {
37
+ fileName: string;
38
+ mimeType: string;
39
+ sizeBytes: number;
40
+ }
41
+
42
+ export interface UploadUrlResult {
43
+ signedUrl: string;
44
+ objectPath: string;
45
+ publicUrl: string;
46
+ }
47
+
48
+ export function validateFileRequest(req: FileUploadRequest): string | null {
49
+ if (!ALLOWED_MIME.has(req.mimeType)) {
50
+ return `MIME type ${req.mimeType} is not allowed`;
51
+ }
52
+ if (req.sizeBytes > CONFIG.MAX_FILE_SIZE_BYTES) {
53
+ return `File exceeds ${CONFIG.MAX_FILE_SIZE_BYTES / 1024 / 1024} MB limit`;
54
+ }
55
+ // Strip path traversal from filename
56
+ const safeName = req.fileName.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 128);
57
+ if (!safeName) return 'Invalid file name';
58
+ return null;
59
+ }
60
+
61
+ export function validateFileCount(count: number): string | null {
62
+ if (count > CONFIG.MAX_FILE_COUNT) {
63
+ return `Cannot upload more than ${CONFIG.MAX_FILE_COUNT} files at once`;
64
+ }
65
+ if (count < 1) return 'File count must be at least 1';
66
+ return null;
67
+ }
68
+
69
+ // ── Signed upload URL generation ─────────────────────────────────────────────
70
+
71
+ /**
72
+ * Generates a Supabase signed upload URL.
73
+ * The client uses this to PUT the file directly to Supabase β€” HF server
74
+ * never buffers any bytes, keeping memory usage at ~0 for file transfers.
75
+ *
76
+ * URL expires in 60 seconds β€” client must begin upload immediately.
77
+ */
78
+ export async function generateUploadUrl(
79
+ uploaderUid: string,
80
+ req: FileUploadRequest
81
+ ): Promise<UploadUrlResult> {
82
+ const supabase = getClient();
83
+
84
+ // Sanitize file name
85
+ const safeName = req.fileName.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 128);
86
+ const ext = safeName.split('.').pop() ?? 'bin';
87
+
88
+ // Unique path per user prevents collisions and leakage
89
+ const objectPath = `users/${uploaderUid}/${uuidv4()}.${ext}`;
90
+
91
+ const { data, error } = await supabase.storage
92
+ .from(CONFIG.SUPABASE_BUCKET)
93
+ .createSignedUploadUrl(objectPath);
94
+
95
+ if (error || !data) {
96
+ logger.error('Supabase signed URL failed', { error: error?.message });
97
+ throw new Error('Could not generate upload URL');
98
+ }
99
+
100
+ // Derive the public URL (bucket must be public, or use signed read URL)
101
+ const { data: urlData } = supabase.storage
102
+ .from(CONFIG.SUPABASE_BUCKET)
103
+ .getPublicUrl(objectPath);
104
+
105
+ return {
106
+ signedUrl: data.signedUrl,
107
+ objectPath,
108
+ publicUrl: urlData.publicUrl,
109
+ };
110
+ }
src/utils/jwt.ts ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import jwt from 'jsonwebtoken';
2
+ import { CONFIG } from '../config';
3
+
4
+ export interface JWTPayload {
5
+ uid: string;
6
+ iat?: number;
7
+ exp?: number;
8
+ }
9
+
10
+ /**
11
+ * Verifies a JWT and returns the decoded payload.
12
+ * Throws if the token is invalid or expired.
13
+ * The uid is ALWAYS extracted from the verified token β€” never from client input.
14
+ */
15
+ export function verifyToken(token: string): JWTPayload {
16
+ const decoded = jwt.verify(token, CONFIG.JWT_SECRET) as JWTPayload;
17
+
18
+ if (!decoded.uid || typeof decoded.uid !== 'string') {
19
+ throw new Error('JWT payload missing uid field');
20
+ }
21
+
22
+ // Sanitize uid to prevent Redis key injection
23
+ if (!/^[a-zA-Z0-9_-]{1,64}$/.test(decoded.uid)) {
24
+ throw new Error('JWT uid contains invalid characters');
25
+ }
26
+
27
+ return decoded;
28
+ }
29
+
30
+ /** Extract Bearer token from Upgrade/Authorization header or query param */
31
+ export function extractToken(
32
+ authHeader: string | undefined,
33
+ queryToken: string | undefined
34
+ ): string {
35
+ if (authHeader?.startsWith('Bearer ')) {
36
+ return authHeader.slice(7).trim();
37
+ }
38
+ if (queryToken) {
39
+ return queryToken.trim();
40
+ }
41
+ throw new Error('No authorization token provided');
42
+ }
src/utils/logger.ts ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { CONFIG } from '../config';
2
+
3
+ type Level = 'info' | 'warn' | 'error' | 'debug';
4
+
5
+ const LEVELS: Record<Level, number> = { debug: 0, info: 1, warn: 2, error: 3 };
6
+ const MIN_LEVEL = CONFIG.NODE_ENV === 'production' ? LEVELS.info : LEVELS.debug;
7
+
8
+ function log(level: Level, msg: string, meta?: Record<string, unknown>): void {
9
+ if (LEVELS[level] < MIN_LEVEL) return;
10
+
11
+ const entry = JSON.stringify({
12
+ ts: new Date().toISOString(),
13
+ level,
14
+ msg,
15
+ ...(meta ?? {}),
16
+ });
17
+
18
+ if (level === 'error') {
19
+ process.stderr.write(entry + '\n');
20
+ } else {
21
+ process.stdout.write(entry + '\n');
22
+ }
23
+ }
24
+
25
+ export const logger = {
26
+ info: (msg: string, meta?: Record<string, unknown>) => log('info', msg, meta),
27
+ warn: (msg: string, meta?: Record<string, unknown>) => log('warn', msg, meta),
28
+ error: (msg: string, meta?: Record<string, unknown>) => log('error', msg, meta),
29
+ debug: (msg: string, meta?: Record<string, unknown>) => log('debug', msg, meta),
30
+ };
src/utils/protocol.ts ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import WebSocket from 'ws';
2
+
3
+ // ── Inbound message types (client β†’ server) ──────────────────────────────────
4
+ export type ClientMessageType =
5
+ | 'heartbeat'
6
+ | 'offer'
7
+ | 'answer'
8
+ | 'ice-candidate'
9
+ | 'message'
10
+ | 'get-upload-url'
11
+ | 'relay'
12
+ | 'friend';
13
+
14
+ // ── Outbound message types (server β†’ client) ──────────────────────────────────
15
+ export type ServerMessageType =
16
+ | 'connected'
17
+ | 'ice-servers'
18
+ | 'offer'
19
+ | 'answer'
20
+ | 'ice-candidate'
21
+ | 'message'
22
+ | 'upload-url'
23
+ | 'relay'
24
+ | 'peer-online'
25
+ | 'peer-offline'
26
+ | 'offline-flush'
27
+ | 'error';
28
+
29
+ export interface ClientMessage {
30
+ type: ClientMessageType;
31
+ payload: Record<string, unknown>;
32
+ // requestId allows client to correlate responses
33
+ requestId?: string;
34
+ }
35
+
36
+ export interface ServerMessage {
37
+ type: ServerMessageType;
38
+ payload: Record<string, unknown>;
39
+ requestId?: string;
40
+ ts: number; // epoch ms
41
+ }
42
+
43
+ /**
44
+ * Parse and validate an incoming raw WS message.
45
+ * Returns null if the data is malformed β€” caller should silently drop.
46
+ */
47
+ export function parseClientMessage(raw: WebSocket.RawData): ClientMessage | null {
48
+ try {
49
+ const str = raw.toString('utf8');
50
+ if (str.length > 65536) return null; // 64 KB hard cap β€” protects against flood
51
+
52
+ const obj = JSON.parse(str) as unknown;
53
+
54
+ if (
55
+ typeof obj !== 'object' ||
56
+ obj === null ||
57
+ typeof (obj as Record<string, unknown>).type !== 'string'
58
+ ) {
59
+ return null;
60
+ }
61
+
62
+ return obj as ClientMessage;
63
+ } catch {
64
+ return null;
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Serialize a server message.
70
+ * Using a single Buffer allocation avoids repeated JSON.stringify on hot paths.
71
+ */
72
+ export function buildServerMessage(
73
+ type: ServerMessageType,
74
+ payload: Record<string, unknown>,
75
+ requestId?: string
76
+ ): Buffer {
77
+ const msg: ServerMessage = { type, payload, requestId, ts: Date.now() };
78
+ return Buffer.from(JSON.stringify(msg), 'utf8');
79
+ }
80
+
81
+ /**
82
+ * Send a message to a WebSocket, catching send errors silently.
83
+ * Avoids crashing the process when a socket closes mid-send.
84
+ */
85
+ export function safeSend(
86
+ ws: WebSocket,
87
+ type: ServerMessageType,
88
+ payload: Record<string, unknown>,
89
+ requestId?: string
90
+ ): void {
91
+ if (ws.readyState !== WebSocket.OPEN) return;
92
+
93
+ try {
94
+ ws.send(buildServerMessage(type, payload, requestId));
95
+ } catch {
96
+ // Socket probably closed between the readyState check and send β€” ignore
97
+ }
98
+ }
tsconfig.json ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "commonjs",
5
+ "lib": ["ES2022"],
6
+ "outDir": "./dist",
7
+ "rootDir": "./src",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "forceConsistentCasingInFileNames": true,
12
+ "resolveJsonModule": true,
13
+ "declaration": true,
14
+ "declarationMap": true,
15
+ "sourceMap": true,
16
+ "removeComments": true,
17
+ "noUnusedLocals": false,
18
+ "noUnusedParameters": false,
19
+ "exactOptionalPropertyTypes": false
20
+ },
21
+ "include": ["src/**/*"],
22
+ "exclude": ["node_modules", "dist"]
23
+ }