├── test ├── test_helper.exs └── sse_phoenix_pubsub_test.exs ├── .formatter.exs ├── .gitignore ├── mix.exs ├── lib ├── sse_phoenix_pubsub │ ├── config.ex │ ├── chunk.ex │ └── server.ex └── sse_phoenix_pubsub.ex ├── mix.lock └── README.md /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /test/sse_phoenix_pubsub_test.exs: -------------------------------------------------------------------------------- 1 | defmodule SsePhoenixPubsubTest do 2 | use ExUnit.Case 3 | doctest SsePhoenixPubsub 4 | 5 | test "greets the world" do 6 | assert SsePhoenixPubsub.hello() == :world 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | sse_phoenix_pubsub-*.tar 24 | 25 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule SsePhoenixPubsub.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :sse_phoenix_pubsub, 7 | version: "1.0.2", 8 | elixir: "~> 1.7", 9 | start_permanent: Mix.env() == :prod, 10 | description: description(), 11 | package: package(), 12 | deps: deps() 13 | ] 14 | end 15 | 16 | defp description do 17 | """ 18 | Server Sent Events on top of Phoenix PubSub 19 | """ 20 | end 21 | 22 | defp package do 23 | [ 24 | name: :sse_phoenix_pubsub, 25 | maintainers: ["Vlad Jebelev"], 26 | licenses: ["MIT"], 27 | links: %{"GitHub" => "https://github.com/vjebelev/sse_phoenix_pubsub"} 28 | ] 29 | end 30 | 31 | # Run "mix help compile.app" to learn about applications. 32 | def application do 33 | [ 34 | extra_applications: [:logger] 35 | ] 36 | end 37 | 38 | # Run "mix help deps" to learn about dependencies. 39 | defp deps do 40 | [ 41 | {:plug, ">= 1.4.5"}, 42 | {:phoenix_pubsub, "~> 2.0"}, 43 | {:jason, "~> 1.0"}, 44 | {:ex_doc, ">= 0.0.0", only: :dev, runtime: false} 45 | ] 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/sse_phoenix_pubsub/config.ex: -------------------------------------------------------------------------------- 1 | defmodule SsePhoenixPubsub.Config do 2 | @moduledoc """ 3 | 4 | Configuration for `sse_phoenix_pubsub`: 5 | * `:keep_alive` - Optional. Keep-alive interval to send a ping to the client. 6 | Default is `20_000` (milliseconds). 7 | * `:retry` - Optional. The reconnection time to use when attempting to send the event. 8 | Default is `2_000` (milliseconds). 9 | 10 | ### Example 11 | Configured defaults with override from environment variables, if present: 12 | 13 | config :sse_phoenix_pubsub, 14 | retry: {:system, "SSE_RETRY_IN_MS", 2_000}, 15 | keep_alive: {:system, "SSE_KEEP_ALIVE_IN_MS", 20_000} 16 | 17 | """ 18 | 19 | @app :sse_phoenix_pubsub 20 | 21 | @doc """ 22 | Keep alive 23 | """ 24 | @spec keep_alive() :: integer() 25 | def keep_alive do 26 | @app 27 | |> Application.get_env(:keep_alive, 20_000) 28 | |> get_env_var() 29 | |> to_integer() 30 | end 31 | 32 | @spec retry() :: integer() 33 | def retry do 34 | @app 35 | |> Application.get_env(:retry, 2_000) 36 | |> get_env_var() 37 | |> to_integer() 38 | end 39 | 40 | defp get_env_var({:system, name, default}) do 41 | System.get_env(name) || default 42 | end 43 | 44 | defp get_env_var(val) do 45 | val 46 | end 47 | 48 | defp to_integer(val) when is_integer(val) do 49 | val 50 | end 51 | 52 | defp to_integer(val) do 53 | String.to_integer(val) 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/sse_phoenix_pubsub.ex: -------------------------------------------------------------------------------- 1 | defmodule SsePhoenixPubsub do 2 | @moduledoc """ 3 | Server-Sent Events on top of `Phoenix PubSub`. 4 | 5 | ### Installation 6 | 7 | Add to dependencies in mix.exs: 8 | ```elixir 9 | {:sse_phoenix_pubsub, "~> 1.0"} 10 | ``` 11 | 12 | ### Sending SSE 13 | 14 | Start the PubSub system in `application.ex`: 15 | 16 | ```elixir 17 | {Phoenix.PubSub, name: MyApp.PubSub} 18 | ``` 19 | 20 | Publish messages via `Phoenix.PubSub.broadcast` method on a selected topic: 21 | 22 | ```elixir 23 | Phoenix.PubSub.broadcast(MyApp.PubSub, "time", {MyApp.PubSub, "01:34:55.123567"}) 24 | ``` 25 | 26 | ### Receiving SSE 27 | 28 | Configure http endpoint for SSE with `idle_timeout` option to keep 29 | the SSE connection running: 30 | 31 | ```elixir 32 | config :my_app, MyAppWeb.Endpoint, 33 | http: [ 34 | port: 4000, 35 | protocol_options: [ 36 | idle_timeout: 3_600_000 37 | ] 38 | ] 39 | ``` 40 | 41 | Configure phoenix or plug routing: 42 | 43 | ```elixir 44 | pipeline :sse do 45 | plug :put_format, "text/event-stream" 46 | plug :fetch_session 47 | end 48 | 49 | scope "/sse", MyAppWeb do 50 | pipe_through :sse 51 | 52 | get "/", SseController, :subscribe 53 | end 54 | ``` 55 | 56 | Create a Phoenix controller for subscribing http clients to desired topics: 57 | 58 | ```elixir 59 | defmodule MyAppWeb.SseController do 60 | use MyAppWeb, :controller 61 | 62 | def subscribe(conn, params) do 63 | case get_topics(params) do 64 | topics when is_list(topics) -> 65 | SsePhoenixPubsub.stream(conn, {MyApp.PubSub, topics}) 66 | _ -> 67 | Logger.error("No topics provided") 68 | end 69 | end 70 | 71 | defp get_topics(params) do 72 | case params["topics"] do 73 | str when is_binary(str) -> String.split(str, ",") 74 | nil -> [] 75 | end 76 | end 77 | end 78 | ``` 79 | 80 | """ 81 | 82 | defdelegate stream(conn, pubsub_info, data \\ []), 83 | to: SsePhoenixPubsub.Server, 84 | as: :stream 85 | end 86 | -------------------------------------------------------------------------------- /lib/sse_phoenix_pubsub/chunk.ex: -------------------------------------------------------------------------------- 1 | defmodule SsePhoenixPubsub.Chunk do 2 | @moduledoc """ 3 | Structure and type for Chunk model 4 | """ 5 | 6 | @enforce_keys [:data] 7 | 8 | defstruct [:comment, :event, :data, :id, :retry] 9 | 10 | @typedoc """ 11 | Defines the Chunk struct. 12 | Reference: https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#Fields 13 | 14 | * :comment - The comment line can be used to prevent connections from timing 15 | out; a server can send a comment periodically to keep the connection alive. 16 | SSE package keeps connection alive, so you don't have to send the comment. 17 | * :data - The data field for the message. When the EventSource receives 18 | multiple consecutive lines that begin with data:, it will concatenate them, 19 | inserting a newline character between each one. Trailing newlines are 20 | removed. 21 | * :event - A string identifying the type of event described. If this is 22 | specified, an event will be dispatched on the browser to the listener for 23 | the specified event name; the web site source code should use 24 | addEventListener() to listen for named events. The onmessage handler is 25 | called if no event name is specified for a message. 26 | * :id - The event ID to set the EventSource object's last event ID value. 27 | * :retry - The reconnection time to use when attempting to send the event. 28 | This must be an integer, specifying the reconnection time in milliseconds. 29 | If a non-integer value is specified the field is ignored. 30 | """ 31 | @type t :: %__MODULE__{ 32 | comment: String.t() | nil, 33 | data: list(String.t()), 34 | event: String.t() | nil, 35 | id: String.t() | nil, 36 | retry: integer() | nil 37 | } 38 | 39 | @spec build(t()) :: String.t() 40 | def build(%__MODULE__{ 41 | comment: comment, 42 | data: data, 43 | event: event, 44 | id: id, 45 | retry: retry 46 | }) do 47 | build_field("", comment) <> 48 | build_field("id", id) <> 49 | build_field("event", event) <> 50 | build_data(data) <> 51 | build_field("retry", retry) <> "\n" 52 | end 53 | 54 | @spec build_data(nil) :: no_return() 55 | defp build_data(nil) do 56 | raise("Chunk data can't be blank!") 57 | end 58 | 59 | @spec build_data(list(String.t())) :: String.t() 60 | defp build_data(data_list) when is_list(data_list) do 61 | Enum.reduce(data_list, "", fn data, acc -> 62 | acc <> "data: #{data}\n" 63 | end) 64 | end 65 | 66 | @spec build_data(String.t()) :: String.t() 67 | defp build_data(data) when is_binary(data) do 68 | "data: #{data}\n" 69 | end 70 | 71 | @spec build_field(String.t(), nil) :: String.t() 72 | defp build_field(_, nil) do 73 | "" 74 | end 75 | 76 | @spec build_field(String.t(), String.t() | integer()) :: String.t() 77 | defp build_field(field, value) do 78 | "#{field}: #{value}\n" 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "earmark": {:hex, :earmark, "1.4.3", "364ca2e9710f6bff494117dbbd53880d84bebb692dafc3a78eb50aa3183f2bfd", [:mix], [], "hexpm", "8cf8a291ebf1c7b9539e3cddb19e9cef066c2441b1640f13c34c1d3cfc825fec"}, 3 | "event_bus": {:hex, :event_bus, "1.6.1", "07331328b67ccc76d14a12872013464106390abaa47ea0d6a7755e3524899964", [:mix], [], "hexpm", "450b73213a8056c14710d8f0047aefc70f3bbef5a1a7847bb9ceda6fbcdbd42a"}, 4 | "ex_doc": {:hex, :ex_doc, "0.21.3", "857ec876b35a587c5d9148a2512e952e24c24345552259464b98bfbb883c7b42", [:mix], [{:earmark, "~> 1.4", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "0db1ee8d1547ab4877c5b5dffc6604ef9454e189928d5ba8967d4a58a801f161"}, 5 | "jason": {:hex, :jason, "1.2.0", "10043418c42d2493d0ee212d3fddd25d7ffe484380afad769a0a38795938e448", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "116747dbe057794c3a3e4e143b7c8390b29f634e16c78a7f59ba75bfa6852e7f"}, 6 | "makeup": {:hex, :makeup, "1.0.1", "82f332e461dc6c79dbd82fbe2a9c10d48ed07146f0a478286e590c83c52010b5", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "49736fe5b66a08d8575bf5321d716bac5da20c8e6b97714fec2bcd6febcfa1f8"}, 7 | "makeup_elixir": {:hex, :makeup_elixir, "0.14.0", "cf8b7c66ad1cff4c14679698d532f0b5d45a3968ffbcbfd590339cb57742f1ae", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "d4b316c7222a85bbaa2fd7c6e90e37e953257ad196dc229505137c5e505e9eff"}, 8 | "mime": {:hex, :mime, "1.3.1", "30ce04ab3175b6ad0bdce0035cba77bba68b813d523d1aac73d9781b4d193cf8", [:mix], [], "hexpm", "6cbe761d6a0ca5a31a0931bf4c63204bceb64538e664a8ecf784a9a6f3b875f1"}, 9 | "nimble_parsec": {:hex, :nimble_parsec, "0.5.3", "def21c10a9ed70ce22754fdeea0810dafd53c2db3219a0cd54cf5526377af1c6", [:mix], [], "hexpm", "589b5af56f4afca65217a1f3eb3fee7e79b09c40c742fddc1c312b3ac0b3399f"}, 10 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.0.0", "a1ae76717bb168cdeb10ec9d92d1480fec99e3080f011402c0a2d68d47395ffb", [:mix], [], "hexpm", "c52d948c4f261577b9c6fa804be91884b381a7f8f18450c5045975435350f771"}, 11 | "plug": {:hex, :plug, "1.10.0", "6508295cbeb4c654860845fb95260737e4a8838d34d115ad76cd487584e2fc4d", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "422a9727e667be1bf5ab1de03be6fa0ad67b775b2d84ed908f3264415ef29d4a"}, 12 | "plug_crypto": {:hex, :plug_crypto, "1.1.2", "bdd187572cc26dbd95b87136290425f2b580a116d3fb1f564216918c9730d227", [:mix], [], "hexpm", "6b8b608f895b6ffcfad49c37c7883e8df98ae19c6a28113b02aa1e9c5b22d6b5"}, 13 | "sse": {:hex, :sse, "0.4.0", "f17affacbc4618bac07590eec7bff849aa27d1f71bb3d41da3fd3cb255d16910", [:mix], [{:event_bus, ">= 1.6.0", [hex: :event_bus, repo: "hexpm", optional: false]}, {:plug, ">= 1.4.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "2dfb9923725b9d5292763c3de9b7798713f5771522823e961a250204917d7efb"}, 14 | } 15 | -------------------------------------------------------------------------------- /lib/sse_phoenix_pubsub/server.ex: -------------------------------------------------------------------------------- 1 | defmodule SsePhoenixPubsub.Server do 2 | @moduledoc """ 3 | Server for streaming Server-Sent Events to http clients. 4 | 5 | """ 6 | 7 | require Logger 8 | 9 | alias Plug.Conn 10 | alias Phoenix.PubSub 11 | alias SsePhoenixPubsub.{Chunk, Config} 12 | 13 | @type chunk :: Chunk.t() 14 | @type chunk_conn :: {:ok, conn()} | {:error, term()} 15 | @type chunk_data :: list(String.t()) 16 | @type conn :: Conn.t() 17 | @type topic :: String.t() 18 | @type topics :: list(topic()) 19 | @type pubsub_info :: {atom(), topics()} 20 | 21 | @doc """ 22 | Stream SSE events. 23 | 24 | SsePhoenixPubsub.stream(conn, {MyApp.PubSub, ["time"]}) 25 | """ 26 | @spec stream(conn(), pubsub_info(), chunk_data()) :: conn() 27 | def stream(conn, pubsub_info, data) do 28 | chunk = %Chunk{data: data, retry: Config.retry()} 29 | {:ok, conn} = init_sse(conn, chunk) 30 | subscribe_sse(pubsub_info) 31 | 32 | reset_timeout() 33 | Process.flag(:trap_exit, true) 34 | listen_sse(conn, pubsub_info) 35 | end 36 | 37 | # Init SSE connection 38 | @spec init_sse(conn(), chunk()) :: chunk_conn() 39 | defp init_sse(conn, chunk) do 40 | Logger.debug(fn -> "SSE connection (#{inspect(self())}) opened!" end) 41 | 42 | conn 43 | |> Conn.put_resp_header("cache-control", "no-cache") 44 | |> Conn.put_resp_content_type("text/event-stream") 45 | |> Conn.send_chunked(200) 46 | |> Conn.chunk(Chunk.build(chunk)) 47 | end 48 | 49 | # Subscribe to pubsub topics 50 | defp subscribe_sse({pubsub_name, topics}) do 51 | for c <- topics do 52 | Logger.debug(fn -> "Subscribing #{inspect(self())} to topic #{c}" end) 53 | PubSub.subscribe(pubsub_name, c) 54 | end 55 | end 56 | 57 | # Unsubscribe from pubsub topics 58 | defp unsubscribe_sse({pubsub_name, topics}) do 59 | for c <- topics do 60 | Logger.debug(fn -> "Unsubscribing #{inspect(self())} from topic #{c}" end) 61 | PubSub.unsubscribe(pubsub_name, c) 62 | end 63 | end 64 | 65 | # Send SSE chunk 66 | defp send_sse(conn, pubsub_info, chunk) do 67 | case Conn.chunk(conn, Chunk.build(chunk)) do 68 | {:ok, conn} -> 69 | reset_timeout() 70 | listen_sse(conn, pubsub_info) 71 | 72 | {:error, _reason} -> 73 | unsubscribe_sse(pubsub_info) 74 | conn 75 | end 76 | end 77 | 78 | # Listen for Pubsub events (Phoenix Pubsub broadcasts) 79 | defp listen_sse(conn, {pubsub_name, _topics} = pubsub_info) do 80 | receive do 81 | {^pubsub_name, data} -> 82 | chunk = %Chunk{data: data} 83 | send_sse(conn, pubsub_info, chunk) 84 | 85 | {:send_idle} -> 86 | send_sse(conn, pubsub_info, keep_alive_chunk()) 87 | 88 | {:close} -> 89 | unsubscribe_sse(pubsub_info) 90 | 91 | {:EXIT, _from, _reason} -> 92 | unsubscribe_sse(pubsub_info) 93 | Process.exit(self(), :normal) 94 | 95 | _ -> 96 | listen_sse(conn, pubsub_info) 97 | end 98 | end 99 | 100 | @spec reset_timeout() :: :ok 101 | defp reset_timeout do 102 | new_ref = Process.send_after(self(), {:send_idle}, Config.keep_alive()) 103 | old_ref = Process.put(:timer_ref, new_ref) 104 | unless is_nil(old_ref), do: Process.cancel_timer(old_ref) 105 | :ok 106 | end 107 | 108 | @spec keep_alive_chunk() :: chunk() 109 | defp keep_alive_chunk do 110 | %Chunk{comment: "ping", data: []} 111 | end 112 | end 113 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SsePhoenixPubsub 2 | 3 | I needed a way to stream to event source events and at first tried to use the sse + event_bus package by Mustafa Turan (https://github.com/mustafaturan/sse) but it turned out to not be a good fit for my project (main reason being that we have to deal with a large number of dynamically generated topics and event bus uses atoms for channel names, a limited number of which is available). I then looked at the Phoenix PubSub which turned out to be a great fit as it's already used internally by Phoenix for streaming to websockets. So this package is a product of hacking Mustafa's project and replacing event bus with Phoenix PubSub. 4 | 5 | ## Installation 6 | 7 | Detailed instructions can be found in [my blog post](http://blog.jebelev.com/posts/phoenix-pubsub-sse/). 8 | 9 | ### Sending Server-Sent Events 10 | 11 | Add `sse_phoenix_pubsub` to your list of dependencies in `mix.exs`: 12 | 13 | ```elixir 14 | def deps do 15 | [ 16 | {:sse_phoenix_pubsub, "~> 1.0"} 17 | ] 18 | end 19 | ``` 20 | 21 | Broadcast your events via Phoenix Pubsub, e.g: 22 | ```elixir 23 | Phoenix.PubSub.broadcast(SseDemo.PubSub, "time", {SseDemo.PubSub, "02:29:54.360596"}) 24 | ``` 25 | `SseDemo.PubSub` is the name of the pubsub system from `application.ex`, `"time"` is a topic name, "02:29:54.360596" is the message being sent - has to be a string. 26 | 27 | An example of a GenServer-based event generator: 28 | ```elixir 29 | defmodule SseDemo.TimeEventsGenerator do 30 | use GenServer 31 | require Logger 32 | 33 | alias Phoenix.PubSub 34 | 35 | @default_interval 1_000 36 | 37 | def start_link(opts) do 38 | pubsub_name = Keyword.fetch!(opts, :pubsub_name) 39 | topic_name = Keyword.fetch!(opts, :topic_name) 40 | interval = Keyword.get(opts, :interval, @default_interval) 41 | GenServer.start_link(__MODULE__, {pubsub_name, topic_name, interval}) 42 | end 43 | 44 | def init({pubsub_name, topic_name, interval}) do 45 | Process.send_after(self(), :send_time_event, interval) 46 | {:ok, %{pubsub_name: pubsub_name, topic_name: topic_name, interval: interval, last_run_at: nil}} 47 | end 48 | 49 | def handle_info(:send_time_event, %{pubsub_name: pubsub_name, topic_name: topic_name, interval: interval} = state) do 50 | message = Time.utc_now() |> Time.to_string 51 | PubSub.broadcast(pubsub_name, topic_name, {pubsub_name, message}) 52 | Logger.debug(fn -> "Broadcast to topic #{topic_name}, message: #{message}" end) 53 | 54 | Process.send_after(self(), :send_time_event, interval) 55 | {:noreply, %{state | last_run_at: :calendar.local_time()}} 56 | end 57 | end 58 | ``` 59 | 60 | To start, add it to specs in `application.ex`: 61 | ```elixir 62 | {SseDemo.TimeEventsGenerator, [pubsub_name: SseDemo.PubSub, topic_name: "time"]} 63 | ``` 64 | 65 | ### Receiving Server-Sent Events 66 | 67 | Configure SSE's http endpoint with high idle timeout: 68 | 69 | ```elixir 70 | http: [ 71 | port: 4000, 72 | protocol_options: [ 73 | idle_timeout: 3_600_000 74 | ] 75 | ], 76 | 77 | ``` 78 | 79 | Setup a controller for SSE subscriptions and subscribe clients to selected topics: 80 | ```elixir 81 | defmodule SseDemoWeb.SseController do 82 | use SseDemoWeb, :controller 83 | require Logger 84 | 85 | def subscribe(conn, params) do 86 | case get_topics(params) do 87 | topics when is_list(topics) -> 88 | Logger.debug(fn -> "Subscribed to topics #{inspect(topics)}" end) 89 | SsePhoenixPubsub.stream(conn, {SseDemo.PubSub, topics}) 90 | _ -> 91 | Logger.error("No topics provided") 92 | end 93 | end 94 | 95 | defp get_topics(params) do 96 | case params["topics"] do 97 | str when is_binary(str) -> String.split(str, ",") 98 | nil -> [] 99 | end 100 | end 101 | end 102 | ``` 103 | 104 | Make sure `router.ex` is setup for correct content type, e.g.: 105 | ```elixir 106 | pipeline :sse do 107 | plug :put_format, "text/event-stream" 108 | plug :fetch_session 109 | end 110 | 111 | scope "/sse", SseDemoWeb do 112 | pipe_through :sse 113 | 114 | get "/", SseController, :subscribe 115 | end 116 | ``` 117 | 118 | Webpage integration is done via built-in `EventSource` object. 119 | 120 | ## License 121 | 122 | MIT 123 | 124 | Copyright (c) 2020 Vlad Jebelev 125 | 126 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 127 | 128 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 129 | 130 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 131 | 132 | 133 | Portions of this package are copied from an open source package https://github.com/mustafaturan/sse by Mustafa Torin: 134 | 135 | MIT 136 | 137 | Copyright (c) 2018 Mustafa Turan 138 | 139 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 140 | 141 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 142 | 143 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 144 | 145 | --------------------------------------------------------------------------------