All notable changes to this project are documented here. The format follows Keep a Changelog, and this project adheres to Semantic Versioning.
[0.1.1] - 2026-08-22
Fixed
- A resolver raising a raw BIF badarg was executed three times per
request. The rotation-window catch wrapped the resolver call as well as
the ETS reads, so
String.to_integer/1on a bad slug was swallowed and re-run before the error finally propagated — tripled side effects and a misleading stacktrace. Consulting the tables and running the resolver are now separate phases, and only the first is guarded. - Promotion refreshed the TTL. A hot key under continuous rotation could
outlive
cache_ttlindefinitely, never re-resolved — "cached for five minutes" became "cached until traffic stops". Promoted answers keep their original expiry. - The first version of the promotion fix broke the cache's size bound — a raw insert bypassed eviction, and a promote-heavy workload pushed a 6-entry cap to 63 measured entries. Promotion now goes through the same size gate as every other insert, preserving the original expiry through the rotate handler. Caught by reviewing the fix, which is the loop working.
- A router that
uses DynamicRoutes without ever callingdynamic_routes/2now gets a compile-time warning, instead of a 500 on the first dynamic request days later.
[0.1.0] - 2026-08-21
Initial release.
Added
use DynamicRoutes, resolver: MyApp.Pages— installs dynamic routing into a Phoenix router by overriding thecall/2thatPhoenix.Routerleaves overridable for exactly this.dynamic_routes/2— declares the internal route dynamic paths dispatch to. Its position in the router does not matter.DynamicRoutes.Resolver— the one-callback behaviour a host application implements to claim paths.DynamicRoutes.resolution/1anddynamic?/1— what the resolver returned, and whether this request was routed dynamically.DynamicRoutes.Cache— a bounded, expiring ETS cache withinvalidate/0andinvalidate/1.
Fixed — first review pass
- A percent-encoded path bypassed declared routes, and their pipelines.
Plug never decodes path segments, Phoenix's own
call/2decodes immediately before matching, androute_info/4does not decode at all — so asking it about a raw path asked a different question than the router answered a moment later./adminbehind an auth pipeline returned 403 while/%61dminmissed the lookup, reached the resolver, and was served as a database page with the pipeline never run. The same bug shadowed any non-ASCII declared route with no attacker involved, since a browser sends/caféas/caf%C3%A9. The path is now decoded once, and used for the lookup, the resolver and the cache key alike. - The internal route was a live public URL.
/__dynamic__/anythingmatched as a declared route and reached the controller having never been through the resolver, withresolution/1returningnil— which the documented controller body renders. A request that did not come through the rewrite is now a 404. - The cache discarded the answer it had just paid for. Clearing everything on overflow fails at exactly the traffic it exists to survive: a scanner walking random paths keeps the table permanently full, so real pages are re-resolved on every request — measured at 91× database amplification. Replaced with two generations, where an answer found in the previous one is promoted, so anything actually in use survives rotation.
- Cache reads raised while the owner process was restarting, turning a request that should have been merely slower into a 500. Every ETS call is guarded now, not just the writes.
- A second
dynamic_routes/2declaration was silently dead — both compiled and every rewrite went to the first. It now refuses to start. - The internal path was memoised across recompiles, so changing
:pathin development left every dynamic request 404ing against the path that had just been deleted, until the VM restarted. Not memoised when code reloading is on. defoverridable call: 2was missing after the definition, so another library overridingcall/2on top got a clause that could never match. The comment explaining its absence was wrong about why.- Paths longer than
:cache_max_key_bytesare resolved but not cached.
Fixed — verification pass
- The code-reloading guard was dead code. It read a config key nothing sets — not the library, not Phoenix, not the docs — so the staleness it was written to prevent was live in every installation. The memo is gone entirely: it saved a filter over a compiled-in list, which is not worth a correctness hazard.
- The duplicate-declaration check raised at request time. A router with
two declarations compiled, booted, served ordinary traffic, and then
produced a bare 500 on every dynamic page — the only pages the library
exists for. It is a compile-time fact, and is now checked in
@after_compile. - A reader arriving mid-rotation resolved and threw the answer away,
because the
:badargcatch sits above the insert. It retries once. - Promotion left the old copy in place, so a hot key occupied two slots against a bound expressed in entries.
- The supervisor used the default three-restarts-in-five-seconds. The cache is a pure optimisation, so a burst of crashes taking the whole application down with it is the wrong trade.
Documentation corrected
The resolver's docs claimed its argument was already percent-decoded — it was
not, until this release. The README's central claim was false as written for
encoded paths. Nothing mentioned that a LiveView cannot be a target, that
precedence is per method as well as path, that dynamic pages answer every
method, or that a forward/2 prefix cannot host them. All now stated.
Design notes
- Declared routes always win. The router is asked first, through
Phoenix.Router.route_info/4, and only its misses reach the resolver. That is what makes declaration order stop mattering, and it means a database row can never shadow a route somebody deployed. - The path is restored before the controller runs. Matching a
database-backed URL means temporarily replacing
conn.path_info; leaving that in place would make every canonical URL the controller builds name an internal path no client ever requested.DynamicRoutes.Dispatchersits between the route and the controller and undoes it after the pipeline has run. - Caching is in v1 rather than deferred. The resolver is consulted on requests that matched nothing, which on a public site means every scanner probing for paths that do not exist. Misses are cached as well as hits, and the cache is bounded, because its keys are chosen by whoever is making the request.
- The log names the real controller. Phoenix logs the dispatch before the
dispatcher can correct anything, so the route carries
:mfametadata and a dynamic request reads asProcessing with MyAppWeb.PageController.show/2rather than naming an internal module.