A http 1.1 webserver library for nelua
- Routing
- Sessions
- CSRF security
- External http requests
- Builtin JSON library
Add to your nlpm package dependencies
{
name = "nttp-nelua",
repo = "https://github.com/kmafeni04/nttp-nelua",
version = "COMMIT-HASH-OR-TAG",
},Run nlpm install
local nttp = require "nttp"
local app = nttp.Server.new()
app:get(nil, "/", function(self: *nttp.Server): nttp.Response
return self:text(nttp.Status.OK, "hello, world")
end)
app:serve()local nttp = @record{}See json-nelua
local nttp.json = jsonSee variant-nelua
local nttp.variant = variantlocal nttp.send_request = send_requestSee utils.nelua
local nttp.utils = utilsEnum list of different possible nttp status codes
local nttp.Status = @enum{
Continue = 100,
SwitchingProtocols = 101,
Processing = 102,
EarlyHints = 103,
OK = 200,
Created = 201,
Accepted = 202,
NonAuthoritativeInfo = 203,
NoContent = 204,
ResetContent = 205,
PartialContent = 206,
MultiStatus = 207,
AlreadyReported = 208,
IMUsed = 226,
MultipleChoices = 300,
MovedPermanently = 301,
Found = 302,
SeeOther = 303,
NotModified = 304,
UseProxy = 305,
SwitchProxy = 306,
TemporaryRedirect = 307,
PermanentRedirect = 308,
BadRequest = 400,
Unauthorized = 401,
PaymentRequired = 402,
Forbidden = 403,
NotFound = 404,
MethodNotAllowed = 405,
NotAcceptable = 406,
ProxyAuthRequired = 407,
RequestTimeout = 408,
Conflict = 409,
Gone = 410,
LengthRequired = 411,
PreconditionFailed = 412,
RequestEntityTooLarge = 413,
RequestURITooLong = 414,
UnsupportedMediaType = 415,
RequestedRangeNotSatisfiable = 416,
ExpectationFailed = 417,
Teapot = 418,
MisdirectedRequest = 421,
UnprocessableEntity = 422,
Locked = 423,
FailedDependency = 424,
TooEarly = 425,
UpgradeRequired = 426,
PreconditionRequired = 428,
TooManyRequests = 429,
RequestHeaderFieldsTooLarge = 431,
UnavailableForLegalReasons = 451,
InternalServerError = 500,
NotImplemented = 501,
BadGateway = 502,
ServiceUnavailable = 503,
GatewayTimeout = 504,
HTTPVersionNotSupported = 505,
VariantAlsoNegotiates = 506,
InsufficientStorage = 507,
LoopDetected = 508,
NotExtended = 510,
NetworkAuthenticationRequired = 511,
}local nttp.TriBool= @enum{
NULL = -1,
FALSE,
TRUE
}local nttp.Cookie = @record{
name: string,
val: string,
path: string,
domain: string,
expires: string,
secure: nttp.TriBool,
httpOnly: nttp.TriBool
}local nttp.Response = @record{
body: string,
status: nttp.Status,
content_type: string,
headers: hashmap(string, string),
cookies: sequence(nttp.Cookie)
}Destorys the response object and sets it to a zeroed state
function nttp.Response:destroy()This function converts your response into a nttp request string
app:get(nil, "/test", function(self: *nttp.Server)
local resp = self:text(200, "ok")
print(resp:tostring()) -- "HTTP/1.1 200 OK\r\nServer: nttp\r\nDate: Thu, 17 Apr 2025 19:23:00 GMT\r\nContent-type: text/plain\r\nContent-Length: 4\r\n\r\nok\r\n"
return resp
end)function nttp.Response:tostring(): stringSets a header to be sent with the response
app:get(nil, "/", function(self: *nttp.Server)
local resp = self:text(200, "ok")
local err = resp:set_header("name", "james")
if err ~= "" then
return self:error()
end
return resp
end)function nttp.Response:set_header(key: string, val: string): stringSets a cookie to be sent with the response
app:get(nil, "/", function(self: *nttp.Server)
local resp = self:text(200, "ok")
local err = resp:set_cookie({
name = "name",
val = "james"
})
if err ~= "" then
return self:error()
end
return resp
end)function nttp.Response:set_cookie(c: nttp.Cookie): stringlocal nttp.Session = @record{
vals: hashmap(string, string),
send: boolean
}This function is used to set values that will be stored in the sesssion
app:get(nil, "/", function(self: *nttp.Server)
self.session:set_val("name", "james")
self.session:set_val("age", "10")
return self:text(200, "ok")
end)function nttp.Session:set_val(name: string, val: string): stringThis function is used to get values that are stored in the sesssion
app:get(nil, "/test", function(self: *nttp.Server)
local name = self.session:get_val("name")
local age = self.session:get_val("age")
return self:text(200, "ok")
end)local nttp.Request = @record{
method: string,
version: string,
headers: hashmap(string, string),
current_path: string,
params: hashmap(string, string),
body: string
}Gets a header from the request object
app:get(nil, "/test", function(self: *nttp.Server)
local name = self.req:get_header("name")
return self:text(200, "ok")
end)
```lua
function nttp.Request:get_header(name: string): stringGets a cookie from the request object
app:get(nil, "/test", function(self: *nttp.Server)
local name = self.req:get_cookie("name")
return self:text(200, "ok")
end)
```lua
function nttp.Request:get_cookie(name: string): stringDefaults are only set if the server is instantiated with nttp.Server.new
- port: The port you want the server to run on, default is
8080 - bind_host: The interface the server will bind to, default is
0.0.0.0 - secret: This is used to sign your session, default is
please-change-me - session_name: Name of cookie used to store the session, default is
nttp_session - log: This determines whether the server will log the request information to the console, default is nttp.TriBool.NULL
local nttp.Config = @record{
port: uinteger,
bind_host: string,
secret: string,
session_name: string,
log: nttp.TriBool
}Type Alias describing the function signature of before functions called in the nttp.Server:before_filter
local nttp.BeforeFn = @function(self: *nttp.Server): Option(nttp.Response)Type Alias describing the function signature of action functions called on a nttp.Server:#|method|#
local nttp.ActionFn = @function(self: *nttp.Server): nttp.Responsenttp.Server = @record{
config: nttp.Config,
static_dir: string,
static_name: string,
static_headers: hashmap(string, string),
routes: hashmap(string, Route),
var_routes: hashmap(string, Route),
named_routes: hashmap(string, string),
req: nttp.Request,
default_route: nttp.ActionFn,
handle_404: nttp.ActionFn,
session: nttp.Session,
before_funcs: sequence(nttp.BeforeFn),
write: function(self: *nttp.Server, s: string): string,
written: boolean,
_fd: integer
}These are the mime types that will be matched against when a static file is requested from the server alongside their respective content type
local mime_types = map!(string, string, {
["aac"] = "audio/aac",
["abw"] = "application/x-abiword",
["apng"] = "image/apng",
["arc"] = "application/x-freearc",
["avif"] = "image/avif",
["avi"] = "video/x-msvideo",
["azw"] = "application/vnd.amazon.ebook",
["bin"] = "application/octet-stream",
["bmp"] = "image/bmp",
["bz"] = "application/x-bzip",
["bz2"] = "application/x-bzip2",
["cda"] = "application/x-cdf",
["csh"] = "application/x-csh",
["css"] = "text/css",
["csv"] = "text/csv",
["doc"] = "application/msword",
["docx"] = "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
["eot"] = "application/vnd.ms-fontobject",
["epub"] = "application/epub+zip",
["gz"] = "application/gzip",
["gif"] = "image/gif",
["htm"] = "text/html",
["html"] = "text/html",
["ico"] = "image/vnd.microsoft.icon",
["ics"] = "text/calendar",
["jar"] = "application/java-archive",
["jpeg"] = "image/jpeg",
["jpg"] = "image/jpeg",
["js"] = "text/javascript",
["json"] = "application/json",
["jsonld"] = "application/ld+json",
["mid"] = "audio/midi",
["midi"] = "audio/x-midi",
["mjs"] = "text/javascript",
["mp3"] = "audio/mpeg",
["mp4"] = "video/mp4",
["mpeg"] = "video/mpeg",
["mpkg"] = "application/vnd.apple.installer+xml",
["odp"] = "application/vnd.oasis.opendocument.presentation",
["ods"] = "application/vnd.oasis.opendocument.spreadsheet",
["odt"] = "application/vnd.oasis.opendocument.text",
["oga"] = "audio/ogg",
["ogv"] = "video/ogg",
["ogx"] = "application/ogg",
["opus"] = "audio/ogg",
["otf"] = "font/otf",
["png"] = "image/png",
["pdf"] = "application/pdf",
["php"] = "application/x-httpd-php",
["ppt"] = "application/vnd.ms-powerpoint",
["pptx"] = "application/vnd.openxmlformats-officedocument.presentationml.presentation",
["rar"] = "application/vnd.rar",
["rtf"] = "application/rtf",
["sh"] = "application/x-sh",
["svg"] = "image/svg+xml",
["tar"] = "application/x-tar",
["tif"] = "image/tiff",
["tiff"] = "image/tiff",
["ts"] = "video/mp2t",
["ttf"] = "font/ttf",
["txt"] = "text/plain",
["vsd"] = "application/vnd.visio",
["wav"] = "audio/wav",
["weba"] = "audio/webm",
["webm"] = "video/webm",
["webp"] = "image/webp",
["woff"] = "font/woff",
["woff2"] = "font/woff2",
["xhtml"] = "application/xhtml+xml",
["xls"] = "application/vnd.ms-excel",
["xlsx"] = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
["xml"] = "application/xml",
["xul"] = "application/vnd.mozilla.xul+xml",
["zip"] = "application/zip",
["3gp"] = "video/3gpp; audio/3gpp",
["3g2"] = "video/3gpp2; audio/3gpp2",
["7z"] = "application/x-7z-compressed",
})This sets the directory when static files will be read from as well as the name that will be used for routing
local app = nttp.Server.new()
app:set_static("./static", "static")
app:get(nil, "/", function(self: *nttp.Server): nttp.Response
return self:html(200, '<link rel="stylesheet" href="/static/test.css" />')
end)function nttp.Server:set_static(dir: string, name: string)This adds functions that will run before every request
The nttp.BeforeFn returns a boolean and a nttp.Response, if the boolean is true, it will return the response instead of the hit route
app:before_filter(function(self: *nttp.Server): Option(nttp.Response)
self.session:set_val("val", "test")
if self.req.current_path ~= self:url_for("test") and self.session:get_val("val") == "test" then
return Option.Some(self:redirect("/test"))
end
return Option.None()
end)function nttp.Server:before_filter(fn: nttp.BeforeFn)## local methods = {"get", "post", "put", "patch", "delete"}These are routing functions where method could be one of the supported http methods
local nttp = require "path.to.nttp"
local app = nttp.Server.new()
app:get(nil, "/", function(self: *nttp.Server): nttp.Response
return self:text(nttp.Status.OK, "hello, world")
end)- name: This can be provided to set a name for a route, usually to be used with the nttp.Server:url_for function
- route: The actual route that will be called
- action: The function to be called when the route is hit
function nttp.Server:#|method|#(name: facultative(string), route: string, action: nttp.ActionFn)Used to alter the returned url from nttp.Server:url_for
local nttp.Server.UrlForOpts = @record{
route_params: hashmap(string, string),
query_params: hashmap(string, string)
}This function returns the route of the relevant name
opts can be passed to the function to help build a url if it contains route params or you would like to add query params, see nttp.Server.UrlForOpts
Examples:
app:get("get_params", "/get_params", function(self: *nttp.Server)
local route_params: hashmap(string, string)
route_params["id"] = "10"
route_params["name"] = "james"
route_params["*"] = "splat"
local query_params: hashmap(string, string)
query_params["id"] = "10"
query_params["name"] = "james"
return self:html(200, ("<a href='%s'>link</a>"):format(self:url_for("params", {
route_params = route_params,
query_params = query_params
})))
end)
app:get("params", "/params/:id/:name/*", function(self: *nttp.Server)
return self:text(200, self.req.params["id"] .. " " .. self.req.params["name"] .. " " .. self.req.params["*"])
end)
-- Should return "/params/10/james/splat?id=10&name=james"app:get(nil, "/", function(self: *nttp.Server): nttp.Response
return self:text(nttp.Status.OK, self:url_for("test"))
end)
-- will return "/really-long-name"
app:get("test", "/really-long-name", function(self: *nttp.Server): nttp.Response
return self:text(nttp.Status.OK, "hello, world")
end)function nttp.Server:url_for(name: string, opts: nttp.Server.UrlForOpts): stringhelper function for commonly returned nttp.Response to specify the response is html
function nttp.Server:html(code: nttp.Status, html: string): nttp.Responsehelper function for commonly returned nttp.Response to specify the response is json
-body: Can either be a string or a json serializable object
function nttp.Server:json(code: nttp.Status, body: overload(string, auto)): nttp.Responsehelper function for commonly returned nttp.Response to specify the response is text
function nttp.Server:text(code: nttp.Status, text: string): nttp.Responsehelper function to specify that the return should be a redirect and redirect to path
app:get(nil, "/", function(self: *nttp.Server): nttp.Response
return self:redirect(self:url_for("actual"))
end)
app:get("actual", "/actual-path", function(self: *nttp.Server): nttp.Response
return self:text(nttp.Status.OK, "ok")
end)function nttp.Server:redirect(path: string): nttp.ResponseHelper function that returns a nttp text response with a 500 error code and message "Internal Server Error"
function nttp.Server:error(): nttp.Responselocal nttp.csrf = @record{}This function generates a csrf token that is stored in your session and returns it as a value If a token already exists, it returns that and doesn't create a new one
app:before_filter(function(self: *nttp.Server): Option(nttp.Response)
local token = nttp.csrf.generate_token(self)
return Option.None()
end)function nttp.csrf.generate_token(self: *nttp.Server): stringThis function checks that there is a csrf token in your session and that the token passed in your request params matches it
app:post(nil, "/test", function(self: *nttp.Server)
if not nttp.csrf.validate_token(self) then
return self:text(403, "forbidden")
end
return self:text(200, "ok")
end)function nttp.csrf.validate_token(self: *nttp.Server): booleanThis function starts the server It should always be the last line of your file
function nttp.Server:serve()local nttp.MockRequestOpts = @record{
method: string,
params: hashmap(string, string),
headers: hashmap(string, string),
cookies: hashmap(string, string),
session_vals: hashmap(string, string)
}This function is meant for testing and helps you simulate requests to your server
function nttp.Server:mock_request(path: string, opts: nttp.MockRequestOpts): (nttp.Response, string)This function returns a new nttp.Server instance that will be used throughout your app
The config param can be ommited and default values will be used, it is of type nttp.Config
local nttp = require "path.to.nttp"
local app = nttp.new({
secret = os.getenv("SECRET"),
session_name = "my_app"
})function nttp.Server.new(config: nttp.Config): nttp.ServerTo write to the client, the write method is called
Below is the default implementation
s.write = function(self:*nttp.Server, s: string): string
local written_bytes = send(self._fd, (@cstring)(s), #s, MSG_NOSIGNAL)
if written_bytes == -1 then
local err_msg = C.strerror(C.errno)
return (@string)(err_msg)
end
return ""
endWhen a request does not match any of the routes you've defined, the default_route method will be called to create a response.
Below is the default implementation
s.default_route = function(self: *nttp.Server)
if self.req.current_path:match("./$") then
local stripped = self.req.current_path:sub(1, #self.req.current_path - 1)
return self:redirect(stripped)
else
return self:handle_404()
end
endIn the default default_route, the method handle_404 is called when the path of the request did not match any routes.
Below is the default implementation
s.handle_404 = function(self: *nttp.Server)
return self:text(nttp.Status.NotFound, "Page or resource not found")
endlocal SendRequest = @record{
url: string,
method: string,
headers: hashmap(string, string),
body: hashmap(string, string)
}local SendResponse = @record{
body: string,
status: string,
headers: hashmap(string, string)
}This function takes a SendRequest object, makes either an http or https request and returns a SendResponse object If no method is passed, it defaults to "get" Keep note that this function will block whatever route you call it on until the request is completed
local result, err = send_request({
url = "https://dummy-json.mock.beeceptor.com/posts/1",
method = "get"
})local utils = @record{}This function escapes a string so it is url friendly
print(utils.url_escape("hello world"))
-- hello%20worldfunction utils.url_escape(s: string): stringThis function unescapes a url string
print(utils.url_escape("hello%20world"))
-- hello worldfunction utils.url_unescape(s: string): stringThis functions converts a string to a slug suitable for a url
print(utils.slugify("Hello, World! Welcome to ChatGPT: AI for Everyone π"))
-- hello-world-welcome-to-chatgpt-ai-for-everyonefunction utils.slugify(s: string): stringThis function is what is used to sign session data
print(utils.sign("key", "data"))
-- 5031fe3d989c6d1537a013fa6e739da23463fdaec3b70137d828e36ace221bd0function utils.sign(key: cstring, data: cstring): stringThis function encodes a string to base64
print(utils.b64_encode("hello world"))
-- aGVsbG8gd29ybGQ=function utils.b64_encode(input: string): stringThis function decodes a string from base64
print(utils.b64_encode("aGVsbG8gd29ybGQ="))
-- hello worldfunction utils.b64_decode(data: string): stringTrims whitespae off from the ends of a string
print(utils.trim_wspace(" hello "))
-- hellofunction utils.trim_wspace(s: string)Extra libraries attached to this
- datastar-sdk: SDK for datastar.js
- mail: SMTP library
This library is heavliy inspired by the lapis and a bit by the echo web frameworks