├── .gitignore ├── config └── config.exs ├── .formatter.exs ├── lib └── phoenix_live_reload │ ├── socket.ex │ ├── web_console_logger.ex │ ├── application.ex │ ├── channel.ex │ └── live_reloader.ex ├── LICENCE.md ├── .github └── workflows │ └── ci.yml ├── mix.exs ├── test ├── test_helper.exs ├── live_reloader_test.exs └── channel_test.exs ├── mix.lock ├── CHANGELOG.md ├── README.md └── priv └── static └── phoenix_live_reload.js /.gitignore: -------------------------------------------------------------------------------- 1 | /_build 2 | /deps 3 | /doc 4 | erl_crash.dump 5 | *.ez 6 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :phoenix, :json_library, Jason 4 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"], 3 | import_deps: [:phoenix] 4 | ] 5 | -------------------------------------------------------------------------------- /lib/phoenix_live_reload/socket.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.LiveReloader.Socket do 2 | @moduledoc """ 3 | The Socket handler for live reload channels. 4 | """ 5 | use Phoenix.Socket, log: false 6 | 7 | channel "phoenix:live_reload", Phoenix.LiveReloader.Channel 8 | 9 | def connect(_params, socket), do: {:ok, socket} 10 | 11 | def id(_socket), do: nil 12 | end 13 | -------------------------------------------------------------------------------- /LICENCE.md: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright (c) 2014 Chris McCord 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | pull_request: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | tests: 11 | name: Run tests (${{ matrix.image }}) 12 | 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | include: 17 | - image: 1.11.4-erlang-21.3.8.24-debian-buster-20240513-slim 18 | - image: 1.17.2-erlang-27.0.1-debian-bookworm-20240701-slim 19 | 20 | runs-on: ubuntu-latest 21 | container: 22 | image: hexpm/elixir:${{ matrix.image }} 23 | 24 | steps: 25 | - name: Install inotify-tools 26 | run: apt update && apt -y install inotify-tools 27 | 28 | - name: Checkout 29 | uses: actions/checkout@v4 30 | 31 | - name: Hex and Rebar setup 32 | run: | 33 | mix local.hex --force 34 | mix local.rebar --force 35 | 36 | - name: Restore deps and _build cache 37 | uses: actions/cache@v4 38 | with: 39 | path: | 40 | deps 41 | _build 42 | key: deps-${{ runner.os }}-${{ matrix.image }}-${{ hashFiles('**/mix.lock') }} 43 | restore-keys: | 44 | deps-${{ runner.os }}-${{ matrix.image }} 45 | - name: Install dependencies 46 | run: mix deps.get --only test 47 | 48 | - name: Run tests 49 | run: mix test 50 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixLiveReload.Mixfile do 2 | use Mix.Project 3 | 4 | @version "1.6.2" 5 | 6 | def project do 7 | [ 8 | app: :phoenix_live_reload, 9 | version: @version, 10 | elixir: "~> 1.11", 11 | deps: deps(), 12 | 13 | # Hex 14 | description: "Provides live-reload functionality for Phoenix", 15 | package: package(), 16 | 17 | # Docs 18 | name: "Phoenix Live-Reload", 19 | docs: docs() 20 | ] 21 | end 22 | 23 | defp package do 24 | [ 25 | maintainers: ["Chris McCord", "José Valim"], 26 | licenses: ["MIT"], 27 | links: %{"GitHub" => "https://github.com/phoenixframework/phoenix_live_reload"} 28 | ] 29 | end 30 | 31 | def application do 32 | [ 33 | mod: {Phoenix.LiveReloader.Application, []}, 34 | extra_applications: [:logger, :phoenix, :file_system] 35 | ] 36 | end 37 | 38 | defp deps do 39 | [ 40 | {:phoenix, "~> 1.4"}, 41 | {:ex_doc, "~> 0.29", only: :docs}, 42 | {:makeup_eex, ">= 0.1.1", only: :docs}, 43 | {:makeup_diff, "~> 0.1", only: :docs}, 44 | {:file_system, "~> 0.2.10 or ~> 1.0"}, 45 | {:jason, "~> 1.0", only: :test} 46 | ] 47 | end 48 | 49 | defp docs do 50 | [ 51 | main: "readme", 52 | extras: [ 53 | "README.md", 54 | "CHANGELOG.md" 55 | ], 56 | source_ref: "v#{@version}", 57 | source_url: "https://github.com/phoenixframework/phoenix_live_reload" 58 | ] 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/phoenix_live_reload/web_console_logger.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.LiveReloader.WebConsoleLogger do 2 | @moduledoc false 3 | use GenServer 4 | 5 | @registry Phoenix.LiveReloader.WebConsoleLoggerRegistry 6 | @compile {:no_warn_undefined, {Logger, :default_formatter, 0}} 7 | 8 | def registry, do: @registry 9 | 10 | def attach_logger do 11 | if function_exported?(Logger, :default_formatter, 0) do 12 | :ok = 13 | :logger.add_handler(__MODULE__, __MODULE__, %{ 14 | formatter: Logger.default_formatter(colors: [enabled: false]) 15 | }) 16 | end 17 | end 18 | 19 | def detach_logger do 20 | if function_exported?(Logger, :default_formatter, 0) do 21 | :ok = :logger.remove_handler(__MODULE__) 22 | end 23 | end 24 | 25 | def subscribe(prefix) do 26 | {:ok, _} = Registry.register(@registry, :all, prefix) 27 | :ok 28 | end 29 | 30 | def start_link(opts \\ []) do 31 | GenServer.start_link(__MODULE__, opts, name: __MODULE__) 32 | end 33 | 34 | @impl GenServer 35 | def init(opts) do 36 | # We need to trap exits so that we receive the `terminate/2` callback during 37 | # a graceful shutdown 38 | Process.flag(:trap_exit, true) 39 | 40 | attach_logger() 41 | 42 | {:ok, opts} 43 | end 44 | 45 | @impl GenServer 46 | def terminate(_reason, state) do 47 | # On shutdown we need to detach the logger before the Registry stops 48 | detach_logger() 49 | {:ok, state} 50 | end 51 | 52 | # Erlang/OTP log handler 53 | def log(%{meta: meta, level: level} = event, config) do 54 | %{formatter: {formatter_mod, formatter_config}} = config 55 | iodata = formatter_mod.format(event, formatter_config) 56 | msg = IO.chardata_to_string(iodata) 57 | 58 | Registry.dispatch(@registry, :all, fn entries -> 59 | event = %{level: level, msg: msg, file: meta[:file], line: meta[:line]} 60 | 61 | for {pid, prefix} <- entries do 62 | send(pid, {prefix, event}) 63 | end 64 | end) 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /lib/phoenix_live_reload/application.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.LiveReloader.Application do 2 | use Application 3 | require Logger 4 | 5 | alias Phoenix.LiveReloader.WebConsoleLogger 6 | 7 | def start(_type, _args) do 8 | # note we always attach and start the logger as :phoenix_live_reload should only 9 | # be started in dev via user's `only: :dev` entry. 10 | 11 | # the deps paths are read by the channel when getting the full_path for 12 | # opening the configured PLUG_EDITOR 13 | :persistent_term.put(:phoenix_live_reload_deps_paths, deps_paths()) 14 | 15 | children = [ 16 | Registry.child_spec(name: WebConsoleLogger.registry(), keys: :duplicate), 17 | WebConsoleLogger, 18 | %{id: __MODULE__, start: {__MODULE__, :start_link, []}} 19 | ] 20 | 21 | Supervisor.start_link(children, strategy: :one_for_one) 22 | end 23 | 24 | def start_link do 25 | dirs = Application.get_env(:phoenix_live_reload, :dirs, [""]) 26 | backend_opts = Application.get_env(:phoenix_live_reload, :backend_opts, []) 27 | 28 | opts = 29 | [ 30 | name: :phoenix_live_reload_file_monitor, 31 | dirs: Enum.map(dirs, &Path.absname/1) 32 | ] ++ backend_opts 33 | 34 | opts = 35 | if backend = Application.get_env(:phoenix_live_reload, :backend) do 36 | [backend: backend] ++ opts 37 | else 38 | opts 39 | end 40 | 41 | case FileSystem.start_link(opts) do 42 | {:ok, pid} -> 43 | {:ok, pid} 44 | 45 | other -> 46 | Logger.warning(""" 47 | Could not start Phoenix live-reload because we cannot listen to the file system. 48 | You don't need to worry! This is an optional feature used during development to 49 | refresh your browser when you save files and it does not affect production. 50 | """) 51 | 52 | other 53 | end 54 | end 55 | 56 | defp deps_paths do 57 | # TODO: Use `Code.loaded?` on Elixir v1.15+ 58 | if :erlang.module_loaded(Mix.Project) do 59 | for {app, path} <- Mix.Project.deps_paths(), into: %{}, do: {to_string(app), path} 60 | else 61 | %{} 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | Application.put_env(:phoenix_live_reload, MyApp.Endpoint, 2 | pubsub_server: MyApp.PubSub, 3 | live_reload: [ 4 | url: "ws://localhost:4000", 5 | patterns: [ 6 | ~r{priv/static/.*(js|css|png|jpeg|jpg|gif)$}, 7 | ~r{web/views/.*(ex)$}, 8 | ~r{web/templates/.*(eex)$} 9 | ] 10 | ] 11 | ) 12 | 13 | Application.put_env(:phoenix_live_reload, MyApp.EndpointScript, 14 | live_reload: [ 15 | url: "ws://localhost:4000", 16 | patterns: [~r{priv/static/.*(js|css|png|jpeg|jpg|gif)$}] 17 | ], 18 | url: [path: "/foo/bar"] 19 | ) 20 | 21 | Application.put_env(:phoenix_live_reload, MyApp.EndpointConfig, 22 | live_reload: [ 23 | url: "ws://localhost:4000", 24 | suffix: "/foo/bar", 25 | iframe_attrs: [class: "foo", data_attr: "bar"], 26 | patterns: [ 27 | ~r{priv/static/.*(js|css|png|jpeg|jpg|gif)$}, 28 | ~r{web/views/.*(ex)$}, 29 | ~r{web/templates/.*(eex)$} 30 | ] 31 | ] 32 | ) 33 | 34 | Application.put_env(:phoenix_live_reload, MyApp.EndpointTopWindow, 35 | pubsub_server: MyApp.PubSub, 36 | live_reload: [ 37 | target_window: :top 38 | ] 39 | ) 40 | 41 | Application.put_env(:phoenix_live_reload, MyApp.ReloadEndpoint, 42 | pubsub_server: MyApp.PubSub, 43 | live_reload: [ 44 | url: "ws://localhost:4000", 45 | patterns: [ 46 | ~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$", 47 | ~r"priv/gettext/.*(po)$", 48 | ~r{web/views/.*(ex)$}, 49 | ~r{web/templates/.*(eex)$} 50 | ], 51 | notify: [ 52 | live_view: [ 53 | ~r{web/components.ex$}, 54 | ~r{web/live/.*(ex)$} 55 | ] 56 | ] 57 | ] 58 | ) 59 | 60 | Application.put_env(:phoenix_live_reload, MyApp.LogEndpoint, pubsub_server: MyApp.PubSub) 61 | 62 | defmodule MyApp.Endpoint do 63 | use Phoenix.Endpoint, otp_app: :phoenix_live_reload 64 | 65 | socket "/socket", Phoenix.LiveReloader.Socket, websocket: true, longpoll: true 66 | end 67 | 68 | defmodule MyApp.ReloadEndpoint do 69 | use Phoenix.Endpoint, otp_app: :phoenix_live_reload 70 | 71 | socket "/socket", Phoenix.LiveReloader.Socket, websocket: true, longpoll: true 72 | end 73 | 74 | defmodule MyApp.EndpointScript do 75 | use Phoenix.Endpoint, otp_app: :phoenix_live_reload 76 | end 77 | 78 | defmodule MyApp.EndpointConfig do 79 | use Phoenix.Endpoint, otp_app: :phoenix_live_reload 80 | end 81 | 82 | defmodule MyApp.EndpointTopWindow do 83 | use Phoenix.Endpoint, otp_app: :phoenix_live_reload 84 | end 85 | 86 | defmodule MyApp.LogEndpoint do 87 | use Phoenix.Endpoint, otp_app: :phoenix_live_reload 88 | 89 | socket "/socket", Phoenix.LiveReloader.Socket, websocket: true, longpoll: true 90 | end 91 | 92 | children = [ 93 | {Phoenix.PubSub, name: MyApp.PubSub, adapter: Phoenix.PubSub.PG2}, 94 | MyApp.Endpoint, 95 | MyApp.EndpointScript, 96 | MyApp.EndpointConfig, 97 | MyApp.EndpointTopWindow, 98 | MyApp.ReloadEndpoint, 99 | MyApp.LogEndpoint 100 | ] 101 | 102 | Supervisor.start_link(children, strategy: :one_for_one) 103 | 104 | ExUnit.start() 105 | -------------------------------------------------------------------------------- /lib/phoenix_live_reload/channel.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.LiveReloader.Channel do 2 | @moduledoc """ 3 | Phoenix's live-reload channel. 4 | """ 5 | use Phoenix.Channel 6 | require Logger 7 | 8 | alias Phoenix.LiveReloader.WebConsoleLogger 9 | 10 | @logs :logs 11 | 12 | def join("phoenix:live_reload", _msg, socket) do 13 | {:ok, _} = Application.ensure_all_started(:phoenix_live_reload) 14 | 15 | if Process.whereis(:phoenix_live_reload_file_monitor) do 16 | FileSystem.subscribe(:phoenix_live_reload_file_monitor) 17 | 18 | if web_console_logger_enabled?(socket) do 19 | WebConsoleLogger.subscribe(@logs) 20 | end 21 | 22 | config = socket.endpoint.config(:live_reload) 23 | 24 | socket = 25 | socket 26 | |> assign(:patterns, config[:patterns] || []) 27 | |> assign(:debounce, config[:debounce] || 0) 28 | |> assign(:notify_patterns, config[:notify] || []) 29 | 30 | {:ok, join_info(), socket} 31 | else 32 | {:error, %{message: "live reload backend not running"}} 33 | end 34 | end 35 | 36 | def handle_info({:file_event, _pid, {path, _event}}, socket) do 37 | %{ 38 | patterns: patterns, 39 | debounce: debounce, 40 | notify_patterns: notify_patterns 41 | } = socket.assigns 42 | 43 | if matches_any_pattern?(path, patterns) do 44 | ext = Path.extname(path) 45 | 46 | for {path, ext} <- [{path, ext} | debounce(debounce, [ext], patterns)] do 47 | asset_type = remove_leading_dot(ext) 48 | Logger.debug("Live reload: #{Path.relative_to_cwd(path)}") 49 | push(socket, "assets_change", %{asset_type: asset_type}) 50 | end 51 | end 52 | 53 | for {topic, patterns} <- notify_patterns do 54 | if matches_any_pattern?(path, patterns) do 55 | Phoenix.PubSub.broadcast( 56 | socket.pubsub_server, 57 | to_string(topic), 58 | {:phoenix_live_reload, topic, path} 59 | ) 60 | end 61 | end 62 | 63 | {:noreply, socket} 64 | end 65 | 66 | def handle_info({@logs, %{level: level, msg: msg, file: file, line: line}}, socket) do 67 | push(socket, "log", %{ 68 | level: to_string(level), 69 | msg: msg, 70 | file: file, 71 | line: line 72 | }) 73 | 74 | {:noreply, socket} 75 | end 76 | 77 | def handle_in("full_path", %{"rel_path" => rel_path, "app" => app}, socket) do 78 | case :persistent_term.get(:phoenix_live_reload_deps_paths) do 79 | %{^app => dep_path} -> 80 | {:reply, {:ok, %{full_path: Path.join(dep_path, rel_path)}}, socket} 81 | 82 | %{} -> 83 | {:reply, {:ok, %{full_path: Path.join(File.cwd!(), rel_path)}}, socket} 84 | end 85 | end 86 | 87 | defp debounce(0, _exts, _patterns), do: [] 88 | 89 | defp debounce(time, exts, patterns) when is_integer(time) and time > 0 do 90 | Process.send_after(self(), :debounced, time) 91 | debounce(exts, patterns) 92 | end 93 | 94 | defp debounce(exts, patterns) do 95 | receive do 96 | :debounced -> 97 | [] 98 | 99 | {:file_event, _pid, {path, _event}} -> 100 | ext = Path.extname(path) 101 | 102 | if matches_any_pattern?(path, patterns) and ext not in exts do 103 | [{path, ext} | debounce([ext | exts], patterns)] 104 | else 105 | debounce(exts, patterns) 106 | end 107 | end 108 | end 109 | 110 | defp matches_any_pattern?(path, patterns) do 111 | path = to_string(path) 112 | 113 | Enum.any?(patterns, fn pattern -> 114 | String.match?(path, pattern) and not String.match?(path, ~r{(^|/)_build/}) 115 | end) 116 | end 117 | 118 | defp remove_leading_dot("." <> rest), do: rest 119 | defp remove_leading_dot(rest), do: rest 120 | 121 | defp web_console_logger_enabled?(socket) do 122 | socket.endpoint.config(:live_reload)[:web_console_logger] == true 123 | end 124 | 125 | defp join_info do 126 | if url = System.get_env("PLUG_EDITOR") do 127 | %{editor_url: url} 128 | else 129 | %{} 130 | end 131 | end 132 | end 133 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, 3 | "ex_doc": {:hex, :ex_doc, "0.39.2", "da5549bbce34c5fb0811f829f9f6b7a13d5607b222631d9e989447096f295c57", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "62665526a88c207653dbcee2aac66c2c229d7c18a70ca4ffc7f74f9e01324daa"}, 4 | "file_system": {:hex, :file_system, "1.0.0", "b689cc7dcee665f774de94b5a832e578bd7963c8e637ef940cd44327db7de2cd", [:mix], [], "hexpm", "6752092d66aec5a10e662aefeed8ddb9531d79db0bc145bb8c40325ca1d8536d"}, 5 | "jason": {:hex, :jason, "1.2.2", "ba43e3f2709fd1aa1dce90aaabfd039d000469c05c56f0b8e31978e03fa39052", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "18a228f5f0058ee183f29f9eae0805c6e59d61c3b006760668d8d18ff0d12179"}, 6 | "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, 7 | "makeup_diff": {:hex, :makeup_diff, "0.1.0", "5be352b6aa6f07fa6a236e3efd7ba689a03f28fb5d35b7a0fa0a1e4a64f6d8bb", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "186bad5bb433a8afeb16b01423950e440072284a4103034ca899180343b9b4ac"}, 8 | "makeup_eex": {:hex, :makeup_eex, "0.1.1", "89352d5da318d97ae27bbcc87201f274504d2b71ede58ca366af6a5fbed9508d", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.16", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_html, "~> 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d111a0994eaaab09ef1a4b3b313ef806513bb4652152c26c0d7ca2be8402a964"}, 9 | "makeup_elixir": {:hex, :makeup_elixir, "0.16.2", "627e84b8e8bf22e60a2579dad15067c755531fea049ae26ef1020cad58fe9578", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "41193978704763f6bbe6cc2758b84909e62984c7752b3784bd3c218bb341706b"}, 10 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, 11 | "makeup_html": {:hex, :makeup_html, "0.1.2", "19d4050c0978a4f1618ffe43054c0049f91fe5feeb9ae8d845b5dc79c6008ae5", [:mix], [{:makeup, "~> 1.2", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "b7fb9afedd617d167e6644a0430e49c1279764bfd3153da716d4d2459b0998c5"}, 12 | "mime": {:hex, :mime, "1.4.0", "5066f14944b470286146047d2f73518cf5cca82f8e4815cf35d196b58cf07c47", [:mix], [], "hexpm", "75fa42c4228ea9a23f70f123c74ba7cece6a03b1fd474fe13f6a7a85c6ea4ff6"}, 13 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, 14 | "phoenix": {:hex, :phoenix, "1.5.6", "8298cdb4e0f943242ba8410780a6a69cbbe972fef199b341a36898dd751bdd66", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 2.13", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.1.2 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "0dc4d39af1306b6aa5122729b0a95ca779e42c708c6fe7abbb3d336d5379e956"}, 15 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.0.0", "a1ae76717bb168cdeb10ec9d92d1480fec99e3080f011402c0a2d68d47395ffb", [:mix], [], "hexpm", "c52d948c4f261577b9c6fa804be91884b381a7f8f18450c5045975435350f771"}, 16 | "plug": {:hex, :plug, "1.11.0", "f17217525597628298998bc3baed9f8ea1fa3f1160aa9871aee6df47a6e4d38e", [: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: false]}], "hexpm", "2d9c633f0499f9dc5c2fd069161af4e2e7756890b81adcbb2ceaa074e8308876"}, 17 | "plug_crypto": {:hex, :plug_crypto, "1.2.0", "1cb20793aa63a6c619dd18bb33d7a3aa94818e5fd39ad357051a67f26dfa2df6", [:mix], [], "hexpm", "a48b538ae8bf381ffac344520755f3007cc10bd8e90b240af98ea29b69683fc2"}, 18 | "telemetry": {:hex, :telemetry, "0.4.2", "2808c992455e08d6177322f14d3bdb6b625fbcfd233a73505870d8738a2f4599", [:rebar3], [], "hexpm", "2d1419bd9dda6a206d7b5852179511722e2b18812310d304620c7bd92a13fcef"}, 19 | } 20 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 1.6.2 (2025-12-08) 4 | 5 | * Bug fixes 6 | * Properly deal with Unicode when forwarding logs 7 | 8 | ## 1.6.1 (2025-08-31) 9 | 10 | * Enhancements 11 | * Set `:phoenix_live_reload` private field to downstream instrumentation 12 | * Add `@import` directive support to CSS reload strategy 13 | 14 | ## 1.6.0 (2025-04-10) 15 | 16 | * Enhancements 17 | * Add support for `__RELATIVEFILE__` when invoking editors 18 | * Change the default target window to `:parent` to not reload the whole page if a Phoenix app is shown inside an iframe. You can get the old behavior back by setting the `:target_window` option to `:top`: 19 | ```elixir 20 | config :phoenix_live_reload, MyAppWeb.Endpoint, 21 | target_window: :top, 22 | ... 23 | ``` 24 | 25 | * Bug fixes 26 | * Inject iframe if web console logger is enabled but there are no patterns 27 | * Allow web console to shutdown cleanly 28 | 29 | ## 1.5.3 (2024-03-27) 30 | 31 | * Bug fixes 32 | * Fix warnings on earlier Elixir versions 33 | * Use darkcyan for log levels 34 | 35 | ## 1.5.2 (2024-03-11) 36 | 37 | * Bug fixes 38 | * Fix CSS updates failing with errors 39 | * Fix logging errors caused by unknown server log types 40 | 41 | ## 1.5.1 (2024-02-29) 42 | 43 | * Bug fixes 44 | * Fix regression on Elixir v1.14 and earlier 45 | 46 | ## 1.5.0 (2024-02-29) 47 | 48 | * Improvements 49 | * Introduce streaming server logs to the browser's web console with the new `:web_console_logger` endpoint configuration 50 | * Introduce `openEditorAtCaller` and `openEditorAtDef` client functions for opening the developer's configured `PLUG_EDITOR` to the elixir source file/line given a DOM element 51 | * Dispatch `"phx:live_reload:attached"` to parent window when live reload is attached to server and awaiting changes 52 | 53 | ## 1.4.1 (2022-11-29) 54 | 55 | * Improvements 56 | * Support new `:notify` configuration for third-party integration to file change events 57 | 58 | ## 1.4.0 (2022-10-29) 59 | 60 | * Improvements 61 | * Allow reload events to be debounced instead of triggered immediately 62 | * Add option to trigger full page reloads on css changes 63 | * Bug fixes 64 | * Handle false positives on `` tags 65 | 66 | ## 1.3.3 (2021-07-06) 67 | 68 | * Improvements 69 | * Do not attempt to fetch source map for phoenix.js 70 | 71 | ## 1.3.2 (2021-06-21) 72 | 73 | * Improvements 74 | * Allow reload `:target_window` to be configured 75 | 76 | ## 1.3.1 (2021-04-12) 77 | 78 | * Bug fixes 79 | * Use width=0 and height=0 on iframe 80 | 81 | ## 1.3.0 (2020-11-03) 82 | 83 | This release requires Elixir v1.6+. 84 | 85 | * Enhancements 86 | * Use `hidden` attribute instead of `style="display: none"` 87 | * Fix warnings on Elixir v1.11 88 | 89 | * Deprecations 90 | * `:iframe_class` is deprecated in favor of `:iframe_attrs` 91 | 92 | ## 1.2.4 (2020-06-10) 93 | 94 | * Bug fixes 95 | * Fix a bug related to improper live reload interval 96 | 97 | ## 1.2.3 (2020-06-10) 98 | 99 | * Enhancements 100 | * Support the iframe_class option for live reload 101 | 102 | ## 1.2.2 (2020-05-13) 103 | 104 | * Enhancements 105 | * Support the suffix option 106 | 107 | ## 1.2.1 (2019-05-24) 108 | 109 | * Enhancements 110 | * Allow custom file_system backend options 111 | 112 | ## 1.2.0 (2018-11-07) 113 | 114 | * Enhancements 115 | * Support Phoenix 1.4 transport changes 116 | 117 | ## 1.1.7 (2018-10-10) 118 | 119 | * Enhancements 120 | * Relax version requirements to support Phoenix 1.4 121 | 122 | ## 1.1.6 (2018-09-28) 123 | 124 | * Enhancements 125 | * Allow file system watcher backend to be configured 126 | * Add `:fs_poll` backend as fallback for generic OS support 127 | 128 | ## 1.1.5 129 | 130 | * Bug fix 131 | * Use proper default interval of 100ms 132 | 133 | ## 1.1.4 134 | 135 | * Enhancements 136 | * Support `:interval` configuration for cases where the live reloading was triggering too fast 137 | 138 | * Bug fix 139 | * Support IE11 140 | * Fix CSS reloading in iframe 141 | 142 | ## 1.1.3 (2017-09-25) 143 | 144 | * Bug fix 145 | * Do not return unsupported `:ignore` from live channel 146 | 147 | ## 1.1.2 (2017-09-25) 148 | 149 | * Enhancements 150 | * Improve error messages 151 | 152 | ## 1.1.1 (2017-08-27) 153 | 154 | * Enhancements 155 | * Bump `:file_system` requirement 156 | 157 | * Bug fixes 158 | * Do not raise when response has no body 159 | 160 | ## 1.1.0 (2017-08-10) 161 | 162 | * Enhancements 163 | * Use `:file_system` for file change notifications for improved reliability 164 | 165 | ## 1.0.8 (2017-02-01) 166 | 167 | * Enhancements 168 | * Revert to `:fs` 0.9.1 to side-step rebar build problems 169 | 170 | ## 1.0.7 (2017-01-18) 171 | 172 | * Enhancements 173 | * Update to latest `:fs` 2.12 174 | 175 | ## 1.0.6 (2016-11-29) 176 | 177 | * Bug fixes 178 | * Remove warnings on Elixir v1.4 179 | * Do not try to access the endpoint if it is no longer loaded 180 | 181 | ## 1.0.5 (2016-05-04) 182 | 183 | * Bug fixes 184 | * Do not include hard earmark requirement 185 | 186 | ## 1.0.4 (2016-04-29) 187 | 188 | * Enhancements 189 | * Support Phoenix v1.2 190 | 191 | ## 1.0.3 (2016-01-11) 192 | 193 | * Enhancements 194 | * Log whenever a live reload event is sent 195 | 196 | ## 1.0.2 (2016-01-07) 197 | 198 | * Bug fixes 199 | * Fix issue where iframe path did not respect script_name 200 | 201 | ## 1.0.1 (2015-09-18) 202 | 203 | * Bug fixes 204 | * Fix issue causing stylesheet link taps to duplicate on reload 205 | -------------------------------------------------------------------------------- /test/live_reloader_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.LiveReloaderTest do 2 | use ExUnit.Case, async: true 3 | 4 | import Plug.Test 5 | import Plug.Conn 6 | 7 | setup do 8 | Logger.disable(self()) 9 | :ok 10 | end 11 | 12 | defp conn(path) do 13 | conn(:get, path) 14 | |> Plug.Conn.put_private(:phoenix_endpoint, MyApp.Endpoint) 15 | end 16 | 17 | test "renders frame with phoenix.js" do 18 | conn = 19 | conn("/phoenix/live_reload/frame") 20 | |> Phoenix.LiveReloader.call([]) 21 | 22 | assert conn.status == 200 23 | 24 | assert to_string(conn.resp_body) =~ 25 | ~s[var socket = new Phoenix.Socket("ws://localhost:4000");\n] 26 | 27 | assert to_string(conn.resp_body) =~ 28 | ~s[var interval = 100;\n] 29 | 30 | assert to_string(conn.resp_body) =~ 31 | ~s[var targetWindow = "parent";\n] 32 | 33 | assert to_string(conn.resp_body) =~ 34 | ~s[var reloadPageOnCssChanges = false;\n] 35 | 36 | refute to_string(conn.resp_body) =~ 37 | ~s[ tag" do 41 | opts = Phoenix.LiveReloader.init([]) 42 | 43 | conn = 44 | conn("/") 45 | |> put_resp_content_type("text/html") 46 | |> Phoenix.LiveReloader.call(opts) 47 | |> send_resp(200, "

Phoenix

") 48 | 49 | assert to_string(conn.resp_body) == 50 | "

Phoenix

" 51 | end 52 | 53 | test "injects live_reload for html requests if configured and contains multiple tags" do 54 | opts = Phoenix.LiveReloader.init([]) 55 | 56 | conn = 57 | conn("/") 58 | |> put_resp_content_type("text/html") 59 | |> Phoenix.LiveReloader.call(opts) 60 | |> send_resp(200, "

Phoenix

") 61 | 62 | assert to_string(conn.resp_body) == 63 | "

Phoenix

" 64 | end 65 | 66 | test "injects live_reload with script_name" do 67 | opts = Phoenix.LiveReloader.init([]) 68 | 69 | conn = 70 | conn("/") 71 | |> put_private(:phoenix_endpoint, MyApp.EndpointScript) 72 | |> put_resp_content_type("text/html") 73 | |> Phoenix.LiveReloader.call(opts) 74 | |> send_resp(200, "

Phoenix

") 75 | 76 | assert to_string(conn.resp_body) == 77 | "

Phoenix

" 78 | end 79 | 80 | test "skips live_reload injection if html response missing body tag" do 81 | opts = Phoenix.LiveReloader.init([]) 82 | 83 | conn = 84 | conn("/") 85 | |> put_resp_content_type("text/html") 86 | |> Phoenix.LiveReloader.call(opts) 87 | |> send_resp(200, "

Phoenix

") 88 | 89 | assert to_string(conn.resp_body) == "

Phoenix

" 90 | end 91 | 92 | test "skips live_reload if not html request" do 93 | opts = Phoenix.LiveReloader.init([]) 94 | 95 | conn = 96 | conn("/") 97 | |> put_resp_content_type("application/json") 98 | |> Phoenix.LiveReloader.call(opts) 99 | |> send_resp(200, "") 100 | 101 | refute to_string(conn.resp_body) =~ 102 | ~s(" 132 | end 133 | 134 | test "works with iolists as input" do 135 | opts = Phoenix.LiveReloader.init([]) 136 | 137 | conn = 138 | conn("/") 139 | |> put_private(:phoenix_endpoint, MyApp.Endpoint) 140 | |> put_resp_content_type("text/html") 141 | |> Phoenix.LiveReloader.call(opts) 142 | |> send_resp(200, [ 143 | "", 144 | ~c""], 146 | "

Phoenix

", 147 | "", 150 | "" 151 | ]) 152 | 153 | assert to_string(conn.resp_body) == 154 | "

Phoenix

" 155 | end 156 | 157 | test "window target can be set to parent" do 158 | conn = 159 | conn("/phoenix/live_reload/frame") 160 | |> put_private(:phoenix_endpoint, MyApp.EndpointTopWindow) 161 | |> Phoenix.LiveReloader.call([]) 162 | 163 | assert to_string(conn.resp_body) =~ 164 | ~s[var targetWindow = "top";\n] 165 | end 166 | end 167 | -------------------------------------------------------------------------------- /test/channel_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.LiveReloader.ChannelTest do 2 | use ExUnit.Case 3 | require Logger 4 | 5 | import Phoenix.ChannelTest 6 | 7 | alias Phoenix.LiveReloader 8 | alias Phoenix.LiveReloader.Channel 9 | 10 | @endpoint MyApp.Endpoint 11 | @moduletag :capture_log 12 | 13 | defp file_event(path, event) do 14 | {:file_event, self(), {path, event}} 15 | end 16 | 17 | defp update_live_reload_env(endpoint, func) do 18 | conf = Application.get_env(:phoenix_live_reload, endpoint)[:live_reload] || [] 19 | new_conf = func.(conf) 20 | Application.put_env(:phoenix_live_reload, endpoint, new_conf) 21 | endpoint.config_change([{endpoint, [live_reload: new_conf]}], []) 22 | end 23 | 24 | setup do 25 | {:ok, _, socket} = 26 | LiveReloader.Socket |> socket() |> subscribe_and_join(Channel, "phoenix:live_reload", %{}) 27 | 28 | {:ok, socket: socket} 29 | end 30 | 31 | test "sends a notification when asset is created", %{socket: socket} do 32 | send(socket.channel_pid, file_event("priv/static/phoenix_live_reload.js", :created)) 33 | assert_push "assets_change", %{asset_type: "js"} 34 | end 35 | 36 | test "sends a notification when asset is removed", %{socket: socket} do 37 | send(socket.channel_pid, file_event("priv/static/long_gone.js", :removed)) 38 | assert_push "assets_change", %{asset_type: "js"} 39 | end 40 | 41 | test "logs on live reload", %{socket: socket} do 42 | content = 43 | ExUnit.CaptureLog.capture_log(fn -> 44 | send(socket.channel_pid, file_event("priv/static/long_gone.js", :removed)) 45 | assert_push "assets_change", %{asset_type: "js"} 46 | end) 47 | 48 | assert content =~ "[debug] Live reload: priv/static/long_gone.js" 49 | end 50 | 51 | test "does not send a notification when asset comes from _build", %{socket: socket} do 52 | send( 53 | socket.channel_pid, 54 | file_event( 55 | "_build/test/lib/phoenix_live_reload/priv/static/phoenix_live_reload.js", 56 | :created 57 | ) 58 | ) 59 | 60 | refute_receive _anything, 100 61 | end 62 | 63 | test "it allows project names containing _build", %{socket: socket} do 64 | send( 65 | socket.channel_pid, 66 | file_event( 67 | "/Users/auser/www/widget_builder/lib/live_web/templates/layout/app.html.eex", 68 | :created 69 | ) 70 | ) 71 | 72 | assert_push "assets_change", %{asset_type: "eex"} 73 | end 74 | 75 | test "sends notification for js", %{socket: socket} do 76 | send(socket.channel_pid, file_event("priv/static/phoenix_live_reload.js", :created)) 77 | assert_push "assets_change", %{asset_type: "js"} 78 | end 79 | 80 | test "sends notification for css", %{socket: socket} do 81 | send(socket.channel_pid, file_event("priv/static/phoenix_live_reload.css", :created)) 82 | assert_push "assets_change", %{asset_type: "css"} 83 | end 84 | 85 | test "sends notification for images", %{socket: socket} do 86 | send(socket.channel_pid, file_event("priv/static/phoenix_live_reload.png", :created)) 87 | assert_push "assets_change", %{asset_type: "png"} 88 | end 89 | 90 | test "sends notification for templates", %{socket: socket} do 91 | send(socket.channel_pid, file_event("lib/live_web/templates/user/show.html.eex", :created)) 92 | assert_push "assets_change", %{asset_type: "eex"} 93 | end 94 | 95 | test "sends notification for views", %{socket: socket} do 96 | send(socket.channel_pid, file_event(~c"a/b/c/lib/live_web/views/user_view.ex", :created)) 97 | assert_push "assets_change", %{asset_type: "ex"} 98 | end 99 | 100 | @endpoint MyApp.ReloadEndpoint 101 | test "sends notification for liveviews" do 102 | {:ok, _, socket} = 103 | LiveReloader.Socket |> socket() |> subscribe_and_join(Channel, "phoenix:live_reload", %{}) 104 | 105 | socket.endpoint.subscribe("live_view") 106 | send(socket.channel_pid, file_event("lib/live_web/live/user_live.ex", :created)) 107 | assert_receive {:phoenix_live_reload, :live_view, "lib/live_web/live/user_live.ex"} 108 | end 109 | 110 | @endpoint MyApp.LogEndpoint 111 | # web console logger relies on Logger.default_formatter/0 112 | # which is only available since Elixir v1.15 113 | if Version.match?(System.version(), ">= 1.15.0") do 114 | test "sends logs for web console only when enabled" do 115 | System.delete_env("PLUG_EDITOR") 116 | 117 | update_live_reload_env(@endpoint, fn conf -> 118 | Keyword.drop(conf, [:web_console_logger]) 119 | end) 120 | 121 | {:ok, info, _socket} = 122 | LiveReloader.Socket |> socket() |> subscribe_and_join(Channel, "phoenix:live_reload", %{}) 123 | 124 | assert info == %{} 125 | Logger.info("hello") 126 | 127 | refute_receive _ 128 | 129 | update_live_reload_env(@endpoint, fn conf -> 130 | Keyword.merge(conf, web_console_logger: true) 131 | end) 132 | 133 | {:ok, info, _socket} = 134 | LiveReloader.Socket |> socket() |> subscribe_and_join(Channel, "phoenix:live_reload", %{}) 135 | 136 | assert info == %{} 137 | 138 | Logger.info("hello again") 139 | assert_receive %Phoenix.Socket.Message{event: "log", payload: %{msg: msg, level: "info"}} 140 | assert msg =~ "hello again" 141 | end 142 | end 143 | 144 | test "sends editor_url and relative_path only when configurd" do 145 | System.delete_env("PLUG_EDITOR") 146 | 147 | {:ok, info, _socket} = 148 | LiveReloader.Socket |> socket() |> subscribe_and_join(Channel, "phoenix:live_reload", %{}) 149 | 150 | assert info == %{} 151 | 152 | System.put_env("PLUG_EDITOR", "vscode://file/__FILE__:__LINE__") 153 | 154 | {:ok, info, _socket} = 155 | LiveReloader.Socket |> socket() |> subscribe_and_join(Channel, "phoenix:live_reload", %{}) 156 | 157 | assert info == %{editor_url: "vscode://file/__FILE__:__LINE__"} 158 | end 159 | end 160 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | A project for live-reload functionality for [Phoenix](http://github.com/phoenixframework/phoenix) during development. 2 | 3 | ## Usage 4 | 5 | You can use `phoenix_live_reload` in your projects by adding it to your `mix.exs` dependencies: 6 | 7 | ```elixir 8 | def deps do 9 | [{:phoenix_live_reload, "~> 1.5"}] 10 | end 11 | ``` 12 | 13 | You can configure the reloading interval in ms in your `config/dev.exs`: 14 | 15 | ```elixir 16 | # Watch static and templates for browser reloading. 17 | config :my_app, MyAppWeb.Endpoint, 18 | live_reload: [ 19 | interval: 1000, 20 | patterns: [ 21 | ... 22 | ``` 23 | 24 | The default interval is 100ms. 25 | 26 | ## Streaming serving logs to the web console 27 | 28 | > *Note:* This feature is only available for Elixir v1.15+ 29 | 30 | Streaming server logs that you see in the terminal when running `mix phx.server` can be useful to have on the client during development, especially when debugging with SPA fetch callbacks, GraphQL queries, or LiveView actions in the browsers web console. You can enable log streaming to collocate client and server logs in the web console with the `web_console_logger` configuration in your `config/dev.exs`: 31 | 32 | ```elixir 33 | config :my_app, MyAppWeb.Endpoint, 34 | live_reload: [ 35 | interval: 1000, 36 | patterns: [...], 37 | web_console_logger: true 38 | ] 39 | ``` 40 | 41 | Next, you'll need to listen for the `"phx:live_reload:attached"` event and enable client logging by calling the reloader's `enableServerLogs()` function, for example: 42 | 43 | ```javascript 44 | window.addEventListener("phx:live_reload:attached", ({detail: reloader}) => { 45 | // enable server log streaming to client. 46 | // disable with reloader.disableServerLogs() 47 | reloader.enableServerLogs() 48 | }) 49 | ``` 50 | 51 | ## Jumping to HEEx function definitions 52 | 53 | Many times it's useful to inspect the HTML DOM tree to find where markup is being rendered from within your application. HEEx supports annotating rendered HTML with HTML comments that give you the file/line of a HEEx function component and caller. `:phoenix_live_reload` will look for the `PLUG_EDITOR` environment export (used by the plug debugger page to link to source code) to launch a configured URL of your choice to open your code editor to the file-line of the HTML annotation. For example, the following export on your system would open vscode at the correct file/line: 54 | 55 | ``` 56 | export PLUG_EDITOR="vscode://file/__FILE__:__LINE__" 57 | ``` 58 | 59 | The `vscode://` protocol URL will open vscode with placeholders of `__FILE__:__LINE__` substituted at runtime. Check your editor's documentation on protocol URL support. To open your configured editor URL when an element is clicked, say with alt-click, you can wire up an event listener within your `"phx:live_reload:attached"` callback and make use of the reloader's `openEditorAtCaller` and `openEditorAtDef` functions, passing the event target as the DOM node to reference for HEEx file:line annotation information. For example: 60 | 61 | ```javascript 62 | window.addEventListener("phx:live_reload:attached", ({detail: reloader}) => { 63 | // Enable server log streaming to client. Disable with reloader.disableServerLogs() 64 | reloader.enableServerLogs() 65 | 66 | // Open configured PLUG_EDITOR at file:line of the clicked element's HEEx component 67 | // 68 | // * click with "c" key pressed to open at caller location 69 | // * click with "d" key pressed to open at function component definition location 70 | let keyDown 71 | window.addEventListener("keydown", e => keyDown = e.key) 72 | window.addEventListener("keyup", e => keyDown = null) 73 | window.addEventListener("click", e => { 74 | if(keyDown === "c"){ 75 | e.preventDefault() 76 | e.stopImmediatePropagation() 77 | reloader.openEditorAtCaller(e.target) 78 | } else if(keyDown === "d"){ 79 | e.preventDefault() 80 | e.stopImmediatePropagation() 81 | reloader.openEditorAtDef(e.target) 82 | } 83 | }, true) 84 | window.liveReloader = reloader 85 | }) 86 | ``` 87 | 88 | ## Backends 89 | 90 | This project uses [`FileSystem`](https://github.com/falood/file_system) as a dependency to watch your filesystem whenever there is a change and it supports the following operating systems: 91 | 92 | * Linux via [inotify](https://github.com/rvoicilas/inotify-tools/wiki) (installation required) 93 | * Windows via [inotify-win](https://github.com/thekid/inotify-win) (no installation required) 94 | * Mac OS X via fsevents (no installation required) 95 | * FreeBSD/OpenBSD/~BSD via [inotify](https://github.com/rvoicilas/inotify-tools/wiki) (installation required) 96 | 97 | There is also a `:fs_poll` backend that polls the filesystem and is available on all Operating Systems in case you don't want to install any dependency. You can configure the `:backend` in your `config/config.exs`: 98 | 99 | ```elixir 100 | config :phoenix_live_reload, 101 | backend: :fs_poll 102 | ``` 103 | 104 | By default the entire application directory is watched by the backend. However, with some environments and backends, this may be inefficient, resulting in slow response times to file modifications. To account for this, it's also possible to explicitly declare a list of directories for the backend to watch (they must be relative to the project root, otherwise they are just ignored), and additional options for the backend: 105 | 106 | ```elixir 107 | config :phoenix_live_reload, 108 | dirs: [ 109 | "priv/static", 110 | "priv/gettext", 111 | "lib/example_web/live", 112 | "lib/example_web/views", 113 | "lib/example_web/templates", 114 | "../another_project/priv/static", # Contents of this directory is not watched 115 | "/another_project/priv/static", # Contents of this directory is not watched 116 | ], 117 | backend: :fs_poll, 118 | backend_opts: [ 119 | interval: 500 120 | ] 121 | ``` 122 | 123 | 124 | ## Skipping remote CSS reload 125 | 126 | All stylesheets are reloaded without a page refresh anytime a style is detected as having changed. In certain cases such as serving stylesheets from a remote host, you may wish to prevent unnecessary reload of these stylesheets during development. For this, you can include a `data-no-reload` attribute on the link tag, ie: 127 | 128 | 129 | 130 | ## Differences between [Phoenix.CodeReloader](https://hexdocs.pm/phoenix/Phoenix.CodeReloader.html#content) 131 | 132 | [Phoenix.CodeReloader](https://hexdocs.pm/phoenix/Phoenix.CodeReloader.html#content) recompiles code in the lib directory. This means that if you change anything in the lib directory (such as a context) then the Elixir code will be reloaded and used on your next request. 133 | 134 | In contrast, this project adds a plug which injects some JavaScript into your page with a WebSocket connection to the server. When you make a change to anything in your config for live\_reload (JavaScript, stylesheets, templates and views by default) then the page will be reloaded in response to a message sent via the WebSocket. If the change was to an Elixir file then it will be recompiled and served when the page is reloaded. If it is JavaScript or CSS, then only assets are reloaded, without triggering a full page load. 135 | 136 | ## License 137 | 138 | [Same license as Phoenix](https://github.com/phoenixframework/phoenix/blob/master/LICENSE.md). 139 | -------------------------------------------------------------------------------- /priv/static/phoenix_live_reload.js: -------------------------------------------------------------------------------- 1 | let getFreshUrl = (url) => { 2 | let date = Math.round(Date.now() / 1000).toString() 3 | let cleanUrl = url.replace(/(&|\?)vsn=\d*/, "") 4 | let freshUrl = cleanUrl + (cleanUrl.indexOf("?") >= 0 ? "&" : "?") + "vsn=" + date 5 | return freshUrl; 6 | } 7 | 8 | let buildFreshLinkUrl = (link) => { 9 | let newLink = document.createElement('link') 10 | let onComplete = () => { 11 | if(link.parentNode !== null){ 12 | link.parentNode.removeChild(link) 13 | } 14 | } 15 | 16 | newLink.onerror = onComplete 17 | newLink.onload = onComplete 18 | link.setAttribute("data-pending-removal", "") 19 | newLink.setAttribute("rel", "stylesheet") 20 | newLink.setAttribute("type", "text/css") 21 | newLink.setAttribute("href", getFreshUrl(link.href)) 22 | link.parentNode.insertBefore(newLink, link.nextSibling) 23 | return newLink 24 | } 25 | 26 | let buildFreshImportUrl = (style) => { 27 | let newStyle = document.createElement('style') 28 | let onComplete = () => { 29 | if (style.parentNode !== null) { 30 | style.parentNode.removeChild(style) 31 | } 32 | } 33 | 34 | let originalCSS = style.textContent || style.innerHTML 35 | let freshCSS = originalCSS.replace(/@import\s+(?:url\()?['"]?([^'"\)]+)['"]?\)?/g, (match, url) => { 36 | const freshUrl = getFreshUrl(url); 37 | 38 | if (match.includes('url(')) { 39 | return `@import url("${freshUrl}")` 40 | } else { 41 | return `@import "${freshUrl}"` 42 | } 43 | }) 44 | 45 | newStyle.onerror = onComplete 46 | newStyle.onload = onComplete 47 | style.setAttribute("data-pending-removal", "") 48 | newStyle.setAttribute("type", "text/css") 49 | newStyle.textContent = freshCSS 50 | 51 | style.parentNode.insertBefore(newStyle, style.nextSibling) 52 | return newStyle 53 | } 54 | 55 | let repaint = () => { 56 | let browser = navigator.userAgent.toLowerCase() 57 | if (browser.indexOf("chrome") > -1) { 58 | setTimeout(() => document.body.offsetHeight, 25) 59 | } 60 | } 61 | 62 | let cssStrategy = () => { 63 | let reloadableLinkElements = window.parent.document.querySelectorAll( 64 | "link[rel=stylesheet]:not([data-no-reload]):not([data-pending-removal])" 65 | ) 66 | 67 | Array.from(reloadableLinkElements) 68 | .filter(link => link.href) 69 | .forEach(link => buildFreshLinkUrl(link)) 70 | 71 | let reloadablestyles = window.parent.document.querySelectorAll( 72 | "style:not([data-no-reload]):not([data-pending-removal])" 73 | ) 74 | 75 | Array.from(reloadablestyles) 76 | .filter(style => style.textContent.includes("@import")) 77 | .forEach(style => buildFreshImportUrl(style)) 78 | 79 | repaint() 80 | }; 81 | 82 | let pageStrategy = channel => { 83 | channel.off("assets_change") 84 | window[targetWindow].location.reload() 85 | } 86 | 87 | let reloadStrategies = { 88 | css: reloadPageOnCssChanges ? pageStrategy : cssStrategy, 89 | page: pageStrategy 90 | }; 91 | 92 | class LiveReloader { 93 | constructor(socket){ 94 | this.socket = socket 95 | this.logsEnabled = false 96 | this.enabledOnce = false 97 | this.editorURL = null 98 | } 99 | enable(){ 100 | this.socket.onOpen(() => { 101 | if(this.enabledOnce){ return } 102 | this.enabledOnce = true 103 | if(["complete", "loaded", "interactive"].indexOf(parent.document.readyState) >= 0){ 104 | this.dispatchConnected() 105 | } else { 106 | parent.addEventListener("load", () => this.dispatchConnected()) 107 | } 108 | }) 109 | 110 | this.channel = socket.channel("phoenix:live_reload", {}) 111 | this.channel.on("assets_change", msg => { 112 | let reloadStrategy = reloadStrategies[msg.asset_type] || reloadStrategies.page 113 | setTimeout(() => reloadStrategy(this.channel), interval) 114 | }) 115 | this.channel.on("log", ({msg, level}) => this.logsEnabled && this.log(level, msg)) 116 | this.channel.join().receive("ok", ({editor_url}) => { 117 | this.editorURL = editor_url 118 | }) 119 | this.socket.connect() 120 | } 121 | 122 | disable(){ 123 | this.channel.leave() 124 | socket.disconnect() 125 | } 126 | 127 | enableServerLogs(){ this.logsEnabled = true } 128 | disableServerLogs(){ this.logsEnabled = false } 129 | 130 | openEditorAtCaller(targetNode){ 131 | if(!this.editorURL){ 132 | return console.error("phoenix_live_reload cannot openEditorAtCaller without configured PLUG_EDITOR") 133 | } 134 | 135 | let fileLineApp = this.closestCallerFileLine(targetNode) 136 | if(fileLineApp){ 137 | this.openFullPath(...fileLineApp) 138 | } 139 | } 140 | 141 | openEditorAtDef(targetNode){ 142 | if(!this.editorURL){ 143 | return console.error("phoenix_live_reload cannot openEditorAtDef without configured PLUG_EDITOR") 144 | } 145 | 146 | let fileLineApp = this.closestDefFileLine(targetNode) 147 | if(fileLineApp){ 148 | this.openFullPath(...fileLineApp) 149 | } 150 | } 151 | 152 | // private 153 | 154 | openFullPath(file, line, app){ 155 | console.log("opening full path", file, line, app) 156 | this.channel.push("full_path", {rel_path: file, app: app}) 157 | .receive("ok", ({full_path}) => { 158 | console.log("full path", full_path) 159 | let url = this.editorURL 160 | .replace("__RELATIVEFILE__", file) 161 | .replace("__FILE__", full_path) 162 | .replace("__LINE__", line) 163 | window.open(url, "_self") 164 | }) 165 | .receive("error", reason => console.error("failed to resolve full path", reason)) 166 | } 167 | 168 | dispatchConnected(){ 169 | parent.dispatchEvent(new CustomEvent("phx:live_reload:attached", {detail: this})) 170 | } 171 | 172 | log(level, str){ 173 | let levelColor = level === "debug" ? "darkcyan" : "inherit" 174 | let consoleFunc = this.logFunc(level) 175 | this.logMsg(consoleFunc, str, levelColor) 176 | } 177 | 178 | logMsg(fun, str, color) { 179 | fun(`%c📡 ${str}`, `color: ${color};`) 180 | } 181 | 182 | logFunc(level){ 183 | switch(level) { 184 | case "debug": 185 | return console.debug; 186 | case "info": 187 | return console.info; 188 | case "warning": 189 | return console.warn; 190 | default: 191 | return console.error; 192 | } 193 | } 194 | 195 | closestCallerFileLine(node){ 196 | while(node.previousSibling){ 197 | node = node.previousSibling 198 | if(node.nodeType === Node.COMMENT_NODE){ 199 | let callerComment = node.previousSibling 200 | let callerMatch = callerComment && 201 | callerComment.nodeType === Node.COMMENT_NODE && 202 | callerComment.nodeValue.match(/\s@caller\s+(.+):(\d+)\s\((.*)\)\s/i) 203 | 204 | if(callerMatch){ 205 | return [callerMatch[1], callerMatch[2], callerMatch[3]] 206 | } 207 | } 208 | } 209 | if(node.parentNode){ return this.closestCallerFileLine(node.parentNode) } 210 | } 211 | 212 | closestDefFileLine(node){ 213 | while(node.previousSibling){ 214 | node = node.previousSibling 215 | if(node.nodeType === Node.COMMENT_NODE){ 216 | let fcMatch = node.nodeValue.match(/.*>\s([\w\/]+.*ex):(\d+)\s\((.*)\)\s/i) 217 | if(fcMatch){ 218 | return [fcMatch[1], fcMatch[2], fcMatch[3]] 219 | } 220 | } 221 | } 222 | if(node.parentNode){ return this.closestDefFileLine(node.parentNode) } 223 | } 224 | } 225 | 226 | reloader = new LiveReloader(socket) 227 | reloader.enable() 228 | -------------------------------------------------------------------------------- /lib/phoenix_live_reload/live_reloader.ex: -------------------------------------------------------------------------------- 1 | defmodule Phoenix.LiveReloader do 2 | @moduledoc """ 3 | Router for live-reload detection in development. 4 | 5 | ## Usage 6 | 7 | Add the `Phoenix.LiveReloader` plug within a `code_reloading?` block 8 | in your Endpoint, ie: 9 | 10 | if code_reloading? do 11 | socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket 12 | plug Phoenix.CodeReloader 13 | plug Phoenix.LiveReloader 14 | end 15 | 16 | ## Configuration 17 | 18 | All live-reloading configuration must be done inside the `:live_reload` 19 | key of your endpoint, such as this: 20 | 21 | config :my_app, MyApp.Endpoint, 22 | ... 23 | live_reload: [ 24 | patterns: [ 25 | ~r{priv/static/.*(js|css|png|jpeg|jpg|gif)$}, 26 | ~r{lib/my_app_web/views/.*(ex)$}, 27 | ~r{lib/my_app_web/templates/.*(eex)$} 28 | ] 29 | ] 30 | 31 | The following options are supported: 32 | 33 | * `:patterns` - a list of patterns to trigger the live reloading. 34 | This option is required to enable any live reloading. 35 | 36 | * `:notify` - a keyword list of topics pointing to a list of patterns. 37 | A message of the form `{:phoenix_live_reload, topic, path}` will be 38 | broadcast on the topic whenever file in the list of patterns changes. 39 | 40 | * `:debounce` - an integer in milliseconds to wait before sending 41 | live reload events to the browser. Defaults to `0`. 42 | 43 | * `:iframe_attrs` - attrs to be given to the iframe injected by 44 | live reload. Expects a keyword list of atom keys and string values. 45 | 46 | * `:target_window` - the window that will be reloaded, as an atom. 47 | Valid values are `:top` and `:parent`. Defaults to `:parent`. 48 | 49 | * `:url` - the URL of the live reload socket connection. By default 50 | it will use the browser's host and port. 51 | 52 | * `:suffix` - if you are running live-reloading on an umbrella app, 53 | you may want to give a different suffix to each socket connection. 54 | You can do so with the `:suffix` option: 55 | 56 | live_reload: [ 57 | suffix: "/proxied/app/path" 58 | ] 59 | 60 | And then configure the endpoint to use the same suffix: 61 | 62 | if code_reloading? do 63 | socket "/phoenix/live_reload/socket/proxied/app/path", Phoenix.LiveReloader.Socket 64 | ... 65 | end 66 | 67 | * `:reload_page_on_css_changes` - If true, CSS changes will trigger a full 68 | page reload like other asset types instead of the default hot reload. 69 | Useful when class names are determined at runtime, for example when 70 | working with CSS modules. Defaults to false. 71 | 72 | * `:web_console_logger` - If true, the live reloader will log messages 73 | to the web console in your browser. Defaults to false. 74 | *Note*: Requires Elixir v1.15+ and your application javascript bundle will need 75 | to enable logs. See the README for more information. 76 | 77 | In an umbrella app, if you want to enable live reloading based on code 78 | changes in sibling applications, set the `reloadable_apps` option on your 79 | endpoint to ensure the code will be recompiled, then add the dirs to 80 | `:phoenix_live_reload` to trigger page reloads: 81 | 82 | # in config/dev.exs 83 | root_path = 84 | __ENV__.file 85 | |> Path.dirname() 86 | |> Path.join("..") 87 | |> Path.expand() 88 | 89 | config :phoenix_live_reload, :dirs, [ 90 | Path.join([root_path, "apps", "app1"]), 91 | Path.join([root_path, "apps", "app2"]), 92 | ] 93 | 94 | You'll also want to be sure that the configured `:patterns` for 95 | `live_reload` will match files in the sibling application. 96 | """ 97 | 98 | import Plug.Conn 99 | @behaviour Plug 100 | 101 | phoenix_path = Application.app_dir(:phoenix, "priv/static/phoenix.js") 102 | reload_path = Application.app_dir(:phoenix_live_reload, "priv/static/phoenix_live_reload.js") 103 | @external_resource phoenix_path 104 | @external_resource reload_path 105 | 106 | @html_before """ 107 | 108 | 109 | 116 | 117 | """ 118 | 119 | def init(opts) do 120 | opts 121 | end 122 | 123 | def call(%Plug.Conn{path_info: ["phoenix", "live_reload", "frame" | _suffix]} = conn, _) do 124 | endpoint = conn.private.phoenix_endpoint 125 | config = endpoint.config(:live_reload) 126 | url = config[:url] || endpoint.path("/phoenix/live_reload/socket#{suffix(endpoint)}") 127 | interval = config[:interval] || 100 128 | target_window = get_target_window(config[:target_window] || :parent) 129 | reload_page_on_css_changes? = config[:reload_page_on_css_changes] || false 130 | 131 | conn 132 | |> put_resp_content_type("text/html") 133 | |> send_resp(200, [ 134 | @html_before, 135 | ~s[var socket = new Phoenix.Socket("#{url}");\n], 136 | ~s[var interval = #{interval};\n], 137 | ~s[var targetWindow = "#{target_window}";\n], 138 | ~s[var reloadPageOnCssChanges = #{reload_page_on_css_changes?};\n], 139 | @html_after 140 | ]) 141 | |> halt() 142 | end 143 | 144 | def call(conn, _) do 145 | endpoint = conn.private.phoenix_endpoint 146 | config = endpoint.config(:live_reload) 147 | 148 | if match?([_ | _], config[:patterns]) || config[:web_console_logger] do 149 | conn 150 | |> put_private(:phoenix_live_reload, true) 151 | |> before_send_inject_reloader(endpoint, config) 152 | else 153 | conn 154 | end 155 | end 156 | 157 | defp before_send_inject_reloader(conn, endpoint, config) do 158 | register_before_send(conn, fn conn -> 159 | if conn.resp_body != nil and html?(conn) do 160 | resp_body = IO.iodata_to_binary(conn.resp_body) 161 | 162 | if has_body?(resp_body) and :code.is_loaded(endpoint) do 163 | {head, [last]} = Enum.split(String.split(resp_body, ""), -1) 164 | head = Enum.intersperse(head, "") 165 | body = [head, reload_assets_tag(conn, endpoint, config), "" | last] 166 | put_in(conn.resp_body, body) 167 | else 168 | conn 169 | end 170 | else 171 | conn 172 | end 173 | end) 174 | end 175 | 176 | defp html?(conn) do 177 | case get_resp_header(conn, "content-type") do 178 | [] -> false 179 | [type | _] -> String.starts_with?(type, "text/html") 180 | end 181 | end 182 | 183 | defp has_body?(resp_body), do: String.contains?(resp_body, " 198 | "please remove it or use :iframe_attrs instead" 199 | ) 200 | 201 | Keyword.put_new(attrs, :class, config[:iframe_class]) 202 | else 203 | attrs 204 | end 205 | 206 | IO.iodata_to_binary([""]) 207 | end 208 | 209 | defp attrs(attrs) do 210 | Enum.map(attrs, fn 211 | {_key, nil} -> [] 212 | {_key, false} -> [] 213 | {key, true} -> [?\s, key(key)] 214 | {key, value} -> [?\s, key(key), ?=, ?", value(value), ?"] 215 | end) 216 | end 217 | 218 | defp key(key) do 219 | key 220 | |> to_string() 221 | |> String.replace("_", "-") 222 | |> Plug.HTML.html_escape_to_iodata() 223 | end 224 | 225 | defp value(value) do 226 | value 227 | |> to_string() 228 | |> Plug.HTML.html_escape_to_iodata() 229 | end 230 | 231 | defp suffix(endpoint), do: endpoint.config(:live_reload)[:suffix] || "" 232 | 233 | defp get_target_window(:parent), do: "parent" 234 | 235 | defp get_target_window(_), do: "top" 236 | end 237 | --------------------------------------------------------------------------------