diff --git a/bun.lockb b/bun.lockb index bcfd9769cc..3bdd61c26d 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/packages/client-ws/package.json b/packages/client-ws/package.json new file mode 100644 index 0000000000..cfe8cb0918 --- /dev/null +++ b/packages/client-ws/package.json @@ -0,0 +1,18 @@ +{ + "name": "@communication/client-ws", + "version": "0.1.0", + "main": "src/index.ts", + "module": "src/index.ts", + "type": "module", + "devDependencies": { + "@types/bun": "^1.1.14" + }, + "dependencies": { + "@communication/types": "workspace:*", + "@communication/sdk-types": "workspace:*", + "@msgpack/msgpack": "^3.0.0-beta2" + }, + "peerDependencies": { + "typescript": "^5.6.3" + } +} diff --git a/packages/client-ws/src/client.ts b/packages/client-ws/src/client.ts new file mode 100644 index 0000000000..b8329f7755 --- /dev/null +++ b/packages/client-ws/src/client.ts @@ -0,0 +1,213 @@ +import { + type CardID, + type ContextID, + type FindMessagesParams, + type FindNotificationContextParams, + type FindNotificationsParams, + type Message, + type MessageID, + type Notification, + type NotificationContext, + type NotificationContextUpdate, + type RichText, + type SocialID +} from '@communication/types' +import { + type BroadcastEvent, + type Client, + type CreateAttachmentEvent, + type CreateMessageEvent, + type CreateMessageResult, + type CreateNotificationContextEvent, + type CreateNotificationContextResult, + type CreateNotificationEvent, + type CreatePatchEvent, + type CreateReactionEvent, + type Event, + type EventResult, + EventType, + type RemoveAttachmentEvent, + type RemoveMessageEvent, + type RemoveNotificationContextEvent, + type RemoveNotificationEvent, + type RemoveReactionEvent, + type UpdateNotificationContextEvent +} from '@communication/sdk-types' + +import { WebSocketConnection } from './connection' + +class WsClient implements Client { + private readonly ws: WebSocketConnection + + onEvent: (event: BroadcastEvent) => void = () => {} + + constructor( + private readonly url: string, + private readonly token: string, + private readonly binary: boolean = false + ) { + const connectionUrl = this.url + '?token=' + this.token + this.ws = new WebSocketConnection(connectionUrl, this.binary) + this.ws.onEvent = (event) => { + void this.onEvent(event) + } + } + + async createMessage(card: CardID, content: RichText, creator: SocialID): Promise { + const event: CreateMessageEvent = { + type: EventType.CreateMessage, + card, + content, + creator + } + const result = await this.sendEvent(event) + return (result as CreateMessageResult).id + } + + async removeMessage(message: MessageID) { + const event: RemoveMessageEvent = { + type: EventType.RemoveMessage, + message + } + await this.sendEvent(event) + } + + async createPatch(message: MessageID, content: RichText, creator: SocialID): Promise { + const event: CreatePatchEvent = { + type: EventType.CreatePatch, + message, + content, + creator + } + await this.sendEvent(event) + } + + async createReaction(message: MessageID, reaction: string, creator: SocialID): Promise { + const event: CreateReactionEvent = { + type: EventType.CreateReaction, + message, + reaction, + creator + } + await this.sendEvent(event) + } + + async removeReaction(message: MessageID, reaction: string, creator: SocialID): Promise { + const event: RemoveReactionEvent = { + type: EventType.RemoveReaction, + message, + reaction, + creator + } + await this.sendEvent(event) + } + + async createAttachment(message: MessageID, card: CardID, creator: SocialID): Promise { + const event: CreateAttachmentEvent = { + type: EventType.CreateAttachment, + message, + card, + creator + } + await this.sendEvent(event) + } + + async removeAttachment(message: MessageID, card: CardID): Promise { + const event: RemoveAttachmentEvent = { + type: EventType.RemoveAttachment, + message, + card + } + await this.sendEvent(event) + } + + async findMessages(params: FindMessagesParams, queryId?: number): Promise { + const rawMessages = await this.ws.send('findMessages', [params, queryId]) + return rawMessages.map(this.toMessage) + } + + toMessage(raw: any): Message { + return { + id: raw.id, + content: raw.content, + creator: raw.creator, + created: new Date(raw.created), + edited: new Date(raw.edited), + reactions: raw.reactions, + attachments: raw.attachments + } + } + + async createNotification(message: MessageID, context: ContextID): Promise { + const event: CreateNotificationEvent = { + type: EventType.CreateNotification, + message, + context + } + await this.sendEvent(event) + } + + async removeNotification(message: MessageID, context: ContextID): Promise { + const event: RemoveNotificationEvent = { + type: EventType.RemoveNotification, + message, + context + } + await this.sendEvent(event) + } + + async createNotificationContext(card: CardID, lastView?: Date, lastUpdate?: Date): Promise { + const event: CreateNotificationContextEvent = { + type: EventType.CreateNotificationContext, + card, + lastView, + lastUpdate + } + const result = await this.sendEvent(event) + return (result as CreateNotificationContextResult).id + } + + async removeNotificationContext(context: ContextID): Promise { + const event: RemoveNotificationContextEvent = { + type: EventType.RemoveNotificationContext, + context + } + await this.sendEvent(event) + } + + async updateNotificationContext(context: ContextID, update: NotificationContextUpdate): Promise { + const event: UpdateNotificationContextEvent = { + type: EventType.UpdateNotificationContext, + context, + update + } + await this.sendEvent(event) + } + + async findNotificationContexts( + params: FindNotificationContextParams, + queryId?: number + ): Promise { + return await this.ws.send('findNotificationContexts', [params, queryId]) + } + + async findNotifications(params: FindNotificationsParams, queryId?: number): Promise { + return await this.ws.send('findNotifications', [params, queryId]) + } + + async unsubscribeQuery(id: number): Promise { + await this.ws.send('unsubscribeQuery', [id]) + } + + private async sendEvent(event: Event): Promise { + return await this.ws.send('event', [event]) + } + + close() { + void this.ws.close() + } +} + +export async function getWebsocketClient(url: string, token: string): Promise { + return new WsClient(url, token) +} diff --git a/packages/client-ws/src/connection.ts b/packages/client-ws/src/connection.ts new file mode 100644 index 0000000000..679d3f8d06 --- /dev/null +++ b/packages/client-ws/src/connection.ts @@ -0,0 +1,123 @@ +import type { Response, HelloRequest, RequestId, BroadcastEvent, Request } from '@communication/sdk-types' +import { encode, decode } from '@msgpack/msgpack' + +const PING_TIMEOUT = 10000 +const RECONNECT_TIMEOUT = 1000 + +export class WebSocketConnection { + private ws!: WebSocket | Promise + private requests: { [key: RequestId]: { resolve: (response: any) => void; reject: (reason: any) => void } } = {} + private lastId: number = 0 + + private pingInterval: any + private reconnectTimeout: any + + onEvent: (event: BroadcastEvent) => void = () => {} + + constructor( + private url: string, + private readonly binary: boolean = false + ) { + this.connect() + } + + private connect(): void { + const ws = new WebSocket(this.url) + + ws.onmessage = (event: MessageEvent) => { + const response = deserializeResponse(event.data, this.binary) + if (response.id !== undefined) { + const handlers = this.requests[response.id] + if (handlers === undefined) return + delete this.requests[response.id] + if (response.error !== undefined) { + console.error('Websocket error', response.error) + handlers.reject(response.error) + } else { + handlers.resolve(response.result) + } + } else { + if (response.error !== undefined) { + console.error('Websocket error', response.error) + } else { + const event = response.result as BroadcastEvent + this.onEvent(event) + } + } + } + + ws.onclose = () => { + clearInterval(this.pingInterval) + this.handleReconnect() + } + + this.ws = new Promise((resolve, reject) => { + ws.onopen = () => { + const request: HelloRequest = { id: 'hello', method: 'hello', params: [], binary: this.binary } + ws.send(serializeRequest(request, this.binary)) + clearInterval(this.pingInterval) + this.pingInterval = setInterval(() => { + void this.sendRequest({ method: 'ping', params: [] }) + }, PING_TIMEOUT) + resolve(ws) + } + ws.onerror = (event: any) => { + console.error('Websocket error', event) + reject(new Error('Websocket error')) + } + }) + } + + private handleReconnect() { + clearTimeout(this.reconnectTimeout) + this.reconnectTimeout = setTimeout(() => { + this.connect() + }, RECONNECT_TIMEOUT) + } + + async waitWs(): Promise { + if (this.ws instanceof Promise) { + this.ws = await this.ws + } + return this.ws + } + + async send(method: string, params: any[]): Promise { + const id = ++this.lastId + return await this.sendRequest({ id: id.toString(), method, params }) + } + + private async sendRequest(request: Request): Promise { + const ws = await this.waitWs() + + return new Promise((resolve, reject) => { + if (request.id !== undefined) { + this.requests[request.id] = { resolve, reject } + } + ws.send(serializeRequest(request, this.binary)) + }) + } + + async close(): Promise { + clearInterval(this.pingInterval) + clearTimeout(this.reconnectTimeout) + const ws = await this.waitWs() + ws.close() + } +} + +function serializeRequest(request: Request, binary: boolean): any { + if (binary) { + return encode(request) + } else { + return JSON.stringify(request) + } +} + +function deserializeResponse(data: any, binary: boolean): Response { + if (binary) { + return decode(data) as Response + } else { + return JSON.parse(data.toString()) + } +} diff --git a/packages/client-ws/src/index.ts b/packages/client-ws/src/index.ts new file mode 100644 index 0000000000..83dae7638c --- /dev/null +++ b/packages/client-ws/src/index.ts @@ -0,0 +1 @@ +export * from './client' diff --git a/packages/client-ws/tsconfig.json b/packages/client-ws/tsconfig.json new file mode 100644 index 0000000000..3ae07cd3fa --- /dev/null +++ b/packages/client-ws/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "jsx": "react-jsx", + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src"] +} diff --git a/packages/sdk-types/src/client.ts b/packages/sdk-types/src/client.ts new file mode 100644 index 0000000000..dce6d468e9 --- /dev/null +++ b/packages/sdk-types/src/client.ts @@ -0,0 +1,45 @@ +import type { + CardID, + ContextID, + FindNotificationContextParams, + FindNotificationsParams, + Message, + MessageID, + NotificationContext, + NotificationContextUpdate, + RichText, + SocialID, + Notification +} from '@communication/types' +import type { FindMessagesParams } from '@communication/types' + +import type { BroadcastEvent } from './event.ts' + +export interface Client { + createMessage(card: CardID, content: RichText, creator: SocialID): Promise + removeMessage(id: MessageID): Promise + createPatch(message: MessageID, content: RichText, creator: SocialID): Promise + + createReaction(message: MessageID, reaction: string, creator: SocialID): Promise + removeReaction(message: MessageID, reaction: string, creator: SocialID): Promise + + createAttachment(message: MessageID, card: CardID, creator: SocialID): Promise + removeAttachment(message: MessageID, card: CardID): Promise + + createNotification(message: MessageID, context: ContextID): Promise + removeNotification(message: MessageID, context: ContextID): Promise + + createNotificationContext(card: CardID, lastView?: Date, lastUpdate?: Date): Promise + removeNotificationContext(context: ContextID): Promise + updateNotificationContext(context: ContextID, update: NotificationContextUpdate): Promise + + onEvent(event: BroadcastEvent): void + + findMessages(params: FindMessagesParams, queryId?: number): Promise + findNotificationContexts(params: FindNotificationContextParams, queryId?: number): Promise + findNotifications(params: FindNotificationsParams, queryId?: number): Promise + + unsubscribeQuery(id: number): Promise + close(): void +} + diff --git a/packages/sdk-types/src/index.ts b/packages/sdk-types/src/index.ts index 084f5c7e90..38c4587b9e 100644 --- a/packages/sdk-types/src/index.ts +++ b/packages/sdk-types/src/index.ts @@ -1,3 +1,4 @@ export * from './db' export * from './event' export * from './ws' +export * from './client' \ No newline at end of file