Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
f3b0577
Works for most cases but there is still work left to check if a cowbo…
burbas Jan 6, 2025
63083f6
Add some more support for having multiple cowboy listeners running
burbas Feb 5, 2025
fa2c964
Add functions for removing an application and list all started applic…
burbas Aug 17, 2025
77174e7
Docs and a new implementation of routing_trie
burbas Sep 27, 2025
315ed64
Intermediate commit
burbas Dec 9, 2025
0f160dc
Remove unused format_status callback
burbas Dec 9, 2025
d082672
Add tests and functionality to router
burbas Dec 16, 2025
6ecbf78
Add tests
burbas Jan 16, 2026
1ddc043
Add routing trie with some tests
burbas Jan 16, 2026
003b22e
Add labeler.yml
burbas Jan 16, 2026
9955a44
Add the ability to specify both plugin and security-strategies for su…
burbas Apr 11, 2026
2f71b8d
Add the ability to specify both plugin and security-strategies for su…
burbas Apr 11, 2026
0435816
Jailed pathing and some improvements in configuration
burbas May 19, 2026
ce15b44
Fix bug where we popped elements pre-mature in jailed-check
burbas May 19, 2026
f8e63fc
Merge remote-tracking branch 'origin/new-router' into new-router
burbas May 19, 2026
33c9fed
Merge origin/master into new-router
Taure Aug 10, 2026
ff21243
feat(router): bring nova_routing_trie up to routing_tree parity
Taure Aug 10, 2026
b508679
fix(router): drive the trie correctly and repair route compilation
Taure Aug 10, 2026
170385f
test: port the suite off routing_tree
Taure Aug 10, 2026
6e49a69
test: boot a full-feature Nova application in-repo
Taure Aug 10, 2026
3d13c27
ci: run common test on every OTP version
Taure Aug 10, 2026
4739882
feat(sup): multiple Cowboy listeners with per-listener routing tables
Taure Aug 10, 2026
856bea6
docs: document the routing rewrite and the multi-app lifecycle
Taure Aug 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .github/workflows/erlang.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,39 @@ jobs:
${{runner.os}}-rebar3-${{matrix.otp}}-${{matrix.rebar3}}-
- name: Run eunit
run: rebar3 eunit
ct:
needs: [build]
runs-on: ubuntu-24.04
name: CT Erlang/OTP ${{matrix.otp}} / rebar3 ${{matrix.rebar3}}
strategy:
fail-fast: false
matrix:
otp: ['27.3.4.11', '28.5', '29.0']
rebar3: ['3.25.0']
steps:
- uses: actions/checkout@v6
- uses: erlef/setup-beam@v1
with:
otp-version: ${{matrix.otp}}
rebar3-version: ${{matrix.rebar3}}
version-type: strict
- name: Cache rebar3 deps and build
uses: actions/cache@v4
with:
path: |
~/.cache/rebar3
_build
key: ${{runner.os}}-rebar3-${{matrix.otp}}-${{matrix.rebar3}}-${{hashFiles('rebar.lock')}}
restore-keys: |
${{runner.os}}-rebar3-${{matrix.otp}}-${{matrix.rebar3}}-
- name: Run common test
run: rebar3 ct
- name: Upload CT logs on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: ct-logs-${{matrix.otp}}
path: _build/test/logs
dialyzer:
runs-on: ubuntu-24.04
name: Dialyzer Erlang/OTP ${{matrix.otp}} / rebar3 ${{matrix.rebar3}}
Expand Down
2 changes: 1 addition & 1 deletion guides/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ These parameters can be specified in your *main* application (Eg the one you've
|-----|-------------|-------|
| `json_lib` | JSON lib to use. Read more in the subsection *Configure json lib* | `atom()` |
| `watchers` | Watchers are external programs that will run together with Nova. Watchers are defined as list of tuples where the tuples is in format `{Command, ArgumentList}` (Like `[{my_app, "npm", ["run", "watch"], #{workdir => "priv/assets/js/my-app"}}]`) | `[{string(), string()}] | [{atom(), string(), map()}] | [{atom(), string(), list(), map()}]` |

| `router_module` | Module that contains the `routes/1` callback | `atom()` |


### Configure json_lib
Expand Down
45 changes: 45 additions & 0 deletions guides/deprecations.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,48 @@ This is a list of the things that are currently deprecated in Nova.
- The old format with `{Module, Function}` is deprecated in favor of `fun Module:function/1`. This is a breaking change,
but it is a good time to do it now before we release 1.0.0. The old format will be removed in 1.0.0. This goes for all occurrences
of `{Module, Function}` in Nova.


## Unreleased

- `routing_tree` is no longer a dependency. Nova's dispatch table is now built
by the in-tree `nova_routing_trie`. If you introspected the routing table by
including `routing_tree.hrl` and matching on `#host_tree{}`, `#routing_tree{}`,
`#node{}` or `#node_comp{}`, those records are gone. Use
`nova_routing_trie:routes/1`, which returns
`[{Host, Path, Method, HandlerValue}]`; the trie itself is opaque.

Nothing changes for applications that only declare routes and let Nova serve
them.

### Behaviour changes to be aware of

- **Route matching backtracks.** A request is now matched against every route
that could serve it, so `/a/:x/c` matches `/a/b/c` even when a `/a/b/d` route
exists. Previously matching committed to the first sibling it found and gave
up, returning a 404. Routes that were unreachable before may start being
reached.

- **Every binding at the same depth is reachable.** `/p/:id/picture` and
`/p/:user_id/name` both work. Previously only whichever one happened to be
visited first was reachable, and which one that was depended on insertion
order.

- **`use_strict_routing` now takes effect.** It never reached the routing table
before, so it has been a no-op. An application with genuinely conflicting
routes and `use_strict_routing` set to `true` will now refuse to start.
Conflicts are reported with both paths and the method.

- **An application's own status-code routes now win.** Nova's routes are
compiled last, so a `{404, fun my_controller:not_found/1, #{}}` entry in your
router replaces Nova's default error page. Previously Nova's was registered
first and yours was silently ignored.

- **Plugins.** A route entry that declares `plugins` uses exactly those, and one
that does not uses the globally configured ones - unchanged. The new
`plugin_strategy` option lets you combine both; see the
[routing guide](routing.md).

- **`nova_router:lookup_url/1,2,3`** keep their return shapes, including
`{error, comparator_not_found, AllowedMethods}` for a path that exists but
does not accept the method.
4 changes: 2 additions & 2 deletions guides/graceful-shutdown.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,8 @@ If you use readiness probes, your health endpoint should reflect the application
Nova implements graceful shutdown in `nova_app:prep_stop/1`, which is called by OTP before the supervision tree is terminated. The sequence is:

1. **Delay** — Sleep for `shutdown_delay` milliseconds. During this time, the listener is still active and serving requests normally. This covers the load balancer propagation window.
2. **Suspend** — Call `ranch:suspend_listener(nova_listener)` to stop accepting new TCP connections. Existing connections continue to be served.
2. **Suspend** — Call `ranch:suspend_listener/1` on every listener Nova has started, so no new TCP connections are accepted. Existing connections continue to be served.
3. **Drain** — Poll `ranch:info/1` every 500ms until active connections reach zero or `shutdown_drain_timeout` is exceeded.
4. **Stop** — Call `cowboy:stop_listener(nova_listener)` to fully shut down the listener.
4. **Stop** — Call `cowboy:stop_listener/1` on every listener to fully shut them down.

After `prep_stop` returns, OTP proceeds with the normal supervision tree shutdown.
108 changes: 89 additions & 19 deletions guides/multi-app.md
Original file line number Diff line number Diff line change
@@ -1,34 +1,104 @@
# Including other nova applications
# Running several Nova applications

Nova is built to support inclusion of other applications built with Nova. To include an application you first need to include it in the `rebar.config` as a dependency. Then add the `nova_apps` option in your application-configuration. `nova_apps` should contain a list of atoms or two-tuples (For defining options).
Nova can serve more than one Nova application from the same node. An
application can be mounted into another one's listener at startup, or started
and stopped on its own listener at runtime.

## Configuration options
## Including another application at startup

There's currently two different options available and they works in the same way in the routing-module.
Add the application as a dependency in `rebar.config`, then list it under the
`nova_apps` key in your own application's configuration. `nova_apps` takes
application names, or `{Name, Options}` two-tuples when you want to configure
how it is mounted.

| Key | Value | Description |
Included applications are resolved depth-first, so an application that
declares `nova_apps` of its own has those registered too.

### Options

| Key | Value | Description |
|---|---|---|
| prefix | string | Defines if the applications urls should be prefixed |
| secure | false | {Mod, Fun} | Tells if the application should be secured |
| `prefix` | `string()` | Mount the application's routes under this path |
| `secure` | `false` \| `fun/1` | Security callback for the application's routes |
| `override_secure` | `false` \| `fun/1` | Replace the security callback the application declares for itself |
| `plugin_strategy` | see the [routing guide](routing.md) | How the application's route-local plugins combine with the global ones |

## Example
### Example

*rebar.config*:

```
...
```erlang
{deps, [
{another_nova_app, "1.0.0"},
]
...
{another_nova_app, "1.0.0"}
]}.
```

*sys.config*:

```erlang
{my_nova_app, [
{nova_apps, [{another_nova_app, #{prefix => "/another"}}]}
]}.
```

`another_nova_app` now shares the listener, and the routing table, of
`my_nova_app`. Its routes answer under `/another`.

## Starting an application at runtime

*sys.config*
`nova_sup:add_application/2` starts a Nova application while the node is
running. The second argument takes the same shape as the `cowboy_configuration`
environment key.

```erlang
{ok, App, Host, Port} = nova_sup:add_application(my_other_app, #{port => 8081}).
```
...
{my_nova_app, [
{nova_apps, [{another_nova_app, #{prefix => "/another"}}]}
]}
...

What happens depends on whether the host and port are already bound:

- **A free port.** A new Cowboy listener is started with a routing table of its
own, holding that application, anything in its `nova_apps`, and Nova's own
error pages. The listener serves only those routes.
- **A port Nova already listens on.** The application's routes are added to
that listener's existing routing table, and the two applications are served
side by side.

Starting an application that is already running returns
`{error, {already_started, App}}`.

Because each listener has its own routing table, two applications on two ports
do not serve each other's routes. That is the point of binding a second port:
an admin interface on `8081` is not reachable on the public `8080` just
because both are running in the same node.

## Stopping an application

```erlang
ok = nova_sup:remove_application(my_other_app).
```

The application's routes are removed from the listener serving it. If that
leaves the listener with no applications, the listener is stopped and its
routing table discarded, releasing the port. Other applications on the same
listener are unaffected.

Removing an application that was never started returns `{error, not_found}`.

## Inspecting what is running

```erlang
nova_sup:get_started_applications().
%% [#{app => my_app, host => {0,0,0,0}, port => 8080, listener => nova_listener},
%% #{app => my_other_app, host => {0,0,0,0}, port => 8081,
%% listener => {nova_listener, my_other_app, 8081}}]
```

`nova_router:compiled_apps/0` lists the applications compiled into the default
listener's routing table, and `nova_router:compiled_apps/1` does the same for
any other.

## Graceful shutdown

Every listener Nova has started is suspended, drained and stopped on shutdown,
including ones added at runtime. See the
[graceful shutdown guide](graceful-shutdown.md).
83 changes: 78 additions & 5 deletions guides/routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,22 +208,95 @@ It's possible to configure a small set of endpoints with a specific plugin. This
In the example above we have enabled the *pre-request*-plugin `nova_json_schemas` for all routes under the `/admin` prefix. This will cause all requests to be validated against the JSON schema defined in the `nova_json_schemas` plugin.
You can also include *post-request*-plugins in the same way.

By default a route entry that declares `plugins` uses exactly those, and one
that does not uses the plugins configured globally. Set `plugin_strategy` on
the entry to combine them instead:

| Value | Plugins used |
|---|---|
| `local_or_global` | The entry's own plugins if it declares any, otherwise the global ones. This is the default. |
| `local_first` | The entry's own plugins, then the global ones. |
| `global_first` | The global plugins, then the entry's own. |
| `local_only` | Only the entry's own plugins. An entry with none gets none. |
| `global_only` | Only the global plugins. |
| `{override, List}` | Exactly `List`. |

When both sets are combined, a plugin appearing in both is kept once, at its
first position, so ordering within a phase is preserved.

### Overriding security for an included application

When you include another Nova application, you may want to put your own
security callback in front of its routes rather than the one it declares for
itself. Set `override_secure` in the options for that application:

```erlang
{nova_apps, [{another_nova_app, #{prefix => "/admin",
override_secure => fun my_security:check/1}}]}
```

It takes the same values as `secure`: `false` for no override, a `fun/1`, or
the deprecated `{Module, Function}`.


## Route precedence

More than one route can match a request. Nova resolves that the same way every
time, at each path segment in turn:

1. A literal segment.
2. A binding, `:name`. When several bindings sit at the same depth they are
tried in name order.
3. A `[...]` catch-all.

Matching backtracks, so a route is only skipped if nothing below it can match.
`/users/new` and `/users/:id` can therefore both be declared: `/users/new`
serves the literal path and `/users/:id` serves everything else.

Once a path matches, the method is resolved. An exact method wins over a route
declared with `'_'`. If the path matches but the method does not, Nova answers
`405` with an `allow` header listing the methods that path does accept.

If two routes declare the same path *and* the same method, the first one
registered wins and the second is logged and ignored. Set
`use_strict_routing` to `true` in the `nova` application environment to make
Nova refuse to start on a conflict instead, which also reports overlapping
literal and binding routes.

## Adding routes programatically

You can also add routes programatically by calling `nova_router:add_route/2`. This is useful if you want to add routes dynamically. The spec for it is:
You can also add routes programatically by calling `nova_router:add_routes/2`. This is useful if you want to add routes dynamically. The spec for it is:

```erlang
%% nova_router:add_route/2 specification
-spec add_route(App :: atom(), Routes :: map() | [map()]) -> ok.
%% nova_router:add_routes/2 specification
-spec add_routes(App :: atom(), Routes :: map() | [map()]) -> ok.
```

First argument is the application you want to add the route to. The second argument is the route or a list of routes you want to add - it uses the same structure as in the regular routers.

```erlang
nova_router:add_route(my_app, #{prefix => "/admin", routes => [{"/", fun my_controller:main/1, #{methods => [get]}}]}).
nova_router:add_routes(my_app, #{prefix => "/admin", routes => [{"/", fun my_controller:main/1, #{methods => [get]}}]}).
```

This will add the routes defined in the second argument to the `my_app` application.

**Note**: If a route already exists it will be overwritten.
**Note**: If a route already exists it will be overwritten. This is the one
place where a later route wins; routes compiled at startup keep the first.

If the application has a router module you can leave the routes out and let
Nova call it for you:

```erlang
nova_router:add_routes(my_app).
```

To take an application's routes back out again:

```erlang
nova_router:remove_application(my_app).
```

Both work on the routing table of the default listener. If you started the
application on its own listener with `nova_sup:add_application/2`, use
`nova_sup:remove_application/1` instead, which also stops the listener once
nothing is left on it. See the [multi-app guide](multi-app.md).
12 changes: 8 additions & 4 deletions rebar.config
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,18 @@
{cowboy, "2.18.0"},
{erlydtl, "0.14.0"},
{jhn_stdlib, "5.11.2"},
{routing_tree, "1.0.11"},
{thoas, "1.2.1"}
]}.

{profiles, [
{prod, [{relx, [{dev_mode, false}, {include_erts, true}]}]},
{test, [
{erl_opts, [debug_info, nowarn_export_all]},
{deps, [{proper, "1.4.0"}, {meck, "1.1.1"}]}
{deps, [{proper, "1.4.0"}, {meck, "1.1.1"}]},
%% nova_test_app is a real Nova application that nova_full_app_SUITE
%% boots on a Cowboy listener, so framework-level regressions are
%% caught here rather than downstream.
{project_app_dirs, [".", "test/nova_test_app", "test/nova_test_sub_app"]}
]}
]}.

Expand All @@ -36,8 +39,7 @@
xmerl,
cowboy,
erlydtl,
cowlib,
routing_tree]}
cowlib]}
]}.

{xref_checks,[
Expand Down Expand Up @@ -71,6 +73,8 @@
<<"guides/handlers.md">>,
<<"guides/plugins.md">>,
<<"guides/pubsub.md">>,
<<"guides/multi-app.md">>,
<<"guides/deprecations.md">>,
<<"guides/graceful-shutdown.md">>,
<<"guides/building-releases.md">>,
<<"guides/books-and-links.md">>,
Expand Down
Loading
Loading