@eweser/db
Version:
eweser-db core database
254 lines (229 loc) • 7.72 kB
text/typescript
import { Room } from '../../room.js';
import type { ServerRoom } from '@eweser/shared';
import { HocuspocusProvider, WebSocketStatus } from '@hocuspocus/provider';
import { initializeDocAndLocalProvider } from '../../utils/connection/initializeDoc.js';
import type { Doc } from 'yjs';
import type { Database } from '../../index.js';
import type { RoomConnectionStatus } from '../../events.js';
import { wait } from '@eweser/shared';
import { Awareness } from 'y-protocols/awareness';
import type { EweDocument } from '@eweser/shared';
const TOKEN_EXPIRY_BUFFER_MS = 5 * 60 * 1000;
function validate(room: ServerRoom) {
if (!room) {
throw new Error('room is required');
}
const { id: roomId, collectionKey } = room;
if (!roomId) {
throw new Error('roomId is required');
}
if (!collectionKey) {
throw new Error('collectionKey is required');
}
return { roomId, collectionKey };
}
function checkLoadedState(db: Database) {
return (room: Room<EweDocument>, syncUrl: string | null) => {
const localLoaded = !!room && !!room.ydoc && !!room.indexedDbProvider;
const syncProvider = room.syncProvider;
const shouldLoadSync =
room.connectionStatus === 'disconnected' &&
!!db.getToken() &&
db.useSync &&
!!room?.syncUrl &&
syncProvider?.status !== WebSocketStatus.Connecting;
const syncLoaded =
room.connectionStatus === 'connected' &&
syncUrl &&
syncProvider &&
room.syncUrl === syncUrl &&
syncProvider.status === WebSocketStatus.Connected;
db.info('checkLoadedState', {
room: room.name,
localLoaded,
syncLoaded,
shouldLoadSync,
status: syncProvider?.status,
});
return { localLoaded, syncLoaded, shouldLoadSync };
};
}
async function loadLocal(db: Database, room: Room<EweDocument>) {
const { yDoc: ydoc, localProvider } = await initializeDocAndLocalProvider(
room.id,
room.ydoc,
db.indexedDBProviderPolyfill
);
room.ydoc = ydoc;
room.indexedDbProvider = localProvider;
db.debug(
'initialized ydoc and localProvider',
room.ydoc,
room.indexedDbProvider
);
}
export async function loadSync(
db: Database,
room: Room<EweDocument>,
withAwareness = true,
awaitConnection = false,
maxWait = 30000
) {
if (!room.connectAbortController) {
room.connectAbortController = new AbortController();
}
function emitConnectionChange(status: RoomConnectionStatus) {
room.connectionStatus = status;
if (status === 'connected') {
room.connectionRetries = 0;
}
room.emit('roomConnectionChange', status, room);
db.emit('roomConnectionChange', status, room);
}
const handleStatusChange = ({ status }: { status: WebSocketStatus }) => {
if (status === WebSocketStatus.Connected) {
room.connectionRetries = 0;
room.connectAbortController?.abort();
}
emitConnectionChange(status);
};
function handleSynced({ state }: { state: boolean }) {
emitConnectionChange(state ? 'connected' : 'disconnected');
db.debug('syncProvider synced', state);
}
function handleAuthenticationFailed({ reason }: { reason: string }) {
db.error('syncProvider authentication failed', room.name, reason);
emitConnectionChange('disconnected');
}
async function pollForSyncConnectionAndAwait(maxWait: number) {
let waited = 0;
while (room.syncProvider?.status !== WebSocketStatus.Connected) {
await wait(300);
waited += 300;
if (waited >= maxWait) {
db.debug(
'timed out waiting for sync connection',
room.name,
waited,
maxWait,
room.syncProvider
);
throw new Error('timed out waiting for sync connection ' + room.name);
}
}
}
async function getValidSyncToken() {
if (
room.syncToken &&
room.tokenExpiry &&
new Date(room.tokenExpiry) > new Date(Date.now() + TOKEN_EXPIRY_BUFFER_MS)
) {
db.debug('returning existing sync token', room.name);
return room.syncToken;
}
const refreshed = await db.refreshSyncToken(room);
db.debug(
'refreshed sync token. success:',
refreshed?.syncToken && refreshed.tokenExpiry,
room.name
);
if (refreshed?.syncToken && refreshed.syncUrl && refreshed.tokenExpiry) {
room.tokenExpiry = refreshed.tokenExpiry;
room.syncToken = refreshed.syncToken;
room.syncUrl = refreshed.syncUrl;
return refreshed.syncToken;
}
throw new Error('No sync token found: ' + room.name);
}
if (room.syncProvider) {
if (awaitConnection) {
try {
await pollForSyncConnectionAndAwait(maxWait);
} catch (e) {
db.error(e);
}
}
db.emit('roomRemoteLoaded', room);
return;
}
if (!room.syncUrl) {
throw new Error('No syncUrl found: ' + room.name);
}
emitConnectionChange('connecting');
room.syncProvider = new HocuspocusProvider({
url: room.syncUrl,
name: room.id,
document: room.ydoc as Doc,
token: getValidSyncToken,
awareness: withAwareness ? new Awareness(room.ydoc as Doc) : null,
});
room.syncProvider.on('status', handleStatusChange);
room.syncProvider.on('synced', handleSynced);
room.syncProvider.on('authenticationFailed', handleAuthenticationFailed);
db.debug('created syncProvider', room.syncProvider);
room.disconnect = () => {
room.syncProvider?.off('status', handleStatusChange);
room.syncProvider?.off('synced', handleSynced);
room.syncProvider?.off('authenticationFailed', handleAuthenticationFailed);
room.syncProvider?.disconnect();
room.webRtcProvider?.disconnect();
emitConnectionChange('disconnected');
};
if (awaitConnection) {
try {
await pollForSyncConnectionAndAwait(maxWait);
} catch (e) {
db.error(e);
}
}
db.emit('roomRemoteLoaded', room);
}
export type RemoteLoadOptions = {
/* whether to load the remote sync connection. Default is true */
awaitLoadRemote?: boolean;
/* whether to await the remote sync connection. Default is true */
loadRemote?: boolean;
/* the maximum time to wait for the remote sync connection. Default is 30000ms */
loadRemoteMaxWait?: number;
/** use Awareness, false by default */
withAwareness?: boolean;
};
export const loadRoom =
(db: Database) =>
async (serverRoom: ServerRoom, remoteLoadOptions?: RemoteLoadOptions) => {
const loadRemote = remoteLoadOptions?.loadRemote ?? true;
const awaitLoadRemote = remoteLoadOptions?.awaitLoadRemote ?? true;
const loadRemoteMaxWait = remoteLoadOptions?.loadRemoteMaxWait ?? 30000;
const withAwareness = remoteLoadOptions?.withAwareness ?? false;
const { roomId, collectionKey } = validate(serverRoom);
const room = (db.collections[collectionKey][roomId] ??
new Room({ db, ...serverRoom })) as unknown as Room<EweDocument>;
db.info('loading room', { room, serverRoom });
const { localLoaded, syncLoaded, shouldLoadSync } = checkLoadedState(db)(
room,
serverRoom.syncUrl
);
db.debug('loadedState', {
localLoaded,
syncLoaded,
shouldLoadSync,
});
if (localLoaded && (!shouldLoadSync || syncLoaded)) {
db.debug('room already loaded', room);
return room;
}
if (!localLoaded) {
await loadLocal(db, room);
}
if (loadRemote && shouldLoadSync && !syncLoaded) {
if (awaitLoadRemote) {
await loadSync(db, room, withAwareness, true, loadRemoteMaxWait);
} else {
loadSync(db, room, withAwareness, false);
}
}
// Save the room to the db
db.collections[collectionKey][roomId] = room;
db.emit('roomLoaded', room);
return room;
};