refactor: extract API client into parameterized BackspaceApiClient class

Refactor singleton api object into a class with constructor-scoped
closures parameterized by baseUrl and getToken. Export backward-
compatible api singleton, createApiClient factory for remote instances,
and new instance.info() and users.verifyPassword() methods for
federation support.
This commit is contained in:
Jannis Braun
2026-03-02 23:21:32 +01:00
parent 22440b5a24
commit c72fcdda70
+129 -34
View File
@@ -26,20 +26,102 @@ import type {
Friend, Friend,
FriendRequest, FriendRequest,
InstanceStreamingLimits, InstanceStreamingLimits,
InstanceInfoResponse,
VerifyPasswordResponse,
} from '@backspace/shared'; } from '@backspace/shared';
const BASE_URL = '/api'; export class BackspaceApiClient {
readonly auth: {
register: (data: RegisterRequest) => Promise<AuthResponse>;
login: (data: LoginRequest) => Promise<AuthResponse>;
};
function getToken(): string | null { readonly users: {
return localStorage.getItem('backspace_token'); me: () => Promise<User>;
} update: (data: UpdateUserRequest) => Promise<User>;
get: (id: string) => Promise<User>;
verifyPassword: (password: string) => Promise<VerifyPasswordResponse>;
};
async function request<T>( readonly servers: {
list: () => Promise<Server[]>;
get: (id: string) => Promise<ServerWithChannelsAndMembers>;
create: (data: CreateServerRequest) => Promise<Server>;
update: (id: string, data: UpdateServerRequest) => Promise<Server>;
delete: (id: string) => Promise<{ success: boolean }>;
invite: (id: string) => Promise<{ inviteCode: string }>;
join: (id: string, data: JoinServerRequest) => Promise<Server>;
joinByCode: (inviteCode: string) => Promise<Server>;
members: (id: string) => Promise<MemberWithUser[]>;
updateMember: (serverId: string, userId: string, data: UpdateMemberRequest) => Promise<MemberWithUser>;
removeMember: (serverId: string, userId: string) => Promise<{ success: boolean }>;
};
readonly channels: {
list: (serverId: string) => Promise<Channel[]>;
create: (serverId: string, data: CreateChannelRequest) => Promise<Channel>;
update: (id: string, data: UpdateChannelRequest) => Promise<Channel>;
delete: (id: string) => Promise<{ success: boolean }>;
messages: (id: string, before?: string, limit?: number) => Promise<MessageWithUser[]>;
sendMessage: (channelId: string, data: CreateMessageRequest) => Promise<MessageWithUser>;
getOverrides: (channelId: string) => Promise<{ channelId: string; targetType: string; targetId: string; allow: string; deny: string }[]>;
putOverride: (channelId: string, data: { targetType: string; targetId: string; allow: string; deny: string }) => Promise<{ success: boolean }>;
deleteOverride: (channelId: string, targetType: string, targetId: string) => Promise<{ success: boolean }>;
};
readonly messages: {
update: (id: string, data: UpdateMessageRequest) => Promise<MessageWithUser>;
delete: (id: string) => Promise<{ success: boolean }>;
};
readonly uploads: {
upload: (file: File) => Promise<Attachment>;
url: (filename: string) => string;
};
readonly dm: {
list: () => Promise<DmChannel[]>;
create: (data: CreateDmRequest) => Promise<DmChannel>;
close: (id: string) => Promise<{ success: boolean }>;
messages: (id: string, before?: string, limit?: number) => Promise<DmMessageWithUser[]>;
sendMessage: (id: string, data: CreateDmMessageRequest) => Promise<DmMessageWithUser>;
updateMessage: (id: string, data: UpdateMessageRequest) => Promise<DmMessageWithUser>;
deleteMessage: (id: string) => Promise<{ success: boolean }>;
addMember: (dmChannelId: string, data: { userId: string }) => Promise<DmChannel>;
leave: (dmChannelId: string) => Promise<{ success: boolean }>;
};
readonly social: {
friends: () => Promise<Friend[]>;
requests: () => Promise<FriendRequest[]>;
sendRequest: (username: string) => Promise<{ success: boolean }>;
updateRequest: (id: string, status: 'accepted' | 'declined') => Promise<{ success: boolean }>;
removeFriend: (id: string) => Promise<{ success: boolean }>;
cancelRequest: (id: string) => Promise<{ success: boolean }>;
search: (q: string) => Promise<User[]>;
};
readonly livekit: {
token: (channelId: string) => Promise<LiveKitTokenResponse>;
dmToken: (dmChannelId: string) => Promise<LiveKitTokenResponse>;
};
readonly settings: {
getStreaming: () => Promise<InstanceStreamingLimits>;
updateStreaming: (data: Partial<InstanceStreamingLimits>) => Promise<InstanceStreamingLimits>;
};
readonly instance: {
info: () => Promise<InstanceInfoResponse>;
};
constructor(baseUrl: string, getToken: () => string | null) {
async function request<T>(
method: string, method: string,
path: string, path: string,
body?: unknown, body?: unknown,
requireAuth = true, requireAuth = true,
): Promise<T> { ): Promise<T> {
const headers: Record<string, string> = {}; const headers: Record<string, string> = {};
if (body) { if (body) {
@@ -53,7 +135,7 @@ async function request<T>(
} }
} }
const response = await fetch(`${BASE_URL}${path}`, { const response = await fetch(`${baseUrl}${path}`, {
method, method,
headers, headers,
body: body ? JSON.stringify(body) : undefined, body: body ? JSON.stringify(body) : undefined,
@@ -65,9 +147,9 @@ async function request<T>(
} }
return response.json() as Promise<T>; return response.json() as Promise<T>;
} }
async function uploadFile(file: File): Promise<Attachment> { async function uploadFile(file: File): Promise<Attachment> {
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);
@@ -77,7 +159,7 @@ async function uploadFile(file: File): Promise<Attachment> {
headers['Authorization'] = `Bearer ${token}`; headers['Authorization'] = `Bearer ${token}`;
} }
const response = await fetch(`${BASE_URL}/uploads`, { const response = await fetch(`${baseUrl}/uploads`, {
method: 'POST', method: 'POST',
headers, headers,
body: formData, body: formData,
@@ -89,23 +171,24 @@ async function uploadFile(file: File): Promise<Attachment> {
} }
return response.json() as Promise<Attachment>; return response.json() as Promise<Attachment>;
} }
export const api = { this.auth = {
auth: {
register: (data: RegisterRequest) => register: (data: RegisterRequest) =>
request<AuthResponse>('POST', '/auth/register', data, false), request<AuthResponse>('POST', '/auth/register', data, false),
login: (data: LoginRequest) => login: (data: LoginRequest) =>
request<AuthResponse>('POST', '/auth/login', data, false), request<AuthResponse>('POST', '/auth/login', data, false),
}, };
users: { this.users = {
me: () => request<User>('GET', '/users/@me'), me: () => request<User>('GET', '/users/@me'),
update: (data: UpdateUserRequest) => request<User>('PATCH', '/users/@me', data), update: (data: UpdateUserRequest) => request<User>('PATCH', '/users/@me', data),
get: (id: string) => request<User>('GET', `/users/${id}`), get: (id: string) => request<User>('GET', `/users/${id}`),
}, verifyPassword: (password: string) =>
request<VerifyPasswordResponse>('POST', '/users/@me/verify-password', { password }),
};
servers: { this.servers = {
list: () => request<Server[]>('GET', '/servers'), list: () => request<Server[]>('GET', '/servers'),
get: (id: string) => request<ServerWithChannelsAndMembers>('GET', `/servers/${id}`), get: (id: string) => request<ServerWithChannelsAndMembers>('GET', `/servers/${id}`),
create: (data: CreateServerRequest) => request<Server>('POST', '/servers', data), create: (data: CreateServerRequest) => request<Server>('POST', '/servers', data),
@@ -119,9 +202,9 @@ export const api = {
request<MemberWithUser>('PATCH', `/servers/${serverId}/members/${userId}`, data), request<MemberWithUser>('PATCH', `/servers/${serverId}/members/${userId}`, data),
removeMember: (serverId: string, userId: string) => removeMember: (serverId: string, userId: string) =>
request<{ success: boolean }>('DELETE', `/servers/${serverId}/members/${userId}`), request<{ success: boolean }>('DELETE', `/servers/${serverId}/members/${userId}`),
}, };
channels: { this.channels = {
list: (serverId: string) => request<Channel[]>('GET', `/servers/${serverId}/channels`), list: (serverId: string) => request<Channel[]>('GET', `/servers/${serverId}/channels`),
create: (serverId: string, data: CreateChannelRequest) => create: (serverId: string, data: CreateChannelRequest) =>
request<Channel>('POST', `/servers/${serverId}/channels`, data), request<Channel>('POST', `/servers/${serverId}/channels`, data),
@@ -143,19 +226,19 @@ export const api = {
request<{ success: boolean }>('PUT', `/channels/${channelId}/overrides`, data), request<{ success: boolean }>('PUT', `/channels/${channelId}/overrides`, data),
deleteOverride: (channelId: string, targetType: string, targetId: string) => deleteOverride: (channelId: string, targetType: string, targetId: string) =>
request<{ success: boolean }>('DELETE', `/channels/${channelId}/overrides/${targetType}/${targetId}`), request<{ success: boolean }>('DELETE', `/channels/${channelId}/overrides/${targetType}/${targetId}`),
}, };
messages: { this.messages = {
update: (id: string, data: UpdateMessageRequest) => request<MessageWithUser>('PATCH', `/messages/${id}`, data), update: (id: string, data: UpdateMessageRequest) => request<MessageWithUser>('PATCH', `/messages/${id}`, data),
delete: (id: string) => request<{ success: boolean }>('DELETE', `/messages/${id}`), delete: (id: string) => request<{ success: boolean }>('DELETE', `/messages/${id}`),
}, };
uploads: { this.uploads = {
upload: uploadFile, upload: uploadFile,
url: (filename: string) => `${BASE_URL}/uploads/${filename}`, url: (filename: string) => `${baseUrl}/uploads/${filename}`,
}, };
dm: { this.dm = {
list: () => request<DmChannel[]>('GET', '/dm'), list: () => request<DmChannel[]>('GET', '/dm'),
create: (data: CreateDmRequest) => request<DmChannel>('POST', '/dm', data), create: (data: CreateDmRequest) => request<DmChannel>('POST', '/dm', data),
close: (id: string) => request<{ success: boolean }>('DELETE', `/dm/${id}`), close: (id: string) => request<{ success: boolean }>('DELETE', `/dm/${id}`),
@@ -175,9 +258,9 @@ export const api = {
request<DmChannel>('POST', `/dm/${dmChannelId}/members`, data), request<DmChannel>('POST', `/dm/${dmChannelId}/members`, data),
leave: (dmChannelId: string) => leave: (dmChannelId: string) =>
request<{ success: boolean }>('DELETE', `/dm/${dmChannelId}/members`), request<{ success: boolean }>('DELETE', `/dm/${dmChannelId}/members`),
}, };
social: { this.social = {
friends: () => request<Friend[]>('GET', '/social/friends'), friends: () => request<Friend[]>('GET', '/social/friends'),
requests: () => request<FriendRequest[]>('GET', '/social/requests'), requests: () => request<FriendRequest[]>('GET', '/social/requests'),
sendRequest: (username: string) => request<{ success: boolean }>('POST', '/social/requests', { username }), sendRequest: (username: string) => request<{ success: boolean }>('POST', '/social/requests', { username }),
@@ -186,18 +269,30 @@ export const api = {
removeFriend: (id: string) => request<{ success: boolean }>('DELETE', `/social/friends/${id}`), removeFriend: (id: string) => request<{ success: boolean }>('DELETE', `/social/friends/${id}`),
cancelRequest: (id: string) => request<{ success: boolean }>('DELETE', `/social/requests/${id}`), cancelRequest: (id: string) => request<{ success: boolean }>('DELETE', `/social/requests/${id}`),
search: (q: string) => request<User[]>('GET', `/social/search?q=${encodeURIComponent(q)}`), search: (q: string) => request<User[]>('GET', `/social/search?q=${encodeURIComponent(q)}`),
}, };
livekit: { this.livekit = {
token: (channelId: string) => token: (channelId: string) =>
request<LiveKitTokenResponse>('POST', '/livekit/token', { channelId }), request<LiveKitTokenResponse>('POST', '/livekit/token', { channelId }),
dmToken: (dmChannelId: string) => dmToken: (dmChannelId: string) =>
request<LiveKitTokenResponse>('POST', '/livekit/token', { dmChannelId }), request<LiveKitTokenResponse>('POST', '/livekit/token', { dmChannelId }),
}, };
settings: { this.settings = {
getStreaming: () => request<InstanceStreamingLimits>('GET', '/settings/streaming'), getStreaming: () => request<InstanceStreamingLimits>('GET', '/settings/streaming'),
updateStreaming: (data: Partial<InstanceStreamingLimits>) => updateStreaming: (data: Partial<InstanceStreamingLimits>) =>
request<InstanceStreamingLimits>('PATCH', '/settings/streaming', data), request<InstanceStreamingLimits>('PATCH', '/settings/streaming', data),
}, };
};
this.instance = {
info: () => request<InstanceInfoResponse>('GET', '/instance/info', undefined, false),
};
}
}
export const api = new BackspaceApiClient('/api', () => localStorage.getItem('backspace_token'));
export function createApiClient(origin: string, getToken: () => string | null): BackspaceApiClient {
const baseUrl = origin ? `${origin}/api` : '/api';
return new BackspaceApiClient(baseUrl, getToken);
}