Websockets
A fetch handler upgrades a request into a declared connection whose handlers run per item.
Declaring a connection
A connection's program is a top-level declaration, the same shape as an object class: named handlers for the wire's stimuli.
local Room = objects "Room"
local Session = connection "Session" {
-- Runs once per connection, before any item. conn.state was
-- seeded by the upgrade call.
open = function(conn)
conn:follow(Room(conn.state.room), "message")
end,
-- A json frame the client sent up the wire.
frame = function(conn, data)
Room(conn.state.room):post(conn.name, data.text)
end,
-- A delivered edge event. A function chooses a custom wire shape;
-- event = "forward" sends the platform envelope instead, without
-- running any code at all.
event = function(conn, event)
conn:send({ event = "message", text = event.data.text })
end,
-- A heartbeat on the connection's own clock.
timer = { every = "30s", run = function(conn, missed)
conn:send({ event = "ping" })
end },
-- The wire ended, either side. Edges are severed by the platform
-- after this returns; cleanup here is app-level courtesy.
close = function(conn) end,
}
Nothing calls a connection, so a body declares handlers, not methods.
A key outside open, frame, event, timer and close refuses at
declaration, and actias check verifies the shapes.
Upgrading
A websocket handshake arrives as an HTTP request, so it authenticates in the same router as everything else.
local User = objects "User"
on "fetch" (function(request)
if request.upgrade and request.path == "/ws" then
local name = request.query.user
if name == nil then
return { status_code = 401, body = "log in first" }
end
return request:upgrade(Session, { room = "lobby" }, User(name))
end
return { status_code = 404, body = "" }
end)
request.upgrade is present only when the request is a real
handshake. request:upgrade(Class, seed?, identity) names the
declared connection class, optionally seeds conn.state, and fixes
the identity the connection speaks as. Whatever the handler computed
travels only through the seed.
conn.state
A json-serializable table, alive exactly as long as the connection: seeded at upgrade, visible to every handler, written back after each one returns. The seed and every later write cap at 64 KB serialized; past it the handler raises and the connection ends.
It is not durable storage. A connection dying with its node loses it, and the client's reconnect path re-reads real state from objects.
Identity
The identity argument is an object handle: User(name),
Account(name). The connection speaks as that identity, and a
stream gate sees it shaped like any follower.
Mint it after authenticating. It is fixed for the life of the connection and cannot be changed by anything the client sends.
Sending and receiving frames
conn:send(value) writes one JSON text frame. The wire carries JSON
text only: a binary frame, or text that does not parse as JSON, is
dropped without reaching frame.
Apart from the envelope a class asks for with event = "forward",
there is no platform wire protocol: the frames on the wire are the
ones your handlers send. To let clients change subscriptions, define a
frame for it:
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,
Timers
timer = { every = "30s", run = function(conn, missed) end } runs on
the connection's own clock, one second or longer. missed counts
ticks that fell during a previous run; the next deadline realigns to
the grid rather than drifting. A tick never queues behind the inbox,
so a heartbeat cannot overflow it. A connection with a timer never
hibernates.
Connection edges
conn:follow makes an edge like state:follow does, with different
promises:
| object edge | connection edge | |
|---|---|---|
| delivery | at-least-once | at-most-once |
| retry | yes, with backoff | none |
| on failure | retried, then dropped | edge removed |
| lifetime | until unfollowed | dies with the connection |
A tab that closes takes its edges with it. Nothing accumulates for dead connections. Put durability one hop back: the account object follows what must not be missed; the connection follows the account for live updates.
Warm, hibernated, gone
flowchart TD
hs([handshake]) --> reg[register the connection id] --> open[open handler]
open --> loop{next inbox item<br/>wire frame, edge delivery, tick, close}
loop -- declared handler --> vm[build the vm if it is gone,<br/>run the handler, write back conn.state] --> loop
loop -- event = forward --> fwd[platform writes the envelope,<br/>no vm] --> loop
loop -- no handler declared --> loop
loop -- idle 300 s --> hib[drop the vm; keep the socket,<br/>the inbox, conn.state, the follows] --> loop
loop -- wire closed, conn:close, a handler threw --> close[close handler] --> sever[sever follows,<br/>unregister] --> gone([gone])
| State | Holds | Cost | Leaves it |
|---|---|---|---|
| warm | a vm and everything below | a vm | 300 s idle drops the vm (CONNECTION_HIBERNATE_SECS; 0 disables) |
| hibernated | the socket, the inbox, conn.state, the follows | about a file descriptor | anything with a declared handler to run rebuilds the vm |
| gone | nothing | zero | is the end |
Hibernation is invisible from both ends of the wire: the socket stays
open, deliveries land in the inbox, and conn.state is exactly what
the last handler left. It is a memory economy, not durability.
An undeclared handler costs nothing at all: a close with no close
handler never builds a vm to discover there is nothing to run. Neither
does a delivery to a class that declared event = "forward", since
the platform writes that frame itself.
Backpressure and limits
Handlers for one connection run one at a time, in arrival order, each as one platform call under the call budget. Each connection has an inbox of 256 items.
| Inbox full because of | What happens |
|---|---|
| edge deliveries the handlers cannot keep up with | the delivery is refused and every edge the connection holds is severed; the socket stays open, silent |
| client frames arriving faster than the handlers run | the wire is closed |
A handler throwing, conn:close(), or the peer disconnecting all end
the connection and remove the edges it made.
Wires this project opened
A connection is a wire with a program attached, and which side opened
the wire does not change that. Class:open(url, seed?, options?)
dials a ws:// or wss:// url through the egress policy and runs the
same handlers over it: open once the handshake is up, frame per
message from the far side, event for what it follows, close when
it ends. It hibernates and holds conn.state like any other
connection, lives on the node that opened it, and shows up in the
console's Connections page marked outbound.
local Upstream = connection "Upstream" {
open = function(conn)
conn:follow(Chat(conn.state.room), "upstream")
Chat(conn.state.room):wire_up()
end,
event = function(conn, event) conn:send(event.data) end,
frame = function(conn, data) Chat(conn.state.room):token(data) end,
close = function(conn) Chat(conn.state.room):upstream_closed(conn.closed) end,
}
Three rules make it easy to hold right.
The wire says when it is up. open returns when the handshake is
up, not when the wire's open handler has followed anything, and that
handler's follow lands only after the call that opened the wire
returns. So the handler follows and then posts into the object, and
the object sends what was waiting from there; a publish in the same
call that opened the wire reaches nobody.
Sending up the wire is publishing. The connection has no socket
handle in Lua. Whoever wants to send publishes on a topic the
connection follows, and the event handler sends the payload raw.
The publisher's code is the same whether the wire is up, reopening or
gone, and a topic meant for the wire alone is guarded by the object's
follow hook admitting only that class:
publishes = { tokens = "public", "upstream" },
hooks = {
follow = function(state, topic, follower)
return topic == "tokens" or follower:is(Upstream)
end,
},
An object owns the intent. An inbound connection is unique because
the client's wire is; an outbound one has nobody to make it unique,
so three requests calling open open three wires. The thing that
decides exactly one should exist, and reopens it when it drops, is a
leased single-home job: an object. It stores the name open
returned, opens one only when nothing is stored, and cannot race
itself because it takes one call at a time.
Knowing a wire dropped, in order of trust:
- The
closehandler runs on every end except a node death, withconn.closed.bysaying who ended it:peer,program,errororoverflow, andreasonwhen there is one. Post into the owning object from it. - The next send: a publish into a topic the gone wire followed reaches nobody, and the object's next call finds no stored name and opens again.
- A node death runs no handler at all. The object armed an alarm
when it took the work; an alarm is a row in the placement store
until it fires, so it fires wherever the object wakes next, and its
handler reopens the wire and resends. This is why the intent lives
in the object and not in the connection, and why the watchdog is
an alarm rather than
init, which runs once per instance ever. Before reopening, the alarm asksUpstream(name):alive(): the registry answers whether any live node still holds the wire, so a stored name from before a node death is found stale rather than trusted.
A response can be a wire too. Class:stream(request, seed?) sends an
HTTP request and delivers the response body to frame as it arrives:
one frame per event under text/event-stream, one per line
otherwise, json when it parses. This is what a model API that streams
tokens over SSE looks like from a script, and it is the same
connection in every other way: it follows, hibernates, is listed, and
runs close when the body ends. It has no uplink, so conn:send
raises on it; the request is the whole of what goes up.
A wire dropping mid-answer loses the rest of that answer. What the
frame handler already posted into the object is durable, since each
post is an ordinary write; the object discards its draft, tells its
followers to expect a retry, and resends. Whether the far side can
continue a partial answer is the far side's feature.
Gotchas
- Follow targets are handles named in your code, never strings taken
from the wire. A client sends
{op = "watch", id = "lot-42"}and your code decides that meansAuction("lot-42"). - A dropped frame is not reported. At-most-once means the client can miss one and never learn that it did, which is why reconnect should re-read state rather than resume.
- A connection whose edges were severed for overflow learns nothing either. A client that stops receiving should reconnect.
- The identity is fixed at upgrade. Nothing the client sends later can change what the connection speaks as.
- Locals captured in the fetch handler do not reach the handlers;
anything a session needs travels through the seed into
conn.state. - An outbound wire is not replayed. The platform tells you it ended and lets the retry be one method call; frames sent between the drop and the reopen are gone, as for any client that disconnected.
Related
- Streams: the publisher side and who may follow.
- connection reference: every handler and verb.