This commit is contained in:
2025-05-21 08:46:12 +03:00
parent 9531bff01a
commit 079ce23363
94 changed files with 4630 additions and 2704 deletions

View File

@@ -1,34 +1,34 @@
import axios from "../http-client"
import type { FullUser } from "../types"
import axios from "../http-client";
import type { FullUser } from "../types";
interface RegisterRequest {
email: string
username: string
displayName?: string
password: string
email: string;
username: string;
displayName?: string;
password: string;
}
interface LoginRequest {
username: string
password: string
username: string;
password: string;
}
interface LoginResponse {
user: FullUser
token: string
user: FullUser;
token: string;
}
export async function register(request: RegisterRequest) {
await axios.post("/auth/register", request)
await axios.post("/auth/register", request);
}
export async function login(request: LoginRequest) {
const response = await axios.post("/auth/login", request)
const response = await axios.post("/auth/login", request);
return response.data as LoginResponse
return response.data as LoginResponse;
}
export default {
register,
login,
}
};

View File

@@ -10,26 +10,28 @@ export async function paginatedMessages(
params: {
limit,
before,
}
})
},
});
return (response.data as any[]).map((value, _) => messageSchema.parse(value))
return (response.data as any[]).map((value, _) =>
messageSchema.parse(value),
);
}
export async function sendMessage(
channelId: ChannelId,
content: string,
attachments?: Uuid[]
attachments?: Uuid[],
) {
const response = await axios.post(`/channels/${channelId}/messages`, {
content,
attachments,
})
});
return messageSchema.parse(response.data)
return messageSchema.parse(response.data);
}
export default {
paginatedMessages,
sendMessage,
}
};

View File

@@ -1,26 +1,26 @@
import axios from "../http-client"
import axios from "../http-client";
export async function uploadFile(file: File) {
const formData = new FormData()
formData.append("files", file)
const formData = new FormData();
formData.append("files", file);
const response = await axios.postForm(`/files`, formData)
const response = await axios.postForm(`/files`, formData);
return response.data as string[]
return response.data as string[];
}
export async function uploadFiles(file: File[]) {
const formData = new FormData()
const formData = new FormData();
for (const f of file) {
formData.append("files", f)
formData.append("files", f);
}
const response = await axios.postForm(`/files`, formData)
const response = await axios.postForm(`/files`, formData);
return response.data as string[]
return response.data as string[];
}
export default {
uploadFile,
uploadFiles
}
uploadFiles,
};

View File

@@ -1,74 +1,88 @@
import axios from "../http-client"
import type { ChannelId, ChannelType, Server, ServerChannel, ServerId, ServerInvite } from "../types"
import axios from "../http-client";
import type {
ChannelId,
ChannelType,
Server,
ServerChannel,
ServerId,
ServerInvite,
} from "../types";
interface CreateServerRequest {
name: string
iconId?: string
name: string;
iconId?: string;
}
interface CreateServerChannelRequest {
name: string
type: ChannelType
name: string;
type: ChannelType;
}
export async function list() {
const response = await axios.get("/servers")
const response = await axios.get("/servers");
return response.data as Server[]
return response.data as Server[];
}
export async function create(request: CreateServerRequest) {
const response = await axios.post("/servers", request)
const response = await axios.post("/servers", request);
return response.data as Server
return response.data as Server;
}
export async function get(serverId: ServerId) {
const response = await axios.get(`/servers/${serverId}`)
const response = await axios.get(`/servers/${serverId}`);
return response.data as Server
return response.data as Server;
}
export async function delet(serverId: ServerId) {
const response = await axios.delete(`/servers/${serverId}`)
const response = await axios.delete(`/servers/${serverId}`);
return response.data as Server
return response.data as Server;
}
export async function listChannels(serverId: ServerId) {
const response = await axios.get(`/servers/${serverId}/channels`)
const response = await axios.get(`/servers/${serverId}/channels`);
return response.data as ServerChannel[]
return response.data as ServerChannel[];
}
export async function createChannel(serverId: ServerId, request: CreateServerChannelRequest) {
const response = await axios.post(`/servers/${serverId}/channels`, request)
export async function createChannel(
serverId: ServerId,
request: CreateServerChannelRequest,
) {
const response = await axios.post(`/servers/${serverId}/channels`, request);
return response.data as ServerChannel
return response.data as ServerChannel;
}
export async function getChannel(serverId: ServerId, channelId: ChannelId) {
const response = await axios.get(`/servers/${serverId}/channels/${channelId}`)
const response = await axios.get(
`/servers/${serverId}/channels/${channelId}`,
);
return response.data as ServerChannel
return response.data as ServerChannel;
}
export async function deleteChannel(serverId: ServerId, channelId: ChannelId) {
const response = await axios.delete(`/servers/${serverId}/channels/${channelId}`)
const response = await axios.delete(
`/servers/${serverId}/channels/${channelId}`,
);
return response.data as ServerChannel
return response.data as ServerChannel;
}
export async function createInvite(serverId: ServerId) {
const response = await axios.post(`/servers/${serverId}/invites`)
const response = await axios.post(`/servers/${serverId}/invites`);
return response.data as ServerInvite
return response.data as ServerInvite;
}
export async function getInvite(inviteCode: string) {
const response = await axios.get(`/invites/${inviteCode}`)
const response = await axios.get(`/invites/${inviteCode}`);
return response.data as Server
return response.data as Server;
}
export default {
@@ -81,5 +95,5 @@ export default {
getChannel,
deleteChannel,
createInvite,
getInvite
}
getInvite,
};

View File

@@ -1,38 +1,53 @@
import axios from "../http-client"
import type { FullUser, PartialUser, RecipientChannel, UserId, Uuid } from "../types"
import axios from "../http-client";
import type {
FullUser,
PartialUser,
RecipientChannel,
UserId,
Uuid,
} from "../types";
export async function me() {
const response = await axios.get("/users/@me")
const response = await axios.get("/users/@me");
return response.data as FullUser
return response.data as FullUser;
}
export async function getUser(userId: UserId) {
const response = await axios.get(`/users/${userId}`)
const response = await axios.get(`/users/${userId}`);
return response.data as PartialUser
return response.data as PartialUser;
}
export async function channels() {
const response = await axios.get("/users/@me/channels")
const response = await axios.get("/users/@me/channels");
return response.data as RecipientChannel[]
return response.data as RecipientChannel[];
}
export async function createChannel(recipients: UserId[]) {
const response = await axios.post("/users/@me/channels", {
recipients,
});
return response.data as RecipientChannel;
}
interface PatchUserRequest {
displayName?: string | null
avatarId?: Uuid | null
displayName?: string | null;
avatarId?: Uuid | null;
}
export async function patchUser(request: PatchUserRequest) {
const response = await axios.patch(`/users/@me`, request)
const response = await axios.patch(`/users/@me`, request);
return response.data as FullUser
return response.data as FullUser;
}
export default {
me,
channels,
createChannel,
getUser,
patchUser
}
patchUser,
};

View File

@@ -1,23 +1,23 @@
import axios from "axios"
import { useTokenStore } from "~/stores/token-store"
import { API_URL } from "../consts"
import axios from "axios";
import { useTokenStore } from "~/stores/token-store";
import { API_URL } from "../consts";
axios.interceptors.request.use(
(config) => {
const token = useTokenStore.getState().token
const token = useTokenStore.getState().token;
if (token) {
config.headers.Authorization = `Bearer ${token}`
config.headers.Authorization = `Bearer ${token}`;
}
return config
return config;
},
(error) => {
return Promise.reject(error)
}
)
return Promise.reject(error);
},
);
axios.defaults.baseURL = API_URL
axios.defaults.headers.common["Content-Type"] = "application/json"
axios.defaults.baseURL = API_URL;
axios.defaults.headers.common["Content-Type"] = "application/json";
export default axios
export default axios;

View File

@@ -1,85 +1,86 @@
import { z } from "zod";
export type TypeToZod<T> = {
[K in keyof T]:
// 1. Handle Arrays (including arrays of objects, optional or required)
[K in keyof T]: // 1. Handle Arrays (including arrays of objects, optional or required)
T[K] extends ReadonlyArray<infer E> | undefined
? undefined extends T[K]
? E extends object
? z.ZodOptional<z.ZodArray<z.ZodObject<TypeToZod<E>>>>
: z.ZodOptional<z.ZodArray<z.ZodType<Exclude<E, null | undefined>>>>
: E extends object
? z.ZodArray<z.ZodObject<TypeToZod<E>>>
: z.ZodArray<z.ZodType<Exclude<E, null | undefined>>>
// 2. Handle Primitives
: T[K] extends string | number | boolean | Date | null | undefined
? undefined extends T[K]
? z.ZodOptional<z.ZodType<Exclude<T[K], undefined | null>>>
: z.ZodType<T[K]>
// 3. Handle Objects (required or optional, but not arrays)
: T[K] extends object | undefined
? undefined extends T[K]
? z.ZodOptional<z.ZodObject<TypeToZod<NonNullable<T[K]>>>>
: T[K] extends object
? z.ZodObject<TypeToZod<T[K]>>
: z.ZodUnknown // Fallback for unexpected required non-object/non-primitive types
// 4. Fallback for any other types
: z.ZodUnknown;
? undefined extends T[K]
? E extends object
? z.ZodOptional<z.ZodArray<z.ZodObject<TypeToZod<E>>>>
: z.ZodOptional<
z.ZodArray<z.ZodType<Exclude<E, null | undefined>>>
>
: E extends object
? z.ZodArray<z.ZodObject<TypeToZod<E>>>
: z.ZodArray<z.ZodType<Exclude<E, null | undefined>>>
: // 2. Handle Primitives
T[K] extends string | number | boolean | Date | null | undefined
? undefined extends T[K]
? z.ZodOptional<z.ZodType<Exclude<T[K], undefined | null>>>
: z.ZodType<T[K]>
: // 3. Handle Objects (required or optional, but not arrays)
T[K] extends object | undefined
? undefined extends T[K]
? z.ZodOptional<z.ZodObject<TypeToZod<NonNullable<T[K]>>>>
: T[K] extends object
? z.ZodObject<TypeToZod<T[K]>>
: z.ZodUnknown // Fallback for unexpected required non-object/non-primitive types
: // 4. Fallback for any other types
z.ZodUnknown;
};
export const createZodObject = <T>(obj: TypeToZod<T>) => {
return z.object(obj);
};
export type Uuid = string
export type Uuid = string;
export type UserId = Uuid
export type ServerId = Uuid
export type ChannelId = Uuid
export type MessageId = Uuid
export type UserId = Uuid;
export type ServerId = Uuid;
export type ChannelId = Uuid;
export type MessageId = Uuid;
export interface FullUser {
id: UserId
avatarUrl?: string
username: string
displayName?: string
email: string
bot: boolean
system: boolean
settings: any
id: UserId;
avatarUrl?: string;
username: string;
displayName?: string;
email: string;
bot: boolean;
system: boolean;
settings: any;
}
export interface Server {
id: ServerId
name: string
iconUrl?: string
ownerId: UserId
id: ServerId;
name: string;
iconUrl?: string;
ownerId: UserId;
}
export enum ChannelType {
SERVER_TEXT = 'server_text',
SERVER_VOICE = 'server_voice',
SERVER_CATEGORY = 'server_category',
SERVER_TEXT = "server_text",
SERVER_VOICE = "server_voice",
SERVER_CATEGORY = "server_category",
DIRECT_MESSAGE = 'direct_message',
GROUP = 'group',
DIRECT_MESSAGE = "direct_message",
GROUP = "group",
}
export interface Message {
id: MessageId
channelId: ChannelId
authorId: UserId
content: string
createdAt: Date
attachments: UploadedFile[]
id: MessageId;
channelId: ChannelId;
authorId: UserId;
content: string;
createdAt: Date;
attachments: UploadedFile[];
}
export interface UploadedFile {
id: Uuid
filename: string
contentType: string
size: number
url: string
id: Uuid;
filename: string;
contentType: string;
size: number;
url: string;
}
export const uploadFileSchema = createZodObject<UploadedFile>({
@@ -88,7 +89,7 @@ export const uploadFileSchema = createZodObject<UploadedFile>({
contentType: z.string(),
size: z.number(),
url: z.string(),
})
});
export const messageSchema = createZodObject<Message>({
id: z.string(),
@@ -97,44 +98,44 @@ export const messageSchema = createZodObject<Message>({
content: z.string(),
createdAt: z.coerce.date(),
attachments: z.array(uploadFileSchema),
})
});
export interface Channel {
id: ChannelId
name: string
type: ChannelType
lastMessageId?: MessageId
id: ChannelId;
name: string;
type: ChannelType;
lastMessageId?: MessageId;
}
export interface ServerChannel {
id: ChannelId
name: string
type: ChannelType
lastMessageId?: MessageId
serverId: ServerId
parentId?: ChannelId
id: ChannelId;
name: string;
type: ChannelType;
lastMessageId?: MessageId;
serverId: ServerId;
parentId?: ChannelId;
}
export interface ServerInvite {
code: string
serverId: ServerId
inviterId?: UserId
expiresAt?: string
code: string;
serverId: ServerId;
inviterId?: UserId;
expiresAt?: string;
}
export interface RecipientChannel {
id: ChannelId
name: string
type: ChannelType
lastMessageId?: MessageId
recipients: PartialUser[]
id: ChannelId;
name: string;
type: ChannelType;
lastMessageId?: MessageId;
recipients: PartialUser[];
}
export interface PartialUser {
id: ChannelId
username: string
displayName?: string
avatarUrl?: string,
bot: boolean
system: boolean
}
id: ChannelId;
username: string;
displayName?: string;
avatarUrl?: string;
bot: boolean;
system: boolean;
}

View File

@@ -1 +1 @@
export const API_URL = "http://localhost:12345/api/v1"
export const API_URL = "http://localhost:12345/api/v1";

View File

@@ -2,42 +2,46 @@ import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
return twMerge(clsx(inputs));
}
export function getFirstLetters(str: string, n: number): string {
return str
.split(/\s+/)
.slice(0, n)
.map(word => word[0] || '')
.join('');
return str
.split(/\s+/)
.slice(0, n)
.map((word) => word[0] || "")
.join("");
}
export function formatFileSize(bytes: number, decimals = 2): string {
if (bytes === 0) return '0 Bytes';
if (bytes === 0) return "0 Bytes";
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i];
}
export function createPrefixedLogger(prefix: string, styles?: string[]) {
const result: Record<string, (...args: any[]) => void> = {};
const result: Record<string, (...args: any[]) => void> = {};
const methods = ['log', 'trace', 'debug', 'info', 'warn', 'error'] as const;
const methods = ["log", "trace", "debug", "info", "warn", "error"] as const;
for (const methodName of methods) {
const originalMethod = console[methodName].bind(console);
for (const methodName of methods) {
const originalMethod = console[methodName].bind(console);
result[methodName] = (...args: any[]) => {
if (typeof args[0] === 'string') {
originalMethod(`${prefix} ${args[0]}`, ...(styles || []), ...args.slice(1));
} else {
originalMethod(prefix, styles, ...args);
}
};
}
result[methodName] = (...args: any[]) => {
if (typeof args[0] === "string") {
originalMethod(
`${prefix} ${args[0]}`,
...(styles || []),
...args.slice(1),
);
} else {
originalMethod(prefix, styles, ...args);
}
};
}
return result;
}
return result;
}

View File

@@ -1,5 +1,5 @@
import type { ChannelId, ServerId, UserId } from '~/lib/api/types';
import { createPrefixedLogger } from '~/lib/utils';
import type { ChannelId, ServerId, UserId } from "~/lib/api/types";
import { createPrefixedLogger } from "~/lib/utils";
import {
type ClientMessage,
ClientMessageType,
@@ -8,11 +8,11 @@ import {
type EventData,
EventType,
type ServerMessage,
ServerMessageType
} from './types';
ServerMessageType,
} from "./types";
export type GatewayEvents = {
[K in EventType]: (data: Extract<EventData, { type: K }>['data']) => void;
[K in EventType]: (data: Extract<EventData, { type: K }>["data"]) => void;
};
export type ControlEvents = {
@@ -48,21 +48,21 @@ export class GatewayClient {
this.options = {
reconnect: options.reconnect ?? true,
reconnectDelay: options.reconnectDelay ?? 5000,
maxReconnectAttempts: options.maxReconnectAttempts ?? 10
maxReconnectAttempts: options.maxReconnectAttempts ?? 10,
};
}
// Public methods
public connect(token: string): void {
logger.log('Connecting to %s', this.url);
logger.log("Connecting to %s", this.url);
if (this.connectionLock) {
logger.warn('Connection already in progress');
logger.warn("Connection already in progress");
return;
}
if (this.token === token) {
logger.warn('Token is the same as the current token');
logger.warn("Token is the same as the current token");
return;
}
@@ -79,7 +79,7 @@ export class GatewayClient {
}
public disconnect(): void {
logger.log('Disconnecting');
logger.log("Disconnecting");
this.closeInitiatedByClient = true;
this.cleanupSocket();
@@ -91,18 +91,21 @@ export class GatewayClient {
public updateVoiceState(serverId: ServerId, channelId: ChannelId): void {
this.sendMessage({
type: ClientMessageType.VOICE_STATE_UPDATE,
data: { serverId, channelId }
data: { serverId, channelId },
});
}
public requestVoiceStates(serverId: ServerId): void {
this.sendMessage({
type: ClientMessageType.REQUEST_VOICE_STATES,
data: { serverId }
data: { serverId },
});
}
public onEvent<K extends keyof GatewayEvents>(event: K | string, handler: GatewayEvents[K]): void {
public onEvent<K extends keyof GatewayEvents>(
event: K | string,
handler: GatewayEvents[K],
): void {
this.serverEventHandlers[event as K] = handler;
}
@@ -110,7 +113,10 @@ export class GatewayClient {
delete this.serverEventHandlers[event];
}
public onControl<K extends keyof ControlEvents>(event: K, handler: ControlEvents[K]): void {
public onControl<K extends keyof ControlEvents>(
event: K,
handler: ControlEvents[K],
): void {
this.eventHandlers[event] = handler;
}
@@ -142,7 +148,7 @@ export class GatewayClient {
this.socket.onerror = this.onSocketError.bind(this);
this.socket.onclose = this.onSocketClose.bind(this);
} catch (error) {
this.emitError(new Error('Failed to create WebSocket connection'));
this.emitError(new Error("Failed to create WebSocket connection"));
this.setState(ConnectionState.ERROR);
}
}
@@ -150,16 +156,16 @@ export class GatewayClient {
private onSocketOpen(): void {
this.connectionLock = false;
logger.log('Socket opened');
logger.log("Socket opened");
this.setState(ConnectionState.AUTHENTICATING);
if (this.token) {
this.sendMessage({
type: ClientMessageType.AUTHENTICATE,
data: { token: this.token }
data: { token: this.token },
});
} else {
this.emitError(new Error('No authentication token provided'));
this.emitError(new Error("No authentication token provided"));
this.disconnect();
}
}
@@ -169,19 +175,23 @@ export class GatewayClient {
const message = JSON.parse(event.data) as ServerMessage;
this.handleServerMessage(message);
} catch (error) {
this.emitError(new Error('Failed to parse WebSocket message', { cause: error }));
this.emitError(
new Error("Failed to parse WebSocket message", {
cause: error,
}),
);
}
}
private onSocketError(event: Event): void {
this.connectionLock = false;
logger.log('Socket error: %s', event);
logger.log("Socket error: %s", event);
this.emitError(new Error('WebSocket error occurred'));
this.emitError(new Error("WebSocket error occurred"));
}
private onSocketClose(event: CloseEvent): void {
logger.log('Socket closed: %s', event);
logger.log("Socket closed: %s", event);
this.connectionLock = false;
@@ -190,7 +200,12 @@ export class GatewayClient {
!this.closeInitiatedByClient &&
this.reconnectAttempts < this.options.maxReconnectAttempts
) {
logger.log('Reconnecting in %d seconds (%d/%d)', this.options.reconnectDelay / 1000, this.reconnectAttempts + 1, this.options.maxReconnectAttempts);
logger.log(
"Reconnecting in %d seconds (%d/%d)",
this.options.reconnectDelay / 1000,
this.reconnectAttempts + 1,
this.options.maxReconnectAttempts,
);
this.reconnectAttempts++;
this.reconnectTimeout = setTimeout(() => {
@@ -204,23 +219,30 @@ export class GatewayClient {
}
private handleServerMessage(message: ServerMessage): void {
logger.log('Received message: ', message);
logger.log("Received message: ", message);
switch (message.type) {
case ServerMessageType.AUTHENTICATE_ACCEPTED:
this.userId = message.data.userId;
this.sessionKey = message.data.sessionKey;
this.setState(ConnectionState.CONNECTED);
this.emitControl('authenticated', message.data.userId, message.data.sessionKey);
this.emitControl(
"authenticated",
message.data.userId,
message.data.sessionKey,
);
break;
case ServerMessageType.AUTHENTICATE_DENIED:
this.emitError(new Error('Authentication denied'));
this.emitError(new Error("Authentication denied"));
this.disconnect();
break;
case ServerMessageType.ERROR:
this.emitError(new Error(`Server error: ${message.data.code}`), message.data.code);
this.emitError(
new Error(`Server error: ${message.data.code}`),
message.data.code,
);
break;
case ServerMessageType.EVENT:
@@ -228,7 +250,7 @@ export class GatewayClient {
break;
default:
console.warn('Unhandled server message type:', message);
console.warn("Unhandled server message type:", message);
}
}
@@ -237,39 +259,47 @@ export class GatewayClient {
}
private sendMessage(message: ClientMessage): void {
logger.log('Sending message: %o', message);
logger.log("Sending message: %o", message);
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
this.socket.send(JSON.stringify(message));
} else {
this.emitError(new Error('Cannot send message: socket not connected'));
this.emitError(
new Error("Cannot send message: socket not connected"),
);
}
}
private setState(state: ConnectionState): void {
if (this.state !== state) {
logger.log('State changed to %s', state);
logger.log("State changed to %s", state);
this.state = state;
this.emitControl('stateChange', state);
this.emitControl("stateChange", state);
}
}
private emitError(error: Error, code?: ErrorCode): void {
logger.error('Error: %s', error, error.cause);
logger.error("Error: %s", error, error.cause);
this.setState(ConnectionState.ERROR);
this.emitControl('error', error, code);
this.emitControl("error", error, code);
}
private emitControl<K extends keyof ControlEvents>(event: K, ...args: Parameters<ControlEvents[K]>): void {
private emitControl<K extends keyof ControlEvents>(
event: K,
...args: Parameters<ControlEvents[K]>
): void {
const handler = this.eventHandlers[event];
if (handler) {
(handler as Function)(...args);
}
}
private emitEvent<K extends keyof GatewayEvents>(event: K, ...args: Parameters<GatewayEvents[K]>): void {
private emitEvent<K extends keyof GatewayEvents>(
event: K,
...args: Parameters<GatewayEvents[K]>
): void {
const handler = this.serverEventHandlers[event];
if (handler) {
(handler as Function)(...args);
@@ -277,7 +307,7 @@ export class GatewayClient {
}
private cleanupSocket(): void {
logger.log('Cleaning up socket');
logger.log("Cleaning up socket");
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout);
@@ -292,8 +322,10 @@ export class GatewayClient {
this.socket.onclose = null;
// Close the connection if it's still open
if (this.socket.readyState === WebSocket.OPEN ||
this.socket.readyState === WebSocket.CONNECTING) {
if (
this.socket.readyState === WebSocket.OPEN ||
this.socket.readyState === WebSocket.CONNECTING
) {
this.socket.close();
}
@@ -305,4 +337,7 @@ export class GatewayClient {
}
}
const logger = createPrefixedLogger('%cGateway WS%c:', ['color: red; font-weight: bold;', '']);
const logger = createPrefixedLogger("%cGateway WS%c:", [
"color: red; font-weight: bold;",
"",
]);

View File

@@ -1,50 +1,58 @@
import type { ChannelId, Message, MessageId, PartialUser, Server, ServerId, UserId } from "~/lib/api/types";
import type {
ChannelId,
Message,
MessageId,
PartialUser,
Server,
ServerId,
UserId,
} from "~/lib/api/types";
type Channel = any; // TODO: Define Channel type
export enum ServerMessageType {
AUTHENTICATE_ACCEPTED = 'AUTHENTICATE_ACCEPTED',
AUTHENTICATE_DENIED = 'AUTHENTICATE_DENIED',
EVENT = 'EVENT',
ERROR = 'ERROR'
AUTHENTICATE_ACCEPTED = "AUTHENTICATE_ACCEPTED",
AUTHENTICATE_DENIED = "AUTHENTICATE_DENIED",
EVENT = "EVENT",
ERROR = "ERROR",
}
export enum ClientMessageType {
AUTHENTICATE = 'AUTHENTICATE',
VOICE_STATE_UPDATE = 'VOICE_STATE_UPDATE',
REQUEST_VOICE_STATES = 'REQUEST_VOICE_STATES',
AUTHENTICATE = "AUTHENTICATE",
VOICE_STATE_UPDATE = "VOICE_STATE_UPDATE",
REQUEST_VOICE_STATES = "REQUEST_VOICE_STATES",
}
// Error codes from the server
export enum ErrorCode {
AUTHENTICATION_FAILED = 'AUTHENTICATION_FAILED',
TOKEN_GENERATION_FAILED = 'TOKEN_GENERATION_FAILED'
AUTHENTICATION_FAILED = "AUTHENTICATION_FAILED",
TOKEN_GENERATION_FAILED = "TOKEN_GENERATION_FAILED",
}
// Event types from the server
export enum EventType {
ADD_SERVER = 'ADD_SERVER',
REMOVE_SERVER = 'REMOVE_SERVER',
ADD_SERVER = "ADD_SERVER",
REMOVE_SERVER = "REMOVE_SERVER",
ADD_DM_CHANNEL = 'ADD_DM_CHANNEL',
REMOVE_DM_CHANNEL = 'REMOVE_DM_CHANNEL',
ADD_DM_CHANNEL = "ADD_DM_CHANNEL",
REMOVE_DM_CHANNEL = "REMOVE_DM_CHANNEL",
ADD_SERVER_CHANNEL = 'ADD_SERVER_CHANNEL',
REMOVE_SERVER_CHANNEL = 'REMOVE_SERVER_CHANNEL',
ADD_SERVER_CHANNEL = "ADD_SERVER_CHANNEL",
REMOVE_SERVER_CHANNEL = "REMOVE_SERVER_CHANNEL",
ADD_USER = 'ADD_USER',
REMOVE_USER = 'REMOVE_USER',
ADD_USER = "ADD_USER",
REMOVE_USER = "REMOVE_USER",
ADD_SERVER_MEMBER = 'ADD_SERVER_MEMBER',
REMOVE_SERVER_MEMBER = 'REMOVE_SERVER_MEMBER',
ADD_SERVER_MEMBER = "ADD_SERVER_MEMBER",
REMOVE_SERVER_MEMBER = "REMOVE_SERVER_MEMBER",
ADD_MESSAGE = 'ADD_MESSAGE',
REMOVE_MESSAGE = 'REMOVE_MESSAGE',
ADD_MESSAGE = "ADD_MESSAGE",
REMOVE_MESSAGE = "REMOVE_MESSAGE",
VOICE_CHANNEL_CONNECTED = 'VOICE_CHANNEL_CONNECTED',
VOICE_CHANNEL_DISCONNECTED = 'VOICE_CHANNEL_DISCONNECTED',
VOICE_CHANNEL_CONNECTED = "VOICE_CHANNEL_CONNECTED",
VOICE_CHANNEL_DISCONNECTED = "VOICE_CHANNEL_DISCONNECTED",
VOICE_SERVER_UPDATE = 'VOICE_SERVER_UPDATE'
VOICE_SERVER_UPDATE = "VOICE_SERVER_UPDATE",
}
// Client message types
@@ -70,7 +78,10 @@ export interface RequestVoiceStatesMessage {
};
}
export type ClientMessage = AuthenticateMessage | VoiceStateUpdateMessage | RequestVoiceStatesMessage;
export type ClientMessage =
| AuthenticateMessage
| VoiceStateUpdateMessage
| RequestVoiceStatesMessage;
// Server message types
export interface AuthenticateAcceptedMessage {
@@ -111,6 +122,7 @@ export interface AddDmChannelEvent {
type: EventType.ADD_DM_CHANNEL;
data: {
channel: Channel;
recipients: PartialUser[];
};
}
@@ -241,9 +253,9 @@ export type ServerMessage =
// Connection states
export enum ConnectionState {
DISCONNECTED = 'DISCONNECTED',
CONNECTING = 'CONNECTING',
AUTHENTICATING = 'AUTHENTICATING',
CONNECTED = 'CONNECTED',
ERROR = 'ERROR'
}
DISCONNECTED = "DISCONNECTED",
CONNECTING = "CONNECTING",
AUTHENTICATING = "AUTHENTICATING",
CONNECTED = "CONNECTED",
ERROR = "ERROR",
}

View File

@@ -1,5 +1,11 @@
import { createPrefixedLogger } from "~/lib/utils";
import { ClientMessageType, ConnectionState, ServerMessageType, type ClientMessage, type ServerMessage } from "./types";
import {
ClientMessageType,
ConnectionState,
ServerMessageType,
type ClientMessage,
type ServerMessage,
} from "./types";
export class WebRTCClient {
private socket: WebSocket | null = null;
@@ -20,7 +26,7 @@ export class WebRTCClient {
url: string,
onStateChange: (state: ConnectionState) => void,
onError: (error: Error) => void,
onRemoteStream: (stream: MediaStream) => void
onRemoteStream: (stream: MediaStream) => void,
) {
this.url = url;
this.onStateChange = onStateChange;
@@ -30,26 +36,29 @@ export class WebRTCClient {
public connect = async (token: string) => {
if (this.connectionLock) {
warn('WebRTC: Connection already in progress');
warn("WebRTC: Connection already in progress");
return;
}
this.connectionLock = true;
if (this.state !== ConnectionState.DISCONNECTED && this.state !== ConnectionState.ERROR) {
if (
this.state !== ConnectionState.DISCONNECTED &&
this.state !== ConnectionState.ERROR
) {
this.disconnect();
}
if (this.disconnectPromise) {
warn('WebRTC: Waiting for previous disconnect to complete');
warn("WebRTC: Waiting for previous disconnect to complete");
try {
await this.disconnectPromise;
} catch (error) {
console.error('WebRTC: Previous disconnect failed:', error);
console.error("WebRTC: Previous disconnect failed:", error);
}
}
log('Connecting to %s', this.url);
log("Connecting to %s", this.url);
try {
this.setState(ConnectionState.CONNECTING);
@@ -57,25 +66,25 @@ export class WebRTCClient {
this.socket = new WebSocket(this.url);
this.socket.onopen = () => {
log('Socket opened');
log("Socket opened");
this.connectionLock = false;
this.setState(ConnectionState.AUTHENTICATING);
this.sendMessage({
type: ClientMessageType.AUTHENTICATE,
data: { token }
data: { token },
});
};
this.socket.onmessage = this.handleServerMessage;
this.socket.onerror = (event) => {
this.handleError(new Error('WebSocket error occurred'));
this.handleError(new Error("WebSocket error occurred"));
};
this.socket.onclose = (e) => {
log('Socket closed', e);
log("Socket closed", e);
this.cleanupResources();
if (this.state !== ConnectionState.ERROR) {
this.setState(ConnectionState.DISCONNECTED);
@@ -88,7 +97,9 @@ export class WebRTCClient {
}
};
} catch (error) {
this.handleError(error instanceof Error ? error : new Error('Unknown error'));
this.handleError(
error instanceof Error ? error : new Error("Unknown error"),
);
}
};
@@ -112,16 +123,16 @@ export class WebRTCClient {
});
const onSocketClose = () => {
this.socket?.removeEventListener('close', onSocketClose);
this.socket?.removeEventListener("close", onSocketClose);
this.disconnectResolve?.();
this.disconnectResolve = null;
this.disconnectPromise = null;
};
this.socket.addEventListener('close', onSocketClose);
this.socket.addEventListener("close", onSocketClose);
if (this.socket.readyState !== WebSocket.CLOSING) {
this.socket.close(1000, 'WebRTC: Cleaning up resources');
this.socket.close(1000, "WebRTC: Cleaning up resources");
}
} else {
this.cleanupResources();
@@ -131,21 +142,21 @@ export class WebRTCClient {
public createOffer = async (localStream?: MediaStream): Promise<void> => {
if (this.state !== ConnectionState.CONNECTED) {
this.handleError(new Error('Cannot create offer: not connected'));
this.handleError(new Error("Cannot create offer: not connected"));
return;
}
try {
// Create RTCPeerConnection with standard configuration
const configuration: RTCConfiguration = {
iceServers: []
iceServers: [],
};
this.peerConnection = new RTCPeerConnection(configuration);
// Add local stream tracks if provided
if (localStream) {
localStream.getTracks().forEach(track => {
localStream.getTracks().forEach((track) => {
this.peerConnection!.addTrack(track, localStream);
});
}
@@ -174,20 +185,26 @@ export class WebRTCClient {
this.sendMessage({
type: ClientMessageType.SDP_OFFER,
data: {
sdp: this.peerConnection.localDescription
}
sdp: this.peerConnection.localDescription,
},
});
}
} catch (error) {
this.handleError(error instanceof Error ? error : new Error('Error creating WebRTC offer'));
this.handleError(
error instanceof Error
? error
: new Error("Error creating WebRTC offer"),
);
}
};
private handleServerMessage = async (event: MessageEvent): Promise<void> => {
private handleServerMessage = async (
event: MessageEvent,
): Promise<void> => {
try {
const message: ServerMessage = JSON.parse(event.data);
log('Received message: %o', message);
log("Received message: %o", message);
switch (message.type) {
case ServerMessageType.AUTHENTICATE_ACCEPTED:
@@ -195,7 +212,7 @@ export class WebRTCClient {
break;
case ServerMessageType.AUTHENTICATE_DENIED:
this.handleError(new Error('Authentication failed'));
this.handleError(new Error("Authentication failed"));
break;
case ServerMessageType.SDP_ANSWER:
@@ -203,54 +220,66 @@ export class WebRTCClient {
break;
default:
warn('Unhandled message type:', message);
warn("Unhandled message type:", message);
}
} catch (error) {
this.handleError(error instanceof Error ? error : new Error('Failed to process message'));
this.handleError(
error instanceof Error
? error
: new Error("Failed to process message"),
);
}
};
private handleSdpAnswer = async (sdp: RTCSessionDescription): Promise<void> => {
log('Received SDP answer: %o', sdp);
private handleSdpAnswer = async (
sdp: RTCSessionDescription,
): Promise<void> => {
log("Received SDP answer: %o", sdp);
if (!this.peerConnection) {
this.handleError(new Error('No peer connection established'));
this.handleError(new Error("No peer connection established"));
return;
}
try {
await this.peerConnection.setRemoteDescription(sdp);
} catch (error) {
this.handleError(error instanceof Error ? error : new Error('Error setting remote description'));
this.handleError(
error instanceof Error
? error
: new Error("Error setting remote description"),
);
}
};
private sendMessage = (message: ClientMessage): void => {
log('Sending message: %o', message);
log("Sending message: %o", message);
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
this.socket.send(JSON.stringify(message));
} else {
this.handleError(new Error('Cannot send message: socket not connected'));
this.handleError(
new Error("Cannot send message: socket not connected"),
);
}
};
private setState = (state: ConnectionState): void => {
log('State changed to %s', state);
log("State changed to %s", state);
this.state = state;
this.onStateChange(state);
};
private handleError = (error: Error): void => {
log('Error: %s', error.message);
log("Error: %s", error.message);
this.setState(ConnectionState.ERROR);
this.onError(error);
};
private cleanupResources = (): void => {
log('Cleaning up resources');
log("Cleaning up resources");
if (this.peerConnection) {
this.peerConnection.close();
@@ -258,14 +287,13 @@ export class WebRTCClient {
}
if (this.socket) {
this.socket.close(1000, 'WebRTC: Cleaning up resources');
this.socket.close(1000, "WebRTC: Cleaning up resources");
this.socket = null;
}
};
}
const {
log,
warn,
...other
} = createPrefixedLogger('%cWebRTC WS%c:', ['color: blue; font-weight: bold;', '']);
const { log, warn, ...other } = createPrefixedLogger("%cWebRTC WS%c:", [
"color: blue; font-weight: bold;",
"",
]);

View File

@@ -1,36 +1,38 @@
export enum ConnectionState {
DISCONNECTED = 'DISCONNECTED',
DISCONNECTING = 'DISCONNECTING',
CONNECTING = 'CONNECTING',
AUTHENTICATING = 'AUTHENTICATING',
CONNECTED = 'CONNECTED',
ERROR = 'ERROR',
DISCONNECTED = "DISCONNECTED",
DISCONNECTING = "DISCONNECTING",
CONNECTING = "CONNECTING",
AUTHENTICATING = "AUTHENTICATING",
CONNECTED = "CONNECTED",
ERROR = "ERROR",
}
export enum ServerMessageType {
AUTHENTICATE_ACCEPTED = 'AUTHENTICATE_ACCEPTED',
AUTHENTICATE_DENIED = 'AUTHENTICATE_DENIED',
SDP_ANSWER = 'SDP_ANSWER',
AUTHENTICATE_ACCEPTED = "AUTHENTICATE_ACCEPTED",
AUTHENTICATE_DENIED = "AUTHENTICATE_DENIED",
SDP_ANSWER = "SDP_ANSWER",
}
export type ServerMessage =
| { type: ServerMessageType.AUTHENTICATE_ACCEPTED }
| { type: ServerMessageType.AUTHENTICATE_DENIED }
| {
type: ServerMessageType.SDP_ANSWER; data: {
sdp: RTCSessionDescription
}
}
type: ServerMessageType.SDP_ANSWER;
data: {
sdp: RTCSessionDescription;
};
};
export enum ClientMessageType {
AUTHENTICATE = 'AUTHENTICATE',
SDP_OFFER = 'SDP_OFFER',
AUTHENTICATE = "AUTHENTICATE",
SDP_OFFER = "SDP_OFFER",
}
export type ClientMessage =
| { type: ClientMessageType.AUTHENTICATE; data: { token: string } }
| {
type: ClientMessageType.SDP_OFFER; data: {
sdp: RTCSessionDescription
}
};
type: ClientMessageType.SDP_OFFER;
data: {
sdp: RTCSessionDescription;
};
};