diff --git a/assets/js/phoenix_live_view/constants.ts b/assets/js/phoenix_live_view/constants.ts index 8e3dc6c3b2..8d3032d4fa 100644 --- a/assets/js/phoenix_live_view/constants.ts +++ b/assets/js/phoenix_live_view/constants.ts @@ -64,6 +64,7 @@ export const FOCUSABLE_INPUTS = [ export const CHECKABLE_INPUTS = ["checkbox", "radio"]; export const PHX_HAS_SUBMITTED = "phx-has-submitted"; export const PHX_SESSION = "data-phx-session"; +export const PHX_ADOPT = "data-phx-adopt"; export const PHX_VIEW_SELECTOR = `[${PHX_SESSION}]`; export const PHX_STICKY = "data-phx-sticky"; export const PHX_STATIC = "data-phx-static"; diff --git a/assets/js/phoenix_live_view/view.ts b/assets/js/phoenix_live_view/view.ts index 77fa0f10c1..d4f375b541 100644 --- a/assets/js/phoenix_live_view/view.ts +++ b/assets/js/phoenix_live_view/view.ts @@ -44,6 +44,7 @@ import { PHX_PORTAL, PHX_TELEPORTED_REF, PHX_TELEPORTED_SRC, + PHX_ADOPT, } from "./constants"; import { @@ -97,6 +98,7 @@ export default class View { private channel: Channel; private rendered: Rendered | null; private flash: string | null; + private liveReferer: string | null; private parent: View | null; private ref: number; private lastAckRef: number | null; @@ -130,6 +132,7 @@ export default class View { this.isDead = false; this.liveSocket = liveSocket; this.flash = flash; + this.liveReferer = liveReferer; this.parent = parentView; this.root = parentView ? parentView.root : this; this.el = el; @@ -190,19 +193,23 @@ export default class View { this.children = this.parent ? null : {}; this.root.children![this.id] = {}; this.formsForRecovery = {}; - this.channel = this.liveSocket.channel(`lv:${this.id}`, () => { + this.channel = this.buildChannel(); + this.portalElementIds = new Set(); + } + + buildChannel() { + return this.liveSocket.channel(`lv:${this.id}`, () => { const url = this.href && this.expandURL(this.href); return { redirect: this.redirect ? url : undefined, url: this.redirect ? undefined : url || undefined, - params: this.connectParams(liveReferer), + params: this.connectParams(this.liveReferer), session: this.getSession(), static: this.getStatic(), flash: this.flash ?? undefined, sticky: this.el.hasAttribute(PHX_STICKY), }; }); - this.portalElementIds = new Set(); } setHref(href) { @@ -251,6 +258,14 @@ export default class View { return val === "" ? null : val; } + getAdopt(): string | null { + return this.el.getAttribute(PHX_ADOPT); + } + + clearAdopt() { + this.el.removeAttribute(PHX_ADOPT); + } + destroy(callback = function () {}) { this.destroyAllChildren(); this.destroyPortalElements(); @@ -1150,11 +1165,28 @@ export default class View { callback ? callback(this.joinCount, onDone) : onDone(); }; - this.wrapPush(() => this.channel.join(), { - ok: (resp) => this.liveSocket.requestDOMUpdate(() => this.onJoin(resp)), - error: (error) => this.onJoinError(error), - timeout: () => this.onJoinError({ reason: "timeout" }), - }); + const adoptToken = this.getAdopt(); + this.clearAdopt(); + if (adoptToken && "adopt" in this.channel) { + // @ts-expect-error adopt is not in the type definitions yet + this.wrapPush(() => this.channel.adopt(adoptToken), { + ok: (resp) => this.liveSocket.requestDOMUpdate(() => this.onJoin(resp)), + error: ({ reason }) => { + if (reason === "invalid adoption") { + this.channel.leave(); + this.channel = this.buildChannel(); + this.join(); + } + }, + timeout: () => this.onJoinError({ reason: "timeout" }), + }); + } else { + this.wrapPush(() => this.channel.join(), { + ok: (resp) => this.liveSocket.requestDOMUpdate(() => this.onJoin(resp)), + error: (error) => this.onJoinError(error), + timeout: () => this.onJoinError({ reason: "timeout" }), + }); + } } onJoinError(resp) { diff --git a/lib/phoenix_live_view.ex b/lib/phoenix_live_view.ex index 4b287f7579..108354f1cb 100644 --- a/lib/phoenix_live_view.ex +++ b/lib/phoenix_live_view.ex @@ -486,6 +486,12 @@ defmodule Phoenix.LiveView do * `:on_mount` - a list of tuples with module names and arguments to be invoked as `on_mount` hooks + * `:adoptable` - a boolean indicating whether the LiveView is adoptable. + If true, the LiveView can be adopted after the initial mount. + + * `:adoption_timeout` - the timeout in milliseconds for the adoption. + If no socket connects within this time, the LiveView process is terminated. Defaults to 5000ms. + """ def __live__(opts \\ []) do on_mount = opts[:on_mount] || [] @@ -508,7 +514,9 @@ defmodule Phoenix.LiveView do kind: :view, layout: layout, lifecycle: Phoenix.LiveView.Lifecycle.build(on_mount), - log: log + log: log, + adoptable: Keyword.get(opts, :adoptable, false), + adoption_timeout: Keyword.get(opts, :adoption_timeout, 5000) } end diff --git a/lib/phoenix_live_view/application.ex b/lib/phoenix_live_view/application.ex index e888ed6828..3c0ebe08d8 100644 --- a/lib/phoenix_live_view/application.ex +++ b/lib/phoenix_live_view/application.ex @@ -6,6 +6,13 @@ defmodule Phoenix.LiveView.Application do @impl true def start(_type, _args) do Phoenix.LiveView.Logger.install() - Supervisor.start_link([], strategy: :one_for_one, name: Phoenix.LiveView.Supervisor) + + Supervisor.start_link( + [ + {DynamicSupervisor, name: Phoenix.LiveView.AdoptionSupervisor, strategy: :one_for_one} + ], + strategy: :one_for_one, + name: Phoenix.LiveView.Supervisor + ) end end diff --git a/lib/phoenix_live_view/channel.ex b/lib/phoenix_live_view/channel.ex index 4d57711e14..dbbd85c581 100644 --- a/lib/phoenix_live_view/channel.ex +++ b/lib/phoenix_live_view/channel.ex @@ -74,14 +74,27 @@ defmodule Phoenix.LiveView.Channel do end @impl true + def init({:adoptable_mount, init, timeout}) do + # adoptable render happens in handle_call + {:ok, {:adoptable, init, timeout}} + end + def init({pid, _ref}) do {:ok, Process.monitor(pid)} end @impl true + def handle_info({Phoenix.Channel, auth_payload, from, phx_socket}, {:adoptable, state}) do + IO.puts("adopting existing channel!") + mount(auth_payload, from, phx_socket, state) + rescue + # Normalize exceptions for better client debugging + e -> reraise(e, __STACKTRACE__) + end + def handle_info({Phoenix.Channel, auth_payload, from, phx_socket}, ref) do Process.demonitor(ref) - mount(auth_payload, from, phx_socket) + mount(auth_payload, from, phx_socket, nil) rescue # Normalize exceptions for better client debugging e -> reraise(e, __STACKTRACE__) @@ -359,6 +372,21 @@ defmodule Phoenix.LiveView.Channel do handle_changed(state, new_socket, nil) end + def handle_info({@prefix, :adoption_timeout}, {:adoptable, state}) do + # State is still {:adoptable, _}, so we were not adopted. + # Bye! + IO.puts("shutting down adoptable socket due to timeout") + {:stop, :shutdown, {:adoptable, state}} + end + + def handle_info({@prefix, :adoption_timeout}, state) do + {:noreply, state} + end + + def handle_info({@prefix, :connected_diff, diff}, state) do + {:noreply, push_diff(state, diff, nil)} + end + def handle_info(msg, %{socket: socket} = state) do msg |> view_handle_info(socket) @@ -431,6 +459,31 @@ defmodule Phoenix.LiveView.Channel do {:reply, {:ok, component_info}, state} end + # Phoenix.LiveView.Static.render/3 + def handle_call({@prefix, :disconnected_adoptable_render}, _from, {:adoptable, init, timeout}) + when is_function(init, 0) do + case init.() do + {:ok, socket} -> + state = %{ + socket: socket, + components: Diff.new_components(), + fingerprints: Diff.new_fingerprints(), + initial_diff: nil + } + + {:diff, diff, state} = render_diff(state, socket, true) + state = %{state | initial_diff: diff} + + Process.send_after(self(), {@prefix, :adoption_timeout}, timeout) + + # TODO: check if we can avoid sending the assigns back + {:reply, {:ok, Diff.to_iodata(diff), socket.assigns}, {:adoptable, state}} + + {:stop, socket} -> + {:stop, :shutdown, {:stop, socket}, nil} + end + end + def handle_call(msg, from, %{socket: socket} = state) do case socket.view.handle_call(msg, from, socket) do {:reply, reply, %Socket{} = new_socket} -> @@ -1035,11 +1088,26 @@ defmodule Phoenix.LiveView.Channel do {socket, %{}, state.fingerprints, state.components} end - diff = Diff.render_private(socket, diff) + diff = + case state do + %{initial_diff: initial_diff} when is_map(initial_diff) -> + send(self(), {@prefix, :connected_diff, Diff.render_private(socket, diff)}) + initial_diff + + _ -> + Diff.render_private(socket, diff) + end + new_socket = Utils.clear_temp(socket) {:diff, diff, - %{state | socket: new_socket, fingerprints: fingerprints, components: components}} + %{ + state + | socket: new_socket, + fingerprints: fingerprints, + components: components, + initial_diff: nil + }} end defp reply(state, {ref, extra}, status, payload) do @@ -1066,7 +1134,7 @@ defmodule Phoenix.LiveView.Channel do ## Mount - defp mount(%{"session" => session_token} = params, from, phx_socket) do + defp mount(%{"session" => session_token} = params, from, phx_socket, adopted_state) do %Phoenix.Socket{endpoint: endpoint, topic: topic} = phx_socket case Session.verify_session(endpoint, topic, session_token, params["static"]) do @@ -1126,7 +1194,8 @@ defmodule Phoenix.LiveView.Channel do params, from, phx_socket, - connect_info + connect_info, + adopted_state ) else {:error, :unauthorized} -> @@ -1145,7 +1214,7 @@ defmodule Phoenix.LiveView.Channel do end end - defp mount(%{}, from, phx_socket) do + defp mount(%{}, from, phx_socket, _adopted_state) do Logger.error("Mounting #{phx_socket.topic} failed because no session was provided") GenServer.reply(from, {:error, %{reason: "stale"}}) {:stop, :shutdown, :no_session} @@ -1171,7 +1240,8 @@ defmodule Phoenix.LiveView.Channel do params, from, phx_socket, - connect_info + connect_info, + adopted_state ) do %Session{ id: id, @@ -1206,16 +1276,24 @@ defmodule Phoenix.LiveView.Channel do Process.monitor(transport_pid) load_csrf_token(endpoint, socket_session) - socket = %Socket{ - endpoint: endpoint, - view: view, - transport_pid: transport_pid, - parent_pid: parent, - root_pid: root_pid || self(), - id: id, - router: router, - sticky?: params["sticky"] - } + socket = + %Socket{ + endpoint: endpoint, + view: view, + transport_pid: transport_pid, + parent_pid: parent, + root_pid: root_pid || self(), + id: id, + router: router, + sticky?: params["sticky"] + } + # For adoption, we only need to copy assigns and private. + # The state already contains existing fingerprints and + # components, but we need to do a full render anyway, since + # the client needs the full diff and the connected mount may + # have changed assigns. Unchanged expressions won't be executed. + |> maybe_copy_adopted(adopted_state, :private) + |> maybe_copy_adopted(adopted_state, :assigns) {params, host_uri, action} = case route do @@ -1237,7 +1315,7 @@ defmodule Phoenix.LiveView.Channel do socket |> load_layout(route) |> Utils.maybe_call_live_view_mount!(view, params, merged_session, url) - |> build_state(phx_socket) + |> build_state(phx_socket, adopted_state) |> maybe_call_mount_handle_params(router, url, params) |> reply_mount(from, verified, route) |> maybe_subscribe_to_live_reload() @@ -1276,6 +1354,12 @@ defmodule Phoenix.LiveView.Channel do end end + defp maybe_copy_adopted(socket, nil, _), do: socket + + defp maybe_copy_adopted(socket, adopted_state, key) do + Map.put(socket, key, Map.fetch!(adopted_state.socket, key)) + end + defp verify_flash(endpoint, %Session{} = verified, flash_token, connect_params) do cond do # flash_token is given by the client on live_redirects and has higher priority. @@ -1446,18 +1530,24 @@ defmodule Phoenix.LiveView.Channel do end end - defp build_state(%Socket{} = lv_socket, %Phoenix.Socket{} = phx_socket) do - %{ + defp build_state(%Socket{} = lv_socket, %Phoenix.Socket{} = phx_socket, adopted_state) do + base = + adopted_state || + %{ + components: Diff.new_components(), + fingerprints: Diff.new_fingerprints(), + initial_diff: nil + } + + Map.merge(base, %{ join_ref: phx_socket.join_ref, serializer: phx_socket.serializer, socket: lv_socket, topic: phx_socket.topic, - components: Diff.new_components(), - fingerprints: Diff.new_fingerprints(), redirect_count: 0, upload_names: %{}, upload_pids: %{} - } + }) end defp build_uri(%{socket: socket}, "/" <> _ = to) do diff --git a/lib/phoenix_live_view/static.ex b/lib/phoenix_live_view/static.ex index 4ec586e71e..9b38deef9e 100644 --- a/lib/phoenix_live_view/static.ex +++ b/lib/phoenix_live_view/static.ex @@ -74,7 +74,11 @@ defmodule Phoenix.LiveView.Static do end @doc """ - Renders a live view without spawning a LiveView server. + Renders a live view without spawning a LiveView server, + except if the view is marked as adoptable. + + In case the view is adoptable, a separate process will be joined + that can be claimed by a socket connection up to a configured timeout. * `conn` - the Plug.Conn struct form the HTTP request * `view` - the LiveView module @@ -152,30 +156,87 @@ defmodule Phoenix.LiveView.Static do host_uri ) - case call_mount_and_handle_params!(socket, view, mount_session, conn.params, request_url) do - {:ok, socket} -> - data_attrs = [ - phx_session: sign_root_session(socket, router, view, to_sign_session, live_session), - phx_static: sign_static_token(socket) - ] - - data_attrs = if(router, do: [phx_main: true], else: []) ++ data_attrs - - attrs = [ - {:id, socket.id}, - {:data, data_attrs} - | extended_attrs - ] - - try do - {:ok, to_rendered_content_tag(socket, tag, view, attrs), socket.assigns} - catch - :throw, {:phoenix, :child_redirect, redirected, flash} -> - {:stop, Utils.replace_flash(%{socket | redirected: redirected}, flash)} + case adoptable?(view) do + nil -> + case call_mount_and_handle_params!(socket, view, mount_session, conn.params, request_url) do + {:ok, socket} -> + data_attrs = [ + phx_session: sign_root_session(socket, router, view, to_sign_session, live_session), + phx_static: sign_static_token(socket) + ] + + data_attrs = if(router, do: [phx_main: true], else: []) ++ data_attrs + + attrs = [ + {:id, socket.id}, + {:data, data_attrs} + | extended_attrs + ] + + try do + {:ok, to_rendered_content_tag(socket, tag, view, attrs), socket.assigns} + catch + :throw, {:phoenix, :child_redirect, redirected, flash} -> + {:stop, Utils.replace_flash(%{socket | redirected: redirected}, flash)} + end + + {:stop, socket} -> + {:stop, socket} end - {:stop, socket} -> - {:stop, socket} + # TODO: maybe make the use option adoptable: true (-> defaults) / adoptable: %{timeout: 5000} (config map) + # instead of separate adoption_timeout + %{timeout: timeout} -> + # from usually is a {endpoint, pid} tuple set by the + # Phoenix channel server + from = + {:adoptable_mount, + fn -> + call_mount_and_handle_params!( + socket, + view, + mount_session, + conn.params, + request_url + ) + end, timeout} + + opts = {endpoint, from} + + {:ok, pid} = + DynamicSupervisor.start_child( + Phoenix.LiveView.AdoptionSupervisor, + {Phoenix.LiveView.Channel, opts} + ) + + # TODO: handle call timeout + with {:ok, iodata, assigns} <- + GenServer.call(pid, {:phoenix, :disconnected_adoptable_render}) do + data_attrs = [ + phx_session: sign_root_session(socket, router, view, to_sign_session, live_session), + phx_static: sign_static_token(socket), + phx_adopt: Phoenix.Socket.sign_adoption_token(endpoint, pid) + ] + + data_attrs = if(router, do: [phx_main: true], else: []) ++ data_attrs + + attrs = [ + {:id, socket.id}, + {:data, data_attrs} + | extended_attrs + ] + + # TODO: we currently expect the socket assigns for LiveViewTest + # check if we can aboid copying assigns... + {:ok, content_tag(tag, attrs, iodata), assigns} + end + end + end + + defp adoptable?(view) do + case view.__live__() do + %{adoptable: true} = config -> %{timeout: Map.get(config, :adoption_timeout, 5000)} + _ -> nil end end diff --git a/mix.exs b/mix.exs index 0cc00b1113..b524aa2a91 100644 --- a/mix.exs +++ b/mix.exs @@ -46,7 +46,8 @@ defmodule Phoenix.LiveView.MixProject do defp deps do [ {:igniter, "~> 0.6 and >= 0.6.16", optional: true}, - {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0"}, + # {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0"}, + {:phoenix, github: "phoenixframework/phoenix", branch: "sd-adopt", override: true}, {:plug, "~> 1.15"}, {:phoenix_template, "~> 1.0"}, {:phoenix_html, "~> 3.3 or ~> 4.0 or ~> 4.1"}, diff --git a/mix.lock b/mix.lock index 98cbe9f41b..02cca6f358 100644 --- a/mix.lock +++ b/mix.lock @@ -1,8 +1,8 @@ %{ - "bandit": {:hex, :bandit, "1.10.3", "1e5d168fa79ec8de2860d1b4d878d97d4fbbe2fdbe7b0a7d9315a4359d1d4bb9", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "99a52d909c48db65ca598e1962797659e3c0f1d06e825a50c3d75b74a5e2db18"}, + "bandit": {:hex, :bandit, "1.12.0", "6c5214daa2469644ac4ab0113b98abc24f75e348378e6a974c6343b3e5da22ef", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.5", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "45dac82dc86f45cf4a196dee9cc5a8b791d9c9469d996055f055e6ee36c66e20"}, "castore": {:hex, :castore, "1.0.17", "4f9770d2d45fbd91dcf6bd404cf64e7e58fed04fadda0923dc32acca0badffa2", [:mix], [], "hexpm", "12d24b9d80b910dd3953e165636d68f147a31db945d2dcb9365e441f8b5351e5"}, "cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"}, - "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, + "decimal": {:hex, :decimal, "2.4.1", "6c0fbede12fb122ba685e9ab41c6a40c129e322b3aa192f9e072e61f3a6ffaf2", [:mix], [], "hexpm", "7e618897933a8455f19a727d7c5e50a2c071a544b700e5e724298ecb4340187f"}, "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, "ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"}, "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, @@ -15,7 +15,7 @@ "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, "html_entities": {:hex, :html_entities, "0.5.2", "9e47e70598da7de2a9ff6af8758399251db6dbb7eebe2b013f2bbd2515895c3c", [:mix], [], "hexpm", "c53ba390403485615623b9531e97696f076ed415e8d8058b1dbaa28181f4fdcc"}, "igniter": {:hex, :igniter, "0.7.4", "b5f9dd512eb1e672f1c141b523142b5b4602fcca231df5b4e362999df4b88e14", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "971b240ee916a06b1af56381a262d9eeaff9610eddc299d61a213cd7a9d79efd"}, - "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, + "jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"}, "lazy_html": {:hex, :lazy_html, "0.1.10", "ffe42a0b4e70859cf21a33e12a251e0c76c1dff76391609bd56702a0ef5bc429", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "50f67e5faa09d45a99c1ddf3fac004f051997877dc8974c5797bb5ccd8e27058"}, "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, "makeup_eex": {:hex, :makeup_eex, "2.0.2", "88983b72aadb2e8408b06f7c9413804ce7eae2ca2a5a35cb738c6a9cb393c155", [:mix], [{:makeup, "~> 1.2.1 or ~> 1.3", [hex: :makeup, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_html, "~> 0.2.0 or ~> 1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 1.2", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "30ac121dda580298ff3378324ffaec94aad5a5b67e0cc6af177c67d5f45629b9"}, @@ -28,7 +28,7 @@ "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, "owl": {:hex, :owl, "0.13.0", "26010e066d5992774268f3163506972ddac0a7e77bfe57fa42a250f24d6b876e", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "59bf9d11ce37a4db98f57cb68fbfd61593bf419ec4ed302852b6683d3d2f7475"}, - "phoenix": {:hex, :phoenix, "1.8.5", "919db335247e6d4891764dc3063415b0d2457641c5f9b3751b5df03d8e20bbcf", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "83b2bb125127e02e9f475c8e3e92736325b5b01b0b9b05407bcb4083b7a32485"}, + "phoenix": {:git, "https://github.com/phoenixframework/phoenix.git", "b3be8fb8ba847805f1c2a74cc2b68e913f9074e7", [branch: "sd-adopt"]}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"}, "phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"}, "phoenix_html_helpers": {:hex, :phoenix_html_helpers, "1.0.1", "7eed85c52eff80a179391036931791ee5d2f713d76a81d0d2c6ebafe1e11e5ec", [:mix], [{:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cffd2385d1fa4f78b04432df69ab8da63dc5cf63e07b713a4dcf36a3740e3090"}, @@ -36,16 +36,16 @@ "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"}, "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, "phoenix_view": {:hex, :phoenix_view, "2.0.4", "b45c9d9cf15b3a1af5fb555c674b525391b6a1fe975f040fb4d913397b31abf4", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "4e992022ce14f31fe57335db27a28154afcc94e9983266835bb3040243eb620b"}, - "plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"}, + "plug": {:hex, :plug, "1.20.1", "82cdee1d7535d4f4db5c5602a7fd49512d64690be54fd62374856ee70e62eb29", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "892d2a1a7a3f5368c5a3b9067bba1050c031495f48c430ec00b09691dbf211b7"}, "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, "req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"}, "rewrite": {:hex, :rewrite, "1.3.0", "67448ba7975690b35ba7e7f35717efcce317dbd5963cb0577aa7325c1923121a", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "d111ac7ff3a58a802ef4f193bbd1831e00a9c57b33276e5068e8390a212714a5"}, "rustler_precompiled": {:hex, :rustler_precompiled, "0.8.4", "700a878312acfac79fb6c572bb8b57f5aae05fe1cf70d34b5974850bbf2c05bf", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "3b33d99b540b15f142ba47944f7a163a25069f6d608783c321029bc1ffb09514"}, "sourceror": {:hex, :sourceror, "1.12.0", "da354c5f35aad3cc1132f5d5b0d8437d865e2661c263260480bab51b5eedb437", [:mix], [], "hexpm", "755703683bd014ebcd5de9acc24b68fb874a660a568d1d63f8f98cd8a6ef9cd0"}, "spitfire": {:hex, :spitfire, "0.3.10", "19aea9914132456515e8f7d592f63ab9f3130876b0252e834d2390bdd8becb24", [:mix], [], "hexpm", "6a6a5f77eb4165249c76199cd2d01fb595bac9207aed3de551918ac1c2bc9267"}, - "telemetry": {:hex, :telemetry, "1.4.1", "ab6de178e2b29b58e8256b92b382ea3f590a47152ca3651ea857a6cae05ac423", [:rebar3], [], "hexpm", "2172e05a27531d3d31dd9782841065c50dd5c3c7699d95266b2edd54c2dafa1c"}, + "telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"}, "text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"}, - "thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"}, + "thousand_island": {:hex, :thousand_island, "1.5.0", "f50a213cac97262b6d5ebb85745aa2c00fec1413191e6e66834788d45425cecb", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "708923d40523e43cf99041ab37a0d4b0ec426ac6438fa3716ab23d919eaeb412"}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, "websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"}, }