@casual-simulation/aux-common
Version:
Common library for AUX projects
691 lines • 30.7 kB
JavaScript
import { isTagEdit, preserve, del, insert, edit, ON_REMOTE_WHISPER_ACTION_NAME, ON_REMOTE_DATA_ACTION_NAME, getInstStateFromUpdates, } from '../bots';
import { BehaviorSubject } from 'rxjs';
import { breakIntoIndividualEvents, botAdded, createBot, botRemoved, hasValue, botUpdated, action, asyncResult, asyncError, convertToString, ON_SPACE_MAX_SIZE_REACHED, ON_SPACE_RATE_LIMIT_EXCEEDED_ACTION_NAME, } from '../bots';
import { Text, Map, applyUpdate, YMapEvent, createAbsolutePositionFromRelativePosition, YTextEvent, } from 'yjs';
import { MemoryPartitionImpl } from './MemoryPartition';
import { createRelativePositionFromStateVector, getClock, getStateVector, } from '../yjs/YjsHelpers';
import { fromByteArray, toByteArray } from 'base64-js';
import { YjsPartitionImpl } from './YjsPartition';
import { ensureTagIsSerializable, getStateFromUpdates, supportsRemoteEvent, } from './PartitionUtils';
import { device } from '../common';
import { RemoteYjsSharedDocument } from '../documents/RemoteYjsSharedDocument';
import { v4 as uuid } from 'uuid';
/**
* Attempts to create a YjsPartition from the given config.
* @param config The config.
* @param authSource The auth source.
*/
export function createRemoteClientYjsPartition(config, authSource) {
if (config.type === 'yjs_client') {
return new RemoteYjsPartitionImpl(config.client, authSource, config);
}
return undefined;
}
export class RemoteYjsPartitionImpl extends RemoteYjsSharedDocument {
get onBotsAdded() {
return this._internalPartition.onBotsAdded;
}
get onBotsRemoved() {
return this._internalPartition.onBotsRemoved;
}
get onBotsUpdated() {
return this._internalPartition.onBotsUpdated;
}
get onStateUpdated() {
return this._internalPartition.onStateUpdated;
}
unsubscribe() {
return this._sub.unsubscribe();
}
get closed() {
return this._sub.closed;
}
get state() {
return this._internalPartition.state;
}
get space() {
return this._internalPartition.space;
}
set space(value) {
this._internalPartition.space = value;
}
get realtimeStrategy() {
return this._static ? 'delayed' : 'immediate';
}
constructor(client, authSource, config) {
super(client, authSource, config);
this._emittedMaxSizeReached = false;
this.type = 'yjs';
this.private = config.private || false;
this._remoteEvents =
'remoteEvents' in config ? config.remoteEvents : true;
this._bots = this._doc.getMap('bots');
this._masks = this._doc.getMap('masks');
this._doc.on('afterTransaction', (transaction) => {
this._processTransaction(transaction);
});
this._currentVersion = {
currentSite: this._localId.toString(),
remoteSite: this._remoteId.toString(),
vector: {},
};
this._onVersionUpdated = new BehaviorSubject(this._currentVersion);
this._internalPartition = new MemoryPartitionImpl({
type: 'memory',
initialState: {},
localSiteId: this._currentSite,
remoteSiteId: this._remoteSite,
});
this._internalPartition.getCurrentVersion = () => this._currentVersion;
this._internalPartition.getNextVersion = (textEdit) => {
var _a;
const version = getStateVector(this._doc);
if (this._isRemoteUpdate) {
const { [this._currentSite]: currentVersion, ...otherVersions } = version;
return {
currentSite: this._currentSite,
remoteSite: this._remoteSite,
vector: otherVersions,
};
}
else {
const site = textEdit.isRemote
? this._remoteSite
: this._currentSite;
return {
currentSite: this._currentSite,
remoteSite: this._remoteSite,
vector: {
[site]: (_a = version[this._doc.clientID]) !== null && _a !== void 0 ? _a : 0,
},
};
}
};
}
async applyEvents(events) {
if (this._static) {
return [];
}
let finalEvents = [];
for (let e of events) {
if (e.type === 'apply_state') {
finalEvents.push(...breakIntoIndividualEvents(this.state, e));
}
else if (e.type === 'add_bot' ||
e.type === 'remove_bot' ||
e.type === 'update_bot') {
finalEvents.push(e);
}
}
this._applyEvents(finalEvents);
return [];
}
async sendRemoteEvents(events) {
if (this._readOnly || !this._remoteEvents) {
return;
}
for (let event of events) {
if (!supportsRemoteEvent(this._remoteEvents, event)) {
continue;
}
if (event.type === 'remote') {
if (event.event.type === 'get_remotes') {
// Do nothing for get_remotes since it will be handled by the OtherPlayersPartition.
// TODO: Make this mechanism more extensible so that we don't have to hardcode for each time
// we do this type of logic.
}
else if (event.event.type === 'get_remote_count') {
const action = event.event;
this._client
.connectionCount(action.recordName, action.inst, action.branch)
.subscribe({
next: (count) => {
this._onEvents.next([
asyncResult(event.taskId, count),
]);
},
error: (err) => {
this._onEvents.next([
asyncError(event.taskId, err),
]);
},
});
}
else if (event.event.type === 'list_inst_updates') {
const action = event.event;
this._client
.getBranchUpdates(this._recordName, this._inst, this._branch)
.subscribe({
next: ({ updates, timestamps }) => {
this._onEvents.next([
asyncResult(event.taskId, updates.map((u, i) => ({
id: i,
update: u,
timestamp: timestamps === null || timestamps === void 0 ? void 0 : timestamps[i],
}))),
]);
},
error: (err) => {
this._onEvents.next([
asyncError(event.taskId, err),
]);
},
});
}
else if (event.event.type === 'get_inst_state_from_updates') {
const action = event.event;
try {
let partition = new YjsPartitionImpl({
type: 'yjs',
});
for (let { update } of action.updates) {
const updateBytes = toByteArray(update);
applyUpdate(partition.doc, updateBytes);
}
this._onEvents.next([
asyncResult(event.taskId, partition.state, false),
]);
}
catch (err) {
this._onEvents.next([asyncError(event.taskId, err)]);
}
}
else if (event.event.type === 'create_initialization_update') {
const action = (event.event);
try {
let partition = new YjsPartitionImpl({
type: 'yjs',
});
partition.doc.on('update', (update) => {
let instUpdate = {
id: 0,
timestamp: Date.now(),
update: fromByteArray(update),
};
this._onEvents.next([
asyncResult(event.taskId, instUpdate, false),
]);
});
await partition.applyEvents(action.bots.map((b) => botAdded(createBot(b.id, b.tags))));
}
catch (err) {
this._onEvents.next([asyncError(event.taskId, err)]);
}
}
else if (event.event.type === 'apply_updates_to_inst') {
const action = event.event;
try {
this.applyStateUpdates(action.updates);
this._onEvents.next([
asyncResult(event.taskId, null, false),
]);
}
catch (err) {
this._onEvents.next([asyncError(event.taskId, err)]);
}
}
else if (event.event.type === 'get_current_inst_update') {
const action = event.event;
try {
const update = this.getStateUpdate();
this._onEvents.next([
asyncResult(event.taskId, update, false),
]);
}
catch (err) {
this._onEvents.next([asyncError(event.taskId, err)]);
}
}
else if (event.event.type === 'install_aux_file') {
const action = event.event;
try {
if (action.mode === 'copy') {
let bots;
if (action.aux.version === 2) {
const state = getStateFromUpdates(getInstStateFromUpdates(action.aux.updates));
bots = Object.values(state);
}
else {
bots = Object.values(action.aux.state);
}
if (bots && bots.length > 0) {
this._applyEvents(bots.map((b) => botAdded(createBot(uuid(), b.tags))));
}
}
else {
if (action.aux.version === 2) {
this.applyStateUpdates(action.aux.updates);
}
else if (action.aux.version === 1) {
this._applyEvents(Object.values(action.aux.state).map((b) => botAdded(createBot(b.id, b.tags))));
}
}
this._onEvents.next([
asyncResult(event.taskId, null, false),
]);
}
catch (err) {
this._onEvents.next([asyncError(event.taskId, err)]);
}
}
else {
this._client.sendAction(this._recordName, this._inst, this._branch, event);
}
}
else {
this._client.sendAction(this._recordName, this._inst, this._branch, event);
}
}
}
_handleClientEvent(event) {
if (event.action.type === 'device') {
if (event.action.event.type === 'action') {
const remoteAction = event.action.event;
this._onEvents.next([
action(ON_REMOTE_DATA_ACTION_NAME, null, null, {
name: remoteAction.eventName,
that: remoteAction.argument,
remoteId: event.action.connection.connectionId,
}),
action(ON_REMOTE_WHISPER_ACTION_NAME, null, null, {
name: remoteAction.eventName,
that: remoteAction.argument,
playerId: event.action.connection.connectionId,
}),
]);
}
else if (hasValue(event.action.taskId)) {
const newEvent = device(event.action.connection, {
...event.action.event,
taskId: event.action.taskId,
playerId: event.action.connection.connectionId,
}, event.action.taskId);
this._onEvents.next([newEvent]);
}
else {
this._onEvents.next([event.action]);
}
}
else {
super._handleClientEvent(event);
}
}
_onMaxSizeReached(event) {
super._onMaxSizeReached(event);
if (!this._emittedMaxSizeReached) {
console.log('[RemoteYjsPartition] Max size reached!', this.recordName, this.address);
this._emittedMaxSizeReached = true;
this._onEvents.next([
action(ON_SPACE_MAX_SIZE_REACHED, null, null, {
space: this.space,
maxSizeInBytes: event.maxBranchSizeInBytes,
neededSizeInBytes: event.neededBranchSizeInBytes,
}),
]);
}
}
_onRateLimitExceeded(event) {
super._onRateLimitExceeded(event);
this._onEvents.next([
action(ON_SPACE_RATE_LIMIT_EXCEEDED_ACTION_NAME, null, null, {
space: this.space,
}),
]);
}
_applyEvents(events) {
try {
this._isLocalTransaction = true;
this._doc.transact((t) => {
for (let event of events) {
if (event.type === 'add_bot') {
const map = new Map();
for (let tag in event.bot.tags) {
const val = ensureTagIsSerializable(event.bot.tags[tag]);
if (hasValue(val)) {
const yVal = typeof val === 'string'
? new Text(val)
: val;
map.set(tag, yVal);
}
}
if (this.space && event.bot.masks) {
const tags = event.bot.masks[this.space];
if (tags) {
for (let tag of Object.keys(tags)) {
const maskId = tagMaskId(event.id, tag);
const val = ensureTagIsSerializable(tags[tag]);
if (hasValue(val)) {
const yVal = typeof val === 'string'
? new Text(val)
: val;
this._masks.set(maskId, yVal);
}
}
}
}
this._bots.set(event.id, map);
}
else if (event.type === 'remove_bot') {
this._bots.delete(event.id);
}
else if (event.type === 'update_bot') {
const currentBot = this.state[event.id];
const currentMap = this._bots.get(event.id);
if (event.update.tags && currentBot && currentMap) {
for (let tag of Object.keys(event.update.tags)) {
let newVal = ensureTagIsSerializable(event.update.tags[tag]);
const oldVal = currentBot.tags[tag];
if (newVal === oldVal) {
if (Array.isArray(newVal)) {
newVal = newVal.slice();
}
else {
continue;
}
}
this._updateValueInMap(this._doc, currentMap, tag, newVal);
}
}
if (this.space && event.update.masks) {
const tags = event.update.masks[this.space];
if (tags) {
for (let tag of Object.keys(tags)) {
const maskId = tagMaskId(event.id, tag);
const value = ensureTagIsSerializable(tags[tag]);
this._updateValueInMap(this._doc, this._masks, maskId, value);
}
}
}
}
}
});
}
finally {
this._isLocalTransaction = false;
}
}
// private _applyUpdates(updates: string[], transactionOrigin?: string) {
// try {
// this._isRemoteUpdate = true;
// for (let updateBase64 of updates) {
// const update = toByteArray(updateBase64);
// applyUpdate(this._doc, update, transactionOrigin);
// }
// } finally {
// this._isRemoteUpdate = false;
// }
// }
async _processTransaction(transaction) {
let memoryEvents = [];
const version = getStateVector(this._doc);
for (let [type, events] of transaction.changedParentTypes) {
if (type === this._bots) {
for (let event of events) {
// Update the current target so that the event
// path is calculated from the bots map.
// see https://github.com/yjs/yjs/blob/5244755879daaa7b5a1ca64e6af617cdbb110462/src/utils/YEvent.js#L63
event.currentTarget = this._bots;
const target = event.target;
if (target === type) {
// Bot was added or removed
if (event instanceof YMapEvent) {
for (let [key, change] of event.changes.keys) {
if (change.action === 'add') {
// bot added
const value = this._bots.get(key);
const bot = this._mapToBot(key, value);
memoryEvents.push(botAdded(bot));
}
else if (change.action === 'delete') {
// bot deleted
memoryEvents.push(botRemoved(key));
}
else if (change.action === 'update') {
// bot updated in a way that we can't track
// in this scenario, we just remove the bot and re-add it
memoryEvents.push(botRemoved(key));
const value = this._bots.get(key);
const bot = this._mapToBot(key, value);
memoryEvents.push(botAdded(bot));
}
}
}
}
else {
this._handleValueUpdates(target, event, memoryEvents, version, (event) => event.path[event.path.length - 1], (event, key) => {
throw new Error('should not happen');
}, (event) => ({
id: event.path[event.path.length - 2],
tag: event.path[event.path.length - 1],
}), (id, tags) => botUpdated(id, {
tags,
}), (id, tag, val) => botUpdated(id, {
tags: {
[tag]: val,
},
}));
}
}
}
else if (type === this._masks) {
for (let event of events) {
// Update the current target so that the event
// path is calculated from the bots map.
// see https://github.com/yjs/yjs/blob/5244755879daaa7b5a1ca64e6af617cdbb110462/src/utils/YEvent.js#L63
event.currentTarget = this._masks;
const target = event.target;
this._handleValueUpdates(target, event, memoryEvents, version, (event) => event.path[event.path.length - 1], (event, key) => parseTagMaskId(key), (event) => parseTagMaskId(event.path[event.path.length - 1]), (id, tags) => botUpdated(id, {
masks: {
[this.space]: tags,
},
}), (id, tag, val) => botUpdated(id, {
masks: {
[this.space]: {
[tag]: val,
},
},
}));
}
}
}
this._currentVersion = {
currentSite: this._currentSite,
remoteSite: this._remoteSite,
vector: version,
};
await this._internalPartition.applyEvents(memoryEvents);
this._onVersionUpdated.next(this._currentVersion);
}
_mapToBot(id, map) {
let tags = {};
for (let [key, value] of map.entries()) {
const val = map.get(key);
let finalVal;
if (hasValue(val)) {
if (val instanceof Text) {
finalVal = val.toString();
}
else {
finalVal = val;
}
tags[key] = finalVal;
}
}
return createBot(id, tags);
}
/**
* Updates the given value in the given document and map.
* This is a generalization of the update logic for tags and tag masks.
* @param doc The document.
* @param map The map that contains the values.
* @param valueId The ID that the value should be looked up by. For tags, this is the tag name. For tag masks, this is the tag mask ID.
* @param newVal The new value.
*/
_updateValueInMap(doc, map, valueId, newVal) {
doc.clientID = this._localId;
if (hasValue(newVal)) {
if (isTagEdit(newVal)) {
if (newVal.isRemote) {
doc.clientID = this._remoteId;
this._isLocalTransaction = false;
}
const val = map.get(valueId);
let text;
if (val instanceof Text) {
text = val;
}
else {
text = new Text(convertToString(val));
map.set(valueId, text);
}
const version = {
...newVal.version,
[this._currentSite]: getClock(doc, this._localId),
};
if (text instanceof Text) {
for (let ops of newVal.operations) {
let index = 0;
for (let op of ops) {
if (op.type === 'preserve') {
index += op.count;
}
else if (op.type === 'insert') {
if (op.text.length <= 0) {
continue;
}
const relativePos = createRelativePositionFromStateVector(text, version, index);
const finalPosition = createAbsolutePositionFromRelativePosition(relativePos, doc);
text.insert(finalPosition.index, op.text);
index += op.text.length;
}
else if (op.type === 'delete') {
if (op.count <= 0) {
continue;
}
const relativePos = createRelativePositionFromStateVector(text, version, index);
const finalPosition = createAbsolutePositionFromRelativePosition(relativePos, doc);
text.delete(finalPosition.index, op.count);
}
}
version[this._currentSite] = getClock(doc, this._localId);
}
}
}
else {
const yVal = typeof newVal === 'string' ? new Text(newVal) : newVal;
map.set(valueId, yVal);
}
}
else {
map.delete(valueId);
}
}
_handleValueUpdates(target, event, events, version, getMapBotId, getTagMaskBotValues, getTextBotValues, createBotUpdate, createTextUpdate) {
// child event - this could mean that a bot tag was updated
if (target instanceof Map) {
if (event instanceof YMapEvent) {
if (target === this._masks) {
let botToMasks = new globalThis.Map();
for (let [key, change] of event.changes.keys) {
const { id, tag } = getTagMaskBotValues(event, key);
let tags = botToMasks.get(id);
if (!tags) {
tags = {};
botToMasks.set(id, tags);
}
if (change.action === 'add' ||
change.action === 'update') {
// tag added
const value = target.get(key);
const auxValue = value instanceof Text
? value.toString()
: value;
tags[tag] = auxValue;
}
else if (change.action === 'delete') {
tags[tag] = null;
}
}
for (let [id, tags] of botToMasks) {
events.push(createBotUpdate(id, tags));
}
}
else {
// Maps are only used for bots and tags
// so a map that is not the bots map must be for a tag
const id = getMapBotId(event);
let tags = {};
for (let [key, change] of event.changes.keys) {
if (change.action === 'add' ||
change.action === 'update') {
// tag added
const value = target.get(key);
const auxValue = value instanceof Text
? value.toString()
: value;
tags[key] = auxValue;
}
else if (change.action === 'delete') {
tags[key] = null;
}
}
events.push(createBotUpdate(id, tags));
}
}
}
else if (target instanceof Text) {
// text is used for string tag values
const { id, tag } = getTextBotValues(event);
if (event instanceof YTextEvent) {
let operations = [];
for (let delta of event.delta) {
if (hasValue(delta.retain)) {
operations.push(preserve(delta.retain));
}
else if (hasValue(delta.insert)) {
if (typeof delta.insert === 'string') {
operations.push(insert(delta.insert));
}
else if (Array.isArray(delta.insert)) {
for (let str of delta.insert) {
operations.push(insert(str));
}
}
}
else if (hasValue(delta.delete)) {
operations.push(del(delta.delete));
}
}
const siteId = this._isLocalTransaction
? this._currentSite
: this._remoteSite;
const e = edit({ [siteId]: version[siteId] }, ...operations);
e.isRemote = !this._isLocalTransaction;
events.push(createTextUpdate(id, tag, e));
}
}
}
}
/**
* Creates the tag mask ID for the given bot ID and tag.
* @param botId The ID of the bot.
* @param tag the tag.
*/
function tagMaskId(botId, tag) {
return `${botId}:${tag}`;
}
/**
* Creates the tag mask ID for the given bot ID and tag.
* @param botId The ID of the bot.
* @param tag the tag.
*/
function parseTagMaskId(val) {
const index = val.indexOf(':');
if (index < 0) {
throw new Error('Invalid tag mask ID');
}
const id = val.substring(0, index);
const tag = val.substring(index + 1);
return { id, tag };
}
//# sourceMappingURL=RemoteYjsPartition.js.map