Byte streams: talking to things outside the engine
A world often needs to speak to something that is not part of it — a socket on another machine, an external process on localhost, a device reached through the program that drives its serial port, a…
A stream is opened by URL. The scheme picks the transport, so tcp:// reaches a connection on the network and loopback:// stays in this process — and a transport added later reaches Luau under its own scheme without this API changing shape.
It goes both ways. stream.open dials out: Zero is the one that connects, and it must find the peer already up. stream.listen holds an address and answers whoever dials in — the shape to reach for when the thing on the other end is a phone, a browser, an external viewer or a driver process that starts, stops and restarts on its own schedule, or when several of them connect at once. Either way what you end up holding is the same thing: a stream handle you write to, drain, and close.
Dial out: open, write, read, close
-- The URL is scheme://target[?k=v]. open() hands back a promise:
-- task.await it to get the stream handle once the transport is up.
local handle = task.await(stream.open("tcp://127.0.0.1:9000"))
local outcome = stream.write(handle, "hello\n") -- bytes, never blocks
local chunk = stream.read(handle) -- everything buffered, "" when nothing
local s = stream.status(handle)
print(s.transport, s.state, s.bytesIn, s.bytesOut, s.pending, s.capacity)
stream.close(handle)
write takes a Luau string and read returns one. Luau strings already carry arbitrary bytes, so binary crosses both ways unharmed — no base64, no escaping. stream.read(handle, max) drains at most max bytes, which is how you pull a fixed-size record out of the buffer and leave the rest.
A connect that is refused raises out of task.await, so wrap it when a failure is expected:
local ok, handleOrErr = pcall(task.await, stream.open("tcp://127.0.0.1:9000"))
if not ok then warn("connect failed: " .. tostring(handleOrErr)) end
Listen, accept
stream.listen holds an address. Every peer that dials it becomes an ordinary stream, taken one at a time from stream.accept:
-- Hold an address. listen() hands back a promise, like open().
local listener = task.await(stream.listen("tcp://127.0.0.1:9000"))
print(stream.listenerStatus(listener).address) -- the address to hand a peer
-- Each tick: take everything that arrived, then drive what you hold.
local peers = {}
while true do
local peer = stream.accept(listener)
if not peer then break end
table.insert(peers, peer)
end
for _, peer in peers do
stream.write(peer, frame) -- the same write as a dialled stream
local said = stream.read(peer) -- the same read
end
accept answers nil when nothing is waiting, which is why the loop above is the shape: call it until it says nil and you have taken every peer that arrived this tick. stream.listenerStatus(listener).pending is how many are still queued behind it.
An accepted connection is a stream handle like any other — write, read, status and close all behave exactly as they do for one stream.open returned, and nothing about it records which side dialled.
Who can reach it
The host in the URL is the interface bound, and the whole of what decides who can reach the listener. There is no default to get wrong — a listen URL has no target-less spelling, so you write the reach you mean:
| URL | Who can connect |
|---|---|
tcp://127.0.0.1:9000 | Programs on this machine only. Nothing off the box can reach it. |
tcp://0.0.0.0:9000 | Any host that can route to this machine on that port — every device on the same wifi, and whatever else the network lets through. |
stream.listenerStatus(listener) reports what you actually got: address is the address the operating system resolved the bind to, and reach is "thisMachine" or "network". A port of 0 asks the operating system to choose a free one, which that same address then tells you — the way to take a port without picking one that is already busy.
Serving a phone or another machine means 0.0.0.0, and means anything else on that network can connect too. Bind loopback unless you want that.
A listener belongs to the chunk that opened it
An address is exclusive: hold it twice and the second bind is refused. So a listener's life follows the chunk that opened it — the chunk whose own code called stream.listen, which is the module holding that line even when something else called into it. When that chunk runs again — a module hot-reload, a cleared require cache — the listener and the connections it answered are closed, and the new run binds the address for itself. The peer sees its connection close and dials back in, which is the behaviour you want from a viewer that should follow your edits.
stream.listeners() names that chunk on every entry, as owner, so which run an address is tied to is read rather than guessed. A chunk that never runs a second time keeps its listener until something closes it by hand — closing by handle from that same list is how the address comes back.
Closing a listener by hand is the other case, and it does less: stream.close(listener) stops it answering new peers and closes the ones nobody took, while the connections accept already handed over keep running until you close them.
Finding what is open
A handle is the only way to reach a listener or a stream, and stream.listeners() and stream.streams() are how you get one back. Each lists what is open in the order it was opened, and each entry carries what stream.listenerStatus / stream.status reports about it plus two more things: the handle it is addressed by, and the owner — the chunk its life follows.
for _, l in stream.listeners() do
print(l.handle, l.address, l.reach, l.accepted, l.owner)
end
That makes reclaiming an address a filter rather than a guess. A run that ended without closing its listener — a chunk that raised between the bind and storing the handle — leaves the address held, and this is what finds it:
for _, l in stream.listeners() do
if l.address == "127.0.0.1:9000" then stream.close(l.handle) end
end
stream.streams() answers the same question one level down, for the connections themselves: which are open, what each is carrying, how much is still queued on it, and which listener's owner it came in under.
Which transports this build carries
stream.transports() is a capability probe, and the thing to call before opening or listening on an unfamiliar scheme:
local t = stream.transports()
if not t.tcp.supported then warn(t.tcp.reason) end -- can't dial it here
if not t.tcp.listen then warn(t.tcp.listenReason) end -- can't hold an address here
It names every scheme this build knows and answers both directions for each: supported for stream.open, listen for stream.listen, each carrying its own reason when false. A scheme can have one and not the other. A typo'd scheme is absent from the table entirely, which is what tells it apart from a real transport this engine does not carry.
loopback://name— carries written bytes back out of the same stream. It works on every platform, needs nothing on the other end, and is what you reach for to test a protocol, to buffer between two pieces of content, or to read frames back in Luau before writing them on somewhere else. Its peer is itself, so nothing dials it and it cannot be listened on.tcp://host:port— dials a TCP peer, and holds an address for peers that dial in. Both directions are native-only, and no scheme holds an address in a browser.tty://device?baud=…— opens a serial device node: a USB CDC board such as an ESP32 (/dev/ttyACM0,/dev/ttyUSB0,COM5on Windows), or a controller paired over classic Bluetooth SPP (/dev/rfcomm0, after an OS-levelrfcomm bind). Native-only, and a device node is opened rather than bound, so nothing dials it.stream.serialPorts()reports which devices are attached; its own section is below.ble://device?service=…&write=…¬ify=…— connects to a Bluetooth Low Energy device over GATT, on a desktop engine and in a browser alike. It is the wireless transport a web world reaches a device through, since a browser has no way to open a serial port or a classic-Bluetooth link. Its own section is below.
Serial: which port is the board on today
A serial device's path is not stable. An ESP32 over USB CDC comes up at /dev/ttyACM0 or /dev/ttyACM1 depending on what was plugged in first, and moves across COM3-COM5 on Windows. Hardcode one and the content breaks the next time the board is replugged, without being able to say which port to use instead. stream.serialPorts() is how you find it:
for _, p in stream.serialPorts().ports do
if p.vendorId == 0x303A then -- Espressif
local board = task.await(stream.open(p.url .. "?baud=115200"))
end
end
The path is the part that moves; what a USB device advertises is the part that holds still. Match on vendorId/productId to find a model of board, or on serialNumber to tell two of the same board apart, and open the url that entry carries — it is ready for stream.open, which is also how you avoid writing the tty:// plus absolute device node triple slash (tty:///dev/ttyACM0) wrong. kind names the bus the device is on (usb, pci, bluetooth, unknown) and only a usb one carries identifiers; manufacturer and product are what to show a person picking from a list.
An empty list means this machine has nothing attached — not that this build cannot enumerate. Those are three separate answers, and content that collapses them takes the wrong branch:
local r = stream.serialPorts()
if not r.supported then
warn(r.reason) -- no serial bus here at all — what a browser reports
elseif r.error then
warn(r.error) -- the OS refused this enumeration; a later call may answer
elseif #r.ports == 0 then
warn("no serial device attached") -- an ordinary result, not a failure
end
Bluetooth: reaching a device over GATT
A tcp stream needs a host and a port. A GATT peer needs three things, and all three are in the url: the device to connect to, the service on it that holds the conversation, and the characteristics the bytes travel over — one this engine writes to, one it subscribes to for what the device sends back.
local paw = task.await(stream.open(
"ble://Paw*?service=6e400001-b5a3-f393-e0a9-e50e24dcca9e"
.. "&write=6e400002-b5a3-f393-e0a9-e50e24dcca9e"
.. "¬ify=6e400003-b5a3-f393-e0a9-e50e24dcca9e"
))
The device is the name it advertises. * takes any device offering the service — the usual form in a browser, where the person at the machine picks from the chooser it shows — and a trailing * matches a prefix, so Paw* finds Paw-0417.
write and notify are named for what this engine does with them. A datasheet's rx/tx are written from the peripheral's side, so each end of the link reads them as the other one: a peripheral's RX characteristic is the one a central writes to, and its TX is the one a central subscribes to. On a Nordic UART Service that makes write=…0002… and notify=…0003…; on an HM-10-style module with one bidirectional characteristic, both name ffe1. Every UUID may be written 16-bit (ffe0), 32-bit, or in full.
Three optional parameters: chunk bounds what one packet carries (1-512 bytes; left out, it is the negotiated MTU where the platform reports one and the 20 bytes every BLE connection carries where it does not), writeMode is withResponse (the default, one acknowledgement per packet) or withoutResponse (unacknowledged, for throughput), and timeout is how many seconds finding and connecting to the device may take before the stream closes naming what it looked for.
A GATT link carries packets rather than a byte flow, and the transport keeps your write boundaries where it can: a write that fits one packet is delivered as one packet, and a longer one is split across as many as it needs. A device that treats one write as one message receives what was written.
Connecting is a state, not a wait
A ble stream is handed back the moment it exists, and reports the rest of its connecting as state — because finding a device takes as long as the device takes to advertise, and in a browser it takes the person at the machine choosing one:
local s = stream.status(paw)
if s.state == "opening" then -- looking for the device
elseif s.state == "permissionPending" then
print(s.error) -- what the person has to do: click to choose a device
elseif s.state == "denied" then
print(s.error) -- they said no; ask again when they are willing
elseif s.state == "open" then -- connected, subscribed, carrying bytes
elseif s.state == "closed" then
print(s.error) -- no device answered, or the connection ended
end
A browser opens its device chooser only while a click is still in effect. A stream opened outside one waits for the next click, and says so: permissionPending with error carrying what the person at the machine has to do. That wait is the normal path rather than a fault, which is why it is a state you read rather than an error the open raised. denied is the answer being no — terminal the way closed is, and told apart from it because a refusal can be asked for again once they are willing.
Writes are taken throughout. What you write while it connects is queued and goes out when it does, so opening and writing in one breath delivers.
stream.transports().ble answers the question before any of that: in a browser the page itself reports whether that browser offers Web Bluetooth, whether the page has the secure context it needs, and whether the machine has a radio switched on, and reason carries which of those it is.
Writes never block — they answer
stream.write returns immediately with one of four outcomes, and each means something different for the caller:
| Outcome | Meaning | What to do |
|---|---|---|
"accepted" | The bytes are queued for the peer. | Carry on. |
"full" | Backpressure: the peer is alive but draining slower than you are producing. | Retry once it catches up; watch pending against capacity. |
"closed" | The stream is finished, or the handle names no open stream. | Reopen — retrying never succeeds. |
"tooLarge" | The write is bigger than the stream's whole outbound capacity, so it can never fit at any queue depth. | Split it, or reopen with a bigger outboundCapacity. |
stream.open(url, { inboundCapacity = N, outboundCapacity = N }) sets those bounds (65536 bytes each by default). Size them to the largest single thing you will write — a whole video frame, a whole message — times the jitter you want absorbed.
Accepted means queued, and close carries the queue
"accepted" means the bytes are queued for the peer, not that they have gone out: write returns to you immediately and the transport puts them on the wire after the call. pending on stream.status(handle) is exactly what is queued and not yet handed over, and it falls to zero as the bytes go.
stream.close(handle) carries that queue out before the connection ends. So the shape a request-and-one-response has — write the answer, close the connection, both in the same tick — delivers:
stream.write(peer, response)
stream.close(peer) -- the queued response goes out, then the connection ends
A peer that has stopped reading altogether holds that finish for thirty seconds; past that the connection ends and whatever is still queued ends with it. Nothing is counted as dropped for it — droppedWrites and droppedBytes count backpressure alone, writes that were refused where you could still see them. When you must know what you wrote reached the socket, watch pending reach zero before you close, which is the same number backpressure is read from:
stream.write(peer, response)
while (stream.status(peer) :: any).pending > 0 do task.wait() end
stream.close(peer)
stream.status(handle) is the running account: state ("opening" / "permissionPending" / "open" / "denied" / "closed"), the bytes carried each way, pending against capacity, the writes and bytes dropped, and error holding the most recent transport failure for the life of the stream.
A listener has the same pre-loss shape in its own terms. stream.listen(url, { backlog = N }) bounds the connections held for stream.accept, and stream.listenerStatus(listener) reports pending against capacity for them. At the backlog the listener stops taking connections from the operating system, which leaves the rest in the kernel's own queue — a peer waits to be answered rather than being answered by a listener that then forgets it. accepted counts what accept has handed over. stream.listen(url, { inboundCapacity = N, outboundCapacity = N }) sets the byte bounds every connection it answers is built with.
What travels a stream
Bytes, and only bytes — the framing is yours. A stream carries what you write in the order you wrote it, so a protocol with messages in it needs a length, a delimiter or a fixed record size that both ends agree on. That is a few lines of Luau on top, and it keeps the wire format in content where it can change without an engine build.
Two things in the engine already speak streams:
frameStreamcarries the frames a camera renders into a stream, so what the engine draws reaches another program as raw pixels, live — the frame-streaming guide.stream.readin a task is how content consumes an incoming feed: drain once a frame, parse what is complete, leave the remainder buffered for the next tick.
Finding the rest
lsp.methods("stream") lists this API and lsp.describe("stream.listen") gives its exact signature. For requests to a web service rather than a raw connection, http.get_json / http.post_json are the higher-level surface; where the peer speaks HTTP and wants a path rather than a socket, httpServer.route serves one from this engine (the http-server guide).
The model to hold: one URL names one transport and one address, a handle names one open pipe or one held address, and everything above that — messages, frames, protocols — is content you write on top of the bytes.