Moud

Reaching websites#

http is the global a server script uses to call an address on the web and read what comes back: a webhook, a score board of your own, a Discord message. Client scripts cannot; a player's machine never makes the call, so nothing a place asks for depends on what is installed on their computer or what their network allows.

Turning it on#

Requests are off until the place asks for them. Add the feature to place.toml:

toml
[features]
httpRequests = true

In the editor the same switch is Project Settings > Scripts > httpRequests. Without it every request errors with "http requests are off".

Asking#

luau
-- server
task.spawn(function()
    local reply = http.get("https://example.com/scores")
    print(reply.status, reply.ok)
    local scores = http.jsonDecode(reply.body)
end)

The call runs inside task.spawn because it waits for the website to answer. While it waits, the rest of the place keeps running: the tick carries on, other players are served, and your script resumes on the line after the call once the answer arrives.

There are three ways to ask, and they differ only in how much you spell out:

Call
http.get(url, headers?)
http.post(url, body, contentType?, headers?) contentType is application/json when left out
http.request({ url = ..., method = ..., headers = ..., body = ... }) method is GET when left out

Use http.request when you need a method the other two do not cover, or when you want to build the request as a table. method is one of GET, POST, PUT, PATCH, DELETE, HEAD or OPTIONS. A method the engine does not know is an error that says so.

Each of them hands back one table:

Field
status the code, like 200 or 404
ok true for 200 to 299
body the answer as text
headers the answer's headers, joined with , where one was sent twice

body is always text. If the site answered with JSON, run it through http.jsonDecode yourself.

A request waits, so it runs inside a function started with task.spawn, like anything else that waits. Calling it straight out of game.stepped holds up the tick everybody else is on.

Helpers#

These turn values into text and back, and generate the ids and escaped strings a web call usually needs. They work on both sides, with the feature off, and never wait:

luau
http.jsonEncode({ name = "meek", score = 12 })   -- '{"name":"meek","score":12}'
http.jsonDecode('{"ok":true}').ok                -- true
http.generateGuid()                              -- "{2E3A...}"
http.generateGuid(false)                         -- the same without the braces
http.urlEncode("a b&c")                          -- "a%20b%26c"
  • jsonEncode takes what a store takes: numbers, text, booleans and nested tables with text keys.
  • jsonDecode errors on text it cannot read, so catch it with pcall when the text came from outside.
  • jsonDecode and its XML twin xml.decode also read files that ship with the place, through files.read. See Reading files.
  • generateGuid gives you a unique id, with braces by default and without them when you pass false.
  • urlEncode escapes a value so you can put it in a query string without the spaces and ampersands breaking the address.

Catch what you send#

A website can be down, slow or angry, and a request that fails raises in the caller. Wrap it:

luau
-- server
local hook = "https://example.com/hooks/rounds"

local function announce(winner: string, seconds: number)
    task.spawn(function()
        local ok, reply = pcall(function()
            return http.post(hook, http.jsonEncode({
                content = winner .. " won in " .. math.floor(seconds) .. " seconds",
            }))
        end)
        if not ok then
            print("the webhook did not go through:", reply)
        elseif not reply.ok then
            print("the webhook answered", reply.status, reply.body)
        end
    end)
end

There are two ways this goes wrong, and you check them separately:

  • The call never got an answer at all, because the address is unreachable or it ran out of time. pcall returns false and the second value is the error.
  • The call got an answer and the answer was a refusal, such as 401 or 429. pcall returns true, and reply.ok is false. reply.status and reply.body say why.

Websockets#

A request asks once and gets one answer. A websocket stays open, and either end can send whenever it likes, which is what a live feed needs: a chat bridge, a match server of your own, a dashboard that pushes commands to your game. http.websocket opens one from a server script, under the same httpRequests switch as everything else here:

luau
-- server
task.spawn(function()
    local feed = http.websocket("wss://example.com/feed", {
        headers = { Authorization = "Bearer " .. token },
        reconnect = true,
    })

    feed.message:connect(function(text)
        local event = http.jsonDecode(text)
        if event.kind == "announce" then
            game.chat:system(event.text)
        end
    end)

    feed.closed:connect(function(code, reason, reconnecting)
        print("the feed closed", code, reason, reconnecting)
    end)

    feed.reconnected:connect(function()
        feed:send(http.jsonEncode({ kind = "hello", server = game.serverId }))
    end)

    feed:send(http.jsonEncode({ kind = "hello", server = game.serverId }))
end)

http.websocket waits until the socket is open, so it runs inside task.spawn like a request, and it errors if the address does not answer. What it hands back is the socket:

  • message fires with every message that arrives, as text, or as a buffer when the other end sent binary. Handlers run on the server's tick, never in the middle of one.
  • send takes text or a buffer and sends it as it is; encode a table with http.jsonEncode first. Sending on a socket that is not open is an error.
  • closed fires with the close code, the reason and whether Moud will try again.
  • close(code?, reason?) closes it for good. Stopping the script that opened it closes it too.
  • open is true while it is connected, and url is where it goes.

With reconnect = true a socket that drops without your script closing it is opened again after one second, then two, four, and up to thirty seconds between tries. reconnected fires when it is back, which is where you say hello again, since the other end sees a fresh connection. Without it, call feed:reconnect() yourself; it waits until the socket is open again.

message handlers cannot wait, like any other signal handler. Start a task.spawn inside one when what you do with a message has to wait, such as a request.

Only ws:// and wss:// addresses open a websocket. Client scripts cannot open one, for the same reason they cannot make requests.

Limits#

  • Only http:// and https:// addresses. Anything else is an error before the call leaves.
  • 30 seconds to connect, 30 more for the answer to start, and a minute for the whole answer. A request that runs out of time errors.
  • 16 MB each way. A longer body is refused before it leaves, and a longer answer is an error. A websocket message has the same limit.
  • Redirects are followed, except from https:// to http://.
  • Every address is reachable, your own machine and local network included. httpRequests is the only switch.
  • A player is never told what the server asked or what it heard. Send it over a channel yourself if they should see it.

Treat everything in body as something a stranger wrote. Check it before you put it in the world: a name you paste into a TextLabel, a number you feed to cframe, or a block id you look up all come from somebody outside your place.