Merge pull request 'feat: add WebSocket server with ticket auth and heartbeat (#38)' (#45) from issue-38-websocket into issue-35-web-setup

This commit is contained in:
maximus 2026-04-06 16:07:00 +00:00
commit 2f2a48f644
7 changed files with 221 additions and 21 deletions

View file

@ -25,10 +25,12 @@ RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/server.ts ./server.ts
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]
# Use custom server instead of default next start
CMD ["node", "server.ts"]

33
web/package-lock.json generated
View file

@ -16,6 +16,7 @@
"pg": "^8.20.0",
"react": "19.2.4",
"react-dom": "19.2.4",
"ws": "^8.20.0",
"zod": "^4.3.6"
},
"devDependencies": {
@ -23,6 +24,7 @@
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/ws": "^8.18.1",
"drizzle-kit": "^0.31.10",
"eslint": "^9",
"eslint-config-next": "16.2.2",
@ -2543,6 +2545,16 @@
"@types/react": "^19.2.0"
}
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.58.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.0.tgz",
@ -8465,6 +8477,27 @@
"node": ">=0.10.0"
}
},
"node_modules/ws": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",

View file

@ -17,6 +17,7 @@
"pg": "^8.20.0",
"react": "19.2.4",
"react-dom": "19.2.4",
"ws": "^8.20.0",
"zod": "^4.3.6"
},
"devDependencies": {
@ -24,6 +25,7 @@
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/ws": "^8.18.1",
"drizzle-kit": "^0.31.10",
"eslint": "^9",
"eslint-config-next": "16.2.2",

24
web/server.ts Normal file
View file

@ -0,0 +1,24 @@
import { createServer } from 'http';
import next from 'next';
import { setupWebSocket } from './src/lib/ws';
const dev = process.env.NODE_ENV !== 'production';
const hostname = process.env.HOSTNAME || '0.0.0.0';
const port = parseInt(process.env.PORT || '3000', 10);
const app = next({ dev, hostname, port });
const handle = app.getRequestHandler();
app.prepare().then(() => {
const server = createServer((req, res) => {
// Don't log query params on /ws route (ticket security)
handle(req, res);
});
setupWebSocket(server);
server.listen(port, hostname, () => {
console.log(`> Ready on http://${hostname}:${port}`);
console.log(`> WebSocket server on ws://${hostname}:${port}/ws`);
});
});

View file

@ -1,6 +1,7 @@
import { NextResponse } from 'next/server';
import { db } from '@/db/client';
import { sql } from 'drizzle-orm';
import { getActiveConnections } from '@/lib/ws';
export async function GET() {
const start = Date.now();
@ -16,6 +17,9 @@ export async function GET() {
status: 'connected',
latencyMs: dbLatency,
},
ws: {
activeConnections: getActiveConnections(),
},
});
} catch (error) {
return NextResponse.json({
@ -25,6 +29,9 @@ export async function GET() {
status: 'disconnected',
error: error instanceof Error ? error.message : 'Unknown error',
},
ws: {
activeConnections: getActiveConnections(),
},
}, { status: 503 });
}
}

View file

@ -1,35 +1,20 @@
import { NextResponse } from 'next/server';
import { randomUUID } from 'crypto';
import { requireAuth } from '@/lib/api';
// In-memory ticket store (TTL 30s, single use)
const ticketStore = new Map<string, { userId: string; expiresAt: number }>();
import { getTicketStore } from '@/lib/ws';
const TTL_30S = 30 * 1000;
// Cleanup expired tickets
function cleanupTickets() {
const store = getTicketStore();
const now = Date.now();
for (const [key, entry] of ticketStore) {
for (const [key, entry] of store) {
if (entry.expiresAt < now) {
ticketStore.delete(key);
store.delete(key);
}
}
}
/**
* Validate and consume a WS ticket. Returns userId if valid, null otherwise.
*/
export function consumeTicket(ticket: string): string | null {
const entry = ticketStore.get(ticket);
if (!entry || entry.expiresAt < Date.now()) {
ticketStore.delete(ticket);
return null;
}
ticketStore.delete(ticket); // Single use
return entry.userId;
}
export async function POST() {
const auth = await requireAuth();
if (auth.error) return auth.error;
@ -37,7 +22,7 @@ export async function POST() {
cleanupTickets();
const ticket = randomUUID();
ticketStore.set(ticket, {
getTicketStore().set(ticket, {
userId: auth.userId,
expiresAt: Date.now() + TTL_30S,
});

147
web/src/lib/ws.ts Normal file
View file

@ -0,0 +1,147 @@
import { WebSocketServer, WebSocket } from 'ws';
import type { IncomingMessage } from 'http';
import type { Server } from 'http';
export type WsMessage =
| { type: 'sync'; entity: 'list' | 'task' | 'tag'; action: 'create' | 'update' | 'delete'; id: string }
| { type: 'auth_expired' };
interface AuthenticatedSocket extends WebSocket {
userId: string;
lastValidated: number;
isAlive: boolean;
}
// Ticket store (shared with /api/ws-ticket route)
// In production this would be imported from a shared module
const ticketStore = globalThis as unknown as {
__wsTickets?: Map<string, { userId: string; expiresAt: number }>;
};
export function getTicketStore() {
if (!ticketStore.__wsTickets) {
ticketStore.__wsTickets = new Map();
}
return ticketStore.__wsTickets;
}
const ALLOWED_ORIGINS = [
'https://liste.lacompagniemaximus.com',
'http://localhost:3000',
];
const REVALIDATION_INTERVAL = 15 * 60 * 1000; // 15 minutes
const HEARTBEAT_INTERVAL = 30 * 1000; // 30 seconds
const clients = new Set<AuthenticatedSocket>();
export function setupWebSocket(server: Server) {
const wss = new WebSocketServer({ noServer: true });
server.on('upgrade', (request: IncomingMessage, socket, head) => {
const url = new URL(request.url || '', `http://${request.headers.host}`);
if (url.pathname !== '/ws') {
socket.destroy();
return;
}
// Validate Origin
const origin = request.headers.origin;
if (origin && !ALLOWED_ORIGINS.includes(origin)) {
socket.destroy();
return;
}
// Validate ticket
const ticket = url.searchParams.get('ticket');
if (!ticket) {
socket.destroy();
return;
}
const store = getTicketStore();
const entry = store.get(ticket);
if (!entry || entry.expiresAt < Date.now()) {
store.delete(ticket);
socket.destroy();
return;
}
// Invalidate ticket (single use)
const userId = entry.userId;
store.delete(ticket);
wss.handleUpgrade(request, socket, head, (ws) => {
const authWs = ws as AuthenticatedSocket;
authWs.userId = userId;
authWs.lastValidated = Date.now();
authWs.isAlive = true;
wss.emit('connection', authWs);
});
});
wss.on('connection', (ws: AuthenticatedSocket) => {
clients.add(ws);
ws.on('pong', () => {
ws.isAlive = true;
});
ws.on('close', () => {
clients.delete(ws);
});
ws.on('error', () => {
clients.delete(ws);
});
});
// Heartbeat + session revalidation
const interval = setInterval(() => {
const now = Date.now();
for (const ws of clients) {
// Check session expiry
if (now - ws.lastValidated > REVALIDATION_INTERVAL) {
const msg: WsMessage = { type: 'auth_expired' };
ws.send(JSON.stringify(msg));
ws.terminate();
clients.delete(ws);
continue;
}
// Heartbeat
if (!ws.isAlive) {
ws.terminate();
clients.delete(ws);
continue;
}
ws.isAlive = false;
ws.ping();
}
}, HEARTBEAT_INTERVAL);
wss.on('close', () => {
clearInterval(interval);
});
return wss;
}
/**
* Broadcast a sync notification to all connected clients of a specific user.
*/
export function broadcastToUser(userId: string, message: WsMessage) {
const payload = JSON.stringify(message);
for (const ws of clients) {
if (ws.userId === userId && ws.readyState === WebSocket.OPEN) {
ws.send(payload);
}
}
}
export function getActiveConnections(): number {
return clients.size;
}