From 8fbe41951ccdc96eb6e10469dc993580da979123 Mon Sep 17 00:00:00 2001 From: Daniel Widgren Date: Thu, 12 Mar 2026 22:53:21 +0100 Subject: [PATCH 1/6] feat: Arizona LiveView routing integration Add nova_arizona bridge module enabling Arizona LiveView routes in Nova. Routes defined with `#{protocol => liveview}` render Arizona views through Nova's full middleware chain (plugins, security) and auto-register the /live WebSocket endpoint for Arizona's client-side reactivity. Co-Authored-By: Claude Opus 4.6 --- rebar.config | 5 ++ src/nova_arizona.erl | 110 +++++++++++++++++++++++++++++++++++++++++++ src/nova_router.erl | 29 +++++++++++- 3 files changed, 142 insertions(+), 2 deletions(-) create mode 100644 src/nova_arizona.erl diff --git a/rebar.config b/rebar.config index 62a190b..c20a70a 100644 --- a/rebar.config +++ b/rebar.config @@ -48,6 +48,11 @@ deprecated_functions ]}. +%% Arizona is an optional runtime dependency +{xref_ignores, [{arizona_cowboy_request, new, 1}, + {arizona_view, call_mount_callback, 3}, + {arizona_renderer, render_layout, 1}]}. + {plugins, [rebar3_ex_doc, {rebar3_erlydtl_plugin, ".*", diff --git a/src/nova_arizona.erl b/src/nova_arizona.erl new file mode 100644 index 0000000..f449de7 --- /dev/null +++ b/src/nova_arizona.erl @@ -0,0 +1,110 @@ +%%%------------------------------------------------------------------- +%%% @doc +%%% Bridge module for Arizona LiveView integration with Nova. +%%% +%%% Provides rendering of Arizona views as Nova controller callbacks +%%% and manages the Arizona dispatch table for WebSocket view resolution. +%%% +%%% Arizona is an optional dependency - this module only has runtime +%%% dependencies on Arizona modules, which are only invoked when +%%% liveview routes are actually defined. +%%% @end +%%%------------------------------------------------------------------- +-module(nova_arizona). + +-export([ + render_view/3, + register_view/3, + finalize/1 +]). + +%% Arizona modules are optional runtime dependencies +-ignore_xref([{arizona_cowboy_request, new, 1}, + {arizona_view, call_mount_callback, 3}, + {arizona_renderer, render_layout, 1}]). +-dialyzer({nowarn_function, render_view/3}). + +-include("../include/nova_router.hrl"). +-include_lib("kernel/include/logger.hrl"). + +-define(ARIZONA_VIEWS_KEY, nova_arizona_views). + +%%-------------------------------------------------------------------- +%% @doc +%% Called from nova_router during route compilation for each liveview +%% route. Accumulates view registrations in persistent_term. +%% @end +%%-------------------------------------------------------------------- +-spec register_view(Path :: string(), ViewModule :: module(), MountArg :: term()) -> ok. +register_view(Path, ViewModule, MountArg) -> + Views = persistent_term:get(?ARIZONA_VIEWS_KEY, []), + persistent_term:put(?ARIZONA_VIEWS_KEY, [{Path, ViewModule, MountArg} | Views]), + ok. + +%%-------------------------------------------------------------------- +%% @doc +%% Called from nova_router after all routes have been compiled. +%% If any liveview routes were registered: +%% 1. Builds Arizona's Cowboy dispatch table (persistent_term) so +%% arizona_websocket can resolve views from ?path= query params +%% 2. Auto-registers /live WebSocket endpoint for Arizona connections +%% +%% Returns the dispatch tree unchanged if no liveview routes exist. +%% @end +%%-------------------------------------------------------------------- +-spec finalize(Dispatch :: term()) -> term(). +finalize(Dispatch) -> + Views = persistent_term:get(?ARIZONA_VIEWS_KEY, []), + case Views of + [] -> + Dispatch; + _ -> + setup_arizona_dispatch(Views), + setup_live_websocket(Dispatch) + end. + +%%-------------------------------------------------------------------- +%% @doc +%% Nova controller callback for rendering Arizona views. +%% Returns {status, 200, Headers, Html} which Nova's handler system +%% processes via nova_basic_handler:handle_status/3. +%% @end +%%-------------------------------------------------------------------- +-spec render_view(ViewModule :: module(), MountArg :: term(), Req :: cowboy_req:req()) -> + {status, 200, map(), iodata()}. +render_view(ViewModule, MountArg, Req) -> + ArizonaReq = arizona_cowboy_request:new(Req), + View = arizona_view:call_mount_callback(ViewModule, MountArg, ArizonaReq), + {Html, _RenderView} = arizona_renderer:render_layout(View), + {status, 200, #{<<"content-type">> => <<"text/html; charset=utf-8">>}, Html}. + + +%%%%%%%%%%%%%%%%%%%%%%%% +%% INTERNAL FUNCTIONS %% +%%%%%%%%%%%%%%%%%%%%%%%% + +%% Build Arizona's Cowboy dispatch table from accumulated view routes. +%% This allows arizona_websocket to resolve view modules when clients +%% connect to /live?path=/some-page via arizona_server:get_handler_opts/1. +setup_arizona_dispatch(Views) -> + CowboyRoutes = [{list_to_binary(Path), arizona_view_handler, + {view, Mod, MountArg, []}} + || {Path, Mod, MountArg} <- Views], + ArizonaDispatch = cowboy_router:compile([{'_', CowboyRoutes}]), + persistent_term:put(arizona_dispatch, ArizonaDispatch), + ?LOG_DEBUG(#{action => <<"Arizona dispatch table built">>, + view_count => length(Views)}). + +%% Auto-register /live WebSocket endpoint for Arizona connections. +setup_live_websocket(Dispatch) -> + LivePath = application:get_env(nova, arizona_live_path, "/live"), + Value = #cowboy_handler_value{ + app = nova, + handler = arizona_websocket, + arguments = #{}, + plugins = [], + secure = false + }, + ?LOG_DEBUG(#{action => <<"Adding Arizona WebSocket route">>, + route => LivePath}), + routing_tree:insert('_', LivePath, '_', Value, Dispatch). diff --git a/src/nova_router.erl b/src/nova_router.erl index 16d3b8a..104d624 100644 --- a/src/nova_router.erl +++ b/src/nova_router.erl @@ -63,10 +63,14 @@ plugins() -> -spec compile(Apps :: [atom() | {atom(), map()}]) -> host_tree(). compile(Apps) -> UseStrict = application:get_env(nova, use_strict_routing, false), + %% Reset Arizona view accumulator before compilation + persistent_term:put(nova_arizona_views, []), Dispatch = compile(Apps, routing_tree:new(#{use_strict => UseStrict, convert_to_binary => true}), #{}), + %% Finalize Arizona integration (builds dispatch table, adds /live WS route) + Dispatch1 = nova_arizona:finalize(Dispatch), StorageBackend = application:get_env(nova, dispatch_backend, persistent_term), - StorageBackend:put(nova_dispatch, Dispatch), - Dispatch. + StorageBackend:put(nova_dispatch, Dispatch1), + Dispatch1. -spec execute(Req, Env :: cowboy_middleware:env()) -> {ok, Req, Env0} | {stop, Req} when Req::cowboy_req:req(), @@ -370,6 +374,27 @@ parse_url(Host, [{Path, Callback, Options}|Tl], T = #{prefix := Prefix}, Value = router_file => maps:get(router_file, Options, undefined)}), parse_url(Host, Tl, T, Value, Tree) end; +parse_url(Host, + [{Path, Mod, #{protocol := liveview} = Options} | Tl], + T = #{prefix := Prefix}, Value = #nova_handler_value{app = App}, Tree) + when is_atom(Mod) -> + MountArg = maps:get(mount_arg, Options, #{}), + RealPath = concat_strings(Prefix, Path), + Callback = fun(Req) -> nova_arizona:render_view(Mod, MountArg, Req) end, + Methods = maps:get(methods, Options, ['_']), + ExtraState = maps:get(extra_state, Options, undefined), + Value0 = Value#nova_handler_value{extra_state = ExtraState}, + CompiledPaths = + lists:foldl( + fun(Method, Tree0) -> + BinMethod = method_to_binary(Method), + Value1 = Value0#nova_handler_value{callback = Callback}, + ?LOG_DEBUG(#{action => <<"Adding liveview route">>, + route => RealPath, app => App, method => Method}), + insert(Host, RealPath, BinMethod, Value1, Tree0) + end, Tree, Methods), + nova_arizona:register_view(RealPath, Mod, MountArg), + parse_url(Host, Tl, T, Value, CompiledPaths); parse_url(Host, [{Path, Mod, #{protocol := ws}} | Tl], T = #{prefix := Prefix}, #nova_handler_value{app = App, secure = Secure} = Value, From 80a033d502e6efc14e3c2184494a9e642042c71a Mon Sep 17 00:00:00 2001 From: Daniel Widgren Date: Thu, 12 Mar 2026 23:00:45 +0100 Subject: [PATCH 2/6] feat: add PubSub bridge to nova_arizona Add broadcast/subscribe/unsubscribe functions that delegate to arizona_pubsub, letting Nova controllers push real-time updates to Arizona views without importing Arizona modules directly. Co-Authored-By: Claude Opus 4.6 --- rebar.config | 6 +++- src/nova_arizona.erl | 68 ++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 70 insertions(+), 4 deletions(-) diff --git a/rebar.config b/rebar.config index c20a70a..f76fab7 100644 --- a/rebar.config +++ b/rebar.config @@ -51,7 +51,11 @@ %% Arizona is an optional runtime dependency {xref_ignores, [{arizona_cowboy_request, new, 1}, {arizona_view, call_mount_callback, 3}, - {arizona_renderer, render_layout, 1}]}. + {arizona_renderer, render_layout, 1}, + {arizona_pubsub, broadcast, 2}, + {arizona_pubsub, broadcast_from, 3}, + {arizona_pubsub, join, 2}, + {arizona_pubsub, leave, 2}]}. {plugins, [rebar3_ex_doc, diff --git a/src/nova_arizona.erl b/src/nova_arizona.erl index f449de7..fbb347f 100644 --- a/src/nova_arizona.erl +++ b/src/nova_arizona.erl @@ -15,14 +15,26 @@ -export([ render_view/3, register_view/3, - finalize/1 + finalize/1, + %% PubSub bridge + broadcast/2, + broadcast_from/3, + subscribe/1, + subscribe/2, + unsubscribe/1, + unsubscribe/2 ]). %% Arizona modules are optional runtime dependencies -ignore_xref([{arizona_cowboy_request, new, 1}, {arizona_view, call_mount_callback, 3}, - {arizona_renderer, render_layout, 1}]). --dialyzer({nowarn_function, render_view/3}). + {arizona_renderer, render_layout, 1}, + {arizona_pubsub, broadcast, 2}, + {arizona_pubsub, broadcast_from, 3}, + {arizona_pubsub, join, 2}, + {arizona_pubsub, leave, 2}]). +-dialyzer({nowarn_function, [render_view/3, broadcast/2, broadcast_from/3, + subscribe/1, subscribe/2, unsubscribe/1, unsubscribe/2]}). -include("../include/nova_router.hrl"). -include_lib("kernel/include/logger.hrl"). @@ -79,6 +91,56 @@ render_view(ViewModule, MountArg, Req) -> {status, 200, #{<<"content-type">> => <<"text/html; charset=utf-8">>}, Html}. +%%-------------------------------------------------------------------- +%% @doc +%% Broadcast a message to all Arizona view subscribers of a topic. +%% Views receive this in their `handle_event(Topic, Data, View)' callback. +%% +%% Example from a Nova controller: +%% ``` +%% nova_arizona:broadcast(<<"user_updated">>, #{id => UserId}). +%% ''' +%% @end +%%-------------------------------------------------------------------- +-spec broadcast(Topic :: binary(), Data :: term()) -> ok. +broadcast(Topic, Data) -> + arizona_pubsub:broadcast(Topic, Data). + +%%-------------------------------------------------------------------- +%% @doc +%% Broadcast a message to all subscribers except the sender. +%% @end +%%-------------------------------------------------------------------- +-spec broadcast_from(From :: pid(), Topic :: binary(), Data :: term()) -> ok. +broadcast_from(From, Topic, Data) -> + arizona_pubsub:broadcast_from(From, Topic, Data). + +%%-------------------------------------------------------------------- +%% @doc +%% Subscribe the calling process to an Arizona PubSub topic. +%% The process will receive `{pubsub_message, Topic, Data}' messages. +%% @end +%%-------------------------------------------------------------------- +-spec subscribe(Topic :: binary()) -> ok. +subscribe(Topic) -> + arizona_pubsub:join(Topic, self()). + +%% @doc Subscribe a specific process to an Arizona PubSub topic. +-spec subscribe(Topic :: binary(), Pid :: pid()) -> ok. +subscribe(Topic, Pid) -> + arizona_pubsub:join(Topic, Pid). + +%% @doc Unsubscribe the calling process from an Arizona PubSub topic. +-spec unsubscribe(Topic :: binary()) -> ok | not_joined. +unsubscribe(Topic) -> + arizona_pubsub:leave(Topic, self()). + +%% @doc Unsubscribe a specific process from an Arizona PubSub topic. +-spec unsubscribe(Topic :: binary(), Pid :: pid()) -> ok | not_joined. +unsubscribe(Topic, Pid) -> + arizona_pubsub:leave(Topic, Pid). + + %%%%%%%%%%%%%%%%%%%%%%%% %% INTERNAL FUNCTIONS %% %%%%%%%%%%%%%%%%%%%%%%%% From d6a8f8e64171c6ee7eafd26e5750cbb2e2e8aa58 Mon Sep 17 00:00:00 2001 From: Daniel Widgren Date: Thu, 12 Mar 2026 23:27:25 +0100 Subject: [PATCH 3/6] feat: unified PubSub and simplified Arizona bridge Move PubSub to nova_pubsub with broadcast/2, subscribe/1,2, unsubscribe/1,2 using Arizona's {pubsub_message, Topic, Data} format on nova_scope. Simplify nova_arizona: remove PubSub wrappers, add resolve_view/1 for WebSocket view resolution, set Arizona's pubsub scope to nova_scope. Co-Authored-By: Claude Opus 4.6 --- rebar.config | 5 +-- src/nova_arizona.erl | 78 ++++++++++---------------------------------- src/nova_pubsub.erl | 59 +++++++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+), 64 deletions(-) diff --git a/rebar.config b/rebar.config index f76fab7..3624497 100644 --- a/rebar.config +++ b/rebar.config @@ -52,10 +52,7 @@ {xref_ignores, [{arizona_cowboy_request, new, 1}, {arizona_view, call_mount_callback, 3}, {arizona_renderer, render_layout, 1}, - {arizona_pubsub, broadcast, 2}, - {arizona_pubsub, broadcast_from, 3}, - {arizona_pubsub, join, 2}, - {arizona_pubsub, leave, 2}]}. + {arizona_pubsub, set_scope, 1}]}. {plugins, [rebar3_ex_doc, diff --git a/src/nova_arizona.erl b/src/nova_arizona.erl index fbb347f..42193d3 100644 --- a/src/nova_arizona.erl +++ b/src/nova_arizona.erl @@ -16,25 +16,17 @@ render_view/3, register_view/3, finalize/1, - %% PubSub bridge - broadcast/2, - broadcast_from/3, - subscribe/1, - subscribe/2, - unsubscribe/1, - unsubscribe/2 + resolve_view/1 ]). %% Arizona modules are optional runtime dependencies -ignore_xref([{arizona_cowboy_request, new, 1}, {arizona_view, call_mount_callback, 3}, {arizona_renderer, render_layout, 1}, - {arizona_pubsub, broadcast, 2}, - {arizona_pubsub, broadcast_from, 3}, - {arizona_pubsub, join, 2}, - {arizona_pubsub, leave, 2}]). --dialyzer({nowarn_function, [render_view/3, broadcast/2, broadcast_from/3, - subscribe/1, subscribe/2, unsubscribe/1, unsubscribe/2]}). + {arizona_pubsub, set_scope, 1}, + {cowboy_router, execute, 2}, + resolve_view/1]). +-dialyzer({nowarn_function, [render_view/3, resolve_view/1, finalize/1]}). -include("../include/nova_router.hrl"). -include_lib("kernel/include/logger.hrl"). @@ -59,7 +51,8 @@ register_view(Path, ViewModule, MountArg) -> %% If any liveview routes were registered: %% 1. Builds Arizona's Cowboy dispatch table (persistent_term) so %% arizona_websocket can resolve views from ?path= query params -%% 2. Auto-registers /live WebSocket endpoint for Arizona connections +%% 2. Sets Arizona's pubsub scope to nova_scope +%% 3. Auto-registers /live WebSocket endpoint for Arizona connections %% %% Returns the dispatch tree unchanged if no liveview routes exist. %% @end @@ -72,6 +65,7 @@ finalize(Dispatch) -> Dispatch; _ -> setup_arizona_dispatch(Views), + arizona_pubsub:set_scope(nova_scope), setup_live_websocket(Dispatch) end. @@ -90,55 +84,19 @@ render_view(ViewModule, MountArg, Req) -> {Html, _RenderView} = arizona_renderer:render_layout(View), {status, 200, #{<<"content-type">> => <<"text/html; charset=utf-8">>}, Html}. - -%%-------------------------------------------------------------------- -%% @doc -%% Broadcast a message to all Arizona view subscribers of a topic. -%% Views receive this in their `handle_event(Topic, Data, View)' callback. -%% -%% Example from a Nova controller: -%% ``` -%% nova_arizona:broadcast(<<"user_updated">>, #{id => UserId}). -%% ''' -%% @end -%%-------------------------------------------------------------------- --spec broadcast(Topic :: binary(), Data :: term()) -> ok. -broadcast(Topic, Data) -> - arizona_pubsub:broadcast(Topic, Data). - %%-------------------------------------------------------------------- %% @doc -%% Broadcast a message to all subscribers except the sender. +%% Resolve a view module from the Arizona dispatch table. +%% Used by the WebSocket handler to determine which view to mount. %% @end %%-------------------------------------------------------------------- --spec broadcast_from(From :: pid(), Topic :: binary(), Data :: term()) -> ok. -broadcast_from(From, Topic, Data) -> - arizona_pubsub:broadcast_from(From, Topic, Data). - -%%-------------------------------------------------------------------- -%% @doc -%% Subscribe the calling process to an Arizona PubSub topic. -%% The process will receive `{pubsub_message, Topic, Data}' messages. -%% @end -%%-------------------------------------------------------------------- --spec subscribe(Topic :: binary()) -> ok. -subscribe(Topic) -> - arizona_pubsub:join(Topic, self()). - -%% @doc Subscribe a specific process to an Arizona PubSub topic. --spec subscribe(Topic :: binary(), Pid :: pid()) -> ok. -subscribe(Topic, Pid) -> - arizona_pubsub:join(Topic, Pid). - -%% @doc Unsubscribe the calling process from an Arizona PubSub topic. --spec unsubscribe(Topic :: binary()) -> ok | not_joined. -unsubscribe(Topic) -> - arizona_pubsub:leave(Topic, self()). - -%% @doc Unsubscribe a specific process from an Arizona PubSub topic. --spec unsubscribe(Topic :: binary(), Pid :: pid()) -> ok | not_joined. -unsubscribe(Topic, Pid) -> - arizona_pubsub:leave(Topic, Pid). +-spec resolve_view(Req :: cowboy_req:req()) -> {view, module(), term(), list()}. +resolve_view(Req) -> + {ok, _Req, Env} = cowboy_router:execute( + Req, + #{dispatch => {persistent_term, arizona_dispatch}} + ), + maps:get(handler_opts, Env). %%%%%%%%%%%%%%%%%%%%%%%% @@ -163,7 +121,7 @@ setup_live_websocket(Dispatch) -> Value = #cowboy_handler_value{ app = nova, handler = arizona_websocket, - arguments = #{}, + arguments = #{view_resolver => fun nova_arizona:resolve_view/1}, plugins = [], secure = false }, diff --git a/src/nova_pubsub.erl b/src/nova_pubsub.erl index 479b87f..3e43613 100644 --- a/src/nova_pubsub.erl +++ b/src/nova_pubsub.erl @@ -46,8 +46,13 @@ join/2, leave/1, leave/2, + broadcast/2, broadcast/3, local_broadcast/3, + subscribe/1, + subscribe/2, + unsubscribe/1, + unsubscribe/2, get_members/1, get_local_members/1 ]). @@ -152,6 +157,60 @@ get_members(Channel) -> get_local_members(Channel) -> pg:get_local_members(?SCOPE, Channel). +%%-------------------------------------------------------------------- +%% @doc +%% Broadcast to all subscribers of a binary topic. +%% Sends {pubsub_message, Topic, Data} which Arizona views handle +%% in their handle_event/3 callback. +%% @end +%%-------------------------------------------------------------------- +-spec broadcast(Topic :: binary(), Data :: term()) -> ok. +broadcast(Topic, Data) when is_binary(Topic) -> + Members = pg:get_members(?SCOPE, Topic), + [Receiver ! {pubsub_message, Topic, Data} || Receiver <- Members], + ok. + +%%-------------------------------------------------------------------- +%% @doc +%% Subscribe the calling process to a binary topic on the shared scope. +%% @end +%%-------------------------------------------------------------------- +-spec subscribe(Topic :: binary()) -> ok. +subscribe(Topic) when is_binary(Topic) -> + subscribe(Topic, self()). + +%%-------------------------------------------------------------------- +%% @doc +%% Subscribe a process to a binary topic on the shared scope. +%% @end +%%-------------------------------------------------------------------- +-spec subscribe(Topic :: binary(), Pid :: pid()) -> ok. +subscribe(Topic, Pid) when is_binary(Topic), is_pid(Pid) -> + pg:join(?SCOPE, Topic, Pid). + +%%-------------------------------------------------------------------- +%% @doc +%% Unsubscribe the calling process from a binary topic. +%% @end +%%-------------------------------------------------------------------- +-spec unsubscribe(Topic :: binary()) -> ok | not_joined. +unsubscribe(Topic) when is_binary(Topic) -> + unsubscribe(Topic, self()). + +%%-------------------------------------------------------------------- +%% @doc +%% Unsubscribe a process from a binary topic. +%% @end +%%-------------------------------------------------------------------- +-spec unsubscribe(Topic :: binary(), Pid :: pid()) -> ok | not_joined. +unsubscribe(Topic, Pid) when is_binary(Topic), is_pid(Pid) -> + pg:leave(?SCOPE, Topic, Pid). + + +%%%%%%%%%%%%%%%%%%%%%%%% +%% INTERNAL FUNCTIONS %% +%%%%%%%%%%%%%%%%%%%%%%%% + create_envelope(Channel, Sender, Topic, Payload) -> #nova_pubsub{channel = Channel, sender = Sender, From 338b1ef2ab754ec9753fe369f6b31a4c08a2b7b8 Mon Sep 17 00:00:00 2001 From: Daniel Widgren Date: Fri, 13 Mar 2026 00:00:00 +0100 Subject: [PATCH 4/6] refactor: use arizona_nova_websocket instead of arizona_websocket Nova's liveview routing now uses arizona_nova_websocket (from the arizona_nova integration package) instead of directly referencing arizona_websocket. Removes arizona_pubsub:set_scope/1 from nova_arizona since arizona_nova_sup handles scope setup during startup. Co-Authored-By: Claude Opus 4.6 --- rebar.config | 3 +-- src/nova_arizona.erl | 4 +--- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/rebar.config b/rebar.config index 3624497..c20a70a 100644 --- a/rebar.config +++ b/rebar.config @@ -51,8 +51,7 @@ %% Arizona is an optional runtime dependency {xref_ignores, [{arizona_cowboy_request, new, 1}, {arizona_view, call_mount_callback, 3}, - {arizona_renderer, render_layout, 1}, - {arizona_pubsub, set_scope, 1}]}. + {arizona_renderer, render_layout, 1}]}. {plugins, [rebar3_ex_doc, diff --git a/src/nova_arizona.erl b/src/nova_arizona.erl index 42193d3..987a499 100644 --- a/src/nova_arizona.erl +++ b/src/nova_arizona.erl @@ -23,7 +23,6 @@ -ignore_xref([{arizona_cowboy_request, new, 1}, {arizona_view, call_mount_callback, 3}, {arizona_renderer, render_layout, 1}, - {arizona_pubsub, set_scope, 1}, {cowboy_router, execute, 2}, resolve_view/1]). -dialyzer({nowarn_function, [render_view/3, resolve_view/1, finalize/1]}). @@ -65,7 +64,6 @@ finalize(Dispatch) -> Dispatch; _ -> setup_arizona_dispatch(Views), - arizona_pubsub:set_scope(nova_scope), setup_live_websocket(Dispatch) end. @@ -120,7 +118,7 @@ setup_live_websocket(Dispatch) -> LivePath = application:get_env(nova, arizona_live_path, "/live"), Value = #cowboy_handler_value{ app = nova, - handler = arizona_websocket, + handler = arizona_nova_websocket, arguments = #{view_resolver => fun nova_arizona:resolve_view/1}, plugins = [], secure = false From ee686b005ed8d144a6e63c3fa96c4eca751b201b Mon Sep 17 00:00:00 2001 From: Daniel Widgren Date: Fri, 13 Mar 2026 00:10:43 +0100 Subject: [PATCH 5/6] feat: route Arizona LiveView through Nova's WS handler Extend handle_ws/2 to support multi-frame replies ({reply, [Frame], CD}) so Arizona LiveView can send initial renders, diffs, and action responses in a single callback. LiveView WebSocket connections now go through nova_ws_handler with plugin pipeline support instead of bypassing Nova as a raw cowboy_websocket handler. Co-Authored-By: Claude Opus 4.6 --- src/nova_arizona.erl | 7 +++++-- src/nova_basic_handler.erl | 7 +++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/nova_arizona.erl b/src/nova_arizona.erl index 987a499..4ceb98a 100644 --- a/src/nova_arizona.erl +++ b/src/nova_arizona.erl @@ -114,12 +114,15 @@ setup_arizona_dispatch(Views) -> view_count => length(Views)}). %% Auto-register /live WebSocket endpoint for Arizona connections. +%% Uses Nova's WS handler (nova_ws_handler) so that Arizona LiveView +%% connections go through the Nova plugin pipeline. setup_live_websocket(Dispatch) -> LivePath = application:get_env(nova, arizona_live_path, "/live"), Value = #cowboy_handler_value{ app = nova, - handler = arizona_nova_websocket, - arguments = #{view_resolver => fun nova_arizona:resolve_view/1}, + handler = nova_ws_handler, + arguments = #{module => arizona_nova_websocket, + controller_data => #{view_resolver => fun nova_arizona:resolve_view/1}}, plugins = [], secure = false }, diff --git a/src/nova_basic_handler.erl b/src/nova_basic_handler.erl index ad44095..777cceb 100644 --- a/src/nova_basic_handler.erl +++ b/src/nova_basic_handler.erl @@ -270,6 +270,13 @@ handle_websocket({websocket, ControllerData}, Callback, Req) -> %% Example of a valid return value is {reply, Frame, State} %% @end %%----------------------------------------------------------------- +handle_ws({reply, Frames, NewControllerData}, State = #{commands := Commands}) when is_list(Frames) -> + State#{controller_data => NewControllerData, + commands => Frames ++ Commands}; +handle_ws({reply, Frames, NewControllerData, hibernate}, State = #{commands := Commands}) when is_list(Frames) -> + State#{controller_data => NewControllerData, + commands => Frames ++ Commands, + hibernate => true}; handle_ws({reply, Frame, NewControllerData}, State = #{commands := Commands}) -> State#{controller_data => NewControllerData, commands => [Frame|Commands]}; From da48df9db743e3f417a7609faa0ff0c851a34fde Mon Sep 17 00:00:00 2001 From: Daniel Widgren Date: Fri, 13 Mar 2026 00:50:57 +0100 Subject: [PATCH 6/6] fix: static file serving crash from invalid fun reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `fun Module:Variable/1` syntax doesn't work with variable atoms in Erlang — it produces undefined. Use `erlang:make_fun/3` to construct the callback dynamically. Co-Authored-By: Claude Opus 4.6 --- src/nova_router.erl | 2 +- test/nova_file_controller_tests.erl | 112 ++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 test/nova_file_controller_tests.erl diff --git a/src/nova_router.erl b/src/nova_router.erl index 104d624..e5c4ef5 100644 --- a/src/nova_router.erl +++ b/src/nova_router.erl @@ -331,7 +331,7 @@ parse_url(Host, [{RemotePath, LocalPath, Options}|Tl], T = #{prefix := Prefix}, Value0 = #nova_handler_value{ app = App, - callback = fun nova_file_controller:TargetFun/1, + callback = erlang:make_fun(nova_file_controller, TargetFun, 1), extra_state = #{static => Payload, options => Options}, plugins = Value#nova_handler_value.plugins, secure = Secure diff --git a/test/nova_file_controller_tests.erl b/test/nova_file_controller_tests.erl new file mode 100644 index 0000000..c647b0a --- /dev/null +++ b/test/nova_file_controller_tests.erl @@ -0,0 +1,112 @@ +-module(nova_file_controller_tests). +-include_lib("eunit/include/eunit.hrl"). + +%%==================================================================== +%% get_file/1 +%%==================================================================== + +get_file_serves_existing_file_test() -> + Filepath = create_temp_file("hello world"), + Req = #{extra_state => #{static => {file, Filepath}, options => #{}}, + headers => #{}}, + Result = nova_file_controller:get_file(Req), + ?assertMatch({sendfile, 200, #{}, {0, 11, Filepath}, _MimeType}, Result), + file:delete(Filepath). + +get_file_priv_file_test() -> + %% Use a known priv file from nova itself + PrivDir = code:priv_dir(nova), + case file:list_dir(PrivDir) of + {ok, [F|_]} -> + FullPath = filename:join(PrivDir, F), + case filelib:is_file(FullPath) of + true -> + Req = #{extra_state => #{static => {priv_file, nova, F}, + options => #{}}, + headers => #{}}, + Result = nova_file_controller:get_file(Req), + ?assertMatch({sendfile, 200, _, _, _}, Result); + false -> + ok + end; + _ -> + ok + end. + +get_file_returns_404_for_bad_req_test() -> + ?assertEqual({status, 404}, nova_file_controller:get_file(#{})). + +get_file_range_request_test() -> + Filepath = create_temp_file("0123456789"), + Req = #{extra_state => #{static => {file, Filepath}, options => #{}}, + headers => #{<<"range">> => <<"bytes=2-5">>}}, + Result = nova_file_controller:get_file(Req), + ?assertMatch({sendfile, 206, _, {2, 4, Filepath}, _}, Result), + file:delete(Filepath). + +get_file_invalid_range_test() -> + Filepath = create_temp_file("short"), + Req = #{extra_state => #{static => {file, Filepath}, options => #{}}, + headers => #{<<"range">> => <<"bytes=100-200">>}}, + Result = nova_file_controller:get_file(Req), + ?assertMatch({status, 416, _}, Result), + file:delete(Filepath). + +%%==================================================================== +%% get_dir/1 +%%==================================================================== + +get_dir_with_pathinfo_serves_file_test() -> + Dir = create_temp_dir(), + Filepath = filename:join(Dir, "test.txt"), + ok = file:write_file(Filepath, <<"dir file">>), + Req = #{extra_state => #{pathinfo => [<<"test.txt">>], + static => {dir, Dir}, + options => #{}}, + headers => #{}}, + Result = nova_file_controller:get_dir(Req), + ?assertMatch({sendfile, 200, _, _, _}, Result), + file:delete(Filepath), + file:del_dir(Dir). + +get_dir_pathinfo_not_found_test() -> + Dir = create_temp_dir(), + Req = #{extra_state => #{pathinfo => [<<"nope.txt">>], + static => {dir, Dir}, + options => #{}}, + headers => #{}}, + ?assertEqual({status, 404}, nova_file_controller:get_dir(Req)), + file:del_dir(Dir). + +get_dir_returns_404_for_bad_req_test() -> + ?assertEqual({status, 404}, nova_file_controller:get_dir(#{})). + +%%==================================================================== +%% Router callback construction (regression for make_fun fix) +%%==================================================================== + +router_static_callback_is_callable_test() -> + %% Verify that erlang:make_fun/3 produces a callable function + Callback = erlang:make_fun(nova_file_controller, get_file, 1), + ?assert(is_function(Callback, 1)), + %% Should return 404 for a bad request, not crash + ?assertEqual({status, 404}, Callback(#{})). + +router_static_dir_callback_is_callable_test() -> + Callback = erlang:make_fun(nova_file_controller, get_dir, 1), + ?assert(is_function(Callback, 1)), + ?assertEqual({status, 404}, Callback(#{})). + +%%==================================================================== +%% Helpers +%%==================================================================== + +create_temp_file(Content) -> + Filename = filename:join("/tmp", "nova_test_" ++ integer_to_list(erlang:unique_integer([positive]))), + ok = file:write_file(Filename, Content), + Filename. + +create_temp_dir() -> + Dir = filename:join("/tmp", "nova_test_dir_" ++ integer_to_list(erlang:unique_integer([positive]))), + ok = file:make_dir(Dir), + Dir.