HTTP Server
Astra offers HTTP1/2 web server through the axum project. On the Lua's side, the server holds configuration and route details, which are then sent to the Rust for running them. Since it is running on Tokio, it can take advantage of all available resources automatically, making it easy for vertical scaling. Throughout this documentation, the word server is used to describe an HTTP web server table on the Lua's side. You can create one as such:
-- create a new server with
local server = require("http").server.new()
-- run the server with
server:run()
Configuration
Astra can be configured in a few ways for runtime. As of now there is no native TLS/SSL support and needs a reverse proxy such as Caddy to handle that. Check Deployment for more information.
However every configuration option will be available at the server instead. For example, changing the compression, port and hostname is as such:
-- configure the server with
server.compression = false
server.port = 8000
server.hostname = "0.0.0.0"
You can also configure other languages that compiles to Lua such as Fennel. Astra's api is for pure Lua however, so it will be up to you to make type definitions and make sure it can call the right functions and tables.
Routes
The server holds all of the route details. The routes are loaded at the start of the runtime and cannot be dynamically modified later on. There are also methods within the server that makes it easy to add new routes. For example:
-- A simple GET index route with text return
server:get("/", function()
return "hello from default Astra instance! " .. Astra.version
end)
The syntax are as follows:
server:ROUTE_TYPE(ROUTE_PATH, CALLBACK);
-- Where callback is:
function(request?, response?);
The following route types are supported as of now:
- GET
- POST
- PUT
- PATCH
- PARSE
- DELETE
- OPTIONS
- TRACE
All lowercase and snake_case when calling with astra of course. There are two additional ones available:
- STATIC_DIR
- STATIC_FILE
Which does as expected, serves a file or directory over a route.
Route Logic
Each route function needs a callback which contains a route's logic. This callback function optionally can have two arguments: request and response respectively, and may optionally have a return.
Interally requests and responses are each a struct in Rust initialized but not parsed/deserialized beforehand. This is to save performance overhead of serialization. However its content and be modified or accessed through their methods. We will discuss them later on.
Return types of the callback can optionally be either empty, string, or a table. The table responses are parsed in Rust and serialized to JSON, and then returned. Empty responses does not include any content. Responses, or lack of them, are by default sent with status code of 200.
Requests
Requests are provided as the first argument of the route callbacks as a table (not deseralized). Each request in the route callbacks can be accessed through its methods. The following methods are available:
- body:
Body - headers:
table<string, string> - params:
table<string, string | number> - uri:
string - queries:
table<any, any> - method:
string - multipart:
Multipart
where Body has:
- text:
string - json:
table
and where Multipart has:
save_file(file_path: string | nil)
Example:
server:get("/", function(req)
-- access the headers
pprint(req:headers())
-- print the body as text
print(req:body():text())
end)
Responses
Responses are the second argument provided in the route callback. They allow you to modify the response to the way you want. Each response has the default 200 OK status along content header based on your response. The following methods are available:
set_status_code(status_code: number)set_header(key: string, value: string)remove_header(key: string)get_headers():table<string, string>
Example:
server:get("/", function(req, res)
-- set header code
res:set_status_code(300)
-- set headers
res:set_header("header-key", "header-value")
return "Responding with Code 300 cuz why not"
end)
The headers, as stated, will include content type when sending to user, but can be changed while setting the type yourself.
Cookies
Cookies allow you to store data on each HTTP request, if supported. Astra does not currently support signed and private cookies. You can create a new cookie by getting it from a request:
server:get("/", function(request)
local cookie = request:new_cookie("key", "value")
return "HEY"
end)
You can also get a previously set cookie:
local cookie = request:get_cookie("key")
After modification or creation, they will have no effect unless you set them to the response
response:set_cookie("key", cookie)
And similary, remove them with
response:remove_cookie("key")
Each cookie contains extra details and functions which are as follows:
set_name(cookie: Cookie, name: string)
set_value(cookie: Cookie, value: string)
set_domain(cookie: Cookie, domain: string)
set_path(cookie: Cookie, path: string)
set_expiration(cookie: Cookie, expiration: number)
set_http_only(cookie: Cookie, http_only: boolean)
set_max_age(cookie: Cookie, max_age: number)
set_permanent(cookie: Cookie)
get_name(cookie: Cookie): string?
get_value(cookie: Cookie): string?
get_domain(cookie: Cookie): string?
get_path(cookie: Cookie): string?
get_expiration(cookie: Cookie): number?
get_http_only(cookie: Cookie): boolean?
get_max_age(cookie: Cookie): number?
WebSocket
Astra offers a WebSocket server powered by axum. Server creation takes a route similar to any other normal routes:
local server = require("astra.http").server:new()
local function handle_socket(socket)
print("Connection opened!")
end
server:websocket("/", handle_socket)
server:run()
After creating our Astra server, we have to create a function to handle WebSocket behaviour, this function is then used as a callback by the server:websocket() function, which first takes the route for our websocket server, then the callback function.
We can print out received messages from our client like this:
local function handle_socket(socket)
print("Connection opened!")
while true do
pprint(socket:recv())
end
end
You can send 5 types of messages from a WebSocket: text, bytes, ping, pong, and a close frame. The first 4 all take a string as their message, with bytes, ping, and pong also being able to take a table of 8-bit unsigned bytes, a close frame takes a close code and a reason, which is an optional string, both of which should be places inside a lua table.
-- The first parameter is a type:
-- text
-- bytes
-- ping
-- pong
-- close
socket:send("text", "hey there, this is a very informative message.")
-- There are also specialized functions for each type, which skip a lot of type checking to be more direct and concise:
socket:send_text("yet another informative message")
-- I can send a string of bytes:
socket:send_bytes("this will be illegible soon")
-- Or a table of them:
socket:send_ping({0, 88, 14, 67, 45})
socket:send_pong({17, 38, 80, 0, 85})
-- I can also send a close frame, first with the close code, and then the reason:
socket:send_close({code = 1000, reason = "finally, done with everything"})
-- The reason is optional, so you can also just go with this:
socket:send_close(1000)
-- If you're in a rush, you can close it uncleanly too, like this:
socket:send_close()
socket
Middleware
Middleware modifies the way a request is processed.
Since Lua is a very flexible language, there are lots of ways to implement middlewares.
We decided to take an advantage of Lua functions being a first-class values.
note
If you are familiar with this concept, feel free to go to the Full example at the bottom of the page.
Astra has several built-in middlewares. You can modify or extend middlewares directly in .astra/middleware.lua (after running astra export) or in your own files. We are opened to PRs.
For more details, see the middleware.lua and the middleware_example.lua.
Basic middleware
The following example shows the most basic middleware that changes the response headers.
local http = require("http")
local server = http.server:new()
local function sunny_day(request, response)
return "What a great sunny day!"
end
--- `on Leave:`
--- sets `"Content-Type": "text/html"` response header
local function html(next_handler)
return function(request, response, ctx)
local result = next_handler(request, response, ctx)
response:set_header("Content-Type", "text/html")
return result
end
end
server:get("/sunny-day-plain-text", sunny_day)
server:get("/sunny-day-html", html(sunny_day))
server:run()
Context
When we want to pass data through middleware, we can use the third argument and treat it as a context table.
local datetime = require("datetime")
local server = http.server:new()
---@param ctx { datetime: DateTime }
local function favourite_day(_request, _response, ctx)
local today = string.format(
"%d/%d/%d",
ctx.datetime:get_day(),
ctx.datetime:get_month(),
ctx.datetime:get_year()
)
return "My favourite day is " .. today
end
--- `on Entry:`
--- Inserts `datetime.new()` into `ctx.datetime`
---
--- `Depends on:`
--- `ctx`
local function insert_datetime(next_handler)
return function(request, response, ctx)
ctx.datetime = datetime.new()
return next_handler(request, response, ctx)
end
end
--- `on Entry:`
--- Creates a new `ctx` table and passes it as a third argument into the `next_handler`
local function ctx(next_handler)
return function(request, response)
local ctx = {}
return next_handler(request, response, ctx)
end
end
--- `on Leave:`
--- sets `"Content-Type": "text/html"` response header
local function html(next_handler)
return function(request, response, ctx)
local result = next_handler(request, response, ctx)
response:set_header("Content-Type", "text/html")
return result
end
end
server:get("/favourite-day", ctx(insert_datetime(html(favourite_day))))
server:run()
Chaining middlewares
To make it less tedious to compose middleware, we introduced the chain function, which combines all provided middleware into a single middleware.
note
Read more about why we can drop parenthesis while calling chain function here: Writing a DSL in Lua
local chain = http.middleware.chain
-- This will behave exactly the same as ctx(insert_datetime(html(favourite_day)))
server:get("/favourite-day", chain {ctx, insert_datetime, html} (favourite_day) )
-- We can create a common middlewares and reuse them
local composed_middleware = chain {ctx, insert_datetime, html}
server:get("/favourite-day-again", composed_middleware(favourite_day))
server:run()
Complex middleware
We can use Lua closures to create more complex middlewares.
This example shows how to create a file logger:
--- `on Entry:`
--- Logs request method and uri into the file
---@param file_handler file* A file handler opened with an append mode `io.open("filepath", "a")`
---@param flush_interval number? The number of log entries after which the file handler will be flushed
local function file_logger(file_handler, flush_interval)
local flush_interval = flush_interval or 1
local flush_countdown = flush_interval
return function(next_handler)
return function(request, response, ctx)
local str = string.format("[New Request %s] %s %s\n", os.date(), request:method(), request:uri())
file_handler:write(str)
flush_countdown = flush_countdown - 1
if flush_countdown == 0 then
file_handler:flush()
flush_countdown = flush_interval
end
return next_handler(request, response, ctx)
end
end
end
local file_handler, err = io.open("logs.txt", "a")
if not file_handler then error(err) end
local logger = file_logger(file_handler)
local common = chain { ctx, logger, html }
server:get("/sunny-day", common(sunny_day))
server:get("/normal-day", common(normal_day))
server:get("/favourite-day", chain { common, insert_datetime } (favourite_day))
server:run()
The logger we got from the file_logger is gonna be used in all routes we pass it as a middleware.
Full example
local http = require("http")
local datetime = require("datetime")
local server = http.server:new()
local chain = http.middleware.chain
local function sunny_day(_request, _response)
return "What a great sunny day!"
end
local function normal_day(_request, _response)
return "It's a normal day... I guess..."
end
---@param ctx { datetime: DateTime }
local function favourite_day(_request, _response, ctx)
local today = string.format(
"%d/%d/%d",
ctx.datetime:get_day(),
ctx.datetime:get_month(),
ctx.datetime:get_year()
)
return "My favourite day is " .. today
end
--- `on Entry:`
--- Creates a new `ctx` table and passes it as a third argument into the `next_handler`
local function ctx(next_handler)
return function(request, response)
local ctx = {}
return next_handler(request, response, ctx)
end
end
--- `on Entry:`
--- Inserts `datetime.new()` into `ctx.datetime`
---
--- `Depends on:`
--- `ctx`
local function insert_datetime(next_handler)
return function(request, response, ctx)
ctx.datetime = datetime.new()
return next_handler(request, response, ctx)
end
end
--- `on Leave:`
--- sets `"Content-Type": "text/html"` response header
local function html(next_handler)
return function(request, response, ctx)
local result = next_handler(request, response, ctx)
response:set_header("Content-Type", "text/html")
return result
end
end
--- `on Entry:`
--- Logs request method and uri into the file
---@param file_handler file* A file handler opened with an append mode `io.open("filepath", "a")`
---@param flush_interval number? The number of log entries after which the file handler will be flushed
local function file_logger(file_handler, flush_interval)
local flush_interval = flush_interval or 1
local flush_countdown = flush_interval
return function(next_handler)
return function(request, response, ctx)
local str = string.format("[New Request %s] %s %s\n", os.date(), request:method(), request:uri())
file_handler:write(str)
flush_countdown = flush_countdown - 1
if flush_countdown == 0 then
file_handler:flush()
flush_countdown = flush_interval
end
return next_handler(request, response, ctx)
end
end
end
local file_handler, err = io.open("logs.txt", "a")
if not file_handler then error(err) end
local logger = file_logger(file_handler)
server:get("/sunny-day", logger(html(sunny_day)))
server:get("/normal-day", chain { logger, html } (normal_day))
server:get("/favourite-day", chain { ctx, logger, insert_datetime, html } (favourite_day))
server:run()
Deployment
You can follow the steps covered in Configuration to setup the Astra itself.
Astra does not support TLS/SSL as of yet, but may support by the 1.0 release. However generally a reverse proxy service is recommended for deployment. We recommend Caddy as it is easy to setup and use, especially for majority of our, and hopefully your, usecases. What caddy also does is automatically fetching TLS certificates for your domain as well which is always a good idea. You can install caddy through your system's package manager.
Then open a new file with the name Caddyfile with the following content:
your_domain.tld {
encode zstd gzip
reverse_proxy :8080 {
# Can also pass extra details such as IP addresses
header_up X-Forwarded-For {client_ip}
header_up X-Real-IP {client_ip}
header_up X-Forwarded-Proto {scheme}
header_up X-Forwarded-Host {host}
}
}
and change your_domain.tld to your domain, and :8080 to the port you have set for your server. After this, make sure your 443 and 80 ports are open through your firewall. For a linux server running ufw you can open them by:
sudo ufw allow 80
sudo ufw allow 443
And finally run the caddy:
caddy run
Make sure your server is running before that. That is pretty much it for the basic deployment.
Fault Tolerance
Astra ensures fault tolerance through several methods internally and offers guidence on how you can ensure it on the Lua's endpoint as well.
Fault-tolerance essentially describes the ability to tolerate crashing errors and continue execution whereas otherwise caused the server to shut down. In Astra's internal case, this is ensured by removing all of the crashing points and handle every error that could occur during runtime. This was achieved through denying unwraps and expects throughout the codebase for the runtime. However there are still crashes on startup for cases that needs to be looked into, such as Lua parsing and errors, bad path, and/or system issues such as port being unavailable or unauthorized for Astra.
In Lua however, the errors are usually crash by default, which are still tolerated with Astra and does not shutdown the server. To handle the errors as values, where it allows you to ensure the server does not crash and the issues are handled, you can use features such as the pcall. This is always recommended over any other method. For Astra's case, there are usually chained calls that each can faily on their own as well, hence wrapping them in lambda functions or individually pcall wrapping them always is a good idea.
Shutdown
You can also shutdown your server using the :shutdown() method.