Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
2e3a7fe
Add roadrunner framework with baseline and pipeline endpoints
williamthome May 11, 2026
258aada
Add JSON endpoint and dataset loader to roadrunner
williamthome May 11, 2026
093132f
Enable gzip compression middleware for json-comp profile
williamthome May 11, 2026
19dfa4f
Add TLS listener for json-tls profile
williamthome May 11, 2026
4bbb61d
Add upload endpoint for upload profile
williamthome May 11, 2026
401fa4b
Add sasl to applications list to silence relx upgrade warning
williamthome May 11, 2026
fa5791f
Add WebSocket echo handler for echo-ws profiles
williamthome May 11, 2026
c5ee460
Add async-db endpoint and Postgres connection pool
williamthome May 11, 2026
930795e
Add HTTP/2 listener for baseline-h2 profile
williamthome May 11, 2026
67e4d1f
Add limited-conn profile and HTTP/2 to description
williamthome May 11, 2026
33c1d0f
Add static file route via roadrunner_static
williamthome May 11, 2026
cacab85
Add fortunes endpoint with HTML escaping
williamthome May 11, 2026
379554c
Add CRUD endpoints with ETS cache for cache-aside profile
williamthome May 11, 2026
454535d
Expose TLS and HTTP/2 ports in Dockerfile
williamthome May 11, 2026
ab3c43e
Extract items row-to-JSON into shared module
williamthome May 11, 2026
291307a
Enable h2c profiles
williamthome May 11, 2026
0dd4c78
Bump roadrunner SHA and subscribe to static-h2
williamthome May 11, 2026
06f6a64
Capture benchmark-lite snapshot under bench-results.md
williamthome May 11, 2026
2dd3e56
Stream 20 MB upload via body_buffering=manual
williamthome May 11, 2026
9a670dd
Pin roadrunner to hex 0.1.0
williamthome May 17, 2026
cd280cb
Drop bench-results.md working artifact
williamthome May 17, 2026
3ea3a09
Register roadrunner in site/data/frameworks.json
williamthome May 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions frameworks/roadrunner/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
_build/
_checkouts/
.rebar3/
*.beam
*.swp
13 changes: 13 additions & 0 deletions frameworks/roadrunner/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
FROM erlang:29.0-alpine AS build
RUN apk add --no-cache ca-certificates git
WORKDIR /src
COPY rebar.config ./
COPY config ./config
COPY src ./src
RUN rebar3 as prod release

FROM alpine:3.23
RUN apk add --no-cache libstdc++ ncurses-libs openssl
COPY --from=build /src/_build/prod/rel/roadrunner_httparena /app
EXPOSE 8080 8081 8082 8443
CMD ["/app/bin/roadrunner_httparena", "foreground"]
26 changes: 26 additions & 0 deletions frameworks/roadrunner/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# roadrunner

[Roadrunner](https://github.com/arizona-framework/roadrunner) is the pure-Erlang HTTP/1.1 + HTTP/2 + WebSocket server from the Arizona framework. This entry packages a minimal OTP release wrapping roadrunner with the HttpArena endpoint contract.

## Profiles

Covered (19 of 28 profiles):

- `baseline`, `pipelined`, `limited-conn`
- `json`, `json-comp`, `json-tls`
- `upload`
- `async-db`, `api-4`, `api-16`
- `static`, `static-h2`
- `fortunes`, `crud`
- `baseline-h2`, `baseline-h2c`, `json-h2c`
- `echo-ws`, `echo-ws-pipeline`

Deferred (tracked under [HttpArena coverage gaps](https://github.com/arizona-framework/roadrunner/blob/main/docs/roadmap.md) in the roadrunner roadmap):

- `baseline-h3`, `static-h3`: roadrunner has no HTTP/3 stack yet.
- `unary-grpc`, `stream-grpc`, TLS variants: no gRPC stack.
- `gateway-64`, `gateway-h3`, `production-stack`: reverse-proxy multi-container scenarios; out of scope for the single-framework entry.

## Build

`docker build -t httparena-roadrunner frameworks/roadrunner` then run via `scripts/validate.sh roadrunner` from the repo root.
5 changes: 5 additions & 0 deletions frameworks/roadrunner/config/sys.config
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
[
{roadrunner_httparena, [
{http_port, 8080}
]}
].
31 changes: 31 additions & 0 deletions frameworks/roadrunner/meta.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
"display_name": "roadrunner",
"language": "Erlang",
"type": "production",
"engine": "roadrunner",
"description": "Roadrunner: pure-Erlang HTTP/1.1 + HTTP/2 + WebSocket server from the Arizona framework.",
"repo": "https://github.com/arizona-framework/roadrunner",
"enabled": true,
"tests": [
"baseline",
"pipelined",
"limited-conn",
"json",
"json-comp",
"json-tls",
"upload",
"async-db",
"api-4",
"api-16",
"static",
"fortunes",
"crud",
"baseline-h2",
"static-h2",
"baseline-h2c",
"json-h2c",
"echo-ws",
"echo-ws-pipeline"
],
"maintainers": ["williamthome"]
}
24 changes: 24 additions & 0 deletions frameworks/roadrunner/rebar.config
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{erl_opts, [debug_info]}.

{minimum_otp_vsn, "29"}.

{deps, [
{roadrunner, "0.1.0"},
{epgsql, "4.8.0"},
{pooler, "1.6.0"}
]}.

{relx, [
{release, {roadrunner_httparena, "0.1.0"},
[roadrunner_httparena, roadrunner]},
{sys_config, "./config/sys.config"},
{dev_mode, false},
{include_erts, true},
{extended_start_script, true}
]}.

{profiles, [
{prod, [
{relx, [{mode, prod}]}
]}
]}.
17 changes: 17 additions & 0 deletions frameworks/roadrunner/rebar.lock
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{"1.2.0",
[{<<"epgsql">>,{pkg,<<"epgsql">>,<<"4.8.0">>},0},
{<<"pooler">>,{pkg,<<"pooler">>,<<"1.6.0">>},0},
{<<"roadrunner">>,{pkg,<<"roadrunner">>,<<"0.1.0">>},0},
{<<"telemetry">>,{pkg,<<"telemetry">>,<<"1.4.2">>},1}]}.
[
{pkg_hash,[
{<<"epgsql">>, <<"C491B141B8C37BCE7B67F2079F168F339BB374A7CF9A286CB3B40AD1CD3FABE5">>},
{<<"pooler">>, <<"F4F33C94AB3AB82565A2E31CEA9EFE4149A160651F3707A0A2669BC54AAF81C8">>},
{<<"roadrunner">>, <<"ECC4CA6CFE2ED65661D57DE8FF53986850F62F882AAF76829150F8ED00954249">>},
{<<"telemetry">>, <<"A0CB522801DFFB1C49FE6E30561BADFFC7B6D0E180DB1300DF759FAA22062855">>}]},
{pkg_hash_ext,[
{<<"epgsql">>, <<"00E550006A62FB439FC7E879419443C889C34605E9B9DC406029D8BAA1AD79D8">>},
{<<"pooler">>, <<"748C988FD2928DE9577C882A49621863CAB57809E3E1A88A14C9D3B55C6AB877">>},
{<<"roadrunner">>, <<"1F393EBB706D61427F2C472A362030D45B0E85B59A680E6180154116C8BE4D6A">>},
{<<"telemetry">>, <<"928F6495066506077862C0D1646609EED891A4326BEE3126BA54B60AF61FEBB1">>}]}
].
18 changes: 18 additions & 0 deletions frameworks/roadrunner/src/roadrunner_httparena.app.src
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{application, roadrunner_httparena, [
{description, "HttpArena entry for the Roadrunner HTTP server"},
{vsn, "0.1.0"},
{registered, [roadrunner_httparena_sup]},
{mod, {roadrunner_httparena_app, []}},
{applications, [
kernel,
stdlib,
sasl,
ssl,
epgsql,
pooler,
roadrunner
]},
{env, []},
{modules, []},
{licenses, ["Apache-2.0"]}
]}.
81 changes: 81 additions & 0 deletions frameworks/roadrunner/src/roadrunner_httparena_app.erl
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
-module(roadrunner_httparena_app).
-behaviour(application).

-export([start/2, stop/1]).

start(_StartType, _StartArgs) ->
{ok, SupPid} = roadrunner_httparena_sup:start_link(),
ok = roadrunner_httparena_dataset:load(),
_ = roadrunner_httparena_db:start_pool(),
ok = roadrunner_httparena_crud:init(),
Routes = roadrunner_httparena_handler:routes(),
HttpPort = application:get_env(roadrunner_httparena, http_port, 8080),
{ok, _} = roadrunner:start_listener(httparena_http, #{
port => HttpPort,
routes => Routes,
middlewares => [roadrunner_compress],
%% 25 MB headroom for the upload profile (validator goes up to 20 MB).
max_content_length => 26214400,
%% Manual body buffering: handlers read the body themselves via
%% `roadrunner_req:read_body[_chunked]/1`. Lets the upload handler
%% stream chunks instead of buffering the entire 20 MB body in
%% the conn process before dispatch. Auto-mode handlers
%% (`baseline11` POST) still work transparently via `read_body/1`.
body_buffering => manual
}),
H2cPort = application:get_env(roadrunner_httparena, h2c_port, 8082),
{ok, _} = roadrunner:start_listener(httparena_h2c, #{
port => H2cPort,
routes => Routes,
middlewares => [roadrunner_compress],
max_content_length => 26214400,
%% h2c prior-knowledge: `[http2]` on a plain-TCP listener
%% serves h2 directly (client sends the h2 preface, no
%% `Upgrade: h2c` negotiation).
protocols => [http2],
body_buffering => manual
}),
case tls_opts() of
{ok, TlsOpts} ->
TlsPort = application:get_env(roadrunner_httparena, tls_port, 8081),
{ok, _} = roadrunner:start_listener(httparena_tls, #{
port => TlsPort,
routes => Routes,
middlewares => [roadrunner_compress],
max_content_length => 26214400,
tls => TlsOpts,
body_buffering => manual
}),
H2Port = application:get_env(roadrunner_httparena, h2_port, 8443),
{ok, _} = roadrunner:start_listener(httparena_h2, #{
port => H2Port,
routes => Routes,
middlewares => [roadrunner_compress],
max_content_length => 26214400,
tls => TlsOpts,
%% Listener derives `alpn_preferred_protocols` from
%% this list — `h2` preferred, fall back to `http/1.1`.
protocols => [http2, http1],
body_buffering => manual
});
skip ->
ok
end,
{ok, SupPid}.

stop(_State) ->
ok.

tls_opts() ->
Cert = env_path("TLS_CERT_PATH", tls_cert, "/certs/server.crt"),
Key = env_path("TLS_KEY_PATH", tls_key, "/certs/server.key"),
case filelib:is_regular(Cert) andalso filelib:is_regular(Key) of
true -> {ok, [{certfile, Cert}, {keyfile, Key}]};
false -> skip
end.

env_path(EnvVar, AppKey, Default) ->
case os:getenv(EnvVar) of
false -> application:get_env(roadrunner_httparena, AppKey, Default);
V -> V
end.
147 changes: 147 additions & 0 deletions frameworks/roadrunner/src/roadrunner_httparena_crud.erl
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
-module(roadrunner_httparena_crud).

-export([init/0]).
-export([list/1, get/1, create/1, update/1]).

-define(CACHE, httparena_crud_cache).

-spec init() -> ok.
init() ->
case ets:info(?CACHE, name) of
undefined ->
ets:new(?CACHE, [
public,
named_table,
{read_concurrency, true},
{write_concurrency, true}
]);
_ ->
ok
end,
ok.

list(Req) ->
Qs = roadrunner_req:parse_qs(Req),
Cat = qs_bin(~"category", Qs, ~"electronics"),
Page = max(1, qs_int(~"page", Qs, 1)),
Limit = clamp(qs_int(~"limit", Qs, 10), 1, 50),
Offset = (Page - 1) * Limit,
ListSql = ~"""
SELECT id, name, category, price, quantity, active, tags,
rating_score, rating_count
FROM items
WHERE category = $1
ORDER BY id
LIMIT $2 OFFSET $3
""",
CountSql = ~"SELECT COUNT(*) FROM items WHERE category = $1",
Items =
case roadrunner_httparena_db:query(ListSql, [Cat, Limit, Offset]) of
{ok, _, Rows} -> [roadrunner_httparena_items:row_to_json(R) || R <- Rows];
_ -> []
end,
Total =
case roadrunner_httparena_db:query(CountSql, [Cat]) of
{ok, _, [{N}]} -> N;
_ -> 0
end,
Body = #{~"items" => Items, ~"total" => Total, ~"page" => Page},
{roadrunner_resp:json(200, Body), Req}.

get(Req) ->
Id = id_from_path(Req),
case ets:lookup(?CACHE, Id) of
[{Id, Item}] ->
Resp = roadrunner_resp:json(200, Item),
{roadrunner_resp:add_header(Resp, ~"x-cache", ~"HIT"), Req};
[] ->
case roadrunner_httparena_db:query(read_sql(), [Id]) of
{ok, _, [Row]} ->
Item = roadrunner_httparena_items:row_to_json(Row),
ets:insert(?CACHE, {Id, Item}),
Resp = roadrunner_resp:json(200, Item),
{roadrunner_resp:add_header(Resp, ~"x-cache", ~"MISS"), Req};
_ ->
{roadrunner_resp:not_found(), Req}
end
end.

create(Req) ->
{ok, Body, Req2} = roadrunner_req:read_body(Req),
Input = json:decode(Body),
Id = maps:get(~"id", Input),
Sql = ~"""
INSERT INTO items (id, name, category, price, quantity,
active, tags, rating_score, rating_count)
VALUES ($1, $2, $3, $4, $5, true, '[]'::jsonb, 0, 0)
""",
case roadrunner_httparena_db:query(Sql, [
Id,
maps:get(~"name", Input),
maps:get(~"category", Input),
maps:get(~"price", Input),
maps:get(~"quantity", Input)
]) of
{ok, 1} ->
ets:delete(?CACHE, Id),
{roadrunner_resp:status(201), Req2};
_ ->
{roadrunner_resp:internal_error(), Req2}
end.

update(Req) ->
Id = id_from_path(Req),
{ok, Body, Req2} = roadrunner_req:read_body(Req),
Input = json:decode(Body),
Sql = ~"""
UPDATE items
SET name = $2, category = $3, price = $4, quantity = $5
WHERE id = $1
""",
case roadrunner_httparena_db:query(Sql, [
Id,
maps:get(~"name", Input),
maps:get(~"category", Input),
maps:get(~"price", Input),
maps:get(~"quantity", Input)
]) of
{ok, 1} ->
ets:delete(?CACHE, Id),
{roadrunner_resp:status(200), Req2};
{ok, 0} ->
{roadrunner_resp:not_found(), Req2};
_ ->
{roadrunner_resp:internal_error(), Req2}
end.

read_sql() ->
~"""
SELECT id, name, category, price, quantity, active, tags,
rating_score, rating_count
FROM items
WHERE id = $1
""".

id_from_path(Req) ->
binary_to_integer(maps:get(~"id", roadrunner_req:bindings(Req))).

qs_int(Key, Qs, Default) ->
case lists:keyfind(Key, 1, Qs) of
{Key, V} when is_binary(V) ->
case string:to_integer(V) of
{N, _} when is_integer(N) -> N;
_ -> Default
end;
_ ->
Default
end.

qs_bin(Key, Qs, Default) ->
case lists:keyfind(Key, 1, Qs) of
{Key, V} when is_binary(V) -> V;
_ -> Default
end.

clamp(N, Lo, _Hi) when N < Lo -> Lo;
clamp(N, _Lo, Hi) when N > Hi -> Hi;
clamp(N, _Lo, _Hi) -> N.
Loading