424 lines
13 KiB
Lua
424 lines
13 KiB
Lua
-- server.lua
|
|
-- Simple HTTP server in Lua using LuaSocket
|
|
-- Install dependency: luarocks install luasocket
|
|
|
|
local socket = require("socket")
|
|
|
|
-- ─── Configuration ────────────────────────────────────────────────────────────
|
|
local HOST = "127.0.0.1"
|
|
local PORT = 8080
|
|
|
|
-- ─── In-memory data store ─────────────────────────────────────────────────────
|
|
local messages = {} -- stores messages sent from the client
|
|
|
|
-- ─── Helper: parse the request body from raw HTTP request ─────────────────────
|
|
local function parse_body(raw)
|
|
-- Body follows the blank line after headers
|
|
local body = raw:match("\r\n\r\n(.*)")
|
|
return body or ""
|
|
end
|
|
|
|
-- ─── Helper: URL-decode a string ──────────────────────────────────────────────
|
|
local function url_decode(str)
|
|
str = str:gsub("+", " ")
|
|
str = str:gsub("%%(%x%x)", function(h)
|
|
return string.char(tonumber(h, 16))
|
|
end)
|
|
return str
|
|
end
|
|
|
|
-- ─── Helper: parse application/x-www-form-urlencoded body ────────────────────
|
|
local function parse_form(body)
|
|
local params = {}
|
|
for key, value in body:gmatch("([^&=]+)=([^&=]*)") do
|
|
params[url_decode(key)] = url_decode(value)
|
|
end
|
|
return params
|
|
end
|
|
|
|
-- ─── Helper: encode a Lua table as a simple JSON object ───────────────────────
|
|
local function to_json(tbl)
|
|
local parts = {}
|
|
for k, v in pairs(tbl) do
|
|
local val
|
|
if type(v) == "string" then
|
|
val = string.format('"%s"', v:gsub('"', '\\"'))
|
|
elseif type(v) == "number" then
|
|
val = tostring(v)
|
|
elseif type(v) == "boolean" then
|
|
val = tostring(v)
|
|
else
|
|
val = '"[unsupported]"'
|
|
end
|
|
table.insert(parts, string.format('"%s": %s', k, val))
|
|
end
|
|
return "{ " .. table.concat(parts, ", ") .. " }"
|
|
end
|
|
|
|
-- ─── Helper: encode a list of messages as a JSON array ────────────────────────
|
|
local function messages_to_json()
|
|
local items = {}
|
|
for _, msg in ipairs(messages) do
|
|
table.insert(items, to_json(msg))
|
|
end
|
|
return "[" .. table.concat(items, ", ") .. "]"
|
|
end
|
|
|
|
-- ─── HTTP response builders ────────────────────────────────────────────────────
|
|
local function http_response(status, content_type, body)
|
|
return string.format(
|
|
"HTTP/1.1 %s\r\nContent-Type: %s\r\nContent-Length: %d\r\nAccess-Control-Allow-Origin: *\r\nConnection: close\r\n\r\n%s",
|
|
status, content_type, #body, body
|
|
)
|
|
end
|
|
|
|
local function ok_json(body)
|
|
return http_response("200 OK", "application/json", body)
|
|
end
|
|
|
|
local function ok_html(body)
|
|
return http_response("200 OK", "text/html", body)
|
|
end
|
|
|
|
local function not_found()
|
|
return http_response("404 Not Found", "text/plain", "404 Not Found")
|
|
end
|
|
|
|
-- ─── HTML page served to the browser ─────────────────────────────────────────
|
|
local HTML = [[<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
|
<title>Lua Server Demo</title>
|
|
<style>
|
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
|
|
:root {
|
|
--bg: #0d1117;
|
|
--surface: #161b22;
|
|
--border: #30363d;
|
|
--accent: #58a6ff;
|
|
--green: #3fb950;
|
|
--text: #e6edf3;
|
|
--muted: #8b949e;
|
|
--radius: 10px;
|
|
}
|
|
|
|
body {
|
|
font-family: 'Courier New', monospace;
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
min-height: 100vh;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
padding: 40px 20px;
|
|
}
|
|
|
|
header {
|
|
text-align: center;
|
|
margin-bottom: 36px;
|
|
}
|
|
|
|
header h1 {
|
|
font-size: 2rem;
|
|
color: var(--accent);
|
|
letter-spacing: 2px;
|
|
}
|
|
|
|
header p {
|
|
color: var(--muted);
|
|
margin-top: 6px;
|
|
font-size: 0.85rem;
|
|
}
|
|
|
|
.card {
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius);
|
|
padding: 24px;
|
|
width: 100%;
|
|
max-width: 640px;
|
|
margin-bottom: 24px;
|
|
}
|
|
|
|
.card h2 {
|
|
font-size: 0.9rem;
|
|
text-transform: uppercase;
|
|
letter-spacing: 1.5px;
|
|
color: var(--muted);
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
input[type="text"] {
|
|
width: 100%;
|
|
background: var(--bg);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius);
|
|
color: var(--text);
|
|
padding: 10px 14px;
|
|
font-family: inherit;
|
|
font-size: 0.95rem;
|
|
outline: none;
|
|
transition: border-color 0.2s;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
input[type="text"]:focus { border-color: var(--accent); }
|
|
|
|
button {
|
|
background: var(--accent);
|
|
color: #0d1117;
|
|
border: none;
|
|
border-radius: var(--radius);
|
|
padding: 10px 20px;
|
|
font-family: inherit;
|
|
font-size: 0.9rem;
|
|
font-weight: bold;
|
|
cursor: pointer;
|
|
transition: opacity 0.2s;
|
|
}
|
|
|
|
button:hover { opacity: 0.85; }
|
|
|
|
#status {
|
|
margin-top: 10px;
|
|
font-size: 0.8rem;
|
|
color: var(--green);
|
|
min-height: 18px;
|
|
}
|
|
|
|
#message-list {
|
|
list-style: none;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 10px;
|
|
}
|
|
|
|
#message-list li {
|
|
background: var(--bg);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius);
|
|
padding: 10px 14px;
|
|
font-size: 0.88rem;
|
|
}
|
|
|
|
#message-list li span.sender {
|
|
color: var(--accent);
|
|
font-weight: bold;
|
|
margin-right: 8px;
|
|
}
|
|
|
|
#message-list li span.time {
|
|
float: right;
|
|
color: var(--muted);
|
|
font-size: 0.78rem;
|
|
}
|
|
|
|
#server-info {
|
|
font-size: 0.82rem;
|
|
color: var(--muted);
|
|
line-height: 1.8;
|
|
}
|
|
|
|
#server-info b { color: var(--text); }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<h1>▶ Lua HTTP Server</h1>
|
|
<p>Bidirectional client ↔ server communication demo</p>
|
|
</header>
|
|
|
|
<!-- Send a message -->
|
|
<div class="card">
|
|
<h2>Send a Message</h2>
|
|
<input type="text" id="nameInput" placeholder="Your name" value="Alice" />
|
|
<input type="text" id="messageInput" placeholder="Your message" value="Hello from the browser!" />
|
|
<button onclick="sendMessage()">Send to Server</button>
|
|
<div id="status"></div>
|
|
</div>
|
|
|
|
<!-- Live message feed -->
|
|
<div class="card">
|
|
<h2>Server Message Log</h2>
|
|
<ul id="message-list"><li style="color:var(--muted)">Loading…</li></ul>
|
|
</div>
|
|
|
|
<!-- Server info -->
|
|
<div class="card">
|
|
<h2>Server Info</h2>
|
|
<div id="server-info">Fetching…</div>
|
|
</div>
|
|
|
|
<script>
|
|
const BASE = ""; // same origin
|
|
|
|
// ── Send a message to the server ─────────────────────────────────────────
|
|
async function sendMessage() {
|
|
const name = document.getElementById("nameInput").value.trim();
|
|
const message = document.getElementById("messageInput").value.trim();
|
|
if (!name || !message) return;
|
|
|
|
const body = new URLSearchParams({ name, message });
|
|
|
|
const res = await fetch(BASE + "/send", { method: "POST", body });
|
|
const data = await res.json();
|
|
|
|
document.getElementById("status").textContent =
|
|
data.ok ? "✓ Message received by server!" : "✗ Error: " + data.error;
|
|
|
|
document.getElementById("messageInput").value = "";
|
|
fetchMessages();
|
|
}
|
|
|
|
// ── Fetch all messages from the server ───────────────────────────────────
|
|
async function fetchMessages() {
|
|
const res = await fetch(BASE + "/messages");
|
|
const list = await res.json();
|
|
|
|
const ul = document.getElementById("message-list");
|
|
if (list.length === 0) {
|
|
ul.innerHTML = '<li style="color:var(--muted)">No messages yet.</li>';
|
|
return;
|
|
}
|
|
|
|
ul.innerHTML = list.reverse().map(m => `
|
|
<li>
|
|
<span class="sender">${esc(m.name)}</span>${esc(m.text)}
|
|
<span class="time">${esc(m.time)}</span>
|
|
</li>
|
|
`).join("");
|
|
}
|
|
|
|
// ── Fetch server metadata ────────────────────────────────────────────────
|
|
async function fetchInfo() {
|
|
const res = await fetch(BASE + "/info");
|
|
const data = await res.json();
|
|
document.getElementById("server-info").innerHTML =
|
|
`<b>Host:</b> ${esc(data.host)} | <b>Port:</b> ${esc(data.port)}<br>` +
|
|
`<b>Uptime:</b> ${esc(data.uptime)}s | <b>Requests served:</b> ${esc(data.requests)}<br>` +
|
|
`<b>Lua version:</b> ${esc(data.lua_version)}`;
|
|
}
|
|
|
|
// ── Simple HTML escape ───────────────────────────────────────────────────
|
|
function esc(s) {
|
|
return String(s)
|
|
.replace(/&/g,"&").replace(/</g,"<")
|
|
.replace(/>/g,">").replace(/"/g,""");
|
|
}
|
|
|
|
// ── Poll the server every 3 seconds ─────────────────────────────────────
|
|
fetchMessages();
|
|
fetchInfo();
|
|
setInterval(fetchMessages, 3000);
|
|
setInterval(fetchInfo, 5000);
|
|
</script>
|
|
</body>
|
|
</html>
|
|
]]
|
|
|
|
-- ─── Route handler ────────────────────────────────────────────────────────────
|
|
local start_time = os.time()
|
|
local request_count = 0
|
|
|
|
local function handle(raw_request)
|
|
request_count = request_count + 1
|
|
|
|
-- Parse method and path from the request line
|
|
local method, path = raw_request:match("^(%u+) (/[^ ]*) HTTP")
|
|
method = method or "GET"
|
|
path = path or "/"
|
|
|
|
-- GET / → serve the HTML page
|
|
if method == "GET" and path == "/" then
|
|
return ok_html(HTML)
|
|
|
|
-- GET /messages → return all stored messages as JSON
|
|
elseif method == "GET" and path == "/messages" then
|
|
return ok_json(messages_to_json())
|
|
|
|
-- GET /info → return server metadata as JSON
|
|
elseif method == "GET" and path == "/info" then
|
|
local info = {
|
|
host = HOST,
|
|
port = tostring(PORT),
|
|
uptime = tostring(os.time() - start_time),
|
|
requests = tostring(request_count),
|
|
lua_version = _VERSION,
|
|
}
|
|
return ok_json(to_json(info))
|
|
|
|
-- POST /send → accept a message from the client
|
|
elseif method == "POST" and path == "/send" then
|
|
local body = parse_body(raw_request)
|
|
local params = parse_form(body)
|
|
|
|
local name = params["name"] or "Anonymous"
|
|
local text = params["message"] or ""
|
|
|
|
if text == "" then
|
|
return ok_json('{ "ok": false, "error": "empty message" }')
|
|
end
|
|
|
|
-- Store the message
|
|
table.insert(messages, {
|
|
name = name,
|
|
text = text,
|
|
time = os.date("%H:%M:%S"),
|
|
})
|
|
|
|
print(string.format("[POST /send] %s: %s", name, text))
|
|
return ok_json('{ "ok": true }')
|
|
|
|
else
|
|
return not_found()
|
|
end
|
|
end
|
|
|
|
-- ─── Main server loop ─────────────────────────────────────────────────────────
|
|
local server = assert(socket.bind(HOST, PORT))
|
|
server:settimeout(0) -- non-blocking accept
|
|
|
|
print(string.format("Lua HTTP server running at http://%s:%d/", HOST, PORT))
|
|
print("Press Ctrl-C to stop.\n")
|
|
|
|
while true do
|
|
local client = server:accept()
|
|
if client then
|
|
client:settimeout(5)
|
|
|
|
-- Read the full request (stop at blank line for GET, read body for POST)
|
|
local raw = {}
|
|
local content_length = 0
|
|
|
|
-- Read headers
|
|
while true do
|
|
local line, err = client:receive("*l")
|
|
if not line or line == "" then break end
|
|
table.insert(raw, line)
|
|
|
|
-- Capture Content-Length so we can read the body
|
|
local cl = line:match("^[Cc]ontent%-[Ll]ength:%s*(%d+)")
|
|
if cl then content_length = tonumber(cl) end
|
|
end
|
|
|
|
local header_str = table.concat(raw, "\r\n") .. "\r\n\r\n"
|
|
|
|
-- Read body if present
|
|
local body_str = ""
|
|
if content_length > 0 then
|
|
body_str = client:receive(content_length) or ""
|
|
end
|
|
|
|
local full_request = header_str .. body_str
|
|
local response = handle(full_request)
|
|
|
|
client:send(response)
|
|
client:close()
|
|
end
|
|
|
|
socket.sleep(0.01) -- prevent busy-loop
|
|
end
|