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(())
            },
        );
    }
}
}