Skip to content

Commit 8224f1d

Browse files
authored
Merge pull request #40 from Mathews-25/realtime
Add real-time messaging with WebSockets and encrypted chat UI
2 parents 30c4134 + f6a7343 commit 8224f1d

10 files changed

Lines changed: 893 additions & 0 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { describe, it } from 'vitest'
2+
3+
describe('messages integration', () => {
4+
it.todo('sends and receives messages between two users via WebSocket')
5+
it.todo('persists history and supports search by keyword')
6+
it.todo('supports file attachments end-to-end')
7+
})
8+
9+
describe('messages e2e', () => {
10+
it.todo('complete conversation flow with file sharing in browser (Playwright)')
11+
})
12+
13+
describe('performance & security', () => {
14+
it.todo('handles 1000+ concurrent users with <1s latency (load test)')
15+
it.todo('encrypts messages end-to-end and prevents XSS/rate-limit bypass')
16+
})

__tests__/messages.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { describe, expect, it, vi, beforeEach } from 'vitest'
2+
import { messageCrypto } from '@/hooks/useMessages'
3+
import { RealtimeWebSocket } from '@/lib/websocket'
4+
5+
declare const global: typeof globalThis & { WebSocket: any }
6+
7+
describe('message encryption', () => {
8+
it('encrypts and decrypts with shared key', async () => {
9+
const key = await messageCrypto.deriveKey('secret', 'thread-1')
10+
const { ciphertext, iv } = await messageCrypto.encryptText('hello world', key)
11+
const plaintext = await messageCrypto.decryptText(ciphertext, iv, key)
12+
expect(plaintext).toBe('hello world')
13+
})
14+
})
15+
16+
describe('websocket wrapper', () => {
17+
class FakeWebSocket {
18+
public readyState = 0
19+
public onopen: (() => void) | null = null
20+
public onmessage: ((event: { data: string }) => void) | null = null
21+
public onclose: (() => void) | null = null
22+
public onerror: (() => void) | null = null
23+
public sent: string[] = []
24+
constructor() {
25+
setTimeout(() => {
26+
this.readyState = 1
27+
this.onopen?.()
28+
}, 0)
29+
}
30+
send(data: string) {
31+
this.sent.push(data)
32+
this.onmessage?.({ data })
33+
}
34+
close() {
35+
this.readyState = 3
36+
this.onclose?.()
37+
}
38+
}
39+
40+
beforeEach(() => {
41+
global.WebSocket = FakeWebSocket as any
42+
})
43+
44+
it('notifies status changes and sends data', async () => {
45+
const statuses: string[] = []
46+
const socket = new RealtimeWebSocket('ws://example.com', { onStatusChange: (s) => statuses.push(s) })
47+
await new Promise((resolve) => setTimeout(resolve, 10))
48+
expect(statuses).toContain('open')
49+
socket.send({ hello: 'world' })
50+
expect((socket as any).ws.sent[0]).toContain('hello')
51+
socket.close()
52+
await new Promise((resolve) => setTimeout(resolve, 10))
53+
expect(statuses).toContain('closed')
54+
})
55+
})

app/api/messages/route.ts

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
import { NextRequest, NextResponse } from 'next/server'
2+
3+
type Attachment = {
4+
name: string
5+
type: string
6+
size: number
7+
data: string // base64 string
8+
}
9+
10+
type MessagePayload = {
11+
id: string
12+
threadId: string
13+
senderId: string
14+
recipientId: string
15+
ciphertext: string
16+
iv: string
17+
createdAt: string
18+
attachment?: Attachment | null
19+
status?: 'sent' | 'delivered' | 'read'
20+
readBy?: string[]
21+
metadata?: Record<string, unknown>
22+
}
23+
24+
type ServerState = {
25+
clients: Set<WebSocket>
26+
history: MessagePayload[]
27+
}
28+
29+
const getState = (): ServerState => {
30+
const globalRef = globalThis as unknown as { __messageState?: ServerState }
31+
if (!globalRef.__messageState) {
32+
globalRef.__messageState = { clients: new Set<WebSocket>(), history: [] }
33+
}
34+
return globalRef.__messageState
35+
}
36+
37+
const upgradeWebSocket = (request: Request) => {
38+
const denoUpgrade = (globalThis as any)?.Deno?.upgradeWebSocket
39+
if (denoUpgrade) {
40+
return denoUpgrade(request)
41+
}
42+
43+
const anyRequest = request as any
44+
if (anyRequest?.webSocket) {
45+
const { webSocket } = anyRequest
46+
webSocket.accept()
47+
return { socket: webSocket as WebSocket, response: new Response(null, { status: 101 }) }
48+
}
49+
50+
throw new Error('WebSocket upgrade is not supported in this runtime')
51+
}
52+
53+
const broadcast = (data: unknown) => {
54+
const payload = JSON.stringify(data)
55+
const { clients } = getState()
56+
clients.forEach((socket) => {
57+
try {
58+
socket.send(payload)
59+
} catch (err) {
60+
console.error('Failed to send message to client', err)
61+
try {
62+
socket.close()
63+
} catch {
64+
// ignore
65+
}
66+
clients.delete(socket)
67+
}
68+
})
69+
}
70+
71+
const handleMessageEvent = (socket: WebSocket, raw: string) => {
72+
const state = getState()
73+
try {
74+
const parsed = JSON.parse(raw) as { type: string; [key: string]: any }
75+
if (parsed.type === 'message') {
76+
const message: MessagePayload = {
77+
id: parsed.id || crypto.randomUUID(),
78+
threadId: parsed.threadId || 'general',
79+
senderId: parsed.senderId,
80+
recipientId: parsed.recipientId || 'all',
81+
ciphertext: parsed.ciphertext,
82+
iv: parsed.iv,
83+
createdAt: parsed.createdAt || new Date().toISOString(),
84+
attachment: parsed.attachment || null,
85+
status: 'sent',
86+
readBy: parsed.readBy || [parsed.senderId],
87+
metadata: parsed.metadata || {},
88+
}
89+
state.history.push(message)
90+
broadcast({ type: 'message', data: message })
91+
} else if (parsed.type === 'typing') {
92+
broadcast({ type: 'typing', userId: parsed.userId, threadId: parsed.threadId })
93+
} else if (parsed.type === 'read-receipt') {
94+
const { messageId, userId } = parsed
95+
state.history = state.history.map((msg) =>
96+
msg.id === messageId
97+
? { ...msg, status: 'read', readBy: Array.from(new Set([...(msg.readBy || []), userId])) }
98+
: msg
99+
)
100+
broadcast({ type: 'read-receipt', messageId, userId })
101+
} else if (parsed.type === 'moderate') {
102+
const { messageId, action, moderatorId, reason } = parsed
103+
if (action === 'delete') {
104+
state.history = state.history.filter((m) => m.id !== messageId)
105+
}
106+
broadcast({ type: 'moderated', messageId, action, moderatorId, reason })
107+
} else if (parsed.type === 'ping') {
108+
socket.send(JSON.stringify({ type: 'pong', ts: Date.now() }))
109+
}
110+
} catch (err) {
111+
console.error('Invalid payload', err)
112+
socket.send(JSON.stringify({ type: 'error', message: 'Invalid payload' }))
113+
}
114+
}
115+
116+
export async function GET(request: NextRequest) {
117+
const upgradeHeader = request.headers.get('upgrade')
118+
119+
if (upgradeHeader && upgradeHeader.toLowerCase() === 'websocket') {
120+
try {
121+
const { socket, response } = upgradeWebSocket(request)
122+
const state = getState()
123+
124+
socket.addEventListener('open', () => {
125+
state.clients.add(socket)
126+
socket.send(
127+
JSON.stringify({ type: 'history', data: state.history.slice(-200) }) // cap initial history
128+
)
129+
})
130+
131+
socket.addEventListener('message', (event: MessageEvent) => {
132+
const raw = typeof event.data === 'string' ? event.data : ''
133+
handleMessageEvent(socket, raw)
134+
})
135+
136+
socket.addEventListener('close', () => {
137+
state.clients.delete(socket)
138+
})
139+
140+
socket.addEventListener('error', () => {
141+
state.clients.delete(socket)
142+
})
143+
144+
return response
145+
} catch (err) {
146+
console.error('WebSocket upgrade failed', err)
147+
return NextResponse.json({ error: 'WebSocket upgrade failed' }, { status: 400 })
148+
}
149+
}
150+
151+
const { searchParams } = new URL(request.url)
152+
const q = searchParams.get('q')?.toLowerCase().trim()
153+
const threadId = searchParams.get('threadId') || undefined
154+
const state = getState()
155+
const filtered = state.history.filter((msg) => {
156+
const inThread = threadId ? msg.threadId === threadId : true
157+
if (!q) return inThread
158+
return inThread && (msg.metadata?.plainText as string | undefined)?.toLowerCase().includes(q)
159+
})
160+
161+
return NextResponse.json({ messages: filtered })
162+
}
163+
164+
export async function POST(request: NextRequest) {
165+
const body = await request.json()
166+
const state = getState()
167+
const message: MessagePayload = {
168+
id: body.id || crypto.randomUUID(),
169+
threadId: body.threadId || 'general',
170+
senderId: body.senderId,
171+
recipientId: body.recipientId || 'all',
172+
ciphertext: body.ciphertext,
173+
iv: body.iv,
174+
createdAt: new Date().toISOString(),
175+
attachment: body.attachment || null,
176+
status: 'sent',
177+
readBy: [body.senderId],
178+
metadata: body.metadata || {},
179+
}
180+
state.history.push(message)
181+
broadcast({ type: 'message', data: message })
182+
return NextResponse.json({ ok: true, message })
183+
}
184+
185+
export const dynamic = 'force-dynamic'
186+
export const runtime = 'edge'

app/layout.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import Script from 'next/script'
33
import { Geist, Geist_Mono } from 'next/font/google'
44
import { Analytics } from '@vercel/analytics/next'
55
import { ThemeProvider } from 'next-themes'
6+
import { Toaster } from '@/components/ui/sonner'
67
import AnalyticsClient from './providers/AnalyticsClient'
78
import './globals.css'
89

@@ -65,6 +66,7 @@ export default function RootLayout({
6566
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
6667
<AnalyticsClient plausibleDomain={PLAUSIBLE_DOMAIN} />
6768
{children}
69+
<Toaster richColors closeButton position="top-right" />
6870
<Analytics />
6971
</ThemeProvider>
7072
</body>

app/messages/page.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import ChatInterface from '@/components/chat-interface'
2+
3+
export const metadata = {
4+
title: 'Messages | Stellar Creators',
5+
description: 'Real-time messaging between clients and creators',
6+
}
7+
8+
export default function MessagesPage() {
9+
return (
10+
<main className="mx-auto max-w-6xl space-y-6 px-4 py-8">
11+
<header className="space-y-2">
12+
<p className="text-sm uppercase tracking-wide text-muted-foreground">Messaging</p>
13+
<h1 className="text-3xl font-semibold">Real-time collaboration</h1>
14+
<p className="text-muted-foreground">Chat live, share files, and keep requirements clear across projects.</p>
15+
</header>
16+
<ChatInterface />
17+
</main>
18+
)
19+
}

0 commit comments

Comments
 (0)