|
| 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' |
0 commit comments