stream
The stream namespace — the engine's Luau API reference for stream.
The stream namespace — 12 functions.
globals/stream/accept
stream.accept(listener: string) -> string?
Take the connection that has waited longest on the listener, as a
stream handle that reads, writes, and closes exactly like one
stream.open returned. Returns nil when nothing is waiting, so call
it in a loop each tick to take every peer that arrived.
stream.listenerStatus(listener).pending is how many are still
waiting.
Parameters
listenerstring— Listener handle from stream.listen.
Returns string? — The connection's stream handle, or nil when none is waiting.
while true do local h = stream.accept(listener); if not h then break end; table.insert(peers, h) end
globals/stream/close
stream.close(handle: string) -> boolean
Finish whatever handle names — a stream or a listener — and
drop it from the registry.
Closing a stream refuses every later write and carries the bytes
already queued to the peer before the connection ends, so a write and
a close in the same tick deliver — the shape a request answered with
one response has. A peer that has stopped reading altogether holds
that finish for thirty seconds; past that the connection ends and
what is still queued ends with it, so a caller that must know its
bytes went out watches stream.status(handle).pending reach zero
before it closes.
Closing a listener stops it answering new peers and closes the
connections nobody took; the connections stream.accept already
handed over keep running until they are closed themselves.
Parameters
handlestring— Stream handle from stream.open or stream.accept, or listener handle from stream.listen.
Returns boolean — True if a stream or listener was closed, false if handle already named none.
stream.write(peer, response); stream.close(peer)
globals/stream/listen
stream.listen(url: string, opts: StreamListenOpts?) -> string
Hold the address url names and answer the peers that dial it —
the other direction from stream.open, for when the thing you are
talking to starts the conversation and restarts on its own schedule.
Returns a promise handle: task.await() it to get the listener
handle once the address is held, or it raises the reason a malformed
url, a scheme that cannot listen, or a failed bind was refused with.
Take the connections with stream.accept.
The host in the url is the interface bound, and the whole of what
decides who can reach it. 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 — every device on the
wifi, and anything beyond it the network lets through. Write the one
you mean; there is no default, and stream.listenerStatus reports
which of the two you got. A port of 0 asks the operating system to
choose one, which that same status then reports.
opts.inboundCapacity and opts.outboundCapacity bound each
answered connection (65536 bytes each by default);
opts.backlog bounds the connections held for stream.accept
before the listener stops taking them from the operating system,
which leaves the rest queued in the kernel rather than answered and
forgotten (16 by default, and at least 1).
The listener belongs to 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. Peers see the connection close and dial again.
stream.listeners() names that chunk as each entry's owner.
Parameters
urlstring— Listen URL — scheme://host:port.optsStreamListenOpts(optional) — Per-connection capacities and the accept backlog (optional).
Returns string — Promise handle for task.await().
local pending = stream.listen("tcp://127.0.0.1:9000"); local listener = task.await(pending)
globals/stream/listenerStatus
stream.listenerStatus(listener: string) -> ListenerStatus?
Report what the listener holds and has handed over. address is
the address the operating system resolved the bind to, port
included — the one to hand a peer. reach says who can connect to
it: "thisMachine" when it is a loopback address and only programs
on this machine can, "network" when any host that can route here
can. accepted counts the connections stream.accept handed over,
pending the ones still waiting, and capacity the value pending
may reach before the listener stops taking connections from the
operating system. nil when handle names no open listener.
Parameters
listenerstring— Listener handle from stream.listen.
Returns ListenerStatus? — Listener status, or nil when handle names no open listener.
local s = stream.listenerStatus(listener); print(s.address, s.reach, s.pending)
globals/stream/listeners
stream.listeners() -> { OpenListener }
Every listener this engine currently holds an address for, in the
order they were opened. Each entry is what stream.listenerStatus
reports about it, plus the handle it is addressed by and the owner
chunk its life follows.
This is how an address is reached again once nothing holds its handle:
filter on address for the port you want and close the entry by its
handle, rather than guessing at handles.
Returns { OpenListener } — An array of open listeners.
for _, l in stream.listeners() do if l.address == want then stream.close(l.handle) end end
globals/stream/open
stream.open(url: string, opts: StreamOpenOpts?) -> string
Open a byte stream at url (scheme://target[?k=v]). loopback
carries written bytes back out of the same stream and works on
every platform; tcp dials host:port; tty opens a serial
device node — /dev/ttyACM0 or /dev/ttyUSB0 for a USB CDC board
such as an ESP32, /dev/rfcomm0 for a Bluetooth controller paired
over classic SPP (both present as a tty on Linux, so one transport
serves either peer), COM5 on Windows. tty query parameters:
baud (default 115200), dataBits (5-8, default 8), parity
(none | odd | even, default none), stopBits (1 or 2,
default 1).
ble connects to a Bluetooth Low Energy device over GATT, on a
desktop engine and in a browser alike — the wireless transport a
web world reaches a device through:
ble://<device>?service=<uuid>&write=<uuid>¬ify=<uuid>. The
device is the name it advertises, * any device offering the
service, a trailing * a name prefix (Paw*). write is the
characteristic this engine writes to and notify the one it
subscribes to, which on a Nordic UART peripheral are that
peripheral's RX and TX; a module with one bidirectional
characteristic names it for both. UUIDs may be 16-bit (ffe0),
32-bit, or full. Optional: chunk (bytes per packet, 1-512 —
otherwise what the connection carries), writeMode
(withResponse | withoutResponse, default withResponse),
timeout (seconds to find and connect to the device, default
15).
opts bounds the stream's undrained inbound buffer
and in-flight outbound bytes (default 65536 each). Returns a
promise handle: task.await() it
to get the stream handle once the transport is open, or it raises
the reason a malformed url, an unknown or unsupported scheme, or a
failed connect was refused with. A ble stream resolves as soon as
it exists and reports the rest as state — watch
stream.status(handle).state go opening, permissionPending
while the browser asks the person at the machine to pick a device,
then open; writes made meanwhile are queued and go out when it
connects. Check stream.transports() first
to tell a mistyped scheme from one this build does not carry.
Parameters
urlstring— Stream URL — scheme://target[?k=v&k=v].optsStreamOpenOpts(optional) — Buffer capacities (optional).
Returns string — Promise handle for task.await().
local pending = stream.open("loopback://echo"); local handle = task.await(pending)
local paw = task.await(stream.open("ble://Paw*?service=ffe0&write=ffe1¬ify=ffe1"))
globals/stream/read
stream.read(handle: string, max: number?) -> string
Drain up to max buffered inbound bytes from the stream.
Parameters
handlestring— Stream handle from stream.open.maxnumber(optional) — Maximum bytes to drain (optional). Omit to drain everything buffered.
Returns string — Drained bytes, byte-safe. "" when none buffered or handle names no open stream.
local chunk = stream.read(handle)
globals/stream/serialPorts
stream.serialPorts() -> SerialPorts
Every serial device this machine has, for picking the one to open.
ports is an array ordered by path. Each entry carries the path the
device is at (/dev/ttyACM0 on Linux, COM3 on Windows), the url
that opens it, the kind of bus it attaches by, and — for a USB
device — the vendorId, productId, serialNumber, manufacturer
and product it advertises.
A device's path moves with enumeration order: a board that came up at
/dev/ttyACM0 is at /dev/ttyACM1 once something else is plugged in
first, and moves across COM3-COM5 on Windows. What the device
advertises holds still across those moves, so match on
vendorId/productId — or on serialNumber to tell two of the same
board apart — and open the url that entry carries, appending the
port settings stream.open documents.
Three answers are distinct. supported false with a reason means
this platform has no serial bus to enumerate at all. error set means
it has one and the operating system refused this enumeration, so a
later call may answer. An empty ports with neither means the machine
has no serial device attached, which is an ordinary result.
Returns SerialPorts — { supported, reason, error, ports } — the platform's answer, this enumeration's, and the devices it found.
for _, p in stream.serialPorts().ports do if p.vendorId == 0x303A then print(p.url, p.product) end end
globals/stream/status
stream.status(handle: string) -> StreamStatus?
Report what the stream has carried and lost. state is where
the stream is in its life: opening, permissionPending while the
platform asks the person at the machine to allow the connection,
open, denied when that permission was refused, and closed
when it is finished. pending is bytes
accepted and not yet handed to the peer; capacity is the value
pending may reach before a write is refused. error holds the
most recent transport failure and the refusal a denied stream
carries, retained for the life of the
stream. nil when handle names no open stream.
Parameters
handlestring
Returns StreamStatus? — Stream status, or nil when handle names no open stream.
local s = stream.status(handle); print(s.pending, s.capacity)
globals/stream/streams
stream.streams() -> { OpenStream }
Every open stream, dialled or answered, in the order they were
opened. Each entry is what stream.status reports about it, plus the
handle it is addressed by and the owner chunk its life follows —
a connection stream.accept handed over carries the owner of the
listener that answered it.
Returns { OpenStream } — An array of open streams.
for _, s in stream.streams() do print(s.handle, s.transport, s.pending, s.owner) end
globals/stream/transports
stream.transports() -> { [string]: TransportSupport }
Every stream scheme this build knows about — a capability
probe, in both directions. supported answers stream.open and
listen answers stream.listen, since a scheme can carry one and
not the other. Each reason is nil when its direction works,
otherwise it names why not: an unbuilt transport names its own
absence, a transport this platform lacks (tcp and tty on wasm;
ble in a browser without Web Bluetooth or with the radio off,
which the page itself answers) names that, and a loopback stream,
whose peer is itself, names that nothing dials it. A typo'd scheme
is absent from this table entirely, which is what tells it apart
from a real transport this build lacks.
Returns { [string]: TransportSupport } — Map of scheme name to { supported, reason, listen, listenReason }.
local t = stream.transports(); if not t.tcp.listen then warn(t.tcp.listenReason) end
globals/stream/write
stream.write(handle: string, bytes: string) -> WriteOutcome
Queue bytes for the stream's peer. Never blocks. "accepted"
means the bytes were queued. "full" means the outbound queue has
no room right now — backpressure, not failure: the peer is alive
and draining slower than this call is producing, so a retry after
it catches up can succeed. Compare pending against capacity on
stream.status() to see it coming before a write is refused.
"closed" means the stream is finished, or handle names no open
stream — reopen to continue, retrying never succeeds. "tooLarge"
means bytes 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.
Parameters
handlestring— Stream handle from stream.open.bytesstring— Bytes to queue, byte-safe.
Returns WriteOutcome — "accepted" | "full" | "closed" | "tooLarge"
local outcome = stream.write(handle, data)