@casual-simulation/aux-vm-browser
Version:
A set of utilities required to securely run an AUX in a web browser.
531 lines • 21.4 kB
JavaScript
import { botAdded, botRemoved, botUpdated, breakIntoIndividualEvents, getActiveObjects, hasValue, stateUpdatedEvent, merge, } from '@casual-simulation/aux-common';
import { union } from 'es-toolkit/compat';
import { Subject, Subscription, fromEventPattern, BehaviorSubject } from 'rxjs';
import { startWith, filter, map } from 'rxjs/operators';
import { applyTagEdit, edits, isTagEdit, } from '@casual-simulation/aux-common/bots';
import { ensureBotIsSerializable, ensureTagIsSerializable, } from '@casual-simulation/aux-common/partitions/PartitionUtils';
import { v4 as uuid } from 'uuid';
/**
* Attempts to create a proxy client partition that is loaded from a remote inst.
* @param options The options to use.
* @param config The config to use.
*/
export async function createLocalStoragePartition(config) {
if (config.type === 'local_storage') {
const partition = new LocalStoragePartitionImpl(config);
await partition.init();
return partition;
}
return undefined;
}
export class LocalStoragePartitionImpl {
get realtimeStrategy() {
return 'immediate';
}
get onBotsAdded() {
return this._onBotsAdded.pipe(startWith(getActiveObjects(this.state)));
}
get onBotsRemoved() {
return this._onBotsRemoved;
}
get onBotsUpdated() {
return this._onBotsUpdated;
}
get onStateUpdated() {
return this._onStateUpdated.pipe(startWith(stateUpdatedEvent(this.state, this._onVersionUpdated.value)));
}
get onError() {
return this._onError;
}
get onEvents() {
return this._onEvents;
}
get onStatusUpdated() {
return this._onStatusUpdated;
}
get onVersionUpdated() {
return this._onVersionUpdated;
}
unsubscribe() {
return this._sub.unsubscribe();
}
get closed() {
return this._sub.closed;
}
get state() {
return this._state;
}
constructor(config) {
this._onBotsAdded = new Subject();
this._onBotsRemoved = new Subject();
this._onBotsUpdated = new Subject();
this._onStateUpdated = new Subject();
this._onError = new Subject();
this._onEvents = new Subject();
this._onStatusUpdated = new Subject();
this._hasRegisteredSubs = false;
this._state = {};
this._sub = new Subscription();
this._siteId = uuid();
this._remoteSite = uuid();
this._updateCounter = 0;
this.type = 'local_storage';
this.private = config.private || false;
this.namespace = config.namespace;
this._botsNamespace = `${this.namespace}/bots`;
this._instNamespace = `${this.namespace}/inst`;
this._onVersionUpdated = new BehaviorSubject({
currentSite: this._siteId,
remoteSite: this._remoteSite,
vector: {},
});
}
async applyEvents(events) {
const finalEvents = events.flatMap((e) => {
if (e.type === 'apply_state') {
return breakIntoIndividualEvents(this.state, e);
}
else if (e.type === 'add_bot' ||
e.type === 'remove_bot' ||
e.type === 'update_bot') {
return [e];
}
else {
return [];
}
});
this._applyEvents(finalEvents, true);
return [];
}
async init() { }
connect() {
this._watchLocalStorage();
this._loadExistingBots();
this._onStatusUpdated.next({
type: 'connection',
connected: true,
});
this._onStatusUpdated.next({
type: 'authentication',
authenticated: true,
});
this._onStatusUpdated.next({
type: 'authorization',
authorized: true,
});
this._onStatusUpdated.next({
type: 'sync',
synced: true,
});
}
_watchLocalStorage() {
this._sub.add(storedBotUpdated(this._botsNamespace, this.space).subscribe((event) => {
this._applyEvents([event], false);
}));
}
_loadExistingBots() {
let events = [];
if (localStorage) {
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key.startsWith(this._botsNamespace + '/')) {
// it is a bot
const stored = getStoredBot(key);
if (stored.id) {
events.push(botAdded(stored));
}
else {
const id = key.substring(this._botsNamespace.length + 1);
events.push(botUpdated(id, stored));
}
}
}
}
this._applyEvents(events, false);
}
_applyEvents(events, updateStorage) {
var _a, _b;
let addedBots = new Map();
let removedBots = [];
let updated = new Map();
let updatedState = {};
let nextVersion;
// Flag to record if we have already created a new state object
// during the update.
let createdNewState = false;
let hasUpdate = false;
for (let event of events) {
if (event.type === 'add_bot') {
let bot = {
...ensureBotIsSerializable(event.bot),
space: this.space,
};
if (createdNewState) {
this._state[event.bot.id] = bot;
}
else {
this._state = Object.assign({}, this._state, {
[event.bot.id]: bot,
});
createdNewState = true;
}
updatedState[event.bot.id] = bot;
addedBots.set(event.bot.id, bot);
if (updateStorage) {
const key = botKey(this._botsNamespace, bot.id);
hasUpdate = storeBot(key, bot, this.namespace);
}
}
else if (event.type === 'remove_bot') {
const id = event.id;
if (createdNewState) {
delete this._state[id];
}
else {
let { [id]: removedBot, ...state } = this._state;
this._state = state;
createdNewState = true;
}
if (!addedBots.delete(event.id)) {
removedBots.push(event.id);
}
if (updateStorage) {
const key = botKey(this._botsNamespace, id);
hasUpdate = storeBot(key, null, this.namespace);
}
updatedState[event.id] = null;
}
else if (event.type === 'update_bot') {
if (event.update.tags && this.state[event.id]) {
let newBot = Object.assign({}, this.state[event.id]);
let changedTags = [];
let lastBot = updatedState[event.id];
const updatedBot = (updatedState[event.id] = merge(updatedState[event.id] || {}, event.update));
for (let tag of Object.keys(event.update.tags)) {
if (!newBot.tags) {
newBot.tags = {};
}
const newVal = ensureTagIsSerializable(event.update.tags[tag]);
const oldVal = newBot.tags[tag];
if ((newVal !== oldVal &&
(hasValue(newVal) || hasValue(oldVal))) ||
Array.isArray(newVal)) {
changedTags.push(tag);
}
if (hasValue(newVal)) {
if (isTagEdit(newVal)) {
newBot.tags[tag] = applyTagEdit(newBot.tags[tag], newVal);
nextVersion = {
currentSite: this._onVersionUpdated.value
.currentSite,
remoteSite: this._onVersionUpdated.value.remoteSite,
vector: {
...this._onVersionUpdated.value.vector,
[newVal.isRemote
? this._remoteSite
: this._siteId]: (this._updateCounter += 1),
},
};
let combinedEdits = [];
if (lastBot) {
const lastVal = lastBot.tags[tag];
if (lastVal !== oldVal &&
isTagEdit(lastVal)) {
combinedEdits = lastVal.operations;
}
}
updatedBot.tags[tag] = edits(nextVersion.vector, ...combinedEdits, ...newVal.operations);
}
else {
newBot.tags[tag] = newVal;
updatedBot.tags[tag] = newVal;
}
if (!hasValue(newBot.tags[tag])) {
delete newBot.tags[tag];
}
}
else if (hasValue(oldVal)) {
delete newBot.tags[tag];
updatedBot.tags[tag] = null;
}
else {
// The tag was already deleted and set to null/undefined,
// so no change should be recorded.
delete newBot.tags[tag];
delete updatedBot.tags[tag];
}
}
this.state[event.id] = newBot;
let update = updated.get(event.id);
if (update) {
update.bot = newBot;
update.tags = union(update.tags, changedTags);
}
else if (changedTags.length > 0) {
updated.set(event.id, {
bot: newBot,
tags: changedTags,
});
}
else if (!addedBots.has(event.id)) {
// No tags were changed, so the update should not be included in the updated state
delete updatedState[event.id];
}
}
if (event.update.masks && event.update.masks[this.space]) {
const tags = event.update.masks[this.space];
let newBot = Object.assign({}, this.state[event.id]);
if (!newBot.masks) {
newBot.masks = {};
}
if (!newBot.masks[this.space]) {
newBot.masks[this.space] = {};
}
let lastMasks = (_b = (_a = updatedState[event.id]) === null || _a === void 0 ? void 0 : _a.masks) === null || _b === void 0 ? void 0 : _b[this.space];
const masks = newBot.masks[this.space];
const updatedBot = (updatedState[event.id] = merge(updatedState[event.id] || {}, event.update));
let changedTags = [];
for (let tag in tags) {
const newVal = ensureTagIsSerializable(tags[tag]);
const oldVal = masks[tag];
if (newVal !== oldVal) {
changedTags.push(tag);
}
if (hasValue(newVal)) {
if (isTagEdit(newVal)) {
masks[tag] = applyTagEdit(masks[tag], newVal);
nextVersion = {
currentSite: this._onVersionUpdated.value
.currentSite,
remoteSite: this._onVersionUpdated.value.remoteSite,
vector: {
...this._onVersionUpdated.value.vector,
[newVal.isRemote
? this._remoteSite
: this._siteId]: (this._updateCounter += 1),
},
};
let combinedEdits = [];
if (lastMasks) {
const lastVal = lastMasks[tag];
if (lastVal !== oldVal &&
isTagEdit(lastVal)) {
combinedEdits = lastVal.operations;
}
}
updatedBot.masks[this.space][tag] = edits(nextVersion.vector, ...combinedEdits, ...newVal.operations);
}
else {
masks[tag] = newVal;
if (newVal !== tags[tag]) {
updatedBot.masks[this.space][tag] = newVal;
}
}
}
else {
delete masks[tag];
updatedBot.masks[this.space][tag] = null;
}
}
if (newBot.masks) {
for (let space in event.update.masks) {
for (let tag in event.update.masks[this.space]) {
if (newBot.masks[space][tag] === null) {
delete newBot.masks[space][tag];
}
}
if (Object.keys(newBot.masks[space]).length <= 0) {
delete newBot.masks[space];
}
}
if (!!newBot.masks &&
Object.keys(newBot.masks).length <= 0) {
delete newBot.masks;
}
}
this.state[event.id] = newBot;
}
const updatedBot = updatedState[event.id];
if ((updatedBot === null || updatedBot === void 0 ? void 0 : updatedBot.tags) &&
Object.keys(updatedBot.tags).length <= 0) {
delete updatedBot.tags;
}
}
}
if (addedBots.size > 0) {
this._onBotsAdded.next([...addedBots.values()]);
}
if (removedBots.length > 0) {
this._onBotsRemoved.next(removedBots);
}
if (updated.size > 0) {
let updatedBots = [...updated.values()];
this._onBotsUpdated.next(updatedBots);
}
const updateEvent = stateUpdatedEvent(updatedState, nextVersion !== null && nextVersion !== void 0 ? nextVersion : this._onVersionUpdated.value);
if (updateEvent.addedBots.length > 0 ||
updateEvent.removedBots.length > 0 ||
updateEvent.updatedBots.length > 0) {
if (updateStorage && updateEvent.updatedBots.length > 0) {
for (let id of updateEvent.updatedBots) {
let bot = this.state[id];
const key = botKey(this._botsNamespace, id);
hasUpdate = storeBot(key, bot, this.namespace);
}
}
this._onStateUpdated.next(updateEvent);
}
if (nextVersion) {
this._onVersionUpdated.next(nextVersion);
}
if (hasUpdate) {
try {
localStorage.setItem(this._instNamespace, Date.now().toString());
}
catch (err) {
console.error(err);
}
}
}
}
function storedBotUpdated(namespace, space) {
return storageUpdated().pipe(filter((e) => e.key.startsWith(namespace + '/')), map((e) => {
var _a;
const newBot = JSON.parse(e.newValue) || null;
const oldBot = JSON.parse(e.oldValue) || null;
const id = e.key.substring(namespace.length + 1);
if (!oldBot && newBot && newBot.id) {
return botAdded(newBot);
}
else if (!newBot && oldBot && oldBot.id) {
return botRemoved(id);
}
else if (newBot) {
let differentTags = calculateDifferentTags(newBot.tags, oldBot === null || oldBot === void 0 ? void 0 : oldBot.tags);
let differentMasks = null;
if (newBot.masks) {
if (newBot.masks[space]) {
differentMasks = {
[space]: calculateDifferentTags(newBot.masks[space], (_a = oldBot === null || oldBot === void 0 ? void 0 : oldBot.masks) === null || _a === void 0 ? void 0 : _a[space]),
};
}
}
let update = {};
if (Object.keys(differentTags).length > 0) {
update.tags = differentTags;
}
if (differentMasks !== null) {
update.masks = differentMasks;
}
return botUpdated(id, update);
}
return null;
}), filter((event) => event !== null));
}
function storageUpdated() {
return fromEventPattern((h) => globalThis.addEventListener('storage', h), (h) => globalThis.removeEventListener('storage', h));
}
function botKey(namespace, id) {
return `${namespace}/${id}`;
}
function getStoredBot(key) {
if (!localStorage) {
return null;
}
const json = localStorage.getItem(key);
if (json) {
const bot = JSON.parse(json);
return bot;
}
else {
return null;
}
}
const MAX_ATTEMPTS = 4;
function storeBot(key, bot, namespace) {
if (!localStorage) {
return false;
}
let lastError;
for (let i = 0; i < MAX_ATTEMPTS; i++) {
try {
if (bot) {
const json = JSON.stringify(bot);
localStorage.setItem(key, json);
}
else {
localStorage.removeItem(key);
}
return true;
}
catch (err) {
lastError = err;
if (!clearOldData(namespace)) {
// break out of the loop if no data was deleted
break;
}
}
}
if (lastError) {
console.error(lastError);
}
console.warn('[LocalStoragePartition] Failed to store bot in local space.');
return false;
}
/**
* Searches local storage and deletes the oldest namespace.
* Returns whether any data was deleted.
* @param namespaceToIgnore The namespace that should not be deleted even if it is the oldest.
* @returns
*/
function clearOldData(namespaceToIgnore) {
if (!localStorage) {
return;
}
console.log('[LocalStoragePartition] Clearing old data');
let validNamespaces = [];
for (let i = 0; i < localStorage.length; i++) {
let k = localStorage.key(i);
if (k.endsWith('/inst') && !k.startsWith(namespaceToIgnore)) {
validNamespaces.push(k);
}
}
validNamespaces.sort();
let oldestNamespace;
let oldestTime = Infinity;
for (let namespace of validNamespaces) {
let time = JSON.parse(localStorage.getItem(namespace));
if (time < oldestTime) {
oldestTime = time;
oldestNamespace = namespace;
}
}
if (oldestNamespace) {
let namespace = oldestNamespace.substring(0, oldestNamespace.length - 'inst'.length);
console.log('[LocalStoragePartition] Deleting namespace', namespace);
let keysToDelete = [];
for (let i = 0; i < localStorage.length; i++) {
let k = localStorage.key(i);
if (k.startsWith(namespace)) {
keysToDelete.push(k);
}
}
for (let k of keysToDelete) {
localStorage.removeItem(k);
}
return keysToDelete.length > 0;
}
return false;
}
function calculateDifferentTags(newTags, oldTags) {
const allTags = union(Object.keys(newTags || {}), Object.keys(oldTags || {}));
let differentTags = {};
for (let t of allTags) {
const newTag = newTags === null || newTags === void 0 ? void 0 : newTags[t];
if (newTag !== (oldTags === null || oldTags === void 0 ? void 0 : oldTags[t])) {
differentTags[t] = newTag;
}
}
return differentTags;
}
//# sourceMappingURL=LocalStoragePartition.js.map