Builtin Library
README.md
httpServer
Serve HTTP from the running engine. A world registers a handler for one method and one path; the engine's HTTP server routes matching requests to it and sends back whatever the handler returns.
This is the server half of the engine's HTTP surface. http.* makes requests
to other machines; httpServer.* answers requests this machine receives — a
control surface a browser tab drives, a status endpoint a dashboard polls, a
webhook another process posts to.
Where a route answers
The engine boots its own HTTP server on 127.0.0.1, the loopback interface.
That socket carries the /engine/* tree, which drives the engine, and content
routes alongside it — so a world that asks for nothing answers callers on this
machine, and callers that reach it through a forward another program opens
(adb forward tcp:<port> tcp:<port> from an Android host, an SSH tunnel, a
reverse proxy in front of the port).
httpServer.listen(target) holds a second address, for this world's routes
alone:
local listener = assert(httpServer.listen("0.0.0.0:8080"))
print(listener.url, listener.reach) --> http://0.0.0.0:8080 network
The host in the target is the interface bound, and the whole of what decides who can reach those routes. There is no default to get wrong — a target names its interface:
| Target | Who connects |
|---|---|
127.0.0.1:8080 | Programs on this machine only. |
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. A port of 0
asks the operating system for a free one, which the returned record reports.
httpServer.address(path) is the URL to call, built from the address routes
answer on — the one listen opened while one is open, and the engine's own
server otherwise:
print(httpServer.address("/status")) --> http://127.0.0.1:7607/app/status
httpServer.status() carries the same address in parts — host, port, url
for the base every route hangs off, and reach, which is "thisMachine" when
only programs on this machine connect and "network" when any host that routes
here can. Both are read back from the address the operating system resolved the
bind to, so what a caller is told about exposure is what the socket has.
status().listeners carries every address routes answer on, each with its own
reach, and every record from httpServer.routes() carries the full url of
its own address.
httpServer.unlisten() releases the address, and returns once the socket is
free, so the same port binds again straight after.
The two sockets expose separately
The engine's /engine/* tree answers on the loopback server the boot bound, and
its interface stays what the boot bound it to whatever a world asks for. The
address listen opens routes through the content registry: what it answers is
what content registered under the /app mount. So a world reachable from a
phone hands out the routes it registered, at the reach it asked for.
The mount
Content routes live under /app:
httpServer.route("GET", "/status", handler) --> GET http://<host>:<port>/app/status
The engine's own routes (/engine/mcp, /engine/status, /engine/vfs/...,
/engine/live.stream) are matched first, and content paths are always under
/app, so the two trees cannot overlap. httpServer.route("GET", "/app/status", h) and httpServer.route("GET", "/status", h) name the same route — the mount
is implicit, and spelling it changes nothing.
A route
local handle = httpServer.route("GET", "/status", function(request)
return {
status = 200,
headers = { ["Cache-Control"] = "no-store" },
body = { entities = #entity.findAll(), mode = engine.mode },
}
end)
The response body decides the content type: a table is JSON-encoded and sent
as application/json, a string is sent verbatim as text/plain; charset=utf-8.
A handler that returns a bare string is a 200 carrying that text. status
defaults to 200. A Content-Type in headers is the one sent. Luau carries
one number type, so a whole number is written to the body as an integer —
{ count = 7 } goes on the wire as {"count":7}.
The 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. |
The body reaches a handler as a Luau string decoded from UTF-8, so a request carrying bytes that are not UTF-8 arrives with those bytes replaced. A body larger than 8 MiB is answered 413 without reaching the handler.
A handler returns one complete response — the status, the headers and the whole body at once.
A route registered from a worker job (jobs.dispatch, a kind = "worker" job)
keeps its handler in the worker VM that registered it. That VM stays in
service for the route after the job returns, and each request is posted to it:
the handler runs on the worker as the job that registered the route, the
request waits for its value, and the engine thread does not wait. A request
whose worker is still answering an earlier one waits behind it within the
route's timeoutMs; a worker holding 256 unanswered requests answers the next
one 503. The route lasts until httpServer.unroute, or until the world changes
or the scripting state resets, whatever became of the job that made it; a
request already waiting for it is answered 503.
Paths
A path is literal except for a trailing /*, which matches the rest of the
path and hands it to the handler as request.wildcard:
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: with /files/*, /files/raw/* and /files/index all registered,
/app/files/index reaches the exact route and /app/files/raw/a.bin reaches
/files/raw/*.
One address, one handler
A method and path is served by one handler. Registering an address another
chunk serves returns nil and a reason naming the handle and the chunk that
holds it:
local handle, reason = httpServer.route("GET", "/status", other)
-- handle == nil
-- reason == "route: GET /app/status is already served by route handle 3, registered from @world::api.status — unroute it first"
This is what keeps a second module from taking over an address another piece of
content is answering on. httpServer.routes() finds the handle and
httpServer.unroute(handle) frees the address.
A route belongs to the chunk that registered it
Every route records its owner — the module, component or execute chunk whose
call to route created it. An address listen opened records the same thing.
httpServer.routes() reports it as owner, and it is what ends the route's
life:
- The chunk runs again. A module hot-reload, or a
requirecache cleared byvfs.reload, releases that chunk's routes and the address it held, and frees their handlers, before the new run starts. The new run registers its own, so the address answers the code that is on disk. If the new run does not register the address, nothing answers there andhttpServer.routes()no longer lists it. - The chunk claims an address it already holds. The claim takes the address back and releases the handler it replaces, so a chunk re-run by any other path serves the handler it has just built rather than the one its previous run left.
Writing a module that serves a page is therefore the plain shape — register at load, edit, save, refetch:
httpServer.route("GET", "/panel", function(request)
return { headers = { ["Content-Type"] = "text/html" }, body = PAGE }
end)
A request that arrived before a release is answered 503 naming the released address, rather than through the handler the release freed.
Timing and failure
A request that matches a route waits for the script thread and runs inside the frame, so it costs at most one frame of latency before the handler starts. Up to 16 handlers run per frame; the rest wait for the next one.
- A handler that raises answers 500 with the error message, and the error is written to the engine log.
- A handler that returns something that is not a response table or a string answers 500 saying what arrived.
- A handler that has not answered within
timeoutMs(5 s by default, 120 s at most) answers 504 and the connection closes. The handler carries on to completion — the frame owns it — but one slow handler costs one request rather than the server. - With 256 requests already waiting, further requests answer 503 at the door rather than queueing without limit.
A handler holds the tick for as long as it runs, exactly like a component update. Long work belongs in a task the handler starts, with the response saying where the result will appear.
CORS and OPTIONS
The engine answers every OPTIONS request for a content route with permissive
CORS headers before content routes are matched, and every content response
carries Access-Control-Allow-Origin: *. A browser page on another origin can
call a content route without the route handling preflight itself.
The engine's own routes (/engine/... and /mcp) answer clients on this
machine that speak HTTP for themselves: the proxy, test runners, curl,
scripts. A request a browser sends for a web page (one carrying an Origin,
a Sec-Fetch-Site other than none, or a Host naming no loopback address:
localhost, 127.0.0.0/8 or [::1]) is refused with 403 before it runs, the
page served from /app included, and no engine route answers with a CORS
header.
Where it is 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, so route and listen return nil
and that reason. The namespace itself is present on every platform, so a world
can ask and adapt rather than discovering the difference by way of a call that
vanished.
An engine booted with no port of its own (--mcp-port 0) serves through the
address listen opens: status().url is nil and address(path) says so
until one is held.
Anything reachable can call
A content route answers anyone who can reach an address it is served on —
status().reach is the word for how far that goes. On 0.0.0.0 that is every
host on the network. 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. The engine authenticates nothing
on a content route's behalf.