Module: Tina4::DispatchPipeline

Included in:
RackApp
Defined in:
lib/tina4/dispatch_pipeline.rb

Overview

The dispatch pipeline: the concerns of RackApp#call, named and ordered.

#call was one 260-line function at cyclomatic complexity 53 against a ceiling of 10, on the path of every single request. Splitting it into a module is not only about that number: rack_app.rb also holds static file serving, the swagger UI, WebSocket upgrades and the error pages, so the pipeline was interleaved with four unrelated subsystems in one 999-line file. This is the pipeline, on its own, readable end to end.

Mixed into RackApp, so a stage can still call the app's helpers (#handle_route, #try_static, #dev_mode?) directly.

Defined Under Namespace

Classes: DispatchContext, RouteContext

Constant Summary collapse

REQUEST_STAGES =

── The stages ────────────────────────────────────────

#call was one 260-line function at cyclomatic complexity 53 against a ceiling of 10, and it is on the path of every single request. The stages below are that function's concerns, named and ordered as DATA so the pipeline can be read, tested and compared across the four frameworks without reading an implementation.

Two phases, because the function genuinely has two:

REQUEST_STAGES   run in order until one RETURNS a Rack triple. That
               triple is the response; later request stages do not
               run. `match_route` is terminal - it always produces
               one.
ALWAYS_STAGES    run over the triple no matter how it was produced -
               including the swagger and static branches, which
               return early and skip everything else.
RESPONSE_STAGES  run in order over the triple, each returning a new
               triple or nil to leave it unchanged. These are the
               post-processing steps (logging, injection, session
               save) that only apply to a dispatched response.

Contract each stage obeys, asserted by spec/dispatch_pipeline_spec.rb:

* it takes (ctx) or (ctx, response) and nothing else - no stage reads a
local of #call, because there are none left to read
* it never calls another stage directly; ordering lives in these lists.
`method_not_allowed` and `not_found` are therefore STAGES, not
helpers called by `match_route` - the fallback chain is expressed as
list order like everything else
* a request stage returning nil means "not mine, keep going"

Ordering is BEHAVIOUR here, not taste: dev routes must beat route matching, the pre-match middleware must run before the match so its headers survive a 401 (ADR-0012), and static resolution happens inside match_route's not-found fallback because routes beat files (ADR-0010).

%i[
  reset_request_caches
  cors_preflight
  websocket_upgrade
  dev_routes
  feedback_routes
  global_middleware_pre
  match_route
  method_not_allowed
  not_found
].freeze
ALWAYS_STAGES =

Runs on EVERY response, including the ones that bypass the rest.

RFC 9110 s9.3.2 is not conditional: a HEAD response MUST NOT carry content, whatever produced it. This used to live in RESPONSE_STAGES, so the swagger and static branches - which return early - skipped it, and HEAD /style.css shipped the whole file body. Measured 2026-07-31: Ruby returned 15 bytes where PHP, Python and Node all returned 0.

%i[
  head_strip
  apply_cors
].freeze
RESPONSE_STAGES =
%i[
  dev_inspector_capture
  request_log
  dev_toolbar_inject
  feedback_inject
  session_save
].freeze
ROUTE_STAGES =

── The route pipeline ───────────────────────────────────────────

#handle_route was cyclomatic complexity 24 in one 118-line function, with the same two-phase shape as #call. These stages run in order until one returns a Rack triple (a 401/403 or a short-circuiting middleware); if none does, the handler is invoked and the result finalised.

Ordering is BEHAVIOUR, decided and written down (ADR-0012): the post-match global middleware runs BEFORE the auth gate so a rate limiter can throttle a brute-force login and an access log records the 401, while the route's OWN middleware runs AFTER it, so middleware attached to a secured route never processes an unauthenticated request.

%i[
  prepare_route_request
  global_middleware_post
  route_auth_handler
  route_auth_gate
  route_middleware
].freeze