import Auth from './src/auth.js' import GraffitiArray from './src/array.js' export default class { // There needs to be a new object map for each tag constructor( graffitiURL="https://graffiti.garden", objectConstructor=()=>({})) { this.graffitiURL = graffitiURL this.open = false this.eventTarget = new EventTarget() this.tagMap = objectConstructor() // tag->{count, Set(uuid)} this.objectMap = objectConstructor() // uuid->object this.GraffitiArray = GraffitiArray(this) this.#initialize() } async #initialize() { // Perform authorization this.authParams = await Auth.connect(this.graffitiURL) // Rewrite the URL this.wsURL = new URL(this.graffitiURL) this.wsURL.host = "app." + this.wsURL.host if (this.wsURL.protocol == 'https:') { this.wsURL.protocol = 'wss:' } else { this.wsURL.protocol = 'ws:' } if (this.authParams.token) { this.wsURL.searchParams.set("token", this.authParams.token) } // Commence connection this.#connect() } // Wait for the connection to be // open (state=true) or closed (state=false) async connectionState(state) { if (this.open != state) { await new Promise(resolve => { this.eventTarget.addEventListener( state? "open": "closed", ()=> resolve()) }) } } #connect() { this.ws = new WebSocket(this.wsURL) this.ws.onmessage = this.#onMessage.bind(this) this.ws.onclose = this.#onClose.bind(this) this.ws.onopen = this.#onOpen.bind(this) } // authorization functions get myID() { return this.authParams.myID } toggleLogIn() { this.myID? Auth.logOut() : Auth.logIn(this.graffitiURL) } async #onClose() { console.error("lost connection to graffiti server, attemping reconnect soon...") this.open = false this.eventTarget.dispatchEvent(new Event("closed")) await new Promise(resolve => setTimeout(resolve, 2000)) this.#connect() } async #request(msg) { if (!this.open) { throw "Can't make request! Not connected to graffiti server" } // Create a random message ID const messageID = crypto.randomUUID() // Create a listener for the reply const dataPromise = new Promise(resolve => { this.eventTarget.addEventListener('$'+messageID, (e) => { resolve(e.data) }) }) // Send the request msg.messageID = messageID this.ws.send(JSON.stringify(msg)) // Await the reply const data = await dataPromise delete data.messageID if ('error' in data) { throw data } else { return data.reply } } #onMessage(event) { const data = JSON.parse(event.data) if ('messageID' in data) { // It's a reply // Forward it back to the sender const messageEvent = new Event('$'+data.messageID) messageEvent.data = data this.eventTarget.dispatchEvent(messageEvent) } else if ('update' in data) { this.#updateCallback(data['update']) } else if ('remove' in data) { this.#removeCallback(data['remove']) } else if (data.type == 'error') { if (data.reason == 'authorization') { Auth.logOut() } throw data } } #updateCallback(object) { const uuid = this.#objectUUID(object) // Add the UUID to the tag map let subscribed = false for (const tag of object._tags) { if (!(tag in this.tagMap)) continue this.tagMap[tag].uuids.add(uuid) subscribed = true } if (!subscribed) return // Define object specific properties if (!('_id' in object)) { // Assign the object UUID Object.defineProperty(object, '_id', { value: uuid }) // Add proxy functions so object modifications // sync with the server object = new Proxy(object, this.#objectHandler(object, true)) } this.objectMap[uuid] = object } #removeCallback(object) { const uuid = this.#objectUUID(object) // Remove the UUID from all relevant tag maps let supported = false for (const tag in this.tagMap) { if (this.tagMap[tag].uuids.has(uuid)) { if (object._tags.includes(tag)) { this.tagMap[tag].uuids.delete(uuid) } else { supported = true } } } // If all tags have been removed, delete entirely if (!supported && uuid in this.objectMap) { delete this.objectMap[uuid] } } async update(object) { object._by = this.myID if (!object._key) object._key = crypto.randomUUID() // Immediately replace the object this.#updateCallback(object) // Send it to the server try { await this.#request({ update: object }) } catch(e) { // Delete the temp object this.#removeCallback(object) throw e } } #objectHandler(object, root) { return { get: (target, prop, receiver)=> this.#getObjectProperty(object, target, prop, receiver), set: (target, prop, val, receiver)=> this.#setObjectProperty(object, root, target, prop, val, receiver), deleteProperty: (target, prop)=> this.#deleteObjectProperty(object, root, target, prop) } } #getObjectProperty(object, target, prop, receiver) { if (typeof target[prop] === 'object' && target[prop] !== null) { return new Proxy(Reflect.get(target, prop, receiver), this.#objectHandler(object, false)) } else { return Reflect.get(target, prop, receiver) } } #setObjectProperty(object, root, target, prop, val, receiver) { // Store the original, perform the update, // sync with server and restore original if error const originalObject = Object.assign({}, object) if (Reflect.set(target, prop, val, receiver)) { this.#removeCallback(originalObject) this.#updateCallback(object) this.#request({ update: object }).catch(e=> { this.#removeCallback(object) this.#updateCallback(originalObject) throw e }) return true } else { return false } } #deleteObjectProperty(object, root, target, prop) { const originalObject = Object.assign({}, object) if (root && ['_key', '_by', '_tags'].includes(prop)) { // This is a deletion of the whole object this.#removeCallback(object) this.#request({ remove: object._key }).catch(e=> { this.#updateCallback(originalObject) throw e }) return true } else { if (Reflect.deleteProperty(target, prop)) { this.#request({ update: object }).catch(e=> { this.#updateCallback(originalObject) throw e }) return true } else { return false } } } async myTags() { return await this.#request({ ls: null }) } async objectByKey(userID, objectKey) { return await this.#request({ get: { _by: userID, _key: objectKey }}) } objects(...tags) { tags = tags.filter(tag=> tag!=null) for (const tag of tags) { if (!(tag in this.tagMap)) { throw `You are not subscribed to '${tag}'` } } // Merge by UUIDs from all tags and // convert to relevant objects const uuids = new Set(tags.map(tag=>[...this.tagMap[tag].uuids]).flat()) const objects = [...uuids].map(uuid=> this.objectMap[uuid]) // Return an array wrapped with graffiti functions return new this.GraffitiArray(...objects) } async subscribe(...tags) { tags = tags.filter(tag=> tag!=null) // Look at what is already subscribed to const subscribingTags = [] for (const tag of tags) { if (tag in this.tagMap) { // Increase the count this.tagMap[tag].count++ } else { // Create a new slot this.tagMap[tag] = { uuids: new Set(), count: 1 } subscribingTags.push(tag) } } // Try subscribing in the background // but don't raise an error since // the subscriptions will happen once connected if (subscribingTags.length) try { await this.#request({ subscribe: subscribingTags }) } catch {} } async unsubscribe(...tags) { tags = tags.filter(tag=> tag!=null) // Decrease the count of each tag, // removing and marking if necessary const unsubscribingTags = [] for (const tag of tags) { this.tagMap[tag].count-- if (!this.tagMap[tag].count) { unsubscribingTags.push(tag) delete this.tagMap[tag] } } // Unsubscribe from all remaining tags if (unsubscribingTags.length) try { await this.#request({ unsubscribe: unsubscribingTags }) } catch {} } async #onOpen() { console.log("connected to the graffiti socket") this.open = true this.eventTarget.dispatchEvent(new Event("open")) // Clear data for (let tag in this.tagMap) { this.tagMap[tag].uuids = new Set() } for (let uuid in this.objectMap) delete this.objectMap[uuid] // Resubscribe const tags = Object.keys(this.tagMap) if (tags.length) await this.#request({ subscribe: tags }) } // Utility function to get a universally unique string // that represents a particular object #objectUUID(object) { if (!object._by || !object._key) { throw { type: 'error', content: 'the object you are trying to identify does not have an owner or key', object } } return object._by + object._key } }