Going Serverless with VueJS Divya Sasidharan
A presentation at GOTO Chicago 2019 in April 2019 in Chicago, IL, USA by Divya
Going Serverless with VueJS Divya Sasidharan
HELLO MY NAME IS Hellot Divya Sasidharan
HELLO MY NAME IS Divya Sasidharan Hellot @shortdiv
The Ordinary World The call to adventure Refusal Meeting the Mentor Crossing the threshold Tests, Allies, Enemies Innermost Cave Ordeal Reward The road back Resurrection Return with the Elixir
The Ordinary World The call to adventure Refusal Meeting the Mentor Crossing the threshold Tests, Allies, Enemies Innermost Cave Ordeal Reward The road back Resurrection Return with the Elixir DEPARTURE
The Ordinary World The call to adventure Refusal DEPARTURE Meeting the Mentor Crossing the threshold Tests, Allies, Enemies Innermost Cave Ordeal Reward The road back Resurrection Return with the Elixir INITIATION
The Ordinary World The call to adventure Refusal DEPARTURE Meeting the Mentor Crossing the threshold Tests, Allies, Enemies Innermost Cave INITIATION Ordeal Reward The road back Resurrection Return with the Elixir RETURN
Act 1 Departure
The Ordinary World DATE SCENE ACT 04/30 1 1
The Ordinary World DATE SCENE ACT 04/30 1 1
The Ordinary World DATE SCENE ACT 04/30 1 1
✅ Sammy Serverus
Call to Adventure DATE SCENE ACT 04/30 2 1
Call to Adventure DATE SCENE ACT 04/30 2 1
Call to Adventure DATE SCENE ACT 04/30 2 1
Orange News 1. Some news (github.com) 100000 points by somedude 3 hours ago | hide | 100000 comments 2. Some news (github.com) 100000 points by somedude 3 hours ago | hide | 1 comments 3. Some news (github.com) 100000 points by somedude 3 hours ago | hide | 100000 comments 4. Some news (github.com) 100000 points by somedude 3 hours ago | hide | 100000 comments 5. Some news (github.com) 100000 points by somedude 3 hours ago | hide | 100000 comments 6. 7. 8. 9. 10. 11. 12. 13. Some news (github.com) 100000 points by somedude 3 hours ago | hide | 100000 comments Some news (github.com) 100000 points by somedude 3 hours ago | hide | 100000 comments Some news (github.com) 100000 points by somedude 3 hours ago | hide | 100000 comments Some news (github.com) 100000 points by somedude 3 hours ago | hide | 100000 comments Some news (github.com) 100000 points by somedude 3 hours ago | hide | 100000 comments Some news (github.com) 100000 points by somedude 3 hours ago | hide | 100000 comments Some news (github.com) 100000 points by somedude 3 hours ago | hide | 100000 comments Some news (github.com) 100000 points by somedude 3 hours ago | hide | 100000 comments 14. Some news (github.com) 100000 points by somedude 3 hours ago | hide | 100000 comments 15. Some news (github.com)
chipie.com
Refusal DATE SCENE ACT 04/30 3 1
Refusal DATE SCENE ACT 04/30 3 1
Refusal DATE SCENE ACT 04/30 3 1
We’re gonna need a bigger server rack
😩😩😩😩
Meeting the Mentor DATE SCENE ACT 04/30 4 1
Meeting the Mentor DATE SCENE ACT 04/30 4 1
Meeting the Mentor DATE SCENE ACT 04/30 4 1
Swami Scale-a-lot
Man who run servers without servers, accomplish many things
Crossing the Threshold DATE SCENE ACT 04/30 5 1
Crossing the Threshold DATE SCENE ACT 04/30 5 1
Crossing the Threshold DATE SCENE ACT 04/30 5 1
Act 2 Initiation
Tests, Allies, Enemies DATE SCENE ACT 04/30 1 2
Tests, Allies, Enemies DATE SCENE ACT 04/30 1 2
Tests, Allies, Enemies DATE SCENE ACT 04/30 1 2
|—————-| | THERE | | ARE | | STILL | | SERVERS | | IN | | SERVERLESS| |—————-| (__/) || (•ㅅ•) || / づ
Innermost Cave DATE SCENE ACT 04/30 2 2
Innermost Cave DATE SCENE ACT 04/30 2 2
Innermost Cave DATE SCENE ACT 04/30 2 2
1 const express = require(‘express’), yelp = require(‘yelp-fusion’), 2 port = 8000, 3 app = express(); 4 5 6 app.use((req, res, next) => { res.header(“Access-Control-Allow-Origin”, “*”); 7 res.header(“Access-Control-Allow-Headers”, “Origin, X-Requested 8 -with, Content-Type, Accept”); 9 next(); 10 }) 11 12 13 app.get(‘/yelpit/:term/:location’, (req, res) => { const clientId = CLIENT_ID; 14 const clientSecret = CLIENT_SECRET; 15 16 yelp.accessToken(clientId, clientSecret) 17 .then((response) => { 18 const client = yelp.client(response.jsonBody.access_token); 19 const searchRequest = { 20 term: req.params.term, 21 limit: 50, 22 location: req.params.location 23 } 24 client.search(searchRequest).then(response => { 25 const firstResult = response.jsonBody.businesses; 26 res.send(firstResult) 27
1 const express = require(‘express’), yelp = require(‘yelp-fusion’), 2 port = 8000, 3 app = express(); 4 5 6 app.use((req, res, next) => { res.header(“Access-Control-Allow-Origin”, “*”); 7 res.header(“Access-Control-Allow-Headers”, “Origin, X-Requested 8 -with, Content-Type, Accept”); 9 next(); 10 }) 11 12 13 app.get(‘/yelpit/:term/:location’, (req, res) => { const clientId = CLIENT_ID; 14 const clientSecret = CLIENT_SECRET; 15 16 yelp.accessToken(clientId, clientSecret) 17 .then((response) => { 18 const client = yelp.client(response.jsonBody.access_token); 19 const searchRequest = { 20 term: req.params.term, 21 limit: 50, 22 location: req.params.location 23 } 24 client.search(searchRequest).then(response => { 25 const firstResult = response.jsonBody.businesses; 26 res.send(firstResult) 27
10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
}) app.get(‘/yelpit/:term/:location’, (req, res) => { const clientId = CLIENT_ID; const clientSecret = CLIENT_SECRET;
})
yelp.accessToken(clientId, clientSecret) .then((response) => { const client = yelp.client(response.jsonBody.access_token); const searchRequest = { term: req.params.term, limit: 50, location: req.params.location } client.search(searchRequest).then(response => { const firstResult = response.jsonBody.businesses; res.send(firstResult) }) })
app.listen(port, ‘localhost’, function(err) { if(err) {console.log(err)} console.info(Listening on port ${port}
) });
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 module.exports = { devServer: { proxy: { ‘^/yelpit/*’: { target: ‘http://localhost:8000/yelpit’, secure: false } } } }
The Chilling Adventures of Functions as a Service |—————-| | THERE | | ARE | | STILL | | SERVERS | | IN | | SERVERLESS| |—————-| (_/) || (•ㅅ•) || / づ |—————-| | THERE | | ARE | | STILL | | SERVERS | | IN | | SERVERLESS| |—————-| (_/) || (•ㅅ•) || / づ |—————-| | THERE | | ARE | | STILL | | SERVERS | | IN | | SERVERLESS| |—————-| (_/) || (•ㅅ•) || / づ |—————-| | THERE | | ARE | | STILL | | SERVERS | | IN | | SERVERLESS| |—————-| (_/) || (•ㅅ•) || / づ
exports.handler = function(event, context, callback) { console.log(event) callback(null, { statusCode: 200, body: JSON.stringify({ msg: “Hello, World!” }) }) } functions/hello.js
yarn add netlify-cli
netlify functions:create
[build] command = “yarn build” functions = “functions” publish = “dist” netlify.toml
/.netlify/functions/{function_name}
/.netlify/functions/hello
/.netlify/functions/yelp-it
1 const yelp = require(“yelp-fusion”); 2 3 exports.handler = function(event, context, callback) { const apiKey = “API_KEY”; 4 5 const client = yelp.client(apiKey); 6 7 const { term, location } = event.queryStringParameters; 8 9 const searchRequest = { 10 term: term, 11 location: location 12 }; 13 14 var fetchFromYelp = async function() { 15 try { 16 let res = await client.search(searchRequest); 17 callback(null, { 18 statusCode: 200, 19 body: JSON.stringify({ 20 results: res 21 }) 22 }); 23 } catch (err) { 24 callback(null, { 25 statusCode: 200, 26 body: JSON.stringify({ 27 err: err 28
9 const searchRequest = { 10 term: term, 11 location: location 12 }; 13 14 var fetchFromYelp = async function() { 15 try { 16 let res = await client.search(searchRequest); 17 callback(null, { 18 statusCode: 200, 19 body: JSON.stringify({ 20 results: res 21 }) 22 }); 23 } catch (err) { 24 callback(null, { 25 statusCode: 200, 26 body: JSON.stringify({ 27 err: err 28 }) 29 }); 30 } 31 }; 32 33 fetchFromYelp(); 34 35 }; 36
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 <template> <div class=“home”> <div id=”sidebar” v-if=”dataLoaded”> <Slider :yelpData=“yelpData.features” /> </div> </div> </template>
<script> export default { name: “home”, mounted() { axios .get(“/.netlify/functions/yelp-it”, { params: { location: “chicago,il”, term: “pizza” } }) .then(async res => { res = JSON.parse(res.data.results.body); const results = this.geojsonify(res.businesses); this.dataLoaded = true; this.yelpData = results; },1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 <template> <div class=“home”> <div id=”sidebar” v-if=”dataLoaded”> <Slider :yelpData=“yelpData.features” /> </div> </div> </template>
<script> export default { name: “home”, mounted() { axios .get(“/.netlify/functions/yelp-it”, { params: { location: “chicago,il”, term: “pizza” } }) .then(async res => { res = JSON.parse(res.data.results.body); const results = this.geojsonify(res.businesses); this.dataLoaded = true; this.yelpData = results; },11 <script> 12 export default { 13 name: “home”, 14 mounted() { 15 axios 16 .get(“/.netlify/functions/yelp-it”, { 17 params: { 18 location: “chicago,il”, 19 term: “pizza” 20 } 21 22 }) 23 .then(async res => { 24 res = JSON.parse(res.data.results.body); 25 const results = this.geojsonify(res.businesses); 26 this.dataLoaded = true; 27 this.yelpData = results; 28 }, 29 methods: { 30 geojsonify(response) { 31 let features = []; 32 33 response.map(item => { 34 features.push({ 35 type: “Feature”, 36 geometry: { 37 type: “Point”, 38 coordinates: [item.coordinates.longitude, 39 item.coordinates.latitude] 40 },
reviews.js export const state = { … } export const mutations = { … } export const actions = { … } export const getters = { … } src/state/reviews.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 export const state = { yelpData: null }; export const mutations = { SET_YELP_REVIEWS(state, value) { state.yelpData = value }, } export const actions = { getYelp({ commit }, value) { var geojsonify = function(data) { … } } } axios .get(“/.netlify/functions/yelp-it”, { params: { location: “chicago,il”, term: “pizza” } }) .then(async res => { res = JSON.parse(res.data.results.body); const results = geojsonify(res.businesses); commit(“SET_YELP_REVIEWS”, res.docs) })
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 export const state = { yelpData: null }; export const mutations = { SET_YELP_REVIEWS(state, value) { state.yelpData = value } } export const actions = { getYelp({ commit }, value) { var geojsonify = function(data) { … } } } axios .get(“/.netlify/functions/yelp-it”, { params: { location: “chicago,il”, term: “pizza” } }) .then(async res => { res = JSON.parse(res.data.results.body); const results = geojsonify(res.businesses); commit(“SET_YELP_REVIEWS”, res.docs) })
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 export const state = { yelpData: null }; export const mutations = { SET_YELP_REVIEWS(state, value) { state.yelpData = value }, } export const actions = { getYelp({ commit }, value) { var geojsonify = function(data) { … } } } axios .get(“/.netlify/functions/yelp-it”, { params: { location: “chicago,il”, term: “pizza” } }) .then(async res => { res = JSON.parse(res.data.results.body); const results = geojsonify(res.businesses); commit(“SET_YELP_REVIEWS”, res.docs) })
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 export const state = { yelpData: null }; export const mutations = { SET_YELP_REVIEWS(state, value) { state.yelpData = value }, } export const actions = { getYelp({ commit }, value) { C var geojsonify = function(data) { … } } } axios .get(“/.netlify/functions/yelp-it”, { params: { location: “chicago,il”, term: “pizza” } }) .then(async res => { res = JSON.parse(res.data.results.body); const results = geojsonify(res.businesses); commit(“SET_YELP_REVIEWS”,Cres.docs) })
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 export const state = { yelpData: null }; export const mutations = { SET_YELP_REVIEWS(state, value) { state.yelpData = value C }, } export const actions = { getYelp({ commit }, value) { var geojsonify = function(data) { … } } } axios .get(“/.netlify/functions/yelp-it”, { params: { location: “chicago,il”, term: “pizza” } }) .then(async res => { res = JSON.parse(res.data.results.body); const results = geojsonify(res.businesses); commit(“SET_YELP_REVIEWS”, res.docs) })
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 <template> <div class=“home”> <div id=”sidebar” v-if=”dataLoaded”> <Slider :yelpData=”yelpData.features” /> </div> </div> </template> <script> import { mapState, mapActions } from “vuex”; export default { name: “home”, computed: { …mapState(“yelp”, [“yelpData”]) }, methods: { …mapActions(“reviews”, [“getYelp”]), }, mounted() { this.getYelp() } } </script>
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 <template> <div class=“home”> <div id=”sidebar” v-if=”dataLoaded”> <Slider :yelpData=”yelpData.features” /> </div> </div> </template> <script> import { mapState, mapActions } from “vuex”; export default { name: “home”, computed: { …mapState(“yelp”, [“yelpData”]) }, methods: { …mapActions(“reviews”, [“getYelp”]), }, mounted() { this.getYelp() } } </script>
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 <template> <div class=“home”> <div id=”sidebar” v-if=”dataLoaded”> <Slider :yelpData=”yelpData.features” /> </div> </div> </template> <script> import { mapState, mapActions } from “vuex”; export default { name: “home”, computed: { …mapState(“yelp”, [“yelpData”]) }, methods: { …mapActions(“reviews”, [“getYelp”]), }, mounted() { this.getYelp() } } </script>
reviews.js export const state = { … } export const mutations = { … } export const actions = { … } export const getters = { … } src/state/reviews.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 firebase.initializeApp(config); const db = firebase.firestore(); export const state = { chiPieReviews: {} }; export const mutations = { SET_CHIPIE_REVIEWS(state, value) { var geojsonify = function() {} var t = {} value.forEach(val => { t[val.id] = val.data() }) debugger state.chiPieReviews = t } }; export const actions = { createRating({ commit }, value) { return new Promise((resolve, reject) => { var user = db .collection(“users”) .doc(value.id); user .collection(“pizza-places”) .doc(value.name)
74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 }, setRatings({ dispatch }, value) { return new Promise((resolve, reject) => { var user = db .collection(“users”) .doc(value.id); var docRef = user.collection(“pizza-places”).doc(value.name); docRef .get() .then(doc => { if (doc.exists) { resolve(“oh yeah”); dispatch(“updateRating”, value); } else { dispatch(“createRating”, value); resolve(“empty”); } }) .catch(err => { reject(err); }); }); }, getRatings({ commit }, value) { return new Promise((resolve, reject) => { var user = db .collection(“users”) .doc(value);
Ordeal DATE SCENE ACT 04/30 3 2
Ordeal DATE SCENE ACT 04/30 3 2
Ordeal DATE SCENE ACT 04/30 3 2
Netlify Identity GoTrue
auth.js export const state = { … } export const mutations = { … } export const actions = { … } export const getters = { … } src/state/auth.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 import GoTrue from “gotrue-js”; import axios from “axios”; const auth = new GoTrue({ APIUrl: “https://chipie.netlify.com/.netlify/identity”, audience: “”, setCookie: false }); export const state = { currentUser: getSavedState(“auth.currentUser”), loading: false, token: null, notifications: [] }; export const mutations = { SET_CURRENT_USER(state, value) { state.currentUser = value; saveState(“auth.currentUser”, value); }, TOGGLE_LOAD(state) { state.loading = !state.loading; } }; export const getters = {
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 import GoTrue from “gotrue-js”; C import axios from “axios”; const auth = new GoTrue({ APIUrl: “https://chipie.netlify.com/.netlify/identity”, audience: “”, C setCookie: false }); export const state = { currentUser: getSavedState(“auth.currentUser”), loading: false, token: null, notifications: [] }; export const mutations = { SET_CURRENT_USER(state, value) { state.currentUser = value; saveState(“auth.currentUser”, value); }, TOGGLE_LOAD(state) { state.loading = !state.loading; } }; export const getters = {
31 32 export const actions = { 33 init() { 34 localStorage.removeItem(“auth.currentUser”); 35 }, 36 validate({ commit, state }) { 37 if (!state.currentUser) return Promise.resolve(null); 38 const user = auth.currentUser(); 39 commit(“SET_CURRENT_USER”, user); 40 return user; 41 }, 42 attemptLogin({ commit, dispatch }, credentials) { 43 return new Promise((resolve, reject) => { 44 dispatch(“attemptConfirmation”, credentials).then(() => { 45 auth 46 .login(credentials.email, credentials.password) 47 .then(response => { 48 resolve(response); 49 commit(“SET_CURRENT_USER”, response); 50 }) 51 .catch(error => { 52 reject(error.json); 53 }); 54 }); 55 }); 56 }, 57 58 attemptConfirmation({ commit, dispatch }, credentials) { 59 return new Promise((resolve, reject) => { 60 if (!credentials.token) {
31 32 export const actions = { 33 init() { 34 localStorage.removeItem(“auth.currentUser”); 35 }, 36 validate({ commit, state }) { 37 if (!state.currentUser) return Promise.resolve(null); 38 const user = auth.currentUser(); 39 commit(“SET_CURRENT_USER”, user); 40 return user; 41 }, 42 attemptLogin({ commit, dispatch }, credentials) { 43 return new Promise((resolve, reject) => { 44 dispatch(“attemptConfirmation”, credentials).then(() => { C 45 auth C 46 .login(credentials.email, credentials.password) 47 .then(response => { 48 resolve(response); 49 commit(“SET_CURRENT_USER”, response); C 50 }) 51 .catch(error => { 52 reject(error.json); 53 }); 54 }); 55 }); 56 }, 57 58 attemptConfirmation({ commit, dispatch }, credentials) { 59 return new Promise((resolve, reject) => { 60 if (!credentials.token) {
15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 export const mutations = { SET_CURRENT_USER(state, value)C{ state.currentUser = value; C saveState(“auth.currentUser”, value); }, TOGGLE_LOAD(state) { state.loading = !state.loading; } }; export const getters = { isLoggedIn(state) { return !!state.currentUser; } }; export const actions = { init() { localStorage.removeItem(“auth.currentUser”); }, validate({ commit, state }) { if (!state.currentUser) return Promise.resolve(null); const user = auth.currentUser(); commit(“SET_CURRENT_USER”, user); return user; }, attemptLogin({ commit, dispatch }, credentials) { return new Promise((resolve, reject) => {
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 <template> <div class=”login-screen”> <div class=”account-login”> <form @submit.prevent=”login()”> <label> <span>Email:</span> <input type=“text” placeholder=“name” v-model=“loginCreds.email” /> </label> <label> <span>Password:</span> <input type=”password” placeholder=”password” v-model=“loginCreds.password” /> </label> <button type=”submit” class=“account-button”>Login</button> </form> </div> </div> </template> <script> import { mapActions } from “vuex”;
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 <template> <div class=”login-screen”> <div class=”account-login”> <form @submit.prevent=”login()”> <label> <span>Email:</span> <input type=“text” placeholder=“name” v-model=“loginCreds.email” /> </label> <label> <span>Password:</span> <input type=”password” placeholder=”password” v-model=“loginCreds.password” /> </label> <button type=”submit” class=“account-button”>Login</button> </form> </div> </div> </template> <script> import { mapActions } from “vuex”;
26 27 <script> 28 import { mapActions } from “vuex”; 29 30 export default { name: “LoginAccount”, 31 data() { 32 return { 33 isNewUser: true, 34 loginCreds: { 35 email: null, 36 password: null 37 } 38 } 39 }, 40 methods: { 41 …mapActions(“auth”, [“attemptLogin”]), 42 transferToDashboard() { 43 this.$router.push(this.$route.query.redirect || “/”); 44 }, 45 login() { 46 let token = decodeURIComponent(window.location.search) 47 .substring(1) 48 .split(“confirmation_token=”)[1]; 49 this.attemptLogin({ token, …this.loginCreds }) 50 .then(res => { 51 this.transferToDashboard(); 52 }) 53 .catch(err => { 54 console.log(err);
40 }, 41 methods: { 42 …mapActions(“auth”, [“attemptLogin”]), 43 transferToDashboard() { 44 this.$router.push(this.$route.query.redirect || “/”); 45 }, 46 login() { 47 let token = decodeURIComponent(window.location.search) 48 .substring(1) 49 .split(“confirmation_token=”)[1]; 50 this.attemptLogin({ token, …this.loginCreds }) 51 .then(res => { 52 this.transferToDashboard(); 53 }) 54 .catch(err => { 55 console.log(err); 56 }); 57 }, 58 } 59 } 60 </script> 61 62 63
40 }, 41 methods: { 42 …mapActions(“auth”, [“attemptLogin”]), 43 transferToDashboard() { 44 this.$router.push(this.$route.query.redirect || “/”); 45 }, 46 login() { 47 let token = decodeURIComponent(window.location.search) 48 .substring(1) 49 .split(“confirmation_token=”)[1]; 50 this.attemptLogin({ token, …this.loginCreds }) 51 .then(res => { 52 this.transferToDashboard(); 53 console.log(res); 54 }) 55 .catch(err => { 56 console.log(err); 57 }); 58 }, 59 } 60 } 61 </script> 62 63
Reward DATE SCENE ACT 04/30 4 2
Reward DATE SCENE ACT 04/30 4 2
Reward DATE SCENE ACT 04/30 4 2
Password????
Act 3 Return
The Road Back DATE SCENE ACT 04/30 1 3
The Road Back DATE SCENE ACT 04/30 1 3
The Road Back DATE SCENE ACT 04/30 1 3
There is no cloud, just someone else’s server
Resurrection/ Return with Elixir DATE SCENE ACT 04/30 2 3
Resurrection/ Return with Elixir DATE SCENE ACT 04/30 2 3
Resurrection/ Return with Elixir DATE SCENE ACT 04/30 2 3
👏 👏 👏 👏 👏 👏 ⚡ ⚡ ⚡ 👏 👏 👏 👏
chipie.netlify.live
% github.com/shortdiv/chipie Divya Sasidharan @shortdiv