Phase 1 MVP: gateway, CRUD handlers, frontend components

Backend:
- WebSocket gateway (hub, client, events) with fanout broadcast
- Server CRUD handlers (create, list, get, update, delete)
- Channel CRUD handlers (create, list, get, update, delete)
- Message CRUD handlers (list with cursor pagination, create, update, delete)
- cmd/migrate standalone migration CLI (up/down)
- cmd/server wired to all handlers + WebSocket + static file serving

Frontend:
- Zustand stores: auth, server, channel, message, websocket
- API client with fetch wrapper
- Terminal-styled components: Layout, LoginForm, ChatArea, ChannelList, ServerBar, MemberList
- React Router with login and main routes
- Gruvbox dark palette throughout

Ops:
- Docker Compose with app service (multi-stage build)
- Caddyfile with WebSocket upgrade support
- Makefile for common tasks
This commit is contained in:
2026-06-26 14:47:29 -04:00
parent aa7854aee2
commit bb5a56816b
32 changed files with 2310 additions and 339 deletions
+181
View File
@@ -0,0 +1,181 @@
import { create } from 'zustand';
import { useMessageStore } from './message.ts';
import { useChannelStore } from './channel.ts';
import { useServerStore } from './server.ts';
import type { Message } from './message.ts';
import type { Channel } from './channel.ts';
import type { Server } from './server.ts';
type UnknownPayload = Record<string, unknown>;
export interface WsEvent {
type: string;
payload: UnknownPayload;
}
interface WebSocketState {
socket: WebSocket | null;
connected: boolean;
connect: (token: string) => void;
disconnect: () => void;
send: (event: WsEvent) => void;
}
function getWsHost(): string {
const { protocol, host } = window.location;
const wsProtocol = protocol === 'https:' ? 'wss:' : 'ws:';
return `${wsProtocol}//${host}`;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
function extractMessage(payload: UnknownPayload): Message | null {
if (!isRecord(payload.message)) return null;
return payload.message as unknown as Message;
}
function extractChannel(payload: UnknownPayload): Channel | null {
if (!isRecord(payload.channel)) return null;
return payload.channel as unknown as Channel;
}
function extractServer(payload: UnknownPayload): Server | null {
if (!isRecord(payload.server)) return null;
return payload.server as unknown as Server;
}
function extractIds(payload: UnknownPayload): { channelId: string; messageId: string } | null {
if (typeof payload.channelId !== 'string' || typeof payload.messageId !== 'string') {
return null;
}
return { channelId: payload.channelId, messageId: payload.messageId };
}
function extractChannelId(payload: UnknownPayload): string | null {
return typeof payload.channelId === 'string' ? payload.channelId : null;
}
export const useWebSocketStore = create<WebSocketState>((set, get) => ({
socket: null,
connected: false,
connect: (token: string) => {
const existing = get().socket;
if (existing && (existing.readyState === WebSocket.OPEN || existing.readyState === WebSocket.CONNECTING)) {
return;
}
const socket = new WebSocket(`${getWsHost()}/ws?token=${encodeURIComponent(token)}`);
let reconnectDelay = 1000;
const maxReconnectDelay = 30000;
let reconnectTimeout: number | null = null;
const scheduleReconnect = () => {
if (reconnectTimeout) {
window.clearTimeout(reconnectTimeout);
}
reconnectTimeout = window.setTimeout(() => {
if (!get().connected) {
get().connect(token);
}
}, reconnectDelay);
reconnectDelay = Math.min(reconnectDelay * 2, maxReconnectDelay);
};
socket.onopen = () => {
set({ connected: true });
reconnectDelay = 1000;
};
socket.onmessage = (event) => {
let data: WsEvent;
try {
data = JSON.parse(event.data) as WsEvent;
} catch {
return;
}
if (data.type === 'ping') {
get().send({ type: 'pong', payload: {} });
return;
}
const addMessage = useMessageStore.getState().addMessage;
const updateMessage = useMessageStore.getState().updateMessage;
const removeMessage = useMessageStore.getState().removeMessage;
const addChannel = useChannelStore.getState().addChannel;
const updateChannel = useChannelStore.getState().updateChannel;
const removeChannel = useChannelStore.getState().removeChannel;
const updateServer = useServerStore.getState().updateServer;
switch (data.type) {
case 'message': {
const msg = extractMessage(data.payload);
if (msg) addMessage(msg);
break;
}
case 'message_updated': {
const msg = extractMessage(data.payload);
if (msg) updateMessage(msg);
break;
}
case 'message_deleted': {
const ids = extractIds(data.payload);
if (ids) removeMessage(ids.channelId, ids.messageId);
break;
}
case 'channel_created': {
const channel = extractChannel(data.payload);
if (channel) addChannel(channel);
break;
}
case 'channel_updated': {
const channel = extractChannel(data.payload);
if (channel) updateChannel(channel);
break;
}
case 'channel_deleted': {
const channelId = extractChannelId(data.payload);
if (channelId) removeChannel(channelId);
break;
}
case 'server_updated': {
const server = extractServer(data.payload);
if (server) updateServer(server);
break;
}
default:
break;
}
};
socket.onclose = () => {
set({ connected: false, socket: null });
scheduleReconnect();
};
socket.onerror = () => {
socket.close();
};
set({ socket });
},
disconnect: () => {
const socket = get().socket;
if (socket) {
socket.close();
}
set({ socket: null, connected: false });
},
send: (event) => {
const socket = get().socket;
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify(event));
}
},
}));