├── .gitignore ├── LICENSE ├── Procfile ├── README.md ├── assets ├── brunch-config.js ├── css │ ├── app.css │ └── phoenix.css ├── js │ ├── app.js │ └── web_console.js ├── package-lock.json ├── package.json ├── static │ ├── favicon.ico │ ├── images │ │ └── phoenix.png │ └── robots.txt └── vendor │ └── sha256.js ├── config ├── config.exs ├── dev.exs ├── prod.exs ├── prod.secret.exs └── test.exs ├── elixir_buildpack.config ├── lib ├── counter │ └── socket_counter.ex ├── ephemeral2.ex ├── ephemeral2 │ └── application.ex ├── ephemeral2_web.ex └── ephemeral2_web │ ├── channels │ ├── all_channel.ex │ ├── have_channel.ex │ ├── user_socket.ex │ └── want_channel.ex │ ├── controllers │ └── page_controller.ex │ ├── endpoint.ex │ ├── gettext.ex │ ├── router.ex │ ├── templates │ ├── layout │ │ └── app.html.eex │ └── page │ │ ├── new.html.eex │ │ └── show.html.eex │ └── views │ ├── error_helpers.ex │ ├── error_view.ex │ ├── layout_view.ex │ └── page_view.ex ├── mix.exs ├── mix.lock ├── priv ├── gettext │ ├── en │ │ └── LC_MESSAGES │ │ │ └── errors.po │ └── errors.pot └── static │ ├── css │ ├── app.css │ └── app.css.map │ ├── favicon.ico │ ├── images │ └── phoenix.png │ ├── js │ ├── app.js │ └── app.js.map │ └── robots.txt └── test ├── ephemeral2_web ├── controllers │ └── page_controller_test.exs └── views │ ├── error_view_test.exs │ ├── layout_view_test.exs │ └── page_view_test.exs ├── support ├── channel_case.ex └── conn_case.ex └── test_helper.exs /.gitignore: -------------------------------------------------------------------------------- 1 | # Mix artifacts 2 | /_build 3 | /deps 4 | /*.ez 5 | 6 | # Generate on crash by the VM 7 | erl_crash.dump 8 | 9 | # Static artifacts 10 | node_modules 11 | 12 | # Since we are building js and css from web/static, 13 | # we ignore priv/static/{css,js}. You may want to 14 | # comment this depending on your deployment strategy. 15 | # /priv/static/css 16 | # /priv/static/js 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Gabriel Durazo 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: mix phx.server 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Ephemeral P2P 2 | 3 | See example implementation [running here](http://ephemeralp2p.durazo.us/). Discussion on Hacker News [here](https://news.ycombinator.com/item?id=9531265). 4 | 5 | This app hosts "P2P" pages that are "ephemeral". We say "P2P" because the clients host the page; new visitors retrieve the page contents from other visitors on the same page. It's "ephemeral" in that the server does not store the contents of any page, so once the last visitor leaves a particular page, it is gone. 6 | 7 | # How it works 8 | 9 | Ephemeral P2P is an Elixir/Phoenix app, taking advantage of Phoenix's excellent Channel functionality. All of the logic lies in two channels, the `HaveChannel` for clients who have a particular bit of content, and the `WantChannel` for visitors who want it. The content is addressed by its SHA256 (let us say `abc123`), and the two topics associated with that content are `have:abc123` and `want:abc123`. 10 | 11 | A new page is created from the homepage. A visitor fills in the textarea with whatever content they desire, and presses the "Submit" button (which is not a form submission). The client hashes the content (let's call the hash `abc123`) and uses the HTML5 history api to change the URL to `/abc123` for easy copy/paste-ability. The client then joins the "have:abc123" topic and begins listening for `"content_request"` messages, ready to respond with a `"content"` message that includes the page content which it has in memory. 12 | 13 | A subsequent visitor who loads `/abc123` joins the `want:abc123` topic and tries to obtain the content. First it listens for a `"content"` message that another visitor may have provoked. If the visitor does not receive it in 2 seconds, it will send a `"content_request"` message itself. The server will re-broadcast this message to all `have:abc123` subscribers, except that a `handle_out` will allow the message with probability `1/subscriber_count` and drop it otherwise. Any `have:abc123` subscribers who receive the message will respond with the content and the server will `broadcast` it to all `want:abc123` subscribers. The new visitor will send a `"content_request"` message every 2 seconds until it gets the content (for the case where the `handle_out` drops the message to everyone, or a `have` subscriber fails to respond for some reason.) 14 | 15 | When a `want:abc123` subscriber gets the content, it leaves the `want:abc123` topic and joins the `have:abc123` topic, ready to pass it along to newer visitors. 16 | 17 | Lastly, whenever a subscriber joins or leaves `have:abc123`, the new visitor count is broadcast, so all clients know the "health" of the page and how close it is to going away. 18 | 19 | # Run it yourself 20 | 21 | First you'll need to [install Elixir](http://elixir-lang.org/install.html). Once you have elixir set up, it should be as easy as: 22 | 23 | ``` 24 | $ git clone git@github.com:losvedir/ephemeral2.git 25 | $ cd ephemeral2 26 | $ mix deps.get 27 | $ mix phoenix.server 28 | ``` 29 | 30 | That should serve a copy of the app locally at `localhost:4000`. If you would like to modify the CSS or Javascript, you'll need to install [npm](https://www.npmjs.com/) and then brunch with: 31 | 32 | ``` 33 | $ npm install -g brunch 34 | ``` 35 | -------------------------------------------------------------------------------- /assets/brunch-config.js: -------------------------------------------------------------------------------- 1 | exports.config = { 2 | // See http://brunch.io/#documentation for docs. 3 | files: { 4 | javascripts: { 5 | joinTo: "js/app.js" 6 | 7 | // To use a separate vendor.js bundle, specify two files path 8 | // http://brunch.io/docs/config#-files- 9 | // joinTo: { 10 | // "js/app.js": /^js/, 11 | // "js/vendor.js": /^(?!js)/ 12 | // } 13 | // 14 | // To change the order of concatenation of files, explicitly mention here 15 | // order: { 16 | // before: [ 17 | // "vendor/js/jquery-2.1.1.js", 18 | // "vendor/js/bootstrap.min.js" 19 | // ] 20 | // } 21 | }, 22 | stylesheets: { 23 | joinTo: "css/app.css" 24 | }, 25 | templates: { 26 | joinTo: "js/app.js" 27 | } 28 | }, 29 | 30 | conventions: { 31 | // This option sets where we should place non-css and non-js assets in. 32 | // By default, we set this to "/assets/static". Files in this directory 33 | // will be copied to `paths.public`, which is "priv/static" by default. 34 | assets: /^(static)/ 35 | }, 36 | 37 | // Phoenix paths configuration 38 | paths: { 39 | // Dependencies and current project directories to watch 40 | watched: ["static", "css", "js", "vendor"], 41 | // Where to compile files to 42 | public: "../priv/static" 43 | }, 44 | 45 | // Configure your plugins 46 | plugins: { 47 | babel: { 48 | // Do not use ES6 compiler in vendor code 49 | ignore: [/vendor/] 50 | } 51 | }, 52 | 53 | modules: { 54 | autoRequire: { 55 | "js/app.js": ["js/app"] 56 | } 57 | }, 58 | 59 | npm: { 60 | enabled: true 61 | } 62 | }; 63 | -------------------------------------------------------------------------------- /assets/css/app.css: -------------------------------------------------------------------------------- 1 | /* This file is for your main application css. */ 2 | 3 | .web-console {height:75px; overflow:scroll; border:1px solid #e5e5e5} 4 | .web-console ul {padding:0; margin:0} 5 | .web-console li {list-style-type:none; padding:0 0 0 1em} 6 | -------------------------------------------------------------------------------- /assets/js/app.js: -------------------------------------------------------------------------------- 1 | // Brunch automatically concatenates all files in your 2 | // watched paths. Those paths can be configured at 3 | // config.paths.watched in "brunch-config.js". 4 | // 5 | // However, those files will only be executed if 6 | // explicitly imported. The only exception are files 7 | // in vendor, which are never wrapped in imports and 8 | // therefore are always executed. 9 | 10 | // Import dependencies 11 | // 12 | // If you no longer want to use a dependency, remember 13 | // to also remove its path from "config.paths.watched". 14 | import "phoenix_html" 15 | 16 | // Import local files 17 | // 18 | // Local files can be imported directly using relative 19 | // paths "./socket" or full ones "web/static/js/socket". 20 | 21 | // import socket from "./socket" 22 | 23 | import {Socket} from "phoenix"; 24 | import WebConsole from "./web_console"; 25 | 26 | let hash; 27 | let content; 28 | let webConsole; 29 | 30 | document.addEventListener("DOMContentLoaded", () => { 31 | webConsole = new WebConsole(document.getElementById('js-console')); 32 | let homePageElement = document.getElementById("create-new-page"); 33 | let showPageElement = document.getElementById("content-goes-here"); 34 | 35 | webConsole.log("Connecting to websocket."); 36 | let socket = new Socket("/ws"); 37 | socket.connect(); 38 | 39 | let chan = socket.channel("all", {}); 40 | chan.join().receive("ok", () => { webConsole.log("Connected!") }); 41 | 42 | if ( homePageElement ) { 43 | homePageElement.addEventListener("click", () => { 44 | content = document.getElementById("new-page-content").value; 45 | hash = SHA256(content); 46 | history.pushState({}, "Your Page", hash); 47 | document.getElementById("content-goes-here").innerHTML = content; 48 | haveContent(socket, hash, content); 49 | }); 50 | } else if ( showPageElement ) { 51 | wantContent(socket, window.location.pathname.substr(1), showPageElement); 52 | } 53 | }); 54 | 55 | function haveContent(socket, hash, content) { 56 | let counter = document.getElementById("visitor-count"); 57 | 58 | for( let i=0; i < socket.channels.length; i++ ) { 59 | if ( socket.channels[i].topic === 'have:' + hash ) { 60 | return; 61 | } 62 | } 63 | 64 | let chan = socket.channel("have:" + hash, {}); 65 | chan.on("content_request", function(_msg) { 66 | webConsole.log("Request received..."); 67 | chan.push("content", {content: content, hash: hash}); 68 | webConsole.log("Content sent!"); 69 | }); 70 | chan.on("visitors_count", function(msg) { 71 | counter.innerHTML = msg.count; 72 | }); 73 | 74 | chan.join().receive("ok", function(chan) { 75 | webConsole.log("Standing by... ready to share this content!") 76 | }); 77 | } 78 | 79 | function wantContent(socket, hash, elem) { 80 | let requestContentInterval; 81 | 82 | let chan = socket.channel("want:" + hash, {}); 83 | chan.on("content", function(msg) { 84 | clearInterval(requestContentInterval); 85 | webConsole.log(`Received content for hash ${hash}`); 86 | elem.innerHTML = msg.content; 87 | chan.leave(); 88 | haveContent(socket, hash, msg.content); 89 | }); 90 | 91 | chan.join().receive("ok", () => { 92 | webConsole.log(`Listening for content for hash ${hash}`); 93 | 94 | requestContentInterval = setInterval(() =>{ 95 | webConsole.log("Requesting content."); 96 | chan.push("content_request", {hash: hash}); 97 | }, 2000); 98 | }); 99 | } 100 | 101 | let App = { 102 | } 103 | 104 | export default App 105 | -------------------------------------------------------------------------------- /assets/js/web_console.js: -------------------------------------------------------------------------------- 1 | export default class WebConsole { 2 | // takes a