UNPKG

@casual-simulation/aux-common

Version:
299 lines 11.4 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 { filter, firstValueFrom, 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 { unsubscribe() { this._sub.unsubscribe(); } constructor(client, authSource, config) { super(config); this._sendInitialUpdates = false; 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._persistence = config.localPersistence; this._synced = false; this._authorized = false; // static implies read only this._readOnly = config.readOnly || this._static || false; } connect() { var _a, _b; if (!this._temporary && ((_a = this._persistence) === null || _a === void 0 ? void 0 : _a.saveToIndexedDb)) { console.log('[RemoteYjsSharedDocument] Using IndexedDB persistence'); const name = `${(_b = this._recordName) !== null && _b !== void 0 ? _b : ''}/${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() { var _a; 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: (_a = this._client.connection.info) !== null && _a !== void 0 ? _a : { 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, }) .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); } _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