Skip to content

Min 17-18 — Hooks 🪝

Heaven has no middleware stack. Instead of wrapping your app in layers of opaque callables, you register hooks that run BEFORE or AFTER a request.

A hook is just a handler. Same signature, same objects, no special base class:

async def check_auth(req, res, ctx):
    ...

The request lifecycle

flowchart TD
    A(["Request"]) --> B["Match route"]
    B -->|"no match"| Z["404"]
    B --> C["BEFORE hooks"]
    C -->|"res.abort()"| Y["Response sent immediately<br/><b>AFTER hooks skipped</b>"]
    C --> D["Handler"]
    D --> E["AFTER hooks"]
    E --> F["Response sent"]
    F --> G["res.defer() callbacks"]

Everything you'd reach for middleware to do — auth, logging, rate limiting, headers, timing — is a BEFORE or an AFTER hook.

Registering hooks

Hooks are matched by path pattern, with * as a wildcard suffix.

app.BEFORE('/*', log_request)              # every request
app.BEFORE('/api/*', rate_limiter)         # everything under /api
app.AFTER('/users/:id', log_access)        # one specific route

Scoping to methods

Pass methods= to run a hook only for certain verbs:

app.BEFORE('/orders', validate_payload, methods=['POST', 'PUT'])

Scoping belongs to the registration, not to the function, so the same function can be registered in several places with different scopes:

app.BEFORE('/x', shared, methods=['POST'])
app.BEFORE('/y', shared)      # unscoped here; runs for every method on /y

Bound methods and other callables work too, which is what plugins normally register.

Execution order

Hooks run in the order they were registered, within the same pattern. Across patterns, BEFORE runs from the broadest pattern inward, and AFTER unwinds in the mirror order, so a pair registered on the same pattern brackets everything more specific.

app.BEFORE('/*', global_auth)          # registered first
app.BEFORE('/dashboard', page_hook)    # registered second

Order for GET /dashboard:

global_auth  →  page_hook  →  handler  →  (AFTER: page_hook, then global_auth)

With three levels of nesting the symmetry is easier to see. For GET /users/7 matching /users/:id:

BEFORE:  /*  →  /users/*  →  /users/:id  →  handler
AFTER:                       /users/:id  →  /users/*  →  /*

This is what you want for guards: a check registered on /* runs before every more specific hook, so authentication registered globally genuinely precedes the route hooks it protects. Scoping a guard to the prefix it defends (/admin/*) still works and remains the clearer choice when the guard only applies to part of the app.

Each hook runs at most once per request even if several patterns match it. When a hook is registered under more than one matching pattern it runs at the earliest position it appears in.

What a hook can do

async def auth(req, res, ctx):
    user = await lookup(req.headers.get('authorization'))

    if not user:
        res.status = 401
        res.abort('Unauthorized')       # stop here — handler never runs

    ctx.user = user                     # hand data to the handler
  • Read the request — headers, cookies, body, params.
  • Write to the context — this is how hooks talk to handlers.
  • Set response headers or status — an AFTER hook can decorate any response.
  • Abort — res.abort(body) ends the request immediately.

abort() skips every AFTER hook

That includes Heaven's own session-saving hook. If a BEFORE hook aborts — or a schema validation fails with a 422 — session writes made during that request are silently discarded.

A worked example: request timing

import time

async def start_timer(req, res, ctx):
    ctx.started = time.perf_counter()

async def record_timing(req, res, ctx):
    elapsed = (time.perf_counter() - ctx.started) * 1000
    res.headers = 'X-Response-Time', f'{elapsed:.1f}ms'

app.BEFORE('/*', start_timer)
app.AFTER('/*', record_timing)

This is the pattern for nearly all middleware in Heaven: stash something in ctx on the way in, use it on the way out.

Hooks and mounting

When you mount a child app onto a parent, hooks nest the way you'd want:

  • BEFORE: parent hooks, then child hooks — broad guard before specific logic.
  • AFTER: child hooks, then parent hooks — specific cleanup unwinds first.

CORS is just a hook

app.cors(origin=['https://myapp.com'], credentials=True)

Under the hood this registers a BEFORE('/*') hook that sets the Access-Control-* headers and a catch-all OPTIONS route that short-circuits preflights. Nothing you couldn't write yourself in fifteen lines — see The Router for the options.


Next: Contracts, validation, and typed bodies → Min 19-20 — Schemas & Validation