├── .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 | [](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 | [](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 |
--------------------------------------------------------------------------------