Build an HTTP application server.

Own the accepted connection, add a borrowed exchange and router where useful, and keep body policy, admission, deadlines, and response framing explicit.

HTTP 01

Start the HTTP server.

The maintained server owns a listening socket through Flyology.IO.Structured_Servers. Each admitted handler wraps its owned connection in the HTTP transport, installs a shared ingress budget, and serves persistent requests under one cancellation token. The kitchen-sink page is a real same-origin application: its HTML, CSS, JavaScript, brand mark, and variable font are read through Flyology.IO.Files.Read_At, while its UI drives SSE, WebSocket chat, streamed upload, middleware, structured request work, and live application introspection.

connection handler excerpt
Channel : aliased HTTP.Connections.Connection_Transport
  (Connection'Unchecked_Access);
Client : aliased HTTP.Connection (Channel'Access);

HTTP.Configure_Ingress_Budget (Client, State.Budget'Access);
State.Routes.Serve
  (State.Application, Client, Peer,
   Timeout        => 120.0,
   Token          => Cancellation,
   Header_Timeout => 10.0);

The complete-request deadline remains absolute, but the shorter header deadline rejects an incremental slow header first. Admitted routes narrow the longer deadline for ordinary work; explicitly long-lived SSE and WebSocket routes retain bounded lifecycle time.

Build with the prepared RTS, then choose the handler lane explicitly:

shell
./scripts/showcases.sh
./showcases/bin/http_application_server lightweight 0 18082 256 .
./showcases/bin/http_application_server native 0 18082 256 .
# open http://127.0.0.1:18082/

The showcase build prepares one automatic execution group per online host CPU, capped at 128. Lightweight handler tasks without an explicit CPU aspect are assigned round-robin, so the server can execute requests on separate event-loop pthreads in parallel. Set FLYOLOGY_LOOP_POOL_SIZE before building to make a smaller benchmark topology reproducible.

HTTP 02

Keep the low-level connection API available.

Flyology.HTTP.Server is the protocol foundation. An application may call Read_Request, inspect headers, explicitly accept a body, stream decoded bytes, and write fixed, chunked, SSE, or WebSocket responses without creating a router. Connection_Handlers supplies the smallest persistent-loop adapter.

raw handler shape
procedure Route
  (Item  : in out Flyology.HTTP.Server.Connection;
   Value : Flyology.HTTP.Server.Request) is
begin
   Flyology.HTTP.Server.Respond
     (Item, 200, "text/plain; charset=utf-8", "hello" & ASCII.LF);
end Route;

package Handler is new
  Flyology.HTTP.Server.Connection_Handlers (Route);

The connection is sole-writer state. It must not be retained past the handler, shared among producer tasks, or closed by a borrowed transport.

HTTP 03

Introduce a borrowed Exchange.

Applications.Exchange borrows the parsed request, connection, application context, cancellation token, peer, and absolute deadline. It adds response state, route metadata, body policy, request ID, principal, and helpers. Neither the exchange nor anything reached through it may escape the handler call.

ordinary synchronous handler
procedure Show_User
  (State : in out Application_Context;
   X     : in out App.Exchange) is
begin
   Complete (State);
   X.Text (200, "user " & X.Parameter ("id") & ASCII.LF);
end Show_User;

The exchange does not force buffering. X.Read_Body incrementally reads a streamed route and Begin_Stream, Write_Chunk, and End_Stream preserve synchronous transport backpressure.

HTTP 04

Route by method and decoded path.

Routers match static segments, {name}, and one final {*remainder}. They provide automatic 404, 405 with Allow, and HEAD fallback to GET. A router declares strict, ignored, or redirected trailing-slash behavior. Path percent-decoding rejects encoded separators, NUL, controls, malformed UTF-8, empty ambiguous segments, and malformed escapes; + is literal in paths.

routes and mounting
State.Routes.Get ("/", Home'Access, Name => "home");
State.Routes.Get
  ("/users/{id}", Show_User'Access, Name => "users.show");

Admin_Routes.Get
  ("/status", Admin_Status'Access, Name => "status");
State.Routes.Mount
  ("/admin", Admin_Routes, Name_Prefix => "admin.");

Query parsing is lazy. Repeated values retain occurrence order, invalid percent escapes are rejected, and + becomes a space only in the query component.

After setup, Route_Count, Describe_Route, and the matching middleware-description operations return owned metadata in deterministic registration order. Applications can build diagnostics without exposing handler access values. Registration and introspection must not run concurrently; dispatch itself does not allocate descriptions or perform extra dynamic lookup.

HTTP 05

Select body behavior per route.

Every route explicitly rejects, streams, buffers, or discards a request body. Buffered bodies and retained WebSocket messages reserve from a server-wide shared ingress budget before allocation. Streaming writes directly into the caller's bounded buffer and still observes the maximum decoded size and original request deadline.

buffered and streamed route policy
State.Routes.Post
  ("/echo", Buffered_Echo'Access, Name => "echo",
   Policy =>
     (Routing.Default_Route_Policy with delta
        Body_Handling => App.Buffer_Body,
        Max_Body      => 64 * 1_024));

State.Routes.Post
  ("/upload", Upload'Access, Name => "upload",
   Policy =>
     (Routing.Default_Route_Policy with delta
        Body_Handling => App.Stream_Body));

The default rejects bodies. Discard is available only when an endpoint deliberately opts in; it is never hidden middleware behavior.

HTTP 06

Compose deterministic around middleware.

A middleware component receives the typed context, exchange, and a single-use Next continuation. Code before Next.Call runs inward; code after it runs outward. A component may short circuit with a response. Global components wrap route-local components in registration order. Request-head components run before body acceptance and delayed 100 Continue; application-stage components run afterward.

maintained middleware order
State.Routes.Add_Middleware
  (Error_Middleware.Call'Access, Name => "errors");
State.Routes.Add_Middleware
  (Request_ID_Middleware.Call'Access, Name => "request-id");
State.Routes.Add_Middleware
  (Logging_Middleware.Call'Access, Name => "access-log");
State.Routes.Add_Middleware
  (Metrics_Middleware.Call'Access, Name => "metrics");
State.Routes.Add_Middleware
  (CORS_Middleware.Call'Access, Name => "cors");
State.Routes.Add_Middleware
  (Rate_Middleware.Call'Access, Name => "rate-limit");
State.Routes.Add_Middleware
  (Bulkhead_Middleware.Call'Access, Name => "bulkhead");
State.Routes.Add_Middleware
  (Security_Middleware.Call'Access, Stage => Routing.Application,
   Name => "security-headers");
HTTP 07

Map errors only while framing is safe.

Error middleware distinguishes protocol failures, timeouts, cancellation, ingress exhaustion, rate rejection, bulkhead rejection, expected application errors, and unexpected exceptions. Detailed exception information goes only to the configured sink. An unexpected exception becomes a generic closing 500 before response start. Once bytes may have been written, the connection is marked failed and closed; middleware never attempts a replacement response over ambiguous framing.

Application scopes may install an expected-error mapper. Mappers are ordinary callbacks and can be instantiated separately for different routers.

HTTP 08

Keep identity and cross-origin policy application-owned.

Authentication middleware parses the authorization scheme and credential, then delegates verification to an application hook. The example token is intentionally only an example. Credentials are never passed to logging or metrics. Required routes receive a generic challenge when the hook does not install a principal.

route-local authentication and CORS slot
State.Routes.Get
  ("/private", Private_Profile'Access, Name => "private",
   Policy =>
     (Routing.Default_Route_Policy with delta
        Authentication => Routing.Required_Authentication,
        CORS_Policy     => 1));
State.Routes.Add_Route_Middleware
  ("private", Authentication_Middleware.Call'Access);

CORS policies use explicit origins, methods, and headers. Wildcard origin plus credentials is rejected. Preflights run before body admission and emit the required Vary fields. Browser security headers are configurable; HSTS remains off unless the deployment is intentionally HTTPS-only.

HTTP 09

Bound rate, concurrency, and time.

The token-bucket limiter uses a bounded, sharded key table and a monotonic clock. The application chooses the client key; forwarded proxy headers are not trusted implicitly. Bulkheads reject without waiting and release controlled permits during normal return, exceptions, cancellation, abort, and finalization. Route and middleware deadlines may only narrow the original absolute deadline.

route-local admission policy
Policy =>
  (Routing.Default_Route_Policy with delta
     Timeout         => 1.0,
     Concurrency     => 8,
     Rate_Per_Second => 10)

Rate rejection is 429 and bulkhead rejection is 503. Both can emit Retry-After and bounded denial metrics.

HTTP 10

Observe stable route names, not attacker paths.

Request-ID middleware generates a bounded safe identifier unless an explicit trust policy accepts a valid inbound value. Its protected fly-N counter remains the zero-configuration default. An optional callback receives application state and a read-only borrowed exchange, so an application can provide a task-safe UUID, trace-context, or deployment-specific generator. Returned values must still pass the middleware's character and 128-byte checks before reaching response headers, logs, or metrics.

UUIDv7 request IDs in the kitchen sink
procedure Generate_Request_ID
  (State : in out Application_Context;
   X     : App.Exchange;
   Value : out Ada.Strings.Unbounded.Unbounded_String)
is
   pragma Unreferenced (X);
begin
   State.Request_IDs.Generate (Value);
end Generate_Request_ID;

package Request_ID_Middleware is new
  Flyology.HTTP.Server.Middleware_Request_IDs
    (Application_Context, Routing.Components,
     Trust_Inbound => False,
     Generate      => Generate_Request_ID'Access);

The showcase keeps uuids in its own Alire manifest rather than imposing that choice on core Flyology applications. Access logs include method, route name, optional safe target, status, request ID, peer, byte counts, and monotonic elapsed time. They do not receive authorization, cookies, or bodies. The default in-memory metrics sink caps route/method/status-class series and reports dropped series rather than growing without bound.

The maintained /metrics endpoint demonstrates reading a snapshot without coupling the server to a monitoring format. Applications can instead implement the sink interface for their metrics system.

owned setup snapshot
State.Application.Introspection :=
  Ada.Strings.Unbounded.To_Unbounded_String
    (Build_Routing_JSON (State.Routes));

The kitchen sink exposes that immutable registry at /introspection: route methods, patterns, stable names, body and admission policies, upgrades, and named middleware. Its /runtime/events SSE endpoint samples the stack pool, bounded HTTP metrics, and Flyology.Observability.Snapshot for already-created execution groups every 750 milliseconds. Reading the feed neither creates configured groups nor instruments each dispatch.

Execution-group members include parked lightweight handler tasks provisioned by server capacity, while active HTTP requests are reported separately. Native handlers do not appear as execution-group members. Route and policy metadata can reveal application structure, so exposing these endpoints outside a development or protected operational surface is an application deployment decision.

HTTP 11

Stream uploads under one deadline.

streamed body loop
loop
   X.Read_Body (Buffer, Last, Finished);
   if Last >= Buffer'First then
      Total := Total + Natural (Last - Buffer'First + 1);
   end if;
   exit when Finished;
end loop;

Routing, authorization, body policy, ingress capacity, and other request-head middleware finish before 100 Continue is sent. Header and complete-request clocks both begin before the first header byte. A route can narrow the latter after admission, but sending one byte at a time cannot restart either deadline. Fixed Content-Length and chunked transfer coding follow the same policy.

The browser demo also exercises the response direction: maintained static assets are read into a bounded 32 KiB Stream_Element_Array and passed directly to the binary X.Write_Chunk overload.

exercise delayed Continue
curl --http1.1 -H 'Expect: 100-continue' \
  --data-binary @large.bin http://127.0.0.1:18082/upload
HTTP 12

Serialize SSE through a bounded mailbox.

The raw SSE procedures remain available. The optional lifecycle session lets producers publish into a fixed-capacity, byte-bounded closeable channel. The maintained browser feed starts a producer concurrently with Run, sends six named flight events, emits heartbeats while one step pauses, and finishes with a complete event. The handler calling Run is the only connection writer and transport backpressure remains synchronous. A send failure closes admission and requests producer cancellation.

bounded SSE endpoint excerpt
Session : Flyology.HTTP.Server.SSE_Handlers.Session
  (Capacity   => 3,
   Byte_Limit => Flyology.HTTP.Server.SSE_Handlers.Default_Session_Bytes,
   Budget     => null);

Flyology.HTTP.Server.SSE_Handlers.Run
  (X, Session, Metrics'Access,
   Idle_Quantum => 0.05, Heartbeat => 0.5);
HTTP 13

Give WebSocket writes one owner.

WebSocket lifecycle endpoints require an explicit browser-origin policy. Incoming messages are reassembled under the shared ingress budget and bounded maximum. Producers receive only a session mailbox; the owner drains it, serializes all frames, processes open/message/close callbacks, and performs a clean close. The maintained example starts two scoped producers without exposing the exchange or connection to either task.

Compression is also explicit. Selecting Permessage_Deflate negotiates RFC 7692 with no context takeover; the pure-Ada decoder charges decompressed bytes to the same ingress policy. The default declines extension offers, which remains appropriate for secret-bearing messages and applications that do not need compressed-client interoperability.

Application payloads are byte-native. A binary send accepts Ada.Streams.Stream_Element_Array directly; a receive returns owned Flyology.Bytes.Unbounded_Bytes because the reassembled length is known only at runtime, and To_Array produces the standard contiguous byte array. The wrapper uses a definite vector internally and hides its index and capacity policy. Text helpers perform an explicitly named one-to-one byte-string mapping; they do not reinterpret arbitrary binary data as characters.

binary message echo
Data : Flyology.Bytes.Unbounded_Bytes;
Kind : HTTP.WebSocket_Data_Kind;

X.Receive_WebSocket (Kind, Data, Closed);
if not Closed and then Kind = HTTP.Binary_Frame then
   X.Send_WebSocket (Kind, Flyology.Bytes.To_Array (Data));
end if;
lifecycle configuration
package Chat_Lifecycle is new
  WS.Lifecycle (WS_Open, WS_Message, WS_Close);

Expected_Origin : constant String :=
  "http://"
  & Ada.Characters.Handling.To_Lower
      (Request_Helpers.Authority (X));

Chat_Lifecycle.Run
  (X, Session,
   Origin_Policy  => HTTP.Require_Exact_Origin,
   Allowed_Origin => Expected_Origin,
   Metric_Output  => Metrics'Access);

The direct HTTP showcase derives one exact expected origin from the core-validated request authority, so the same binary works at either localhost or 127.0.0.1 without allowing arbitrary origins. A TLS deployment would use https://; applications behind a trusted proxy must derive public authority and scheme only through an explicit trusted-hop policy.

The raw RFC 6455 API remains usable for applications needing a different lifecycle. Client masking, UTF-8, close codes, control frames, fragmentation, size limits, and terminal protocol failures remain core invariants.

HTTP 14

Run request work in structured lightweight scopes.

Request_Tasks borrows the exchange cancellation token through an Ada access discriminant and inherits the current absolute deadline into a bounded scope. Child operations receive input, a scope-owned child token, and the deadline but never the exchange or connection. Join collects results and exception identities. Scope finalization cancels and joins unfinished children, so no detached task can retain borrowed request state.

two parallel operations
Scope : Request_Operations.Scope (2, X.Cancellation);
User, Orders : Request_Operations.Operation_Handle;

Request_Work.Configure (Scope, X);
Request_Operations.Spawn (Scope, 20, User);
Request_Operations.Spawn (Scope, 1, Orders);
Request_Operations.Join (Scope);

Configure the scope after all deadline-narrowing middleware. Parent cancellation is linked downward into a scope-owned token. Sibling failure and scope exit request only that child token, so unrelated request work is not cancelled upward.

HTTP 15

Offload selected route work explicitly.

Flyology.HTTP.Server.Native_Routes adapts one routed handler to an application-owned Flyology.Native_Executors pool. The lightweight request task continues to own networking, parsing, middleware, routing, cancellation, and response I/O. Prepare copies detached input from the borrowed exchange, a native worker calls Execute with only that value, and Render receives the detached result after the original request task resumes. The request task never changes its lightweight/native designation.

typed routed native boundary
package CPU_Work is new Flyology.Native_Executors
  (Work_Input, Work_Result, Execute);

Pool : aliased CPU_Work.Executor
  (Workers => 4, Capacity => 68);

package CPU_Route is new Flyology.HTTP.Server.Native_Routes
  (App_Context => Application_Context,
   Input_Type  => Work_Input,
   Result_Type => Work_Result,
   Operations  => CPU_Work,
   Executor    => Pool'Access,
   Prepare     => Prepare,
   Render      => Render);

CPU_Work.Start (Pool); -- application setup
Routes.Get
  ("/cpu/{size}", CPU_Route.Handle'Access, Name => "cpu");
-- After the HTTP server has drained:
CPU_Work.Shutdown (Pool);

Workers fixes native pthread parallelism. Capacity bounds queued, running, and completed-but-unclaimed operations; choose it as the intended worker-plus-queue limit. Submission never waits for capacity. A full or stopping route pool receives HTTP 503, and CPU_Work.Statistics (Pool) exposes accepted, rejected, successful, failed, abandoned, current, and peak operation counts.

The adapter propagates the exchange's absolute deadline and cancellation source into submission and cooperative waiting. Native exceptions retain their identity and pass through the router's normal exception mapper. A cancelled or unwound request abandons its single-use result; the executor requests worker cancellation and reclaims the bounded slot when execution returns. Input_Type and Result_Type must not contain an exchange, connection, or access value aliasing mutable request state. A worker must never read or write the live exchange or connection.

Keep short calculations inline because submission and cross-thread wakeup have a fixed cost. Use a routed native pool when selected CPU-heavy work must run in parallel without occupying event-loop pthreads. Prefer a fully native HTTP server when most request work is CPU-bound and its pthread count is acceptable.

Executor shutdown requests cancellation and joins every worker. Execute must therefore check its token and deadline where practical. A foreign function that cannot be interrupted may delay shutdown and needs process isolation when a bounded stop is required.

HTTP 16

Size every retained resource.

The ingress budget bounds buffered bodies and retained/reassembled WebSocket messages. Outgoing SSE/WebSocket mailboxes have a 64 KiB per-message ceiling, a 256 KiB default per-session limit, and a shared 64 MiB default outbound budget charged until send or drop. These budgets do not cover kernel socket buffers, TLS-provider allocations, descriptors, per-connection parser/header state, task or fiber stacks, application buffers, rate-limit keys, metrics series, or native executor storage. Handler capacity, event-loop count, every bounded table, and upstream bulkheads must be chosen together.

  • Set distinct slow-header, complete-request, route, and connection deadlines for the deployment, not only defaults.
  • Keep header, body, trailer, cookie, WebSocket, and mailbox limits explicit.
  • Budget TLS and kernel memory at maximum accepted connection count.
  • Put slow upstream systems behind their own bounded scope or bulkhead.
  • Drain on shutdown; do not detach work holding borrowed state.

Response compression currently exposes only a bounded provider boundary. Middleware remains deferred until it can preserve streaming, HEAD/no-body rules, partial-write safety, and sensitive-data policy.

HTTP 17

Review the security boundary before deployment.

  • Terminate TLS with certificate and key policy appropriate to the deployment; enable HSTS only for intentional HTTPS-only service.
  • Keep the server's absolute slow-header and complete-request deadlines plus decoded limits enabled.
  • Use shared ingress and outbound budgets plus bounded mailbox, metric, rate, and routing capacities.
  • Do not trust inbound request IDs or proxy client headers without an explicit trusted-hop policy.
  • Use exact CORS origins when credentials are involved and an explicit WebSocket origin policy.
  • Never log authorization fields, cookies, bodies, or unbounded raw paths by default.
  • Never let child tasks retain an exchange or write the connection.
  • Close after partial-response failure; do not attempt replacement framing.
  • Keep blocking foreign calls off event-loop pthreads.
  • Run the behavioral, stress, documentation, proof, and platform checks for the changed boundary.
HTTP 18

Benchmark reproducibly with oha.

The benchmark runner builds the release profile, prepares a fixed event-loop count, starts identical native and lightweight handler capacities, warms each lane, alternates trial order, and prints the exact oha command and version. Use at least three trials and compare medians and latency tails, not one run.

500-connection release campaign
./showcases/run_http_benchmark.sh \
  100000 500 500 18080 16 3 10000 release 20 500

The final two arguments are lane cooldown and warm-up concurrency; this command warms and measures at 500 connections. Record CPU count, build profile, warm-up, request count, connection concurrency, handler capacity, event-loop count, success/error counts, throughput, average latency, and p50/p90/p99 tails. Use identical method, body, route, and limits for both lanes. The maintained campaign covers routed GET, a middleware-heavy route, a small buffered body, a streamed upload, and admission-control overhead. SSE and WebSocket require protocol-aware tools rather than treating an open stream as an ordinary completed HTTP request.

The corrected August 2026 comparison is the current published snapshot. It supersedes the preliminary Flyology, AWS, EWS, and ServletAda results after fixing runtime selection and CPU-placement defects, and adds tiered Rust fixtures with raw observations, resource costs, and explicit limits. The earlier snapshots remain linked from the correction as an archival record.

The separate routed-offload runner calibrates deterministic CPU work, verifies byte-identical inline and native results, rotates inline/pool-size/fully-native order, records executor admission and process resources, and adds a mixed CPU/control-route experiment:

Linux/AArch64 routed-offload campaign
HTTP_BENCH_LOOPS=8 \
HTTP_BENCH_SERVER_CPUSET="0-7" \
HTTP_BENCH_CLIENT_CPUSET="8-15" \
HTTP_HYBRID_TRIALS=7 \
HTTP_HYBRID_DURATION=30s \
  ./showcases/http-comparison/scripts/run-linux-docker-hybrid.sh

The runner stops on a failed request or non-200 response rather than including it in throughput. Its timestamped directory retains calibration, host and toolchain metadata, unmodified oha JSON, process samples, executor counters, CSV, and Markdown summaries.