UNPKG

@casual-simulation/aux-common

Version:
370 lines 14.2 kB
/* CasualOS is a set of web-based tools designed to facilitate the creation of real-time, multi-user, context-aware interactive experiences. * * Copyright (c) 2019-2025 Casual Simulation, Inc. * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see <https://www.gnu.org/licenses/>. */ import { Observable } from 'rxjs'; import { filter, firstValueFrom, startWith, Subject, Subscription } from 'rxjs'; import { encodeStateAsUpdate } from 'yjs'; import { YjsIndexedDBPersistence } from '../yjs/YjsIndexedDBPersistence'; import { fromByteArray } from 'base64-js'; import { getConnectionId } from '../common'; import { YjsSharedDocument, APPLY_UPDATES_TO_INST_TRANSACTION_ORIGIN, } from './YjsSharedDocument'; export function createRemoteClientYjsSharedDocument(config, services, client) { if (!config.inst) { return null; } return new RemoteYjsSharedDocument(client, services.authSource, config); } /** * Defines a shared document that is able to use YJS and a InstRecordsClient to synchronize over the network. */ export class RemoteYjsSharedDocument extends YjsSharedDocument { get remoteClients() { const existingClients = Array.from(this._connectedClients.values()).map((client) => this._clientConnectedEvent(client)); return this.remoteClientsRaw.pipe(startWith(...existingClients)); } get remoteClientsRaw() { return this._remoteClientsRaw; } unsubscribe() { this._stopWatchingBranchDevices(); this._remoteClientsSubject.unsubscribe(); this._sub.unsubscribe(); } constructor(client, authSource, config) { super(config); this._sendInitialUpdates = false; this._connectedClients = new Map(); this._remoteClientsSubject = new Subject(); this._watchingBranchDevices = null; this._remoteClientSubscribers = 0; this._recordName = config.recordName; this._inst = config.inst; this._branch = config.branch; this._client = client; this._authSource = authSource; this._static = config.static; this._skipInitialLoad = config.skipInitialLoad; this._temporary = config.temporary; this._markers = config.markers; this._persistence = config.localPersistence; this._synced = false; this._authorized = false; // static implies read only this._readOnly = config.readOnly || this._static || false; this._remoteClientsRaw = new Observable((subscriber) => { if (this._sub.closed) { return; } const remoteClientsSubscription = this._remoteClientsSubject.subscribe(subscriber); this._remoteClientSubscribers += 1; if (this._remoteClientSubscribers === 1) { this._startWatchingBranchDevices(); } return () => { remoteClientsSubscription.unsubscribe(); this._remoteClientSubscribers -= 1; if (this._remoteClientSubscribers <= 0) { this._remoteClientSubscribers = 0; this._stopWatchingBranchDevices(); } }; }); } connect() { if (!this._temporary && this._persistence?.saveToIndexedDb) { console.log('[RemoteYjsSharedDocument] Using IndexedDB persistence'); const name = `${this._recordName ?? ''}/${this._inst}/${this._branch}`; this._indexeddb = new YjsIndexedDBPersistence(name, this._doc); } if (this._skipInitialLoad) { this._initializePartitionWithoutLoading(); } else if (this._static) { this._requestBranch(); } else { this._watchBranch(); } } async enableCollaboration() { this._static = false; this._skipInitialLoad = false; this._sendInitialUpdates = true; this._synced = false; const promise = firstValueFrom(this._onStatusUpdated.pipe(filter((u) => u.type === 'sync' && u.synced))); this._watchBranch(); await promise; } async _initializePartitionWithoutLoading() { this._onStatusUpdated.next({ type: 'connection', connected: true, }); const indicator = this._client.connection.indicator; const connectionId = indicator ? getConnectionId(indicator) : 'missing-connection-id'; this._onStatusUpdated.next({ type: 'authentication', authenticated: true, info: this._client.connection.info ?? { connectionId: connectionId, sessionId: null, userId: null, }, }); this._updateSynced(true); } _requestBranch() { this._client .getBranchUpdates(this._recordName, this._inst, this._branch) .subscribe({ next: (updates) => { this._onStatusUpdated.next({ type: 'connection', connected: true, }); this._onStatusUpdated.next({ type: 'authentication', authenticated: true, info: this._client.connection.info, }); this._updateSynced(true); this._applyUpdates(updates.updates); if (!this._static) { // the partition has been unlocked while getting the branch this._watchBranch(); } }, error: (err) => this._onError.next(err), }); } _watchBranch() { if (this._watchingBranch) { return; } this._watchingBranch = true; this._sub.add(this._client.connection.connectionState.subscribe({ next: (state) => { const connected = state.connected; this._onStatusUpdated.next({ type: 'connection', connected: !!connected, }); if (connected) { this._onStatusUpdated.next({ type: 'authentication', authenticated: true, info: state.info, }); } else { this._updateSynced(false); } }, error: (err) => this._onError.next(err), })); this._sub.add(this._client .watchBranchUpdates({ type: 'repo/watch_branch', recordName: this._recordName, inst: this._inst, branch: this._branch, temporary: this._temporary, markers: this._markers, }) .subscribe({ next: (event) => { // The partition should become synced if it was not synced // and it just got some new data. if (!this._synced && event.type === 'updates') { if (this._sendInitialUpdates) { this._sendInitialUpdates = false; const update = encodeStateAsUpdate(this._doc); const updates = [fromByteArray(update)]; this._client.addUpdates(this._recordName, this._inst, this._branch, updates); } this._updateSynced(true); } if (event.type === 'updates') { this._applyUpdates(event.updates); } else if (event.type === 'event') { this._handleClientEvent(event); } else if (event.type === 'error') { this._handleClientError(event); } else if (event.type === 'repo/watch_branch_result') { if (event.success === false) { const errorCode = event.errorCode; if (this._isNotAuthorizedErrorCode(errorCode)) { const { type, ...error } = event; this._handleNotAuthorized(error); } } } }, error: (err) => this._onError.next(err), })); this._sub.add(this._client.watchRateLimitExceeded().subscribe((event) => { this._onRateLimitExceeded(event); })); const updateHandler = (update, origin, doc, transaction) => { if (this._readOnly) { return; } if (transaction && (transaction.local || origin === APPLY_UPDATES_TO_INST_TRANSACTION_ORIGIN)) { const updates = [fromByteArray(update)]; this._client.addUpdates(this._recordName, this._inst, this._branch, updates); this._onUpdates.next(updates); } }; this._doc.on('update', updateHandler); this._sub.add(new Subscription(() => { this._doc.off('update', updateHandler); })); } /** * Handles a client error that was received from the server. * @param event The event. */ _handleClientError(event) { if (event.kind === 'max_size_reached') { this._onMaxSizeReached(event); } else if (event.kind === 'error') { const error = event.info; if (this._isNotAuthorizedErrorCode(error.errorCode)) { this._handleNotAuthorized(error); } } } _isNotAuthorizedErrorCode(errorCode) { return (errorCode === 'not_authorized' || errorCode === 'subscription_limit_reached' || errorCode === 'inst_not_found' || errorCode === 'record_not_found' || errorCode === 'invalid_record_key' || errorCode === 'invalid_token' || errorCode === 'unacceptable_connection_id' || errorCode === 'unacceptable_connection_token' || errorCode === 'user_is_banned' || errorCode === 'not_logged_in' || errorCode === 'session_expired'); } _handleNotAuthorized(error) { this._onStatusUpdated.next({ type: 'authorization', authorized: false, error: error, }); this._authSource.sendAuthRequest({ type: 'request', kind: 'not_authorized', errorCode: error.errorCode, errorMessage: error.errorMessage, origin: this._client.connection.origin, reason: error.reason, resource: { type: 'inst', recordName: this._recordName, inst: this._inst, branch: this._branch, }, }); } /** * Handles a client event that was received from the server. * @param event The event that was received. */ _handleClientEvent(event) { this._onEvents.next([event.action]); } /** * Called when the server sends a rate limit exceeded message. * @param event The event that was sent. */ _onRateLimitExceeded(event) { console.error('[RemoteYjsSharedDocument] Rate limit exceeded!', event); } /** * Called when the server sends a max size reached message. * @param event The event that was sent. */ _onMaxSizeReached(event) { this._onClientError.next(event); } _clientConnectedEvent(client) { return { type: 'client_connected', client, isSelf: this._isSelfClient(client), }; } _clientDisconnectedEvent(client) { return { type: 'client_disconnected', client, isSelf: this._isSelfClient(client), }; } _isSelfClient(client) { return (client.connectionId === this._client.connection.info?.connectionId); } _startWatchingBranchDevices() { if (this._watchingBranchDevices) { return; } this._watchingBranchDevices = this._client .watchBranchDevices(this._recordName, this._inst, this._branch) .subscribe((event) => { if (event.type === 'repo/connected_to_branch') { this._connectedClients.set(event.connection.connectionId, event.connection); this._remoteClientsSubject.next(this._clientConnectedEvent(event.connection)); } else if (event.type === 'repo/disconnected_from_branch') { this._connectedClients.delete(event.connection.connectionId); this._remoteClientsSubject.next(this._clientDisconnectedEvent(event.connection)); } }); } _stopWatchingBranchDevices() { this._connectedClients.clear(); this._watchingBranchDevices?.unsubscribe(); this._watchingBranchDevices = null; } _updateSynced(synced) { if (synced && !this._authorized) { this._authorized = true; this._onStatusUpdated.next({ type: 'authorization', authorized: true, }); } this._synced = synced; queueMicrotask(() => { this._onStatusUpdated.next({ type: 'sync', synced: synced, }); }); } } //# sourceMappingURL=RemoteYjsSharedDocument.js.map