summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorsportdeath2023-02-01 20:01:31 -0500
committerGitHub2023-02-01 20:01:31 -0500
commit67eca331d5cdbd9df5102b4463567f948faefdfd (patch)
tree473b7922b8ed1a549a41c41393ee2b7ac8137ece
parentb4a69044a200e1d06f86f21489a3470a90d835cf (diff)
parenta2cc33b1de416bef592ecf25816f17017e62fe8e (diff)
Merge pull request #1 from graffiti-garden/tags
Tags
-rw-r--r--README.md8
-rw-r--r--graffiti.js317
-rw-r--r--index.html84
-rw-r--r--plugins/vue/index.html34
-rw-r--r--plugins/vue/plugin.js58
-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.html97
8 files changed, 397 insertions, 201 deletions
diff --git a/README.md b/README.md
index cf97d02..8c06ad0 100644
--- a/README.md
+++ b/README.md
@@ -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/auth.js b/src/auth.js
index 8ee803d..8ee803d 100644
--- a/auth.js
+++ b/src/auth.js
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>