Log inGet started
World

Builtin Library

Branchmain
Branchesmainpublish
Commit3bb1da2647

README.md

stream Module

Public Luau surface over the __stream Internal FFI namespace — named byte streams by URL scheme.

Purpose

Wrap the raw __stream.* FFI namespace in a typed Luau table that gets auto-injected as _G.stream via the prelude. A stream is opened by URL (scheme://target[?k=v]); the scheme picks the transport — loopback works on every platform and echoes written bytes back out of the same stream, tcp dials host:port on native, tty opens a serial device node on native, ble connects to a Bluetooth Low Energy device over GATT on native and in a browser. Handles are opaque strings; write and read cross bytes with no encoding layer, since Luau strings already carry arbitrary bytes.

A stream comes from one of two directions. stream.open dials out. stream.listen holds an address and stream.accept hands over each peer that dials in, as a stream handle indistinguishable from a dialled one — which is the shape to reach for when the thing on the other end restarts on its own schedule, or when several of them connect at once.

Always check stream.transports() before opening an unfamiliar scheme — it names every scheme this build knows, whether it can be dialled and whether it can be listened on here, and why not for whichever it can't, so a mistyped scheme reads differently from a transport this engine does not carry.

Usage

-- Open a stream. open() returns a promise handle; task.await() it to
-- get the stream handle, or it raises the refusal reason.
local pending = stream.open("loopback://echo")
local handle = task.await(pending)

local outcome = stream.write(handle, "hello")
if outcome == "full" then
    -- backpressure: the peer is alive, just draining slower than this
    -- call is producing. Compare status().pending against .capacity
    -- and retry once there's room.
elseif outcome == "closed" then
    -- the stream is finished; reopen to continue.
elseif outcome == "tooLarge" then
    -- the write can never fit at any queue depth; split it smaller.
end

local chunk = stream.read(handle)
local s = stream.status(handle)
print(s.transport, s.state, s.pending, s.capacity)

stream.close(handle)

A tcp stream dials a real host:

local pending = stream.open("tcp://127.0.0.1:9000")
local ok, handleOrErr = pcall(task.await, pending)
if not ok then
    warn("connect failed: " .. tostring(handleOrErr))
end

Listening

stream.listen holds an address; each peer that dials it becomes an ordinary stream handle, taken one at a time from stream.accept:

local listener = task.await(stream.listen("tcp://127.0.0.1:9000"))
print(stream.listenerStatus(listener).address)   -- what to hand a peer

-- Each tick: take everything that arrived, then drive what you hold.
while true do
    local peer = stream.accept(listener)
    if not peer then break end
    table.insert(peers, peer)
end

The host in the url is the interface bound, and the whole of what decides who can reach the listener. tcp://127.0.0.1:9000 answers only programs on this same machine. tcp://0.0.0.0:9000 answers any host that can route to this machine on that port. Write the one you mean; stream.listenerStatus(listener).reach reports which of the two you got, as "thisMachine" or "network".

A listener belongs to the chunk that opened it. When that chunk runs again — a module hot-reload, a cleared require cache — the listener and the connections it answered are closed, so the new run binds the address for itself and peers dial back in. Closing a listener by hand instead stops it answering new peers and leaves the connections already taken running.

Serial devices

A tty stream opens a serial device node — a USB CDC board such as an ESP32 (/dev/ttyACM0, /dev/ttyUSB0, or COM5 on Windows) and a Bluetooth controller paired over classic SPP (/dev/rfcomm0, after an OS-level rfcomm bind) both present as a tty on Linux, so the same URL form reaches either peer:

local pending = stream.open("tty:///dev/ttyACM0?baud=115200")
local ok, handleOrErr = pcall(task.await, pending)
if not ok then
    warn("open failed: " .. tostring(handleOrErr))
end

tty query parameters: baud (default 115200), dataBits (5 - 8, default 8), parity (none | odd | even, default none), stopBits (1 or 2, default 1). A device node is opened rather than bound, so nothing dials a tty and stream.listen refuses it.

Bluetooth Low Energy

A ble stream connects to a device over GATT, on a desktop engine and in a browser alike. Three things address a GATT peer, and all three are in the url: the device, the service on it, and the characteristics the bytes travel over.

local paw = task.await(stream.open(
    "ble://Paw*?service=6e400001-b5a3-f393-e0a9-e50e24dcca9e"
    .. "&write=6e400002-b5a3-f393-e0a9-e50e24dcca9e"
    .. "&notify=6e400003-b5a3-f393-e0a9-e50e24dcca9e"
))

-- The stream is handed back while it is still connecting; watch it get
-- there, and write whenever — what is written first goes out first.
while stream.status(paw).state == "opening"
   or stream.status(paw).state == "permissionPending" do
    task.wait()
end
if stream.status(paw).state ~= "open" then
    warn(stream.status(paw).error)
end

The device is the name it advertises: * takes any device offering the service (the usual form in a browser, where the person picks from the chooser), and a trailing * matches a prefix.

write names the characteristic this engine writes to and notify the one it subscribes to — on a Nordic UART peripheral, that peripheral's RX and TX, which is why they are named for what this end does rather than for the rx/tx of a datasheet. A module with one bidirectional characteristic (an HM-10's ffe1) names it for both. UUIDs may be 16-bit (ffe0), 32-bit, or the full dashed form.

Optional parameters: chunk (bytes one packet carries, 1-512 — otherwise what the connection carries, which is the negotiated MTU where the platform reports it and the 20 bytes every BLE connection carries where it does not), writeMode (withResponse | withoutResponse, default withResponse), timeout (seconds to find and connect to the device, default 15).

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.

Permission is a state, not an error. A browser asks the person at the machine to pick the device, and only while a click is still in effect, so a stream opened outside one waits for the next click: stream.status(handle).state reads permissionPending with error carrying what they have to do, and denied if the answer is no — terminal like closed, and told apart from it because a refusal can be asked for again. stream.transports().ble says whether the browser can reach Bluetooth at all before any of that.

Write outcomes

stream.write never blocks — it returns one of four outcomes, and each means something different for the caller:

  • "accepted" — the bytes are queued for the peer.
  • "full" — backpressure. The outbound queue has no room right now, but the peer is alive and consuming; a retry after it drains may succeed. Compare pending against capacity on stream.status() to see this coming before a write is refused.
  • "closed" — the stream is finished, or the handle names no open stream. Retrying never succeeds; reopen to continue.
  • "tooLarge" — the write is bigger than the stream's whole outbound capacity, so it can never fit at any queue depth. Retrying the same write returns this again.

"accepted" means queued, not sent: the transport carries the bytes after the call returns, and stream.status(handle).pending is what is queued and not yet handed over. stream.close carries that queue out before the connection ends, so writing an answer and closing in the same tick delivers it. A peer that has stopped reading holds that finish for thirty seconds, after which the connection ends and what is still queued ends with it — watch pending reach zero before closing when you must know the bytes went out.

Finding what is open

stream.listeners() and stream.streams() list what this engine holds, in the order it was opened. Each entry is the status of the thing plus the handle it is addressed by and the owner chunk its life follows, so an address whose handle nobody holds is reclaimed by filtering on the address rather than by guessing at handles:

for _, l in stream.listeners() do
    if l.address == "127.0.0.1:9000" then stream.close(l.handle) end
end

Transports

stream.transports() is a capability probe, not a connection check — it names every scheme this build knows about 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 loopback stream's peer is itself, so it opens everywhere and nothing dials it — and on wasm no scheme carries a backend that can hold an address. In a browser, ble is the entry the page itself answers: whether that browser offers Web Bluetooth, whether the page has the secure context it needs, and whether the machine has a radio switched on. That lets a caller tell a mistyped scheme apart from a real transport this engine build does not carry, before ever calling stream.open or stream.listen.

Exports

  • stream.open(url, opts?) -> string — promise handle; task.await() for the stream handle.
  • stream.listen(url, opts?) -> string — promise handle; task.await() for the listener handle.
  • stream.accept(listener) -> string? — the next connection's stream handle, nil when none is waiting.
  • stream.write(handle, bytes) -> "accepted" | "full" | "closed" | "tooLarge"
  • stream.read(handle, max?) -> string — drained bytes, "" when none.
  • stream.status(handle) -> StreamStatus? — nil when handle names no open stream.
  • stream.listenerStatus(listener) -> ListenerStatus? — nil when handle names no open listener.
  • stream.listeners() -> { OpenListener } — every held address, with its handle and owner.
  • stream.streams() -> { OpenStream } — every open stream, with its handle and owner.
  • stream.close(handle) -> boolean — a stream handle or a listener handle; a stream's queued bytes go out first.
  • stream.transports() -> { [string]: TransportSupport }