---
title: "Serving HTTP from the running world"
description: "Sometimes something outside the engine has to reach in."
section: "Topics"
slug: "topics-http-server"
canonical: "https://origozero.ai/docs/topics-http-server"
updated: "2026-10-17T04:27:56.557148477+00:00"
tags: ["documentation", "guide"]
---

# Serving HTTP from the running world

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

```lua
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:

```lua
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 a content route's `OPTIONS` with permissive CORS before routes are matched, so a page served from another origin can call a route without the route handling preflight. The engine's own routes (`/engine/...`, `/mcp`) refuse every request a browser sends for a page, this one included, with `403`: they answer clients on this machine that speak HTTP for themselves, such as the proxy, test runners and `curl`.

## 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:

```lua
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:

```lua
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:

```lua
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:

```lua
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.
