Log inGet started
World

Builtin Library

Branchmain
Branchesmainpublish
Commit3bb1da2647

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:

TargetWho connects
127.0.0.1:8080Programs on this machine only.
0.0.0.0:8080Any 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:

FieldValue
methodThe verb, uppercased.
pathThe address asked for, mount included (/app/status).
routeThe path this handler registered (/status).
wildcardFor a /* route, the part of the path it matched.
queryThe query string decoded into a table of pairs.
rawQueryThe query string as it arrived.
headersRequest headers, names lowercased.
bodyThe 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 require cache cleared by vfs.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 and httpServer.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.