@casual-simulation/aux-common
Version:
Common library for AUX projects
370 lines • 14.2 kB
JavaScript
/* 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