Skip to content

Commit dbe64ff

Browse files
committed
Added websocket handler to frontend.
1 parent ff4b3de commit dbe64ff

File tree

5 files changed

+187
-8
lines changed

5 files changed

+187
-8
lines changed

frontend/package-lock.json

Lines changed: 32 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@
2626
"react-router": "^7.5.3",
2727
"remix-auth": "^4.2.0",
2828
"remix-auth-form": "^3.0.0",
29-
"tsx": "^4.20.3"
29+
"tsx": "^4.20.3",
30+
"ws": "^8.18.3"
3031
},
3132
"devDependencies": {
3233
"@react-router/dev": "^7.5.3",
@@ -39,6 +40,7 @@
3940
"@types/node": "^20",
4041
"@types/react": "^19.1.2",
4142
"@types/react-dom": "^19.1.2",
43+
"@types/ws": "^8.18.1",
4244
"cross-env": "^7.0.3",
4345
"tailwindcss": "^4.1.4",
4446
"typed-css-modules": "^0.9.1",

frontend/server.ts

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,34 @@
11
import compression from "compression";
22
import express from "express";
33
import morgan from "morgan";
4+
import http from "http";
5+
import { WebSocketServer } from "ws";
46

57
// Short-circuit the type-checking of the built output.
68
const BUILD_PATH = "./build/server/index.js";
79
const DEVELOPMENT = process.env.NODE_ENV === "development";
810
const PORT = Number.parseInt(process.env.PORT || "3000");
911

12+
// Initialize the express app
1013
const app = express();
11-
1214
app.use(compression());
1315
app.disable("x-powered-by");
1416

17+
// Initialize the websocket server as soon as both it and the server-module are ready
18+
let _serverModule: any = null;
19+
let _websocketServer: WebSocketServer | null = null;
20+
const setWebsocketServer = (websocketServer: WebSocketServer) => {
21+
if (_websocketServer != null) return;
22+
if (_serverModule != null) _serverModule.initializeWebsocketServer(websocketServer);
23+
_websocketServer = websocketServer;
24+
}
25+
const setServerModule = (serverModule: any) => {
26+
if (_serverModule != null) return;
27+
if (_websocketServer != null) serverModule.initializeWebsocketServer(_websocketServer);
28+
_serverModule = serverModule;
29+
}
30+
31+
// Handle development vs production
1532
if (DEVELOPMENT) {
1633
console.log("Starting development server");
1734
const viteDevServer = await import("vite").then((vite) =>
@@ -22,8 +39,9 @@ if (DEVELOPMENT) {
2239
app.use(viteDevServer.middlewares);
2340
app.use(async (req, res, next) => {
2441
try {
25-
const source = await viteDevServer.ssrLoadModule("./server/app.ts");
26-
return await source.app(req, res, next);
42+
const serverModule = await viteDevServer.ssrLoadModule("./server/app.ts");
43+
setServerModule(serverModule);
44+
return await serverModule.app(req, res, next);
2745
} catch (error) {
2846
if (typeof error === "object" && error instanceof Error) {
2947
viteDevServer.ssrFixStacktrace(error);
@@ -44,9 +62,16 @@ if (DEVELOPMENT) {
4462
}
4563
}));
4664
app.use(express.static("build/client", { maxAge: "1h" }));
47-
app.use(await import(BUILD_PATH).then((mod) => mod.app));
65+
const serverModule = await import(BUILD_PATH);
66+
app.use(serverModule.app);
67+
setServerModule(serverModule);
4868
}
4969

50-
app.listen(PORT, () => {
70+
// Create both the http and websocket servers
71+
const server = http.createServer(app);
72+
setWebsocketServer(new WebSocketServer({ server }));
73+
74+
// Begin listening for connections
75+
server.listen(PORT, () => {
5176
console.log(`Server is running on http://localhost:${PORT}`);
5277
});

frontend/server/app.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import "react-router";
22
import { createRequestHandler } from "@react-router/express";
33
import express from "express";
44
import { createProxyMiddleware } from "http-proxy-middleware";
5+
import { websocketServer } from "./websocket.server";
56

67
declare module "react-router" {
78
interface AppLoadContext {
@@ -10,6 +11,7 @@ declare module "react-router" {
1011
}
1112

1213
export const app = express();
14+
export const initializeWebsocketServer = websocketServer.initialize;
1315

1416
// Proxy all webdav and api requests to the backend
1517
const forwardToBackend = createProxyMiddleware({
@@ -42,4 +44,4 @@ app.use(
4244
};
4345
},
4446
}),
45-
);
47+
);
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import WebSocket, { WebSocketServer } from 'ws';
2+
import { sessionStorage } from "../app/auth/authentication.server";
3+
import type { IncomingMessage } from 'http';
4+
5+
function initializeWebsocketServer(wss: WebSocketServer) {
6+
// keep track of socket subscriptions
7+
const websockets = new Map<WebSocket, string>();
8+
const subscriptions = new Map<string, Set<WebSocket>>();
9+
const lastMessage = new Map<string, string>();
10+
initializeWebsocketClient(subscriptions, lastMessage);
11+
12+
// authenticate new websocket sessions
13+
wss.on("connection", async (ws: WebSocket, request: IncomingMessage) => {
14+
const cookieHeader = request.headers.cookie;
15+
if (cookieHeader) {
16+
try {
17+
const session = await sessionStorage.getSession(cookieHeader);
18+
const user = session.get("user");
19+
if (!user) {
20+
console.warn("Websocket authentication failed. Sign in required.");
21+
ws.close(1008, "Unauthorized");
22+
return;
23+
}
24+
25+
// handle topic subscription
26+
ws.onmessage = (event: WebSocket.MessageEvent) => {
27+
var topic = event.data.toString();
28+
websockets.set(ws, topic);
29+
var topicSubscriptions = subscriptions.get(topic);
30+
if (topicSubscriptions) topicSubscriptions.add(ws);
31+
else subscriptions.set(topic, new Set<WebSocket>([ws]));
32+
var messageToSend = lastMessage.get(topic);
33+
if (messageToSend) ws.send(messageToSend);
34+
};
35+
36+
// unsubscribe from topics
37+
ws.onclose = () => {
38+
var topic = websockets.get(ws);
39+
if (topic) {
40+
websockets.delete(ws);
41+
var topicSubscriptions = subscriptions.get(topic);
42+
if (topicSubscriptions) topicSubscriptions.delete(ws);
43+
}
44+
};
45+
} catch (error) {
46+
console.error("Error authenticating websocket session:", error);
47+
ws.close(1011, "Internal server error");
48+
return;
49+
}
50+
} else {
51+
console.warn("Websocket authentication failed. Sign in required.");
52+
ws.close(1008, "Unauthorized");
53+
return;
54+
}
55+
});
56+
}
57+
58+
export function initializeWebsocketClient(subscriptions: Map<string, Set<WebSocket>>, lastMessage: Map<string, string>) {
59+
let reconnectRetryDelay = 1000;
60+
let reconnectRetryMaxDelay = 30000;
61+
let reconnectTimeout: NodeJS.Timeout | null = null;
62+
const url = getBackendWebsocketUrl();
63+
64+
function connect() {
65+
const socket = new WebSocket(url);
66+
67+
socket.onopen = () => {
68+
reconnectRetryDelay = 1000;
69+
if (reconnectTimeout) {
70+
clearTimeout(reconnectTimeout);
71+
reconnectTimeout = null;
72+
}
73+
74+
socket.send(Buffer.from(process.env.FRONTEND_BACKEND_API_KEY!, "utf-8"), { binary: false });
75+
};
76+
77+
socket.onmessage = (event: WebSocket.MessageEvent) => {
78+
var rawMessage = event.data.toString();
79+
var topicMessage = JSON.parse(rawMessage);
80+
var [topic, message] = [topicMessage.Topic, topicMessage.Message];
81+
if (!topic || !message) return;
82+
lastMessage.set(topic, message);
83+
var subscribed = subscriptions.get(topic) || [];
84+
subscribed.forEach(client => {
85+
if (client.readyState === client.OPEN) {
86+
client.send(message);
87+
}
88+
});
89+
};
90+
91+
socket.onerror = (event: WebSocket.ErrorEvent) => {
92+
console.error('WebSocket error:', event.message);
93+
};
94+
95+
socket.onclose = (event: WebSocket.CloseEvent) => {
96+
console.info(`WebSocket closed (code: ${event.code}, reason: ${event.reason}) — retrying in ${reconnectRetryDelay / 1000}s`);
97+
scheduleReconnect();
98+
};
99+
}
100+
101+
function scheduleReconnect() {
102+
if (reconnectTimeout) return;
103+
reconnectTimeout = setTimeout(() => {
104+
reconnectRetryDelay = Math.min(reconnectRetryDelay * 2, reconnectRetryMaxDelay);
105+
connect();
106+
}, reconnectRetryDelay);
107+
}
108+
109+
connect();
110+
}
111+
112+
function getBackendWebsocketUrl() {
113+
const host = process.env.BACKEND_URL!;
114+
return `${host.replace(/\/$/, '')}/ws`.replace(/^http/, 'ws');
115+
}
116+
117+
export const websocketServer = {
118+
initialize: initializeWebsocketServer
119+
}

0 commit comments

Comments
 (0)