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:
@@ -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));
|
||||
}
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user