ACTIAS

connection

Declared connection classes, their handlers, and the conn value.

Declare a class

local Session = connection "Session" {
    open = function(conn) end,
    frame = function(conn, data) end,
    event = function(conn, event) end,
    timer = { every = "30s", run = function(conn, missed) end },
    close = function(conn) end,
}

Each handler is optional; a key outside these five refuses at declaration. Nothing calls a connection, so there are no methods: handlers run when the wire, an edge or the timer delivers.

HandlerRuns
open(conn)once, before any item; the follow calls usually live here
frame(conn, data)per JSON text frame the client sent; binary and non-JSON frames are dropped
event(conn, event)per delivered edge event; takes a policy instead of a function (below)
timer.run(conn, missed)every timer.every, one second or more; missed counts ticks that fell during a previous run
close(conn)after the wire ends, either side; edges sever after it returns

Handlers for one connection run one at a time, in arrival order, each under the per-call budget. A tick is never queued behind the inbox, and a connection with a timer never hibernates.

The forward policy

event takes a policy in place of a function, the same hook-or-policy shape publishes uses for topics:

local Account = objects "Account"

local Feed = connection "Feed" {
    open = function(conn) conn:follow(Account(conn.name), "events") end,
    event = "forward",
}

Each delivery then reaches the client as

{ "type": "event", "topic": "...",
  "from": { "id": "...", "class": "...", "name": "..." },
  "data": { } }

built by the platform, with no vm involved, so a hibernated connection stays hibernated while it relays. Declare a function instead to choose your own wire shape, and pay a handler call per event for it.

Declaring neither drops deliveries, which is what you want only when the follow exists to be counted (state:followers) rather than heard.

Upgrading

if request.upgrade and request.path == "/ws" then
    return request:upgrade(Session, { room = "lobby" }, User(name))
end

request:upgrade(Class, seed?, identity) returns the value your fetch handler should return. The seed becomes the initial conn.state; the identity is an object handle the connection speaks as for the rest of its life. Two arguments means no seed.

RefusedText
a class that is not a declared connectionupgrade takes a declared connection class
a connection declared in another scriptConnection 'X' is not declared in this script.
an identity that is not an object handlerefused; a platform class refuses with a connection cannot speak as a platform class.
a seed over 64 KB serializedrefused at upgrade
a second upgrade in one requestone upgrade per request.

conn

FieldMeaning
conn.statejson-serializable session table, seeded at upgrade, kept across handlers; past 64 KB the handler raises and the connection ends
conn.namethe identity's instance name
conn.classthe identity's class
conn:send(value)writes one JSON text frame
conn:follow(target, topic, filter?)makes a connection edge; returns true
conn:unfollow(target, topic)removes it
conn:close()ends the connection
conn.closedon the close handler only: { by, reason? }, where by is peer, program, error or overflow

The event a handler receives is shaped { topic = "...", from = { id, class, name }, data = {...} }.

Opening a wire outward

The same class dials as well as accepts:

local name = Upstream:open("wss://api.example/realtime", { room = "lobby" }, {
    headers = { Authorization = "Bearer " .. key },
    protocols = { "json" },
})
ArgumentMeaning
urlws:// or wss://; the egress policy applies, and every address the host resolves to is checked before any is dialled
seedthe initial conn.state, optional, 64 KB cap
options.headersrequest headers for the handshake
options.protocolsthe subprotocol list
returnsthe instance name, once the handshake is up; the call raises with the reason otherwise, and gives up after 10 s

The connection speaks as its own class and the minted name, lives on the node that opened it, and is listed in the console's Connections page beside the inbound ones. There is no socket handle: to send up the wire, publish on a topic the connection follows, and send the payload from its event handler. See wires this project opened for the pattern and who should own the reopen.

Streaming a response

The same class reads an HTTP response as frames:

local name = Model:stream({
    url = "https://api.openai.com/v1/chat/completions",
    headers = { authorization = "Bearer " .. secret "OPENAI_API_KEY" },
    body = { model = "gpt-4o-mini", stream = true, messages = messages },
}, { chat = chat })
ArgumentMeaning
request.urlhttp:// or https://; the egress policy applies
request.methoddefault POST with a body, GET without
request.headersrequest headers
request.bodya string, or a table sent as json with content-type: application/json unless a content type is given
seedthe initial conn.state, optional, 64 KB cap
returnsthe instance name once the response headers are in with a success status; any other status raises with the status and the start of the body, and the call gives up after 10 s

How the body becomes frames:

ResponseFrames
text/event-streamone per event: its data lines joined with newlines; event, id, retry and comments are dropped
anything elseone per line; empty lines dropped; the last line reaches you without a trailing newline too

Each frame reaches frame(conn, data) as json when it parses and as a string when it does not, so data: [DONE] arrives as the string "[DONE]". A frame longer than 1 MiB without its delimiter ends the wire. The body's end runs close with by = "peer". A stream has no uplink: conn:send raises on it, and its event handler receives what it follows like any connection.

A wire's liveness

Class(name) is the handle of a wire this project opened, from the name open or stream returned:

if not Upstream(state.store:get("upstream")):alive() then
    state.store:delete("upstream")
end
VerbMeaning
Class(name):alive()true when a live node holds the wire; false after it ended, and after the node that held it died, which is the case no close handler reports

Example

local Account = objects "Account"
local Auction = objects "Auction"

local Feed = connection "Feed" {
    open = function(conn)
        conn:follow(Account(conn.name), "events")
    end,

    -- The platform envelope, without waking this program.
    event = "forward",

    frame = function(conn, data)
        if data.op == "watch" then
            if conn.state.watching then
                conn:unfollow(Auction(conn.state.watching), "bid")
            end
            conn.state.watching = data.id
            conn:follow(Auction(data.id), "bid")
        end
    end,
}

on "fetch" (function(request)
    if request.upgrade and request.query.user then
        return request:upgrade(Feed, nil, Account(request.query.user))
    end
    return { status_code = 404, body = "" }
end)

Gotchas

  • Locals in the fetch handler do not reach the handlers; a session's context travels through the seed into conn.state.
  • conn.state dies with the connection on purpose. Anything that must survive belongs on an object one hop back.
  • Connection edges are at-most-once and die with the connection.
  • The inbox holds 256 items. Deliveries that overflow it sever every edge the connection holds and leave the socket open; client frames that overflow it close the wire.
  • The only frame the platform defines is the forwarded event envelope above, and declaring an event handler replaces it. Every other frame on the wire is one your handlers send or interpret, so follow targets come from your code, never from a string the client sent.