Introduction
Versions
This documentation is on the latest version of Astra and will store a version for every x.0.0 release. The other versions will be listed below here when available.
About
ArkForge Astra is an easy-to-use, fault-tolerant, extendible, and high performant web server runtime targeting Lua (5.1 upto 5.4), LuaJIT (LuaJIT and LuaJIT 5.2), and Luau, and built upon Rust. Astra takes advantages of the Rust's performance, correctness and memory safety offering, and with a rich standard library making full use of the Tokio async runtime, to write fault-tolerant and no-build servers with ease.
Currently Astra is used within the ArkForge for some internal and external products. However being very young and early project, it lacks battle testing. Even so, it does not mean you cannot build software with it, as the base, Rust and Lua, is already mature and Astra is a thin wrapper over it.
Philosophy
The goal is to have the cake and eat it too. Obtaining the low-level advantages of Rust while having the iteration, ease and development speed of Lua. This way you can both have a small runtime and iterate over your products and servers with a very simple CI setup that ships in seconds, or even direct SSH. This is also called no-build, as there is no building and packaging stage required.
In an ideal world, Astra will be able to handle majority of the common use cases, which are basic REST servers that uses a PostgreSQL or SQLite DB if needed, single server instance, and manage hundreds of thousands of users per second upon it.
Astra's development style is to be as minimalist and simple as we can afford. Simplicity means decreasing as many steps as possible between the core developers and someone completely new to the project being able to pick it up and start changing it to their needs. However we do add complexity when it is required as well. Keeping the minimalistic development style also means we use minimal number of tools, and if we do use a tool, it should not be too foreign from the source. The result of this is an output of a single binary, a single lua prelude that includes batteries along with it; still having all of the goodies that the Lua language provides.
Getting Started
Dev environment
For development, we recommend a linux machine with visual studio code (or alternative) along with the lua extension.
After your setup is complete, you will want to obtain a prebuilt binary of the runtime from the releases page. Alternatively you can get it with wget
as well. For example for the latest version with LuaJIT VM:
wget https://github.com/ArkForgeLabs/Astra/releases/latest/download/astra-luajit-linux-amd64
There are also windows binaries available if you are working on Windows, however we mostly assume your server instance will likely run linux, hence more support is geared towards it.
After getting your binary on linux, you'll want to change permissions to make it executable:
chmod +x astra-luajit-linux-amd64
Alternatively you can also install through cargo tool, if you have it installed:
cargo install lua-astra
Each release likely contains updates to the packaged bundle lua code that contains definitions you might need during development which you can omit and ignore during production release. You can obtain them through:
./astra-luajit-linux-amd64 export-bundle
which will create a new file called astra_bundle.lua
. You can include it in your code to get intellisense and references for the available functions. The import will be ignored during runtime as Astra will use it's own packaged bundle internally instead.
The reason for this is because Astra includes many global functions that includes the server itself along utilities. These are loaded at start of a runtime. These functions are not written in lua, and are written in Rust and intended to be used by the runtime binary. Hence changing the bundled lua code does not affect anything. There are some pure lua utilities for example table validation, ... which are also included in the bundle by default, but you can ignore them if you wish.
From here on, you can begin writing your server code in a fresh lua file and enjoying everything Astra has to offer. Everything you do locally can be replicated exactly in the server environment as well since they both use the same binary and there are no build stages present.
Interal dev environment
If you want to extend or modify Astra, you will need Rust to be installed on the latest version. You will also need to make sure Cargo
, clippy
and rust-analyzer
components are also installed. These components often are packaged alongside the basic installation. Your IDE may depend on whichever you are comfortable with as Rust have amazing support everywhere.
Then you'll need to clone the repository:
git clone https://github.com/ArkForgeLabs/Astra
You may also want to install LuaJIT as well for some tasks as well such as packing the lua bundle for runtime binary. If you wish to write/extend the docs, you'll need mdbook.
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 Astra
global table instead. For example, changing the compression, port and hostname is as such:
-- configure the server with
Astra.compression = false
Astra.port = 8000
Astra.hostname = "0.0.0.0"
-- run the server with
Astra:run()
You can also configure other languages that compiles to Lua such as Pluto and Fennel, although Pluto targets Lua 5.4 which might get support by 1.0 release of Astra. Astra's api is for pure Lua however, so it will be up to you to make sure it can call the right functions and tables.
In the future, there may be binaries with different combinations prebuilt as well!
Routes
The Astra
global table 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 Astra
table that makes it easy to add new routes. For example:
-- A simple GET index route with text return
Astra:get("/", function()
return "hello from default Astra instance! " .. Astra.version
end)
The syntax are as follows:
Astra.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>
- 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:
Astra:get("/", function(req)
-- access the headers
pretty_print(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:
Astra: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.
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.
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 :3000
}
and change your_domain.tld
to your domain, and :3000
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,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.
Astra IO
Astra provides some IO functionality to help extend the standard library. This is included by default and not as a utility.
Relative imports
You can use the require
function to include code and modules relatively. For example:
local mylib = require("./folder/lib")
AstraIO
Is a global table that contains the IO functions. The current list is as follows:
get_metadata
read_dir
get_current_dir
get_script_path
change_dir
exists
create_dir
create_dir_all
remove
remove_dir
remove_dir_all
Utilities
As with the goal of being an easy to use webserver, several utilities have been included with every binary. These by themselves usually are enough for a lot of basic use cases, and the number of them grow with time as well.
In this chapter we will go through them in details and describe how they work along with some examples.
Schema Validation
Sometimes during development, your server likely recieves structured data such as JSON from outside. You likely also have a structure in mind for them. For these cases to validate that the structures are correct and to confidently go through them without risk of errors, hopefully, you can use the schema validation utility.
Schema Validation essentially is a function that returns true if a given table is of a given structure. The structure is defined as a separate table that has the field names along the types and requirements. For example:
-- Your schema
local schema = {
-- Type names along their types and requirements
id = { type = "number" },
name = { type = "string", required = false }
}
-- Your actual data
local example = { id = "123", name = 456 }
-- Check the validation
local isValid, err = validateTable(example, schema)
assert(not isValid, "Test failed: expected validation to fail")
Almost all of the native lua types are accounted for. Deeply nesting is obviously supported as well:
local schema = {
user = {
type = "table",
schema = {
profile = {
type = "table",
schema = {
id = { type = "number" },
name = { type = "string" }
}
}
}
}
}
local example = { user = { profile = { id = 123, name = "John" } } }
local isValid, err = validateTable(example, schema)
assert(isValid, "Test failed: " .. err)
As well as arrays:
local schema = {
-- normal single type array
numbers = { type = "array", array_item_type = "number" },
strings = { type = "array", array_item_type = "string" },
-- table array
entries = {
type = "array",
schema = {
id = { type = "number" },
text = { type = "string" }
}
}
}
local tbl = {
numbers = { 1, 2, 3 },
strings = { "a", "b", "c" },
entries = { { id = 123, text = "hey!" }, { id = "456", text = "hello!" } }
}
local isValid, err = validate_table(tbl, schema)
if isValid then
print("Validation succeeded")
else
print("Validation failed: " .. err)
end
Markup Language Parsing
Often you will have to deal with a medium of structured data between your server and the clients. This could be in form of JSON, YAML, e.t.c. Astra includes some utilities to serialize and deserialize these with native Lua structures.
For JSON, the json.encode()
and json.decode()
methods are available which converts JSON data from and into Lua tables.
Crypto
During development of your web servers, you might need some cryptography functionality such as hashing and encoding. For these cases, Astra provides commonly used cryptographic functions to ease up development.
Hashing
Currently Astra provides SHA2 and SHA3 (both 256 and 512 variants) hashing functions.
Crypto.hash("sha2_512", "MY INPUT")
Base64
Astra also provides encoding and decoding of base64 strings, including URL safe variants:
local input = "MY VERY COOL STRING"
local encoded = Crypto.base64_encode(input)
print(encoded)
local decoded = Crypto.base64_decode(encoded)
print(decoded)
SQL Driver
If your server requires access to an SQL database such as PostgreSQL and SQLite, Astra provides utilities for basic connection and querying.
-- connect to your db
local db = database_connect("postgres", "postgres://astra_postgres:password@localhost/astr_database")
-- You can execute queries to the database along with optional parameters
db:execute([[
CREATE TABLE IF NOT EXISTS test (id SERIAL PRIMARY KEY, name TEXT);
INSERT INTO TABLE test(name) VALUES ('Astra');
]], {});
-- And finally query either one which returns a single result or
local result = db:query_one("SELECT * FROM test;", {});
-- query all from tables which returns an array as result
-- which also supports parameters for protection against SQL injection attacks
local name = "Tom"
local result = db:query_all("INSERT INTO test (name) VALUES ($1)", {name});
pretty_print(result)
Async Tasks
The spawn_task
function spawns a new Async Task internally within Astra. An async task is a non-blocking block of code that runs until completion without affecting the rest of the software's control flow. Internally Astra runs these tasks as Tokio tasks which are asynchronous green threads. There are no return values as they are not awaited until completion nor joined. The tasks accept a callback function that will be run.
These are useful for when you do not wish to wait for something to be completed, such as making an HTTP request to an API that may or may not fail but you do not want to make sure of either. For example, telemetry or marketing APIs where it can have delays because of volume.
An example of async task:
spawn_task(function ()
print("RUNNING ON ASYNC GREENTHREAD")
end)
print("RUNNING ON MAIN SYNC THREAD")
The tasks return a TaskHandler
as well which has a single method: abort
. This will kill the running task, even if it isn't finished.
Additionally two more task types are also available:
-- Runs in a loop with a delay
local task_id = spawn_interval(function ()
print("I AM LOOPING");
end, 2000)
-- Runs once after the given delay in milliseconds
spawn_timeout(function ()
print("I AM RUNNING ONLY ONCE.")
print("Time to abort the interval above")
-- cancel the interval task
task_id:abort()
end, 5000)
note
The interval code runs immediately and then the delay happens before the loop starts again. In contrast the timeout's delay happen first before the code runs.
HTTP Client
Sometimes your server needs to access other servers and make an HTTP Request, for these cases Astra provides a HTTP Client function:
-- By default its always a GET request
local response = http_request("https://myip.wtf/json"):execute()
pretty_print(response:status_code())
pretty_print(response:headers())
pretty_print(response:remote_address())
pretty_print(response:body():json())
The http_request function returns a HTTPClientRequest object which can be further modified to the needs before execution. The way to do these modification is through chained setters.
local request_client = http_request("https://example.com")
-- - Method. You can pick between one of these:
-- - GET,
-- - POST,
-- - PUT,
-- - PATCH,
-- - DELETE,
-- - HEAD,
:set_method("POST")
:set_header("key", "value")
:set_headers({ key = "value" })
:set_form("key", "value")
:set_forms({ key = "value" })
:set_body("THE CONTENT OF THE BODY")
:set_json({ key = "value" })
:set_file("/path/to/file")
-- You can also execute as an async task
:execute_task(function (result) end)
Dotenv
It is always a good idea to never include sensitive API keys within your server code. For these reasons we usually recommend using a .env
file. Astra automatically loads them if they are present in the same folder into _G.ENV
table so that it doesn't overlap with system's environment variables. You can also load your own file using the global dotenv_load
function.
This is the load order of these files (They can overwrite the ones loaded previously):
- .env
- .env.production
- .env.prod
- .env.development
- .env.dev
- .env.test
- .env.local
Extra Goodies
There are other less known utilities included with Astra that makes the life easier but are not big enough to have their own sections. These include:
Pretty Print
pretty_print
function is a wrapper over print
function that also lets you print tables as well as other values.
Pretty JSON Table
pretty_json_table
recursively converts a table into a pretty formatted JSON string.
String Split
string.split
splits a string according to the seperator provided and result into an array of chunks.
Importing Overhaul
The Lua's default module and importing system has proven to have some issues with our approaches, namely async. Because of this, the require
function has been modified.
Currently the require
function is able to correctly allow any non-main lua files to be able to use every feature Astra contains, including async utilities. The import order of the modules also affect the imported data, in case the module data is shared across. For example:
-- module A.lua:
return { value = 0 }
-- module B.lua:
local a = require("A")
a.value = 2
-- main.lua
local a = require("A")
print(a.value) -- value = 0
local b = require("B")
print(a.value) -- value = 2
If you require relative imports, there is the function import
which gives you the abilities to do so.
Structure
Astra is written in Rust and is built on top of Axum. Axum internally uses Tokio for its async runtime and Tower. Astra also have other dependencies to assist with utilities and functionality of the runtime itself. One of them is mlua which is the library used for the Lua runtime.
Astra's internal philosophy is to contain all routes in a single table array and use mlua UserData
as much as possible for every other functionality. Since routes and server itself is the main point, utilities and functionality should be optional. Hence main.rs
, routes.rs
and common.rs
are the main files that contain the essential details of the framework, while everything else is optional and extra. The utilities that require native system interopability must also be written in Rust. For example, the database drivers and the HTTP client.
note
If you wish to extend Astra with your own utilities written in Rust, check out this guide.
The source code structure is containing all of the sources in the src
folder which divides between actual Rust code, lua
folder which contains sources for the lua side and lua based utilities, and docs
folder which contains sources for this documentation. The docs
folder on the root only contains built doc files for usage with GitHub Pages.
There is also astra_build.lua
which is a small script for automating different operations within Astra, such as automatic doc builds, lua packing, changelogs, ...
Extending Astra
Through Components
Your point of interest will be the src
directory. If your aim is to extend the engine, you may look into the components
folder upon which you can add a new file of your choice.
For extensions, you need a struct that implements LuaComponents
trait implemented which gives an async lua
runtime context. Within it you can create a new lua function that adds your function to the global context at runtime.
#![allow(unused)] fn main() { pub struct MyNewExtension { pub field: String, } impl crate::components::LuaComponents for MyNewExtension { async fn register_to_lua(lua: &mlua::Lua) -> mlua::Result<()> { let function = lua.create_async_function(|lua, param: mlua::Value| async move { /* Content */ Ok(Self { field: "Hello!".to_string() }) })?; lua.globals().set("my_extension", function) } } }
After this, open mod.rs
folder, include your new extention's file, and within register_components
call your extension's register_to_lua
method.
#![allow(unused)] fn main() { my_extension::MyNewExtension::register_to_lua(lua).await?; }
If you wish to return a table with methods that your lua code can call, then you need to implement mlua's UserData
trait:
#![allow(unused)] fn main() { impl UserData for MyNewExtension { fn add_methods<M: mlua::UserDataMethods<Self>>(methods: &mut M) { // Add your custom method that you can call within Lua methods.add_method( "sync_method", |lua, this, param: mlua::Value| { // Your code println!("{:param#?}"); Ok(()) }, ); // It can also be async methods.add_async_method( "async_method", |lua, this, param: mlua::Value| async move { // Your code println!("{:param#?}"); Ok(()) }, ); // As well as mutability methods.add_mut_method( "sync_method", |lua, this, param: String| { // Your code this.field = param.clone(); println!("{:param#?}"); Ok(()) }, ); } } }