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.
| Handler | Runs |
|---|---|
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.
| Refused | Text |
|---|---|
| a class that is not a declared connection | upgrade takes a declared connection class |
| a connection declared in another script | Connection 'X' is not declared in this script. |
| an identity that is not an object handle | refused; a platform class refuses with a connection cannot speak as a platform class. |
| a seed over 64 KB serialized | refused at upgrade |
| a second upgrade in one request | one upgrade per request. |
conn
| Field | Meaning |
|---|---|
conn.state | json-serializable session table, seeded at upgrade, kept across handlers; past 64 KB the handler raises and the connection ends |
conn.name | the identity's instance name |
conn.class | the 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.closed | on 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" },
})
| Argument | Meaning |
|---|---|
url | ws:// or wss://; the egress policy applies, and every address the host resolves to is checked before any is dialled |
seed | the initial conn.state, optional, 64 KB cap |
options.headers | request headers for the handshake |
options.protocols | the subprotocol list |
| returns | the 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 })
| Argument | Meaning |
|---|---|
request.url | http:// or https://; the egress policy applies |
request.method | default POST with a body, GET without |
request.headers | request headers |
request.body | a string, or a table sent as json with content-type: application/json unless a content type is given |
seed | the initial conn.state, optional, 64 KB cap |
| returns | the 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:
| Response | Frames |
|---|---|
text/event-stream | one per event: its data lines joined with newlines; event, id, retry and comments are dropped |
| anything else | one 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
| Verb | Meaning |
|---|---|
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.statedies 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
eventhandler 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.
Related
- Websockets: the upgrade path and identity.
- Streams: the publisher side.