279 lines
8.3 KiB
TypeScript
279 lines
8.3 KiB
TypeScript
import EventEmitter from 'events';
|
|
import * as Y from 'yjs';
|
|
import { encode, decode } from '@msgpack/msgpack';
|
|
import { MessageTree } from './chat/message-tree';
|
|
import { Chat } from './chat/types';
|
|
import { AsyncLoop } from "./utils/async-loop";
|
|
import { ChatManager } from '.';
|
|
import { getRateLimitResetTimeFromResponse } from './utils';
|
|
import { importChat } from './chat/chat-persistance';
|
|
|
|
const endpoint = '/chatapi';
|
|
|
|
export let backend: {
|
|
current?: Backend | null
|
|
} = {};
|
|
|
|
export interface User {
|
|
id: string;
|
|
email?: string;
|
|
name?: string;
|
|
avatar?: string;
|
|
services?: string[];
|
|
}
|
|
|
|
export class Backend extends EventEmitter {
|
|
public user: User | null = null;
|
|
public services: string[] = [];
|
|
private checkedSession = false;
|
|
|
|
private sessionInterval = new AsyncLoop(() => this.getSession(), 1000 * 30);
|
|
private syncInterval = new AsyncLoop(() => this.sync(), 1000 * 5);
|
|
|
|
private pendingYUpdate: Uint8Array | null = null;
|
|
private lastFullSyncAt = 0;
|
|
private legacySync = false;
|
|
private rateLimitedUntil = 0;
|
|
|
|
public constructor(private context: ChatManager) {
|
|
super();
|
|
|
|
if ((window as any).AUTH_PROVIDER) {
|
|
backend.current = this;
|
|
|
|
this.sessionInterval.start();
|
|
this.syncInterval.start();
|
|
}
|
|
}
|
|
|
|
public isSynced() {
|
|
return (this.checkedSession && !this.isAuthenticated) || this.lastFullSyncAt > 0;
|
|
}
|
|
|
|
public async getSession() {
|
|
if (Date.now() < this.rateLimitedUntil) {
|
|
console.log(`Waiting another ${this.rateLimitedUntil - Date.now()}ms to check session due to rate limiting.`);
|
|
return;
|
|
}
|
|
|
|
const wasAuthenticated = this.isAuthenticated;
|
|
const session = await this.get(endpoint + '/session');
|
|
|
|
if (session?.authProvider) {
|
|
(window as any).AUTH_PROVIDER = session.authProvider;
|
|
}
|
|
|
|
if (session?.authenticated) {
|
|
this.user = {
|
|
id: session.userID,
|
|
email: session.email,
|
|
name: session.name,
|
|
avatar: session.picture,
|
|
services: session.services,
|
|
};
|
|
this.services = session.services || [];
|
|
} else {
|
|
this.user = null;
|
|
this.services = session?.services || [];
|
|
}
|
|
|
|
this.checkedSession = true;
|
|
|
|
if (wasAuthenticated !== this.isAuthenticated) {
|
|
this.emit('authenticated', this.isAuthenticated);
|
|
this.lastFullSyncAt = 0;
|
|
}
|
|
}
|
|
|
|
public async sync() {
|
|
if (!this.isAuthenticated) {
|
|
return;
|
|
}
|
|
|
|
if (Date.now() < this.rateLimitedUntil) {
|
|
console.log(`Waiting another ${this.rateLimitedUntil - Date.now()}ms before syncing due to rate limiting.`);
|
|
return;
|
|
}
|
|
|
|
const encoding = await import('lib0/encoding');
|
|
const decoding = await import('lib0/decoding');
|
|
const syncProtocol = await import('y-protocols/sync');
|
|
|
|
const sinceLastFullSync = Date.now() - this.lastFullSyncAt;
|
|
|
|
const pendingYUpdate = this.pendingYUpdate;
|
|
if (pendingYUpdate && pendingYUpdate.length > 4) {
|
|
this.pendingYUpdate = null;
|
|
|
|
const encoder = encoding.createEncoder();
|
|
syncProtocol.writeUpdate(encoder, pendingYUpdate);
|
|
|
|
const response = await fetch(endpoint + '/y-sync', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/octet-stream'
|
|
},
|
|
body: encoding.toUint8Array(encoder),
|
|
});
|
|
|
|
if (response.status === 429) {
|
|
this.rateLimitedUntil = getRateLimitResetTimeFromResponse(response);
|
|
}
|
|
} else if (sinceLastFullSync > 1000 * 60 * 1) {
|
|
this.lastFullSyncAt = Date.now();
|
|
|
|
const encoder = encoding.createEncoder();
|
|
syncProtocol.writeSyncStep1(encoder, this.context.doc.root);
|
|
|
|
const queue: Uint8Array[] = [
|
|
encoding.toUint8Array(encoder),
|
|
];
|
|
|
|
for (let i = 0; i < 4; i++) {
|
|
if (!queue.length) {
|
|
break;
|
|
}
|
|
|
|
const buffer = queue.shift()!;
|
|
|
|
const response = await fetch(endpoint + '/y-sync', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/octet-stream'
|
|
},
|
|
body: buffer,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
this.rateLimitedUntil = getRateLimitResetTimeFromResponse(response);
|
|
throw new Error(response.statusText);
|
|
}
|
|
|
|
const responseBuffer = await response.arrayBuffer();
|
|
const responseChunks = decode(responseBuffer) as Uint8Array[];
|
|
|
|
for (const chunk of responseChunks) {
|
|
if (!chunk.byteLength) {
|
|
continue;
|
|
}
|
|
|
|
const encoder = encoding.createEncoder();
|
|
const decoder = decoding.createDecoder(chunk);
|
|
|
|
const messageType = decoding.readVarUint(decoder);
|
|
decoder.pos = 0;
|
|
|
|
syncProtocol.readSyncMessage(decoder, encoder, this.context.doc.root, 'sync');
|
|
|
|
if (encoding.length(encoder)) {
|
|
queue.push(encoding.toUint8Array(encoder));
|
|
}
|
|
}
|
|
}
|
|
|
|
this.context.emit('update');
|
|
}
|
|
|
|
if (!this.legacySync) {
|
|
this.legacySync = true;
|
|
|
|
const chats = await this.get(endpoint + '/legacy-sync');
|
|
|
|
this.context.doc.transact(() => {
|
|
for (const chat of chats) {
|
|
try {
|
|
importChat(this.context.doc, chat);
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
public receiveYUpdate(update: Uint8Array) {
|
|
if (!this.pendingYUpdate) {
|
|
this.pendingYUpdate = update;
|
|
} else {
|
|
this.pendingYUpdate = Y.mergeUpdates([this.pendingYUpdate, update]);
|
|
}
|
|
}
|
|
|
|
async signIn() {
|
|
window.location.href = endpoint + '/login';
|
|
}
|
|
|
|
get isAuthenticated() {
|
|
return this.user !== null;
|
|
}
|
|
|
|
async logout() {
|
|
window.location.href = endpoint + '/logout';
|
|
}
|
|
|
|
async shareChat(chat: Chat): Promise<string | null> {
|
|
try {
|
|
const { id } = await this.post(endpoint + '/share', {
|
|
...chat,
|
|
messages: chat.messages.serialize(),
|
|
});
|
|
if (typeof id === 'string') {
|
|
return id;
|
|
}
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async getSharedChat(id: string): Promise<Chat | null> {
|
|
const format = process.env.REACT_APP_SHARE_URL || (endpoint + '/share/:id');
|
|
const url = format.replace(':id', id);
|
|
try {
|
|
const chat = await this.get(url);
|
|
if (chat?.messages?.length) {
|
|
chat.messages = new MessageTree(chat.messages);
|
|
return chat;
|
|
}
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async deleteChat(id: string) {
|
|
if (!this.isAuthenticated) {
|
|
return;
|
|
}
|
|
|
|
return this.post(endpoint + '/delete', { id });
|
|
}
|
|
|
|
async get(url: string) {
|
|
const response = await fetch(url);
|
|
if (response.status === 429) {
|
|
this.rateLimitedUntil = getRateLimitResetTimeFromResponse(response);
|
|
}
|
|
if (!response.ok) {
|
|
throw new Error(response.statusText);
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
async post(url: string, data: any) {
|
|
const response = await fetch(url, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify(data),
|
|
});
|
|
if (response.status === 429) {
|
|
this.rateLimitedUntil = getRateLimitResetTimeFromResponse(response);
|
|
}
|
|
if (!response.ok) {
|
|
throw new Error(response.statusText);
|
|
}
|
|
return response.json();
|
|
}
|
|
} |