Serving HTTP from the running world
Sometimes something outside the engine has to reach in. You want a web page you can open in a browser to see what the world is doing, a control surface on a phone on the same wifi, a route another…
This is the answering half of the engine's HTTP surface — http.get_json and friends make requests to other machines; httpServer.route answers requests this machine receives.
One route, end to end
local handle, why = httpServer.route("GET", "/status", function(request)
return {
status = 200,
headers = { ["Cache-Control"] = "no-store" },
body = { entities = #entity.findAll(), mode = engine.mode },
}
end)
if not handle then error(why) end
print(httpServer.address("/status")) --> http://127.0.0.1:7607/app/status
for _, r in ipairs(httpServer.routes()) do print(r.method, r.url, r.owner) end
-- When the module reloads or the feature is done:
httpServer.unroute(handle)
Content routes live under the /app mount, so that handler answers http://<host>:<port>/app/status. The engine's own /engine/* routes are matched first, which is why a registration can never take one over. Spelling the mount changes nothing — "/status" and "/app/status" name the same route.
httpServer.address(path) is the URL to hand someone, built from the interface and port the server is actually listening on. httpServer.status() carries the same address in parts (host, port, and the base url every route hangs off), and each record from httpServer.routes() carries its own url and the owner chunk that registered it.
A whole page is a route too. The body decides the content type: a table is JSON-encoded, a string is sent verbatim as text, and a Content-Type in headers is the one used:
httpServer.route("GET", "/panel", function()
return {
headers = { ["Content-Type"] = "text/html; charset=utf-8" },
body = "<!doctype html><h1>hello</h1><script>fetch('/app/status')…</script>",
}
end)
Open http://127.0.0.1:<port>/app/panel and that page is a control surface for the running world. Every content response carries Access-Control-Allow-Origin: * and the engine answers OPTIONS with permissive CORS before routes are matched, so a page served from another origin can call a route without the route handling preflight.
Serving a phone: who can reach it
The engine's own server holds 127.0.0.1, so the page above is this machine's. To hand it to a phone, hold an address the network reaches:
local listener = assert(httpServer.listen("0.0.0.0:8080"))
print(listener.url, listener.reach) --> http://0.0.0.0:8080 network
print(httpServer.address("/panel")) --> http://0.0.0.0:8080/app/panel
The host in the target is the interface bound, and the whole of what decides who can reach the routes on it. There is no default to get wrong — a target names its interface, so you write the reach you mean:
| Target | Who connects |
|---|---|
127.0.0.1:8080 | Programs on this machine only. Nothing off the box reaches it. |
0.0.0.0:8080 | Any host that routes to this machine on that port — every device on the same wifi, and whatever else the network lets through. |
Serving a phone or another machine means 0.0.0.0, and means anything else on that network can connect too. Bind loopback unless you want that. Then open http://<this machine's address on the wifi>:8080/app/panel on the phone.
httpServer.status() reports what you actually got: host and port are read back from the address the operating system resolved the bind to, and reach is "thisMachine" or "network". A port of 0 asks the operating system to choose a free one, which that same port then tells you. status().listeners lists every address routes answer on, each with its own reach. httpServer.unlisten() releases the address and returns once the socket is free.
Two sockets, two decisions. The address listen opens serves the routes this world registered under /app — that is its whole router. The engine's /engine/* tree, which drives the engine, answers on the loopback server the boot bound, and stays on the interface the boot gave it whatever a world asks for. Opening the world to a phone hands out the world's own routes.
A route on a network address answers whoever dials it, so a route that mutates the world, reads files, or spends credits should check something in the request it can trust — a shared secret in a header, a token in the query — before acting.
What a handler receives
| Field | Value |
|---|---|
method | The verb, uppercased. |
path | The address asked for, mount included (/app/status). |
route | The path this handler registered (/status). |
wildcard | For a /* route, the part of the path it matched. |
query | The query string decoded into a table of pairs. |
rawQuery | The query string as it arrived. |
headers | Request headers, names lowercased. |
body | The request body, as text. |
A path is literal except for a trailing /*, which matches the rest of the path:
httpServer.route("GET", "/files/*", function(request)
return { body = vfs.read("/zero/source/" .. request.wildcard) }
end)
An exact path answers ahead of a wildcard, and among wildcards the longest one wins. The body reaches a handler as a Luau string decoded from UTF-8; a body larger than 8 MiB is answered 413 before the handler runs.
One address, one handler
A method and path is served by one handler, and a route remembers the chunk that registered it — the module, component or execute chunk whose run it lasts for. Registering an address another chunk serves returns nil and a reason naming the handle and the chunk holding it:
local handle, reason = httpServer.route("GET", "/status", other)
-- reason == "route: GET /app/status is already served by route handle 3 — unroute it first"
That is what stops a second module from silently taking over an address something else answers on. Registering an address the same chunk already serves takes it back and releases the handler it replaces, so a module that runs again — a hot reload, a re-run of the same script — ends up serving the handler it just built, with no unregister dance in between.
httpServer.routes() finds a handle to release deliberately, and httpServer.unroute(handle) frees the address:
for _, r in ipairs(httpServer.routes()) do
if r.method == "GET" and r.path == "/status" then httpServer.unroute(r.handle) end
end
A handler runs inside the frame
The handler runs on the script thread, like a component update: it may read and write the world, and it holds the tick for as long as it runs. A matching request costs at most one frame of latency before the handler starts, and up to 16 handlers run per frame.
- Raising inside a handler answers 500 with the message, and writes the error to the engine log.
- Returning something that is neither a response table nor a string answers 500 saying what arrived.
- A handler that has not answered within
timeoutMs(5 s by default, 120 s at most, set per route) answers 504 and the connection closes, while the handler itself carries on to completion — one slow handler costs one request rather than the server. - With 256 requests already waiting, further ones answer 503 at the door.
Long work belongs in a task the handler starts, with the response saying where the result will appear.
Where routes are served
httpServer.status().supported says whether this engine holds addresses at all, and reason says why when it holds none: a browser tab answers HTTP requests and has no address of its own. The namespace is present on every platform, so a world asks and adapts.
An address listen opened belongs to the chunk that opened it, like a route: when that chunk runs again the address is released and the new run holds what its current source names. httpServer.status().listeners names the owner of each one.
Finding the rest
lsp.methods("httpServer") lists this API and lsp.describe("httpServer.route") gives its exact signature. For a raw two-way connection to another process rather than request/response, the byte-streams guide covers stream.open; to push what a camera renders to that same phone as live pixels, the frame-streaming guide covers frameStream; and the microphone guide covers reading the room, which a route can then report.
The model to hold: a route is a handler the world owns, mounted under /app, running inside the frame like everything else the world does.