Skip to content

Commit 35b8180

Browse files
committed
basic websocket and room with messages implementation
1 parent 55bbb94 commit 35b8180

21 files changed

Lines changed: 1557 additions & 67 deletions

File tree

apps/backend/dev.db

20 KB
Binary file not shown.

apps/backend/package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,13 @@
2121
"@prisma/client": "^7.4.0",
2222
"cors": "^2.8.5",
2323
"dotenv": "^17.3.1",
24-
"express": "^5.2.1"
24+
"express": "^5.2.1",
25+
"ws": "^8.18.3"
2526
},
2627
"devDependencies": {
28+
"@types/cors": "^2.8.19",
2729
"@types/express": "^5.0.6",
30+
"@types/ws": "^8.18.1",
2831
"prisma": "^7.4.0",
2932
"tsx": "^4.20.5"
3033
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/*
2+
Warnings:
3+
4+
- Added the required column `projectId` to the `Room` table without a default value. This is not possible if the table is not empty.
5+
6+
*/
7+
-- CreateTable
8+
CREATE TABLE "Project" (
9+
"id" TEXT NOT NULL PRIMARY KEY,
10+
"name" TEXT NOT NULL,
11+
"slug" TEXT NOT NULL,
12+
"description" TEXT,
13+
"createdById" TEXT NOT NULL,
14+
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
15+
"updatedAt" DATETIME NOT NULL,
16+
CONSTRAINT "Project_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
17+
);
18+
19+
-- CreateTable
20+
CREATE TABLE "ProjectMember" (
21+
"id" TEXT NOT NULL PRIMARY KEY,
22+
"projectId" TEXT NOT NULL,
23+
"userId" TEXT NOT NULL,
24+
"role" TEXT NOT NULL DEFAULT 'member',
25+
"joinedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
26+
CONSTRAINT "ProjectMember_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
27+
CONSTRAINT "ProjectMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
28+
);
29+
30+
-- RedefineTables
31+
PRAGMA defer_foreign_keys=ON;
32+
PRAGMA foreign_keys=OFF;
33+
CREATE TABLE "new_Room" (
34+
"id" TEXT NOT NULL PRIMARY KEY,
35+
"projectId" TEXT NOT NULL,
36+
"name" TEXT NOT NULL,
37+
"repositoryId" TEXT,
38+
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
39+
"updatedAt" DATETIME NOT NULL,
40+
CONSTRAINT "Room_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
41+
CONSTRAINT "Room_repositoryId_fkey" FOREIGN KEY ("repositoryId") REFERENCES "Repository" ("id") ON DELETE SET NULL ON UPDATE CASCADE
42+
);
43+
INSERT INTO "new_Room" ("createdAt", "id", "name", "repositoryId", "updatedAt") SELECT "createdAt", "id", "name", "repositoryId", "updatedAt" FROM "Room";
44+
DROP TABLE "Room";
45+
ALTER TABLE "new_Room" RENAME TO "Room";
46+
PRAGMA foreign_keys=ON;
47+
PRAGMA defer_foreign_keys=OFF;
48+
49+
-- CreateIndex
50+
CREATE UNIQUE INDEX "Project_slug_key" ON "Project"("slug");
51+
52+
-- CreateIndex
53+
CREATE UNIQUE INDEX "ProjectMember_projectId_userId_key" ON "ProjectMember"("projectId", "userId");

apps/backend/prisma/schema.prisma

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ model User {
4343
createdAt DateTime @default(now())
4444
updatedAt DateTime @updatedAt
4545
roomMembership RoomMember[]
46+
projectMemberships ProjectMember[]
47+
createdProjects Project[]
4648
sentMessages Message[]
4749
accounts Account[]
4850
sessions Session[]
@@ -86,12 +88,39 @@ model VerificationToken {
8688
@@unique([identifier, token])
8789
}
8890

91+
model Project {
92+
id String @id @default(cuid())
93+
name String
94+
slug String @unique
95+
description String?
96+
createdById String
97+
createdAt DateTime @default(now())
98+
updatedAt DateTime @updatedAt
99+
createdBy User @relation(fields: [createdById], references: [id], onDelete: Cascade)
100+
members ProjectMember[]
101+
rooms Room[]
102+
}
103+
104+
model ProjectMember {
105+
id String @id @default(cuid())
106+
projectId String
107+
userId String
108+
role String @default("member")
109+
joinedAt DateTime @default(now())
110+
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
111+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
112+
113+
@@unique([projectId, userId])
114+
}
115+
89116
model Room {
90117
id String @id @default(cuid())
118+
projectId String
91119
name String
92120
repositoryId String?
93121
createdAt DateTime @default(now())
94122
updatedAt DateTime @updatedAt
123+
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
95124
roomMembers RoomMember[]
96125
messages Message[]
97126
repository Repository? @relation(fields: [repositoryId], references: [id])

apps/backend/src/auth/session.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import type { Request } from "express";
2+
3+
export type AuthenticatedUser = {
4+
id: string;
5+
name?: string | null;
6+
email?: string | null;
7+
image?: string | null;
8+
};
9+
10+
type SessionResponse = {
11+
user?: {
12+
id?: string;
13+
name?: string | null;
14+
email?: string | null;
15+
image?: string | null;
16+
};
17+
};
18+
19+
function parseAuthenticatedUser(payload: SessionResponse | null): AuthenticatedUser | null {
20+
if (!payload?.user?.id) {
21+
return null;
22+
}
23+
24+
return {
25+
id: payload.user.id,
26+
name: payload.user.name,
27+
email: payload.user.email,
28+
image: payload.user.image
29+
};
30+
}
31+
32+
export async function resolveAuthUserFromSession(params: {
33+
origin: string;
34+
cookieHeader?: string;
35+
}): Promise<AuthenticatedUser | null> {
36+
if (!params.cookieHeader) {
37+
return null;
38+
}
39+
40+
try {
41+
const response = await fetch(`${params.origin}/auth/session`, {
42+
method: "GET",
43+
headers: {
44+
cookie: params.cookieHeader,
45+
accept: "application/json"
46+
}
47+
});
48+
49+
if (!response.ok) {
50+
return null;
51+
}
52+
53+
const payload = (await response.json()) as SessionResponse | null;
54+
55+
return parseAuthenticatedUser(payload);
56+
} catch {
57+
return null;
58+
}
59+
}
60+
61+
export async function getAuthenticatedUserFromRequest(req: Request): Promise<AuthenticatedUser | null> {
62+
const host = req.get("host");
63+
64+
if (!host) {
65+
return null;
66+
}
67+
68+
return resolveAuthUserFromSession({
69+
origin: `${req.protocol}://${host}`,
70+
cookieHeader: req.headers.cookie
71+
});
72+
}

apps/backend/src/chat/realtime.ts

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import type { Server as HttpServer, IncomingMessage } from "node:http";
2+
3+
import WebSocket, { WebSocketServer } from "ws";
4+
5+
import { resolveAuthUserFromSession } from "../auth/session.js";
6+
7+
import { type ChatMessage, isUserRoomMember } from "./service.js";
8+
9+
type ClientContext = {
10+
socket: WebSocket;
11+
userId: string;
12+
roomId?: string;
13+
};
14+
15+
type IncomingClientMessage = {
16+
type?: string;
17+
roomId?: string;
18+
};
19+
20+
export type ChatRealtimeBroadcaster = {
21+
broadcastMessage: (message: ChatMessage) => void;
22+
};
23+
24+
function getRequestOrigin(request: IncomingMessage): string | null {
25+
const host = request.headers.host;
26+
27+
if (!host) {
28+
return null;
29+
}
30+
31+
const forwardedProtocol =
32+
typeof request.headers["x-forwarded-proto"] === "string"
33+
? request.headers["x-forwarded-proto"]
34+
: undefined;
35+
const socketWithTlsFlag = request.socket as { encrypted?: boolean };
36+
const protocol = forwardedProtocol ?? (socketWithTlsFlag.encrypted ? "https" : "http");
37+
38+
return `${protocol}://${host}`;
39+
}
40+
41+
function safeSend(socket: WebSocket, payload: unknown): void {
42+
if (socket.readyState !== WebSocket.OPEN) {
43+
return;
44+
}
45+
46+
socket.send(JSON.stringify(payload));
47+
}
48+
49+
export class WsChatRealtimeGateway implements ChatRealtimeBroadcaster {
50+
private readonly server: WebSocketServer;
51+
52+
private readonly clients = new Set<ClientContext>();
53+
54+
constructor(httpServer: HttpServer) {
55+
this.server = new WebSocketServer({
56+
server: httpServer,
57+
path: "/ws/chat"
58+
});
59+
60+
this.server.on("connection", (socket, request) => {
61+
void this.handleConnection(socket, request);
62+
});
63+
}
64+
65+
broadcastMessage(message: ChatMessage): void {
66+
for (const client of this.clients) {
67+
if (client.roomId === message.roomId) {
68+
safeSend(client.socket, {
69+
type: "message:new",
70+
message
71+
});
72+
}
73+
}
74+
}
75+
76+
private async handleConnection(socket: WebSocket, request: IncomingMessage): Promise<void> {
77+
const origin = getRequestOrigin(request);
78+
79+
if (!origin) {
80+
socket.close(1008, "Missing origin");
81+
82+
return;
83+
}
84+
85+
const authUser = await resolveAuthUserFromSession({
86+
origin,
87+
cookieHeader: request.headers.cookie
88+
});
89+
90+
if (!authUser) {
91+
socket.close(1008, "Unauthorized");
92+
93+
return;
94+
}
95+
96+
const context: ClientContext = {
97+
socket,
98+
userId: authUser.id
99+
};
100+
101+
this.clients.add(context);
102+
103+
safeSend(socket, {
104+
type: "connected",
105+
userId: authUser.id
106+
});
107+
108+
socket.on("message", (rawMessage) => {
109+
void this.handleClientMessage(context, rawMessage.toString());
110+
});
111+
112+
socket.on("close", () => {
113+
this.clients.delete(context);
114+
});
115+
116+
socket.on("error", () => {
117+
this.clients.delete(context);
118+
});
119+
}
120+
121+
private async handleClientMessage(client: ClientContext, rawMessage: string): Promise<void> {
122+
let payload: IncomingClientMessage;
123+
124+
try {
125+
payload = JSON.parse(rawMessage) as IncomingClientMessage;
126+
} catch {
127+
safeSend(client.socket, {
128+
type: "error",
129+
message: "Invalid message payload"
130+
});
131+
132+
return;
133+
}
134+
135+
if (payload.type !== "subscribe" || !payload.roomId) {
136+
safeSend(client.socket, {
137+
type: "error",
138+
message: "Unsupported event"
139+
});
140+
141+
return;
142+
}
143+
144+
const isMember = await isUserRoomMember({
145+
userId: client.userId,
146+
roomId: payload.roomId
147+
});
148+
149+
if (!isMember) {
150+
safeSend(client.socket, {
151+
type: "error",
152+
message: "Access denied for room"
153+
});
154+
155+
return;
156+
}
157+
158+
client.roomId = payload.roomId;
159+
160+
safeSend(client.socket, {
161+
type: "subscribed",
162+
roomId: payload.roomId
163+
});
164+
}
165+
}

0 commit comments

Comments
 (0)