refactor: Chat connection code split into message and room managers
Some checks failed
Build & Test / build (20.x) (push) Has been cancelled
Build & Test / build (22.x) (push) Has been cancelled

This commit is contained in:
Shibo Lyu 2025-05-15 02:29:13 +08:00
parent 6bbd7a6428
commit c885f66847
7 changed files with 183 additions and 82 deletions

View file

@ -1,2 +1,3 @@
onlyBuiltDependencies: onlyBuiltDependencies:
- '@tailwindcss/oxide'
- esbuild - esbuild

View file

@ -7,25 +7,36 @@ import { BlahError } from './error';
const RECONNECT_TIMEOUT = 1500; const RECONNECT_TIMEOUT = 1500;
const RECONNECT_MAX_TRIES = 5; const RECONNECT_MAX_TRIES = 5;
export type MessageListener = (message: BlahSignedPayload<BlahMessage>) => void;
export interface MessageFilter {
roomID?: string;
}
export interface MessageSubscription {
unsubscribe: () => void;
}
export class BlahChatServerConnection { export class BlahChatServerConnection {
private static commonHeaders = { 'x-blah-client': `Weblah/${version}` }; private static commonHeaders = { 'x-blah-client': `Weblah/${version}` };
private endpoint_: string; private endpoint_: string;
private keypair: BlahKeyPair | null; private keypair_: BlahKeyPair | null;
get endpoint() { get endpoint() {
return this.endpoint_; return this.endpoint_;
} }
get keypair() {
return this.keypair_;
}
private webSocket: WebSocket | null = null; private webSocket: WebSocket | null = null;
private roomListeners: Map<string, Set<(message: BlahSignedPayload<BlahMessage>) => void>> = private roomListeners: Map<string, Set<MessageListener>> = new Map();
new Map(); private serverListeners: Set<MessageListener> = new Set();
private serverListeners: Set<(message: BlahSignedPayload<BlahMessage>) => void> = new Set();
private webSocketRetryTimeout: number | null = null; private webSocketRetryTimeout: number | null = null;
constructor(endpoint: string, keypair: BlahKeyPair | null = null) { constructor(endpoint: string, keypair: BlahKeyPair | null = null) {
this.endpoint_ = endpoint; this.endpoint_ = endpoint;
this.keypair = keypair; this.keypair_ = keypair;
} }
private async generateAuthHeader(): Promise<{ Authorization: string }> { private async generateAuthHeader(): Promise<{ Authorization: string }> {
@ -62,7 +73,7 @@ export class BlahChatServerConnection {
}); });
} }
private async apiCall<P, R>(method: 'POST' | 'GET', path: `/${string}`, payload?: P): Promise<R> { public async apiCall<P, R>(method: 'POST' | 'GET', path: `/${string}`, payload?: P): Promise<R> {
if (payload && !this.keypair) throw new Error('Must make authorized API call with a keypair'); if (payload && !this.keypair) throw new Error('Must make authorized API call with a keypair');
let response: Response; let response: Response;
@ -82,53 +93,18 @@ export class BlahChatServerConnection {
return await response.json(); return await response.json();
} }
async joinRoom(id: string): Promise<void> { async tryRegisterIfNoyYet(): Promise<void> {
if (!this.keypair) throw new Error('Must join with a keypair'); if (!this.keypair) throw new Error('Must register with a keypair');
const payload: BlahUserJoinMessage = { try {
typ: 'add_member', await this.apiCall('GET', '/user/me');
room: id, } catch (e) {
permission: 1, if (e instanceof BlahError && e.statusCode === 404) {
user: this.keypair.id // TODO: Register user
}; } else {
throw e;
await this.apiCall('POST', `/room/${id}/admin`, payload);
} }
private async fetchRoomList(filter: 'joined' | 'public'): Promise<BlahRoomInfo[]> {
const { rooms }: { rooms: BlahRoomInfo[] } = await this.apiCall(
'GET',
`/room?filter=${filter}`
);
return rooms;
} }
async fetchJoinedRooms(): Promise<BlahRoomInfo[]> {
if (!this.keypair) return [];
return await this.fetchRoomList('joined');
}
async discoverRooms(): Promise<BlahRoomInfo[]> {
return await this.fetchRoomList('public');
}
async sendMessage(room: string, message: BlahRichText): Promise<void> {
if (!this.keypair) throw new Error('Must send message with a keypair');
const payload: BlahMessage = { room, rich_text: message, typ: 'chat' };
await this.apiCall('POST', `/room/${room}/item`, payload);
}
async fetchRoomInfo(roomId: string): Promise<BlahRoomInfo> {
const room: BlahRoomInfo = await this.apiCall('GET', `/room/${roomId}`);
return room;
}
async fetchRoomHistory(roomId: string): Promise<BlahSignedPayload<BlahMessage>[]> {
const { items }: { items: BlahSignedPayload<BlahMessage>[] } = await this.apiCall(
'GET',
`/room/${roomId}/item`
);
return items;
} }
private createWebSocket(remainingTries: number = RECONNECT_MAX_TRIES - 1): WebSocket { private createWebSocket(remainingTries: number = RECONNECT_MAX_TRIES - 1): WebSocket {
@ -180,41 +156,36 @@ export class BlahChatServerConnection {
} }
changeKeyPair(keypair: BlahKeyPair | null) { changeKeyPair(keypair: BlahKeyPair | null) {
this.keypair = keypair; this.keypair_ = keypair;
if (this.webSocket) { if (this.webSocket) {
this.disconnect(); this.disconnect();
this.connect(); this.connect();
} }
} }
subscribeRoom( subscribe(listener: MessageListener, filter: MessageFilter = {}): MessageSubscription {
roomId: string, const roomID = filter.roomID;
onNewMessage: (message: BlahSignedPayload<BlahMessage>) => void
): { unsubscribe: () => void } { if (roomID) {
const listeners = this.roomListeners.get(roomId) ?? new Set(); const listeners = this.roomListeners.get(roomID) ?? new Set();
listeners.add(onNewMessage); listeners.add(listener);
this.roomListeners.set(roomId, listeners); this.roomListeners.set(roomID, listeners);
return { return {
unsubscribe: () => { unsubscribe: () => {
const listeners = this.roomListeners.get(roomId) ?? new Set(); const listeners = this.roomListeners.get(roomID) ?? new Set();
listeners.delete(onNewMessage); listeners.delete(listener);
if (listeners.size === 0) { if (listeners.size === 0) {
this.roomListeners.delete(roomId); this.roomListeners.delete(roomID);
} }
} }
}; };
} }
subscribe(onNewMessage: (message: BlahSignedPayload<BlahMessage>) => void): { this.serverListeners.add(listener);
unsubscribe: () => void;
} {
this.serverListeners.add(onNewMessage);
return { return {
unsubscribe: () => { unsubscribe: () => this.serverListeners.delete(listener)
this.serverListeners.delete(onNewMessage);
}
}; };
} }
} }

View file

@ -1,16 +1,26 @@
import { blahErrorResponseSchema } from '../structures/error';
export class BlahError extends Error { export class BlahError extends Error {
statusCode: number; statusCode: number;
raw: Record<string, unknown>; raw: unknown;
blahCode: string | null = null;
constructor(statusCode: number, errRespJSON: unknown) {
const parsed = blahErrorResponseSchema.safeParse(errRespJSON);
if (parsed.success) {
super(parsed.data.error.message);
} else {
super();
}
constructor(statusCode: number, errRespJson: { message: string } & Record<string, unknown>) {
super(errRespJson.message);
this.statusCode = statusCode; this.statusCode = statusCode;
this.raw = errRespJson; this.raw = errRespJSON;
this.name = 'BlahError'; this.name = 'BlahError';
this.blahCode = parsed.success ? parsed.data.error.code : null;
} }
static async fromResponse(response: Response): Promise<BlahError> { static async fromResponse(response: Response): Promise<BlahError> {
const errRespJson = await response.json(); const errRespJSON = await response.json();
return new BlahError(response.status, errRespJson); return new BlahError(response.status, errRespJSON);
} }
} }

View file

@ -0,0 +1,35 @@
import type { BlahRichText } from '@blah-im/core/richText';
import type { BlahSignedPayload } from '@blah-im/core/crypto';
import type { BlahChatServerConnection } from './chatServer';
import type { BlahMessage } from '../structures';
export default class MessageManager {
connection: BlahChatServerConnection;
roomID: string;
messages: BlahSignedPayload<BlahMessage>[] = $state([]);
constructor(connection: BlahChatServerConnection, roomID: string) {
this.connection = connection;
this.roomID = roomID;
}
async sendMessage(message: BlahRichText): Promise<void> {
if (!this.connection.keypair) throw new Error('Must send message with a keypair');
const payload: BlahMessage = { room: this.roomID, rich_text: message, typ: 'chat' };
await this.connection.apiCall('POST', `/room/${payload.room}/item`, payload);
}
async fetchRoomHistory() {
const { items }: { items: BlahSignedPayload<BlahMessage>[] } = await this.connection.apiCall(
'GET',
`/room/${this.roomID}/item`
);
this.messages = items;
}
listen() {
return this.connection.subscribe((m) => this.messages.push(m), { roomID: this.roomID })
.unsubscribe;
}
}

View file

@ -0,0 +1,53 @@
import type { BlahRoomInfo, BlahUserJoinMessage } from '../structures';
import type { BlahChatServerConnection } from './chatServer';
export default class RoomManager {
connection: BlahChatServerConnection;
joinedRooms: BlahRoomInfo[] = $state([]);
publicRooms: BlahRoomInfo[] = $state([]);
constructor(connection: BlahChatServerConnection) {
this.connection = connection;
}
async joinRoom(id: string): Promise<void> {
const keypair = this.connection.keypair;
if (!keypair) throw new Error('Must join with a keypair');
const payload: BlahUserJoinMessage = {
typ: 'add_member',
room: id,
permission: 1,
user: keypair.id
};
await this.connection.apiCall('POST', `/room/${id}/admin`, payload);
}
private async fetchRoomList(filter: 'joined' | 'public'): Promise<BlahRoomInfo[]> {
const { rooms }: { rooms: BlahRoomInfo[] } = await this.connection.apiCall(
'GET',
`/room?filter=${filter}`
);
return rooms;
}
async fetchJoinedRooms() {
if (!this.connection.keypair) return [];
this.joinedRooms = await this.fetchRoomList('joined');
}
async discoverRooms() {
this.publicRooms = await this.fetchRoomList('public');
}
async updateRoomInfo(roomId: string) {
const room: BlahRoomInfo = await this.connection.apiCall('GET', `/room/${roomId}`);
const index = this.joinedRooms.findIndex((r) => r.rid === roomId);
if (index !== -1) {
this.joinedRooms[index] = room;
} else {
this.joinedRooms.push(room);
}
}
}

View file

@ -1 +1,17 @@
import { z } from 'zod';
export type BlahAuth = { typ: 'auth' }; export type BlahAuth = { typ: 'auth' };
export type BlahUserRegisterChallenge = {
pow: {
nonce: number;
difficulty: number;
};
};
export const blahUserRegisterChallengeSchema = z.object({
pow: z.object({
nonce: z.number().int(),
difficulty: z.number().int()
})
});

View file

@ -0,0 +1,15 @@
import { z } from 'zod';
export interface BlahErrorResponse {
error: {
code: string;
message: string;
};
}
export const blahErrorResponseSchema = z.object({
error: z.object({
code: z.string(),
message: z.string()
})
});