Durable objects, workflows and realtime, in one file.
Publish a script and it answers at a URL. Whatever it asks for exists the first time it runs. Open source, and yours to run.
local visits = kv "visits"
on "fetch" (function(request)
local count = (visits:get("count") or 0) + 1
visits:set("count", count)
return {
body = json.stringify({ visits = count }),
}
end)One line each
Nothing here exists until your code asks for it. Then it does, and the console can show you inside it.
Requests arrive typed. Static files in the bundle get served without a route.
The small things you look up by name. Sessions, flags, counters.
A database you query properly. Migrations get applied the first time anything touches it.
A named thing that exists once, takes one call at a time, keeps its own database file that can grow to a gigabyte.
One row per object, derived after every write. Query a class without waking anything in it.
An object wakes itself later. The alarm lives in its file, so it survives the worker.
Answer now, do the slow part after. Failures wait in a dead letter queue you can read.
Journaled steps, so a run can wait days for approval and pick up where it stopped.
Publish once and everything following hears it, browser tabs included.
A socket with a program: what it follows, what a frame does. Its vm can go while the wire stays open.
Versioned keys you can rotate without a deploy. Never in the bundle.
The schedule lives in the file that runs on it.
Two bids, the same millisecond
Usually this is where you reach for a transaction, or a lock, or a Redis mutex you are fairly sure you got right.
An object here takes one call at a time. The second bid waits, reads the number the first one wrote, and loses. Nothing in the example above locks anything, because there is nothing to lock.
The console is reading that object's own SQLite file. Same for queue journals and workflow runs. The shell asks the same things in a line of Lua, from the console or the cli.
One writer is a placement decision, not a lock
leases and placementNothing is running. Auction("lot-42") is a name and a SQLite file sitting in storage, so having a million of them costs storage and no memory.
Idle instances cost storage, not memory, so a project can hold a million names and only pay for the ones in use.
Take a peek: a chat platform, all Actias
websocketsRooms, threads, presence, mentions, moderation, a nightly digest. Hover a box to read what it is, click to pin it, and switch the view to watch a message travel or see what happens when everybody logs off.
One project, one repository. Every box is a line somebody wrote, and the lines between them are calls and streams. Hover either to read what it does.
Every call in the drawing, in words
- the open socket
conn:send(value) - The wire itself, held by the connection rather than by a handler. Frames the client sends arrive at its frame handler, and whatever it follows arrives at its event handler.
- an ordinary request
on "fetch" (handler) - Not every tab holds a socket. A plain request hits the same handler, gets a clean run, and is gone.
- the upgrade
request:upgrade(Session, seed, User(name)) - A handshake is an ordinary request, so it authenticates in the same router. The upgrade names the connection class, seeds conn.state, and fixes the identity the socket speaks as.
- connection edge
conn:follow(Channel(room), "message") - Both directions of the socket path. The frame handler posts into the room, and the follow brings the room’s published events back. At-most-once, no retry, and it dies with the socket, so anything that must not be missed is followed one hop back by an object.
- serving the client
assets served from the bundle - The same handler serves the front end. Static files ride in the bundle with the script, so the client and the code it talks to ship together.
- gateway to server
Server(id):channels() - The handler asks the community object for its channel list, roles and invites.
- gateway to presence
Presence(user):touch() - Connecting marks the member online inside their own object rather than a shared table.
- an http post
Channel(name):post(msg) - The path for clients without a socket open. Same call into the room, made from an ordinary request instead of a frame.
- gateway to a sleeping room
Channel(name):post(msg) - The same call to a room nobody is using. It wakes where its file is and answers.
- gateway to thread
Thread(id):post(msg) - Threads take posts directly, so a busy thread never queues behind its room.
- listing the rooms
Channel:list { where = { … } } - The handler lists rooms from the index. Nothing wakes; the rows are what each room last wrote about itself.
- a row per write
directory = { from, fields } - After any call that wrote, the room derives its row and the index takes it. Methods never mention the index, so the row cannot drift from the state it reads.
- nightly digest
on "schedule" (handler) - The schedule walks each community once a night and hands the mail to the queue.
- presence stream
state:publish("presence", …) - Presence publishes, the server follows. The member owns the write, so nobody contends for a shared row.
- server to room
Channel(name):configure(…) - Renames, topics and permissions travel from the community object into the room.
- thread to room
Channel(name):bump(thread) - A thread tells its room it has activity, so the room list can order itself without scanning anything.
- starting a workflow
workflow "Report" (fn) - A report starts a run that parks until a moderator decides, then continues on its own.
- queue reads prefs
kv "settings":get(user) - Before sending anything the consumer checks whether that member wants the push at all.
- indexing
sql "search":exec(…) - The room writes a copy into the shared index, which is how search spans rooms without touching their private files.
- mention to queue
queue "push":send(mention) - Mentions leave the request path here.
- consumer reads the secret
secret "APNS_KEY" - The credential is read at send time. It is versioned, rotatable, and never inside the bundle.
- the room asks the model
state:publish("upstream", prompt) - The room publishes the prompt on a topic its follow hook admits only the model wire to; the wire sends it raw. Tokens come back as calls into the room, one at a time, each durable before the next is handled.
- leaving Actias, from inside
wss to the provider - The other hop that is not yours, this one dialled by the project. When the provider drops it, the close hook says why and the room reopens it; when the node holding it dies, the room’s alarm does.
- leaving Actias
http to APNs - The one hop that is not yours. What fails comes back as a retry and eventually a dead letter you can read.
- object storage
state.sql - Every instance owns a SQLite file. Loading and flushing happen at the edges of its life, not on every call.
Hosted, and Wasm
Today it runs on your machine and the APIs still move without much warning. Two things change who it is for.
Leave an address and it goes on the list for the first announcement: what shipped, what broke, and when hosted opens.
Five minutes on your own machine
Compose up, init, publish, hit the URL. If something falls over, open an issue.