defmodule Mix.Tasks.Flick.Install do
@moduledoc """
Installs flick into a Phoenix project.
## What it does
1. Vendors `flick.min.js.gz` (pre-minified, pre-compressed) to
`assets/vendor/flick.min.js.gz` and
`priv/static/assets/js/flick.min.js.gz`.
2. Patches the root layout to add a `<script>` tag before `app.js`.
3. Generates a `WebSock` behaviour module skeleton.
4. Generates an upgrade controller.
5. Patches `router.ex` with a `get` route for the WebSocket path.
6. Appends a starter hook to `assets/js/app.js`.
`Plug.Static` serves `.gz` files automatically when `gzip: true` is set
(the Phoenix default). No esbuild or runtime minification step required.
Steps 3–6 are skipped when `--no-boilerplate` is passed.
All steps are idempotent — re-running is safe.
## Usage
mix flick.install
mix flick.install --module TickerSocket --path /ws/ticker
mix flick.install --channels
mix flick.install --channels --no-boilerplate
mix flick.install --layout lib/my_app_web/components/layouts/root.html.heex
mix flick.install --no-boilerplate
mix flick.install --yes
mix flick.install --channels --no-plug-crypto
## Options
* `--module` - WebSock module name suffix appended to the `<AppWeb>`
namespace. Defaults to `MySocket`. Produces `<AppWeb>.MySocket` and
`<AppWeb>.MySocketController`.
* `--path` - WebSocket URL path used in the router and JS hook.
Defaults to `/ws`.
* `--layout` - path to the root layout file. Defaults to
`lib/<app>_web/components/layouts/root.html.heex`.
* `--skip-layout` - skip the root layout patch.
* `--channels` - also vendor `flick-channel.min.js.gz` for projects
using `Flick.Socket.Serializer` with Phoenix Channels. Can be combined
with `--no-boilerplate` to add Channels support to an existing
installation without re-running the boilerplate generator.
* `--no-boilerplate` - only vendor the JS files and patch the layout;
skip steps 3–6.
* `--no-plug-crypto` - skip the `:plug_crypto` dependency check.
By default, the installer requires `:plug_crypto` so that
`Flick.Socket.Serializer.decode!/2` calls
`Plug.Crypto.non_executable_binary_to_term/2` instead of
`:erlang.binary_to_term/2` for decoding client payloads, guaranteeing
rejection of executable terms regardless of OTP version. Pass this
flag to opt out and rely on `:erlang.binary_to_term/2` with `:safe`
alone.
* `--yes` - apply all changes without prompting for confirmation.
"""
@shortdoc "Installs flick into a Phoenix project"
use Mix.Task
@js_name "flick.min.js.gz"
@vendor_name "flick.min.js.gz"
@impl Mix.Task
def run(args) do
{opts, _} =
OptionParser.parse!(args,
strict: [
layout: :string,
skip_layout: :boolean,
channels: :boolean,
no_boilerplate: :boolean,
module: :string,
path: :string,
plug_crypto: :boolean,
yes: :boolean
]
)
check_websock_adapter!()
check_plug_crypto!(opts[:plug_crypto])
app_name = Mix.Project.config()[:app] |> to_string()
web_module = Macro.camelize(app_name) <> "Web"
mod_suffix = opts[:module] || "MySocket"
ws_path = opts[:path] || "/ws"
ctrl_suffix = mod_suffix <> "Controller"
vendor_path = Path.join(["assets", "vendor", @vendor_name])
static_path = Path.join(["priv", "static", "assets", "js", @js_name])
layout_path = opts[:layout] || default_layout_path(app_name)
socket_file = web_lib_path(app_name, module_to_filename(mod_suffix))
ctrl_file = web_lib_path(app_name, module_to_filename(ctrl_suffix))
router_file = web_lib_path(app_name, "router.ex")
app_js = Path.join(["assets", "js", "app.js"])
route_line = ~s( get "#{ws_path}", #{web_module}.#{ctrl_suffix}, :connect)
channel_serializer_path =
if opts[:channels],
do: Path.join(["assets", "vendor", "flick-channel.min.js.gz"])
# ------------------------------------------------------------------
# Plan
# ------------------------------------------------------------------
plan = []
plan = plan ++ [{:write, vendor_path, :flick_js}]
plan = plan ++ [{:write, static_path, :flick_js}]
plan =
if channel_serializer_path,
do: plan ++ [{:write, channel_serializer_path, :channel_serializer}],
else: plan
plan =
if opts[:skip_layout],
do: plan,
else: plan ++ [plan_layout(layout_path)]
plan =
if opts[:no_boilerplate],
do: plan,
else:
plan ++
[
plan_new_file(socket_file, "WebSock module",
generate_socket_content(web_module, mod_suffix)),
plan_new_file(ctrl_file, "controller",
generate_controller_content(web_module, mod_suffix, ctrl_suffix)),
plan_router(router_file, ws_path, route_line),
plan_app_js(app_js)
]
print_plan(plan, web_module, mod_suffix, ctrl_suffix, ws_path, opts)
unless opts[:yes] do
unless Mix.shell().yes?("\nProceed?") do
Mix.shell().info("Aborted.")
exit(:normal)
end
end
# ------------------------------------------------------------------
# Execute
# ------------------------------------------------------------------
Enum.each(plan, fn
{:write, path, :flick_js} ->
write_file!(path, read_priv_file!(@js_name))
{:write, path, :channel_serializer} ->
write_file!(path, read_priv_file!("flick-channel.min.js.gz"))
{:patch_layout, path} ->
patch_layout!(path)
{:skip, _path, _reason} ->
:ok
{:warn, msg} ->
Mix.shell().info("\n! #{msg}")
{:create, path, content} ->
path |> Path.dirname() |> File.mkdir_p!()
File.write!(path, content)
Mix.shell().info("* wrote #{path}")
{:patch_router, path} ->
patch_router!(path, route_line, ws_path)
{:patch_app_js, path} ->
patch_app_js!(path, ws_path)
end)
Mix.shell().info("""
Done. Next steps:
1. Ensure Plug.Static has `gzip: true` in your endpoint (Phoenix default).
2. Run `mix assets.deploy` or restart the dev server.
3. Edit #{socket_file} to push ETF frames.
""")
end
# ------------------------------------------------------------------
# Planning helpers
# ------------------------------------------------------------------
defp plan_layout(path) do
cond do
not File.exists?(path) ->
{:warn, "root layout not found at #{path} — add the <script> tag manually"}
File.read!(path) |> String.contains?("/assets/js/flick.min.js") ->
{:skip, path, "layout already references flick.min.js"}
File.read!(path) |> String.contains?(~s|~p"/assets/js/app.js"|) ->
{:patch_layout, path}
true ->
{:warn, "no app.js <script> found in #{path} — add the <script> tag manually"}
end
end
defp plan_new_file(path, label, content) do
if File.exists?(path),
do: {:skip, path, "#{label} already exists"},
else: {:create, path, content}
end
defp plan_router(path, ws_path, route_line) do
cond do
not File.exists?(path) ->
{:warn, "router not found at #{path} — add this route manually:\n #{route_line}"}
File.read!(path) |> String.contains?(~s|"#{ws_path}"|) ->
{:skip, path, "route for #{ws_path} already present"}
File.read!(path) |> String.contains?("scope ") ->
{:patch_router, path}
true ->
{:warn, "no scope block found in #{path} — add this route manually:\n #{route_line}"}
end
end
defp plan_app_js(path) do
cond do
not File.exists?(path) ->
{:warn, "#{path} not found — add the flick JS hook manually (see README step 6)"}
File.read!(path) |> String.contains?("flick WebSocket") ->
{:skip, path, "flick hook already present"}
true ->
{:patch_app_js, path}
end
end
defp print_plan(plan, web_module, mod_suffix, ctrl_suffix, ws_path, opts) do
Mix.shell().info("""
App: #{Mix.Project.config()[:app]} (#{web_module})
WebSock: #{web_module}.#{mod_suffix}
Controller: #{web_module}.#{ctrl_suffix}
WS path: #{ws_path}
#{if opts[:channels], do: "Mode: Phoenix Channels\n", else: ""}
Planned actions:
""")
Enum.each(plan, fn
{:write, path, _} -> Mix.shell().info(" write #{path}")
{:patch_layout, path} -> Mix.shell().info(" patch #{path} (add <script> tag)")
{:patch_router, path} -> Mix.shell().info(" patch #{path} (insert get route)")
{:patch_app_js, path} -> Mix.shell().info(" append #{path} (flick WebSocket hook)")
{:create, path, _} -> Mix.shell().info(" create #{path}")
{:skip, path, reason} -> Mix.shell().info(" skip #{path} (#{reason})")
{:warn, msg} -> Mix.shell().info(" warn #{msg}")
end)
end
# ------------------------------------------------------------------
# Execution helpers
# ------------------------------------------------------------------
defp patch_router!(router_file, route_line, ws_path) do
contents = File.read!(router_file)
updated =
String.replace(
contents,
~r/^([ \t]*scope\b[^\n]*\bdo[ \t]*)$/m,
"\\1\n#{route_line}",
global: false
)
File.write!(router_file, updated)
Mix.shell().info("* patched #{router_file} with route for #{ws_path}")
end
defp patch_app_js!(app_js, ws_path) do
hook = """
// flick WebSocket — #{ws_path}
const _flickProto = location.protocol === "https:" ? "wss" : "ws"
const _flickUrl = `${_flickProto}://${location.host}#{ws_path}`
const _flickWs = new WebSocket(_flickUrl)
_flickWs.binaryType = "arraybuffer"
_flickWs.onmessage = (event) => {
const msg = window.Flick.decode(event.data)
const type = msg.type && msg.type.value ? msg.type.value : String(msg.type)
console.log("flick message:", type, msg)
}
"""
File.write!(app_js, hook, [:append])
Mix.shell().info("* appended flick hook to #{app_js}")
end
defp patch_layout!(layout_path) do
contents = File.read!(layout_path)
updated =
String.replace(
contents,
~r/^([ \t]*)(<script[^>]*src=\{~p"\/assets\/js\/app\.js"\}.*)$/m,
~s(\\1<script src={~p"/assets/js/flick.min.js"}></script>\n\\1\\2)
)
File.write!(layout_path, updated)
Mix.shell().info("* patched #{layout_path} with flick.min.js <script> tag")
end
defp check_websock_adapter! do
deps = Mix.Project.config()[:deps] || []
unless Enum.any?(deps, fn dep -> elem(dep, 0) == :websock_adapter end) do
Mix.raise("""
:flick requires the :websock_adapter dependency. Add it to your mix.exs:
{:websock_adapter, "~> 0.5"}
Then run `mix deps.get` and retry.
""")
end
end
defp check_plug_crypto!(false), do: :ok
defp check_plug_crypto!(_) do
deps = Mix.Project.config()[:deps] || []
unless Enum.any?(deps, fn dep -> elem(dep, 0) == :plug_crypto end) do
Mix.raise("""
:flick requires the :plug_crypto dependency. Add it to your mix.exs:
{:plug_crypto, "~> 1.2 or ~> 2.0"}
Then run `mix deps.get` and retry, or pass --no-plug-crypto to skip
this check and decode with :erlang.binary_to_term/2 alone using `:safe`
option.
""")
end
end
defp read_priv_file!(name) do
path = Application.app_dir(:flick, "priv/#{name}")
case File.read(path) do
{:ok, source} -> source
{:error, reason} -> Mix.raise("Failed to read #{path}: #{:file.format_error(reason)}")
end
end
defp write_file!(path, contents) do
path |> Path.dirname() |> File.mkdir_p!()
File.write!(path, contents)
Mix.shell().info("* wrote #{path}")
end
defp web_lib_path(app_name, filename) do
Path.join(["lib", "#{app_name}_web", filename])
end
defp module_to_filename(module_suffix) do
module_suffix
|> Macro.underscore()
|> Kernel.<>(".ex")
end
defp default_layout_path(app_name) do
Path.join(["lib", "#{app_name}_web", "components", "layouts", "root.html.heex"])
end
defp generate_socket_content(web_module, mod_suffix) do
"""
defmodule #{web_module}.#{mod_suffix} do
@moduledoc \"\"\"
Raw WebSocket handler streaming ETF binary frames, decoded client-side
with flick.js.
\"\"\"
@behaviour WebSock
@impl WebSock
def init(args) do
{:ok, %{args: args}}
end
@impl WebSock
def handle_in(_frame, state), do: {:ok, state}
@impl WebSock
def handle_info(_msg, state), do: {:ok, state}
@impl WebSock
def terminate(_reason, _state), do: :ok
end
"""
end
defp generate_controller_content(web_module, mod_suffix, ctrl_suffix) do
"""
defmodule #{web_module}.#{ctrl_suffix} do
use #{web_module}, :controller
def connect(conn, params) do
WebSockAdapter.upgrade(conn, #{web_module}.#{mod_suffix}, params, [])
end
end
"""
end
end