├── .crystal-version ├── sentry ├── src ├── bifrost │ └── version.cr ├── views │ ├── index.ecr │ └── test.ecr └── bifrost.cr ├── spec ├── spec_helper.cr └── bifrost_spec.cr ├── .travis.yml ├── shard.yml ├── shard.lock ├── app.json ├── .gitignore ├── LICENSE ├── dev ├── sentry_cli.cr └── sentry.cr └── README.md /.crystal-version: -------------------------------------------------------------------------------- 1 | 0.24.2 2 | -------------------------------------------------------------------------------- /sentry: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alternatelabs/bifrost/HEAD/sentry -------------------------------------------------------------------------------- /src/bifrost/version.cr: -------------------------------------------------------------------------------- 1 | module Bifrost 2 | VERSION = "0.1.0" 3 | end 4 | -------------------------------------------------------------------------------- /spec/spec_helper.cr: -------------------------------------------------------------------------------- 1 | ENV["KEMAL_ENV"] = "test" 2 | require "spec-kemal" 3 | require "json" 4 | require "jwt" 5 | require "../src/bifrost" 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: crystal 2 | env: 3 | global: 4 | - JWT_SECRET=ba285ec0135d950e39643e8cd2f086f81f41fc569fb299d00bdee5e125e98a3fb2940b5a7bc40e35a18ca52c9d8670cdab28ba3398db3fcedcd285d6236b7a36 5 | - KEMAL_ENV=test 6 | -------------------------------------------------------------------------------- /src/views/index.ecr: -------------------------------------------------------------------------------- 1 | 2 | 3 | Bifröst 4 | 5 | 6 | 7 | 8 |

Bifröst

9 | 10 |

Bifröst is an open source websocket server written in Crystal and powered by JWTs.

11 | 12 | 13 | -------------------------------------------------------------------------------- /shard.yml: -------------------------------------------------------------------------------- 1 | name: bifrost 2 | version: 0.1.0 3 | 4 | authors: 5 | - Pete Hawkins 6 | 7 | targets: 8 | bifrost: 9 | main: src/bifrost.cr 10 | 11 | dependencies: 12 | spec-kemal: 13 | github: kemalcr/spec-kemal 14 | branch: master 15 | kemal: 16 | github: kemalcr/kemal 17 | version: ~> 0.22.0 18 | jwt: 19 | github: crystal-community/jwt 20 | dotenv: 21 | github: gdotdesign/cr-dotenv 22 | 23 | crystal: 0.24.2 24 | 25 | license: MIT 26 | -------------------------------------------------------------------------------- /shard.lock: -------------------------------------------------------------------------------- 1 | version: 1.0 2 | shards: 3 | dotenv: 4 | github: gdotdesign/cr-dotenv 5 | version: 0.1.0 6 | 7 | jwt: 8 | github: crystal-community/jwt 9 | version: 0.2.2 10 | 11 | kemal: 12 | github: kemalcr/kemal 13 | version: 0.22.0 14 | 15 | kilt: 16 | github: jeromegn/kilt 17 | version: 0.4.0 18 | 19 | radix: 20 | github: luislavena/radix 21 | version: 0.3.8 22 | 23 | spec-kemal: 24 | github: kemalcr/spec-kemal 25 | commit: 93ab608228ea6619d15555d1c77a3bcff29f1dba 26 | 27 | -------------------------------------------------------------------------------- /src/views/test.ecr: -------------------------------------------------------------------------------- 1 | 2 | 3 | Bifröst test page 4 | 5 | 6 | 7 | 8 |
Connecting to websocket, open your console for more info
9 | 10 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Bifrost - Crystal websocket server", 3 | "description": "Simple and fast websocket service to broadcast realtime events powered by JWTs, written in Crystal", 4 | "repository": "https://github.com/alternatelabs/bifrost", 5 | "buildpacks": [ 6 | { 7 | "url": "https://github.com/crystal-lang/heroku-buildpack-crystal.git" 8 | } 9 | ], 10 | "env": { 11 | "JWT_SECRET": { 12 | "description": "A secret key for verifying the integrity of signed JWTs. Use the same key on your server side application.", 13 | "generator": "secret" 14 | }, 15 | "KEMAL_ENV": { 16 | "description": "Set the web server environment, setting this to anything other than production may have security consequences!", 17 | "value": "production" 18 | }, 19 | }, 20 | "keywords": ["crystal", "websocket", "realtime"] 21 | } 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.gitignore.io/api/macos,crystal 2 | 3 | ### Crystal ### 4 | /docs/ 5 | /lib/ 6 | /bin/ 7 | /.shards/ 8 | *.dwarf 9 | 10 | # Libraries don't need dependency lock 11 | # Dependencies will be locked in application that uses them 12 | #/shard.lock 13 | 14 | ### macOS ### 15 | *.DS_Store 16 | .AppleDouble 17 | .LSOverride 18 | 19 | # Icon must end with two \r 20 | Icon 21 | 22 | # Thumbnails 23 | ._* 24 | 25 | # Files that might appear in the root of a volume 26 | .DocumentRevisions-V100 27 | .fseventsd 28 | .Spotlight-V100 29 | .TemporaryItems 30 | .Trashes 31 | .VolumeIcon.icns 32 | .com.apple.timemachine.donotpresent 33 | 34 | # Directories potentially created on remote AFP share 35 | .AppleDB 36 | .AppleDesktop 37 | Network Trash Folder 38 | Temporary Items 39 | .apdisk 40 | 41 | 42 | # End of https://www.gitignore.io/api/macos,crystal 43 | 44 | /bifrost 45 | /.env 46 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 Alternate Labs Ltd 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 | -------------------------------------------------------------------------------- /spec/bifrost_spec.cr: -------------------------------------------------------------------------------- 1 | require "json" 2 | require "./spec_helper" 3 | 4 | describe Bifrost do 5 | describe "GET /" do 6 | # You can use get,post,put,patch,delete to call the corresponding route. 7 | it "renders" do 8 | get "/" 9 | response.body.should contain "Bifröst is an open source websocket server" 10 | end 11 | end 12 | 13 | describe "GET /info.json" do 14 | it "returns stats" do 15 | get "info.json" 16 | resp = JSON.parse(response.body) 17 | resp["stats"]["connected"].should eq 0 18 | resp["stats"]["deliveries"].should eq 0 19 | end 20 | end 21 | 22 | describe "POST /broadcast" do 23 | context "invalid JWT" do 24 | it "returns bad request" do 25 | payload = { 26 | exp: Time.now.epoch + 3600, # 1 hour 27 | } 28 | jwt = JWT.encode(payload, "bad-secret-key", "HS512") 29 | post "/broadcast", headers: HTTP::Headers{"Content-Type" => "application/json"}, body: {token: jwt}.to_json 30 | 31 | response.status_code.should eq 400 32 | json = JSON.parse(response.body) 33 | json["error"].should eq("Bad signature") 34 | end 35 | end 36 | 37 | context "expired JWT" do 38 | it "returns bad request" do 39 | payload = { 40 | exp: Time.now.epoch - 10, 41 | } 42 | jwt = JWT.encode(payload, ENV["JWT_SECRET"], "HS512") 43 | post "/broadcast", headers: HTTP::Headers{"Content-Type" => "application/json"}, body: {token: jwt}.to_json 44 | 45 | response.status_code.should eq 400 46 | json = JSON.parse(response.body) 47 | json["error"].should eq("Bad signature") 48 | end 49 | end 50 | 51 | context "valid JWT" do 52 | it "returns success" do 53 | payload = { 54 | exp: Time.now.epoch + 3600, 55 | channel: "user:12", 56 | message: {test: "test"}.to_json, 57 | } 58 | jwt = JWT.encode(payload, ENV["JWT_SECRET"], "HS512") 59 | post "/broadcast", headers: HTTP::Headers{"Content-Type" => "application/json"}, body: {token: jwt}.to_json 60 | 61 | response.status_code.should eq 200 62 | json = JSON.parse(response.body) 63 | json["message"].should eq("Success") 64 | end 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /dev/sentry_cli.cr: -------------------------------------------------------------------------------- 1 | require "option_parser" 2 | require "yaml" 3 | require "./sentry" 4 | 5 | process_name = nil 6 | 7 | begin 8 | shard_yml = YAML.parse File.read("shard.yml") 9 | name = shard_yml["name"]? 10 | process_name = name.as_s if name 11 | rescue e 12 | end 13 | 14 | build_args = [] of String 15 | build_command = "crystal build ./src/#{process_name}.cr" 16 | run_args = [] of String 17 | run_command = "./#{process_name}" 18 | files = ["./src/**/*.cr", "./src/**/*.ecr"] 19 | files_cleared = false 20 | show_help = false 21 | should_build = true 22 | 23 | OptionParser.parse! do |parser| 24 | parser.banner = "Usage: ./sentry [options]" 25 | parser.on( 26 | "-n NAME", 27 | "--name=NAME", 28 | "Sets the name of the app process (current name: #{process_name})") { |name| process_name = name } 29 | parser.on( 30 | "-b COMMAND", 31 | "--build=COMMAND", 32 | "Overrides the default build command") { |command| build_command = command } 33 | parser.on( 34 | "--build-args=ARGS", 35 | "Specifies arguments for the build command") do |args| 36 | args_arr = args.strip.split(" ") 37 | build_args = args_arr if args_arr.size > 0 38 | end 39 | parser.on( 40 | "--no-build", 41 | "Skips the build step") { should_build = false } 42 | parser.on( 43 | "-r COMMAND", 44 | "--run=COMMAND", 45 | "Overrides the default run command") { |command| run_command = command } 46 | parser.on( 47 | "--run-args=ARGS", 48 | "Specifies arguments for the run command") do |args| 49 | args_arr = args.strip.split(" ") 50 | run_args = args_arr if args_arr.size > 0 51 | end 52 | parser.on( 53 | "-w FILE", 54 | "--watch=FILE", 55 | "Overrides default files and appends to list of watched files") do |file| 56 | unless files_cleared 57 | files.clear 58 | files_cleared = true 59 | end 60 | files << file 61 | end 62 | parser.on( 63 | "-i", 64 | "--info", 65 | "Shows the values for build/run commands, build/run args, and watched files") do 66 | puts " 67 | name: #{process_name} 68 | build: #{build_command} 69 | build args: #{build_args} 70 | run: #{run_command} 71 | run args: #{run_args} 72 | files: #{files} 73 | " 74 | end 75 | parser.on( 76 | "-h", 77 | "--help", 78 | "Show this help") do 79 | puts parser 80 | exit 0 81 | end 82 | end 83 | 84 | if process_name 85 | process_runner = Sentry::ProcessRunner.new( 86 | process_name: process_name.as(String), 87 | build_command: build_command, 88 | run_command: run_command, 89 | build_args: build_args, 90 | run_args: run_args, 91 | should_build: should_build, 92 | files: files 93 | ) 94 | 95 | process_runner.run 96 | else 97 | puts "🤖 Sentry error: 'name' not given and not found in shard.yml" 98 | exit 1 99 | end 100 | -------------------------------------------------------------------------------- /dev/sentry.cr: -------------------------------------------------------------------------------- 1 | module Sentry 2 | FILE_TIMESTAMPS = {} of String => String # {file => timestamp} 3 | 4 | class ProcessRunner 5 | getter app_process : (Nil | Process) = nil 6 | property process_name : String 7 | property should_build = true 8 | property files = [] of String 9 | 10 | def initialize( 11 | @process_name : String, 12 | @build_command : String, 13 | @run_command : String, 14 | @build_args : Array(String) = [] of String, 15 | @run_args : Array(String) = [] of String, 16 | files = [] of String, 17 | should_build = true) 18 | @files = files 19 | @should_build = should_build 20 | @should_kill = false 21 | @app_built = false 22 | end 23 | 24 | private def build_app_process 25 | puts "🤖 compiling #{process_name}..." 26 | build_args = @build_args 27 | if build_args.size > 0 28 | Process.run(@build_command, build_args, shell: true, output: true, error: true) 29 | else 30 | Process.run(@build_command, shell: true, output: true, error: true) 31 | end 32 | end 33 | 34 | private def create_app_process 35 | app_process = @app_process 36 | if app_process.is_a? Process 37 | unless app_process.terminated? 38 | puts "🤖 killing #{process_name}..." 39 | app_process.kill 40 | end 41 | end 42 | 43 | puts "🤖 starting #{process_name}..." 44 | run_args = @run_args 45 | if run_args.size > 0 46 | @app_process = Process.new(@run_command, run_args, output: true, error: true) 47 | else 48 | @app_process = Process.new(@run_command, output: true, error: true) 49 | end 50 | end 51 | 52 | private def get_timestamp(file : String) 53 | File.stat(file).mtime.to_s("%Y%m%d%H%M%S") 54 | end 55 | 56 | # Compiles and starts the application 57 | # 58 | def start_app 59 | return create_app_process unless @should_build 60 | build_result = build_app_process() 61 | if build_result && build_result.success? 62 | @app_built = true 63 | create_app_process() 64 | elsif !@app_built # if build fails on first time compiling, then exit 65 | puts "🤖 Compile time errors detected. SentryBot shutting down..." 66 | exit 1 67 | end 68 | end 69 | 70 | # Scans all of the `@files` 71 | # 72 | def scan_files 73 | file_changed = false 74 | app_process = @app_process 75 | files = @files 76 | Dir.glob(files) do |file| 77 | timestamp = get_timestamp(file) 78 | if FILE_TIMESTAMPS[file]? && FILE_TIMESTAMPS[file] != timestamp 79 | FILE_TIMESTAMPS[file] = timestamp 80 | file_changed = true 81 | puts "🤖 #{file}" 82 | elsif FILE_TIMESTAMPS[file]?.nil? 83 | puts "🤖 watching file: #{file}" 84 | FILE_TIMESTAMPS[file] = timestamp 85 | file_changed = true if (app_process && !app_process.terminated?) 86 | end 87 | end 88 | 89 | start_app() if (file_changed || app_process.nil?) 90 | end 91 | 92 | def run 93 | puts "🤖 Your SentryBot is vigilant. beep-boop..." 94 | 95 | loop do 96 | if @should_kill 97 | puts "🤖 Powering down your SentryBot..." 98 | break 99 | end 100 | scan_files 101 | sleep 1 102 | end 103 | end 104 | 105 | def kill 106 | @should_kill = true 107 | end 108 | end 109 | end 110 | -------------------------------------------------------------------------------- /src/bifrost.cr: -------------------------------------------------------------------------------- 1 | require "kemal" 2 | require "http/web_socket" 3 | require "json" 4 | require "colorize" 5 | require "dotenv" 6 | require "jwt" 7 | require "./bifrost/*" 8 | 9 | Dotenv.load unless Kemal.config.env == "production" 10 | 11 | SOCKETS = {} of String => Set(HTTP::WebSocket) 12 | STATS = {} of String => Int32 13 | STATS["deliveries"] = 0 14 | 15 | module Bifrost 16 | get "/" do |env| 17 | env.response.content_type = "text/html" 18 | render "src/views/index.ecr" 19 | end 20 | 21 | get "/test" do |env| 22 | if Kemal.config.env == "development" 23 | env.response.content_type = "text/html" 24 | allowed_channels = {channels: ["user:1"]} 25 | test_token = JWT.encode(allowed_channels, ENV["JWT_SECRET"], "HS512") 26 | render "src/views/test.ecr" 27 | else 28 | render_404 29 | end 30 | end 31 | 32 | get "/info.json" do |env| 33 | connected_clients = 0 34 | SOCKETS.each { |k, v| connected_clients += v.size } 35 | env.response.content_type = "application/json" 36 | new_stats = STATS.merge({"connected" => connected_clients}) 37 | {stats: new_stats}.to_json 38 | end 39 | 40 | get "/ping" do |env| 41 | begin 42 | message = env.params.query["message"].as(String) 43 | channel = env.params.query["channel"].as(String) 44 | 45 | SOCKETS[channel].each do |socket| 46 | socket.send({event: "ping", data: message}.to_json) 47 | STATS["deliveries"] += 1 48 | end 49 | rescue KeyError 50 | puts "Key error!".colorize(:red) 51 | end 52 | end 53 | 54 | post "/broadcast" do |env| 55 | env.response.content_type = "application/json" 56 | 57 | begin 58 | token = env.params.json["token"].as(String) 59 | payload = self.decode_jwt(token) 60 | 61 | channel = payload["channel"].as(String) 62 | deliveries = 0 63 | 64 | if SOCKETS.has_key?(channel) 65 | SOCKETS[channel].each do |socket| 66 | socket.send(payload["message"].as(Hash).to_json) 67 | deliveries += 1 68 | end 69 | end 70 | 71 | STATS["deliveries"] += deliveries 72 | 73 | {message: "Success", deliveries: deliveries}.to_json 74 | rescue JWT::DecodeError 75 | env.response.status_code = 400 76 | {error: "Bad signature"}.to_json 77 | end 78 | end 79 | 80 | ws "/subscribe" do |socket| 81 | puts "Socket connected".colorize(:green) 82 | ponged = true 83 | 84 | socket.on_message do |message| 85 | puts message.colorize(:blue) 86 | 87 | data = JSON.parse(message) 88 | 89 | # If message is authing, store in hash 90 | if data["event"].to_s == "authenticate" 91 | token = data["data"].to_s 92 | 93 | begin 94 | payload = self.decode_jwt(token) 95 | payload["channels"].as(Array).each do |channel| 96 | channel = channel.as(String) 97 | if SOCKETS.has_key?(channel) 98 | SOCKETS[channel] << socket 99 | else 100 | SOCKETS[channel] = Set{socket} 101 | end 102 | 103 | socket.send({event: "subscribed", data: {channel: channel}.to_json}.to_json) 104 | STATS["deliveries"] += 1 105 | end 106 | rescue JWT::DecodeError 107 | socket.close("Not Authorized!") 108 | end 109 | end 110 | end 111 | 112 | socket.on_pong do 113 | ponged = true 114 | end 115 | 116 | # Remove clients from the list when it's closed 117 | socket.on_close do 118 | SOCKETS.each do |channel, set| 119 | if set.includes?(socket) 120 | set.delete(socket) 121 | 122 | puts "Socket disconnected from #{channel}!".colorize(:yellow) 123 | end 124 | end 125 | end 126 | 127 | # Ping sockets every 15 seconds to keep them alive 128 | spawn do 129 | loop do 130 | sleep 15 131 | 132 | if ponged 133 | puts "Socket ponged, pinging again!" 134 | ponged = false 135 | else 136 | puts "Socket didn't respond to ping, disconnecting!".colorize(:red) 137 | socket.close("Socket didn't respond to ping") 138 | break 139 | end 140 | 141 | begin 142 | socket.ping 143 | rescue IO::Error 144 | puts "Socket closed, stopping ping timer".colorize(:yellow) 145 | break 146 | end 147 | end 148 | end 149 | end 150 | 151 | def self.decode_jwt(token : String) 152 | payload, header = JWT.decode(token, ENV["JWT_SECRET"], "HS512") 153 | payload 154 | end 155 | end 156 | 157 | Kemal.run 158 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Bifröst 2 | 3 | Bifröst is a standalone websocket server written in Crystal. It’s easy to use and works with any server side language that can use [JSON Web Tokens](https://jwt.io/). 4 | 5 | [![Build Status](https://travis-ci.org/alternatelabs/bifrost.svg?branch=master)](https://travis-ci.org/alternatelabs/bifrost) 6 | 7 | ## Why use Bifröst? 8 | 9 | Tools like [ActionCable](https://github.com/rails/rails/tree/master/actioncable) seamlessly integrate websockets into your web framework but can put stress on your web servers and consume a lot of memory, making your application harder to deploy and scale. 10 | 11 | Crystal delivers blazing fast performance with minimal memory footprint so Bifröst can handle thousands of websocket connections on a tiny VPS or hobby dyno on heroku. 12 | 13 | ## Quickstart 14 | 15 | Get started by deploying this service to heroku. 16 | 17 | [![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy) 18 | 19 | ## Usage 20 | 21 | Bifrost is powered by [JWTs](https://jwt.io/), you can use the JWT library for the language of your choice, the examples will be in [Ruby](https://github.com/jwt/ruby-jwt). 22 | 23 | **Make sure your server side JWT secret is shared with your bifrost server to validate JWTs.** 24 | 25 | ### 1. Create an API endpoint in your application that can give users a realtime token 26 | 27 | *If you use Ruby we have a [bifrost-client gem](https://github.com/alternatelabs/bifrost-ruby-client) available to help simplify things.* 28 | 29 | Create a JWT that can be sent to the client side for your end user to connect to the websocket with. This should list all of the channels that user is allowed to subscribe to. 30 | 31 | ```ruby 32 | get "/api/bifrost-token" do 33 | authenticate_user! 34 | payload = { channels: ["user:#{current_user.id}", "global"] } 35 | jwt = JWT.encode(payload, ENV["JWT_SECRET"], "HS512") 36 | { token: jwt }.to_json 37 | end 38 | ``` 39 | 40 | ### 2. Subscribe clients to channels 41 | 42 | On the client side open up a websocket and send an authentication message with the generated JWT, this will subscribe the user to the allowed channels. 43 | 44 | ```js 45 | // Recommend using ReconnectingWebSocket to automatically reconnect websockets if you deploy the server or have any network disconnections 46 | import ReconnectingWebSocket from "reconnectingwebsocket"; 47 | 48 | let ws = new ReconnectingWebSocket(`${process.env.BIFROST_WSS_URL}/subscribe`); // URL your bifrost server is running on 49 | 50 | // Step 1 51 | // ====== 52 | // When you first open the websocket the goal is to request a signed realtime 53 | // token from your server side application and then authenticate with bifrost, 54 | // subscribing your user to the channels your server side app allows them to 55 | // connect to 56 | ws.onopen = function() { 57 | axios.get("/api/bifrost-token").then((resp) => { 58 | const jwtToken = resp.data.token; 59 | const msg = { 60 | event: "authenticate", 61 | data: jwtToken, // Your server generated token with allowed channels 62 | }; 63 | ws.send(JSON.stringify(msg)); 64 | 65 | console.log("WS Connected"); 66 | }); 67 | }; 68 | 69 | // Step 2 70 | // ====== 71 | // Upon receiving a message you can check the event name and ignore subscribed 72 | // and pong events, everything else will be an event sent by your server side 73 | // app. 74 | ws.onmessage = function(event) { 75 | const msg = JSON.parse(event.data); 76 | 77 | switch (msg.event) { 78 | case "subscribed": { 79 | const channelName = JSON.parse(msg.data).channel; 80 | console.log(`Subscribed to channel ${channelName}`); 81 | break; 82 | } 83 | default: { 84 | // Note: 85 | // We advise you broadcast messages with a data key 86 | const eventData = JSON.parse(msg.data); 87 | console.log(`Bifrost msg: ${msg.event}`, eventData); 88 | 89 | if (msg.event === "new_item") { 90 | console.log("new item!", eventData); 91 | } 92 | } 93 | } 94 | }; 95 | 96 | // Step 3 97 | // ====== 98 | // Do some cleanup when the socket closes 99 | ws.onclose = function(event) { 100 | console.error("WS Closed", event); 101 | }; 102 | ``` 103 | 104 | ### 3. Broadcast messages from the server 105 | 106 | Generate a token and send it to bifrost 107 | 108 | ```ruby 109 | data = { 110 | channel: "user:1", # Channel to broadcast to 111 | message: { 112 | event: "new_item", 113 | data: JSON.dump(item) 114 | }, 115 | exp: Time.zone.now.to_i + 1.hour 116 | } 117 | jwt = JWT.encode(data, ENV["JWT_SECRET"], "HS512") 118 | url = ENV.fetch("BIFROST_URL") 119 | url += "/broadcast" 120 | 121 | req = HTTP.post(url, json: { token: jwt }) 122 | 123 | if req.status > 206 124 | raise "Error communicating with Bifrost server on URL: #{url}" 125 | end 126 | ``` 127 | 128 | ### You're done 🚀 129 | 130 | That's all you need to start broadcasting realtime events directly to clients in an authenticated manner. Despite the name, there is no planned support for bi-directional communication, it adds a lot of complications and for most apps it's simply not necessary. 131 | 132 | #### Ping Pong 🏓 133 | 134 | Bifröst server will send a ping to each socket every 15 seconds, if a pong hasn't been received after a further 15 seconds the socket will be closed. 135 | 136 | #### `GET /info.json` 137 | 138 | An endpoint that returns basic stats. As all sockets are persisted in memory if you restart the server or deploy an update the stats will reset. 139 | 140 | ```json 141 | { 142 | "stats":{ 143 | "deliveries": 117, 144 | "connected": 21 145 | } 146 | } 147 | ``` 148 | 149 | ## Contributing 150 | 151 | These instructions will get you a copy of the project up and running on your local machine for development and testing purposes. See deployment for notes on how to deploy the project on a live system. 152 | 153 | ### Prerequisites 154 | 155 | You need to have [crystal lang](https://crystal-lang.org/) installed 156 | 157 | ``` 158 | brew install crystal-lang 159 | ``` 160 | 161 | ### Running locally 162 | 163 | Create a `.env` file in the root of this repository with the following environment variables, or set the variables if deploying to heroku. 164 | 165 | ``` 166 | JWT_SECRET=[> 64 character string] 167 | ``` 168 | 169 | [Sentry](https://github.com/samueleaton/sentry) is used to run the app and recompile when files change 170 | 171 | ``` 172 | ./sentry 173 | ``` 174 | 175 | ## Running the tests 176 | 177 | ``` 178 | crystal spec 179 | ``` 180 | 181 | ## Built With 182 | 183 | * [Crystal lang](https://crystal-lang.org/) 184 | * [Kemal](https://github.com/kemalcr/kemal) - Web microframework for crystal 185 | 186 | ## Versioning 187 | 188 | We use [SemVer](http://semver.org/) for versioning. For the versions available, see the [tags on this repository](https://github.com/your/project/tags). 189 | 190 | ## Authors 191 | 192 | * **Pete Hawkins** - [phawk](https://github.com/phawk) 193 | 194 | See also the list of [contributors](https://github.com/alternatelabs/crystal-realtime/contributors) who participated in this project. 195 | 196 | ## License 197 | 198 | This project is licensed under the MIT License - see the [LICENSE.md](LICENSE.md) file for details 199 | --------------------------------------------------------------------------------