UNPKG

@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
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