diff options
-rw-r--r-- | README.md | 8 | ||||
-rw-r--r-- | graffiti.js | 317 | ||||
-rw-r--r-- | index.html | 84 | ||||
-rw-r--r-- | plugins/vue/index.html | 34 | ||||
-rw-r--r-- | plugins/vue/plugin.js | 58 | ||||
-rw-r--r-- | src/auth.js (renamed from auth.js) | 0 | ||||
-rw-r--r-- | src/logoot.js (renamed from logoot.js) | 0 | ||||
-rw-r--r-- | test.html | 97 |
8 files changed, 397 insertions, 201 deletions
@@ -1,6 +1,4 @@ -# Graffiti for Vanilla Javascript +# Graffiti Javascript Library -This is the base Javascript library that interfaces with the [Graffiti server](https://github.com/graffiti-garden/server). -We recommend not using this vanilla library itself but instead using framework plugins that are built on top of it like the [Graffiti plugin for Vue.JS](https://github.com/graffiti-garden/graffiti-x-vue). - -If you create a [local Graffiti instance](https://github.com/graffiti-garden/server#local-usage) and a local webserver in this directory (*e.g.* `python3 -m http.server`) and navigate to `test.html` (*e.g.* [http://localhost:8000/test.html](http://localhost:8000/test.html)) you should be able to log in and test each of the Graffiti primitives: `subscribe`, `unsubscribe`, `update`, `remove`. These primitives will be robust to spamming and network interruptions. +This is the base Javascript library that interfaces with the [Graffiti server](https://github.com/graffiti-garden/graffiti-server). +It also includes a plugin that extends it to operate with the [Vue.js framework](https://vuejs.org/). diff --git a/graffiti.js b/graffiti.js index fe7c2d8..9b92b4f 100644 --- a/graffiti.js +++ b/graffiti.js @@ -1,16 +1,23 @@ -import Auth from './auth.js' +import Auth from './src/auth.js' +//import Collection from './src/collection.js' export default class { - constructor(graffitiURL="https://graffiti.garden") { + // There needs to be a new object map for each tag + constructor( + graffitiURL="https://graffiti.garden", + objectMapConstructor=()=>({})) { + this.graffitiURL = graffitiURL + this.objectMapConstructor = objectMapConstructor this.open = false - this.subscriptionData = {} this.eventTarget = new EventTarget() + this.tagMap = {} + + this.#initialize() } - // CALL THIS BEFORE DOING ANYTHING ELSE - async initialize() { + async #initialize() { // Perform authorization this.authParams = await Auth.connect(this.graffitiURL) @@ -26,15 +33,23 @@ export default class { this.wsURL.searchParams.set("token", this.authParams.token) } - // And commence connection - this.connect() + // Commence connection + this.#connect() + } + + async opened() { + if (!this.open) { + await new Promise(resolve => { + this.eventTarget.addEventListener("graffitiOpen", () => resolve() ) + }) + } } - connect() { + #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) + this.ws.onmessage = this.#onMessage.bind(this) + this.ws.onclose = this.#onClose.bind(this) + this.ws.onopen = this.#onOpen.bind(this) } // authorization functions @@ -43,14 +58,18 @@ export default class { this.myID? Auth.logOut() : Auth.logIn(this.graffitiURL) } - async onClose() { + async #onClose() { this.open = false console.error("lost connection to graffiti server, attemping reconnect soon...") await new Promise(resolve => setTimeout(resolve, 2000)) - this.connect() + this.#connect() } - async request(msg) { + async #request(msg) { + if (!this.open) { + throw { 'error': 'Not connected!' } + } + // Create a random message ID const messageID = crypto.randomUUID() @@ -61,13 +80,6 @@ export default class { }) }) - // Wait for the socket to open - if (!this.open) { - await new Promise(resolve => { - this.eventTarget.addEventListener("graffitiOpen", () => resolve() ) - }) - } - // Send the request msg.messageID = messageID this.ws.send(JSON.stringify(msg)) @@ -79,11 +91,11 @@ export default class { if (data.type == 'error') { throw data } else { - return data + return data['reply'] } } - onMessage(event) { + #onMessage(event) { const data = JSON.parse(event.data) if ('messageID' in data) { @@ -93,30 +105,12 @@ export default class { messageEvent.data = data this.eventTarget.dispatchEvent(messageEvent) - } else if (['updates', 'removes'].includes(data.type)) { - // Subscription data - if (data.queryID in this.subscriptionData) { - const sd = this.subscriptionData[data.queryID] - - // For each data point, either add or remove it - for (const r of data.results) { - if (data.type == 'updates') { - sd.updateCallback(r) - } else { - sd.removeCallback(r) - } - } + } else if ('update' in data) { + this.#updateCallback(data['update']) + + } else if ('remove' in data) { + this.#removeCallback(data['remove']) - // And update this query's notion of "now" - if (data.complete) { - if (data.historical) { - sd.historyComplete = true - } - if (sd.historyComplete) { - sd.since = data.now - } - } - } } else if (data.type == 'error') { if (data.reason == 'authorization') { Auth.logOut() @@ -125,87 +119,212 @@ export default class { } } - async update(object, query) { - const data = await this.request({ object, query }) - return data.objectID + #updateCallback(object) { + const uuid = this.#objectUUID(object) + + let originalObject = null + for (const tag in this.tagMap) { + const objectMap = this.tagMap[tag].objectMap + + if (uuid in objectMap) { + // Copy the original object if + // one exists, in case of failure + originalObject = Object.assign({},objectMap[uuid]) + + // Replace the object by copying + // so references to it don't break + this.#recursiveCopy(objectMap[uuid], object) + } else { + + // Add properties to the object + // so it can be updated and removed + // without the collection + Object.defineProperty(object, '_update', { value: ()=>this.update(object) }) + Object.defineProperty(object, '_remove', { value: ()=>this.remove(object) }) + objectMap[uuid] = object + } + } + + // Return the original in case of failure + return originalObject } - async remove(objectID) { - await this.request({ objectID }) + #removeCallback(object) { + const uuid = this.#objectUUID(object) + + let originalObject = null + for (const tag in this.tagMap) { + const objectMap = this.tagMap[tag].objectMap + + if (!(uuid in objectMap)) return + originalObject = Object.assign({},objectMap[uuid]) + delete objectMap[uuid] + } } - async subscribe( - query, - updateCallback, - removeCallback, - flags={}, - since=null, - queryID=null) { + async update(object) { + if (!this.myID) { + throw 'you can\'t update objects without logging in!' + } - // Create a random query ID - if (!queryID) queryID = crypto.randomUUID() + // Add by/to fields + object._by = this.myID + if ('_to' in object && !Array.isArray(object._to)) { + throw new Error("_to must be an array") + } - // Send the request - await this.request({ queryID, query, since, ...flags }) + // Pre-generate the object's ID if it does not already exist + if (!object._key) object._key = crypto.randomUUID() + + // Immediately replace the object + const originalObject = this.#updateCallback(object) + + // Send it to the server + try { + await this.#request({ update: object }) + } catch(e) { + if (originalObject) { + // Restore the original object + this.#updateCallback(originalObject) + } else { + // Delete the temp object + this.#removeCallback(object) + } + throw e + } + } + + async remove(object) { + if (!this.myID) { + throw 'you can\'t remove objects without logging in!' + } - // Store the subscription in case of disconnections - this.subscriptionData[queryID] = { - query, since, flags, updateCallback, removeCallback, - historyComplete: false + if (this.myID != object._by) { + throw 'you can\'t remove an object that isn\'t yours!' } - return queryID + // Immediately remove the object + // but store it in case there is an error + const originalObject = this.#removeCallback(object) + + try { + return await this.#request({ remove: object._key }) + } catch(e) { + // Delete failed, restore the object + if (originalObject) this.#updateCallback(originalObject) + throw e + } } - async unsubscribe(queryID) { - // Remove allocated space - delete this.subscriptionData[queryID] + async myTags() { + return await this.#request({ ls: null }) + } - // And unsubscribe - const data = await this.request({ queryID }) + async objectByKey(userID, objectKey) { + return await this.#request({ get: { + _by: userID, + _key: objectKey + }}) } - async onOpen() { + objectsByTags(...tags) { + for (const tag of tags) { + if (!(tag in this.tagMap)) { + throw `You are not subscribed to '${tag}'` + } + } + + // Merge by UUID to combine all the maps + const combinedMaps = Object.assign({}, + ...tags.map(tag=> this.tagMap[tag].objectMap)) + + // Return just the array + return Object.values(combinedMaps) + } + + async subscribe(...tags) { + // 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] = { + objectMap: this.objectMapConstructor(), + count: 1 + } + subscribingTags.push(tag) + } + } + + // Begin subscribing in the background + if (subscribingTags.length) + await this.#request({ subscribe: subscribingTags }) + } + + async unsubscribe(...tags) { + // 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) + await this.#request({ unsubscribe: unsubscribingTags }) + } + + async #onOpen() { console.log("connected to the graffiti socket") this.open = true this.eventTarget.dispatchEvent(new Event("graffitiOpen")) - // Resubscribe to hanging queries - for (const queryID in this.subscriptionData) { - const sd = this.subscriptionData[queryID] - await this.subscribe( - sd.query, - sd.updateCallback, - sd.removeCallback, - sd.flags, - sd.since, - queryID) - } - } - - // Adds required fields to an object. - // You should probably call this before 'update' - completeObject(object) { - // Add by/to fields - object._by = this.myID - if ('_to' in object && !Array.isArray(object._to)) { - throw new Error("_to must be an array") + + // Clear data + for (let tag in this.tagMap) { + const objectMap = this.tagMap[tag].objectMap + for (let uuid in objectMap) delete objectMap[uuid] } - // Pre-generate the object's ID if it does not already exist - if (!object._id) object._id = crypto.randomUUID() + // 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._id || !object._by) { + #objectUUID(object) { + if (!object._by || !object._key) { throw { type: 'error', - content: 'the object you are trying to identify does not have an ID or owner', + content: 'the object you are trying to identify does not have an owner or key', object } } - return object._id + object._by + return object._by + object._key } + #recursiveCopy(target, source) { + for (const field in target) { + if (!(field in source)) { + delete target[field] + } + } + + for (const field in source) { + if (field in target && typeof target[field] == 'object' && typeof source[field] == 'object') { + this.#recursiveCopy(target[field], source[field]) + } else { + target[field] = source[field] + } + } + } } + diff --git a/index.html b/index.html new file mode 100644 index 0000000..e3c2dc0 --- /dev/null +++ b/index.html @@ -0,0 +1,84 @@ +<!DOCTYPE html> +<html> +<body> + <h1> Graffiti x JS </h1> + + <p id="ID"></p> + + <h2 id="status"></h2> + + <button onclick="Subscribe()"> + Subscribe + </button> + + <button onclick="Unsubscribe()"> + Unsubscribe + </button> + + <button onclick="Update()"> + Update + </button> + + <button onclick="Remove()"> + Remove + </button> + + <button onclick="LogOut()"> + Log Out + </button> + +<script type="module"> + import Graffiti from "./graffiti.js" + + // Connect to a local Graffiti instance + // (see the server README for how to n locally) + window.graffiti = new Graffiti("http://localhost:5001") + await graffiti.opened() + + // Log in automatically if not already + // and supply a log out button + if (!graffiti.myID) graffiti.toggleLogIn() + window.LogOut = ()=> graffiti.toggleLogIn() + document.getElementById('ID').innerHTML = `Your Graffiti ID is: ${graffiti.myID}` + + const myTag = "asdf" + + // Make a display + async function displayObjects() { + let display = 'not subscribed' + try { + const objects = graffiti.objectsByTags(myTag) + display = `objects: ${JSON.stringify(objects)}` + } catch {} + + document.getElementById('status').innerHTML = display + } + + // Create an object containing a special string + window.Subscribe = async function() { + await graffiti.subscribe(myTag) + await new Promise(r => setTimeout(r, 1000)); + displayObjects() + } + + window.Unsubscribe = async function() { + await graffiti.unsubscribe(myTag) + await new Promise(r => setTimeout(r, 1000)); + displayObjects() + } + + // Create an object containing a special string + window.Update = async function() { + await graffiti.update({_tags: [myTag]}) + displayObjects() + } + + // Remove an existing object + window.Remove = async function() { + await graffiti.objectsByTags(myTag)[0]._remove() + displayObjects() + } + +</script> +</body> +</html> diff --git a/plugins/vue/index.html b/plugins/vue/index.html new file mode 100644 index 0000000..ceea8c7 --- /dev/null +++ b/plugins/vue/index.html @@ -0,0 +1,34 @@ +<!DOCTYPE html> +<html> +<head> + <script async src="https://ga.jspm.io/npm:es-module-shims@1.6.2/dist/es-module-shims.js"></script> + <script type="importmap">{ "imports": { + "vue": "https://cdnjs.cloudflare.com/ajax/libs/vue/3.2.45/vue.esm-browser.min.js" + }}</script> + <script type="module"> + import { createApp } from 'vue' + import GraffitiVue from './plugin.js' + + createApp() + .use(GraffitiVue, {url: "http://localhost:5001"}) + .mount('#app') + </script> +</head> +<body id="app"> + {{ $graffitiID }} + + <button @click="$graffitiToggleLogIn"> + Log In + </button> + + <graffiti-objects :tags="['asdf']" v-slot="{objects}"> + <li v-for="object in objects"> + {{object._tags}} + <button @click="object._remove()"> + ❌ + </button> + </li> + </graffiti-objects> + +</body> +</html> diff --git a/plugins/vue/plugin.js b/plugins/vue/plugin.js new file mode 100644 index 0000000..2c61f40 --- /dev/null +++ b/plugins/vue/plugin.js @@ -0,0 +1,58 @@ +import { ref, reactive } from 'vue' +import Graffiti from '../../graffiti.js' + +export default { + install(app, options) { + + const graffitiURL = options && 'url' in options? + options.url : 'https://graffiti.garden' + + // Initialize graffiti + const graffiti = new Graffiti(graffitiURL, ()=>reactive({})) + + // These ID need to change after opening + app.config.globalProperties.$graffitiID = ref(null) + graffiti.opened().then(()=> { + app.config.globalProperties.$graffitiID.value = graffiti.myID + }) + + // Add logging in and out + app.config.globalProperties.$graffitiToggleLogIn = + graffiti.toggleLogIn.bind(graffiti) + + // A composable for subscribing and + // unsubscribing to tags that returns + // a reactive array of the results + app.component('GraffitiObjects', { + + props: ['tags'], + + watch: { + tags: { + async handler(newTags, oldTags=[]) { + // Subscribe to the new tags + await graffiti.subscribe(...newTags) + // Unsubscribe to the existing tags + await graffiti.unsubscribe(...oldTags) + }, + immediate: true, + deep: true + } + }, + + // Handle unmounting too + unmount() { + graffiti.unsubscribe(this.tags) + }, + + computed: { + objects() { + return graffiti.objectsByTags(...this.tags) + } + }, + + template: '<slot :objects="objects"></slot>' + }) + + } +} diff --git a/logoot.js b/src/logoot.js index c8c7c02..c8c7c02 100644 --- a/logoot.js +++ b/src/logoot.js diff --git a/test.html b/test.html deleted file mode 100644 index 3a0d853..0000000 --- a/test.html +++ /dev/null @@ -1,97 +0,0 @@ -<!DOCTYPE html> -<html> -<body> - <h1> Graffiti x JS </h1> - - <p id="ID"></p> - - <h2 id="status">Unsubscribed</h2> - - <button onclick="Subscribe()"> - Subscribe - </button> - - <button onclick="Unsubscribe()"> - Unsubscribe - </button> - - <button onclick="Update()"> - Update - </button> - - <button onclick="Remove()"> - Remove - </button> - - <button onclick="LogOut()"> - Log Out - </button> - -<script type="module"> - import Graffiti from "./graffiti.js" - - // Connect to a local Graffiti instance - // (see the server README for how to run locally) - const graffiti = new Graffiti("http://localhost:5001") - await graffiti.initialize() - - // Log in automatically if not already - // and supply a log out button - if (!graffiti.myID) graffiti.toggleLogIn() - window.LogOut = ()=> graffiti.toggleLogIn() - document.getElementById('ID').innerHTML = `Your Graffiti ID is: ${graffiti.myID}` - - // Create a display counter - let count = 0 - function displayCount() { - document.getElementById('status').innerHTML = `Subscribed: ${count} Objects` - } - - // From here to below we're going to - // define functions that can be activated - // with button presses, corresponding to - // each of the four Graffiti primitives. - - // Create an object containing a special string - const special = crypto.randomUUID() - const usedIDs = [] - window.Update = async function() { - usedIDs.unshift(crypto.randomUUID()) - await graffiti.update({ - _id: usedIDs[0], - _by: graffiti.myID, - special - }, {}) - } - - // Remove an existing object - window.Remove = async function() { - if ( usedIDs.length ) { - await graffiti.remove( usedIDs.pop() ) - } - } - - // Subscribe to objects containing the special string - let queryID = null - window.Subscribe = async function() { - if (queryID) return - count = 0 - queryID = await graffiti.subscribe( - { special }, - (obj)=> { count++; displayCount() }, - (obj)=> { count--; displayCount() } - ) - displayCount() - } - - // Unsubscribe to the existing query - window.Unsubscribe = async function() { - if (queryID) { - await graffiti.unsubscribe(queryID) - queryID = null - document.getElementById('status').innerHTML = "Unsubscribed" - } - } -</script> -</body> -</html> |