From 1b81b263cf393942a2ab425ab351a5acbbc67ca5 Mon Sep 17 00:00:00 2001 From: thisisjofrank Date: Fri, 19 Jun 2020 10:58:58 +0100 Subject: [PATCH 1/3] p2p demo and readme --- ably-visual-p2p | 1 + 1 file changed, 1 insertion(+) create mode 160000 ably-visual-p2p diff --git a/ably-visual-p2p b/ably-visual-p2p new file mode 160000 index 00000000..f15b48d3 --- /dev/null +++ b/ably-visual-p2p @@ -0,0 +1 @@ +Subproject commit f15b48d35a215e2759d838ca10b9568fb390a5a4 From 6a7de6567029b888e2c8a98b79e6d643719f5c52 Mon Sep 17 00:00:00 2001 From: thisisjofrank Date: Fri, 19 Jun 2020 11:14:42 +0100 Subject: [PATCH 2/3] tutorial files and readme updated --- README.md | 517 +++++++++++++++++- ably-visual-p2p | 1 - p2p-vue-demo/.gitignore | 2 + p2p-vue-demo/api/.gitignore | 43 ++ p2p-vue-demo/api/.vscode/extensions.json | 5 + p2p-vue-demo/api/.vscode/launch.json | 12 + p2p-vue-demo/api/.vscode/settings.json | 7 + p2p-vue-demo/api/.vscode/tasks.json | 23 + .../api/createTokenRequest/function.json | 6 + p2p-vue-demo/api/createTokenRequest/index.js | 10 + p2p-vue-demo/api/host.json | 7 + p2p-vue-demo/api/package-lock.json | 387 +++++++++++++ p2p-vue-demo/api/package.json | 14 + p2p-vue-demo/index.html | 46 ++ p2p-vue-demo/index.js | 54 ++ p2p-vue-demo/p2p.js | 38 ++ p2p-vue-demo/p2p.lib.client.js | 30 + p2p-vue-demo/p2p.lib.server.js | 26 + p2p-vue-demo/style.css | 98 ++++ 19 files changed, 1319 insertions(+), 7 deletions(-) delete mode 160000 ably-visual-p2p create mode 100644 p2p-vue-demo/.gitignore create mode 100644 p2p-vue-demo/api/.gitignore create mode 100644 p2p-vue-demo/api/.vscode/extensions.json create mode 100644 p2p-vue-demo/api/.vscode/launch.json create mode 100644 p2p-vue-demo/api/.vscode/settings.json create mode 100644 p2p-vue-demo/api/.vscode/tasks.json create mode 100644 p2p-vue-demo/api/createTokenRequest/function.json create mode 100644 p2p-vue-demo/api/createTokenRequest/index.js create mode 100644 p2p-vue-demo/api/host.json create mode 100644 p2p-vue-demo/api/package-lock.json create mode 100644 p2p-vue-demo/api/package.json create mode 100644 p2p-vue-demo/index.html create mode 100644 p2p-vue-demo/index.js create mode 100644 p2p-vue-demo/p2p.js create mode 100644 p2p-vue-demo/p2p.lib.client.js create mode 100644 p2p-vue-demo/p2p.lib.server.js create mode 100644 p2p-vue-demo/style.css diff --git a/README.md b/README.md index 20da62cb..b2768da1 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,516 @@ -[![Ably](https://s3.amazonaws.com/files.ably.io/logo-with-type.png)](https://www.ably.io) +# Peer to Peer Static Web App built with Vue.js ---- +## Explanation: -# Tutorials repository +[Ably Channels](https://www.ably.io/channels) are multicast (many publishers can publish to many subscribers) and we can use them to build peer-to-peer apps. -This repository contains the working code for many of the [Ably tutorials](https://www.ably.io/tutorials). +"Peer to peer" (p2p) is a term from distributed computing that describes any system where many participants, often referred to as "nodes", can participate in some form of collective communication. The idea of peer to peer was popularised in early filesharing networks, where users could connect to each other to exchange files, and search operated across all of the connected users, there is a long history of apps built using p2p. In this demo, we're going to build a simple app that will allow one of the peers to elect themselves the **"leader"**, and co-ordinate communication between each instance of our app. -See [https://www.ably.io/tutorials](https://www.ably.io/tutorials) for a complete list of Ably tutorials. The source code for each tutorial exists as a branch in this repo, see [the complete list of tutorial branches in this repository](https://github.com/ably/tutorials/branches/all). +## What's a leader? -To find out more Ably and our realtime data delivery platform, visit [https://www.ably.io](https://www.ably.io) +Horizontally scaled systems, like P2P, often incorporate some form of "leader election" - where all of the nodes in the system attempt to become the "leader". This "leader" will co-ordinate the work distributed amongst the peers. For the sake of this demo, we will not implement leader election, instead, one of the users is going to click a button that makes them the leader. We'll refer to them as the `host` throughout this tutorial, although really they are just another peer. + +If you want to learn more about robust leader election patterns, [Microsoft have an excellent writeup](https://docs.microsoft.com/en-us/azure/architecture/patterns/leader-election) + +## What are we going to build? + +To demonstrate these concepts, we're going to build a simple app that keeps track of users as they join an Ably channel. +The `channel name` will be up to the `host` to define - by typing it into the UI. There's nothing special about the `host`, they're just the first one to click the `host` button. + +Once a `host` has joined the `channel`, their browser will be listening for messages from `clients` joining the same `channel`, and keeping track each time somebody joins. +They'll also keep all the `clients` in sync, by sending the complete list of `clients` on every connection. + +By the end of this demo, everyone that joins the `channel`, should see the name of every other participant in the browser UI - powered by multicast messages. + +## A brief introduction to Vue.js before we start + +> Vue (pronounced /vjuː/, like view) is a progressive framework for building user interfaces. It is designed from the ground up to be incrementally adoptable, and can easily scale between a library and a framework depending on different use cases. It consists of an approachable core library that focuses on the view layer only, and an ecosystem of supporting libraries that helps you tackle complexity in large Single-Page Applications. +> -- [vue.js Github repo](https://github.com/vuejs/vue) + +[Vue.js](https://vuejs.org/) is a single page app framework, and we will use it to build the UI of our app. Our Vue code lives in [index.js](index.js) - and handles all of the user interactions. We're using Vue because it doesn't require a toolchain and it provides simple binding syntax for updating the UI when data changes. + +Our Vue app looks a little like this abridged sample: + +```js +var app = new Vue({ + el: '#app', + data: { + greeting: "hello world", + displayGreeting: true, + } + methods: { + doSomething: async function(evt) { ... } + } +}); +``` + +It finds an element with the id of `app` in our markup, and treats any elements within it as markup that can contain `Vue Directives` - extra attributes to bind data and manipulate our HTML based on the applications state. + +Typically, the Vue app makes data available (such as `greeting` in the above code snippet), and when that data changes, it'll re-render the parts of the UI that are bound to it. +Vue.js exposes a `methods` property, which we use to implement things like click handlers and callbacks from our UI, like the `doSomething` function above. + +This snippet of HTML should help illustrate how Vue if-statements and directives work + +```html +
+
+ {{ greeting }} +
+
+``` + +Here you'll see Vue's `v-if` directive, which means that this `div` and its contents will only display if the `displayGreeting` `data` property is true. +You can also see Vue's binding syntax, where we use `{{ greeting }}` to bind data to the UI. + +**Vue is simple to get started with, especially with a small app like this, with easy to understand data-binding syntax. +Vue works well for our example here, because it doesn't require much additional code.** + +## Ably channels and API keys + +In order to run this app, you will need an Ably API key. If you are not already signed up, you can [sign up now for a free Ably account](https://www.ably.io/signup). Once you have an Ably account: + +* Log into your app dashboard +* Under **“Your apps”**, click on **“Manage app”** for any app you wish to use for this tutorial, or create a new one with the “Create New App” button +* Click on the **“API Keys”** tab +* Copy the secret **“API Key”** value from your Root key, we will use this later when we build our app. + +This app is going to use [Ably Channels](https://www.ably.io/channels) and [Token Authentication](https://www.ably.io/documentation/rest/authentication/#token-authentication). + +## Making sure we send consistent messages by wrapping our Ably client + +Next we're going to make a class called `PubSubClient` which will do a few things for us: + +1. Allow us to call connect twice to the same channel to make our calling code simpler +2. Adds metadata to messages sent outwards, so we don't have to remember to do it in our calling code. + +```js +class PubSubClient { + constructor(onMessageReceivedCallback) { + this.connected = false; + this.onMessageReceivedCallback = onMessageReceivedCallback; + } +``` + +We define a constructor for the class and set up some values. These values are a property called `connected`, set to false, and `onMessageReceivedCallback` - a function passed to the constructor that we will use later when Ably messages arrive. + +We're then going to define a `connect` function + +```js + async connect(identity, uniqueId) { + if(this.connected) return; + + this.metadata = { uniqueId: uniqueId, ...identity }; + + const ably = new Ably.Realtime.Promise({ authUrl: '/api/createTokenRequest' }); + this.channel = await ably.channels.get(`p2p-sample-${uniqueId}`); + + this.channel.subscribe((message) => { + this.onMessageReceivedCallback(message.data, this.metadata); + }); + + this.connected = true; + } +``` + +While we're making a connection, we're subscribing to an Ably Channel and adding a callback function that passes on the `data` property from any received message to the function that we pass to the constructor. This data is the JSON that our peers sent, along with some identifying `metadata` (like the user's `friendlyName` in this example). + +We're also going to define a `sendMessage` function, which adds some functionality on top of the default Ably `publish()`. + +```javascript + sendMessage(message, targetClientId) { + if (!this.connected) { + throw "Client is not connected"; + } + + message.metadata = this.metadata; + message.forClientId = targetClientId ? targetClientId : null; + this.channel.publish({ name: "myMessageName", data: message}); + } +} +``` + +Whenever `sendMessage` is called, we're including the data stored in `this.metadata` that was set during construction (our clients friendlyName). This ensures that whenever a message is sent **from** a peer, it's always going to include the name of the person that sent it. + +We're also making sure that if the message is **for** a specific peer - set using `targetClientId` - then this property is added to the message before we publish it on the Ably Channel. + +We will pass this wrapper to the instances of our `P2PClient` and `P2PServer` classes, to make sure they publish messages in a predictable way. + +## Creating our Vue app + +The application is going to be composed of a `Vue` UI, and two main classes, `P2PClient` and `P2PServer`. + +The `peer` who elects themselves as host will be the only one to have an instance of `P2PServer` and all of their `peers` will be `P2PClients`. + +When we define our Vue app, we're going to create two `null` properties inside of `Vue.data`: + +```js +var app = new Vue({ + el: '#app', + data: { + p2pClient: null, + p2pServer: null, + ... +``` + +When a Vue instance is created, it adds all the properties found in its data object to Vue’s **reactivity system**. When the values of those properties change, the view will “react”, updating to match the new values. + +By defining both our `p2pClient` and `p2pServer` properties inside of Vue's data object, we make them **reactive**, so any changes observed to the properties, will cause the UI to re-render. + +Our Vue app only contains two functions, one to start `hosting` and the other to `join`. In reality, they're both doing the same thing (connecting to an Ably channel by name), but depending on which button is clicked in our UI, that `peer` will either behave as a host or a client. + +```js + host: async function(evt) { + evt.preventDefault(); + + const pubSubClient = new PubSubClient((message, metadata) => { + handleMessagefromAbly(message, metadata, this.p2pClient, this.p2pServer); + }); + + const identity = new Identity(this.friendlyName); + this.p2pServer = new P2PServer(identity, this.uniqueId, pubSubClient); + this.p2pClient = new P2PClient(identity, this.uniqueId, pubSubClient); + + await this.p2pServer.connect(); + await this.p2pClient.connect(); + }, +``` + +The `host` function, creates an instance of the `PubSubClient`, and provides it with a callback to `handleMessageFromAbly` then: + +* Creates a new `Identity` instance, using the `friendlyName` bound to our UI +* Creates a new `P2PServer` +* Creates a new `P2PClient` +* Connects to each of them (which in turn, calls `connect` on our `PubSubClient` instance) + +Joining is very similar + +```js + + join: async function(evt) { + evt.preventDefault(); + + const pubSubClient = new PubSubClient((message, metadata) => { + handleMessagefromAbly(message, metadata, this.p2pClient, this.p2pServer); + }); + + const identity = new Identity(this.friendlyName); + this.p2pClient = new P2PClient(identity, this.uniqueId, pubSubClient); + + await this.p2pClient.connect(); + } +``` + +Here, we're doing *exactly the same* as the host, except we're only creating a `P2PClient`. + +## HandleMessageFromAbly + +`handleMessageFromAbly` is the callback function that the `PubSubClient` will trigger whenever a message appears on the `Ably Channel`. + +```js +function shouldHandleMessage(message, metadata) { + return message.forClientId == null || !message.forClientId || (message.forClientId && message.forClientId === metadata.clientId); +} + +function handleMessagefromAbly(message, metadata, p2pClient, p2pServer) { + if (shouldHandleMessage(message, metadata)) { + p2pServer?.onReceiveMessage(message); + p2pClient?.onReceiveMessage(message); + } +} +``` + +It is responsible for calling any p2pServer `onReceiveMessage` if the client is a `host`, calling `onReceiveMessage` on our client. It also makes sure that if a message has been flagged as for a specific client, by including the property `forClientId`, that it doesn't get processed by other peers. + +This is deliberately **not secure**. All the messages sent on our Ably channel are multicast, and received by all peers, so it should not be considered tamper proof - but it does prevent us having to filter inside the client and server instances. + +## P2PClient + +The `P2PClient` class does most of the work in the app. +It is responsible for sending a `connected` message over the `PubSubClient` when `connect` is called, and most importantly of keeping track of a copy of the `serverState` whenever a message is received. + +```js +class P2PClient { + constructor(identity, uniqueId, ably) { + this.identity = identity; + this.uniqueId = uniqueId; + this.ably = ably; + + this.serverState = null; + this.state = { status: "disconnected" }; + } +``` + +The constructor assigns parameters to instance variables, and initilises a `null` `this.serverState` property, along with its own client state in `this.state`. + +We then go on to define the `connect` function + +```js + async connect() { + await this.ably.connect(this.identity, this.uniqueId); + + this.ably.sendMessage({ kind: "connected" }); + this.state.status = "awaiting-acknowledgement"; + } +``` + +This uses the provided `PubSubClient` (here stored as the property `this.ably`) to send a `connected` message. The `PubSubClient` is doing the rest of the work - adding in the `identity` of the sender during the `sendMessage` call. + +It also sets `this.state.status` to `awaiting-acknowledgement` - the default state for all of our client instances until the `P2PServer` has sent them a `connection-acknowledged` message. + +`OnReceiveMessage` does a little more work: + +```js + onReceiveMessage(message) { + if (message.serverState) { + this.serverState = message.serverState; + } + + switch(message.kind) { + case "connection-acknowledged": + this.state.status = "acknowledged"; + break; + default: () => { }; + } + } + } +``` + +There are two things to pay close attention to here - firstly that we update the property `this.serverState` whenever an incoming message has a property called `serverState` on it - our clients use this to keep a local copy of whatever the `host` says its state is, and we'll use this to bind to our UI later. + +Then there's our switch on `message.kind` - the type of message we're receiving. + +In this case, we only actually care about the `connection-acknowledged` message, updating the `this.state.status` property to `acknowledged` once we receive one. + +## P2PServer + +Our `P2PServer` class hardly differs from the client. + +It contains a constructor that creates an empty `this.state` object + +```js +class P2PServer { + constructor(identity, uniqueId, ably) { + this.identity = identity; + this.uniqueId = uniqueId; + this.ably = ably; + + this.state = { players: [] }; + } +``` + +A connect function that connects to Ably via the `PubSubClient` + +```js + async connect() { + await this.ably.connect(this.identity, this.uniqueId); + } +``` + +And an `onReceiveMessage` callback function that responds to the `connected` message. + +```js + onReceiveMessage(message) { + switch(message.kind) { + case "connected": this.onClientConnected(message); break; + default: () => { }; + } + } +``` + +All of the work is done in `onClientConnected` + +```js + onClientConnected(message) { + this.state.players.push(message.metadata); + this.ably.sendMessage({ kind: "connection-acknowledged", serverState: this.state }, message.metadata.clientId); + this.ably.sendMessage({ kind: "peer-status", serverState: this.state }); + } + } +``` + +When a client connects, we keep track of their `metadata` (the `friendlyName`), and then send two messages. +The first, is a `connection-acknowledged` message, that is sent **specifically** to the `clientId` that just connected. + +Then, it sends a `peer-status` message, with a copy of the latest `this.state` object, that will in turn trigger all the clients to update their internal state. + +## Updating the UI when the internal state of the client updates + +We have to write some markup allow the UI to update when the state changes. + +We'll start off with a HTML document and some script tags + +```html + + + + + P2P Example + + + + + + + + + + + +``` + +We're including: + +* The latest `Ably JavaScript SDK` +* The `Vue` library from their CDN +* Our code, split across a few files for organisation. +* p2p.js - contains our `PubSubClient` and `Identity` classes +* p2p.lib.server.js - contains our `P2PServer` class +* p2p.lib.client.js - contains our `P2PClient` class +* index.js - contains our `Vue` app. + +The files are split up with the expectation that the `P2PClient` and `P2PServer` code would likely grow over time, with additional messages being introduced and handled. + +Now let's fill out the body: + +```html + +
+

P2P Host / Client Example

+ +
+ Client State: {{ state?.status == undefined ? "Disconnected" : state?.status }} +
+``` + +The `Vue` app is bound to the `div` with the id `app` - this means that all the markup inside that `div` is parsed for `Vue directives`. + +Firstly, we're creating a form, to allow users to `host` or `join` a session. + +```html +
+ + + + + + + +
+ +``` + +Most of this is normal HTML, but there are a couple of small details worth paying attention to. +The `v-if` directive, on the first line can be read as "only display this element, if the following condition is met`. + +`joinedOrHosting` is a property defined in our `Vue` app that looks like this + +```js + computed: { + joinedOrHosting: function () { return this.p2pClient != null || this.p2pServer != null; }, + }, +``` + +This `computed` property can be bound to, and is used to toggle the UI if our user hasn't `joined` or `hosted` yet - we've achieved this with a **null check** - because we know that our `this.p2pClient` and `this.p2pServer` instances are only created when our `host` or `join` functions are called. + +Our buttons also have `v-on:click="host"` attributes in them - this is Vue's onclick handler binding syntax, which wires up our buttons to our Vue app functions. We only have two functions to call, so each of those `v-on:click` handlers only differ by the word `host` or `join`. + +We then have some markup to render the `game-info`: + +```html +
+

UniqueId: {{ uniqueId }}

+

Active players: {{ transmittedServerState?.players?.length }}

+
    +
  • + {{ user.friendlyName }} +
  • +
+
+
+ + +``` + +Here you'll notice Vue's `template syntax` - {{ some-property-name }} - by using this syntax, Vue will populate the markup with data sourced from it's data object. We're also using a `v-else` directive to toggle between the form and this information once a user clicks a button. + +The `v-for` directive on the `li` element will loop for each element of the `players` collection, allowing us to output each `user.friendlyName` using just one line of markup. + +This markup, binds to our final Vue app in [index.js](index.js): + +```js +var app = new Vue({ + el: '#app', + data: { + p2pClient: null, + p2pServer: null, + + friendlyName: "Player-" + crypto.getRandomValues(new Uint32Array(1))[0], + uniqueId: "Session" + }, + computed: { + state: function() { return this.p2pClient?.state; }, + transmittedServerState: function() { return this.p2pClient?.serverState; }, + joinedOrHosting: function () { return this.p2pClient != null || this.p2pServer != null; }, + }, + methods: { + host: async function(evt) { ... }, + join: async function(evt) { ... }, + } +}); +``` + +Referencing both the `data` object properties, and our extra `computed` object properties. + +## You now have a working peer to peer demo, but wait there's more! + +This is a self contained demo of building peer to peer apps, hosting in browser tabs and using Ably Channels as the communication medium. + +We'd love to see what exciting apps or games you could build on top of this sort of pattern - and to that end, we made one ourselves! + +We made a distributed bingo game called `Ablingo` on top of this code sample - by adding additional messages, logic, and state to our server. You can read a detailed readme of how we extended this sample at the [repository here](https://github.com/thisisjofrank/Ablingo/blob/master/readme.md). + +## Running the demo on your machine + +While this whole application runs inside a browser, to host it anywhere people can use, we need a backend to keep our `Ably API key` safe. The running version of this app is hosted on [Azure Static Web Apps](https://azure.microsoft.com/en-gb/services/app-service/static/) and provides us a serverless function that we can use to implement [Ably Token Authentication](https://www.ably.io/documentation/realtime/authentication#token-authentication). + +We need to keep the `Ably API key` on the server side, so people can't grab it and use up your personal quota. The client side SDK can request a temporary key from an API call, we just need somewhere to host it. In the `api` directory, there's code for an `Azure Functions` API that implements this `Token Authentication` behaviour. + +Azure Static Web Apps automatically hosts this API for us. To have this same experience locally, we'll need to use the [Azure Functions Core Tools](https://docs.microsoft.com/en-us/learn/modules/develop-test-deploy-azure-functions-with-core-tools/). + +### Local dev pre-requirements + +We'll use [live-server](https://www.npmjs.com/package/live-server) to serve the static files and [Azure functions core tools](https://www.npmjs.com/package/azure-functions-core-tools) for interactivity. In your project folder run: + +```bash +$ npm install -g live-server +$ npm install -g azure-functions-core-tools +``` + +Than set your API key for local dev: + +```bash +$ cd api +$ func settings add ABLY_API_KEY Your-Ably-Api-Key +``` + +Running this command will encrypt your API key into the file `/api/local.settings.json`. +You don't need to check it in to source control, and even if you do, it won't be usable on another machine. + +### How to run for local dev + +To run the app: + +```bash +$ npx live-server --proxy=/api:http://127.0.0.1:7071/api +``` + +And run the APIs + +```bash +$ cd api +$ npm run start +``` + +## Hosting on Azure + +We're hosting this as a Azure Static Web Apps - and the deployment information is in [hosting.md](https://github.com/thisisjofrank/Ablingo/blob/master/readme.md#hosting-on-azure). \ No newline at end of file diff --git a/ably-visual-p2p b/ably-visual-p2p deleted file mode 160000 index f15b48d3..00000000 --- a/ably-visual-p2p +++ /dev/null @@ -1 +0,0 @@ -Subproject commit f15b48d35a215e2759d838ca10b9568fb390a5a4 diff --git a/p2p-vue-demo/.gitignore b/p2p-vue-demo/.gitignore new file mode 100644 index 00000000..730a2ff4 --- /dev/null +++ b/p2p-vue-demo/.gitignore @@ -0,0 +1,2 @@ +/node_modules +/tests/node_modules \ No newline at end of file diff --git a/p2p-vue-demo/api/.gitignore b/p2p-vue-demo/api/.gitignore new file mode 100644 index 00000000..fbbe2efa --- /dev/null +++ b/p2p-vue-demo/api/.gitignore @@ -0,0 +1,43 @@ +bin +obj +csx +.vs +edge +Publish + +*.user +*.suo +*.cscfg +*.Cache +project.lock.json + +/packages +/TestResults + +/tools/NuGet.exe +/App_Data +/secrets +/data +.secrets +appsettings.json +local.settings.json + +node_modules +dist + +# Local python packages +.python_packages/ + +# Python Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class \ No newline at end of file diff --git a/p2p-vue-demo/api/.vscode/extensions.json b/p2p-vue-demo/api/.vscode/extensions.json new file mode 100644 index 00000000..26786f93 --- /dev/null +++ b/p2p-vue-demo/api/.vscode/extensions.json @@ -0,0 +1,5 @@ +{ + "recommendations": [ + "ms-azuretools.vscode-azurefunctions" + ] +} diff --git a/p2p-vue-demo/api/.vscode/launch.json b/p2p-vue-demo/api/.vscode/launch.json new file mode 100644 index 00000000..9306c8ad --- /dev/null +++ b/p2p-vue-demo/api/.vscode/launch.json @@ -0,0 +1,12 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Attach to Node Functions", + "type": "node", + "request": "attach", + "port": 9229, + "preLaunchTask": "func: host start" + } + ] +} diff --git a/p2p-vue-demo/api/.vscode/settings.json b/p2p-vue-demo/api/.vscode/settings.json new file mode 100644 index 00000000..c40c515d --- /dev/null +++ b/p2p-vue-demo/api/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "azureFunctions.deploySubpath": ".", + "azureFunctions.postDeployTask": "npm install", + "azureFunctions.projectLanguage": "JavaScript", + "azureFunctions.projectRuntime": "~2", + "azureFunctions.preDeployTask": "npm prune" +} \ No newline at end of file diff --git a/p2p-vue-demo/api/.vscode/tasks.json b/p2p-vue-demo/api/.vscode/tasks.json new file mode 100644 index 00000000..6a7a88b5 --- /dev/null +++ b/p2p-vue-demo/api/.vscode/tasks.json @@ -0,0 +1,23 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "type": "func", + "command": "host start", + "problemMatcher": "$func-watch", + "isBackground": true, + "dependsOn": "npm install" + }, + { + "type": "shell", + "label": "npm install", + "command": "npm install" + }, + { + "type": "shell", + "label": "npm prune", + "command": "npm prune --production", + "problemMatcher": [] + } + ] +} diff --git a/p2p-vue-demo/api/createTokenRequest/function.json b/p2p-vue-demo/api/createTokenRequest/function.json new file mode 100644 index 00000000..68778ab2 --- /dev/null +++ b/p2p-vue-demo/api/createTokenRequest/function.json @@ -0,0 +1,6 @@ +{ + "bindings": [ + { "authLevel": "function", "type": "httpTrigger", "direction": "in", "name": "req", "methods": [ "get" ] }, + { "type": "http", "direction": "out", "name": "res" } + ] +} \ No newline at end of file diff --git a/p2p-vue-demo/api/createTokenRequest/index.js b/p2p-vue-demo/api/createTokenRequest/index.js new file mode 100644 index 00000000..c56bff70 --- /dev/null +++ b/p2p-vue-demo/api/createTokenRequest/index.js @@ -0,0 +1,10 @@ +const Ably = require('ably/promises'); + +module.exports = async function (context, req) { + const client = new Ably.Realtime(process.env.ABLY_API_KEY); + const tokenRequestData = await client.auth.createTokenRequest({ clientId: 'ably-azure-static-site-demo' }); + context.res = { + headers: { "content-type": "application/json" }, + body: JSON.stringify(tokenRequestData) + }; +}; \ No newline at end of file diff --git a/p2p-vue-demo/api/host.json b/p2p-vue-demo/api/host.json new file mode 100644 index 00000000..8f3cf9db --- /dev/null +++ b/p2p-vue-demo/api/host.json @@ -0,0 +1,7 @@ +{ + "version": "2.0", + "extensionBundle": { + "id": "Microsoft.Azure.Functions.ExtensionBundle", + "version": "[1.*, 2.0.0)" + } +} \ No newline at end of file diff --git a/p2p-vue-demo/api/package-lock.json b/p2p-vue-demo/api/package-lock.json new file mode 100644 index 00000000..2440c0cd --- /dev/null +++ b/p2p-vue-demo/api/package-lock.json @@ -0,0 +1,387 @@ +{ + "requires": true, + "lockfileVersion": 1, + "dependencies": { + "@ably/msgpack-js": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@ably/msgpack-js/-/msgpack-js-0.3.3.tgz", + "integrity": "sha512-H7oWg97VyA1JhWUP7YN7zwp9W1ozCqMSsqCcXNz4XLmZNdJKT2ntF/6DPgbviFgUpShjQlbPC/iamisTjwLHdQ==", + "requires": { + "bops": "~0.0.6" + } + }, + "ably": { + "version": "1.1.25", + "resolved": "https://registry.npmjs.org/ably/-/ably-1.1.25.tgz", + "integrity": "sha512-VGbV2Bsd0Glt0LwwzI39xZ4e9RdFZrFSOS3sKi/8+2ljb8LAujFG5Z6dJaOHPBjUohqj+Gtbv6rNQDrz4b7Ygw==", + "requires": { + "@ably/msgpack-js": "^0.3.3", + "request": "^2.87.0", + "ws": "^5.1" + } + }, + "ajv": { + "version": "6.12.2", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.2.tgz", + "integrity": "sha512-k+V+hzjm5q/Mr8ef/1Y9goCmlsK4I6Sm74teeyGvFk1XrOsbsKLjEdrvny42CZ+a8sXbk8KWpY/bDwS+FLL2UQ==", + "requires": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, + "asn1": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.4.tgz", + "integrity": "sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg==", + "requires": { + "safer-buffer": "~2.1.0" + } + }, + "assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=" + }, + "async-limiter": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz", + "integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==" + }, + "asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=" + }, + "aws-sign2": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", + "integrity": "sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=" + }, + "aws4": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.10.0.tgz", + "integrity": "sha512-3YDiu347mtVtjpyV3u5kVqQLP242c06zwDOgpeRnybmXlYYsLbtTrUBUm8i8srONt+FWobl5aibnU1030PeeuA==" + }, + "base64-js": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.2.tgz", + "integrity": "sha1-Ak8Pcq+iW3X5wO5zzU9V7Bvtl4Q=" + }, + "bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4=", + "requires": { + "tweetnacl": "^0.14.3" + } + }, + "bops": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/bops/-/bops-0.0.7.tgz", + "integrity": "sha1-tKClqDmkBkVK8P4FqLkaenZqVOI=", + "requires": { + "base64-js": "0.0.2", + "to-utf8": "0.0.1" + } + }, + "caseless": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", + "integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=" + }, + "combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "requires": { + "delayed-stream": "~1.0.0" + } + }, + "core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=" + }, + "dashdash": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", + "integrity": "sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=", + "requires": { + "assert-plus": "^1.0.0" + } + }, + "delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=" + }, + "ecc-jsbn": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", + "integrity": "sha1-OoOpBOVDUyh4dMVkt1SThoSamMk=", + "requires": { + "jsbn": "~0.1.0", + "safer-buffer": "^2.1.0" + } + }, + "extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" + }, + "extsprintf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", + "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=" + }, + "fast-deep-equal": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.1.tgz", + "integrity": "sha512-8UEa58QDLauDNfpbrX55Q9jrGHThw2ZMdOky5Gl1CDtVeJDPVrG4Jxx1N8jw2gkWaff5UUuX1KJd+9zGe2B+ZA==" + }, + "fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==" + }, + "forever-agent": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", + "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=" + }, + "form-data": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", + "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.6", + "mime-types": "^2.1.12" + } + }, + "getpass": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", + "integrity": "sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=", + "requires": { + "assert-plus": "^1.0.0" + } + }, + "har-schema": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", + "integrity": "sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=" + }, + "har-validator": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.3.tgz", + "integrity": "sha512-sNvOCzEQNr/qrvJgc3UG/kD4QtlHycrzwS+6mfTrrSq97BvaYcPZZI1ZSqGSPR73Cxn4LKTD4PttRwfU7jWq5g==", + "requires": { + "ajv": "^6.5.5", + "har-schema": "^2.0.0" + } + }, + "http-signature": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", + "integrity": "sha1-muzZJRFHcvPZW2WmCruPfBj7rOE=", + "requires": { + "assert-plus": "^1.0.0", + "jsprim": "^1.2.2", + "sshpk": "^1.7.0" + } + }, + "is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=" + }, + "isstream": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", + "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=" + }, + "jsbn": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", + "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=" + }, + "json-schema": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz", + "integrity": "sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=" + }, + "json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" + }, + "json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=" + }, + "jsprim": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz", + "integrity": "sha1-MT5mvB5cwG5Di8G3SZwuXFastqI=", + "requires": { + "assert-plus": "1.0.0", + "extsprintf": "1.3.0", + "json-schema": "0.2.3", + "verror": "1.10.0" + } + }, + "mime-db": { + "version": "1.44.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.44.0.tgz", + "integrity": "sha512-/NOTfLrsPBVeH7YtFPgsVWveuL+4SjjYxaQ1xtM1KMFj7HdxlBlxeyNLzhyJVx7r4rZGJAZ/6lkKCitSc/Nmpg==" + }, + "mime-types": { + "version": "2.1.27", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.27.tgz", + "integrity": "sha512-JIhqnCasI9yD+SsmkquHBxTSEuZdQX5BuQnS2Vc7puQQQ+8yiP5AY5uWhpdv4YL4VM5c6iliiYWPgJ/nJQLp7w==", + "requires": { + "mime-db": "1.44.0" + } + }, + "oauth-sign": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", + "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==" + }, + "performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=" + }, + "psl": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.8.0.tgz", + "integrity": "sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ==" + }, + "punycode": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", + "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==" + }, + "qs": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz", + "integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==" + }, + "request": { + "version": "2.88.2", + "resolved": "https://registry.npmjs.org/request/-/request-2.88.2.tgz", + "integrity": "sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==", + "requires": { + "aws-sign2": "~0.7.0", + "aws4": "^1.8.0", + "caseless": "~0.12.0", + "combined-stream": "~1.0.6", + "extend": "~3.0.2", + "forever-agent": "~0.6.1", + "form-data": "~2.3.2", + "har-validator": "~5.1.3", + "http-signature": "~1.2.0", + "is-typedarray": "~1.0.0", + "isstream": "~0.1.2", + "json-stringify-safe": "~5.0.1", + "mime-types": "~2.1.19", + "oauth-sign": "~0.9.0", + "performance-now": "^2.1.0", + "qs": "~6.5.2", + "safe-buffer": "^5.1.2", + "tough-cookie": "~2.5.0", + "tunnel-agent": "^0.6.0", + "uuid": "^3.3.2" + } + }, + "safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" + }, + "safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "sshpk": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.16.1.tgz", + "integrity": "sha512-HXXqVUq7+pcKeLqqZj6mHFUMvXtOJt1uoUx09pFW6011inTMxqI8BA8PM95myrIyyKwdnzjdFjLiE6KBPVtJIg==", + "requires": { + "asn1": "~0.2.3", + "assert-plus": "^1.0.0", + "bcrypt-pbkdf": "^1.0.0", + "dashdash": "^1.12.0", + "ecc-jsbn": "~0.1.1", + "getpass": "^0.1.1", + "jsbn": "~0.1.0", + "safer-buffer": "^2.0.2", + "tweetnacl": "~0.14.0" + } + }, + "to-utf8": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/to-utf8/-/to-utf8-0.0.1.tgz", + "integrity": "sha1-0Xrqcv8vujm55DYBvns/9y4ImFI=" + }, + "tough-cookie": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", + "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==", + "requires": { + "psl": "^1.1.28", + "punycode": "^2.1.1" + } + }, + "tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=", + "requires": { + "safe-buffer": "^5.0.1" + } + }, + "tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=" + }, + "uri-js": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.2.2.tgz", + "integrity": "sha512-KY9Frmirql91X2Qgjry0Wd4Y+YTdrdZheS8TFwvkbLWf/G5KNJDCh6pKL5OZctEW4+0Baa5idK2ZQuELRwPznQ==", + "requires": { + "punycode": "^2.1.0" + } + }, + "uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==" + }, + "verror": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", + "integrity": "sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=", + "requires": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + } + }, + "ws": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/ws/-/ws-5.2.2.tgz", + "integrity": "sha512-jaHFD6PFv6UgoIVda6qZllptQsMlDEJkTQcybzzXDYM1XO9Y8em691FGMPmM46WGyLU4z9KMgQN+qrux/nhlHA==", + "requires": { + "async-limiter": "~1.0.0" + } + } + } +} diff --git a/p2p-vue-demo/api/package.json b/p2p-vue-demo/api/package.json new file mode 100644 index 00000000..56c435dc --- /dev/null +++ b/p2p-vue-demo/api/package.json @@ -0,0 +1,14 @@ +{ + "name": "", + "version": "", + "description": "", + "scripts": { + "prestart": "func extensions install", + "start": "func start", + "test": "echo \"No tests yet...\"" + }, + "author": "", + "dependencies": { + "ably": "^1.1.25" + } +} diff --git a/p2p-vue-demo/index.html b/p2p-vue-demo/index.html new file mode 100644 index 00000000..5ca39007 --- /dev/null +++ b/p2p-vue-demo/index.html @@ -0,0 +1,46 @@ + + + + + P2P Example + + + + + + + + + + + + +
+

P2P Host / Client Example

+ +
+ Client State: {{ state?.status == undefined ? "Disconnected" : state?.status }} +
+ +
+ + + + + + + +
+ +
+

UniqueId: {{ uniqueId }}

+

Active players: {{ transmittedServerState?.players?.length }}

+
    +
  • + {{ user.friendlyName }} +
  • +
+
+
+ + \ No newline at end of file diff --git a/p2p-vue-demo/index.js b/p2p-vue-demo/index.js new file mode 100644 index 00000000..539758c9 --- /dev/null +++ b/p2p-vue-demo/index.js @@ -0,0 +1,54 @@ +var app = new Vue({ + el: '#app', + data: { + p2pClient: null, + p2pServer: null, + + friendlyName: "Player-" + crypto.getRandomValues(new Uint32Array(1))[0], + uniqueId: "Session" + }, + computed: { + state: function() { return this.p2pClient?.state; }, + transmittedServerState: function() { return this.p2pClient?.serverState; }, + joinedOrHosting: function () { return this.p2pClient != null || this.p2pServer != null; }, + }, + methods: { + host: async function(evt) { + evt.preventDefault(); + + const pubSubClient = new PubSubClient((message, metadata) => { + handleMessagefromAbly(message, metadata, this.p2pClient, this.p2pServer); + }); + + const identity = new Identity(this.friendlyName); + this.p2pServer = new P2PServer(identity, this.uniqueId, pubSubClient); + this.p2pClient = new P2PClient(identity, this.uniqueId, pubSubClient); + + await this.p2pServer.connect(); + await this.p2pClient.connect(); + }, + join: async function(evt) { + evt.preventDefault(); + + const pubSubClient = new PubSubClient((message, metadata) => { + handleMessagefromAbly(message, metadata, this.p2pClient, this.p2pServer); + }); + + const identity = new Identity(this.friendlyName); + this.p2pClient = new P2PClient(identity, this.uniqueId, pubSubClient); + + await this.p2pClient.connect(); + } + } +}); + +function shouldHandleMessage(message, metadata) { + return message.forClientId == null || !message.forClientId || (message.forClientId && message.forClientId === metadata.clientId); +} + +function handleMessagefromAbly(message, metadata, p2pClient, p2pServer) { + if (shouldHandleMessage(message, metadata)) { + p2pServer?.onReceiveMessage(message); + p2pClient?.onReceiveMessage(message); + } +} \ No newline at end of file diff --git a/p2p-vue-demo/p2p.js b/p2p-vue-demo/p2p.js new file mode 100644 index 00000000..5a2029d6 --- /dev/null +++ b/p2p-vue-demo/p2p.js @@ -0,0 +1,38 @@ +class Identity { + constructor(friendlyName) { + this.clientId = crypto.getRandomValues(new Uint32Array(1))[0]; + this.friendlyName = friendlyName; + } +} + +class PubSubClient { + constructor(onMessageReceivedCallback) { + this.connected = false; + this.onMessageReceivedCallback = onMessageReceivedCallback; + } + + async connect(identity, uniqueId) { + if(this.connected) return; + + this.metadata = { uniqueId: uniqueId, ...identity }; + + const ably = new Ably.Realtime.Promise({ authUrl: '/api/createTokenRequest' }); + this.channel = await ably.channels.get(`p2p-sample-${uniqueId}`); + + this.channel.subscribe((message) => { + this.onMessageReceivedCallback(message.data, this.metadata); + }); + + this.connected = true; + } + + sendMessage(message, targetClientId) { + if (!this.connected) { + throw "Client is not connected"; + } + + message.metadata = this.metadata; + message.forClientId = targetClientId ? targetClientId : null; + this.channel.publish({ name: "myMessageName", data: message}); + } +} \ No newline at end of file diff --git a/p2p-vue-demo/p2p.lib.client.js b/p2p-vue-demo/p2p.lib.client.js new file mode 100644 index 00000000..60cf84c8 --- /dev/null +++ b/p2p-vue-demo/p2p.lib.client.js @@ -0,0 +1,30 @@ +class P2PClient { + constructor(identity, uniqueId, ably) { + this.identity = identity; + this.uniqueId = uniqueId; + this.ably = ably; + + this.serverState = null; + this.state = { status: "disconnected" }; + } + + async connect() { + await this.ably.connect(this.identity, this.uniqueId); + + this.ably.sendMessage({ kind: "connected" }); + this.state.status = "awaiting-acknowledgement"; + } + + onReceiveMessage(message) { + if (message.serverState) { + this.serverState = message.serverState; + } + + switch(message.kind) { + case "connection-acknowledged": + this.state.status = "acknowledged"; + break; + default: () => { }; + } + } + } \ No newline at end of file diff --git a/p2p-vue-demo/p2p.lib.server.js b/p2p-vue-demo/p2p.lib.server.js new file mode 100644 index 00000000..19be0aa7 --- /dev/null +++ b/p2p-vue-demo/p2p.lib.server.js @@ -0,0 +1,26 @@ +class P2PServer { + constructor(identity, uniqueId, ably) { + this.identity = identity; + this.uniqueId = uniqueId; + this.ably = ably; + + this.state = { players: [] }; + } + + async connect() { + await this.ably.connect(this.identity, this.uniqueId); + } + + onReceiveMessage(message) { + switch(message.kind) { + case "connected": this.onClientConnected(message); break; + default: () => { }; + } + } + + onClientConnected(message) { + this.state.players.push(message.metadata); + this.ably.sendMessage({ kind: "connection-acknowledged", serverState: this.state }, message.metadata.clientId); + this.ably.sendMessage({ kind: "peer-status", serverState: this.state }); + } + } \ No newline at end of file diff --git a/p2p-vue-demo/style.css b/p2p-vue-demo/style.css new file mode 100644 index 00000000..c489f407 --- /dev/null +++ b/p2p-vue-demo/style.css @@ -0,0 +1,98 @@ +html, +body { + max-width: 700px; + margin: 0 auto; + border: 0; + padding: 0; + background-color: #fff; + box-sizing: border-box; + font-family: Arial, Helvetica, sans-serif; +} + +*, *:before, *:after { + box-sizing: inherit; +} + +h1 { + margin-top: 40px; + font-size: 2.6em; + text-align: center; +} + +.form { + display: flex; + flex-wrap: wrap; + + padding: 40px; + color: white; + background-color: rgb(34,159,195); + font-size: 1.4em; +} + +label { + width: 100%; + line-height: 1.6em; +} + +input[type="text"] { + width: 100%; + margin-bottom: 1em; + padding: 10px; + border: 0; + border-radius: 4px; + font-size: 0.8em; +} + +button { + padding: 20px; + border: 0; + border-radius: 4px; + font-size: 1em; + color: white; + background-color: #9265ba; + cursor: pointer; +} + +button:hover { + background-color: #be4eb7; +} + +.form-button { + display: inline-block; + width: calc(50% - 10px); + margin: 1em 0 0; +} + +.form-button--host { + margin-right: 20px; +} + +.info { + padding: 0 20px; + width: calc(100% - 404px); +} + +.debug { + position: fixed; + top: 0; + right: 0; + background-color: red; + color: white; + padding: 10px; +} + +.players { + margin: 10px 0 40px; + padding: 0; + border: 1px solid #ccc; + border-radius: 4px; +} + +.player { + list-style: none; + padding: 10px; +} + +.player:nth-child(even) { + background-color: #f1f1f1; +} From 6f31c075fc6db42aeb7dda6128ee889503e79b06 Mon Sep 17 00:00:00 2001 From: thisisjofrank Date: Mon, 29 Jun 2020 18:02:14 +0100 Subject: [PATCH 3/3] adding in rewind example --- README.md | 148 ++++++++++++++++++++++++++++++++- p2p-vue-demo/api/package.json | 2 +- p2p-vue-demo/index.html | 8 +- p2p-vue-demo/index.js | 6 +- p2p-vue-demo/p2p.js | 4 +- p2p-vue-demo/p2p.lib.client.js | 53 ++++++------ p2p-vue-demo/p2p.lib.server.js | 50 ++++++----- 7 files changed, 220 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index b2768da1..a5451733 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ By the end of this demo, everyone that joins the `channel`, should see the name > Vue (pronounced /vjuː/, like view) is a progressive framework for building user interfaces. It is designed from the ground up to be incrementally adoptable, and can easily scale between a library and a framework depending on different use cases. It consists of an approachable core library that focuses on the view layer only, and an ecosystem of supporting libraries that helps you tackle complexity in large Single-Page Applications. > -- [vue.js Github repo](https://github.com/vuejs/vue) -[Vue.js](https://vuejs.org/) is a single page app framework, and we will use it to build the UI of our app. Our Vue code lives in [index.js](index.js) - and handles all of the user interactions. We're using Vue because it doesn't require a toolchain and it provides simple binding syntax for updating the UI when data changes. +[Vue.js](https://vuejs.org/) is a single page app framework, and we will use it to build the UI of our app. Our Vue code lives in [index.js](https://github.com/ably/tutorials/blob/tutorial-p2p-vue/p2p-vue-demo/index.js) - and handles all of the user interactions. We're using Vue because it doesn't require a toolchain and it provides simple binding syntax for updating the UI when data changes. Our Vue app looks a little like this abridged sample: @@ -435,7 +435,7 @@ Here you'll notice Vue's `template syntax` - {{ some-property-name }} - by using The `v-for` directive on the `li` element will loop for each element of the `players` collection, allowing us to output each `user.friendlyName` using just one line of markup. -This markup, binds to our final Vue app in [index.js](index.js): +This markup, binds to our final Vue app in [index.js]:(https://github.com/ably/tutorials/blob/tutorial-p2p-vue/p2p-vue-demo/index.js) ```js var app = new Vue({ @@ -466,6 +466,150 @@ Referencing both the `data` object properties, and our extra `computed` object p This is a self contained demo of building peer to peer apps, hosting in browser tabs and using Ably Channels as the communication medium. We'd love to see what exciting apps or games you could build on top of this sort of pattern - and to that end, we made one ourselves! +## Resilience - what happens if we disconnect? + +The `Ably JavaScript SDK` is pretty smart - it'll buffer **outbound** messages when it finds itself in a disconnected state. We're going to illustrate this with a word streaming example. + +Let's add a couple of bits of code - firstly to the `P2PServer` + +```js + async sendWordsAcrossMultipleMessages() { + const phrase = "Long before his nine o'clock headache appears".split(" "); + const sleep = (ms) => (new Promise(resolve => setTimeout(resolve, ms))); + + for (let word of phrase) { + this.ably.sendMessage({ kind: "word", word: word, serverState: this.state }); + await sleep(500); + } + } +``` +(The full code sample has a much longer phrase embedded in it) + +Here, we're taking a phrase, and sending it as a series of messages word-by-word, with a 500ms pause in between each word. We're using `async / await` to pause our code, just be *resolving a promise* every 500ms. + +Now we should update our client to listen for this message, we'll add a `case` statement for our `word` message to it's `onReceiveMessage` handler. + +```js + onReceiveMessage(message) { + if (message.serverState) { + this.serverState = message.serverState; + } + + switch(message.kind) { + case "connection-acknowledged": + this.state.status = "acknowledged"; + break; + case "word": + this.state.receivedWords += " " + message.word; + break; + default: () => { }; + } + } +``` +When we see a `word` message, we add it to a string in our state object. Because of this, we also need to update the `this.state` object in the `P2PClient` constructor to start us off with an empty `receivedWords` property. + +```js +this.state = { + status: "disconnected", + receivedWords: "" + }; +``` + +Next, we're going to make sure we display this in everyones `UI` by binding the state property into our markup in `index.html` + +```html +
+

UniqueId: {{ uniqueId }}

+ + +

Active players: {{ transmittedServerState?.players?.length }}

+
    +
  • + {{ user.friendlyName }} +
  • +
+ +
+ {{ this.p2pClient.state.receivedWords }} +
+
+ +``` +We're doing two things here: + +1. Adding a `sendWordsAsHost` onclick to a new `html button` +2. Binding `{{ this.p2pClient.state.receivedWords }}` - the string of words our client has seen. + +We need to create a function to handle our `Stream words to client` click, so we'll add a new method to our `Vue methods` property in `index.js` + +```js +sendWordsAsHost: async function(evt) { + await this.p2pServer.sendWordsAcrossMultipleMessages(); +} +``` +This calls the function we added to our `P2PServer` and the button only displays to the host because of the `v-if` directive bound a a new `computed property` `iAmHost` that verifies a `p2pserver` is active. + +We'll add `iAmHost` to our `computed` property in `index.js`. + +```js +computed: { + state: function() { ... }, + transmittedServerState: function() { ... }, + joinedOrHosting: function () { ... }, + iAmHost: function() { return this.p2pServer != null; }, +} +``` + +So now, if you start a hosting a session, and click `Stream words to client` you'll see a stream of words appear one by one in your UI. + +If your internet connection is disrupted, the Ably client, used by `P2PServer`, will buffer it's outbound messages until a connection can be re-established. Ably handles buffered connnections in a few different ways (documented here https://www.ably.io/documentation/realtime/connection) + + The disconnected state is entered if an established connection is dropped, or if a connection attempt was unsuccessful. In the disconnected state the library will periodically attempt to open a new connection (approximately every 15 seconds), anticipating that the connection will be re-established soon and thus connection and channel continuity will be possible. + + In this state, developers can continue to publish messages as they are automatically placed in a local queue, to be sent as soon as a connection is reestablished. Messages published by other clients whilst this client is disconnected will be delivered to it upon reconnection, so long as the connection was resumed within 2 minutes. + + After 2 minutes have elapsed, recovery is no longer possible and the connection will move to the suspended state. + + The suspended state is entered after a failed connection attempt if there has then been no connection for a period of two minutes. In the suspended state, the library will periodically attempt to open a new connection every 30 seconds. Developers are unable to publish messages in this state. A new connection attempt can also be triggered by an explicit call to connect on the Connection object. + + Once the connection has been re-established, channels will be automatically re-attached. The client has been disconnected for too long for them to resume from where they left off, so if it wants to catch up on messages published by other clients while it was disconnected, it needs to use the history API. + +So much like our `P2PServer` will buffer outbound messages, any disconnected clients will receive all the messages sent in a window of up to **two minutes of disconnection** + +### Using the History API to catch up + +Ably offers a `History API` and a `Rewind` setting. The History API allows you to query the history of a channel for by default, the last *two minutes* of data. If you enable `persisted history` on your channel, that window is extended for 24-72 hours. This has a cost - it counts against message quotas (see docs for more: https://support.ably.com/support/solutions/articles/3000030059), but we can use this extended window to allow clients to join "mid-stream" and catch up when they connect. + +The `Rewind` setting can be enabled when you subscribe to a channel, which will immediate then receive up to the last 100 messages in the time window you specify. + +The caveat is, the client will be processing historical messages - so be sure you understand what that means for your specific application. In some scenarios, it may be more effective for another `peer` or the `server` to just send new clients whatever state they need rather than the client reconstructing it. + +If you use the History API you probably don't want your clients re-processing thousands and thousands of messages. + +We need to add an additional parameter to our `channels.get` call, to add the parameter ` { params: { rewind: '1m' } }`. This tells our client to return up to the last 100 messages, from the minute. You can provide different time periods in here in either minutes ('2m') or seconds ('15s'). + +```js +async connect(identity, uniqueId) { + ... + const ably = new Ably.Realtime.Promise({ authUrl: '/api/createTokenRequest' }); + this.channel = await ably.channels.get(`p2p-sample-${uniqueId}`, { params: { rewind: '1m' } }); + ... +} +``` + +Some notes on the limits of the history API from the `Ably docs` + + By default, persisted history on channels is disabled and messages are only stored by the Ably service for two minutes in memory. If persisted history is enabled for the channel, then messages will typically be stored for 24 – 72 hours on disk. + + Every message that is persisted to or retrieved from disk counts as an extra message towards your monthly quote. For example, for a channel that has persistence enabled, if a message is published, two messages will be deducted from your monthly quota. If the message is later retrieved from history, another message will be deducted from your monthly quota. + +If you use the History API to collect history, there's a chance that while you're processing historical messages, new messages could be handled by the SDK - you'd have to write code to buffer and make sure you process these messages in order. + +## Yay, the end, but there's more + +This is a self contained demo of building peer to peer apps, hosting in browser tabs, using `Ably Channels` as their communication medium. + +We'd love to see what exciting apps or games you could build on top of this sort of pattern - and to that end, we made our ourselves! We made a distributed bingo game called `Ablingo` on top of this code sample - by adding additional messages, logic, and state to our server. You can read a detailed readme of how we extended this sample at the [repository here](https://github.com/thisisjofrank/Ablingo/blob/master/readme.md). diff --git a/p2p-vue-demo/api/package.json b/p2p-vue-demo/api/package.json index 56c435dc..57bad863 100644 --- a/p2p-vue-demo/api/package.json +++ b/p2p-vue-demo/api/package.json @@ -1,5 +1,5 @@ { - "name": "", + "name": "p2p_ably_demo", "version": "", "description": "", "scripts": { diff --git a/p2p-vue-demo/index.html b/p2p-vue-demo/index.html index 5ca39007..8c12703a 100644 --- a/p2p-vue-demo/index.html +++ b/p2p-vue-demo/index.html @@ -34,12 +34,18 @@

P2P Host / Client Example

UniqueId: {{ uniqueId }}

+ +

Active players: {{ transmittedServerState?.players?.length }}

  • {{ user.friendlyName }}
  • -
+ + +
+ {{ this.p2pClient.state.receivedWords }} +
diff --git a/p2p-vue-demo/index.js b/p2p-vue-demo/index.js index 539758c9..d0047b95 100644 --- a/p2p-vue-demo/index.js +++ b/p2p-vue-demo/index.js @@ -11,6 +11,7 @@ var app = new Vue({ state: function() { return this.p2pClient?.state; }, transmittedServerState: function() { return this.p2pClient?.serverState; }, joinedOrHosting: function () { return this.p2pClient != null || this.p2pServer != null; }, + iAmHost: function() { return this.p2pServer != null; }, }, methods: { host: async function(evt) { @@ -38,7 +39,10 @@ var app = new Vue({ this.p2pClient = new P2PClient(identity, this.uniqueId, pubSubClient); await this.p2pClient.connect(); - } + }, + sendWordsAsHost: async function(evt) { + await this.p2pServer.sendWordsAcrossMultipleMessages(); + } } }); diff --git a/p2p-vue-demo/p2p.js b/p2p-vue-demo/p2p.js index 5a2029d6..d2c6e881 100644 --- a/p2p-vue-demo/p2p.js +++ b/p2p-vue-demo/p2p.js @@ -17,7 +17,7 @@ class PubSubClient { this.metadata = { uniqueId: uniqueId, ...identity }; const ably = new Ably.Realtime.Promise({ authUrl: '/api/createTokenRequest' }); - this.channel = await ably.channels.get(`p2p-sample-${uniqueId}`); + this.channel = await ably.channels.get(`p2p-sample-${uniqueId}`, { params: { rewind: '1m' } }); this.channel.subscribe((message) => { this.onMessageReceivedCallback(message.data, this.metadata); @@ -25,7 +25,7 @@ class PubSubClient { this.connected = true; } - + sendMessage(message, targetClientId) { if (!this.connected) { throw "Client is not connected"; diff --git a/p2p-vue-demo/p2p.lib.client.js b/p2p-vue-demo/p2p.lib.client.js index 60cf84c8..a597e94e 100644 --- a/p2p-vue-demo/p2p.lib.client.js +++ b/p2p-vue-demo/p2p.lib.client.js @@ -1,30 +1,35 @@ class P2PClient { - constructor(identity, uniqueId, ably) { - this.identity = identity; - this.uniqueId = uniqueId; - this.ably = ably; + constructor(identity, uniqueId, ably) { + this.identity = identity; + this.uniqueId = uniqueId; + this.ably = ably; - this.serverState = null; - this.state = { status: "disconnected" }; - } + this.serverState = null; + this.state = { + status: "disconnected", + receivedWords: "" + }; + } - async connect() { - await this.ably.connect(this.identity, this.uniqueId); + async connect() { + await this.ably.connect(this.identity, this.uniqueId); + this.ably.sendMessage({ kind: "connected" }); + this.state.status = "awaiting-acknowledgement"; + } - this.ably.sendMessage({ kind: "connected" }); - this.state.status = "awaiting-acknowledgement"; + onReceiveMessage(message) { + if (message.serverState) { + this.serverState = message.serverState; } - - onReceiveMessage(message) { - if (message.serverState) { - this.serverState = message.serverState; - } - switch(message.kind) { - case "connection-acknowledged": - this.state.status = "acknowledged"; - break; - default: () => { }; - } - } - } \ No newline at end of file + switch(message.kind) { + case "connection-acknowledged": + this.state.status = "acknowledged"; + break; + case "word": + this.state.receivedWords += " " + message.word; + break; + default: () => { }; + } + } +} \ No newline at end of file diff --git a/p2p-vue-demo/p2p.lib.server.js b/p2p-vue-demo/p2p.lib.server.js index 19be0aa7..519c0f21 100644 --- a/p2p-vue-demo/p2p.lib.server.js +++ b/p2p-vue-demo/p2p.lib.server.js @@ -1,26 +1,36 @@ class P2PServer { - constructor(identity, uniqueId, ably) { - this.identity = identity; - this.uniqueId = uniqueId; - this.ably = ably; + constructor(identity, uniqueId, ably) { + this.identity = identity; + this.uniqueId = uniqueId; + this.ably = ably; - this.state = { players: [] }; - } - - async connect() { - await this.ably.connect(this.identity, this.uniqueId); + this.state = { players: [] }; + } + + async connect() { + await this.ably.connect(this.identity, this.uniqueId); + } + + async sendWordsAcrossMultipleMessages() { + const phrase = "Given the context of this talk, I think it’s quite easy to fall into the trap of thinking that visual design principles only apply to visual interfaces. If you ask a typical developer - the people we’re talking to - you could very quickly think that the visual aspects of what they think about are, you know, the marketing collateral, the website, potentially, even the documentation. And I think that’s partly true. I think, you know, what we’re actually dealing with as developers, right? And they spend most of their time, when they interact with us in our APIs, they’re spending most of their time in code, right? And those are the APIs that they expose to, and that’s the experience they have. So, you know, the same sort of design principles that we apply generally to visual design, you know, to things like documentation, which I’m sure we’ve all had experience sort of thinking about how we make that aesthetically better and how we lay their content out, I think you have to go through those same, the same ideas when you’re designing APIs. And this is what this talk is about. So I don’t think anyone’s going to disagree that, you know, design is important. But I wanted to kind of summarize why I think design matters. And, you know, I kind of distil this down into three things. And, you know, even as a developer, I mean, you know, I’m probably happier sitting writing code. Design is function But I sincerely appreciate the value of design, and the impact it has, and how it changes your perception of the thing that you’re interacting with. And so, you know, I think the first thing is really, when you think about design, design is function, right? Design is not just design in isolation. And Steve Jobs kind of coined this pretty well, which is, you know, “Design is not what it looks like and feels like, design is how it works.” And, you know, I think if you embody that sort of thinking that the two fit together, right, like, good design and bad function, or, you know, great function but terrible design, will still result in a pretty bad experience. So, you know, I think the important thing to remember is that those two things together is what delivers a good design experience. And the whole idea of the design process is about also reviewing the function.".split(" "); + const sleep = (ms) => (new Promise(resolve => setTimeout(resolve, ms))); + + for (let word of phrase) { + this.ably.sendMessage({ kind: "word", word: word, serverState: this.state }); + await sleep(500); } + } - onReceiveMessage(message) { - switch(message.kind) { - case "connected": this.onClientConnected(message); break; - default: () => { }; - } + onReceiveMessage(message) { + switch(message.kind) { + case "connected": this.onClientConnected(message); break; + default: () => { }; } + } - onClientConnected(message) { - this.state.players.push(message.metadata); - this.ably.sendMessage({ kind: "connection-acknowledged", serverState: this.state }, message.metadata.clientId); - this.ably.sendMessage({ kind: "peer-status", serverState: this.state }); - } - } \ No newline at end of file + onClientConnected(message) { + this.state.players.push(message.metadata); + this.ably.sendMessage({ kind: "connection-acknowledged", serverState: this.state }, message.metadata.clientId); + this.ably.sendMessage({ kind: "peer-status", serverState: this.state }); + } +} \ No newline at end of file