diff --git a/CHANGELOG.md b/CHANGELOG.md
index d5c6b48..b6b2c52 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,8 +7,83 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Added
+
+- **`http_version` selects the protocols to serve** (default
+ `['HTTP/1.1']`). The option was documented but read by nothing; it now
+ drives which listeners bind. `'HTTP/2'` and `'HTTP/3'` exist only over
+ TLS and are refused with
+ `{error, {http_version_requires_ssl, Version}}` when `ssl` is off,
+ rather than quietly serving HTTP/1.1.
+ - `['HTTP/1.1', 'HTTP/2']` serves both from one TLS port, chosen per
+ connection by ALPN, so a client that cannot speak HTTP/2 is still
+ served rather than refused. This restores what cowboy's `start_tls`
+ did before the livery move.
+ - `'HTTP/3'` adds a QUIC listener, on the `bind` port number by
+ default or on the new `http3_port`, and advertises it to HTTP/1.1
+ and HTTP/2 clients through `Alt-Svc`. Its certificate and key are
+ converted from the configured PEM files to the DER shapes quic
+ expects.
+- `hornbeam:info/0` reports every bound protocol. Following livery
+ 0.8.0, `listeners` maps each protocol to a **list** of ports, because
+ one port can serve two protocols and one protocol can be on several
+ ports.
+- **`max_request_line_size`, `max_header_size` and `max_headers` are
+ enforced.** They were accepted and documented but reached nothing. A
+ breach is now answered `414` or `431` instead of being ignored.
+
+### Fixed
+
+- **A HEAD response over HTTP/2 or HTTP/3 crashed the handler.** The
+ ASGI and WSGI producers only handled `{error, closed}` from a stream
+ write, and h2/h3 answer `{error, invalid_stream_state}` when a body is
+ written to a stream that must not carry one, where HTTP/1.1 silently
+ drops it.
+
### Changed
+- **HTTP server moved from cowboy to livery**: hornbeam now serves HTTP
+ through [livery](https://github.com/benoitc/livery) instead of cowboy.
+ - The listener is owned by a new supervised `hornbeam_listener`
+ process; `hornbeam:info/0` and `hornbeam:is_running/0` replace ranch
+ introspection.
+ - `hornbeam_handler` is a livery handler; WSGI and ASGI responses
+ resolve through `livery_resp:stream_deferred/1` in the per-request
+ process (one mailbox for request-body chunks and Python events).
+ - WebSocket runs on the `ws_handler` behaviour (new
+ `hornbeam_ws_handler`); `hornbeam_websocket` keeps the upgrade
+ entry point, scope building, and the session registry.
+ - Duplicate request headers are preserved in the ASGI scope and
+ joined with `", "` in WSGI `HTTP_*` keys.
+ - New `max_body` option (default `infinity`) caps the request body at
+ the listener.
+ - Requires livery 0.8.0: 103 Early Hints go out through
+ `livery_req:inform/3`, WSGI `REMOTE_ADDR` and the ASGI `client` /
+ WebSocket scope carry the real peer, `websocket_max_frame_size` and
+ `websocket_compress` are forwarded to `livery_ws:upgrade/3`, and
+ ASGI `websocket.disconnect` reports the peer's own close code
+ (1005 when it closed without one).
+- **HTTP client moved from hackney to livery_client**: the test suites
+ call `livery_client` directly and match its response map; hackney is no
+ longer a direct dependency, and the shim that kept hackney's
+ `request/5` shape is gone.
+
+### Removed (breaking)
+
+- **`routes` option shape changed**: routes are livery router entries
+ `{Method | '_', Pattern, HandlerFun | {Mod, Fun}}` (optionally with a
+ meta map) instead of cowboy `{Path, HandlerModule, Opts}` tuples.
+ Cowboy handler modules are rejected with `{error, {invalid_route, _}}`.
+- **`hornbeam_wsgi` module removed**: the classic `build_environ/1,2`
+ path was dead code (the live path is
+ `hornbeam_request:build_wsgi_tuple/2`).
+
+### Known limitations
+
+- 103 Early Hints are sent on HTTP/1.1 and HTTP/2 but not HTTP/3, which
+ has no interim responses in livery yet; the
+ `http.response.early_hints` ASGI extension is advertised accordingly.
+
- **erlang_python v3.0**: Track the simplified execution model
- Switched dep to erlang_python `main` (worker / owngil modes only)
- `config/sys.config`: replaced obsolete `num_workers` key with `num_contexts`
diff --git a/README.md b/README.md
index 733b37c..e5bbe09 100644
--- a/README.md
+++ b/README.md
@@ -12,7 +12,7 @@ The name combines "horn" (unicorn, like gunicorn) with "BEAM" (Erlang VM).
- **WSGI Support**: Run standard WSGI Python applications
- **ASGI Support**: Run async ASGI Python applications (FastAPI, Starlette, etc.)
- **WebSocket**: Full WebSocket support for real-time apps
-- **HTTP/2**: Via Cowboy, with multiplexing and server push
+- **HTTP via livery**: HTTP/1.1 today; HTTP/2 and HTTP/3 listeners land with upcoming livery releases
- **Shared State**: ETS-backed state accessible from Python (concurrent-safe)
- **Distributed RPC**: Call functions on remote Erlang nodes
- **Pub/Sub**: pg-based publish/subscribe messaging
@@ -227,7 +227,8 @@ hornbeam:start("myapp:application", #{
%% Protocol
worker_class => wsgi, % wsgi | asgi
- http_version => ['HTTP/1.1', 'HTTP/2'],
+ %% 'HTTP/2' and 'HTTP/3' require ssl => true
+ http_version => ['HTTP/1.1'],
%% Workers
workers => 4,
diff --git a/docs/guides/asgi.md b/docs/guides/asgi.md
index acb0bf1..320096f 100644
--- a/docs/guides/asgi.md
+++ b/docs/guides/asgi.md
@@ -39,7 +39,7 @@ The scope dict contains request information:
|-----|------|-------------|
| `type` | str | `"http"` |
| `asgi` | dict | `{"version": "3.0"}` |
-| `http_version` | str | `"1.1"` or `"2"` |
+| `http_version` | str | `"1.1"`, `"2"` or `"3"` |
| `method` | str | HTTP method |
| `scheme` | str | `"http"` or `"https"` |
| `path` | str | URL path (stripped of mount prefix in [multi-app mode](/docs/guides/multi-app)) |
@@ -367,8 +367,8 @@ hornbeam:start("app:app", #{
workers => 4,
timeout => 30000,
- %% HTTP
- http_version => ['HTTP/1.1', 'HTTP/2']
+ %% HTTP; 'HTTP/2' and 'HTTP/3' require ssl => true
+ http_version => ['HTTP/1.1']
}).
```
diff --git a/docs/guides/benchmarking.md b/docs/guides/benchmarking.md
index f9032d0..56de66e 100644
--- a/docs/guides/benchmarking.md
+++ b/docs/guides/benchmarking.md
@@ -283,11 +283,12 @@ hornbeam:start("app:application", #{
### HTTP/2 for Lower Latency
-Enable HTTP/2 for multiplexed connections:
+Enable HTTP/2 for multiplexed connections. Listing HTTP/1.1 alongside it
+serves both from the one TLS port, chosen per connection by ALPN:
```erlang
hornbeam:start("app:application", #{
- http_version => ['HTTP/2', 'HTTP/1.1'],
+ http_version => ['HTTP/1.1', 'HTTP/2'],
ssl => true,
certfile => "server.crt",
keyfile => "server.key"
diff --git a/docs/guides/custom-apps.md b/docs/guides/custom-apps.md
index b1d5779..5edab30 100644
--- a/docs/guides/custom-apps.md
+++ b/docs/guides/custom-apps.md
@@ -333,9 +333,12 @@ def distributed_inference(texts):
return results
```
-## Custom Cowboy Routes
+## Custom Erlang Routes
-Add custom routes alongside your Python app:
+Add custom routes alongside your Python app. A route is
+`{Method | '_', Pattern, Handler}` where Handler is a `fun/1` or
+`{Module, Function}` taking a livery request and returning a livery
+response:
```erlang
%% Start with custom routes
@@ -344,13 +347,13 @@ hornbeam:start("app:app", #{
pythonpath => ["priv/python"],
routes => [
%% Health check - pure Erlang, no Python
- {"/health", health_handler, []},
+ {<<"GET">>, <<"/health">>, fun health_handler:handle/1},
%% Metrics endpoint
- {"/metrics", metrics_handler, []},
+ {<<"GET">>, <<"/metrics">>, {metrics_handler, handle}},
%% WebSocket with custom handler
- {"/ws/[...]", my_websocket_handler, []}
+ {'_', <<"/ws/*rest">>, fun my_websocket_handler:handle/1}
]
}).
```
@@ -358,15 +361,19 @@ hornbeam:start("app:app", #{
```erlang
%% health_handler.erl
-module(health_handler).
--export([init/2]).
+-export([handle/1]).
-init(Req, State) ->
- Reply = cowboy_req:reply(200,
- #{<<"content-type">> => <<"application/json">>},
- <<"{\"status\":\"ok\"}">>,
- Req
- ),
- {ok, Reply, State}.
+handle(_Req) ->
+ livery_resp:json(200, <<"{\"status\":\"ok\"}">>).
+```
+
+A WebSocket route upgrades from inside its handler with a module
+implementing the `ws_handler` behaviour:
+
+```erlang
+%% my_websocket_handler.erl
+handle(Req) ->
+ livery_ws:upgrade(Req, my_ws_session, #{idle_timeout => 60000}).
```
## Configuration
diff --git a/docs/guides/erlang-apps.md b/docs/guides/erlang-apps.md
index e4117c5..fac2d6c 100644
--- a/docs/guides/erlang-apps.md
+++ b/docs/guides/erlang-apps.md
@@ -41,13 +41,8 @@ cd my_app
{vm_args, "config/vm.args"}
]}.
-{profiles, [
- {test, [
- {deps, [
- {hackney, "3.0.2"}
- ]}
- ]}
-]}.
+%% livery (already a hornbeam dependency) provides the HTTP client
+%% used in the test suite below; no extra test dependency needed.
```
### Project Structure
@@ -519,18 +514,18 @@ def get_from_remote(key):
all() -> [test_api].
init_per_suite(Config) ->
- {ok, _} = application:ensure_all_started(hackney),
{ok, _} = application:ensure_all_started(my_app),
Config.
end_per_suite(_Config) ->
application:stop(my_app),
- application:stop(hackney),
ok.
test_api(_Config) ->
- {ok, 200, _Headers, ClientRef} = hackney:request(get, <<"http://localhost:8000/">>, [], <<>>, []),
- {ok, Body} = hackney:body(ClientRef),
+ Client = livery_client:new(#{}),
+ {ok, Resp} = livery_client:get(Client, <<"http://localhost:8000/">>),
+ 200 = livery_client:status(Resp),
+ {full, Body} = livery_client:body(Resp),
true = is_binary(Body),
ok.
```
diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md
index a78c125..8cd13b6 100644
--- a/docs/reference/configuration.md
+++ b/docs/reference/configuration.md
@@ -85,9 +85,10 @@ See [Multi-App Guide](/docs/guides/multi-app) for detailed usage.
|--------|------|---------|-------------|
| `bind` | binary/string | `"127.0.0.1:8000"` | Address and port to bind to |
| `ssl` | boolean | `false` | Enable SSL/TLS |
-| `certfile` | binary | `undefined` | Path to SSL certificate |
-| `keyfile` | binary | `undefined` | Path to SSL private key |
+| `certfile` | binary | `undefined` | Path to SSL certificate (PEM) |
+| `keyfile` | binary | `undefined` | Path to SSL private key (PEM) |
| `cacertfile` | binary | `undefined` | Path to CA certificate |
+| `http3_port` | integer | `undefined` | UDP port for HTTP/3; defaults to the `bind` port |
### SSL Example
@@ -100,6 +101,43 @@ hornbeam:start("app:application", #{
}).
```
+### HTTP versions
+
+`http_version` picks which protocols to serve. It defaults to
+`['HTTP/1.1']`. `'HTTP/2'` and `'HTTP/3'` exist only over TLS, so both
+require `ssl => true`; asking for either without it fails at startup with
+`{error, {http_version_requires_ssl, Version}}` rather than silently
+serving HTTP/1.1.
+
+```erlang
+hornbeam:start("app:application", #{
+ bind => "0.0.0.0:443",
+ ssl => true,
+ certfile => "/path/to/cert.pem",
+ keyfile => "/path/to/key.pem",
+ http_version => ['HTTP/1.1', 'HTTP/2', 'HTTP/3']
+}).
+```
+
+| Setting | What binds |
+|---------|------------|
+| `['HTTP/1.1']` | One TCP listener, cleartext or TLS depending on `ssl` |
+| `['HTTP/1.1', 'HTTP/2']` | One TLS port serving both, chosen per connection by ALPN |
+| `['HTTP/2']` | One TLS port, HTTP/2 only; a client that cannot do h2 is refused |
+| `['HTTP/3']` added | A QUIC listener on `http3_port`, advertised to HTTP/1.1 and HTTP/2 clients with `Alt-Svc` |
+
+HTTP/3 can share the `bind` port number because QUIC is UDP and the other
+listeners are TCP.
+
+`hornbeam:info/0` reports what actually bound:
+
+```erlang
+#{running => true, listeners => #{h1 => [443], h2 => [443], h3 => [443]}}
+```
+
+Each protocol maps to a list of ports, since one port can serve two
+protocols and one protocol can appear on more than one port.
+
## Protocol Options
| Option | Type | Default | Description |
@@ -159,6 +197,10 @@ hornbeam:start("app:app", #{
| `max_header_size` | integer | `8190` | Max HTTP header value length |
| `max_headers` | integer | `100` | Max number of HTTP headers |
+A request over the request-line limit is answered `414 URI Too Long`; one
+over either header limit is answered `431 Request Header Fields Too Large`.
+These apply to the HTTP/1.1 and ALPN listeners.
+
## Python Options
| Option | Type | Default | Description |
diff --git a/examples/embedding_chat/src/embedding_chat_app.erl b/examples/embedding_chat/src/embedding_chat_app.erl
index 7feb541..b979c65 100644
--- a/examples/embedding_chat/src/embedding_chat_app.erl
+++ b/examples/embedding_chat/src/embedding_chat_app.erl
@@ -55,7 +55,7 @@ start(_StartType, _StartArgs) ->
pythonpath => [priv_dir(), VenvSitePackages],
%% /ws handled by Erlang, rest by FastAPI
routes => [
- {"/ws", embedding_chat_ws, #{}}
+ {'_', <<"/ws">>, fun embedding_chat_ws:handle/1}
]
}),
diff --git a/examples/embedding_chat/src/embedding_chat_ws.erl b/examples/embedding_chat/src/embedding_chat_ws.erl
index 05bcaad..8ae3dcb 100644
--- a/examples/embedding_chat/src/embedding_chat_ws.erl
+++ b/examples/embedding_chat/src/embedding_chat_ws.erl
@@ -12,19 +12,23 @@
%% See the License for the specific language governing permissions and
%% limitations under the License.
-%%% @doc WebSocket echo handler using cowboy_websocket.
+%%% @doc WebSocket echo handler using the ws_handler behaviour.
%%%
%%% Pure Erlang WebSocket handler that echoes messages.
%%% Demonstrates handling WebSocket in Erlang while HTTP is Python/FastAPI.
+%%% Mounted through hornbeam's `routes' option; the route handler
+%%% upgrades the request via livery_ws:upgrade/3.
-module(embedding_chat_ws).
--behaviour(cowboy_websocket).
+-behaviour(ws_handler).
+%% Route handler (HTTP side)
+-export([handle/1]).
+%% ws_handler callbacks
-export([init/2]).
--export([websocket_init/1]).
--export([websocket_handle/2]).
--export([websocket_info/2]).
--export([terminate/3]).
+-export([handle_in/2]).
+-export([handle_info/2]).
+-export([terminate/2]).
-record(state, {
count = 0 :: non_neg_integer(),
@@ -32,21 +36,22 @@
}).
%%% ============================================================================
-%%% Cowboy WebSocket callbacks
+%%% Route handler
%%% ============================================================================
-init(Req, Opts) ->
- {cowboy_websocket, Req, Opts, #{
- idle_timeout => 60000,
- max_frame_size => 65536
- }}.
+handle(Req) ->
+ livery_ws:upgrade(Req, ?MODULE, #{idle_timeout => 60000}).
-websocket_init(_Opts) ->
+%%% ============================================================================
+%%% ws_handler callbacks
+%%% ============================================================================
+
+init(_Req, _Opts) ->
State = #state{connected_at = os:timestamp()},
Welcome = <<"Connected to Erlang WebSocket echo server!">>,
- {[{text, Welcome}], State}.
+ {reply, {text, Welcome}, State}.
-websocket_handle({text, Text}, #state{count = Count} = State) ->
+handle_in({text, Text}, #state{count = Count} = State) ->
NewCount = Count + 1,
Reply = iolist_to_binary([
<<"[Erlang echo #">>,
@@ -54,30 +59,31 @@ websocket_handle({text, Text}, #state{count = Count} = State) ->
<<"] ">>,
Text
]),
- {[{text, Reply}], State#state{count = NewCount}};
+ {reply, {text, Reply}, State#state{count = NewCount}};
-websocket_handle({binary, Data}, #state{count = Count} = State) ->
+handle_in({binary, Data}, #state{count = Count} = State) ->
NewCount = Count + 1,
- {[{binary, Data}], State#state{count = NewCount}};
+ {reply, {binary, Data}, State#state{count = NewCount}};
-websocket_handle({ping, Data}, State) ->
- {[{pong, Data}], State};
+handle_in({ping, _Data}, State) ->
+ %% ws auto-replies to pings before this callback
+ {ok, State};
-websocket_handle(_Frame, State) ->
+handle_in(_Frame, State) ->
{ok, State}.
-websocket_info({send, Text}, State) when is_binary(Text) ->
- {[{text, Text}], State};
+handle_info({send, Text}, State) when is_binary(Text) ->
+ {reply, {text, Text}, State};
-websocket_info(close, State) ->
- {[{close, 1000, <<"Server closing">>}], State};
+handle_info(close, State) ->
+ {reply, {close, 1000, <<"Server closing">>}, State};
-websocket_info(_Info, State) ->
+handle_info(_Info, State) ->
{ok, State}.
-terminate(_Reason, _Req, #state{count = Count, connected_at = ConnectedAt}) ->
+terminate(_Reason, #state{count = Count, connected_at = ConnectedAt}) ->
Duration = timer:now_diff(os:timestamp(), ConnectedAt) div 1000000,
io:format("WebSocket closed: ~p messages in ~p seconds~n", [Count, Duration]),
ok;
-terminate(_Reason, _Req, _State) ->
+terminate(_Reason, _State) ->
ok.
diff --git a/rebar.config b/rebar.config
index c3b2ccf..91ad8b4 100644
--- a/rebar.config
+++ b/rebar.config
@@ -15,12 +15,12 @@
unmatched_returns,
error_handling
]},
- {plt_extra_apps, [cowboy, ranch]}
+ {plt_extra_apps, [livery, ws]}
]}.
{deps, [
- {cowboy, "2.12.0"},
- {erlang_python, "3.1.1"}
+ {livery, "~> 0.9.2"},
+ {erlang_python, "5.0.0"}
]}.
{shell, [
@@ -39,7 +39,6 @@
{profiles, [
{test, [
{deps, [
- {hackney, "3.2.1"},
{jsx, "3.1.0"}
]}
]}
diff --git a/src/hornbeam.app.src b/src/hornbeam.app.src
index 5add815..4bfc894 100644
--- a/src/hornbeam.app.src
+++ b/src/hornbeam.app.src
@@ -6,7 +6,7 @@
{applications,
[kernel,
stdlib,
- cowboy,
+ livery,
erlang_python
]},
{env,[
diff --git a/src/hornbeam.erl b/src/hornbeam.erl
index e31e5c4..1c0f2ce 100644
--- a/src/hornbeam.erl
+++ b/src/hornbeam.erl
@@ -15,7 +15,7 @@
%%% @doc Main API for hornbeam WSGI/ASGI server.
%%%
%%% Hornbeam is an Erlang-based WSGI/ASGI server that uses erlang-python
-%%% for Python execution and Cowboy for HTTP handling.
+%%% for Python execution and livery for HTTP handling.
%%%
%%% == Quick Start ==
%%%
@@ -42,7 +42,7 @@
%%% {"/admin", "admin:app", #{worker_class => wsgi}},
%%% {"/", "frontend:app", #{worker_class => wsgi}}
%%% ],
-%%% routes => [{"/health", health_handler, #{}}] %% optional Erlang handlers
+%%% routes => [{<<"GET">>, "/health", fun health_handler:handle/1}] %% optional Erlang handlers
%%% }).
%%%
%%% %% Register Erlang functions callable from Python
@@ -57,6 +57,8 @@
start/1,
start/2,
stop/0,
+ info/0,
+ is_running/0,
register_function/2,
register_function/3,
unregister_function/1
@@ -69,6 +71,7 @@
num_contexts => pos_integer(),
num_acceptors => pos_integer(),
worker_class => wsgi | asgi,
+ http_version => [http_version()],
context_mode => worker | owngil,
timeout => pos_integer(),
keepalive => pos_integer(),
@@ -82,12 +85,15 @@
websocket_timeout => pos_integer(),
websocket_max_frame_size => pos_integer(),
websocket_compress => boolean(),
- routes => [{Path :: binary(), Handler :: module(), Opts :: map()}],
+ max_body => non_neg_integer() | infinity,
+ routes => [route()],
%% SSL/TLS
ssl => boolean(),
certfile => string() | binary() | undefined,
keyfile => string() | binary() | undefined,
cacertfile => string() | binary() | undefined,
+ %% UDP port for the HTTP/3 listener; defaults to the `bind' port
+ http3_port => inet:port_number(),
%% HTTP lifecycle hooks
hooks => #{
on_request => fun((map()) -> map()),
@@ -95,11 +101,19 @@
on_error => fun((term(), map()) -> {integer(), binary()})
}
}.
+-type route() ::
+ {Method :: binary() | '_', Pattern :: binary() | string(),
+ Handler :: fun((term()) -> term()) | {module(), atom()}} |
+ {Method :: binary() | '_', Pattern :: binary() | string(),
+ Handler :: fun((term()) -> term()) | {module(), atom()}, Meta :: map()}.
+%% `HTTP/2' and `HTTP/3' are TLS-only, so they require `ssl => true'.
+-type http_version() :: 'HTTP/1.1' | 'HTTP/2' | 'HTTP/3'.
-type multi_app_config() :: #{
mounts := [mount_spec()],
- routes => [{Path :: binary(), Handler :: module(), Opts :: map()}],
+ routes => [route()],
bind => string() | binary(),
num_acceptors => pos_integer(),
+ http_version => [http_version()],
pythonpath => [string() | binary()],
venv => string() | binary() | undefined,
%% SSL/TLS
@@ -107,6 +121,7 @@
certfile => string() | binary() | undefined,
keyfile => string() | binary() | undefined,
cacertfile => string() | binary() | undefined,
+ http3_port => inet:port_number(),
%% HTTP lifecycle hooks
hooks => #{
on_request => fun((map()) -> map()),
@@ -115,7 +130,8 @@
}
}.
--export_type([app_spec/0, options/0, mount_spec/0, multi_app_config/0]).
+-export_type([app_spec/0, options/0, mount_spec/0, multi_app_config/0, route/0,
+ http_version/0]).
%% @doc Start hornbeam with a WSGI/ASGI application or multi-app config.
%%
@@ -135,8 +151,12 @@ start(AppSpec) when is_list(AppSpec); is_binary(AppSpec) ->
%%
%% - `bind' - Address to bind to (default: "127.0.0.1:8000")
%% - `num_contexts' - Number of Python contexts (default: schedulers)
-%% - `num_acceptors' - Number of Cowboy acceptor processes (default: 100)
+%% - `num_acceptors' - Number of HTTP acceptor processes (default: 100)
%% - `worker_class' - wsgi or asgi (default: wsgi)
+%% - `http_version' - HTTP versions to serve (default: ['HTTP/1.1']).
+%% `'HTTP/2'' and `'HTTP/3'' are TLS-only and require `ssl => true'.
+%% `['HTTP/1.1', 'HTTP/2']' serves both from the `bind' port by ALPN;
+%% `'HTTP/3'' adds a QUIC listener on the same port number over UDP
%% - `timeout' - Request timeout in ms (default: 30000)
%% - `keepalive' - Keep-alive timeout in seconds (default: 2)
%% - `max_requests' - Max requests per worker before restart (default: 1000)
@@ -148,7 +168,7 @@ start(AppSpec) when is_list(AppSpec); is_binary(AppSpec) ->
%% - `lifespan_timeout' - Lifespan startup/shutdown timeout in ms (default: 30000)
%% - `websocket_timeout' - WebSocket idle timeout in ms (default: 60000)
%% - `websocket_max_frame_size' - Max WebSocket frame size (default: 16MB)
-%% - `routes' - Custom Cowboy routes [{Path, Handler, Opts}] (default: [])
+%% - `routes' - Custom livery routes [{Method, Pattern, Handler}] (default: [])
%%
-spec start(app_spec(), options()) -> ok | {error, term()}.
start(AppSpec, Options) ->
@@ -209,9 +229,24 @@ stop() ->
_ = hornbeam_lifespan:shutdown(),
%% Stop the HTTP listener
- _ = ranch:stop_listener(hornbeam_http),
+ _ = hornbeam_listener:stop_service(),
ok.
+%% @doc Listener status: `#{running := boolean(), listeners := map()}'.
+%%
+%% `listeners' maps each running protocol (`h1', `h2', `h3') to the list
+%% of ports serving it. A list because the mapping is many-to-many: an
+%% ALPN listener serves `h1' and `h2' from one port, and `h1' can also be
+%% on a second, cleartext port beside it.
+-spec info() -> #{running := boolean(), listeners := #{h1 | h2 | h3 => [inet:port_number()]}}.
+info() ->
+ hornbeam_listener:info().
+
+%% @doc Whether the HTTP listener is running.
+-spec is_running() -> boolean().
+is_running() ->
+ hornbeam_listener:is_running().
+
%% @doc Register an Erlang function to be callable from Python.
%% The function should accept a list of arguments and return a term.
%%
@@ -469,52 +504,13 @@ run_lifespan_for_mounts([Mount | Rest], Opts) ->
%% @private
%% Start the HTTP listener in multi-app mode
start_listener_multi(Config) ->
- {Ip, Port} = parse_bind(maps:get(bind, Config)),
- NumAcceptors = maps:get(num_acceptors, Config, 100),
-
- %% Build custom routes (WebSocket handlers, etc.)
- CustomRoutes = maps:get(routes, Config, []),
-
%% Multi-app handler state - lookups mount per request
%% Lifespan state cached at startup (shared across mounts)
HandlerState = #{
multi_app => true,
lifespan_state => hornbeam_lifespan:get_state()
},
-
- %% Default catchall route for Python apps (routes to mounts)
- DefaultRoute = {'_', hornbeam_handler, HandlerState},
-
- %% Combine custom routes with default (custom routes take precedence)
- AllRoutes = CustomRoutes ++ [DefaultRoute],
-
- Dispatch = cowboy_router:compile([
- {'_', AllRoutes}
- ]),
-
- %% Build protocol options including request limits
- ProtoOpts = #{
- env => #{dispatch => Dispatch},
- idle_timeout => maps:get(keepalive, Config, 2) * 1000,
- request_timeout => maps:get(timeout, Config, 30000),
- max_request_line_length => maps:get(max_request_line_size, Config, 4094),
- max_header_value_length => maps:get(max_header_size, Config, 8190),
- max_headers => maps:get(max_headers, Config, 100)
- },
-
- %% Start with SSL/TLS or plain HTTP
- case maps:get(ssl, Config, false) of
- true ->
- start_tls_listener(Ip, Port, Config, ProtoOpts);
- false ->
- TransportOpts = #{
- num_acceptors => NumAcceptors,
- socket_opts => [{port, Port}, {ip, Ip}]
- },
- {ok, _} = cowboy:start_clear(hornbeam_http,
- TransportOpts, ProtoOpts),
- ok
- end.
+ start_service(Config, HandlerState).
default_config() ->
#{
@@ -522,6 +518,7 @@ default_config() ->
%% num_contexts defaults to erlang:system_info(schedulers) in ensure_python_runtime
num_acceptors => 100,
worker_class => wsgi,
+ http_version => ['HTTP/1.1'],
timeout => 30000,
keepalive => 2,
max_requests => 1000,
@@ -646,87 +643,303 @@ maybe_run_lifespan_startup(wsgi, _Config) ->
ok.
start_listener(Config) ->
- {Ip, Port} = parse_bind(maps:get(bind, Config)),
- NumAcceptors = maps:get(num_acceptors, Config, 100),
- WorkerClass = maps:get(worker_class, Config),
-
- %% Build custom routes (WebSocket handlers, etc.)
- CustomRoutes = maps:get(routes, Config, []),
-
%% Cache frequently accessed config values in handler state to avoid
%% repeated ETS lookups per request
%% Lifespan state is fetched once here since it doesn't change after startup
HandlerState = #{
- worker_class => WorkerClass,
+ worker_class => maps:get(worker_class, Config),
app_module => maps:get(app_module, Config),
app_callable => maps:get(app_callable, Config),
timeout => maps:get(timeout, Config, 30000),
lifespan_state => hornbeam_lifespan:get_state()
},
+ start_service(Config, HandlerState).
- %% Default catchall route for Python app
- DefaultRoute = {'_', hornbeam_handler, HandlerState},
-
- %% Combine custom routes with default (custom routes take precedence)
- AllRoutes = CustomRoutes ++ [DefaultRoute],
+%% @private
+%% Build the livery service opts from hornbeam config and start it under
+%% the supervised hornbeam_listener owner. The handler state is exposed to
+%% request handlers via livery_req:config/1.
+start_service(Config, HandlerState0) ->
+ {Ip, Port} = parse_bind(maps:get(bind, Config, <<"127.0.0.1:8000">>)),
+ Ssl = maps:get(ssl, Config, false),
+ Versions = maps:get(http_version, Config, ['HTTP/1.1']),
+ BaseOpts = #{
+ port => Port,
+ ip => Ip,
+ acceptors => maps:get(num_acceptors, Config, 100),
+ idle_timeout => maps:get(keepalive, Config, 2) * 1000,
+ request_timeout => maps:get(timeout, Config, 30000),
+ %% No request-body cap by default: hornbeam streamed bodies
+ %% unbounded under cowboy; livery's own default is 16 MiB.
+ max_body => maps:get(max_body, Config, infinity)
+ },
+ with_ok([
+ fun() -> validate_versions(Versions, Ssl) end,
+ fun() -> build_listeners(Versions, Ssl, Config, BaseOpts, HandlerState0) end,
+ fun(Listeners) -> merge_routing(Config, Listeners) end
+ ]).
- Dispatch = cowboy_router:compile([
- {'_', AllRoutes}
- ]),
+%% @private
+%% Thread a list of steps, short-circuiting on the first error. The first
+%% step takes no argument; each later one takes the previous step's value.
+with_ok([First | Rest]) ->
+ with_ok(Rest, First()).
+
+with_ok(_Steps, {error, _} = Error) ->
+ Error;
+with_ok([], Result) ->
+ Result;
+with_ok([Step | Rest], ok) ->
+ with_ok(Rest, Step());
+with_ok([Step | Rest], {ok, Value}) ->
+ with_ok(Rest, Step(Value)).
- %% Build protocol options including request limits
- ProtoOpts = #{
- env => #{dispatch => Dispatch},
- idle_timeout => maps:get(keepalive, Config) * 1000,
- request_timeout => maps:get(timeout, Config),
- %% Cowboy HTTP options for request limits
- max_request_line_length => maps:get(max_request_line_size, Config, 4094),
- max_header_value_length => maps:get(max_header_size, Config, 8190),
- max_headers => maps:get(max_headers, Config, 100)
- },
+%% @private
+merge_routing(Config, Listeners) ->
+ case build_routing(maps:get(routes, Config, [])) of
+ {ok, Routing} ->
+ hornbeam_listener:start_service(maps:merge(Routing, Listeners));
+ {error, _} = Error ->
+ Error
+ end.
- %% Start with SSL/TLS or plain HTTP
- case maps:get(ssl, Config, false) of
- true ->
- start_tls_listener(Ip, Port, Config, ProtoOpts);
- false ->
- TransportOpts = #{
- num_acceptors => NumAcceptors,
- socket_opts => [{port, Port}, {ip, Ip}]
- },
- {ok, _} = cowboy:start_clear(hornbeam_http,
- TransportOpts, ProtoOpts),
+%% @private
+%% `HTTP/2' and `HTTP/3' exist only over TLS, so asking for either without
+%% `ssl => true' is rejected rather than quietly served as HTTP/1.1.
+validate_versions([], _Ssl) ->
+ {error, {invalid_http_version, []}};
+validate_versions(Versions, Ssl) when is_list(Versions) ->
+ Known = ['HTTP/1.1', 'HTTP/2', 'HTTP/3'],
+ case [V || V <- Versions, not lists:member(V, Known)] of
+ [Bad | _] ->
+ {error, {invalid_http_version, Bad}};
+ [] when not Ssl ->
+ case [V || V <- Versions, V =/= 'HTTP/1.1'] of
+ [NeedsTls | _] -> {error, {http_version_requires_ssl, NeedsTls}};
+ [] -> ok
+ end;
+ [] ->
ok
+ end;
+validate_versions(Other, _Ssl) ->
+ {error, {invalid_http_version, Other}}.
+
+%% @private
+%% One livery listener entry per wire protocol. Each carries its own
+%% `config' (the handler state), because `server_scheme' differs between a
+%% cleartext and a TLS listener in the same service and livery lets a
+%% per-listener config override the service-wide one.
+build_listeners(Versions, Ssl, Config, BaseOpts, HandlerState) ->
+ H1 = lists:member('HTTP/1.1', Versions),
+ H2 = lists:member('HTTP/2', Versions),
+ H3 = lists:member('HTTP/3', Versions),
+ Limits = limit_opts(Config),
+ with_ok([
+ fun() -> {ok, #{}} end,
+ fun(Acc) -> add_tcp_or_tls(Acc, H1, H2, Ssl, Config, BaseOpts, Limits, HandlerState) end,
+ fun(Acc) -> add_h3(Acc, H3, Config, BaseOpts, HandlerState) end
+ ]).
+
+%% @private
+%% Without HTTP/2 the `bind' port is an h1 listener, cleartext or TLS. With
+%% it, the port becomes livery's ALPN listener: `[h2, http1]' serves both
+%% and prefers h2, `[h2]' alone is h2-only.
+add_tcp_or_tls(Acc, false, false, _Ssl, _Config, _BaseOpts, _Limits, _HandlerState) ->
+ {ok, Acc};
+add_tcp_or_tls(Acc, true, false, false, _Config, BaseOpts, Limits, HandlerState) ->
+ Opts = maps:merge(BaseOpts, Limits),
+ {ok, Acc#{http => Opts#{config => listener_state(HandlerState, <<"http">>, BaseOpts)}}};
+add_tcp_or_tls(Acc, true, false, true, Config, BaseOpts, Limits, HandlerState) ->
+ case tls_opts(Config) of
+ {ok, Tls} ->
+ Opts = maps:merge(maps:merge(BaseOpts, Limits), Tls),
+ {ok, Acc#{http => Opts#{
+ transport => ssl,
+ config => listener_state(HandlerState, <<"https">>, BaseOpts)
+ }}};
+ {error, _} = Error ->
+ Error
+ end;
+add_tcp_or_tls(Acc, H1, true, true, Config, BaseOpts, Limits, HandlerState) ->
+ Alpn = case H1 of
+ true -> [h2, http1];
+ false -> [h2]
+ end,
+ case tls_opts(Config) of
+ {ok, Tls} ->
+ Opts = maps:merge(maps:merge(BaseOpts, Limits), Tls),
+ {ok, Acc#{https => Opts#{
+ alpn => Alpn,
+ config => listener_state(HandlerState, <<"https">>, BaseOpts)
+ }}};
+ {error, _} = Error ->
+ Error
end.
%% @private
-%% Start SSL/TLS listener with certificate configuration
-start_tls_listener(Ip, Port, Config, ProtoOpts) ->
- NumAcceptors = maps:get(num_acceptors, Config, 100),
- CertFile = maps:get(certfile, Config, undefined),
- KeyFile = maps:get(keyfile, Config, undefined),
- CaCertFile = maps:get(cacertfile, Config, undefined),
- case {CertFile, KeyFile} of
- {undefined, _} ->
- {error, {missing_ssl_option, certfile}};
- {_, undefined} ->
- {error, {missing_ssl_option, keyfile}};
- _ ->
- SocketOpts = [{port, Port}, {ip, Ip},
- {certfile, ensure_list(CertFile)},
- {keyfile, ensure_list(KeyFile)}] ++
- case CaCertFile of
- undefined -> [];
- _ -> [{cacertfile, ensure_list(CaCertFile)}]
- end,
- TransportOpts = #{
- num_acceptors => NumAcceptors,
- socket_opts => SocketOpts
+%% HTTP/3 is QUIC over UDP, so it can hold the same port number as the TCP
+%% listener beside it. livery derives a stable listener name from the port.
+add_h3(Acc, false, _Config, _BaseOpts, _HandlerState) ->
+ {ok, Acc};
+add_h3(Acc, true, Config, BaseOpts, HandlerState) ->
+ case tls_der(Config) of
+ {ok, #{cert := Cert, key := Key}} ->
+ Port = case maps:get(http3_port, Config, undefined) of
+ undefined -> maps:get(port, BaseOpts);
+ Explicit -> Explicit
+ end,
+ H3Opts = #{
+ port => Port,
+ ip => maps:get(ip, BaseOpts),
+ max_body => maps:get(max_body, BaseOpts),
+ cert => Cert,
+ key => Key,
+ config => listener_state(HandlerState, <<"https">>, BaseOpts#{port => Port})
},
- {ok, _} = cowboy:start_tls(hornbeam_http, TransportOpts, ProtoOpts),
- ok
+ {ok, Acc#{http3 => H3Opts, alt_svc => advertise}};
+ {error, _} = Error ->
+ Error
end.
+%% @private
+%% `livery_req:scheme/1' reports `<<"http">>' on HTTP/1.1 even under TLS,
+%% so the scheme a listener serves is recorded here per listener rather
+%% than once per service.
+listener_state(HandlerState, Scheme, BaseOpts) ->
+ HandlerState#{
+ server_scheme => Scheme,
+ server_port => maps:get(port, BaseOpts)
+ }.
+
+%% @private
+%% hornbeam's limit names predate livery; map them onto h1's.
+limit_opts(Config) ->
+ #{
+ max_request_line_size => maps:get(max_request_line_size, Config, 4094),
+ max_header_value_size => maps:get(max_header_size, Config, 8190),
+ max_headers => maps:get(max_headers, Config, 100)
+ }.
+
+%% @private
+%% TLS material as file paths, which is what livery's h1 and h2 listeners take.
+tls_opts(Config) ->
+ case cert_key_files(Config) of
+ {ok, CertFile, KeyFile} ->
+ SslOpts = case maps:get(cacertfile, Config, undefined) of
+ undefined -> [];
+ CaCertFile -> [{cacertfile, ensure_list(CaCertFile)}]
+ end,
+ {ok, #{
+ cert => ensure_list(CertFile),
+ key => ensure_list(KeyFile),
+ ssl_opts => SslOpts
+ }};
+ {error, _} = Error ->
+ Error
+ end.
+
+%% @private
+%% The h3 listener takes DER instead: the certificate as raw DER bytes and
+%% the private key as a decoded key term.
+tls_der(Config) ->
+ case cert_key_files(Config) of
+ {ok, CertFile, KeyFile} ->
+ with_ok([
+ fun() -> read_pem(certfile, CertFile) end,
+ fun(CertEntries) ->
+ case [D || {'Certificate', D, _} <- CertEntries] of
+ [Der | _] -> {ok, Der};
+ [] -> {error, {no_certificate_in_pem, CertFile}}
+ end
+ end,
+ fun(CertDer) ->
+ case read_pem(keyfile, KeyFile) of
+ {ok, KeyEntries} -> decode_key(KeyEntries, KeyFile, CertDer);
+ {error, _} = Error -> Error
+ end
+ end
+ ]);
+ {error, _} = Error ->
+ Error
+ end.
+
+%% @private
+cert_key_files(Config) ->
+ case {maps:get(certfile, Config, undefined), maps:get(keyfile, Config, undefined)} of
+ {undefined, _} -> {error, {missing_ssl_option, certfile}};
+ {_, undefined} -> {error, {missing_ssl_option, keyfile}};
+ {CertFile, KeyFile} -> {ok, CertFile, KeyFile}
+ end.
+
+%% @private
+read_pem(Which, File) ->
+ case file:read_file(File) of
+ {ok, Pem} ->
+ case public_key:pem_decode(Pem) of
+ [] -> {error, {empty_pem, Which, File}};
+ Entries -> {ok, Entries}
+ end;
+ {error, Reason} ->
+ {error, {unreadable_pem, Which, File, Reason}}
+ end.
+
+%% @private
+%% quic wants the key as a decoded record, not as DER bytes.
+decode_key(Entries, KeyFile, CertDer) ->
+ KeyTypes = ['RSAPrivateKey', 'ECPrivateKey', 'PrivateKeyInfo'],
+ case [{T, D} || {T, D, not_encrypted} <- Entries, lists:member(T, KeyTypes)] of
+ [{Type, Der} | _] ->
+ {ok, #{cert => CertDer, key => public_key:der_decode(Type, Der)}};
+ [] ->
+ {error, {no_private_key_in_pem, KeyFile}}
+ end.
+
+%% @private
+%% No custom routes: hand every request to the catch-all handler. With
+%% routes, compile a livery router with the catch-all as wildcard fallback
+%% so custom routes take precedence, as before.
+build_routing([]) ->
+ {ok, #{handler => fun hornbeam_handler:handle/1}};
+build_routing(Routes) when is_list(Routes) ->
+ try
+ Entries = [validate_route(R) || R <- Routes],
+ Fallback = {'_', <<"/*hb_rest">>, fun hornbeam_handler:handle/1},
+ {ok, #{router => livery_router:compile(Entries ++ [Fallback])}}
+ catch
+ throw:{invalid_route, _} = Reason ->
+ {error, Reason}
+ end;
+build_routing(Other) ->
+ {error, {invalid_routes, Other}}.
+
+%% @private
+%% Routes use livery shapes: {Method | '_', Pattern, Handler[, Meta]} with
+%% Handler a fun/1 or {Module, Function}. The old cowboy
+%% {Path, HandlerModule, Opts} shape is rejected.
+validate_route({Method, Pattern, Handler}) ->
+ {validate_route_method(Method), validate_route_pattern(Pattern),
+ validate_route_handler(Handler)};
+validate_route({Method, Pattern, Handler, Meta}) when is_map(Meta) ->
+ {validate_route_method(Method), validate_route_pattern(Pattern),
+ validate_route_handler(Handler), Meta};
+validate_route(Route) ->
+ throw({invalid_route, Route}).
+
+%% @private
+validate_route_method('_') -> '_';
+validate_route_method(M) when is_binary(M) -> M;
+validate_route_method(M) -> throw({invalid_route, {method, M}}).
+
+%% @private
+validate_route_pattern(P) when is_binary(P) -> P;
+validate_route_pattern(P) when is_list(P) -> list_to_binary(P);
+validate_route_pattern(P) -> throw({invalid_route, {pattern, P}}).
+
+%% @private
+validate_route_handler(H) when is_function(H, 1) -> H;
+validate_route_handler({M, F} = H) when is_atom(M), is_atom(F) -> H;
+validate_route_handler(H) -> throw({invalid_route, {handler, H}}).
+
%% @private
ensure_list(V) when is_list(V) -> V;
ensure_list(V) when is_binary(V) -> binary_to_list(V).
diff --git a/src/hornbeam_asgi.erl b/src/hornbeam_asgi.erl
index 6ba63a0..728d86a 100644
--- a/src/hornbeam_asgi.erl
+++ b/src/hornbeam_asgi.erl
@@ -12,84 +12,69 @@
%% See the License for the specific language governing permissions and
%% limitations under the License.
-%%% @doc ASGI handler with fast synchronous path.
+%%% @doc ASGI handler.
%%%
-%%% This module implements ASGI request handling with two paths:
-%%% 1. Fast sync path: For simple requests (no body or small body), uses
-%%% py_nif:context_call() with hornbeam_asgi_runner for WSGI-like performance
-%%% 2. Async path: For streaming/large bodies, uses cowboy_loop handler
-%%% with py_event_loop_pool for full async support
+%%% Requests run in their own livery request process. The Python task is
+%%% submitted to the event loop pool with this process's pid; response
+%%% events (start_response/chunk/fin/error) arrive as plain messages,
+%%% request-body chunks as `{livery_body, Ref, _}' messages, and client
+%%% disconnects as `{livery_disconnect, Ref, Reason}' - all handled by
+%%% one selective receive, first as a stream_deferred resolver (choosing
+%%% status and headers) and then as the stream producer.
%%%
%%% @end
-module(hornbeam_asgi).
--behaviour(cowboy_loop).
+-export([handle/2]).
--export([init/2, info/3, terminate/3]).
-
-%% Threshold for fast synchronous path (64KB)
-%% Requests with bodies smaller than this use the fast sync path
+%% Threshold for passing the request body inline (64KB)
+%% Bodies smaller than this are read synchronously and passed as a binary
-define(ASGI_BODY_BUFFER_THRESHOLD, 65536).
-%% Internal state
--record(state, {
- req_info,
- app_module,
- app_callable,
- timeout_ms,
- scope,
- req_body_ch,
- body_ref, %% Reference for async body reading
- has_body,
- %% Cached for direct send (avoid map lookups per body chunk)
- cowboy_pid,
- cowboy_streamid,
- handler_state %% Original handler state from init
-}).
-
%%% ============================================================================
-%%% Cowboy Loop Handler Callbacks
+%%% Entry point (called from hornbeam_handler)
%%% ============================================================================
-init(Req, HandlerState) ->
- ReqInfo = build_request_info(Req),
+-spec handle(livery_req:req(), map()) -> livery_resp:resp().
+handle(Req, HandlerState) ->
+ ReqInfo = hornbeam_handler:build_request_info(Req, HandlerState),
ReqInfo1 = hornbeam_http_hooks:run_on_request(ReqInfo),
AppModule = maps:get(app_module, HandlerState),
AppCallable = maps:get(app_callable, HandlerState),
TimeoutMs = maps:get(timeout, HandlerState, 30000),
- %% Cache pid/streamid for direct send (avoid map lookups per body chunk)
- Pid = maps:get(pid, Req),
- StreamID = maps:get(streamid, Req),
-
%% Build ASGI scope
Scope = hornbeam_request:build_asgi_scope(Req, HandlerState),
- %% Check if request has a body
%% Body exists if: Content-Length > 0, or Transfer-Encoding is present
- Method = cowboy_req:method(Req),
- ContentLength = get_content_length(Req),
- TransferEncoding = cowboy_req:header(<<"transfer-encoding">>, Req),
+ Method = livery_req:method(Req),
+ ContentLength = hornbeam_handler:get_content_length(Req),
+ TransferEncoding = livery_req:header(<<"transfer-encoding">>, Req),
HasBody = has_request_body(Method, ContentLength, TransferEncoding),
- %% Create request body channel only if body exists (skip for GET/no-body)
- %% For small bodies with known Content-Length, read synchronously and pass directly
- {ReqBodyRef, BodyRef} = case HasBody of
- true when is_integer(ContentLength), ContentLength =< ?ASGI_BODY_BUFFER_THRESHOLD ->
- %% Small body with known size - read synchronously, pass binary directly
- {ok, Body, _Req2} = cowboy_req:read_body(Req),
- {{body, Body}, undefined};
- true ->
- %% Large/streaming body - use channel + async reading
+ %% Small bodies with known Content-Length are read synchronously and
+ %% passed directly; larger/streaming bodies flow through a byte
+ %% channel fed by this process's receive loop
+ {BodyArg, BodyRef} = case {HasBody, livery_req:body(Req)} of
+ {false, _} ->
+ {empty, undefined};
+ {true, empty} ->
+ {empty, undefined};
+ {true, {buffered, Data}} ->
+ {{body, iolist_to_binary(Data)}, undefined};
+ {true, {stream, Reader}} when is_integer(ContentLength),
+ ContentLength =< ?ASGI_BODY_BUFFER_THRESHOLD ->
+ case livery_body:read_all(Reader, TimeoutMs, infinity) of
+ {ok, Body, _Reader1} ->
+ {{body, Body}, undefined};
+ {error, _Reason, _Reader1} ->
+ %% Truncated client body: hand Python what we have
+ {{body, <<>>}, undefined}
+ end;
+ {true, {stream, Reader}} ->
{ok, Ch} = py_byte_channel:new(),
- Ref = make_ref(),
- %% Direct send instead of cowboy_req:cast
- Pid ! {{Pid, StreamID}, {read_body, self(), Ref, auto, infinity}},
- {{channel, Ch}, Ref};
- false ->
- %% No body - pass empty marker, skip channel
- {empty, undefined}
+ {{channel, Ch}, livery_body:ref(Reader)}
end,
%% Submit task to event loop pool for parallel distribution
@@ -97,118 +82,140 @@ init(Req, HandlerState) ->
TaskRef = make_ref(),
ok = py_nif:submit_task(LoopRef, self(), TaskRef,
<<"hornbeam_asgi_worker">>, <<"handle_asgi">>,
- [self(), AppModule, AppCallable, Scope, ReqBodyRef], #{}),
-
- %% Extract channel ref for state (if using channel mode)
- ReqBodyCh = case ReqBodyRef of
- {channel, ChannelRef} -> ChannelRef;
- _ -> ReqBodyRef
- end,
-
- State = #state{
- req_info = ReqInfo1,
- app_module = AppModule,
- app_callable = AppCallable,
- timeout_ms = TimeoutMs,
- scope = Scope,
- req_body_ch = ReqBodyCh,
- body_ref = BodyRef,
- has_body = HasBody,
- cowboy_pid = Pid,
- cowboy_streamid = StreamID,
- handler_state = HandlerState
+ [self(), AppModule, AppCallable, Scope, BodyArg], #{}),
+
+ St = #{
+ req => Req,
+ req_info => ReqInfo1,
+ channel => body_channel(BodyArg),
+ body_ref => BodyRef,
+ timeout_ms => TimeoutMs
},
+ livery_resp:stream_deferred(fun() -> wait_response(St) end).
- %% Return cowboy_loop to enable loop handler
- {cowboy_loop, Req, State, TimeoutMs}.
-
-%% Handle async body chunks from Cowboy - more data coming
-info({request_body, Ref, nofin, Data}, Req,
- #state{body_ref = Ref, req_body_ch = Ch, cowboy_pid = Pid,
- cowboy_streamid = StreamID} = State) ->
- ok = push_to_channel(Ch, Data),
- %% Direct send instead of cowboy_req:cast
- Pid ! {{Pid, StreamID}, {read_body, self(), Ref, auto, infinity}},
- {ok, Req, State};
-
-%% Handle async body chunks from Cowboy - final chunk
-info({request_body, Ref, fin, _BodyLen, Data}, Req,
- #state{body_ref = Ref, req_body_ch = Ch} = State) ->
- case Data of
- <<>> -> ok;
- _ -> ok = push_to_channel(Ch, Data)
- end,
- py_byte_channel:close(Ch),
- {ok, Req, State#state{body_ref = undefined}};
-
-%% New simplified protocol: start_response (headers + first chunk)
-info({<<"start_response">>, StatusCode, Headers, FirstChunk}, Req, State) ->
- CowboyHeaders = convert_headers(filter_hop_by_hop(Headers)),
- Req2 = cowboy_req:stream_reply(StatusCode, CowboyHeaders, Req),
- case to_binary(FirstChunk) of
- <<>> -> ok;
- Body -> ok = cowboy_req:stream_body(Body, nofin, Req2)
- end,
- {ok, Req2, State};
-
-%% New simplified protocol: subsequent chunk
-info({<<"chunk">>, Data}, Req, State) ->
- ok = cowboy_req:stream_body(to_binary(Data), nofin, Req),
- {ok, Req, State};
-
-%% New simplified protocol: end of response
-info(<<"fin">>, Req, #state{handler_state = HS} = State) ->
- ok = cowboy_req:stream_body(<<>>, fin, Req),
- maybe_close_channel(State#state.req_body_ch),
- {stop, Req, HS};
-
-%% Handle early hints from Python
-info({<<"early_hints">>, Headers}, Req, State) ->
- HintHeaders = convert_headers(Headers),
- Req2 = cowboy_req:inform(103, HintHeaders, Req),
- {ok, Req2, State};
-
-%% Handle error from Python
-info({<<"error">>, Reason}, Req, #state{req_info = ReqInfo, handler_state = HandlerState} = State) ->
- maybe_close_channel(State#state.req_body_ch),
- {StatusCode, Body} = hornbeam_http_hooks:run_on_error(Reason, ReqInfo),
- Req2 = cowboy_req:reply(StatusCode,
- #{<<"content-type">> => <<"text/plain">>},
- Body, Req),
- {stop, Req2, HandlerState};
+%%% ============================================================================
+%%% Resolver: wait for the response head while pumping the request body
+%%% ============================================================================
-%% Handle async task completion
-info({async_result, _Ref, {ok, _}}, Req, State) ->
- {ok, Req, State};
+wait_response(#{req_info := ReqInfo, timeout_ms := TimeoutMs} = St) ->
+ BodyRef = pump_ref(St),
+ receive
+ {livery_body, BodyRef, {data, Data}} ->
+ ok = push_to_channel(maps:get(channel, St), Data),
+ wait_response(St);
+ {livery_body, BodyRef, _EofOrError} ->
+ %% eof/trailers/reset/error all end the request body; a reset
+ %% surfaces to Python as EOF on its body reads
+ maybe_close_channel(maps:get(channel, St)),
+ wait_response(St#{body_ref := undefined});
+ {<<"start_response">>, StatusCode, Headers, FirstChunk} ->
+ SafeHeaders = hornbeam_handler:convert_headers(
+ hornbeam_handler:filter_hop_by_hop(Headers)),
+ Status = hornbeam_handler:parse_status_code(StatusCode),
+ {stream, Status, SafeHeaders,
+ fun(Emit) -> stream_response(Emit, FirstChunk, St) end};
+ {<<"early_hints">>, Headers} ->
+ %% Best-effort: h3 (and HTTP/1.0 clients) have no interim
+ %% responses, and livery answers {error, unsupported}
+ _ = livery_req:inform(103, hornbeam_handler:convert_headers(Headers),
+ maps:get(req, St)),
+ wait_response(St);
+ {<<"error">>, Reason} ->
+ cleanup(St),
+ hornbeam_handler:error_decision(Reason, ReqInfo);
+ {async_result, _Ref, {ok, _}} ->
+ wait_response(St);
+ {async_result, _Ref, {error, Reason}} ->
+ cleanup(St),
+ hornbeam_handler:error_decision(Reason, ReqInfo);
+ {livery_disconnect, _Ref, _Reason} ->
+ %% Client gone before the response started; the emit of this
+ %% decision fails on the closed stream, which livery treats
+ %% as a peer disconnect
+ cleanup(St),
+ {full, 500, [], <<>>}
+ after TimeoutMs ->
+ cleanup(St),
+ hornbeam_handler:error_decision(timeout, ReqInfo)
+ end.
-info({async_result, _Ref, {error, Reason}}, Req, #state{req_info = ReqInfo, handler_state = HandlerState} = State) ->
- maybe_close_channel(State#state.req_body_ch),
- {StatusCode, Body} = hornbeam_http_hooks:run_on_error(Reason, ReqInfo),
- Req2 = cowboy_req:reply(StatusCode,
- #{<<"content-type">> => <<"text/plain">>},
- Body, Req),
- {stop, Req2, HandlerState};
+%%% ============================================================================
+%%% Producer: stream chunks to the client
+%%% ============================================================================
-%% Handle timeout
-info(timeout, Req, #state{req_info = ReqInfo, handler_state = HandlerState} = State) ->
- maybe_close_channel(State#state.req_body_ch),
- {StatusCode, Body} = hornbeam_http_hooks:run_on_error(timeout, ReqInfo),
- Req2 = cowboy_req:reply(StatusCode,
- #{<<"content-type">> => <<"text/plain">>},
- Body, Req),
- {stop, Req2, HandlerState};
+stream_response(Emit, FirstChunk, St) ->
+ case hornbeam_handler:to_binary(FirstChunk) of
+ <<>> ->
+ stream_loop(Emit, St);
+ Body ->
+ case Emit(Body) of
+ ok -> stream_loop(Emit, St);
+ {error, _} -> stream_done(St)
+ end
+ end.
-%% Unknown message
-info(_Msg, Req, State) ->
- {ok, Req, State}.
+stream_loop(Emit, #{timeout_ms := TimeoutMs} = St) ->
+ BodyRef = pump_ref(St),
+ receive
+ {livery_body, BodyRef, {data, Data}} ->
+ ok = push_to_channel(maps:get(channel, St), Data),
+ stream_loop(Emit, St);
+ {livery_body, BodyRef, _EofOrError} ->
+ maybe_close_channel(maps:get(channel, St)),
+ stream_loop(Emit, St#{body_ref := undefined});
+ {<<"chunk">>, Data} ->
+ case Emit(hornbeam_handler:to_binary(Data)) of
+ ok -> stream_loop(Emit, St);
+ {error, _} -> stream_done(St)
+ end;
+ <<"fin">> ->
+ cleanup(St),
+ ok;
+ {<<"early_hints">>, _} ->
+ %% Too late: the final response head is already on the wire
+ stream_loop(Emit, St);
+ {<<"error">>, _Reason} ->
+ %% Response already started: truncate the stream
+ cleanup(St),
+ ok;
+ {async_result, _Ref, _} ->
+ stream_loop(Emit, St);
+ {livery_disconnect, _Ref, _Reason} ->
+ stream_done(St)
+ after TimeoutMs ->
+ cleanup(St),
+ ok
+ end.
-terminate(_Reason, _Req, _State) ->
- ok.
+%% @private
+%% Stop producing: the stream will take no more data. Either the client
+%% disconnected, or the stream rejects a body at all - an h2/h3 response
+%% to HEAD answers `{error, invalid_stream_state}' rather than swallowing
+%% the bytes the way HTTP/1.1 does. Close the byte channel either way so
+%% Python's body reads hit EOF (its sends to this pid are dropped once we
+%% return).
+stream_done(St) ->
+ cleanup(St),
+ {error, closed}.
%%% ============================================================================
%%% Internal Functions
%%% ============================================================================
+%% @private
+%% Only pump raw body messages while a channel is live; otherwise use a
+%% fresh ref that matches nothing.
+pump_ref(#{body_ref := Ref}) when is_reference(Ref) -> Ref;
+pump_ref(_) -> make_ref().
+
+%% @private
+body_channel({channel, Ch}) -> Ch;
+body_channel(_) -> undefined.
+
+%% @private
+cleanup(St) ->
+ maybe_close_channel(maps:get(channel, St)).
+
push_to_channel(Channel, Data) ->
case py_byte_channel:send(Channel, Data) of
ok -> ok;
@@ -221,30 +228,18 @@ push_to_channel(Channel, Data) ->
end.
maybe_close_channel(undefined) -> ok;
-maybe_close_channel(empty) -> ok;
-maybe_close_channel({body, _}) -> ok; %% Small body passed inline, no channel
maybe_close_channel(Channel) ->
try
case py_byte_channel:info(Channel) of
#{closed := true} -> ok;
_ ->
- catch py_byte_channel:close(Channel),
+ _ = py_byte_channel:close(Channel),
ok
end
catch
_:_ -> ok
end.
-%% @private
-get_content_length(Req) ->
- case cowboy_req:header(<<"content-length">>, Req) of
- undefined -> undefined;
- CLBin ->
- try binary_to_integer(CLBin)
- catch _:_ -> undefined
- end
- end.
-
%% @private
%% Check if request has a body based on method, content-length, and transfer-encoding
%% Per HTTP spec: body exists if Content-Length > 0 OR Transfer-Encoding is present
@@ -256,53 +251,3 @@ has_request_body(<<"HEAD">>, _, _) -> false;
has_request_body(<<"DELETE">>, _, _) -> false;
has_request_body(<<"OPTIONS">>, _, _) -> false;
has_request_body(_, undefined, undefined) -> false. %% No CL, no TE = no body
-
-%% @private
-build_request_info(Req) ->
- #{
- method => cowboy_req:method(Req),
- path => cowboy_req:path(Req),
- query_string => cowboy_req:qs(Req),
- headers => cowboy_req:headers(Req),
- host => cowboy_req:host(Req),
- port => cowboy_req:port(Req),
- scheme => cowboy_req:scheme(Req),
- peer => cowboy_req:peer(Req)
- }.
-
-%% @private
-filter_hop_by_hop(Headers) ->
- HopByHop = [<<"connection">>, <<"keep-alive">>, <<"proxy-authenticate">>,
- <<"proxy-authorization">>, <<"te">>, <<"trailers">>,
- <<"transfer-encoding">>, <<"upgrade">>],
- lists:filter(fun(Header) ->
- Name = case Header of
- [N, _] -> N;
- {N, _} -> N
- end,
- LowerName = string:lowercase(to_binary(Name)),
- not lists:member(LowerName, HopByHop)
- end, Headers).
-
-%% @private
-convert_headers(Headers) ->
- lists:foldl(fun(Header, Acc) ->
- case Header of
- [Name, Value] ->
- Acc#{to_lower_binary(Name) => to_binary(Value)};
- {Name, Value} ->
- Acc#{to_lower_binary(Name) => to_binary(Value)};
- _ ->
- Acc
- end
- end, #{}, Headers).
-
-to_binary(V) when is_binary(V) -> V;
-to_binary(V) when is_list(V) -> list_to_binary(V);
-to_binary(V) when is_atom(V) -> atom_to_binary(V, utf8);
-to_binary(V) -> iolist_to_binary(io_lib:format("~p", [V])).
-
-to_lower_binary(V) when is_binary(V) -> string:lowercase(V);
-to_lower_binary(V) when is_list(V) -> string:lowercase(list_to_binary(V));
-to_lower_binary(V) when is_atom(V) -> string:lowercase(atom_to_binary(V, utf8));
-to_lower_binary(V) -> string:lowercase(to_binary(V)).
diff --git a/src/hornbeam_config.erl b/src/hornbeam_config.erl
index ba14934..73e235b 100644
--- a/src/hornbeam_config.erl
+++ b/src/hornbeam_config.erl
@@ -27,7 +27,8 @@
%%%
%%% === Protocol ===
%%% - worker_class: wsgi or asgi (default: wsgi)
-%%% - http_version: List of supported HTTP versions (default: ['HTTP/1.1', 'HTTP/2'])
+%%% - http_version: HTTP versions to serve (default: ['HTTP/1.1']).
+%%% 'HTTP/2' and 'HTTP/3' require ssl.
%%%
%%% === Contexts ===
%%% - num_contexts: Number of Python contexts (default: schedulers)
@@ -134,9 +135,12 @@ defaults() ->
certfile => undefined,
keyfile => undefined,
cacertfile => undefined,
+ %% HTTP/3 UDP port; undefined means the `bind' port
+ http3_port => undefined,
%% Protocol
worker_class => wsgi,
+ http_version => ['HTTP/1.1'],
%% Contexts
%% num_contexts defaults to schedulers in hornbeam.erl
@@ -225,9 +229,9 @@ code_change(_OldVsn, State, _Extra) ->
load_app_env() ->
Keys = [
%% Server
- bind, ssl, certfile, keyfile, cacertfile,
+ bind, ssl, certfile, keyfile, cacertfile, http3_port,
%% Protocol
- worker_class,
+ worker_class, http_version,
%% Contexts
num_contexts, timeout, keepalive, max_requests, preload_app,
%% Request limits
diff --git a/src/hornbeam_handler.erl b/src/hornbeam_handler.erl
index 481a732..098e7b4 100644
--- a/src/hornbeam_handler.erl
+++ b/src/hornbeam_handler.erl
@@ -12,7 +12,7 @@
%% See the License for the specific language governing permissions and
%% limitations under the License.
-%%% @doc Cowboy HTTP handler for hornbeam.
+%%% @doc Livery HTTP handler for hornbeam.
%%%
%%% This module handles HTTP requests and routes them to either WSGI or ASGI
%%% handlers based on configuration.
@@ -22,21 +22,49 @@
%%% - ASGI: Uses py_event_loop for full async execution
%%% - Both stream responses via erlang.reply()/send()
%%%
+%%% The request runs in its own livery request process; request-body
+%%% chunks are pushed to that process as `{livery_body, Ref, _}' messages
+%%% and Python response events arrive as plain messages, so a single
+%%% selective receive drives both.
+%%%
%%% @end
-module(hornbeam_handler).
--behaviour(cowboy_websocket).
--behaviour(cowboy_loop).
+-export([handle/1]).
+
+%% Shared helpers used by hornbeam_asgi
+-export([
+ build_request_info/2,
+ filter_hop_by_hop/1,
+ convert_headers/1,
+ parse_status_code/1,
+ error_decision/2,
+ get_content_length/1,
+ to_binary/1
+]).
+
+%% Streaming threshold: bodies larger than this are streamed via the
+%% receive loop while Python runs; smaller ones are read inline first.
+-define(WSGI_STREAMING_THRESHOLD, 65536). %% 64KB
+
+%% Hop-by-hop headers that should not be forwarded
+-define(HOP_BY_HOP_HEADERS, [
+ <<"connection">>, <<"keep-alive">>, <<"proxy-authenticate">>,
+ <<"proxy-authorization">>, <<"te">>, <<"trailers">>,
+ <<"transfer-encoding">>, <<"upgrade">>
+]).
--export([init/2]).
-%% Loop handler callback (for ASGI)
--export([info/3]).
-%% WebSocket callbacks (delegate to hornbeam_websocket)
--export([websocket_init/1, websocket_handle/2, websocket_info/2, terminate/3]).
+-spec handle(livery_req:req()) -> livery_resp:resp().
+handle(Req) ->
+ State = case livery_req:config(Req) of
+ Map when is_map(Map) -> Map;
+ _ -> #{}
+ end,
+ dispatch(Req, State).
-init(Req, #{multi_app := true} = State) ->
+dispatch(Req, #{multi_app := true} = State) ->
%% Multi-app mode: lookup mount based on request path
- Path = cowboy_req:path(Req),
+ Path = livery_req:path(Req),
case hornbeam_mounts:lookup(Path) of
{ok, Mount, PathInfo} ->
%% pythonpath is setup at mount registration time (hornbeam_mounts.erl)
@@ -58,13 +86,9 @@ init(Req, #{multi_app := true} = State) ->
handle_request(WorkerClass, Req, NewState);
{error, no_match} ->
%% No mount matched - return 404
- Req2 = cowboy_req:reply(404,
- #{<<"content-type">> => <<"text/plain">>},
- <<"Not Found">>,
- Req),
- {ok, Req2, State}
+ livery_resp:text(404, <<"Not Found">>)
end;
-init(Req, State) ->
+dispatch(Req, State) ->
%% Single-app mode (backward compatible)
WorkerClass = maps:get(worker_class, State, wsgi),
handle_request(WorkerClass, Req, State).
@@ -75,41 +99,25 @@ handle_request(asgi, Req, State) ->
%% Check for WebSocket upgrade
case is_websocket_upgrade(Req) of
true ->
- handle_websocket_upgrade(Req, State);
+ hornbeam_websocket:upgrade(Req, State);
false ->
- hornbeam_asgi:init(Req, State)
+ hornbeam_asgi:handle(Req, State)
end.
%% @private
is_websocket_upgrade(Req) ->
- case cowboy_req:header(<<"upgrade">>, Req) of
+ case livery_req:header(<<"upgrade">>, Req) of
undefined -> false;
Upgrade ->
string:lowercase(Upgrade) =:= <<"websocket">>
end.
-%% @private
-handle_websocket_upgrade(Req, State) ->
- %% Delegate to WebSocket handler
- hornbeam_websocket:init(Req, State).
-
%%% ============================================================================
%%% WSGI Handler - unified channel-based approach with schedule_inline
%%% ============================================================================
-%% Streaming threshold: bodies larger than this are streamed via channel
--define(WSGI_STREAMING_THRESHOLD, 65536). %% 64KB
--define(WSGI_BODY_CHUNK_SIZE, 65536). %% 64KB chunks
-
-%% Hop-by-hop headers that should not be forwarded
--define(HOP_BY_HOP_HEADERS, [
- <<"connection">>, <<"keep-alive">>, <<"proxy-authenticate">>,
- <<"proxy-authorization">>, <<"te">>, <<"trailers">>,
- <<"transfer-encoding">>, <<"upgrade">>
-]).
-
handle_wsgi(Req, State) ->
- ReqInfo = build_request_info(Req),
+ ReqInfo = build_request_info(Req, State),
ReqInfo1 = hornbeam_http_hooks:run_on_request(ReqInfo),
try
@@ -120,18 +128,10 @@ handle_wsgi(Req, State) ->
%% Build pre-parsed WSGI tuple (O(1) environ creation in Python)
ReqTuple = hornbeam_request:build_wsgi_tuple(Req, State),
- %% Create buffer for request body (skip for bodyless requests)
- ContentLength = get_content_length(Req),
- Method = cowboy_req:method(Req),
- Buffer = case has_request_body(Method, ContentLength) of
- false ->
- %% No body expected - use empty buffer marker
- empty;
- true ->
- {ok, Buf} = create_body_buffer(ContentLength),
- write_body_to_buffer(Req, Buf, ContentLength),
- Buf
- end,
+ %% Create buffer for request body (skip for bodyless requests).
+ %% Small bodies are read inline; large ones stream into the
+ %% buffer inside the receive loop while Python already runs.
+ {Buffer, BodyCtx} = setup_body(Req, TimeoutMs),
%% Call Python with tuple fast path
CtxRef = hornbeam_context_pool:get_context_ref(),
@@ -139,24 +139,28 @@ handle_wsgi(Req, State) ->
<<"hornbeam_wsgi_worker">>, <<"handle_request_tuple">>,
[self(), Buffer, AppModule, AppCallable, ReqTuple], #{}) of
{ok, <<"done">>} ->
- %% Enter receive loop
- wsgi_receive_loop(Req, ReqInfo1, TimeoutMs, State);
+ %% Resolve status/headers once Python replies
+ livery_resp:stream_deferred(fun() ->
+ wsgi_wait(BodyCtx, ReqInfo1, TimeoutMs)
+ end);
{ok, <<"error">>} ->
- handle_error(Req, wsgi_error, ReqInfo1, State);
+ close_body(BodyCtx),
+ error_resp(wsgi_error, ReqInfo1);
{error, Reason} ->
- handle_error(Req, Reason, ReqInfo1, State)
+ close_body(BodyCtx),
+ error_resp(Reason, ReqInfo1)
end
catch
Class:Error:Stack ->
error_logger:error_msg("WSGI handler error: ~p:~p~n~p~n",
[Class, Error, Stack]),
- handle_error(Req, {Class, Error}, ReqInfo1, State)
+ error_resp({Class, Error}, ReqInfo1)
end.
%% @private
%% Get content-length as integer, or undefined if not present/invalid
get_content_length(Req) ->
- case cowboy_req:header(<<"content-length">>, Req) of
+ case livery_req:header(<<"content-length">>, Req) of
undefined -> undefined;
CLBin ->
try binary_to_integer(CLBin)
@@ -173,51 +177,87 @@ has_request_body(<<"OPTIONS">>, undefined) -> false;
has_request_body(_, 0) -> false;
has_request_body(_, _) -> true.
+%% @private
+%% Prepare the request-body buffer.
+%% Returns {BufferArg, BodyCtx} where BufferArg is the Python-side
+%% argument (`empty' or a py_buffer) and BodyCtx tracks whether body
+%% chunks still stream into the buffer ({streaming, Ref, Buffer}) or the
+%% buffer is complete (done).
+setup_body(Req, TimeoutMs) ->
+ Method = livery_req:method(Req),
+ ContentLength = get_content_length(Req),
+ case has_request_body(Method, ContentLength) of
+ false ->
+ {empty, done};
+ true when is_integer(ContentLength),
+ ContentLength >= ?WSGI_STREAMING_THRESHOLD ->
+ {ok, Buf} = py_buffer:new(ContentLength),
+ case livery_req:body(Req) of
+ {stream, Reader} ->
+ {Buf, {streaming, livery_body:ref(Reader), Buf}};
+ {buffered, Data} ->
+ _ = py_buffer:write(Buf, iolist_to_binary(Data)),
+ _ = py_buffer:close(Buf),
+ {Buf, done};
+ empty ->
+ _ = py_buffer:close(Buf),
+ {Buf, done}
+ end;
+ true ->
+ {ok, Buf} = create_body_buffer(ContentLength),
+ case livery_req:body(Req) of
+ {stream, Reader} ->
+ case livery_body:read_all(Reader, TimeoutMs, infinity) of
+ {ok, Body, _Reader1} ->
+ _ = py_buffer:write(Buf, Body),
+ _ = py_buffer:close(Buf);
+ {error, Reason, _Reader1} ->
+ _ = py_buffer:close(Buf),
+ throw({body_read_error, Reason})
+ end;
+ {buffered, Data} ->
+ _ = py_buffer:write(Buf, iolist_to_binary(Data)),
+ _ = py_buffer:close(Buf);
+ empty ->
+ _ = py_buffer:close(Buf)
+ end,
+ {Buf, done}
+ end.
+
%% @private
%% Create buffer for body - pre-allocate if content-length known
-create_body_buffer(undefined) ->
- py_buffer:new();
create_body_buffer(ContentLength) when is_integer(ContentLength), ContentLength > 0 ->
py_buffer:new(ContentLength);
create_body_buffer(_) ->
py_buffer:new().
%% @private
-%% Write body to buffer - unified for small and large bodies
-write_body_to_buffer(Req, Buffer, ContentLength) when
- ContentLength =:= undefined; ContentLength < ?WSGI_STREAMING_THRESHOLD ->
- %% Small body: read all, write once, close
- {ok, Body, _Req2} = cowboy_req:read_body(Req),
- py_buffer:write(Buffer, Body),
- py_buffer:close(Buffer);
-write_body_to_buffer(Req, Buffer, _ContentLength) ->
- %% Large body: spawn process to stream chunks
- spawn_link(fun() -> stream_body_to_buffer(Req, Buffer, ?WSGI_BODY_CHUNK_SIZE) end).
+close_body({streaming, _Ref, Buffer}) ->
+ _ = py_buffer:close(Buffer),
+ ok;
+close_body(done) ->
+ ok.
%% @private
-%% Stream request body to buffer in chunks
-stream_body_to_buffer(Req, Buffer, ChunkSize) ->
- case cowboy_req:read_body(Req, #{length => ChunkSize}) of
- {ok, Chunk, _Req2} ->
- %% Last chunk
- py_buffer:write(Buffer, Chunk),
- py_buffer:close(Buffer);
- {more, Chunk, Req2} ->
- %% More data available
- py_buffer:write(Buffer, Chunk),
- stream_body_to_buffer(Req2, Buffer, ChunkSize)
- end.
-
-%% @private
-%% Main receive loop for WSGI responses from Python
-wsgi_receive_loop(Req, ReqInfo, TimeoutMs, State) ->
+%% Wait for the Python response while pumping request-body chunks into
+%% the buffer. Runs as a stream_deferred resolver: returns the response
+%% decision before any header is written.
+wsgi_wait(BodyCtx, ReqInfo, TimeoutMs) ->
+ {BodyRef, Buffer} = body_ctx_ref(BodyCtx),
receive
+ {livery_body, BodyRef, {data, Chunk}} ->
+ _ = py_buffer:write(Buffer, Chunk),
+ wsgi_wait(BodyCtx, ReqInfo, TimeoutMs);
+ {livery_body, BodyRef, _EofOrError} ->
+ %% eof/trailers/reset/error all complete the buffer; a reset
+ %% surfaces to Python as EOF on the body stream
+ _ = py_buffer:close(Buffer),
+ wsgi_wait(done, ReqInfo, TimeoutMs);
{<<"start_response">>, StatusCode, Headers} ->
%% Streaming response - filter hop-by-hop and start streaming
- SafeHeaders = filter_hop_by_hop(Headers),
- CowboyHeaders = convert_headers(SafeHeaders),
- Req2 = cowboy_req:stream_reply(StatusCode, CowboyHeaders, Req),
- stream_response_loop(Req2, TimeoutMs, State);
+ SafeHeaders = convert_headers(filter_hop_by_hop(Headers)),
+ {stream, parse_status_code(StatusCode), SafeHeaders,
+ fun(Emit) -> wsgi_stream_loop(Emit, BodyCtx, TimeoutMs) end};
{<<"response">>, StatusCode, Headers, Body} ->
%% Complete response
SafeHeaders = filter_hop_by_hop(Headers),
@@ -227,31 +267,59 @@ wsgi_receive_loop(Req, ReqInfo, TimeoutMs, State) ->
<<"body">> => Body
},
Response1 = hornbeam_http_hooks:run_on_response(Response),
- send_response(Req, Response1, State);
+ close_body(BodyCtx),
+ {full,
+ parse_status_code(maps:get(<<"status">>, Response1)),
+ convert_headers(maps:get(<<"headers">>, Response1)),
+ maps:get(<<"body">>, Response1)};
{<<"error">>, Reason} ->
- handle_error(Req, Reason, ReqInfo, State)
+ close_body(BodyCtx),
+ error_decision(Reason, ReqInfo)
after TimeoutMs ->
- handle_error(Req, timeout, ReqInfo, State)
+ close_body(BodyCtx),
+ error_decision(timeout, ReqInfo)
end.
%% @private
-%% Receive and stream response chunks to client
-stream_response_loop(Req, TimeoutMs, State) ->
+%% Receive and stream response chunks to the client while still pumping
+%% any remaining request-body chunks into the buffer
+wsgi_stream_loop(Emit, BodyCtx, TimeoutMs) ->
+ {BodyRef, Buffer} = body_ctx_ref(BodyCtx),
receive
+ {livery_body, BodyRef, {data, Chunk}} ->
+ _ = py_buffer:write(Buffer, Chunk),
+ wsgi_stream_loop(Emit, BodyCtx, TimeoutMs);
+ {livery_body, BodyRef, _EofOrError} ->
+ _ = py_buffer:close(Buffer),
+ wsgi_stream_loop(Emit, done, TimeoutMs);
{<<"chunk">>, Chunk} ->
- ok = cowboy_req:stream_body(Chunk, nofin, Req),
- stream_response_loop(Req, TimeoutMs, State);
+ case Emit(Chunk) of
+ ok ->
+ wsgi_stream_loop(Emit, BodyCtx, TimeoutMs);
+ {error, _} ->
+ %% The stream takes no more data: the client is gone, or
+ %% it rejects a body outright - an h2/h3 response to HEAD
+ %% answers `{error, invalid_stream_state}' where HTTP/1.1
+ %% just swallows the bytes. Unblock Python's body reads
+ %% and stop either way.
+ close_body(BodyCtx),
+ {error, closed}
+ end;
<<"done">> ->
- ok = cowboy_req:stream_body(<<>>, fin, Req),
- {ok, Req, State};
+ close_body(BodyCtx),
+ ok;
{<<"error">>, _Reason} ->
- ok = cowboy_req:stream_body(<<>>, fin, Req),
- {ok, Req, State}
+ close_body(BodyCtx),
+ ok
after TimeoutMs ->
- ok = cowboy_req:stream_body(<<>>, fin, Req),
- {ok, Req, State}
+ close_body(BodyCtx),
+ ok
end.
+%% @private
+body_ctx_ref({streaming, Ref, Buffer}) -> {Ref, Buffer};
+body_ctx_ref(done) -> {make_ref(), undefined}.
+
%% @private
%% Filter hop-by-hop headers from response
filter_hop_by_hop(Headers) ->
@@ -265,87 +333,73 @@ filter_hop_by_hop(Headers) ->
end, Headers).
%%% ============================================================================
-%%% Response sending
+%%% Error handling
%%% ============================================================================
-send_response(Req, Response, State) ->
- Status = maps:get(<<"status">>, Response),
- Headers = maps:get(<<"headers">>, Response),
- Body = maps:get(<<"body">>, Response),
- EarlyHints = maps:get(<<"early_hints">>, Response, []),
-
- %% Parse status code
- StatusCode = parse_status_code(Status),
-
- %% Convert headers to cowboy format
- CowboyHeaders = convert_headers(Headers),
-
- %% Send early hints if any (103 responses)
- Req1 = send_early_hints(Req, EarlyHints),
-
- %% Send response
- Req2 = cowboy_req:reply(StatusCode, CowboyHeaders, Body, Req1),
- {ok, Req2, State}.
-
%% @private
-send_early_hints(Req, []) ->
- Req;
-send_early_hints(Req, [Hints | Rest]) ->
- HintHeaders = convert_headers(Hints),
- Req1 = cowboy_req:inform(103, HintHeaders, Req),
- send_early_hints(Req1, Rest).
-
-parse_status_code(Status) when is_binary(Status) ->
- case binary:split(Status, <<" ">>) of
- [CodeBin | _] -> binary_to_integer(CodeBin);
- _ -> 500
- end;
-parse_status_code(Status) when is_list(Status) ->
- parse_status_code(list_to_binary(Status));
-parse_status_code(Status) when is_integer(Status) ->
- Status.
+%% Full error response as a livery_resp (pre-stream errors)
+error_resp(Error, ReqInfo) ->
+ {full, StatusCode, Headers, Body} = error_decision(Error, ReqInfo),
+ livery_resp:new(StatusCode, Headers, {full, Body}).
-%%% ============================================================================
-%%% Error handling
-%%% ============================================================================
-
-handle_error(Req, Error, ReqInfo, State) ->
+%% @doc Error response as a stream_deferred decision.
+error_decision(Error, ReqInfo) ->
{StatusCode, Body} = hornbeam_http_hooks:run_on_error(Error, ReqInfo),
- Req2 = cowboy_req:reply(StatusCode,
- #{<<"content-type">> => <<"text/plain">>},
- Body,
- Req),
- {ok, Req2, State}.
+ {full, StatusCode, [{<<"content-type">>, <<"text/plain">>}], Body}.
-%% @private
-build_request_info(Req) ->
+%% @doc Request info map passed to HTTP hooks.
+build_request_info(Req, State) ->
+ {Host, Port} = hornbeam_request:server_info(Req, State),
#{
- method => cowboy_req:method(Req),
- path => cowboy_req:path(Req),
- query_string => cowboy_req:qs(Req),
- headers => cowboy_req:headers(Req),
- host => cowboy_req:host(Req),
- port => cowboy_req:port(Req),
- scheme => cowboy_req:scheme(Req),
- peer => cowboy_req:peer(Req)
+ method => livery_req:method(Req),
+ path => livery_req:path(Req),
+ query_string => livery_req:query(Req),
+ headers => headers_map(livery_req:headers(Req)),
+ host => Host,
+ port => Port,
+ scheme => hornbeam_request:scheme(State),
+ peer => livery_req:peer(Req)
}.
+%% @private
+%% Hooks historically saw headers as a lowercase-keyed map; join
+%% duplicates with ", " to preserve every value.
+headers_map(Headers) ->
+ lists:foldl(fun({Name, Value}, Acc) ->
+ case Acc of
+ #{Name := Prev} -> Acc#{Name := <>};
+ _ -> Acc#{Name => Value}
+ end
+ end, #{}, Headers).
+
%%% ============================================================================
%%% Utilities
%%% ============================================================================
-%% @private
+%% @doc Convert Python response headers ([[Name, Value], ...] or
+%% [{Name, Value}, ...]) to livery's list shape, lowercasing names and
+%% preserving duplicates.
convert_headers(Headers) ->
- lists:foldl(fun(Header, Acc) ->
+ lists:filtermap(fun(Header) ->
case Header of
[Name, Value] ->
- Acc#{to_lower_binary(Name) => to_binary(Value)};
+ {true, {to_lower_binary(Name), to_binary(Value)}};
{Name, Value} ->
- Acc#{to_lower_binary(Name) => to_binary(Value)};
+ {true, {to_lower_binary(Name), to_binary(Value)}};
_ ->
- Acc
+ false
end
- end, #{}, Headers).
+ end, Headers).
+
+parse_status_code(Status) when is_binary(Status) ->
+ case binary:split(Status, <<" ">>) of
+ [CodeBin | _] -> binary_to_integer(CodeBin);
+ _ -> 500
+ end;
+parse_status_code(Status) when is_list(Status) ->
+ parse_status_code(list_to_binary(Status));
+parse_status_code(Status) when is_integer(Status) ->
+ Status.
to_binary(V) when is_binary(V) -> V;
to_binary(V) when is_list(V) -> list_to_binary(V);
@@ -356,28 +410,3 @@ to_lower_binary(V) when is_binary(V) -> string:lowercase(V);
to_lower_binary(V) when is_list(V) -> string:lowercase(list_to_binary(V));
to_lower_binary(V) when is_atom(V) -> string:lowercase(atom_to_binary(V, utf8));
to_lower_binary(V) -> string:lowercase(to_binary(V)).
-
-%%% ============================================================================
-%%% Loop handler callback (for ASGI)
-%%% ============================================================================
-
-%% @private
-%% Delegate to hornbeam_asgi for ASGI loop handler messages
-info(Msg, Req, State) ->
- hornbeam_asgi:info(Msg, Req, State).
-
-%%% ============================================================================
-%%% WebSocket callbacks (delegate to hornbeam_websocket)
-%%% ============================================================================
-
-websocket_init(State) ->
- hornbeam_websocket:websocket_init(State).
-
-websocket_handle(Frame, State) ->
- hornbeam_websocket:websocket_handle(Frame, State).
-
-websocket_info(Info, State) ->
- hornbeam_websocket:websocket_info(Info, State).
-
-terminate(Reason, Req, State) ->
- hornbeam_websocket:terminate(Reason, Req, State).
diff --git a/src/hornbeam_listener.erl b/src/hornbeam_listener.erl
new file mode 100644
index 0000000..fc78205
--- /dev/null
+++ b/src/hornbeam_listener.erl
@@ -0,0 +1,107 @@
+%% Copyright 2026 Benoit Chesneau
+%%
+%% Licensed under the Apache License, Version 2.0 (the "License");
+%% you may not use this file except in compliance with the License.
+%% You may obtain a copy of the License at
+%%
+%% http://www.apache.org/licenses/LICENSE-2.0
+%%
+%% Unless required by applicable law or agreed to in writing, software
+%% distributed under the License is distributed on an "AS IS" BASIS,
+%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+%% See the License for the specific language governing permissions and
+%% limitations under the License.
+
+%%% @doc Supervised owner of the livery HTTP service.
+%%%
+%%% `livery:start_service/1' links the service to its caller, so the
+%%% service must be started from a supervised process rather than from
+%%% whichever process calls `hornbeam:start/2' (a shell or a test case).
+%%% This gen_server owns the service pid and exposes start/stop/info.
+-module(hornbeam_listener).
+
+-behaviour(gen_server).
+
+-export([start_link/0]).
+-export([start_service/1, stop_service/0, info/0, is_running/0]).
+-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2]).
+
+-define(SERVER, ?MODULE).
+
+%% @doc Start the livery service with the given service opts.
+-spec start_service(map()) -> ok | {error, term()}.
+start_service(ServiceOpts) ->
+ gen_server:call(?SERVER, {start_service, ServiceOpts}, infinity).
+
+%% @doc Stop the running livery service. Idempotent.
+-spec stop_service() -> ok.
+stop_service() ->
+ gen_server:call(?SERVER, stop_service, infinity).
+
+%% @doc Listener status: `#{running := boolean(), listeners := map()}'.
+-spec info() -> #{running := boolean(), listeners := map()}.
+info() ->
+ gen_server:call(?SERVER, info).
+
+-spec is_running() -> boolean().
+is_running() ->
+ maps:get(running, info()).
+
+start_link() ->
+ gen_server:start_link({local, ?SERVER}, ?MODULE, [], []).
+
+%% gen_server callbacks
+
+init([]) ->
+ process_flag(trap_exit, true),
+ {ok, #{service => undefined}}.
+
+handle_call({start_service, _Opts}, _From, #{service := Pid} = State)
+ when is_pid(Pid) ->
+ {reply, {error, already_started}, State};
+handle_call({start_service, Opts}, _From, State) ->
+ case livery:start_service(Opts) of
+ {ok, Pid} ->
+ {reply, ok, State#{service := Pid}};
+ {error, _} = Error ->
+ {reply, Error, State}
+ end;
+handle_call(stop_service, _From, #{service := Pid} = State) when is_pid(Pid) ->
+ ok = livery:stop_service(Pid),
+ {reply, ok, State#{service := undefined}};
+handle_call(stop_service, _From, State) ->
+ {reply, ok, State};
+handle_call(info, _From, #{service := Pid} = State) when is_pid(Pid) ->
+ Listeners =
+ try livery:which_listeners(Pid)
+ catch _:_ -> #{}
+ end,
+ {reply, #{running => true, listeners => Listeners}, State};
+handle_call(info, _From, State) ->
+ {reply, #{running => false, listeners => #{}}, State}.
+
+handle_cast(_Msg, State) ->
+ {noreply, State}.
+
+handle_info({'EXIT', Pid, Reason}, #{service := Pid} = State) ->
+ case Reason of
+ normal -> ok;
+ shutdown -> ok;
+ _ ->
+ error_logger:error_msg("hornbeam listener service exited: ~p~n",
+ [Reason])
+ end,
+ {noreply, State#{service := undefined}};
+handle_info(_Info, State) ->
+ {noreply, State}.
+
+%% livery:stop_service/1 is synchronous (livery >= 0.7.0): it closes the
+%% listen socket, the acceptors, and every accepted connection before
+%% returning, so nothing is left serving the stopped service.
+terminate(_Reason, #{service := Pid}) when is_pid(Pid) ->
+ try livery:stop_service(Pid)
+ catch _:_ -> ok
+ end,
+ ok;
+terminate(_Reason, _State) ->
+ ok.
diff --git a/src/hornbeam_request.erl b/src/hornbeam_request.erl
index 1a7c4c7..272fbff 100644
--- a/src/hornbeam_request.erl
+++ b/src/hornbeam_request.erl
@@ -27,6 +27,7 @@
-export([build_wsgi_tuple/2, build_asgi_scope/2]).
-export([to_wsgi_header_key/1, format_ip/1, format_http_version/1]).
+-export([server_info/2, peer_info/1, scheme/1]).
%% @doc Build a pre-parsed WSGI request tuple for Python.
%%
@@ -35,17 +36,15 @@
%% ContentType, ContentLength, Body, Server, Client, Scheme, Protocol, State}
%%
%% WsgiHeaders is a map with HTTP_* keys already formatted.
--spec build_wsgi_tuple(cowboy_req:req(), map()) -> tuple().
+-spec build_wsgi_tuple(livery_req:req(), map()) -> tuple().
build_wsgi_tuple(Req, State) ->
- Method = cowboy_req:method(Req),
- Path = cowboy_req:path(Req),
- Qs = cowboy_req:qs(Req),
- Headers = cowboy_req:headers(Req),
- Host = cowboy_req:host(Req),
- Port = cowboy_req:port(Req),
- Scheme = cowboy_req:scheme(Req),
- Version = cowboy_req:version(Req),
- {ClientIp, ClientPort} = cowboy_req:peer(Req),
+ Method = livery_req:method(Req),
+ Path = livery_req:path(Req),
+ Qs = livery_req:query(Req),
+ Headers = livery_req:headers(Req),
+ Scheme = scheme(State),
+ {Host, Port} = server_info(Req, State),
+ {ClientIp, ClientPort} = peer_info(Req),
%% Get SCRIPT_NAME and PATH_INFO from state (multi-app) or defaults
ScriptName = maps:get(script_name, State, <<>>),
@@ -67,9 +66,9 @@ build_wsgi_tuple(Req, State) ->
ContentLength, % CONTENT_LENGTH (or undefined)
undefined, % Body placeholder (passed via buffer)
{Host, Port}, % SERVER_NAME, SERVER_PORT
- {format_ip(ClientIp), ClientPort}, % REMOTE_ADDR, REMOTE_PORT
+ {ClientIp, ClientPort}, % REMOTE_ADDR, REMOTE_PORT
Scheme, % wsgi.url_scheme
- format_protocol(Version), % SERVER_PROTOCOL
+ format_protocol(livery_req:protocol(Req)), % SERVER_PROTOCOL
LifespanState % Lifespan state
}.
@@ -78,41 +77,42 @@ build_wsgi_tuple(Req, State) ->
%% Headers are pre-formatted as [[name, value], ...] list.
%% All binary conversions done in Erlang.
%% Handles mount_id and per-mount lifespan state for multi-app mode.
--spec build_asgi_scope(cowboy_req:req(), map()) -> map().
+-spec build_asgi_scope(livery_req:req(), map()) -> map().
build_asgi_scope(Req, State) ->
- Path = cowboy_req:path(Req),
- Version = cowboy_req:version(Req),
- {ClientIp, ClientPort} = cowboy_req:peer(Req),
+ Path = livery_req:path(Req),
+ Protocol = livery_req:protocol(Req),
+ {Host, Port} = server_info(Req, State),
%% Get root_path and path from state (multi-app) or defaults
RootPath = maps:get(script_name, State, <<>>),
ScopePath = maps:get(path_info, State, Path),
- %% Convert headers to ASGI format [[name, value], ...]
- HeaderList = maps:fold(fun(Name, Value, Acc) ->
- [[Name, Value] | Acc]
- end, [], cowboy_req:headers(Req)),
+ %% Convert headers to ASGI format [[name, value], ...] preserving
+ %% duplicates and wire order
+ HeaderList = [[Name, Value] || {Name, Value} <- livery_req:headers(Req)],
%% Get mount_id for per-mount state isolation (multi-app mode)
MountId = maps:get(mount_id, State, undefined),
+ Client = peer_info(Req),
+
%% Build scope map with all fields
%% Note: state is NOT included here - Python fetches lazily via callback
%% This avoids copying state dict on every request
BaseScope = #{
type => <<"http">>,
asgi => #{<<"version">> => <<"3.0">>, <<"spec_version">> => <<"2.4">>},
- http_version => format_http_version(Version),
- method => cowboy_req:method(Req),
- scheme => cowboy_req:scheme(Req),
+ http_version => format_http_version(Protocol),
+ method => livery_req:method(Req),
+ scheme => scheme(State),
path => ScopePath,
raw_path => ScopePath,
- query_string => cowboy_req:qs(Req),
+ query_string => livery_req:query(Req),
root_path => RootPath,
headers => HeaderList,
- server => {cowboy_req:host(Req), cowboy_req:port(Req)},
- client => {format_ip(ClientIp), ClientPort},
- extensions => build_extensions(Version)
+ server => {Host, Port},
+ client => Client,
+ extensions => build_extensions(Protocol)
},
%% Add mount_id to scope if in multi-app mode (used by Python to get correct state)
@@ -121,6 +121,49 @@ build_asgi_scope(Req, State) ->
_ -> BaseScope#{mount_id => MountId}
end.
+%% @doc URL scheme for the listener that accepted the request.
+%%
+%% On HTTP/1.1 `livery_req:scheme/1' is always `<<"http">>' even under
+%% TLS, so the listener transport recorded in the handler state is
+%% authoritative.
+-spec scheme(map()) -> binary().
+scheme(State) ->
+ maps:get(server_scheme, State, <<"http">>).
+
+%% @doc Server host and port, from the host header (h1) or the
+%% `:authority' pseudo-header (h2/h3), falling back to the bind port
+%% recorded in the handler state.
+-spec server_info(livery_req:req(), map()) -> {binary(), inet:port_number()}.
+server_info(Req, State) ->
+ Authority = case livery_req:header(<<"host">>, Req) of
+ undefined ->
+ case livery_req:authority(Req) of
+ <<>> -> undefined;
+ A -> A
+ end;
+ HostHeader ->
+ HostHeader
+ end,
+ DefaultPort = maps:get(server_port, State, default_port(scheme(State))),
+ case Authority of
+ undefined ->
+ {<<"localhost">>, DefaultPort};
+ _ ->
+ parse_authority(Authority, DefaultPort)
+ end.
+
+%% @doc Client address as `{IpBinary, Port}'.
+%%
+%% `livery_req:peer/1' is set on every adapter, but a synthetic request
+%% (test adapter) may still carry none; degrade to an empty address so
+%% the WSGI/ASGI shape stays stable.
+-spec peer_info(livery_req:req()) -> {binary(), inet:port_number()}.
+peer_info(Req) ->
+ case livery_req:peer(Req) of
+ undefined -> {<<>>, 0};
+ {Ip, Port} -> {format_ip(Ip), Port}
+ end.
+
%% @doc Convert header name to WSGI HTTP_* format.
%% Example: "accept-encoding" becomes "HTTP_ACCEPT_ENCODING"
-spec to_wsgi_header_key(binary()) -> binary().
@@ -141,10 +184,10 @@ format_ip(Addr = {_, _, _, _, _, _, _, _}) ->
list_to_binary(inet:ntoa(Addr)).
%% @doc Format HTTP version for ASGI.
--spec format_http_version(atom()) -> binary().
-format_http_version('HTTP/1.0') -> <<"1.0">>;
-format_http_version('HTTP/1.1') -> <<"1.1">>;
-format_http_version('HTTP/2') -> <<"2">>.
+-spec format_http_version(h1 | h2 | h3) -> binary().
+format_http_version(h1) -> <<"1.1">>;
+format_http_version(h2) -> <<"2">>;
+format_http_version(h3) -> <<"3">>.
%%% ============================================================================
%%% Internal functions
@@ -152,9 +195,10 @@ format_http_version('HTTP/2') -> <<"2">>.
%% @private
%% Convert headers to WSGI format, extracting Content-Type and Content-Length.
+%% Duplicate headers are joined with ", " per RFC 9110.
%% Returns {WsgiHeadersMap, ContentType, ContentLength}
convert_headers_wsgi(Headers) ->
- maps:fold(fun(Name, Value, {Acc, CT, CL}) ->
+ lists:foldl(fun({Name, Value}, {Acc, CT, CL}) ->
case Name of
<<"content-type">> ->
{Acc, Value, CL};
@@ -162,13 +206,42 @@ convert_headers_wsgi(Headers) ->
{Acc, CT, Value};
_ ->
Key = to_wsgi_header_key(Name),
- {Acc#{Key => Value}, CT, CL}
+ Acc1 = case Acc of
+ #{Key := Prev} -> Acc#{Key := <>};
+ _ -> Acc#{Key => Value}
+ end,
+ {Acc1, CT, CL}
end
end, {#{}, undefined, undefined}, Headers).
%% @private
-%% Convert lowercase header to uppercase with underscores.
-%% Example: "accept-encoding" becomes "ACCEPT_ENCODING"
+%% Parse "Host[:Port]" including bracketed IPv6 "[::1]:8000".
+parse_authority(<<"[", Rest/binary>> = Authority, DefaultPort) ->
+ case binary:split(Rest, <<"]">>) of
+ [Host, <<":", PortBin/binary>>] ->
+ {<<"[", Host/binary, "]">>, to_port(PortBin, DefaultPort)};
+ [Host, _] ->
+ {<<"[", Host/binary, "]">>, DefaultPort};
+ _ ->
+ {Authority, DefaultPort}
+ end;
+parse_authority(Authority, DefaultPort) ->
+ case binary:split(Authority, <<":">>) of
+ [Host, PortBin] -> {Host, to_port(PortBin, DefaultPort)};
+ [Host] -> {Host, DefaultPort}
+ end.
+
+%% @private
+to_port(Bin, Default) ->
+ try binary_to_integer(Bin)
+ catch _:_ -> Default
+ end.
+
+%% @private
+default_port(<<"https">>) -> 443;
+default_port(_) -> 80.
+
+%% @private
to_upper_underscore(Bin) ->
<< <<(upper_char(C))>> || <> <= Bin >>.
@@ -177,17 +250,17 @@ upper_char($-) -> $_;
upper_char(C) -> C.
%% @private
-format_protocol('HTTP/1.0') -> <<"HTTP/1.0">>;
-format_protocol('HTTP/1.1') -> <<"HTTP/1.1">>;
-format_protocol('HTTP/2') -> <<"HTTP/2">>.
+format_protocol(h1) -> <<"HTTP/1.1">>;
+format_protocol(h2) -> <<"HTTP/2">>;
+format_protocol(h3) -> <<"HTTP/3">>.
%% @private
-build_extensions('HTTP/2') ->
- #{
- <<"http.response.trailers">> => #{},
- <<"http.response.early_hints">> => #{}
- };
-build_extensions(_) ->
- #{
- <<"http.response.early_hints">> => #{}
- }.
+%% Trailers are an h2/h3 feature. Early hints ride on livery's interim
+%% responses, which h1 and h2 send and h3 does not (livery 0.7.0).
+build_extensions(h1) ->
+ #{<<"http.response.early_hints">> => #{}};
+build_extensions(h2) ->
+ #{<<"http.response.trailers">> => #{},
+ <<"http.response.early_hints">> => #{}};
+build_extensions(h3) ->
+ #{<<"http.response.trailers">> => #{}}.
diff --git a/src/hornbeam_sup.erl b/src/hornbeam_sup.erl
index a6267a5..4fb9c55 100644
--- a/src/hornbeam_sup.erl
+++ b/src/hornbeam_sup.erl
@@ -46,6 +46,14 @@ init([]) ->
},
Children = [
+ #{
+ id => hornbeam_listener,
+ start => {hornbeam_listener, start_link, []},
+ restart => permanent,
+ shutdown => 5000,
+ type => worker,
+ modules => [hornbeam_listener]
+ },
#{
id => hornbeam_mounts,
start => {hornbeam_mounts, start_link, []},
diff --git a/src/hornbeam_websocket.erl b/src/hornbeam_websocket.erl
index b789c36..a63a60b 100644
--- a/src/hornbeam_websocket.erl
+++ b/src/hornbeam_websocket.erl
@@ -12,36 +12,15 @@
%% See the License for the specific language governing permissions and
%% limitations under the License.
-%%% @doc Cowboy WebSocket handler for ASGI applications.
+%%% @doc WebSocket upgrade and session registry.
%%%
-%%% This module implements the Cowboy websocket behavior and bridges
-%%% to ASGI WebSocket applications through the hornbeam_websocket_runner
-%%% Python module.
-%%%
-%%% == ASGI WebSocket Protocol ==
-%%%
-%%% The handler translates between Cowboy WebSocket and ASGI WebSocket events:
-%%%
-%%% Erlang -> Python (receive):
-%%% - websocket.connect: On WebSocket upgrade
-%%% - websocket.receive: On text/binary frame
-%%% - websocket.disconnect: On close
-%%%
-%%% Python -> Erlang (send):
-%%% - websocket.accept: Accept connection
-%%% - websocket.send: Send text/binary frame
-%%% - websocket.close: Close connection
+%%% The HTTP-side entry point: builds the ASGI WebSocket scope and hands
+%%% the request to the ws session via livery_ws:upgrade/3 with
+%%% hornbeam_ws_handler as the session handler. Also owns the
+%%% session-id -> pid ETS registry and the pubsub API exposed to Python.
-module(hornbeam_websocket).
--behaviour(cowboy_websocket).
-
--export([
- init/2,
- websocket_init/1,
- websocket_handle/2,
- websocket_info/2,
- terminate/3
-]).
+-export([upgrade/2, build_websocket_scope/2]).
%% Pubsub API for Python
-export([
@@ -52,26 +31,13 @@
unregister_session/1
]).
--record(state, {
- scope :: map(),
- app_module :: binary(),
- app_callable :: binary(),
- session_id :: binary(),
- accepted = false :: boolean(),
- subprotocol :: binary() | undefined,
- subscriptions = [] :: [term()],
- %% Channel protocol support
- mode = undefined :: undefined | asgi | channel,
- channel_pid :: pid() | undefined
-}).
-
-%% @doc Initialize WebSocket connection.
+%% @doc Upgrade an HTTP request to a WebSocket session.
%%
-%% This is called during HTTP request handling to decide whether to
-%% upgrade to WebSocket.
-init(Req, _Opts) ->
- %% Build ASGI WebSocket scope
- Scope = build_websocket_scope(Req),
+%% Returns the livery response produced by the handshake (101 taken_over
+%% on success, 400/500 otherwise).
+-spec upgrade(livery_req:req(), map()) -> livery_resp:resp().
+upgrade(Req, State) ->
+ Scope = build_websocket_scope(Req, State),
%% Get app module and callable from config
AppModule = hornbeam_config:get_config(app_module),
@@ -80,164 +46,42 @@ init(Req, _Opts) ->
%% Generate session ID for this connection
SessionId = generate_session_id(),
- State = #state{
- scope = Scope,
- app_module = AppModule,
- app_callable = AppCallable,
- session_id = SessionId
- },
-
- %% Get WebSocket options from config
WsTimeout = hornbeam_config:get_config(websocket_timeout),
- WsMaxFrameSize = hornbeam_config:get_config(websocket_max_frame_size),
- WsCompress = hornbeam_config:get_config(websocket_compress, false),
-
- WsOpts = #{
+ MaxFrameSize = hornbeam_config:get_config(websocket_max_frame_size),
+ Compress = hornbeam_config:get_config(websocket_compress),
+
+ %% Subprotocols are deliberately not passed: livery would reject
+ %% clients whose offer does not intersect - negotiation happens in
+ %% the Python app instead, as before
+ Opts = #{
+ scope => Scope,
+ app_module => AppModule,
+ app_callable => AppCallable,
+ session_id => SessionId,
idle_timeout => get_value(WsTimeout, 60000),
- max_frame_size => get_value(WsMaxFrameSize, 16777216),
- compress => WsCompress
+ max_frame_size => get_value(MaxFrameSize, 16777216),
+ compress => get_value(Compress, false) =:= true
},
+ livery_ws:upgrade(Req, hornbeam_ws_handler, Opts).
- %% Upgrade to WebSocket
- {cowboy_websocket, Req, State, WsOpts}.
-
-%% @doc WebSocket initialization after upgrade.
+%% @doc Build the ASGI WebSocket scope from a livery request.
%%
-%% Sends websocket.connect event to Python app.
-websocket_init(#state{scope = Scope, app_module = AppModule,
- app_callable = AppCallable, session_id = SessionId} = State) ->
- %% Register session
- register_session(SessionId, self()),
-
- %% Auto-subscribe to pubsub topic based on path (e.g., /chat/general -> chat:general)
- Path = maps:get(<<"path">>, Scope, <<>>),
- Topic = path_to_topic(Path),
- case Topic of
- undefined -> ok;
- _ -> hornbeam_pubsub:subscribe(Topic, self())
- end,
-
- %% Start Python WebSocket session
- case start_websocket_session(AppModule, AppCallable, Scope, SessionId) of
- {ok, Response} ->
- handle_connect_response(Response, State#state{subscriptions = [Topic]});
- {error, Reason} ->
- unregister_session(SessionId),
- error_logger:error_msg("WebSocket init error: ~p~n", [Reason]),
- {stop, State}
- end.
-
-%% Convert URL path to pubsub topic: /chat/general -> <<"chat:general">>
-path_to_topic(<<"/", Rest/binary>>) ->
- binary:replace(Rest, <<"/">>, <<":">>, [global]);
-path_to_topic(_) ->
- undefined.
-
-%% @doc Handle incoming WebSocket frames.
-websocket_handle({text, Data}, #state{mode = undefined} = State) ->
- %% First message - detect protocol
- handle_first_message(text, Data, State);
-websocket_handle({text, Data}, #state{mode = channel, channel_pid = Pid} = State) ->
- %% Channel protocol mode - route to channel process
- handle_channel_message(text, Data, Pid, State);
-websocket_handle({text, Data}, #state{mode = asgi} = State) ->
- %% ASGI mode - standard handling
- handle_receive(text, Data, State);
-websocket_handle({binary, Data}, #state{mode = undefined} = State) ->
- %% Binary data - assume ASGI mode
- State1 = State#state{mode = asgi},
- handle_receive(binary, Data, State1);
-websocket_handle({binary, Data}, #state{mode = channel, channel_pid = Pid} = State) ->
- %% Channel protocol doesn't use binary, but forward anyway
- handle_channel_message(binary, Data, Pid, State);
-websocket_handle({binary, Data}, #state{mode = asgi} = State) ->
- handle_receive(binary, Data, State);
-websocket_handle(ping, State) ->
- %% Cowboy handles ping/pong automatically
- {ok, State};
-websocket_handle(pong, State) ->
- {ok, State};
-websocket_handle(_Frame, State) ->
- {ok, State}.
-
-%% @doc Handle Erlang messages sent to the WebSocket process.
-websocket_info({websocket_send, text, Data}, State) ->
- {[{text, Data}], State};
-websocket_info({websocket_send, binary, Data}, State) ->
- {[{binary, Data}], State};
-websocket_info({websocket_close, Code, Reason}, State) ->
- {[{close, Code, Reason}], State};
-websocket_info({websocket_close, Code}, State) ->
- {[{close, Code, <<>>}], State};
-%% Handle pubsub messages - forward to WebSocket as JSON (ASGI mode only)
-websocket_info({pubsub, _Topic, Message}, #state{mode = asgi} = State) when is_map(Message) ->
- Json = json:encode(Message),
- {[{text, Json}], State};
-websocket_info({pubsub, _Topic, Message}, #state{mode = asgi} = State) when is_binary(Message) ->
- {[{text, Message}], State};
-websocket_info({pubsub, _Topic, _Message}, #state{mode = channel} = State) ->
- %% Channel mode handles pubsub through the channel process
- {ok, State};
-%% Handle channel process death
-websocket_info({'DOWN', _Ref, process, Pid, _Reason}, #state{channel_pid = Pid} = State) ->
- %% Channel process died - close WebSocket
- {[{close, 1011, <<"Channel terminated">>}], State#state{channel_pid = undefined}};
-websocket_info(_Info, State) ->
- {ok, State}.
-
-%% @doc Handle WebSocket termination.
-terminate(Reason, _Req, #state{session_id = SessionId,
- app_module = AppModule,
- app_callable = AppCallable,
- mode = Mode,
- channel_pid = ChannelPid} = _State) ->
- %% Unregister session from pubsub
- unregister_session(SessionId),
- %% Handle mode-specific cleanup
- case Mode of
- channel when is_pid(ChannelPid) ->
- %% Stop the channel process (it will handle cleanup)
- catch gen_server:stop(ChannelPid, normal, 5000);
- asgi ->
- %% Send disconnect event to Python ASGI app
- Code = reason_to_code(Reason),
- _ = send_disconnect(AppModule, AppCallable, SessionId, Code);
- _ ->
- ok
- end,
- ok;
-terminate(_Reason, _Req, _State) ->
- %% Non-WebSocket request or request without proper state
- ok.
-
-%%% ============================================================================
-%%% Internal Functions
-%%% ============================================================================
-
-build_websocket_scope(Req) ->
- Method = cowboy_req:method(Req),
- Path = cowboy_req:path(Req),
- RawPath = cowboy_req:path(Req),
- Qs = cowboy_req:qs(Req),
- Headers = cowboy_req:headers(Req),
- Host = cowboy_req:host(Req),
- Port = cowboy_req:port(Req),
- Scheme = cowboy_req:scheme(Req),
- Version = cowboy_req:version(Req),
-
- %% Get client info
- {ClientIp, ClientPort} = cowboy_req:peer(Req),
+%% The client address is unknown at upgrade time; hornbeam_ws_handler
+%% fills `<<"client">>' from the ws req map once the session starts.
+-spec build_websocket_scope(livery_req:req(), map()) -> map().
+build_websocket_scope(Req, State) ->
+ Path = livery_req:path(Req),
+ Headers = livery_req:headers(Req),
+ {Host, Port} = hornbeam_request:server_info(Req, State),
%% Convert headers to list of [name, value] pairs
- HeaderList = maps:fold(fun(Name, Value, Acc) ->
- [[Name, Value] | Acc]
- end, [], Headers),
+ HeaderList = [[Name, Value] || {Name, Value} <- Headers],
%% Get WebSocket subprotocols from Sec-WebSocket-Protocol header
- Subprotocols = get_subprotocols(Headers),
+ Subprotocols = get_subprotocols(Req),
- %% Determine WebSocket scheme
- WsScheme = case Scheme of
+ %% Determine WebSocket scheme from the listener transport
+ WsScheme = case hornbeam_request:scheme(State) of
<<"https">> -> <<"wss">>;
_ -> <<"ws">>
end,
@@ -248,21 +92,22 @@ build_websocket_scope(Req) ->
<<"version">> => <<"3.0">>,
<<"spec_version">> => <<"2.4">>
},
- <<"http_version">> => format_http_version(Version),
- <<"method">> => Method,
+ <<"http_version">> =>
+ hornbeam_request:format_http_version(livery_req:protocol(Req)),
+ <<"method">> => livery_req:method(Req),
<<"scheme">> => WsScheme,
<<"path">> => Path,
- <<"raw_path">> => RawPath,
- <<"query_string">> => Qs,
+ <<"raw_path">> => Path,
+ <<"query_string">> => livery_req:query(Req),
<<"root_path">> => <<>>,
<<"headers">> => HeaderList,
<<"server">> => [Host, Port],
- <<"client">> => [format_ip(ClientIp), ClientPort],
+ <<"client">> => [<<>>, 0],
<<"subprotocols">> => Subprotocols
}.
-get_subprotocols(Headers) ->
- case maps:get(<<"sec-websocket-protocol">>, Headers, undefined) of
+get_subprotocols(Req) ->
+ case livery_req:header(<<"sec-websocket-protocol">>, Req) of
undefined -> [];
Protocols ->
%% Parse comma-separated protocol list
@@ -270,18 +115,6 @@ get_subprotocols(Headers) ->
[string:trim(P) || P <- Parts]
end.
-format_http_version('HTTP/1.0') -> <<"1.0">>;
-format_http_version('HTTP/1.1') -> <<"1.1">>;
-format_http_version('HTTP/2') -> <<"2">>.
-
-%% IPv4
-format_ip({A, B, C, D}) ->
- iolist_to_binary(io_lib:format("~B.~B.~B.~B", [A, B, C, D]));
-%% IPv6
-format_ip({A, B, C, D, E, F, G, H}) ->
- iolist_to_binary(io_lib:format("~.16B:~.16B:~.16B:~.16B:~.16B:~.16B:~.16B:~.16B",
- [A, B, C, D, E, F, G, H])).
-
generate_session_id() ->
Bytes = crypto:strong_rand_bytes(16),
Base64 = base64:encode(Bytes),
@@ -292,169 +125,13 @@ generate_session_id() ->
get_value(undefined, Default) -> Default;
get_value(Value, _Default) -> Value.
-start_websocket_session(AppModule, AppCallable, Scope, SessionId) ->
- Timeout = hornbeam_config:get_config(timeout),
- TimeoutMs = case Timeout of
- undefined -> 30000;
- T -> T
- end,
- py:call(hornbeam_websocket_runner, start_session,
- [AppModule, AppCallable, Scope, SessionId], #{}, TimeoutMs).
-
-handle_connect_response(Response, State) ->
- case maps:get(<<"type">>, Response, undefined) of
- <<"websocket.accept">> ->
- Subprotocol = maps:get(<<"subprotocol">>, Response, undefined),
- NewState = State#state{accepted = true, subprotocol = Subprotocol},
- %% Connection accepted, continue with WebSocket
- {ok, NewState};
- <<"websocket.close">> ->
- Code = maps:get(<<"code">>, Response, 1000),
- Reason = maps:get(<<"reason">>, Response, <<>>),
- {[{close, Code, Reason}], State};
- _ ->
- %% Unknown response, close connection
- {[{close, 1002, <<"Protocol error">>}], State}
- end.
-
-handle_receive(Type, Data, #state{session_id = SessionId,
- app_module = AppModule,
- app_callable = AppCallable} = State) ->
- Timeout = hornbeam_config:get_config(timeout),
- TimeoutMs = case Timeout of
- undefined -> 30000;
- T -> T
- end,
-
- TypeStr = case Type of
- text -> <<"text">>;
- binary -> <<"bytes">>
- end,
-
- case py:call(hornbeam_websocket_runner, receive_message,
- [AppModule, AppCallable, SessionId, TypeStr, Data], #{}, TimeoutMs) of
- {ok, Responses} when is_list(Responses) ->
- process_responses(Responses, State);
- {ok, Response} when is_map(Response) ->
- process_responses([Response], State);
- {error, Reason} ->
- error_logger:error_msg("WebSocket receive error: ~p~n", [Reason]),
- {[{close, 1011, <<"Server error">>}], State}
- end.
-
-process_responses([], State) ->
- {ok, State};
-process_responses(Responses, State) ->
- %% First, handle any broadcast requests
- lists:foreach(fun(Response) ->
- case maps:get(<<"type">>, Response, undefined) of
- <<"hornbeam.broadcast">> ->
- Topic = maps:get(<<"topic">>, Response),
- Message = maps:get(<<"message">>, Response),
- hornbeam_pubsub:publish(Topic, Message);
- _ ->
- ok
- end
- end, Responses),
- %% Then collect WebSocket frames to send
- Frames = lists:filtermap(fun(Response) ->
- case maps:get(<<"type">>, Response, undefined) of
- <<"websocket.send">> ->
- case maps:get(<<"text">>, Response, undefined) of
- undefined ->
- case maps:get(<<"bytes">>, Response, undefined) of
- undefined -> false;
- Bytes -> {true, {binary, Bytes}}
- end;
- Text -> {true, {text, Text}}
- end;
- <<"websocket.close">> ->
- Code = maps:get(<<"code">>, Response, 1000),
- Reason = maps:get(<<"reason">>, Response, <<>>),
- {true, {close, Code, Reason}};
- _ ->
- false
- end
- end, Responses),
- case Frames of
- [] -> {ok, State};
- _ -> {Frames, State}
- end.
-
-send_disconnect(AppModule, AppCallable, SessionId, Code) ->
- Timeout = hornbeam_config:get_config(timeout),
- TimeoutMs = case Timeout of
- undefined -> 5000; % Shorter timeout for disconnect
- T -> min(T, 5000)
- end,
- catch py:call(hornbeam_websocket_runner, disconnect,
- [AppModule, AppCallable, SessionId, Code], #{}, TimeoutMs).
-
-reason_to_code(normal) -> 1000;
-reason_to_code(shutdown) -> 1001;
-reason_to_code(timeout) -> 1002;
-reason_to_code(remote) -> 1000;
-reason_to_code({remote, Code, _Reason}) -> Code;
-reason_to_code(_) -> 1006.
-
-%%% ============================================================================
-%%% Channel Protocol Support
-%%% ============================================================================
-
-%% @doc Handle first message to detect protocol.
-%% Channel protocol uses JSON arrays: [join_ref, ref, topic, event, payload]
-handle_first_message(text, Data, State) ->
- case try_parse_channel_message(Data) of
- {ok, Message} when is_list(Message), length(Message) =:= 5 ->
- %% Detected channel protocol
- start_channel_mode(Message, State);
- _ ->
- %% Not channel protocol, use ASGI mode
- State1 = State#state{mode = asgi},
- handle_receive(text, Data, State1)
- end.
-
-try_parse_channel_message(Data) ->
- try
- {ok, json:decode(Data)}
- catch
- _:_ -> error
- end.
-
-start_channel_mode(Message, #state{session_id = SessionId} = State) ->
- %% Start channel process for this connection
- case hornbeam_channel:start_link(self(), SessionId) of
- {ok, Pid} ->
- %% Monitor the channel process
- erlang:monitor(process, Pid),
- %% Route the initial message
- hornbeam_channel:handle_message(Pid, Message),
- {ok, State#state{mode = channel, channel_pid = Pid, accepted = true}};
- {error, Reason} ->
- error_logger:error_msg("Failed to start channel process: ~p~n", [Reason]),
- {[{close, 1011, <<"Server error">>}], State}
- end.
-
-handle_channel_message(text, Data, Pid, State) ->
- case try_parse_channel_message(Data) of
- {ok, Message} ->
- hornbeam_channel:handle_message(Pid, Message),
- {ok, State};
- error ->
- %% Invalid JSON - close connection
- {[{close, 1007, <<"Invalid JSON">>}], State}
- end;
-handle_channel_message(binary, _Data, _Pid, State) ->
- %% Channel protocol is text-only
- {ok, State}.
-
%%% ============================================================================
%%% Pubsub API for Python
%%% ============================================================================
-define(SESSION_TABLE, hornbeam_ws_sessions).
-%% @doc Register a WebSocket session (called from websocket_init).
+%% @doc Register a WebSocket session (called from hornbeam_ws_handler).
-spec register_session(binary(), pid()) -> ok.
register_session(SessionId, Pid) ->
_ = ensure_session_table(),
@@ -465,7 +142,9 @@ register_session(SessionId, Pid) ->
%% @doc Unregister a WebSocket session.
-spec unregister_session(binary()) -> ok.
unregister_session(SessionId) ->
- catch ets:delete(?SESSION_TABLE, SessionId),
+ try ets:delete(?SESSION_TABLE, SessionId)
+ catch _:_ -> true
+ end,
ok.
%% @doc Subscribe a WebSocket session to a pubsub topic.
diff --git a/src/hornbeam_ws_handler.erl b/src/hornbeam_ws_handler.erl
new file mode 100644
index 0000000..e56bfa3
--- /dev/null
+++ b/src/hornbeam_ws_handler.erl
@@ -0,0 +1,343 @@
+%% Copyright 2026 Benoit Chesneau
+%%
+%% Licensed under the Apache License, Version 2.0 (the "License");
+%% you may not use this file except in compliance with the License.
+%% You may obtain a copy of the License at
+%%
+%% http://www.apache.org/licenses/LICENSE-2.0
+%%
+%% Unless required by applicable law or agreed to in writing, software
+%% distributed under the License is distributed on an "AS IS" BASIS,
+%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+%% See the License for the specific language governing permissions and
+%% limitations under the License.
+
+%%% @doc WebSocket session handler (ws_handler behaviour) for ASGI apps.
+%%%
+%%% Bridges the ws session to ASGI WebSocket applications through the
+%%% hornbeam_websocket_runner Python module, or to Phoenix-style channels
+%%% when the first text frame is a JSON array of 5 elements.
+%%%
+%%% == ASGI WebSocket Protocol ==
+%%%
+%%% Erlang -> Python (receive):
+%%% - websocket.connect: On WebSocket upgrade
+%%% - websocket.receive: On text/binary frame
+%%% - websocket.disconnect: On close
+%%%
+%%% Python -> Erlang (send):
+%%% - websocket.accept: Accept connection
+%%% - websocket.send: Send text/binary frame
+%%% - websocket.close: Close connection
+-module(hornbeam_ws_handler).
+
+-behaviour(ws_handler).
+
+-export([init/2, handle_in/2, handle_info/2, terminate/2]).
+
+-record(state, {
+ scope :: map(),
+ app_module :: binary(),
+ app_callable :: binary(),
+ session_id :: binary(),
+ accepted = false :: boolean(),
+ subprotocol :: binary() | undefined,
+ subscriptions = [] :: [term()],
+ %% Channel protocol support
+ mode = undefined :: undefined | asgi | channel,
+ channel_pid :: pid() | undefined
+}).
+
+%% @doc Session start after a successful upgrade handshake.
+%%
+%% `Opts' comes from hornbeam_websocket:upgrade/2. The ws `Req' map
+%% carries the socket peer, which the upgrade-time scope could not know.
+init(Req, Opts) ->
+ Scope0 = maps:get(scope, Opts),
+ Scope = case maps:get(peer, Req, undefined) of
+ {Ip, Port} ->
+ Scope0#{<<"client">> => [hornbeam_request:format_ip(Ip), Port]};
+ _ ->
+ Scope0
+ end,
+ SessionId = maps:get(session_id, Opts),
+ State = #state{
+ scope = Scope,
+ app_module = maps:get(app_module, Opts),
+ app_callable = maps:get(app_callable, Opts),
+ session_id = SessionId
+ },
+
+ %% Register session
+ hornbeam_websocket:register_session(SessionId, self()),
+
+ %% Auto-subscribe to pubsub topic based on path (e.g., /chat/general -> chat:general)
+ Path = maps:get(<<"path">>, Scope, <<>>),
+ Topic = path_to_topic(Path),
+ case Topic of
+ undefined -> ok;
+ _ -> hornbeam_pubsub:subscribe(Topic, self())
+ end,
+
+ %% Start Python WebSocket session
+ case start_websocket_session(State) of
+ {ok, Response} ->
+ handle_connect_response(Response, State#state{subscriptions = [Topic]});
+ {error, Reason} ->
+ hornbeam_websocket:unregister_session(SessionId),
+ error_logger:error_msg("WebSocket init error: ~p~n", [Reason]),
+ {stop, ws_init_failed, State}
+ end.
+
+%% Convert URL path to pubsub topic: /chat/general -> <<"chat:general">>
+path_to_topic(<<"/", Rest/binary>>) ->
+ binary:replace(Rest, <<"/">>, <<":">>, [global]);
+path_to_topic(_) ->
+ undefined.
+
+%% @doc Handle incoming WebSocket frames.
+handle_in({text, Data}, #state{mode = undefined} = State) ->
+ %% First message - detect protocol
+ handle_first_message(text, Data, State);
+handle_in({text, Data}, #state{mode = channel, channel_pid = Pid} = State) ->
+ %% Channel protocol mode - route to channel process
+ handle_channel_message(text, Data, Pid, State);
+handle_in({text, Data}, #state{mode = asgi} = State) ->
+ %% ASGI mode - standard handling
+ handle_receive(text, Data, State);
+handle_in({binary, Data}, #state{mode = undefined} = State) ->
+ %% Binary data - assume ASGI mode
+ State1 = State#state{mode = asgi},
+ handle_receive(binary, Data, State1);
+handle_in({binary, Data}, #state{mode = channel, channel_pid = Pid} = State) ->
+ %% Channel protocol doesn't use binary, but forward anyway
+ handle_channel_message(binary, Data, Pid, State);
+handle_in({binary, Data}, #state{mode = asgi} = State) ->
+ handle_receive(binary, Data, State);
+handle_in({ping, _}, State) ->
+ %% ws_session already replied with a pong
+ {ok, State};
+handle_in({pong, _}, State) ->
+ {ok, State};
+handle_in(_Frame, State) ->
+ {ok, State}.
+
+%% @doc Handle Erlang messages sent to the WebSocket session process.
+handle_info({websocket_send, text, Data}, State) ->
+ {reply, {text, Data}, State};
+handle_info({websocket_send, binary, Data}, State) ->
+ {reply, {binary, Data}, State};
+handle_info({websocket_close, Code, Reason}, State) ->
+ {reply, {close, Code, Reason}, State};
+handle_info({websocket_close, Code}, State) ->
+ {reply, {close, Code, <<>>}, State};
+%% Handle pubsub messages - forward to WebSocket as JSON (ASGI mode only)
+handle_info({pubsub, _Topic, Message}, #state{mode = asgi} = State) when is_map(Message) ->
+ Json = json:encode(Message),
+ {reply, {text, Json}, State};
+handle_info({pubsub, _Topic, Message}, #state{mode = asgi} = State) when is_binary(Message) ->
+ {reply, {text, Message}, State};
+handle_info({pubsub, _Topic, _Message}, #state{mode = channel} = State) ->
+ %% Channel mode handles pubsub through the channel process
+ {ok, State};
+%% Handle channel process death
+handle_info({'DOWN', _Ref, process, Pid, _Reason}, #state{channel_pid = Pid} = State) ->
+ %% Channel process died - close WebSocket
+ {reply, {close, 1011, <<"Channel terminated">>}, State#state{channel_pid = undefined}};
+handle_info(_Info, State) ->
+ {ok, State}.
+
+%% @doc Handle WebSocket termination.
+terminate(Reason, #state{session_id = SessionId,
+ mode = Mode,
+ channel_pid = ChannelPid} = State) ->
+ %% Unregister session from pubsub
+ hornbeam_websocket:unregister_session(SessionId),
+ %% Handle mode-specific cleanup
+ _ = case Mode of
+ channel when is_pid(ChannelPid) ->
+ %% Stop the channel process (it will handle cleanup)
+ _ = try gen_server:stop(ChannelPid, normal, 5000)
+ catch _:_ -> ok
+ end,
+ ok;
+ asgi ->
+ %% Send disconnect event to Python ASGI app
+ Code = reason_to_code(Reason),
+ _ = send_disconnect(State, Code);
+ _ ->
+ ok
+ end,
+ ok;
+terminate(_Reason, _State) ->
+ %% Session torn down before init completed
+ ok.
+
+%%% ============================================================================
+%%% Internal Functions
+%%% ============================================================================
+
+start_websocket_session(#state{app_module = AppModule, app_callable = AppCallable,
+ scope = Scope, session_id = SessionId}) ->
+ Timeout = hornbeam_config:get_config(timeout),
+ TimeoutMs = case Timeout of
+ undefined -> 30000;
+ T -> T
+ end,
+ py:call(hornbeam_websocket_runner, start_session,
+ [AppModule, AppCallable, Scope, SessionId], #{}, TimeoutMs).
+
+handle_connect_response(Response, State) ->
+ case maps:get(<<"type">>, Response, undefined) of
+ <<"websocket.accept">> ->
+ Subprotocol = maps:get(<<"subprotocol">>, Response, undefined),
+ NewState = State#state{accepted = true, subprotocol = Subprotocol},
+ %% Connection accepted, continue with WebSocket
+ {ok, NewState};
+ <<"websocket.close">> ->
+ Code = maps:get(<<"code">>, Response, 1000),
+ Reason = maps:get(<<"reason">>, Response, <<>>),
+ {reply, {close, Code, Reason}, State};
+ _ ->
+ %% Unknown response, close connection
+ {reply, {close, 1002, <<"Protocol error">>}, State}
+ end.
+
+handle_receive(Type, Data, #state{session_id = SessionId,
+ app_module = AppModule,
+ app_callable = AppCallable} = State) ->
+ Timeout = hornbeam_config:get_config(timeout),
+ TimeoutMs = case Timeout of
+ undefined -> 30000;
+ T -> T
+ end,
+
+ TypeStr = case Type of
+ text -> <<"text">>;
+ binary -> <<"bytes">>
+ end,
+
+ case py:call(hornbeam_websocket_runner, receive_message,
+ [AppModule, AppCallable, SessionId, TypeStr, Data], #{}, TimeoutMs) of
+ {ok, Responses} when is_list(Responses) ->
+ process_responses(Responses, State);
+ {ok, Response} when is_map(Response) ->
+ process_responses([Response], State);
+ {error, Reason} ->
+ error_logger:error_msg("WebSocket receive error: ~p~n", [Reason]),
+ {reply, {close, 1011, <<"Server error">>}, State}
+ end.
+
+process_responses([], State) ->
+ {ok, State};
+process_responses(Responses, State) ->
+ %% First, handle any broadcast requests
+ lists:foreach(fun(Response) ->
+ case maps:get(<<"type">>, Response, undefined) of
+ <<"hornbeam.broadcast">> ->
+ Topic = maps:get(<<"topic">>, Response),
+ Message = maps:get(<<"message">>, Response),
+ hornbeam_pubsub:publish(Topic, Message);
+ _ ->
+ ok
+ end
+ end, Responses),
+ %% Then collect WebSocket frames to send
+ Frames = lists:filtermap(fun(Response) ->
+ case maps:get(<<"type">>, Response, undefined) of
+ <<"websocket.send">> ->
+ case maps:get(<<"text">>, Response, undefined) of
+ undefined ->
+ case maps:get(<<"bytes">>, Response, undefined) of
+ undefined -> false;
+ Bytes -> {true, {binary, Bytes}}
+ end;
+ Text -> {true, {text, Text}}
+ end;
+ <<"websocket.close">> ->
+ Code = maps:get(<<"code">>, Response, 1000),
+ Reason = maps:get(<<"reason">>, Response, <<>>),
+ {true, {close, Code, Reason}};
+ _ ->
+ false
+ end
+ end, Responses),
+ case Frames of
+ [] -> {ok, State};
+ _ -> {reply, Frames, State}
+ end.
+
+send_disconnect(#state{app_module = AppModule, app_callable = AppCallable,
+ session_id = SessionId}, Code) ->
+ Timeout = hornbeam_config:get_config(timeout),
+ TimeoutMs = case Timeout of
+ undefined -> 5000; % Shorter timeout for disconnect
+ T -> min(T, 5000)
+ end,
+ try
+ py:call(hornbeam_websocket_runner, disconnect,
+ [AppModule, AppCallable, SessionId, Code], #{}, TimeoutMs)
+ catch
+ _:_ -> ok
+ end.
+
+%% The peer's own close code when it sent one (livery >= 0.7.0);
+%% otherwise derived from the local terminate reason.
+reason_to_code({remote, Code, _Reason}) when is_integer(Code) -> Code;
+reason_to_code(remote) -> 1005;
+reason_to_code(normal) -> 1000;
+reason_to_code(shutdown) -> 1001;
+reason_to_code(timeout) -> 1002;
+reason_to_code({transport_error, _}) -> 1006;
+reason_to_code(_) -> 1006.
+
+%%% ============================================================================
+%%% Channel Protocol Support
+%%% ============================================================================
+
+%% @doc Handle first message to detect protocol.
+%% Channel protocol uses JSON arrays: [join_ref, ref, topic, event, payload]
+handle_first_message(text, Data, State) ->
+ case try_parse_channel_message(Data) of
+ {ok, Message} when is_list(Message), length(Message) =:= 5 ->
+ %% Detected channel protocol
+ start_channel_mode(Message, State);
+ _ ->
+ %% Not channel protocol, use ASGI mode
+ State1 = State#state{mode = asgi},
+ handle_receive(text, Data, State1)
+ end.
+
+try_parse_channel_message(Data) ->
+ try
+ {ok, json:decode(Data)}
+ catch
+ _:_ -> error
+ end.
+
+start_channel_mode(Message, #state{session_id = SessionId} = State) ->
+ %% Start channel process for this connection
+ case hornbeam_channel:start_link(self(), SessionId) of
+ {ok, Pid} ->
+ %% Monitor the channel process
+ _ = erlang:monitor(process, Pid),
+ %% Route the initial message
+ hornbeam_channel:handle_message(Pid, Message),
+ {ok, State#state{mode = channel, channel_pid = Pid, accepted = true}};
+ {error, Reason} ->
+ error_logger:error_msg("Failed to start channel process: ~p~n", [Reason]),
+ {reply, {close, 1011, <<"Server error">>}, State}
+ end.
+
+handle_channel_message(text, Data, Pid, State) ->
+ case try_parse_channel_message(Data) of
+ {ok, Message} ->
+ hornbeam_channel:handle_message(Pid, Message),
+ {ok, State};
+ error ->
+ %% Invalid JSON - close connection
+ {reply, {close, 1007, <<"Invalid JSON">>}, State}
+ end;
+handle_channel_message(binary, _Data, _Pid, State) ->
+ %% Channel protocol is text-only
+ {ok, State}.
diff --git a/src/hornbeam_wsgi.erl b/src/hornbeam_wsgi.erl
deleted file mode 100644
index 75b6ffa..0000000
--- a/src/hornbeam_wsgi.erl
+++ /dev/null
@@ -1,221 +0,0 @@
-%% Copyright 2026 Benoit Chesneau
-%%
-%% Licensed under the Apache License, Version 2.0 (the "License");
-%% you may not use this file except in compliance with the License.
-%% You may obtain a copy of the License at
-%%
-%% http://www.apache.org/licenses/LICENSE-2.0
-%%
-%% Unless required by applicable law or agreed to in writing, software
-%% distributed under the License is distributed on an "AS IS" BASIS,
-%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-%% See the License for the specific language governing permissions and
-%% limitations under the License.
-
-%%% @doc WSGI environment builder and protocol handling.
-%%%
-%%% Builds a PEP 3333 compliant WSGI environ dictionary from a Cowboy request.
-%%% This module creates environ dicts with all required and recommended WSGI
-%%% variables for full PEP 3333 compliance.
-%%%
-%%% == Required CGI Variables ==
-%%% - REQUEST_METHOD
-%%% - SCRIPT_NAME
-%%% - PATH_INFO
-%%% - QUERY_STRING
-%%% - CONTENT_TYPE (if present)
-%%% - CONTENT_LENGTH (if present)
-%%% - SERVER_NAME
-%%% - SERVER_PORT
-%%% - SERVER_PROTOCOL
-%%% - HTTP_* (for each request header)
-%%%
-%%% == Required WSGI Variables ==
-%%% - wsgi.version: (1, 0)
-%%% - wsgi.url_scheme: "http" or "https"
-%%% - wsgi.input: Request body
-%%% - wsgi.errors: "stderr" (routed to logging in Python)
-%%% - wsgi.multithread: true
-%%% - wsgi.multiprocess: true
-%%% - wsgi.run_once: false
-%%%
-%%% == Recommended Extensions ==
-%%% - wsgi.file_wrapper: FileWrapper class
-%%% - wsgi.input_terminated: true
-%%% - wsgi.early_hints: callback function
-%%%
-%%% == Additional Variables ==
-%%% - REMOTE_ADDR: Client IP address
-%%% - REMOTE_PORT: Client port
-%%% - REMOTE_HOST: Client hostname (if available)
--module(hornbeam_wsgi).
-
--export([
- build_environ/1,
- build_environ/2
-]).
-
--type environ_opts() :: #{
- script_name => binary(),
- root_path => binary(),
- proxy_allow_from => [binary()],
- proxy_headers => [binary()]
-}.
-
-%% @doc Build a WSGI environ dictionary from a Cowboy request.
-%%
-%% Creates a dictionary containing all standard WSGI variables.
--spec build_environ(cowboy_req:req()) -> map().
-build_environ(Req) ->
- build_environ(Req, #{}).
-
-%% @doc Build a WSGI environ dictionary with options.
-%%
-%% Options:
-%% - script_name: Override SCRIPT_NAME (default: empty binary)
-%% - root_path: Root path for the application
-%% - proxy_allow_from: IPs allowed to send proxy headers
-%% - proxy_headers: Headers to check for proxied client info
--spec build_environ(cowboy_req:req(), environ_opts()) -> map().
-build_environ(Req, Opts) ->
- %% Get basic request info
- Method = cowboy_req:method(Req),
- Path = cowboy_req:path(Req),
- Qs = cowboy_req:qs(Req),
- Headers = cowboy_req:headers(Req),
- Host = cowboy_req:host(Req),
- Port = cowboy_req:port(Req),
- Scheme = cowboy_req:scheme(Req),
- Version = cowboy_req:version(Req),
-
- %% Get client info
- {ClientIp, ClientPort} = cowboy_req:peer(Req),
-
- %% Check for proxy headers (X-Forwarded-For, X-Real-IP, etc.)
- {RemoteAddr, RemotePort} = get_remote_addr(Headers, ClientIp, ClientPort, Opts),
-
- %% Read body for wsgi.input
- {ok, Body, _Req2} = cowboy_req:read_body(Req),
-
- %% Get SCRIPT_NAME from options or default
- ScriptName = maps:get(script_name, Opts, <<>>),
-
- %% Build base environ
- BaseEnviron = #{
- %% Required CGI variables
- <<"REQUEST_METHOD">> => Method,
- <<"SCRIPT_NAME">> => ScriptName,
- <<"PATH_INFO">> => Path,
- <<"QUERY_STRING">> => Qs,
- <<"SERVER_NAME">> => Host,
- <<"SERVER_PORT">> => integer_to_binary(Port),
- <<"SERVER_PROTOCOL">> => format_protocol(Version),
-
- %% Required WSGI variables
- <<"wsgi.version">> => [1, 0],
- <<"wsgi.url_scheme">> => Scheme,
- <<"wsgi.input">> => Body,
- <<"wsgi.errors">> => <<"stderr">>,
- <<"wsgi.multithread">> => true,
- <<"wsgi.multiprocess">> => true,
- <<"wsgi.run_once">> => false,
-
- %% Recommended extensions (handled in Python runner)
- %% wsgi.file_wrapper - set in Python
- %% wsgi.input_terminated - set in Python
- %% wsgi.early_hints - set in Python
-
- %% Client info
- <<"REMOTE_ADDR">> => RemoteAddr,
- <<"REMOTE_PORT">> => integer_to_binary(RemotePort)
- },
-
- %% Add CONTENT_TYPE and CONTENT_LENGTH if present
- Environ1 = case maps:get(<<"content-type">>, Headers, undefined) of
- undefined -> BaseEnviron;
- CT -> BaseEnviron#{<<"CONTENT_TYPE">> => CT}
- end,
-
- Environ2 = case maps:get(<<"content-length">>, Headers, undefined) of
- undefined -> Environ1;
- CL -> Environ1#{<<"CONTENT_LENGTH">> => CL}
- end,
-
- %% Add HTTP_* headers
- add_http_headers(Headers, Environ2).
-
-%% @private
-%% Get remote address, checking proxy headers if configured
-get_remote_addr(Headers, ClientIp, ClientPort, Opts) ->
- ProxyAllowFrom = maps:get(proxy_allow_from, Opts, []),
- ProxyHeaders = maps:get(proxy_headers, Opts,
- [<<"x-forwarded-for">>, <<"x-real-ip">>]),
-
- ClientIpBin = format_ip(ClientIp),
-
- %% Check if client is in proxy allowlist
- case is_proxy_allowed(ClientIpBin, ProxyAllowFrom) of
- true ->
- %% Try to get real client IP from proxy headers
- case get_forwarded_addr(Headers, ProxyHeaders) of
- {ok, ForwardedAddr} -> {ForwardedAddr, 0};
- not_found -> {ClientIpBin, ClientPort}
- end;
- false ->
- {ClientIpBin, ClientPort}
- end.
-
-%% @private
-is_proxy_allowed(_ClientIp, []) -> false;
-is_proxy_allowed(ClientIp, AllowList) ->
- lists:member(ClientIp, AllowList).
-
-%% @private
-get_forwarded_addr(Headers, [Header | Rest]) ->
- case maps:get(Header, Headers, undefined) of
- undefined ->
- get_forwarded_addr(Headers, Rest);
- Value ->
- %% X-Forwarded-For can contain multiple IPs, take first
- case binary:split(Value, <<",">>) of
- [Ip | _] -> {ok, string:trim(Ip)};
- _ -> {ok, Value}
- end
- end;
-get_forwarded_addr(_Headers, []) ->
- not_found.
-
-%% @private
-format_protocol('HTTP/1.0') -> <<"HTTP/1.0">>;
-format_protocol('HTTP/1.1') -> <<"HTTP/1.1">>;
-format_protocol('HTTP/2') -> <<"HTTP/2">>.
-
-%% @private
-%% IPv4
-format_ip({A, B, C, D}) ->
- iolist_to_binary(io_lib:format("~B.~B.~B.~B", [A, B, C, D]));
-%% IPv6
-format_ip({A, B, C, D, E, F, G, H}) ->
- iolist_to_binary(io_lib:format("~.16B:~.16B:~.16B:~.16B:~.16B:~.16B:~.16B:~.16B",
- [A, B, C, D, E, F, G, H])).
-
-%% @private
-add_http_headers(Headers, Environ) ->
- maps:fold(fun(Name, Value, Acc) ->
- %% Skip content-type and content-length (handled separately)
- case Name of
- <<"content-type">> -> Acc;
- <<"content-length">> -> Acc;
- _ ->
- %% Convert header name to HTTP_* format
- HttpName = header_to_cgi(Name),
- Acc#{HttpName => Value}
- end
- end, Environ, Headers).
-
-%% @private
-%% Convert header name to CGI format: foo-bar -> HTTP_FOO_BAR
-header_to_cgi(Name) when is_binary(Name) ->
- Upper = string:uppercase(Name),
- Underscored = binary:replace(Upper, <<"-">>, <<"_">>, [global]),
- <<"HTTP_", Underscored/binary>>.
diff --git a/test/hornbeam_SUITE.erl b/test/hornbeam_SUITE.erl
index 12f7865..c32fd3c 100644
--- a/test/hornbeam_SUITE.erl
+++ b/test/hornbeam_SUITE.erl
@@ -93,15 +93,13 @@ test_start_stop(_Config) ->
ok = hornbeam:start("hello_wsgi.app:application"),
%% Verify listener is running
- Listeners = ranch:info(),
- ?assert(lists:keymember(hornbeam_http, 1, Listeners)),
+ ?assert(hornbeam:is_running()),
%% Test stopping
ok = hornbeam:stop(),
%% Verify listener is stopped
- Listeners2 = ranch:info(),
- ?assertNot(lists:keymember(hornbeam_http, 1, Listeners2)).
+ ?assertNot(hornbeam:is_running()).
test_config(_Config) ->
%% Test config management
diff --git a/test/hornbeam_asgi_SUITE.erl b/test/hornbeam_asgi_SUITE.erl
index 2598bb4..07c97c0 100644
--- a/test/hornbeam_asgi_SUITE.erl
+++ b/test/hornbeam_asgi_SUITE.erl
@@ -36,7 +36,8 @@
test_hello_world/1,
test_not_found/1,
test_request_info/1,
- test_post_echo/1
+ test_post_echo/1,
+ test_early_hints/1
]).
%% ASGI compliance tests
@@ -116,7 +117,8 @@ groups() ->
test_hello_world,
test_not_found,
test_request_info,
- test_post_echo
+ test_post_echo,
+ test_early_hints
]},
{asgi_compliance, [sequence], [
test_asgi_scope_type,
@@ -169,11 +171,11 @@ groups() ->
init_per_suite(Config) ->
{ok, _} = application:ensure_all_started(hornbeam),
- {ok, _} = application:ensure_all_started(hackney),
+ {ok, _} = application:ensure_all_started(livery),
Config.
end_per_suite(_Config) ->
- application:stop(hackney),
+ ok,
application:stop(hornbeam),
ok.
@@ -251,19 +253,19 @@ make_url(Config, Path) ->
test_hello_world(Config) ->
Url = make_url(Config, <<"/">>),
- {ok, 200, _Headers, Body} = hackney:request(get, Url, [], <<>>, []),
+ {ok, #{status := 200, body := {full, Body}}} = hornbeam_test_http:request(get, Url, #{}),
?assertEqual(<<"Hello from ASGI Test App!\n">>, Body).
test_not_found(Config) ->
Url = make_url(Config, <<"/nonexistent">>),
- {ok, 404, _Headers, Body} = hackney:request(get, Url, [], <<>>, []),
+ {ok, #{status := 404, body := {full, Body}}} = hornbeam_test_http:request(get, Url, #{}),
?assertEqual(<<"Not Found\n">>, Body).
test_request_info(Config) ->
Url = make_url(Config, <<"/info?foo=bar&baz=qux">>),
- {ok, 200, Headers, Body} = hackney:request(get, Url, [], <<>>, []),
+ {ok, #{status := 200, body := {full, Body}} = Resp} = hornbeam_test_http:request(get, Url, #{}),
- ContentType = proplists:get_value(<<"content-type">>, Headers),
+ ContentType = livery_client:header(<<"content-type">>, Resp),
?assertEqual(<<"application/json">>, ContentType),
Info = jsx:decode(Body, [return_maps]),
@@ -279,25 +281,49 @@ test_post_echo(Config) ->
Url = make_url(Config, <<"/echo">>),
ReqBody = <<"Hello, ASGI Echo!">>,
Headers = [{<<"Content-Type">>, <<"text/plain">>}],
- {ok, 200, RespHeaders, RespBody} = hackney:request(post, Url, Headers, ReqBody, []),
+ {ok, #{status := 200, body := {full, RespBody}} = Resp} = hornbeam_test_http:request(post, Url, #{headers => Headers, body => ReqBody}),
?assertEqual(ReqBody, RespBody),
- EchoLength = proplists:get_value(<<"x-echo-length">>, RespHeaders),
+ EchoLength = livery_client:header(<<"x-echo-length">>, Resp),
?assertEqual(<<"17">>, EchoLength).
+%% A `http.response.informational' event with status 103 reaches the
+%% wire as its own head, ahead of the final response. Read raw: an HTTP
+%% client library swallows interim responses.
+test_early_hints(Config) ->
+ Port = proplists:get_value(port, Config),
+ {ok, Sock} = gen_tcp:connect("127.0.0.1", Port,
+ [binary, {active, false}, {packet, raw}]),
+ ok = gen_tcp:send(Sock, [<<"GET /early-hints HTTP/1.1\r\n">>,
+ <<"Host: 127.0.0.1\r\n">>,
+ <<"Connection: close\r\n\r\n">>]),
+ Resp = recv_until_closed(Sock, <<>>),
+ ok = gen_tcp:close(Sock),
+
+ ?assertMatch(<<"HTTP/1.1 103", _/binary>>, Resp),
+ ?assert(binary:match(Resp, <<"rel=preload">>) =/= nomatch),
+ ?assert(binary:match(Resp, <<"HTTP/1.1 200">>) =/= nomatch),
+ ?assert(binary:match(Resp, <<"Response with early hints">>) =/= nomatch).
+
+recv_until_closed(Sock, Acc) ->
+ case gen_tcp:recv(Sock, 0, 5000) of
+ {ok, Data} -> recv_until_closed(Sock, <>);
+ {error, _} -> Acc
+ end.
+
%%% ============================================================================
%%% ASGI compliance tests
%%% ============================================================================
test_asgi_scope_type(Config) ->
Url = make_url(Config, <<"/scope">>),
- {ok, 200, _Headers, Body} = hackney:request(get, Url, [], <<>>, []),
+ {ok, #{status := 200, body := {full, Body}}} = hornbeam_test_http:request(get, Url, #{}),
Scope = jsx:decode(Body, [return_maps]),
?assertEqual(<<"http">>, maps:get(<<"type">>, Scope)).
test_asgi_scope_version(Config) ->
Url = make_url(Config, <<"/scope">>),
- {ok, 200, _Headers, Body} = hackney:request(get, Url, [], <<>>, []),
+ {ok, #{status := 200, body := {full, Body}}} = hornbeam_test_http:request(get, Url, #{}),
Scope = jsx:decode(Body, [return_maps]),
%% ASGI 3.0 requires 'asgi' key with 'version' and optionally 'spec_version'
@@ -312,17 +338,17 @@ test_asgi_scope_method(Config) ->
%% Test various methods
Url = make_url(Config, <<"/scope">>),
- {ok, 200, _, Body1} = hackney:request(get, Url, [], <<>>, []),
+ {ok, #{status := 200, body := {full, Body1}}} = hornbeam_test_http:request(get, Url, #{}),
Scope1 = jsx:decode(Body1, [return_maps]),
?assertEqual(<<"GET">>, maps:get(<<"method">>, Scope1)),
- {ok, 200, _, Body2} = hackney:request(post, Url, [], <<>>, []),
+ {ok, #{status := 200, body := {full, Body2}}} = hornbeam_test_http:request(post, Url, #{}),
Scope2 = jsx:decode(Body2, [return_maps]),
?assertEqual(<<"POST">>, maps:get(<<"method">>, Scope2)).
test_asgi_scope_path(Config) ->
Url = make_url(Config, <<"/scope">>),
- {ok, 200, _Headers, Body} = hackney:request(get, Url, [], <<>>, []),
+ {ok, #{status := 200, body := {full, Body}}} = hornbeam_test_http:request(get, Url, #{}),
Scope = jsx:decode(Body, [return_maps]),
Path = maps:get(<<"path">>, Scope),
?assert(is_binary(Path)),
@@ -330,7 +356,7 @@ test_asgi_scope_path(Config) ->
test_asgi_scope_query_string(Config) ->
Url = make_url(Config, <<"/scope?key1=value1&key2=value2">>),
- {ok, 200, _Headers, Body} = hackney:request(get, Url, [], <<>>, []),
+ {ok, #{status := 200, body := {full, Body}}} = hornbeam_test_http:request(get, Url, #{}),
Scope = jsx:decode(Body, [return_maps]),
QueryString = maps:get(<<"query_string">>, Scope),
?assert(is_binary(QueryString)),
@@ -342,7 +368,7 @@ test_asgi_scope_headers(Config) ->
{<<"X-Custom-Test">>, <<"test-value">>},
{<<"Accept">>, <<"application/json">>}
],
- {ok, 200, _RespHeaders, Body} = hackney:request(get, Url, ReqHeaders, <<>>, []),
+ {ok, #{status := 200, body := {full, Body}}} = hornbeam_test_http:request(get, Url, #{headers => ReqHeaders}),
Scope = jsx:decode(Body, [return_maps]),
%% Headers should be a list of [name, value] pairs
@@ -352,7 +378,7 @@ test_asgi_scope_headers(Config) ->
test_asgi_scope_server(Config) ->
Port = proplists:get_value(port, Config),
Url = make_url(Config, <<"/scope">>),
- {ok, 200, _Headers, Body} = hackney:request(get, Url, [], <<>>, []),
+ {ok, #{status := 200, body := {full, Body}}} = hornbeam_test_http:request(get, Url, #{}),
Scope = jsx:decode(Body, [return_maps]),
%% Server is a tuple/list [host, port] in ASGI scope
@@ -369,17 +395,21 @@ test_asgi_scope_server(Config) ->
test_asgi_scope_client(Config) ->
Url = make_url(Config, <<"/scope">>),
- {ok, 200, _Headers, Body} = hackney:request(get, Url, [], <<>>, []),
+ {ok, #{status := 200, body := {full, Body}}} = hornbeam_test_http:request(get, Url, #{}),
Scope = jsx:decode(Body, [return_maps]),
- %% Client is a tuple/list [host, port] in ASGI scope
+ %% Client is a [host, port] list in ASGI scope, carrying the real
+ %% peer the adapter accepted the connection from. /scope stringifies
+ %% scalar members, so the port comes back as text.
Client = maps:get(<<"client">>, Scope),
- ?assert(is_list(Client)),
- ?assertEqual(2, length(Client)).
+ ?assertMatch([_, _], Client),
+ [Host, ClientPort] = Client,
+ ?assertEqual(<<"127.0.0.1">>, Host),
+ ?assert(binary_to_integer(ClientPort) > 0).
test_asgi_scope_scheme(Config) ->
Url = make_url(Config, <<"/scope">>),
- {ok, 200, _Headers, Body} = hackney:request(get, Url, [], <<>>, []),
+ {ok, #{status := 200, body := {full, Body}}} = hornbeam_test_http:request(get, Url, #{}),
Scope = jsx:decode(Body, [return_maps]),
Scheme = maps:get(<<"scheme">>, Scope),
?assertEqual(<<"http">>, Scheme).
@@ -390,41 +420,41 @@ test_asgi_scope_scheme(Config) ->
test_method_get(Config) ->
Url = make_url(Config, <<"/methods/GET">>),
- {ok, 200, _Headers, Body} = hackney:request(get, Url, [], <<>>, []),
+ {ok, #{status := 200, body := {full, Body}}} = hornbeam_test_http:request(get, Url, #{}),
?assert(binary:match(Body, <<"Method GET OK">>) =/= nomatch).
test_method_post(Config) ->
Url = make_url(Config, <<"/methods/POST">>),
- {ok, 200, _Headers, Body} = hackney:request(post, Url, [], <<>>, []),
+ {ok, #{status := 200, body := {full, Body}}} = hornbeam_test_http:request(post, Url, #{}),
?assert(binary:match(Body, <<"Method POST OK">>) =/= nomatch).
test_method_put(Config) ->
Url = make_url(Config, <<"/methods/PUT">>),
- {ok, 200, _Headers, Body} = hackney:request(put, Url, [], <<>>, []),
+ {ok, #{status := 200, body := {full, Body}}} = hornbeam_test_http:request(put, Url, #{}),
?assert(binary:match(Body, <<"Method PUT OK">>) =/= nomatch).
test_method_delete(Config) ->
Url = make_url(Config, <<"/methods/DELETE">>),
- {ok, 200, _Headers, Body} = hackney:request(delete, Url, [], <<>>, []),
+ {ok, #{status := 200, body := {full, Body}}} = hornbeam_test_http:request(delete, Url, #{}),
?assert(binary:match(Body, <<"Method DELETE OK">>) =/= nomatch).
test_method_patch(Config) ->
Url = make_url(Config, <<"/methods/PATCH">>),
- {ok, 200, _Headers, Body} = hackney:request(patch, Url, [], <<>>, []),
+ {ok, #{status := 200, body := {full, Body}}} = hornbeam_test_http:request(patch, Url, #{}),
?assert(binary:match(Body, <<"Method PATCH OK">>) =/= nomatch).
test_method_head(Config) ->
Url = make_url(Config, <<"/methods/HEAD">>),
- %% HEAD request returns no body, hackney returns 3-tuple
- {ok, 200, Headers} = hackney:request(head, Url, [], <<>>, []),
+ %% HEAD request returns no body, client returns 3-tuple
+ {ok, #{status := 200} = Resp} = hornbeam_test_http:request(head, Url, #{}),
%% Verify headers are present
- ?assert(proplists:get_value(<<"content-type">>, Headers) =/= undefined),
- ?assert(proplists:get_value(<<"content-length">>, Headers) =/= undefined).
+ ?assert(livery_client:header(<<"content-type">>, Resp) =/= undefined),
+ ?assert(livery_client:header(<<"content-length">>, Resp) =/= undefined).
test_method_options(Config) ->
Url = make_url(Config, <<"/methods/OPTIONS">>),
- {ok, 200, Headers, _Body} = hackney:request(options, Url, [], <<>>, []),
- Allow = proplists:get_value(<<"allow">>, Headers),
+ {ok, #{status := 200} = Resp} = hornbeam_test_http:request(options, Url, #{}),
+ Allow = livery_client:header(<<"allow">>, Resp),
?assert(Allow =/= undefined),
?assert(binary:match(Allow, <<"GET">>) =/= nomatch).
@@ -434,40 +464,40 @@ test_method_options(Config) ->
test_status_200(Config) ->
Url = make_url(Config, <<"/status?code=200">>),
- {ok, 200, _Headers, _Body} = hackney:request(get, Url, [], <<>>, []).
+ {ok, #{status := 200}} = hornbeam_test_http:request(get, Url, #{}).
test_status_201(Config) ->
Url = make_url(Config, <<"/status?code=201">>),
- {ok, 201, _Headers, _Body} = hackney:request(get, Url, [], <<>>, []).
+ {ok, #{status := 201}} = hornbeam_test_http:request(get, Url, #{}).
test_status_204(Config) ->
Url = make_url(Config, <<"/status?code=204">>),
- {ok, 204, _Headers, Body} = hackney:request(get, Url, [], <<>>, []),
+ {ok, #{status := 204, body := {full, Body}}} = hornbeam_test_http:request(get, Url, #{}),
?assertEqual(<<>>, Body).
test_status_301(Config) ->
Url = make_url(Config, <<"/status?code=301&location=/redirected">>),
- {ok, 301, Headers, _Body} = hackney:request(get, Url, [], <<>>, [{follow_redirect, false}]),
- Location = proplists:get_value(<<"location">>, Headers),
+ {ok, #{status := 301} = Resp} = hornbeam_test_http:request(get, Url, #{}),
+ Location = livery_client:header(<<"location">>, Resp),
?assertEqual(<<"/redirected">>, Location).
test_status_302(Config) ->
Url = make_url(Config, <<"/status?code=302&location=/found">>),
- {ok, 302, Headers, _Body} = hackney:request(get, Url, [], <<>>, [{follow_redirect, false}]),
- Location = proplists:get_value(<<"location">>, Headers),
+ {ok, #{status := 302} = Resp} = hornbeam_test_http:request(get, Url, #{}),
+ Location = livery_client:header(<<"location">>, Resp),
?assertEqual(<<"/found">>, Location).
test_status_400(Config) ->
Url = make_url(Config, <<"/status?code=400">>),
- {ok, 400, _Headers, _Body} = hackney:request(get, Url, [], <<>>, []).
+ {ok, #{status := 400}} = hornbeam_test_http:request(get, Url, #{}).
test_status_404(Config) ->
Url = make_url(Config, <<"/status?code=404">>),
- {ok, 404, _Headers, _Body} = hackney:request(get, Url, [], <<>>, []).
+ {ok, #{status := 404}} = hornbeam_test_http:request(get, Url, #{}).
test_status_500(Config) ->
Url = make_url(Config, <<"/status?code=500">>),
- {ok, 500, _Headers, _Body} = hackney:request(get, Url, [], <<>>, []).
+ {ok, #{status := 500}} = hornbeam_test_http:request(get, Url, #{}).
%%% ============================================================================
%%% Header tests
@@ -475,20 +505,20 @@ test_status_500(Config) ->
test_custom_headers(Config) ->
Url = make_url(Config, <<"/headers?custom=test-value">>),
- {ok, 200, Headers, _Body} = hackney:request(get, Url, [], <<>>, []),
- CustomHeader = proplists:get_value(<<"x-custom-header">>, Headers),
+ {ok, #{status := 200} = Resp} = hornbeam_test_http:request(get, Url, #{}),
+ CustomHeader = livery_client:header(<<"x-custom-header">>, Resp),
?assertEqual(<<"test-value">>, CustomHeader).
test_multiple_headers(Config) ->
Url = make_url(Config, <<"/headers?multi=yes">>),
- {ok, 200, Headers, _Body} = hackney:request(get, Url, [], <<>>, []),
- MultiHeaders = [V || {K, V} <- Headers, K =:= <<"x-multi">>],
+ {ok, #{status := 200} = Resp} = hornbeam_test_http:request(get, Url, #{}),
+ MultiHeaders = [V || {K, V} <- livery_client:headers(Resp), string:lowercase(K) =:= <<"x-multi">>],
?assert(length(MultiHeaders) >= 1).
test_cache_headers(Config) ->
Url = make_url(Config, <<"/headers?cache=3600">>),
- {ok, 200, Headers, _Body} = hackney:request(get, Url, [], <<>>, []),
- CacheControl = proplists:get_value(<<"cache-control">>, Headers),
+ {ok, #{status := 200} = Resp} = hornbeam_test_http:request(get, Url, #{}),
+ CacheControl = livery_client:header(<<"cache-control">>, Resp),
?assertEqual(<<"max-age=3600">>, CacheControl).
%%% ============================================================================
@@ -499,9 +529,9 @@ test_json_request(Config) ->
Url = make_url(Config, <<"/json">>),
ReqBody = <<"{\"key\": \"value\", \"number\": 42}">>,
Headers = [{<<"Content-Type">>, <<"application/json">>}],
- {ok, 200, RespHeaders, RespBody} = hackney:request(post, Url, Headers, ReqBody, []),
+ {ok, #{status := 200, body := {full, RespBody}} = Resp} = hornbeam_test_http:request(post, Url, #{headers => Headers, body => ReqBody}),
- ContentType = proplists:get_value(<<"content-type">>, RespHeaders),
+ ContentType = livery_client:header(<<"content-type">>, Resp),
?assertEqual(<<"application/json">>, ContentType),
Response = jsx:decode(RespBody, [return_maps]),
@@ -512,19 +542,19 @@ test_json_request(Config) ->
test_large_body(Config) ->
Url = make_url(Config, <<"/large?size=102400">>),
- {ok, 200, _Headers, Body} = hackney:request(get, Url, [], <<>>, []),
+ {ok, #{status := 200, body := {full, Body}}} = hornbeam_test_http:request(get, Url, #{}),
?assertEqual(102400, byte_size(Body)).
test_empty_body(Config) ->
Url = make_url(Config, <<"/echo">>),
- {ok, 200, _Headers, Body} = hackney:request(post, Url, [], <<>>, []),
+ {ok, #{status := 200, body := {full, Body}}} = hornbeam_test_http:request(post, Url, #{}),
?assertEqual(<<>>, Body).
test_unicode_body(Config) ->
Url = make_url(Config, <<"/unicode">>),
- {ok, 200, Headers, Body} = hackney:request(get, Url, [], <<>>, []),
+ {ok, #{status := 200, body := {full, Body}} = Resp} = hornbeam_test_http:request(get, Url, #{}),
- ContentType = proplists:get_value(<<"content-type">>, Headers),
+ ContentType = livery_client:header(<<"content-type">>, Resp),
?assert(binary:match(ContentType, <<"utf-8">>) =/= nomatch),
?assert(binary:match(Body, <<"café"/utf8>>) =/= nomatch),
@@ -537,7 +567,7 @@ test_unicode_body(Config) ->
test_streaming_response(Config) ->
Url = make_url(Config, <<"/streaming?chunks=3&size=50">>),
- {ok, 200, _Headers, Body} = hackney:request(get, Url, [], <<>>, []),
+ {ok, #{status := 200, body := {full, Body}}} = hornbeam_test_http:request(get, Url, #{}),
?assert(binary:match(Body, <<"Chunk 1">>) =/= nomatch),
?assert(binary:match(Body, <<"Chunk 2">>) =/= nomatch),
@@ -545,7 +575,7 @@ test_streaming_response(Config) ->
test_streaming_chunks(Config) ->
Url = make_url(Config, <<"/streaming?chunks=5&size=100">>),
- {ok, 200, _Headers, Body} = hackney:request(get, Url, [], <<>>, []),
+ {ok, #{status := 200, body := {full, Body}}} = hornbeam_test_http:request(get, Url, #{}),
%% Should have 5 chunks
Matches = binary:matches(Body, <<"Chunk">>),
@@ -557,5 +587,5 @@ test_streaming_chunks(Config) ->
test_error_exception(Config) ->
Url = make_url(Config, <<"/error?type=exception">>),
- {ok, 500, _Headers, Body} = hackney:request(get, Url, [], <<>>, []),
+ {ok, #{status := 500, body := {full, Body}}} = hornbeam_test_http:request(get, Url, #{}),
?assert(binary:match(Body, <<"Internal Server Error">>) =/= nomatch).
diff --git a/test/hornbeam_examples_smoke_SUITE.erl b/test/hornbeam_examples_smoke_SUITE.erl
index bd1b26c..bc43227 100644
--- a/test/hornbeam_examples_smoke_SUITE.erl
+++ b/test/hornbeam_examples_smoke_SUITE.erl
@@ -99,7 +99,7 @@ python_dep_skip(_) ->
end_per_testcase(_TestCase, _Config) ->
catch hornbeam:stop(),
- %% Wait for the cowboy listener to actually release its socket and for
+ %% Wait for the listener to actually release its socket and for
%% any in-flight lifespan task to drain. Without this, the next test's
%% start can race the prior listener's shutdown.
timer:sleep(800),
diff --git a/test/hornbeam_lifespan_SUITE.erl b/test/hornbeam_lifespan_SUITE.erl
index 80aba85..f68f5c9 100644
--- a/test/hornbeam_lifespan_SUITE.erl
+++ b/test/hornbeam_lifespan_SUITE.erl
@@ -99,11 +99,11 @@ groups() ->
init_per_suite(Config) ->
{ok, _} = application:ensure_all_started(hornbeam),
- {ok, _} = application:ensure_all_started(hackney),
+ {ok, _} = application:ensure_all_started(livery),
Config.
end_per_suite(_Config) ->
- application:stop(hackney),
+ ok,
application:stop(hornbeam),
ok.
@@ -166,7 +166,7 @@ make_url(Config, Path) ->
test_startup_complete(Config) ->
%% Test that lifespan startup completed
Url = make_url(Config, <<"/state">>),
- {ok, 200, _Headers, Body} = hackney:request(get, Url, [], <<>>, []),
+ {ok, #{status := 200, body := {full, Body}}} = hornbeam_test_http:request(get, Url, #{}),
State = jsx:decode(Body, [return_maps]),
%% Check module_state which tracks lifespan events
@@ -176,7 +176,7 @@ test_startup_complete(Config) ->
test_startup_called(Config) ->
%% Test that startup was called
Url = make_url(Config, <<"/state">>),
- {ok, 200, _Headers, Body} = hackney:request(get, Url, [], <<>>, []),
+ {ok, #{status := 200, body := {full, Body}}} = hornbeam_test_http:request(get, Url, #{}),
State = jsx:decode(Body, [return_maps]),
ModuleState = maps:get(<<"module_state">>, State),
@@ -185,7 +185,7 @@ test_startup_called(Config) ->
test_startup_time_recorded(Config) ->
%% Test that startup time was recorded
Url = make_url(Config, <<"/state">>),
- {ok, 200, _Headers, Body} = hackney:request(get, Url, [], <<>>, []),
+ {ok, #{status := 200, body := {full, Body}}} = hornbeam_test_http:request(get, Url, #{}),
State = jsx:decode(Body, [return_maps]),
ModuleState = maps:get(<<"module_state">>, State),
@@ -195,7 +195,7 @@ test_startup_time_recorded(Config) ->
test_health_after_startup(Config) ->
%% Test health endpoint returns OK after startup
Url = make_url(Config, <<"/health">>),
- {ok, 200, _Headers, Body} = hackney:request(get, Url, [], <<>>, []),
+ {ok, #{status := 200, body := {full, Body}}} = hornbeam_test_http:request(get, Url, #{}),
?assertEqual(<<"OK">>, Body).
%%% ============================================================================
@@ -205,7 +205,7 @@ test_health_after_startup(Config) ->
test_lifespan_info_endpoint(Config) ->
%% Test lifespan info endpoint
Url = make_url(Config, <<"/lifespan-info">>),
- {ok, 200, _Headers, Body} = hackney:request(get, Url, [], <<>>, []),
+ {ok, #{status := 200, body := {full, Body}}} = hornbeam_test_http:request(get, Url, #{}),
Info = jsx:decode(Body, [return_maps]),
?assertEqual(true, maps:get(<<"lifespan_supported">>, Info)),
@@ -214,7 +214,7 @@ test_lifespan_info_endpoint(Config) ->
test_uptime_tracking(Config) ->
%% Test that uptime is tracked
Url = make_url(Config, <<"/lifespan-info">>),
- {ok, 200, _Headers, Body} = hackney:request(get, Url, [], <<>>, []),
+ {ok, #{status := 200, body := {full, Body}}} = hornbeam_test_http:request(get, Url, #{}),
Info = jsx:decode(Body, [return_maps]),
Uptime = maps:get(<<"uptime_seconds">>, Info),
@@ -228,7 +228,7 @@ test_uptime_tracking(Config) ->
test_state_endpoint(Config) ->
%% Test state endpoint returns state info
Url = make_url(Config, <<"/state">>),
- {ok, 200, _Headers, Body} = hackney:request(get, Url, [], <<>>, []),
+ {ok, #{status := 200, body := {full, Body}}} = hornbeam_test_http:request(get, Url, #{}),
State = jsx:decode(Body, [return_maps]),
?assert(maps:is_key(<<"module_state">>, State)).
@@ -238,12 +238,12 @@ test_request_count_increments(Config) ->
Url = make_url(Config, <<"/counter">>),
%% Make first request
- {ok, 200, _Headers1, Body1} = hackney:request(get, Url, [], <<>>, []),
+ {ok, #{status := 200, body := {full, Body1}}} = hornbeam_test_http:request(get, Url, #{}),
Counter1 = jsx:decode(Body1, [return_maps]),
Count1 = maps:get(<<"counter">>, Counter1),
%% Make second request
- {ok, 200, _Headers2, Body2} = hackney:request(get, Url, [], <<>>, []),
+ {ok, #{status := 200, body := {full, Body2}}} = hornbeam_test_http:request(get, Url, #{}),
Counter2 = jsx:decode(Body2, [return_maps]),
Count2 = maps:get(<<"counter">>, Counter2),
@@ -257,7 +257,7 @@ test_request_count_increments(Config) ->
test_counter_endpoint(Config) ->
%% Test counter endpoint
Url = make_url(Config, <<"/counter">>),
- {ok, 200, _Headers, Body} = hackney:request(get, Url, [], <<>>, []),
+ {ok, #{status := 200, body := {full, Body}}} = hornbeam_test_http:request(get, Url, #{}),
Counter = jsx:decode(Body, [return_maps]),
?assert(maps:is_key(<<"counter">>, Counter)),
@@ -268,7 +268,7 @@ test_counter_increments_multiple_times(Config) ->
Url = make_url(Config, <<"/counter">>),
Counts = lists:map(fun(_) ->
- {ok, 200, _Headers, Body} = hackney:request(get, Url, [], <<>>, []),
+ {ok, #{status := 200, body := {full, Body}}} = hornbeam_test_http:request(get, Url, #{}),
Counter = jsx:decode(Body, [return_maps]),
maps:get(<<"counter">>, Counter)
end, lists:seq(1, 5)),
@@ -286,10 +286,10 @@ test_counter_increments_multiple_times(Config) ->
test_root_endpoint(Config) ->
%% Test root endpoint
Url = make_url(Config, <<"/">>),
- {ok, 200, _Headers, Body} = hackney:request(get, Url, [], <<>>, []),
+ {ok, #{status := 200, body := {full, Body}}} = hornbeam_test_http:request(get, Url, #{}),
?assertEqual(<<"Lifespan Test App">>, Body).
test_not_found(Config) ->
%% Test 404 for unknown path
Url = make_url(Config, <<"/unknown-path">>),
- {ok, 404, _Headers, _Body} = hackney:request(get, Url, [], <<>>, []).
+ {ok, #{status := 404}} = hornbeam_test_http:request(get, Url, #{}).
diff --git a/test/hornbeam_multiapp_SUITE.erl b/test/hornbeam_multiapp_SUITE.erl
index ec52f19..6d153c2 100644
--- a/test/hornbeam_multiapp_SUITE.erl
+++ b/test/hornbeam_multiapp_SUITE.erl
@@ -63,11 +63,11 @@ groups() ->
init_per_suite(Config) ->
{ok, _} = application:ensure_all_started(hornbeam),
- {ok, _} = application:ensure_all_started(hackney),
+ {ok, _} = application:ensure_all_started(livery),
Config.
end_per_suite(_Config) ->
- application:stop(hackney),
+ ok,
application:stop(hornbeam),
ok.
@@ -104,8 +104,7 @@ test_multi_app_start(_Config) ->
}),
%% Verify listener is running
- Listeners = ranch:info(),
- ?assert(lists:keymember(hornbeam_http, 1, Listeners)),
+ ?assert(hornbeam:is_running()),
%% Verify mounts are registered
Mounts = hornbeam_mounts:list(),
@@ -116,7 +115,7 @@ test_routing_to_api_mount(_Config) ->
ok = start_multi_app_server(),
%% Request to /api should route to api_app
- {ok, 200, _Headers, Body} = hackney:request(get, "http://127.0.0.1:18080/api", [], <<>>, []),
+ {ok, #{status := 200, body := {full, Body}}} = hornbeam_test_http:request(get, "http://127.0.0.1:18080/api", #{}),
%% Parse JSON response
Response = jsx:decode(Body, [return_maps]),
@@ -129,7 +128,7 @@ test_routing_to_admin_mount(_Config) ->
ok = start_multi_app_server(),
%% Request to /admin/users should route to admin_app
- {ok, 200, _Headers, Body} = hackney:request(get, "http://127.0.0.1:18080/admin/users", [], <<>>, []),
+ {ok, #{status := 200, body := {full, Body}}} = hornbeam_test_http:request(get, "http://127.0.0.1:18080/admin/users", #{}),
%% Parse JSON response
Response = jsx:decode(Body, [return_maps]),
@@ -142,7 +141,7 @@ test_routing_to_root_mount(_Config) ->
ok = start_multi_app_server(),
%% Request to /other should route to frontend_app (root mount)
- {ok, 200, _Headers, Body} = hackney:request(get, "http://127.0.0.1:18080/other/page", [], <<>>, []),
+ {ok, #{status := 200, body := {full, Body}}} = hornbeam_test_http:request(get, "http://127.0.0.1:18080/other/page", #{}),
%% Parse JSON response
Response = jsx:decode(Body, [return_maps]),
@@ -164,14 +163,14 @@ test_longest_prefix_matching(_Config) ->
}),
%% /api/v2/users should match /api/v2 (longer prefix), not /api
- {ok, 200, _Headers1, Body1} = hackney:request(get, "http://127.0.0.1:18080/api/v2/users", [], <<>>, []),
+ {ok, #{status := 200, body := {full, Body1}}} = hornbeam_test_http:request(get, "http://127.0.0.1:18080/api/v2/users", #{}),
Response1 = jsx:decode(Body1, [return_maps]),
?assertEqual(<<"admin">>, maps:get(<<"app">>, Response1)),
?assertEqual(<<"/users">>, maps:get(<<"path_info">>, Response1)),
?assertEqual(<<"/api/v2">>, maps:get(<<"script_name">>, Response1)),
%% /api/v1/users should match /api
- {ok, 200, _Headers2, Body2} = hackney:request(get, "http://127.0.0.1:18080/api/v1/users", [], <<>>, []),
+ {ok, #{status := 200, body := {full, Body2}}} = hornbeam_test_http:request(get, "http://127.0.0.1:18080/api/v1/users", #{}),
Response2 = jsx:decode(Body2, [return_maps]),
?assertEqual(<<"api">>, maps:get(<<"app">>, Response2)),
?assertEqual(<<"/v1/users">>, maps:get(<<"path_info">>, Response2)),
@@ -191,7 +190,7 @@ test_script_name_path_info(_Config) ->
lists:foreach(fun({Path, ExpectedScript, ExpectedPath}) ->
Url = "http://127.0.0.1:18080" ++ Path,
- {ok, 200, _Headers, Body} = hackney:request(get, Url, [], <<>>, []),
+ {ok, #{status := 200, body := {full, Body}}} = hornbeam_test_http:request(get, Url, #{}),
Response = jsx:decode(Body, [return_maps]),
?assertEqual(ExpectedScript, maps:get(<<"script_name">>, Response),
{path, Path, expected_script, ExpectedScript}),
@@ -211,7 +210,7 @@ test_no_match_returns_404(_Config) ->
}),
%% Request to unmatched path should return 404
- {ok, 404, _Headers, _Body} = hackney:request(get, "http://127.0.0.1:18080/other", [], <<>>, []).
+ {ok, #{status := 404}} = hornbeam_test_http:request(get, "http://127.0.0.1:18080/other", #{}).
test_backward_compat_single_app(_Config) ->
%% Test that single-app mode still works
@@ -220,7 +219,7 @@ test_backward_compat_single_app(_Config) ->
}),
%% Should work as before
- {ok, 200, _Headers, Body} = hackney:request(get, "http://127.0.0.1:18080/", [], <<>>, []),
+ {ok, #{status := 200, body := {full, Body}}} = hornbeam_test_http:request(get, "http://127.0.0.1:18080/", #{}),
?assertEqual(<<"Hello from Hornbeam WSGI!\n">>, Body).
test_validation_invalid_prefix(_Config) ->
diff --git a/test/hornbeam_test_certs.erl b/test/hornbeam_test_certs.erl
new file mode 100644
index 0000000..a986ea0
--- /dev/null
+++ b/test/hornbeam_test_certs.erl
@@ -0,0 +1,46 @@
+%% Copyright 2026 Benoit Chesneau
+%%
+%% Licensed under the Apache License, Version 2.0 (the "License");
+%% you may not use this file except in compliance with the License.
+%% You may obtain a copy of the License at
+%%
+%% http://www.apache.org/licenses/LICENSE-2.0
+%%
+%% Unless required by applicable law or agreed to in writing, software
+%% distributed under the License is distributed on an "AS IS" BASIS,
+%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+%% See the License for the specific language governing permissions and
+%% limitations under the License.
+
+%%% @doc Self-signed certificate for the TLS suites.
+%%%
+%%% Generated into the suite's `priv_dir' at setup rather than vendored,
+%%% so nothing long-lived sits in the repo and the cert cannot expire.
+-module(hornbeam_test_certs).
+
+-export([generate/1]).
+
+%% @doc Write `cert.pem' and `key.pem' under `Dir', returning their paths.
+%%
+%% Returns `{error, no_openssl}' when openssl is not on PATH, so a suite
+%% can skip rather than fail on a machine without it.
+-spec generate(file:filename()) ->
+ {ok, file:filename(), file:filename()} | {error, term()}.
+generate(Dir) ->
+ CertFile = filename:join(Dir, "cert.pem"),
+ KeyFile = filename:join(Dir, "key.pem"),
+ case os:find_executable("openssl") of
+ false ->
+ {error, no_openssl};
+ OpenSsl ->
+ Cmd = lists:flatten(io_lib:format(
+ "~s req -x509 -newkey rsa:2048 -sha256 -days 1 -nodes "
+ "-keyout ~s -out ~s -subj /CN=localhost "
+ "-addext subjectAltName=DNS:localhost,IP:127.0.0.1 2>&1",
+ [OpenSsl, KeyFile, CertFile])),
+ Output = os:cmd(Cmd),
+ case {filelib:is_regular(CertFile), filelib:is_regular(KeyFile)} of
+ {true, true} -> {ok, CertFile, KeyFile};
+ _ -> {error, {openssl_failed, Output}}
+ end
+ end.
diff --git a/test/hornbeam_test_http.erl b/test/hornbeam_test_http.erl
new file mode 100644
index 0000000..fa184ab
--- /dev/null
+++ b/test/hornbeam_test_http.erl
@@ -0,0 +1,51 @@
+%% Copyright 2026 Benoit Chesneau
+%%
+%% Licensed under the Apache License, Version 2.0 (the "License");
+%% you may not use this file except in compliance with the License.
+%% You may obtain a copy of the License at
+%%
+%% http://www.apache.org/licenses/LICENSE-2.0
+%%
+%% Unless required by applicable law or agreed to in writing, software
+%% distributed under the License is distributed on an "AS IS" BASIS,
+%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+%% See the License for the specific language governing permissions and
+%% limitations under the License.
+
+%%% @doc HTTP helper for the test suites.
+%%%
+%%% Exists only so the suites do not rebuild a client at every call site.
+%%% The result is `livery_client''s own response, so suites match on
+%%% `#{status := _, headers := _, body := {full, _}}' directly; response
+%%% headers keep their wire case, and `livery_client:header/2,3' looks
+%%% them up case-insensitively.
+-module(hornbeam_test_http).
+
+-export([request/2, request/3, request/4]).
+
+-spec request(atom(), iodata()) -> livery_client:result().
+request(Method, Url) ->
+ request(Method, Url, #{}, #{}).
+
+-spec request(atom(), iodata(), map()) -> livery_client:result().
+request(Method, Url, ReqOpts) ->
+ request(Method, Url, ReqOpts, #{}).
+
+%% @doc `ClientOpts' reaches `livery_client:new/1' (adapter, layer stack,
+%% TLS options); `ReqOpts' reaches the request (headers, body, timeout).
+-spec request(atom(), iodata(), map(), map()) -> livery_client:result().
+request(Method, Url, ReqOpts, ClientOpts) ->
+ Client = livery_client:new(unpooled(ClientOpts)),
+ livery_client:request(Client, Method, iolist_to_binary(Url), ReqOpts).
+
+%% @private
+%% Every request gets a fresh connection. The suites stop and start the
+%% server between groups, and a pooled keep-alive connection held across a
+%% restart fails the next request on it - intermittently, and as whatever
+%% hackney makes of the dead socket rather than as anything the test is
+%% about. Pooling is the client's business, not what these suites check.
+unpooled(ClientOpts) ->
+ AdapterOpts = maps:get(adapter_opts, ClientOpts, #{}),
+ Hackney = maps:get(hackney, AdapterOpts, []),
+ ClientOpts#{adapter_opts =>
+ AdapterOpts#{hackney => [{pool, false} | Hackney]}}.
diff --git a/test/hornbeam_tls_SUITE.erl b/test/hornbeam_tls_SUITE.erl
new file mode 100644
index 0000000..a96404f
--- /dev/null
+++ b/test/hornbeam_tls_SUITE.erl
@@ -0,0 +1,274 @@
+%% Copyright 2026 Benoit Chesneau
+%%
+%% Licensed under the Apache License, Version 2.0 (the "License");
+%% you may not use this file except in compliance with the License.
+%% You may obtain a copy of the License at
+%%
+%% http://www.apache.org/licenses/LICENSE-2.0
+%%
+%% Unless required by applicable law or agreed to in writing, software
+%% distributed under the License is distributed on an "AS IS" BASIS,
+%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+%% See the License for the specific language governing permissions and
+%% limitations under the License.
+
+%%% @doc TLS, HTTP/2, HTTP/3 and request-limit tests.
+%%%
+%%% The certificate is generated into `priv_dir' at setup, so these
+%%% suites need openssl on PATH and skip without it.
+-module(hornbeam_tls_SUITE).
+
+-include_lib("common_test/include/ct.hrl").
+-include_lib("stdlib/include/assert.hrl").
+
+-export([
+ all/0,
+ groups/0,
+ init_per_suite/1,
+ end_per_suite/1,
+ init_per_group/2,
+ end_per_group/2
+]).
+
+-export([
+ test_h1_over_tls/1,
+ test_h2_over_tls/1,
+ test_h1_on_the_h2_port/1,
+ test_info_reports_h1_and_h2/1,
+ test_h3_listener_and_alt_svc/1,
+ test_h3_request/1,
+ test_h2_requires_ssl/1,
+ test_h3_requires_ssl/1,
+ test_invalid_http_version/1,
+ test_max_headers/1,
+ test_max_header_size/1,
+ test_max_request_line_size/1
+]).
+
+all() ->
+ [{group, tls_h1},
+ {group, alpn},
+ {group, h3},
+ {group, validation},
+ {group, limits}].
+
+groups() ->
+ [{tls_h1, [sequence], [
+ test_h1_over_tls
+ ]},
+ {alpn, [sequence], [
+ test_h2_over_tls,
+ test_h1_on_the_h2_port,
+ test_info_reports_h1_and_h2
+ ]},
+ {h3, [sequence], [
+ test_h3_listener_and_alt_svc,
+ test_h3_request
+ ]},
+ {validation, [sequence], [
+ test_h2_requires_ssl,
+ test_h3_requires_ssl,
+ test_invalid_http_version
+ ]},
+ {limits, [sequence], [
+ test_max_headers,
+ test_max_header_size,
+ test_max_request_line_size
+ ]}].
+
+init_per_suite(Config) ->
+ {ok, _} = application:ensure_all_started(hornbeam),
+ {ok, _} = application:ensure_all_started(livery),
+ case hornbeam_test_certs:generate(?config(priv_dir, Config)) of
+ {ok, CertFile, KeyFile} ->
+ [{certfile, list_to_binary(CertFile)},
+ {keyfile, list_to_binary(KeyFile)} | Config];
+ {error, Reason} ->
+ {skip, {no_certificate, Reason}}
+ end.
+
+end_per_suite(_Config) ->
+ application:stop(hornbeam),
+ ok.
+
+init_per_group(validation, Config) ->
+ Config;
+init_per_group(Group, Config) ->
+ Port = 18500 + erlang:phash2(Group, 200),
+ ok = hornbeam:start("asgi_test_app:application",
+ maps:merge(base_opts(Config, Port), group_opts(Group))),
+ timer:sleep(800),
+ [{port, Port} | Config].
+
+end_per_group(validation, _Config) ->
+ ok;
+end_per_group(_Group, _Config) ->
+ hornbeam:stop(),
+ timer:sleep(300),
+ ok.
+
+%% HTTP/1.1 alone over TLS keeps the `http' listener and only changes its
+%% transport; the other groups take the ALPN listener instead.
+group_opts(tls_h1) ->
+ #{http_version => ['HTTP/1.1']};
+group_opts(alpn) ->
+ #{http_version => ['HTTP/1.1', 'HTTP/2']};
+group_opts(h3) ->
+ #{http_version => ['HTTP/1.1', 'HTTP/2', 'HTTP/3']};
+group_opts(limits) ->
+ #{http_version => ['HTTP/1.1'],
+ max_headers => 20,
+ max_header_size => 512,
+ max_request_line_size => 512}.
+
+base_opts(Config, Port) ->
+ #{
+ bind => iolist_to_binary(io_lib:format("127.0.0.1:~p", [Port])),
+ worker_class => asgi,
+ ssl => true,
+ certfile => ?config(certfile, Config),
+ keyfile => ?config(keyfile, Config),
+ pythonpath => [list_to_binary(test_apps_dir())]
+ }.
+
+%%% ============================================================================
+%%% TLS and protocol negotiation
+%%% ============================================================================
+
+test_h1_over_tls(Config) ->
+ ?assertEqual({<<"1.1">>, <<"https">>}, scope_via(Config, [http1])).
+
+test_h2_over_tls(Config) ->
+ ?assertEqual({<<"2">>, <<"https">>}, scope_via(Config, [http2])).
+
+%% The whole point of the ALPN listener: a client that will not speak h2 is
+%% served HTTP/1.1 from the same TLS port rather than refused.
+test_h1_on_the_h2_port(Config) ->
+ ?assertEqual({<<"1.1">>, <<"https">>}, scope_via(Config, [http1])).
+
+test_info_reports_h1_and_h2(Config) ->
+ Port = ?config(port, Config),
+ #{running := true, listeners := Listeners} = hornbeam:info(),
+ ?assertEqual([Port], maps:get(h1, Listeners, undefined)),
+ ?assertEqual([Port], maps:get(h2, Listeners, undefined)).
+
+%%% ============================================================================
+%%% HTTP/3
+%%% ============================================================================
+
+test_h3_listener_and_alt_svc(Config) ->
+ Port = ?config(port, Config),
+ #{listeners := Listeners} = hornbeam:info(),
+ ?assertEqual([Port], maps:get(h3, Listeners, undefined)),
+
+ %% h1 and h2 responses point clients at the QUIC port
+ {ok, Resp} = request(Config, <<"/">>, [http2]),
+ AltSvc = livery_client:header(<<"alt-svc">>, Resp),
+ ?assertNotEqual(undefined, AltSvc),
+ ?assertNotEqual(nomatch, binary:match(AltSvc, <<"h3=">>)).
+
+test_h3_request(Config) ->
+ case scope_via(Config, [http3]) of
+ {<<"3">>, <<"https">>} ->
+ ok;
+ Other ->
+ ct:pal("HTTP/3 request did not negotiate h3: ~p", [Other]),
+ {skip, "no working HTTP/3 client transport"}
+ end.
+
+%%% ============================================================================
+%%% Config validation
+%%% ============================================================================
+
+test_h2_requires_ssl(_Config) ->
+ ?assertEqual({error, {http_version_requires_ssl, 'HTTP/2'}},
+ hornbeam:start("asgi_test_app:application",
+ #{bind => <<"127.0.0.1:18999">>,
+ http_version => ['HTTP/1.1', 'HTTP/2']})).
+
+test_h3_requires_ssl(_Config) ->
+ ?assertEqual({error, {http_version_requires_ssl, 'HTTP/3'}},
+ hornbeam:start("asgi_test_app:application",
+ #{bind => <<"127.0.0.1:18999">>,
+ http_version => ['HTTP/3']})).
+
+test_invalid_http_version(_Config) ->
+ ?assertEqual({error, {invalid_http_version, 'HTTP/4'}},
+ hornbeam:start("asgi_test_app:application",
+ #{bind => <<"127.0.0.1:18999">>,
+ ssl => true,
+ http_version => ['HTTP/4']})),
+ ?assertEqual({error, {invalid_http_version, []}},
+ hornbeam:start("asgi_test_app:application",
+ #{bind => <<"127.0.0.1:18999">>,
+ http_version => []})).
+
+%%% ============================================================================
+%%% Request limits
+%%% ============================================================================
+
+%% h1 answers a limit breach rather than closing: 431 for the header
+%% block, 414 for the request line.
+test_max_headers(Config) ->
+ Headers = [{iolist_to_binary(io_lib:format("x-n~p", [N])), <<"v">>}
+ || N <- lists:seq(1, 40)],
+ {ok, #{status := Status}} = request(Config, <<"/">>, [http1], Headers),
+ ?assertEqual(431, Status).
+
+test_max_header_size(Config) ->
+ Big = binary:copy(<<"a">>, 2048),
+ {ok, #{status := Status}} = request(Config, <<"/">>, [http1], [{<<"x-big">>, Big}]),
+ ?assertEqual(431, Status).
+
+test_max_request_line_size(Config) ->
+ Long = <<"/?q=", (binary:copy(<<"a">>, 2048))/binary>>,
+ {ok, #{status := Status}} = request(Config, Long, [http1]),
+ ?assertEqual(414, Status).
+
+%%% ============================================================================
+%%% Helpers
+%%% ============================================================================
+
+%% The ASGI app echoes its scope, so the protocol the server actually used
+%% is observable rather than inferred from the client's request.
+scope_via(Config, Protocols) ->
+ case request(Config, <<"/info">>, Protocols) of
+ {ok, #{status := 200, body := {full, Body}}} ->
+ Info = jsx:decode(Body, [return_maps]),
+ {maps:get(<<"http_version">>, Info), maps:get(<<"scheme">>, Info)};
+ Other ->
+ Other
+ end.
+
+request(Config, Path, Protocols) ->
+ request(Config, Path, Protocols, []).
+
+request(Config, Path, Protocols, Headers) ->
+ Port = ?config(port, Config),
+ Url = iolist_to_binary([<<"https://127.0.0.1:">>, integer_to_binary(Port), Path]),
+ hornbeam_test_http:request(get, Url, #{headers => Headers}, client_opts(Protocols)).
+
+%% Self-signed certificate, so the client must not verify the chain.
+%% It has to be hackney's own `insecure' flag nested inside `ssl_options':
+%% a bare `{verify, verify_none}' there still fails, because hackney merges
+%% its default `verify_fun' over the top, and `insecure' at the option-list
+%% top level never reaches the TLS layer. `protocols' pins which HTTP
+%% version hackney offers, so each test drives the wire it means to.
+client_opts(Protocols) ->
+ #{adapter_opts => #{hackney => [
+ {ssl_options, [{insecure, true}]},
+ {protocols, Protocols}
+ ]}}.
+
+test_apps_dir() ->
+ EbinDir = filename:dirname(code:which(hornbeam)),
+ LibDir = filename:dirname(EbinDir),
+ SrcLink = filename:join(LibDir, "src"),
+ case file:read_link(SrcLink) of
+ {ok, RelPath} ->
+ ActualSrc = filename:join(LibDir, RelPath),
+ ActualSrcDir = filename:dirname(filename:absname(ActualSrc)),
+ filename:join(ActualSrcDir, "test/test_apps");
+ {error, _} ->
+ filename:join(LibDir, "test/test_apps")
+ end.
diff --git a/test/hornbeam_wsgi_SUITE.erl b/test/hornbeam_wsgi_SUITE.erl
index 86c6a3a..38bb4f2 100644
--- a/test/hornbeam_wsgi_SUITE.erl
+++ b/test/hornbeam_wsgi_SUITE.erl
@@ -142,11 +142,11 @@ groups() ->
init_per_suite(Config) ->
{ok, _} = application:ensure_all_started(hornbeam),
- {ok, _} = application:ensure_all_started(hackney),
+ {ok, _} = application:ensure_all_started(livery),
Config.
end_per_suite(_Config) ->
- application:stop(hackney),
+ ok,
application:stop(hornbeam),
ok.
@@ -221,20 +221,20 @@ make_url(Config, Path) ->
test_hello_world(Config) ->
Url = make_url(Config, <<"/">>),
- {ok, 200, _Headers, Body} = hackney:request(get, Url, [], <<>>, []),
+ {ok, #{status := 200, body := {full, Body}}} = hornbeam_test_http:request(get, Url, #{}),
?assertEqual(<<"Hello from WSGI Test App!\n">>, Body).
test_not_found(Config) ->
Url = make_url(Config, <<"/nonexistent">>),
- {ok, 404, _Headers, Body} = hackney:request(get, Url, [], <<>>, []),
+ {ok, #{status := 404, body := {full, Body}}} = hornbeam_test_http:request(get, Url, #{}),
?assertEqual(<<"Not Found\n">>, Body).
test_request_info(Config) ->
Url = make_url(Config, <<"/info?foo=bar&baz=qux">>),
- {ok, 200, Headers, Body} = hackney:request(get, Url, [], <<>>, []),
+ {ok, #{status := 200, body := {full, Body}} = Resp} = hornbeam_test_http:request(get, Url, #{}),
%% Should be JSON
- ContentType = proplists:get_value(<<"content-type">>, Headers),
+ ContentType = livery_client:header(<<"content-type">>, Resp),
?assertEqual(<<"application/json">>, ContentType),
%% Parse JSON and check fields
@@ -247,11 +247,11 @@ test_post_echo(Config) ->
Url = make_url(Config, <<"/echo">>),
ReqBody = <<"Hello, Echo!">>,
Headers = [{<<"Content-Type">>, <<"text/plain">>}],
- {ok, 200, RespHeaders, RespBody} = hackney:request(post, Url, Headers, ReqBody, []),
+ {ok, #{status := 200, body := {full, RespBody}} = Resp} = hornbeam_test_http:request(post, Url, #{headers => Headers, body => ReqBody}),
?assertEqual(ReqBody, RespBody),
%% Check echo length header
- EchoLength = proplists:get_value(<<"x-echo-length">>, RespHeaders),
+ EchoLength = livery_client:header(<<"x-echo-length">>, Resp),
?assertEqual(<<"12">>, EchoLength).
%%% ============================================================================
@@ -260,41 +260,41 @@ test_post_echo(Config) ->
test_method_get(Config) ->
Url = make_url(Config, <<"/methods/GET">>),
- {ok, 200, _Headers, Body} = hackney:request(get, Url, [], <<>>, []),
+ {ok, #{status := 200, body := {full, Body}}} = hornbeam_test_http:request(get, Url, #{}),
?assert(binary:match(Body, <<"Method GET OK">>) =/= nomatch).
test_method_post(Config) ->
Url = make_url(Config, <<"/methods/POST">>),
- {ok, 200, _Headers, Body} = hackney:request(post, Url, [], <<>>, []),
+ {ok, #{status := 200, body := {full, Body}}} = hornbeam_test_http:request(post, Url, #{}),
?assert(binary:match(Body, <<"Method POST OK">>) =/= nomatch).
test_method_put(Config) ->
Url = make_url(Config, <<"/methods/PUT">>),
- {ok, 200, _Headers, Body} = hackney:request(put, Url, [], <<>>, []),
+ {ok, #{status := 200, body := {full, Body}}} = hornbeam_test_http:request(put, Url, #{}),
?assert(binary:match(Body, <<"Method PUT OK">>) =/= nomatch).
test_method_delete(Config) ->
Url = make_url(Config, <<"/methods/DELETE">>),
- {ok, 200, _Headers, Body} = hackney:request(delete, Url, [], <<>>, []),
+ {ok, #{status := 200, body := {full, Body}}} = hornbeam_test_http:request(delete, Url, #{}),
?assert(binary:match(Body, <<"Method DELETE OK">>) =/= nomatch).
test_method_patch(Config) ->
Url = make_url(Config, <<"/methods/PATCH">>),
- {ok, 200, _Headers, Body} = hackney:request(patch, Url, [], <<>>, []),
+ {ok, #{status := 200, body := {full, Body}}} = hornbeam_test_http:request(patch, Url, #{}),
?assert(binary:match(Body, <<"Method PATCH OK">>) =/= nomatch).
test_method_head(Config) ->
Url = make_url(Config, <<"/methods/HEAD">>),
- %% HEAD request returns no body, hackney returns 3-tuple
- {ok, 200, Headers} = hackney:request(head, Url, [], <<>>, []),
+ %% HEAD request returns no body, client returns 3-tuple
+ {ok, #{status := 200} = Resp} = hornbeam_test_http:request(head, Url, #{}),
%% Verify headers are present
- ?assert(proplists:get_value(<<"content-type">>, Headers) =/= undefined),
- ?assert(proplists:get_value(<<"content-length">>, Headers) =/= undefined).
+ ?assert(livery_client:header(<<"content-type">>, Resp) =/= undefined),
+ ?assert(livery_client:header(<<"content-length">>, Resp) =/= undefined).
test_method_options(Config) ->
Url = make_url(Config, <<"/methods/OPTIONS">>),
- {ok, 200, Headers, _Body} = hackney:request(options, Url, [], <<>>, []),
- Allow = proplists:get_value(<<"allow">>, Headers),
+ {ok, #{status := 200} = Resp} = hornbeam_test_http:request(options, Url, #{}),
+ Allow = livery_client:header(<<"allow">>, Resp),
?assert(Allow =/= undefined),
?assert(binary:match(Allow, <<"GET">>) =/= nomatch).
@@ -304,41 +304,41 @@ test_method_options(Config) ->
test_status_200(Config) ->
Url = make_url(Config, <<"/status?code=200">>),
- {ok, 200, _Headers, _Body} = hackney:request(get, Url, [], <<>>, []).
+ {ok, #{status := 200}} = hornbeam_test_http:request(get, Url, #{}).
test_status_201(Config) ->
Url = make_url(Config, <<"/status?code=201">>),
- {ok, 201, _Headers, _Body} = hackney:request(get, Url, [], <<>>, []).
+ {ok, #{status := 201}} = hornbeam_test_http:request(get, Url, #{}).
test_status_204(Config) ->
Url = make_url(Config, <<"/status?code=204">>),
- {ok, 204, _Headers, Body} = hackney:request(get, Url, [], <<>>, []),
+ {ok, #{status := 204, body := {full, Body}}} = hornbeam_test_http:request(get, Url, #{}),
?assertEqual(<<>>, Body).
test_status_301(Config) ->
Url = make_url(Config, <<"/status?code=301&location=/redirected">>),
%% Don't follow redirects
- {ok, 301, Headers, _Body} = hackney:request(get, Url, [], <<>>, [{follow_redirect, false}]),
- Location = proplists:get_value(<<"location">>, Headers),
+ {ok, #{status := 301} = Resp} = hornbeam_test_http:request(get, Url, #{}),
+ Location = livery_client:header(<<"location">>, Resp),
?assertEqual(<<"/redirected">>, Location).
test_status_302(Config) ->
Url = make_url(Config, <<"/status?code=302&location=/found">>),
- {ok, 302, Headers, _Body} = hackney:request(get, Url, [], <<>>, [{follow_redirect, false}]),
- Location = proplists:get_value(<<"location">>, Headers),
+ {ok, #{status := 302} = Resp} = hornbeam_test_http:request(get, Url, #{}),
+ Location = livery_client:header(<<"location">>, Resp),
?assertEqual(<<"/found">>, Location).
test_status_400(Config) ->
Url = make_url(Config, <<"/status?code=400">>),
- {ok, 400, _Headers, _Body} = hackney:request(get, Url, [], <<>>, []).
+ {ok, #{status := 400}} = hornbeam_test_http:request(get, Url, #{}).
test_status_404(Config) ->
Url = make_url(Config, <<"/status?code=404">>),
- {ok, 404, _Headers, _Body} = hackney:request(get, Url, [], <<>>, []).
+ {ok, #{status := 404}} = hornbeam_test_http:request(get, Url, #{}).
test_status_500(Config) ->
Url = make_url(Config, <<"/status?code=500">>),
- {ok, 500, _Headers, _Body} = hackney:request(get, Url, [], <<>>, []).
+ {ok, #{status := 500}} = hornbeam_test_http:request(get, Url, #{}).
%%% ============================================================================
%%% Header tests
@@ -346,21 +346,21 @@ test_status_500(Config) ->
test_custom_headers(Config) ->
Url = make_url(Config, <<"/headers?custom=test-value">>),
- {ok, 200, Headers, _Body} = hackney:request(get, Url, [], <<>>, []),
- CustomHeader = proplists:get_value(<<"x-custom-header">>, Headers),
+ {ok, #{status := 200} = Resp} = hornbeam_test_http:request(get, Url, #{}),
+ CustomHeader = livery_client:header(<<"x-custom-header">>, Resp),
?assertEqual(<<"test-value">>, CustomHeader).
test_multiple_headers(Config) ->
Url = make_url(Config, <<"/headers?multi=yes">>),
- {ok, 200, Headers, _Body} = hackney:request(get, Url, [], <<>>, []),
+ {ok, #{status := 200} = Resp} = hornbeam_test_http:request(get, Url, #{}),
%% Multiple headers with same name should be present
- MultiHeaders = [V || {K, V} <- Headers, K =:= <<"x-multi">>],
+ MultiHeaders = [V || {K, V} <- livery_client:headers(Resp), string:lowercase(K) =:= <<"x-multi">>],
?assert(length(MultiHeaders) >= 1).
test_cache_headers(Config) ->
Url = make_url(Config, <<"/headers?cache=3600">>),
- {ok, 200, Headers, _Body} = hackney:request(get, Url, [], <<>>, []),
- CacheControl = proplists:get_value(<<"cache-control">>, Headers),
+ {ok, #{status := 200} = Resp} = hornbeam_test_http:request(get, Url, #{}),
+ CacheControl = livery_client:header(<<"cache-control">>, Resp),
?assertEqual(<<"max-age=3600">>, CacheControl).
%%% ============================================================================
@@ -371,9 +371,9 @@ test_json_request(Config) ->
Url = make_url(Config, <<"/json">>),
ReqBody = <<"{\"key\": \"value\", \"number\": 42}">>,
Headers = [{<<"Content-Type">>, <<"application/json">>}],
- {ok, 200, RespHeaders, RespBody} = hackney:request(post, Url, Headers, ReqBody, []),
+ {ok, #{status := 200, body := {full, RespBody}} = Resp} = hornbeam_test_http:request(post, Url, #{headers => Headers, body => ReqBody}),
- ContentType = proplists:get_value(<<"content-type">>, RespHeaders),
+ ContentType = livery_client:header(<<"content-type">>, Resp),
?assertEqual(<<"application/json">>, ContentType),
Response = jsx:decode(RespBody, [return_maps]),
@@ -384,19 +384,19 @@ test_json_request(Config) ->
test_large_body(Config) ->
Url = make_url(Config, <<"/large?size=102400">>), % 100KB
- {ok, 200, _Headers, Body} = hackney:request(get, Url, [], <<>>, []),
+ {ok, #{status := 200, body := {full, Body}}} = hornbeam_test_http:request(get, Url, #{}),
?assertEqual(102400, byte_size(Body)).
test_empty_body(Config) ->
Url = make_url(Config, <<"/echo">>),
- {ok, 200, _Headers, Body} = hackney:request(post, Url, [], <<>>, []),
+ {ok, #{status := 200, body := {full, Body}}} = hornbeam_test_http:request(post, Url, #{}),
?assertEqual(<<>>, Body).
test_unicode_body(Config) ->
Url = make_url(Config, <<"/unicode">>),
- {ok, 200, Headers, Body} = hackney:request(get, Url, [], <<>>, []),
+ {ok, #{status := 200, body := {full, Body}} = Resp} = hornbeam_test_http:request(get, Url, #{}),
- ContentType = proplists:get_value(<<"content-type">>, Headers),
+ ContentType = livery_client:header(<<"content-type">>, Resp),
?assert(binary:match(ContentType, <<"utf-8">>) =/= nomatch),
%% Check various unicode characters are present
@@ -410,7 +410,7 @@ test_unicode_body(Config) ->
test_streaming_response(Config) ->
Url = make_url(Config, <<"/streaming?chunks=3&size=50">>),
- {ok, 200, _Headers, Body} = hackney:request(get, Url, [], <<>>, []),
+ {ok, #{status := 200, body := {full, Body}}} = hornbeam_test_http:request(get, Url, #{}),
%% Should have 3 chunks
?assert(binary:match(Body, <<"Chunk 1">>) =/= nomatch),
@@ -423,5 +423,5 @@ test_streaming_response(Config) ->
test_error_exception(Config) ->
Url = make_url(Config, <<"/error?type=exception">>),
- {ok, 500, _Headers, Body} = hackney:request(get, Url, [], <<>>, []),
+ {ok, #{status := 500, body := {full, Body}}} = hornbeam_test_http:request(get, Url, #{}),
?assert(binary:match(Body, <<"Internal Server Error">>) =/= nomatch).
diff --git a/test/test_apps/asgi_test_app.py b/test/test_apps/asgi_test_app.py
index 2c50558..6b37242 100644
--- a/test/test_apps/asgi_test_app.py
+++ b/test/test_apps/asgi_test_app.py
@@ -494,9 +494,11 @@ async def handle_unicode(scope, receive, send):
async def handle_early_hints(scope, receive, send):
"""Test 103 Early Hints response."""
- # Send 103 Early Hints (informational response)
+ # Send 103 Early Hints ahead of the final response. Interim
+ # responses go through http.response.informational, not
+ # http.response.start, which is reserved for the final head.
await send({
- 'type': 'http.response.start',
+ 'type': 'http.response.informational',
'status': 103,
'headers': [
[b'link', b'; rel=preload; as=style'],