Skip to content

Petrichor.Net#

Petrichor.Net provides TCP and UDP helpers for Luau mods.

  • TCP connect and recv run on background threads and accept an optional timeout. Results resume on the main thread through Petrichor.Task.
  • TCP send and close run synchronously on the calling thread.
  • UDP udp_open, udp_send, udp_connect, udp_send_data, udp_bind, udp_recv_nonblock, and udp_close are synchronous. udp_recv is async like TCP recv and also accepts an optional timeout.
  • UDP sockets default to IPv4 but can be opened as IPv6 via Net.TYPES.INET6: see Address families below.

Loading#

local Net  = require("Petrichor.Net")
local Task = require("Petrichor.Task")
import * as Net from "Petrichor.Net"
import * as Task from "Petrichor.Task"

Return values#

Async calls (connect, recv, udp_recv) resume with two values: result, err.

  • On success, err is nil.
  • On failure, result is nil and err is a string. For timed-out calls, err is exactly the string "timeout".

Check if err then (or if not result then) before using the result.

local sock, err = Task.await(Net.connect("127.0.0.1", 9000))
if err then
    Log.err("my-mod", "connect failed: " .. err)
    return
end
import * as Log from "Petrichor.Log"

const [sock, err] = Task.await(Net.connect("127.0.0.1", 9000))
if (err) {
    Log.err("my-mod", `connect failed: ${err}`)
    return
}

TCP#

Net.connect(host, port, timeout?) -> thunk#

Starts a TCP connect to host:port. timeout is in seconds and defaults to 30. Use with Task.await:

Task.async(function()
    local sock, err = Task.await(Net.connect("localhost", 9000, 5))
    if err then return end

    Net.send(sock, "hello from petrichor\n")

    local data, recv_err = Task.await(Net.recv(sock))
    if recv_err then return end

    Log.info("my-mod", "echo: " .. data)
    Net.close(sock)
end)()
import * as Log from "Petrichor.Log"

Task.async(() => {
    const [sock, err] = Task.await(Net.connect("localhost", 9000, 5))
    if (err) return

    Net.send(sock, "hello from petrichor\n")

    const [data, recvErr] = Task.await(Net.recv(sock))
    if (recvErr) return

    Log.info("my-mod", `echo: ${data}`)
    Net.close(sock)
})()

On success, sock is TCP socket userdata. If the timeout elapses before a connection succeeds, err is "timeout".

If host resolves to multiple addresses (e.g. a dual-stack hostname like localhost, which commonly resolves to both an IPv6 and an IPv4 address), connect tries each resolved address in turn until one succeeds, splitting the timeout across remaining candidates. You don't need to pick a family yourself for TCP. This only matters for UDP, see below.

Net.recv(sock, max_bytes?, timeout?) -> thunk#

Reads up to max_bytes (default 4096) on a background thread, with an optional timeout in seconds (default 30). Resumes with data, err. If the timeout elapses with no data, err is "timeout".

Closing sock (via Net.close) is safe to do immediately after a recv timeout or any other resolved recv. The background thread won't touch the socket again once it has reported a result.

Net.send(sock, data)#

Sends a string on the TCP socket.

Net.close(sock)#

Closes the TCP socket.


UDP#

Address families#

UDP sockets are bound to a single address family for their lifetime, set when they're opened. Use Net.TYPES to pick one explicitly, or pass nothing for the default:

Constant Value Family
Net.TYPES.INET "inet" IPv4 (default)
Net.TYPES.INET6 "inet6" IPv6

A plain string ("inet" / "inet6") works too: Net.TYPES is just a named alternative to typing the string yourself. Any other value falls back to Net.TYPES.INET.

If you try to udp_send or udp_connect to a host that doesn't resolve to an address in your socket's family, for example an INET6 socket targeting a bare IPv4 literal like "127.0.0.1", you'll get back an explicit "address family mismatch" error rather than a generic connect/send failure.

local udp6, err = Net.udp_open(Net.TYPES.INET6)
if err then
    Log.err("my-mod", "udp open failed: " .. err)
    return
end

Net.udp_bind(udp6, 9001)
import * as Log from "Petrichor.Log"

const [udp6, err] = Net.udp_open(Net.TYPES.INET6)
if (err) {
    Log.err("my-mod", `udp open failed: ${err}`)
    return
}

Net.udp_bind(udp6, 9001)

Net.udp_open(family?) -> sock?, err?#

Creates a UDP socket. family is Net.TYPES.INET (default) or Net.TYPES.INET6. On failure returns nil and an error string.

local udp, err = Net.udp_open()
if err then
    Log.err("my-mod", "udp open failed: " .. err)
    return
end
import * as Log from "Petrichor.Log"

const [udp, err] = Net.udp_open()
if (err) {
    Log.err("my-mod", `udp open failed: ${err}`)
    return
}

Net.udp_send(sock, host, port, data) -> err?#

Sends a datagram. Returns an error string on failure, otherwise nil. Failures include both resolution problems and actual send-level errors (e.g. a datagram larger than the OS will send). err describes whichever occurred.

local err = Net.udp_send(udp, "localhost", 9001, "hello from petrichor UDP")
if err then
    Log.err("my-mod", err)
end
const err = Net.udp_send(udp, "localhost", 9001, "hello from petrichor UDP")
if (err) {
    Log.err("my-mod", err)
}

Net.udp_connect(sock, host, port) -> err?#

Sets the socket's default peer with connect(). After this, use udp_send_data instead of udp_send. Returns an error string on failure.

local err = Net.udp_connect(udp, "localhost", 9001)
if err then
    Log.err("my-mod", err)
end
const err = Net.udp_connect(udp, "localhost", 9001)
if (err) {
    Log.err("my-mod", err)
}

Net.udp_send_data(sock, data) -> err?#

Sends a datagram to the peer set by udp_connect. Returns an error string on a send-level failure (e.g. an oversized datagram), otherwise nil.

local err = Net.udp_send_data(udp, "hello from connected UDP")
if err then
    Log.err("my-mod", err)
end
const err = Net.udp_send_data(udp, "hello from connected UDP")
if (err) {
    Log.err("my-mod", err)
}

Net.udp_bind(sock, port) -> err?#

Binds the socket so you can receive datagrams to 0.0.0.0:port for an INET socket, or [::]:port for an INET6 socket. Returns an error string on failure.

local err = Net.udp_bind(udp, 9001)
if err then
    Log.err("my-mod", err)
end
const err = Net.udp_bind(udp, 9001)
if (err) {
    Log.err("my-mod", err)
}

Net.udp_recv(sock, max_bytes?, timeout?) -> thunk#

Receives up to max_bytes (default 4096) on a background thread, with an optional timeout in seconds (default 30). Use with Task.await:

Task.async(function()
    local data, err = Task.await(Net.udp_recv(udp, 4096, 5))
    if err then return end
    Log.info("my-mod", "udp: " .. data)
end)()
import * as Log from "Petrichor.Log"

Task.async(() => {
    const [data, err] = Task.await(Net.udp_recv(udp, 4096, 5))
    if (err) return
    Log.info("my-mod", `udp: ${data}`)
})()

On success, resumes with the datagram string and nil error. If the timeout elapses with no datagram, err is "timeout".

Net.udp_recv_nonblock(sock, max_bytes?) -> {string}#

Drains all datagrams currently waiting on the socket without blocking. Returns a Luau array (1-based table) of strings. max_bytes caps each read (default 4096).

Use this from tick when you want to poll UDP without Task.async. For a single blocking-style receive on a background thread, use udp_recv instead.

function MyMod:tick()
    for _, packet in ipairs(Net.udp_recv_nonblock(self._udp)) do
        Log.info("my-mod", "udp: " .. packet)
    end
end
MyMod.tick = function () {
    for (const packet of Net.udp_recv_nonblock(this._udp) as string[]) {
        Log.info("my-mod", `udp: ${packet}`)
    }
}

Net.udp_close(sock)#

Closes the UDP socket.


Full example (TCP + UDP + Task)#

local ExampleNet = mod("example-net")

local Task    = require("Petrichor.Task")
local Net     = require("Petrichor.Net")
local Log     = require("Petrichor.Log")

function ExampleNet:init()
    Task.async(function()
        local sock, err = Task.await(Net.connect("localhost", 9000, 5))
        if err then
            Log.err("example-net", "connect failed: " .. err)
            return
        end
        Net.send(sock, "hello from petrichor\n")
        local data, recv_err = Task.await(Net.recv(sock))
        Net.close(sock)
        if not recv_err then
            self.log(Levels.INFO, "echo: " .. data)
        end
    end)()

    local udp, udp_err = Net.udp_open()
    if not udp_err then
        Net.udp_send(udp, "localhost", 9001, "hello from petrichor UDP")
        Net.udp_close(udp)
    end

    -- IPv6 UDP works the same way, just pass Net.TYPES.INET6 to udp_open
    local udp6, udp6_err = Net.udp_open(Net.TYPES.INET6)
    if not udp6_err then
        Net.udp_send(udp6, "::1", 9002, "hello over IPv6 UDP")
        Net.udp_close(udp6)
    end
end

return ExampleNet
import * as Task from "Petrichor.Task"
import * as Net from "Petrichor.Net"
import * as Log from "Petrichor.Log"

declare function mod(name: string): any
declare const Levels: { INFO: string; WARN: string; ERR: string }

const ExampleNet = mod("example-net")

ExampleNet.init = function () {
    Task.async(() => {
        const [sock, err] = Task.await(Net.connect("localhost", 9000, 5))
        if (err) {
            Log.err("example-net", `connect failed: ${err}`)
            return
        }
        Net.send(sock, "hello from petrichor\n")
        const [data, recvErr] = Task.await(Net.recv(sock))
        Net.close(sock)
        if (!recvErr) {
            this.log(Levels.INFO, `echo: ${data}`)
        }
    })()

    const [udp, udpErr] = Net.udp_open()
    if (!udpErr) {
        Net.udp_send(udp, "localhost", 9001, "hello from petrichor UDP")
        Net.udp_close(udp)
    }

    // IPv6 UDP works the same way, just pass Net.TYPES.INET6 to udp_open
    const [udp6, udp6Err] = Net.udp_open(Net.TYPES.INET6)
    if (!udp6Err) {
        Net.udp_send(udp6, "::1", 9002, "hello over IPv6 UDP")
        Net.udp_close(udp6)
    }
}

export = ExampleNet

self.log(Levels.INFO, ...) comes from Petrichor.Standard (injected on every mod() instance). Levels is a global from the same module.


See also#