A Sinatra-style web framework for ris, built on tape memory: every request runs on its own tape, from its first byte to the last byte of its response, and all of it is freed at once when the response is sent. An application does nothing for it; routes return strings as usual. See Memory for how and what it saves.
Ported from iyi-web; the git history keeps the original authors and the iyi code it started from.
module app
import web/web/dsl::*
get "/" do |env|
"Hello, world!"
end
run
web serves HTTP/1.1 from native ris programs. Routes are blocks,
middleware is a class, and settings live in a single config value. The type
a route returns is checked when the program compiles.
- Each request on its own tape, freed at once: no collector work for it
- Routes for
GET,POST,PUT,PATCH,DELETE,OPTIONS,HEADandQUERY, with named and wildcard path segments - URL, query-string, form, JSON and
multipart/form-dataparameters, with file uploads - Before and after filters for every route, a path pattern or a single method
- Error pages per status code, automatic
404and405responses, and a500page for a route that panics - Composable routers with
mountandnamespace - Middleware for every request or for a path prefix
- Streaming responses and server-sent events
- WebSockets, with an
Origincheck and sends from any fiber - Static files with
ETag,304 Not Modified, gzip and byte ranges, streamed from disk - File downloads, cookies, redirects and early responses with
halt - Response compression, CORS and HTML form method override
- Templates with layouts, compiled into the program
- Command-line flags for host and port
- Persistent connections, each served by its own fiber
- Graceful shutdown on
INTandTERM, and timeouts for slow and idle clients - IPv4, IPv6 and unix-socket listeners
ris 0.1.0 or later.
web is a ris module. Add it to your project's ris.mod:
ris get github.com/domify/webThis fetches the latest release, records it in ris.sum and writes one line to
ris.mod:
require github.com/domify/web v0.1.0
Files then name web's modules by their full path. One import line loads
a module and brings its names into scope:
import github.com/domify/web/web/dsl::*
The examples in this README write the path once, in ris.mod, under the short
name web. Pass --as web when adding web, or run the same command in a
project that already requires it:
ris get github.com/domify/web --as webor add as web to the require line by hand:
require github.com/domify/web v0.1.0 as web
web/web/... then means github.com/domify/web/web/...:
import web/web/dsl::*
import web/web/router::{Router}
ris get -u moves to the latest release and
ris get github.com/domify/web@v0.1.0 to a given one; both keep the
short name. ris get -u --check says whether a newer release exists without
changing anything.
ris reads ris.mod from the directory of the file being built, so keep the
entry file, and every *_test.ri that uses web, beside it. Modules of
your own can live in subdirectories.
Start a project, add web, and save the program at the top of this page as
app.ri:
mkdir app && cd app
ris init app
ris get github.com/domify/web --as web
ris run app.riris init writes ris.mod together with a starter main.ri, greet.ri
and main_test.ri, which can be deleted.
The server listens on http://localhost:3000. Pass -p 8080 or set PORT to
use another port.
The examples below extend this program. Each goes between the import line and
run, and any import lines it shows go at the top of the file.
get "/articles" do |env|
"All articles"
end
post "/articles" do |env|
env.response.status_code = 201
"Created"
end
get "/articles/:id" do |env|
"Article " + env.params.url["id"]
end
delete "/articles/:id" do |env|
env.response.status_code = 204
nil
end
get "/assets/*path" do |env|
"Asset " + env.params.url["path"]
end
put, patch, options, head and query work the same way. Paths start
with /.
:namematches one path segment:/articles/42setsidto42.*namematches the rest of the path:/assets/css/site.csssetspathtocss/site.css.- A trailing slash does not matter:
/articles/is/articles. - Two routes may name the same segment differently:
/users/:idand/users/:user_id/postseach read their own name. - Defining the same method and path twice stops the program at startup.
A request that matches no route gets 404 Not Found. A path that exists only
for other methods gets 405 Method Not Allowed with an Allow header. Every
GET route also answers HEAD, without a body.
The value a route returns is the response body. A route may return any type
that implements IntoBody; String, Nil (an empty body), Int32, Int64
and Bool do. The check happens at compile time, on the route. Implement
IntoBody to return your own types:
import web/web/body::{IntoBody}
struct Temperature
def initialize(@celsius : Int32)
end
def celsius : Int32
@celsius
end
end
impl IntoBody for Temperature
def into_body : String
celsius.to_s + " °C"
end
end
get "/temperature" do |env|
Temperature.new(21)
end
get "/search" do |env|
term = env.params.query["q"]? || ""
"Results for " + term
end
post "/subscribe" do |env|
email = env.params.body["email"]? || ""
"Subscribed " + email
end
| Source | Access |
|---|---|
Route segments (:id, *path) |
env.params.url |
| Query string | env.params.query |
application/x-www-form-urlencoded or multipart/form-data fields |
env.params.body |
application/json and application/*+json bodies |
env.params.json |
| Uploaded files | env.params.files, env.params.all_files |
url, query and body are Hash(String, String) tables of percent-decoded
values; env.params["name"]? searches them in that order. Indexing without
? panics when the key is missing, so use []? for optional values.
env.params.raw_body is the body as it arrived.
A name sent more than once keeps its last value in the tables.
env.params.query_all("tag") and env.params.body_all("tag") return every
value, in the order sent, and an empty array when there is none; body_all
covers form and multipart text fields alike. A name is matched as sent, so
?tag[]=a&tag[]=b is query_all("tag[]"):
get "/posts" do |env|
tags = env.params.query_all("tag") # ?tag=iyi&tag=web => ["iyi", "web"]
"Tagged " + tags.join(", ")
end
A JSON object's members are in env.params.json, a Hash(String, JSON::Any);
a top-level array is under the key "_json". A request that declares JSON
and sends something that does not parse gets 400 Bad Request before the
route runs.
post "/avatar" do |env|
if upload = env.params.files["avatar"]?
env.text("Received " + upload.filename + ", " + upload.size.to_s + " bytes")
else
env.halt(422, "No file")
end
end
Each uploaded file is written to a temporary file, in a new directory only
the server's account can open: upload.path, upload.filename,
upload.content_type, upload.size, upload.headers and upload.read
describe it, and the file is deleted once the response has been
sent. Files sent under a name ending in [] are all kept, in
env.params.all_files["photos[]"]. The filename is the client's, unchanged:
never join it to a directory path without cleaning it first.
A malformed multipart/form-data body gets 400. More file parts than
max_file_uploads, or a text field larger than
max_multipart_form_field_size, gets 413 Content Too Large.
env.request exposes method, path, query_string, headers, body,
content_type, content_length, host and version. Header lookups ignore
case:
get "/agent" do |env|
env.request.headers["User-Agent"]? || "unknown"
end
Set the status, headers and content type on env.response:
get "/report.csv" do |env|
env.response.content_type = "text/csv; charset=utf-8"
env.response.headers["Cache-Control"] = "no-store"
"id,total\n1,42\n"
end
Responses default to 200 and text/html; charset=utf-8. The server sets
Content-Length, Date and Connection. env.response.headers.add appends
a header instead of replacing it, and headers env, {"X-A" => "1"} sets
several at once. A header name or value containing a line break or another
control character is refused with a panic, so a value built from user input
cannot forge a second header.
These helpers set the content type and the body in one call; each also takes
a content_type: argument:
| Helper | Content-Type |
|---|---|
env.text(body) |
text/plain; charset=utf-8 |
env.html(body) |
text/html; charset=utf-8 |
env.json(body) |
application/json; charset=utf-8 |
env.xml(body) |
application/xml; charset=utf-8 |
env.json sends a String as it is and serialises anything else, such as a
Hash, an Array or a type that implements ToJSON. env.status(code) sets
the status and returns env, so the two combine:
post "/api/items" do |env|
env.status(201).json({"created" => true})
end
std/json parses documents with JSON.parse? and writes them with
JSON.build or JSON.to_json:
import std/json::{JSON}
get "/api/items/:id" do |env|
item = JSON.build do |json|
json.object do
json.field("id", env.params.url["id"])
json.field("status", "active")
end
end
env.json(item)
end
post "/api/echo" do |env|
env.json(env.params.json)
end
get "/home" do |env|
env.redirect("/")
end
get "/legacy" do |env|
env.redirect("/articles", 301)
end
get "/private" do |env|
halt env, status_code: 403, response: "Forbidden"
end
redirect responds with 302 unless given a status; body: adds a body and
close: false keeps the chain running. env.halt(403, "Forbidden") and
halt env, status_code: 403, response: "Forbidden" are the same. After a
redirect or a halt, the rest of the chain is skipped: remaining filters and
the route do not run, the route's return value is discarded, and error pages
are not applied.
post "/sign-in" do |env|
env.set_cookie("session", "3f9a1c", http_only: true, secure: true, max_age: 86400, same_site: "Lax")
env.redirect("/account")
end
get "/account" do |env|
session = env.cookies["session"]?
session ? "Signed in" : "Signed out"
end
post "/sign-out" do |env|
env.delete_cookie("session")
env.redirect("/")
end
set_cookie also takes path (default /), domain and expires (a
Time); with neither max_age nor expires it makes a session cookie.
Values are percent-encoded where cookies do not allow a character and
decoded when read, so any string round-trips. delete_cookie needs the same
path and domain the cookie was set with.
get "/download/report" do |env|
env.send_file("reports/latest.pdf", filename: "report.pdf")
end
get "/download/note" do |env|
env.send_data("Generated for you\n", filename: "note.txt")
end
The content type follows the file extension unless mime_type: is given.
filename: marks the response as a download; disposition: "inline" shows
it instead. Non-ASCII names are encoded for every browser. A Range request
gets 206 Partial Content or 416. A missing file responds with 404.
send_data sends bytes held in memory the same way, typed by filename:.
send_file reads the file as it sends it. A file or range larger than 64 KiB
(FILE_CHUNK_SIZE) goes out in 64 KiB chunks behind its exact
Content-Length, so a download costs 64 KiB of memory whatever its size;
headers set after send_file are not sent for it. A HEAD request is
answered from the file's size without reading it. A file cut short while it
is being sent ends the connection, so the client sees the body as incomplete.
env.response.flush sends what the route has written so far and keeps the
response open; on HTTP/1.1 the body is sent chunked.
get "/export.csv" do |env|
env.response.content_type = "text/csv; charset=utf-8"
env.response.print("id,total\n")
env.response.flush
(1..3).each { |id| env.response.print(id.to_s + "," + (id * 10).to_s + "\n") }
nil
end
Headers changed after the first flush are not sent.
A page rendered onto the request's tape goes out with
env.response.send(text : Str), straight from the tape and never
copied to the heap. The server opens the tape for each request, so the
route opens none:
get "/page" do |env|
page = Str.build(4096) { |buf| render_page(buf) }
env.response.send(page)
nil
end
send writes the status line and headers, with the Content-Length, at
once. So middleware that runs after the route can still read the status, as
Log does, but can't change the headers or the body. Compress leaves a
sent body as it is. Set headers before send, compress at the proxy
(Thruster, nginx), and answer 304 before rendering, from what the page is
made of rather than from its body.
sse "/clock" do |stream, env|
3.times do |tick|
stream.send("tick " + tick.to_s, event: "tick", id: tick)
sleep(1000)
end
end
sse registers a GET route answering text/event-stream. send takes
event:, id: and retry: (a Span), splits multi-line data into data:
lines and flushes; comment sends a keep-alive line clients ignore. An event
name or id containing a line break panics. The stream ends when the block
returns. A browser reads it with new EventSource("/clock").
import web/web/dsl::*
import web/web/websocket::{WebSocket}
class Room
@members : Array(WebSocket)
def initialize
@members = [] of WebSocket
end
def join(socket : WebSocket) : Nil
@members << socket
end
def leave(socket : WebSocket) : Nil
@members.delete(socket)
end
def broadcast(text : String) : Nil
@members.each { |member| member.send(text) }
end
end
ROOM = Room.new
ws "/chat/:name" do |socket, env|
name = env.params.url["name"]
ROOM.join(socket)
socket.on_message { |text| ROOM.broadcast(name + ": " + text) }
socket.on_close { |code, reason| ROOM.leave(socket) }
end
ws registers a WebSocket endpoint (RFC 6455); Router#ws does the same on a
router. A GET with Upgrade: websocket runs the before filters for GET,
then gets 101 Switching Protocols. The block then receives the socket and the
context and sets the callbacks, and the connection's fiber reads frames until
the socket closes. The connection carries no more HTTP requests and has no read
timeout.
- Callbacks:
on_message(text),on_binary(the bytes as aString),on_ping,on_pongandon_close(code, reason). - Sending:
send(text),send_binary(bytes),ping,pongandclose(code = 1000, reason = ""). Each returnsfalseonce the socket is closed or its client has gone, and affects nothing else. Any fiber may send, as a broadcast from another connection does; frames never interleave. - A ping is answered with a pong, and fragments arrive as one message. Text that
is not UTF-8 closes the connection with
1007, an unmasked or malformed frame with1002, a message overwebsocket_max_message_sizewith1009, and a callback that panics with1011.on_closegets1006when the client left without a close frame. - Another method gets
405unless an HTTP route serves it. A missingConnection: Upgrade, HTTP/1.0 or a badSec-WebSocket-Keygets400, anOriginthat is not allowed403, and aSec-WebSocket-Versionother than 13 gets426withSec-WebSocket-Version: 13. A refused handshake closes the connection. - A
GETwithoutUpgrade: websocketgoes to the HTTP route on the same path, or gets426 Upgrade Requiredwhen there is none.
Origin is checked against websocket_allowed_origins. Empty, the default,
means same-origin: the Origin has to name the request's Host. A request
without Origin, which most clients outside a browser send, is refused unless
the list holds "*". A browser connects with
new WebSocket("ws://localhost:3000/chat/ada").
before_all do |env|
env.response.headers["X-Content-Type-Options"] = "nosniff"
end
before_all "/admin/*" do |env|
env.halt(401, "Unauthorized") unless env.cookies.has_key?("session")
end
after_get ["/api/*", "/reports/*"] do |env|
env.response.headers["Cache-Control"] = "no-store"
end
before_allandafter_allrun for every method.before_get,after_postand their siblings run for one method:get,post,put,patch,delete,optionsorquery.- The path defaults to
*, every path./admin/*matches/adminand every path under it;/users/:idmatches one segment in place of:id; any other path matches exactly. A list registers the same block for each path. - Filters run in declaration order.
before_allfilters also run for a request no route matched, before its404or405; the others run only for a matched route. - A before filter that calls
haltorredirectstops the request before the route runs. One that sets an error status with an error page gets that page instead of the route. - A filter's return value is discarded, but like a route's it must implement
IntoBody.
Filters, middleware and routes pass values along a request with env.set,
env.get and env.get?, which hold a String, Int32, Int64, Float64,
Bool or nil. env["key"] reads and writes strings only:
before_all "/account/*" do |env|
env.set("user_id", 42)
end
get "/account/orders" do |env|
"Orders for user " + env.get("user_id").to_s
end
error 404 do |env|
"<h1>Page not found</h1>"
end
error 422 do |env|
"<h1>Please check the form and try again</h1>"
end
error 500 do |env|
"<h1>Something went wrong</h1>"
end
post "/signup" do |env|
email = env.params.body["email"]? || ""
env.response.status_code = 422 if email.empty?
"Welcome"
end
An error page replaces the body of 404 and 405 responses from the router,
and of any response a route finishes with a status of 400 or above. The
status code is kept. Responses ended with halt are sent unchanged.
A route that panics gets 500 Internal Server Error and its connection is
closed; other connections are not affected, and the panic is logged. The
error 500 page is used when there is one. Otherwise the built-in page shows
the panic message in development and hides it in every other environment;
config.show_exceptions = true or false overrides that.
import web/web/router::{Router}
admin = Router.new
admin.get "/" do |env|
"Dashboard"
end
admin.namespace "/reports" do |reports|
reports.get "/daily" do |env|
"Daily report"
end
end
mount "/admin", admin
This serves /admin and /admin/reports/daily. A Router has the same
route, sse, filter (before, after, before_get, …) and error
methods as the program. mount without a path mounts a router at the root,
and namespace passes the nested router to its block.
Larger applications keep routers in their own modules. A module does not need the DSL to define one:
# routes/health.ri
module routes/health
import web/web/router::{Router}
pub def health_routes : Router
router = Router.new
router.get "/health" do |env|
"ok"
end
router
end
import routes/health::*
mount health_routes
Requests pass through request logging, static files, the middleware added with
use in the order it was added, and then the router.
import web/web/cors::{CORSHandler}
import web/web/override::{OverrideMethodHandler}
gzip true
use OverrideMethodHandler.new
use "/api", CORSHandler.new(origin: "https://app.example.com")
use handler runs a handler for every request; use "/api", handler or
use "/api", [first, second] runs it for /api and every path under it.
use handler, 0 places a handler at an index of the finished chain, 0 being
before request logging.
| Handler | Module | Purpose |
|---|---|---|
CompressHandler |
web/web/compress |
Compresses textual responses of 860 bytes or more with gzip or deflate when Accept-Encoding allows it. gzip true adds it. |
CORSHandler |
web/web/cors |
Sets Access-Control-Allow-Origin and answers preflight requests with 204. Takes origin (default *), methods, headers (default *) and max_age (seconds as a string, default "86400"). |
OverrideMethodHandler |
web/web/override |
Treats a form POST with _method set to PUT, PATCH or DELETE as that method. |
LogHandler |
web/web/log |
Writes one line per request to config.logger. Added by default; logging false removes it. |
StaticHandler |
web/web/static |
Serves the public folder. Added by default; serve_static false removes it. |
To write your own, subclass Handler, override call, and pass the request
on with call_next. A handler that does not call call_next ends the chain.
only and exclude record which paths and methods a handler is for, using
the filter path syntax; only_match? and exclude_match? answer for the
current request:
import web/web/handler::{Handler}
import web/web/context::{Context}
class RequireToken < Handler
@token : String
def initialize(@token : String)
super()
only ["/api/*"], "*"
exclude ["/api/health"], "*"
end
pub def call(ctx : Context) : Nil
return call_next(ctx) unless only_match?(ctx)
return call_next(ctx) if exclude_match?(ctx)
if ctx.request.headers["Authorization"]? == "Bearer " + @token
call_next(ctx)
else
ctx.halt(401, "Unauthorized")
end
end
end
token = Program.env("API_TOKEN") || raise "API_TOKEN is not set"
use RequireToken.new(token)
Files under ./public are served for GET and HEAD requests before
routing, so a file takes precedence over a route with the same path:
- The content type follows the extension.
ETagandLast-Modifiedare sent, and a matchingIf-None-MatchorIf-Modified-Sincegets304 Not Modified. - A textual file of 860 bytes to 1 MiB is sent gzip- or deflate-compressed to
a client that accepts it. Any other file is read from disk as it is sent,
like
send_file, and aRangerequest gets206or416. - A directory serves its
index.html, after redirecting to the path with a trailing slash. Paths containing..are never served.
public_folder "/srv/app/public" changes the folder, resolving relative
paths against the working directory, and serve_static false turns static
files off. serve_static({"gzip" => true, "dir_listing" => true}) sets the
options; a key left out counts as false, and dir_index controls the
index.html lookup. static_headers runs for every served file:
static_headers do |env, path, info|
env.response.headers["Cache-Control"] = "public, max-age=3600"
end
import std/html::{HTML}
get "/profile/:name" do |env|
name = HTML.escape(env.params.url["name"])
render "views/profile.eiy", "views/layout.eiy"
end
views/profile.eiy:
<% content_for "title" do %>Profile of <%= name %><% end -%>
<h1>Hello, <%= name %></h1>views/layout.eiy:
<!DOCTYPE html>
<html>
<head><title><%= yield_content "title" %></title></head>
<body><%= content %></body>
</html>Templates use the .eiy format: <%= expr %> prints a value and
<% code %> runs ris code. They are compiled into the program, and see the
local variables where render is written.
render "view.eiy"returns the rendered text.render "view.eiy", "layout.eiy"renders the view, then the layout with the view ascontent.content_for "key" do ... endcaptures markup in the view, andyield_content "key"prints it in the layout; a key never captured prints nothing.- A template path is relative to the file that calls
render, or, for a template rendered inside another template, to that template. - Output is not escaped automatically; escape untrusted text with
HTML.escape.
import web/web/config::{Config}
options = Config.new("storefront")
options.max_request_body_size = 1024 * 1024
| Setting | Default | Shorthand |
|---|---|---|
host |
"0.0.0.0" |
host "127.0.0.1", -b 127.0.0.1 |
port |
PORT, else 3000 |
run 8080, -p 8080 |
env |
WEB_ENV, else "development" |
|
public_folder |
"./public" |
public_folder "assets" |
serve_static |
true |
serve_static false |
logging |
true |
logging false |
logger |
StdoutLogger.new |
logger MyLogger.new |
powered_by_header |
false |
powered_by true |
show_exceptions? |
true in development |
config.show_exceptions = false |
keepalive |
true |
|
max_keepalive_requests |
100 |
|
max_request_body_size |
8 * 1024 * 1024 |
|
max_file_uploads |
128 |
|
max_multipart_form_field_size |
8 * 1024 * 1024 |
|
max_ranges |
16 |
|
shutdown_timeout |
30 |
|
shutdown_message |
true |
|
read_timeout |
60 |
|
write_timeout |
60 |
|
idle_timeout |
75 |
|
unix_socket |
nil |
|
websocket_allowed_origins |
[] of String (same-origin) |
|
websocket_max_message_size |
8 * 1024 * 1024 |
|
app_name |
"web" |
max_keepalive_requestscaps the requests served on one connection before it is closed.- A request whose body is larger than
max_request_body_sizebytes gets413 Content Too Large, as soon as itsContent-Lengthis read, and its connection is closed. max_rangesis the most ranges oneRangeheader may ask for;0turns range requests off.websocket_allowed_originslists origins such as"https://example.com";"*"admits every request and"null"a sandboxed page.websocket_max_message_sizecounts a fragmented message whole.app_nameandenvappear in the line printed at startup. Withenvset totest,runbinds the port without serving requests.powered_by trueaddsX-Powered-By: webto every response.unix_socketis a path to listen at instead ofhostandport, for a proxy on the same machine:config.unix_socket = "/run/app.sock". The file must not exist yet; it is removed when the server stops.- Timeouts are in seconds, and
nilor0turns one off. A client hasread_timeoutto send a whole request, headers and body, counted from its first byte (from the connection, for its first request); a request cut short gets408 Request Timeoutand its connection is closed, and a connection that sent nothing is closed.idle_timeoutis how long a keep-alive connection waits for its next request.write_timeoutis how long one write waits for the client to take bytes; one that runs out ends the connection, so a stream lasts as long as its client keeps reading.
Each request is logged as one line, GET /users 200 1.234ms, after its
response has been built; a request whose handler panicked is logged with its
500. log "message" writes a line of the application's own. Both go to
config.logger, standard output by default, and logging false silences
both.
A logger is a subclass of Logger from web/web/log with
write(message); override request(ctx, elapsed) as well to choose what a
request line holds:
import std/time::{Span}
import web/web/log::{Logger}
import web/web/context::{Context}
class JSONLogger < Logger
pub def request(ctx : Context, elapsed : Span) : Nil
write(%({"method":"#{ctx.request.method}","path":"#{ctx.request.path}","status":#{ctx.response.status_code}}))
end
pub def write(message : String) : Nil
STDERR.puts message
end
end
logger JSONLogger.new
run reads the program's arguments:
./app -b 127.0.0.1 -p 8080
./app --help-b HOST (--bind) and -p PORT (--port) set the host and port;
run 8080 still wins over -p. The host may be an IPv6 address, bare or
bracketed (-b ::1, -b '[::1]'); :: takes IPv6 connections only. An
unknown flag or an invalid port stops the program with the reason and the
list of flags. extra_options adds your own:
extra_options do |parser|
parser.on("--public DIR", "Folder to serve static files from") { |dir| public_folder(dir) }
end
run(args: nil) ignores the command line.
INT (Ctrl-C) or TERM stops the server as stop does: it takes no new
connections, closes the idle ones, and lets each request in flight finish,
closing its connection after the response. run returns once they have, or
after shutdown_timeout seconds, when the rest are cut off: their fibers are
cancelled and their connections closed. A second signal cuts them off at
once. A server-sent-events stream is in flight for as long as it is open.
With shutdown_message on, the stop prints
[production] storefront is going to take a rest!. Code after run runs.
ris build --release app.ri -o app
WEB_ENV=production ./app -p 8080- web speaks plain HTTP/1.1. Terminate TLS at a reverse proxy such as
nginx, Caddy or HAProxy; it can reach the server over a unix socket
(
unix_socket). Keepidle_timeoutabove the proxy's own keep-alive timeout, so that the proxy is the one to close an idle connection. - A client that disconnects while its response is being written ends that request only; the socket layer reports it as a panic line on standard error.
web/web/harness sends requests through a router in-process, without a
socket:
# health_test.ri
module health_test
import web/web/router::{RouteHandler}
import web/web/harness::*
import routes/health::*
handler = RouteHandler.new(health_routes)
response = dispatch(handler, "GET", "/health").response
assert response.status_code == 200
assert response.body == "ok"
ris testA test is a *_test.ri program that exits non-zero on failure, and
ris test runs every one in the project. A test that uses web sits beside
ris.mod, like the entry file. http_request and form_request build
requests with a query string, a body or a content type. handler_chain from
web/web/dsl is the whole application as run serves it (logging, static
files, middleware and routes), for tests that need more than one router.
Paths as written with the short name web. Without a short name, a module is
written with the whole path: github.com/domify/web/web/dsl.
| Module | Provides |
|---|---|
web/web/dsl |
Routes, filters, error, mount, use, sse, ws, render, run and the setting shorthands |
web/web/router |
Router, RouteHandler |
web/web/context |
Context |
web/web/request |
Request |
web/web/response |
Response |
web/web/params |
Params |
web/web/multipart |
FileUpload and the multipart/form-data parser |
web/web/cookies |
CookieJar |
web/web/headers |
Headers |
web/web/body |
IntoBody |
web/web/handler |
Handler, chain |
web/web/config |
Config |
web/web/cli |
CLIParser |
web/web/event_stream |
EventStream |
web/web/websocket |
WebSocket, Frame, FrameError |
web/web/templates |
render, content_for, yield_content |
web/web/compress |
CompressHandler and the Accept-Encoding helpers |
web/web/range |
Range header parsing and 206 responses |
web/web/file_body |
respond_file, FILE_CHUNK_SIZE: a file sent from disk, whole or by range |
web/web/cors |
CORSHandler |
web/web/override |
OverrideMethodHandler |
web/web/static |
StaticHandler |
web/web/log |
LogHandler, Logger, StdoutLogger |
web/web/harness |
http_request, form_request, dispatch, routes |
examples/ holds small programs, each on one topic. They import web by
its path inside this repository, web/dsl where an application writes
web/web/dsl, so run them from the repository root; arguments for the
program go after --:
ris run examples/json_api.ri
ris run examples/json_api.ri -- -p 8080| Example | Shows |
|---|---|
hello.ri |
The smallest application |
json_api.ri |
CRUD over an in-memory store with env.json, env.params.json, status codes and 404 via halt |
forms_and_uploads.ri |
Form fields, multipart uploads with limits, and _method override |
templates.ri |
Views in examples/views/ with a layout, content_for and a partial |
routers.ri |
Router, mount, namespace, router filters and error pages |
middleware.ri |
A custom Handler with only and exclude, CORS, gzip and static files from examples/public/ |
streaming_and_sse.ri |
flush, chunked responses and server-sent events |
websocket_chat.ri |
A chat room over ws, broadcasting to every member |
cookies_and_context.ri |
Cookies, redirects and env.set / env.get |
testing.ri |
Routes in their own module, tested in-process by testing_test.ri |
Each file's opening comment lists curl commands to try. ris test examples
runs the example test.
demo/ is a whole application as another project writes it: its own
ris.mod requires web under the short name web, and a replace line
builds it from this checkout. A guestbook with a form, a template, a JSON
API and a stylesheet:
cd demo && ris run app.riris's tape memory is one mapping per fiber that values are bumped into and
freed together with one store when their tape block ends. web gives each
request one:
- The connection waits for a request on its stack, with no tape, so an idle keep-alive connection holds none.
- The request's bytes are read onto the tape.
std/http'sparse_headreads the request line and the fields in place, as views into those bytes, and the framing (Content-Length,Transfer-Encoding,Expect) is read from them. Nothing of the head is copied to parse it. - The handler runs on heap objects: the request, its context and parameters and the response are classes that application code keeps and changes, and a tape value may not be a class's field. They are made from the views, copying only what they keep.
- The status line, the headers and the body of a buffered response are
written onto the tape and sent in one write. The
Dateheader is formatted once a second, reading the clock through the vDSO.
A browser's request in-process (ris run --release bench/stages.ri, no
socket, fastest of seven, one core), step by step:
| Step | Time | Heap |
|---|---|---|
| Ported, heap only | 10,797 ns | 6,196 B |
Head parsed on the tape (parse_head) |
5,799 ns | 2,489 B |
Framing from the parsed head, Date once a second |
4,145 ns | — |
| Clock through the vDSO | 3,840 ns | — |
| Compiled with LLVM 23 | 3,459 ns | 2,069 B |
Over a socket (bench/run.sh, 64 keep-alive connections with ab,
median of seven, 16-core Linux, 2026-10-16), the response and then the
request on tape gave +10.5% requests per second on a 12-byte page, +19% on
a 40 KB page and -7% peak RSS.
A page rendered on the request's tape for each request (bench/run.sh 7 /render /send, a 42 KB table of 1,000 escaped rows, median of seven,
interleaved, 2026-10-17): copied to the heap with to_s and returned,
19,706 requests per second; sent from the tape with Response#send,
24,632 (+25%). Peak RSS is 10.9 MB against 10.2 MB: each connection's
tape holds its page until the response is out. send hands the socket
the head and the page as two pieces of one write (IyiSocket#write(first, second), a sendmsg), so the page is not copied. Written apart, the
page waited on the client's delayed ACK: 1,586 requests per second.
Joined in one buffer on the tape, 23,349 and 11.1 MB.
A handler already runs inside the request's tape block, and a nested
tape block shares that tape at no cost. Work that makes many short-lived
pieces, such as a large page, can build on it and copy only the result to
the heap:
get "/report" do |env|
page = ""
tape do
html = Str::Builder.new(8192)
ROWS.each do |row|
html.append("<tr><td>")
html.append_html_escaped(row.name)
html.append("</td></tr>")
end
page = html.finish.to_heap
end
page
end
A 200-row table built this way took 16,700 ns and 10.6 KB of heap, against
51,600 ns and 67 KB with String.build and HTML.escape. A tape value
may not leave its scope: copy what you keep with to_s, into a variable of
the code around the scope.
Response#to_http returns a response's bytes as a String, for tests and
code that keeps them.
./check.sh
ris run examples/hello.riweb is released under the MIT License.