Mounting

Compose reusable plugins and full sub-apps under a URL prefix

4 min read 700 words

Two mount APIs —mount vs mount_app

Chirp provides two ways to compose code under a URL prefix. They solve different problems:

app.mount(prefix, plugin) app.mount_app(prefix, sub_app)
Input Any object with aregister(app, prefix)method Anotherchirp.App
When to use Reusable packaged piece: docs site, auth flow, admin console shipped as a library Transitional composition: you have two full apps and need them on one port during a migration
Who owns the surface The plugin author —register()decides what to expose on the host app You — the sub-app is a Chirp app you wrote, with all the normal APIs
Lifecycle Plugin'sregister()runs once at mount time; plugin has no independent freeze Sub-app is consumed: its pending routes, middleware, and hooks are hoisted into the parent; callingsub_app.freeze()/run() afterwards raises RuntimeError
Middleware Plugin typically adds viaapp.add_middleware(...) inside register() Sub-app's middleware is appended to the parent's list
Template globals Plugin registers viaapp.template_global() inside register() Sub-app's globals merge into the parent with parent-wins semantics
Collisions Plugin can check the parent before registering Parent wins; dropped sub-app entries surface as INFO contract issues in categorymount_app_merge
Permanence Stable — plugins are designed to be mounted Transitional — once the migration is done, collapse the sub-app into the parent

When to reach for which

  • Shipping a reusable piece? Write a plugin. Theregister(app, prefix)contract is simple, and consumers don't need to know your internals.
  • Midway through a file-system / IA refactor with two apps and one deployment slot? Usemount_app. One freeze(), one middleware stack, one app.check()run — no two-stack request-time composition.

mount(prefix, plugin)— reusable plugins

class DocsPlugin:
    def register(self, app: App, prefix: str) -> None:
        @app.route(f"{prefix}/", name="docs.home")
        async def home(request):
            return Template("docs/home.html")

        @app.route(f"{prefix}/{{page}}")
        async def page(request, page: str):
            return Template("docs/page.html", page=page)


dashboard = App(AppConfig(...))
dashboard.mount("/docs", DocsPlugin())

Anything the plugin registers (routes, middleware, template globals, contract checks) is persisted on the host app directly. The plugin has no independent lifecycle.

mount_app(prefix, sub_app)— sub-app composition

console_app = App(AppConfig(template_dir="console/templates"))

@console_app.route("/", name="console.home")
async def home(request):
    return Template("home.html")

@console_app.route("/users/{user_id}")
async def user(request, user_id: int):
    return Template("user.html", user_id=user_id)

console_app.add_middleware(ConsoleAuthMiddleware())
console_app.template_global("console_theme")(lambda: "dark")


dashboard_app = App(AppConfig(template_dir="dashboard/templates"))

@dashboard_app.route("/")
async def index(request):
    return Template("index.html")

dashboard_app.add_middleware(SessionMiddleware(...))
dashboard_app.mount_app("/console", console_app)
dashboard_app.run()

After mount_app:

  • /dashboardhome
  • /console → console home (url_for("console.home") returns /console)
  • /console/users/{user_id}→ console user detail
  • For/console/** requests the middleware stack is: SessionMiddlewareConsoleAuthMiddleware→ handler.
  • console_themeis available in both dashboards' and console's templates (parent-wins merge).
  • console_app.run() now raises RuntimeError— it has been consumed.

Merge rules

Category Rule
Routes Paths are prefixed; duplicate paths (after prefixing) fail atfreeze()via the router's existing duplicate check.
Route names Preserved as-is; cross-app name collisions surface via the existingroute_namescontract check.
Middleware Appended in sub-app order. Parent's middleware wraps sub-app's.
Template globals / filters / providers / error handlers / severity overrides Parent wins. Dropped sub-app entries recorded as INFO issues in categorymount_app_merge.
Startup / shutdown hooks (including worker hooks) Appended (parent hooks fire first).
Reload dirs Union (dedup).
Contract checks Appended (order-independent by design).

Unsupported sub-app state

mount_app raises ConfigurationErrorwhen the sub-app carries deep page/shell state that would silently break rendering on the parent:

  • mount_pages(...)-discovered routes, metas, templates, layout chains
  • Registered sections, OOB regions, fragment targets, layout presets, live blocks
  • A custom kida environment, database, or migrations directory

If you hit this, register those on the parent directly or keep the two apps deployed separately. Future versions may relax some of these — seedocs/rfcs/005-mount-app.md.

mount_appis a migration tool

Once the refactor that motivatedmount_app is done, collapse the sub-app into the parent: move its routes, middleware, and hooks inline. Chained mount_appcalls work, but the result is harder to reason about than one flat app.