stream
Public Luau surface over the `__stream` Internal FFI namespace — named byte streams by URL scheme.
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"
.. "¬ify=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. Comparependingagainstcapacityonstream.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,nilwhen none is waiting.stream.write(handle, bytes) -> "accepted" | "full" | "closed" | "tooLarge"stream.read(handle, max?) -> string— drained bytes,""when none.stream.status(handle) -> StreamStatus?—nilwhen handle names no open stream.stream.listenerStatus(listener) -> ListenerStatus?—nilwhen 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 }
Interface
What this asset declares: the schema it conforms to, what it exposes, and the rendered structured payload.
conforms to
zero/source-extract/v2module stream Named byte streams by URL scheme — TCP, tty, Bluetooth Low Energy and loopback transports, dialled out or listened for, with a common read/write/status/close surface over every one of them. Public Luau surface over the `__stream` Internal FFI namespace. global stream global-types require modules/api/engine/stream
open(url: string, opts: StreamOpenOpts?) → PromiseHandle
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.
| arg | type | description |
|---|---|---|
| url | string | Stream URL — scheme://target[?k=v&k=v]. |
| opts | StreamOpenOpts? | Buffer capacities (optional). |
examples
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"))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. enumeration's, and the devices it found. platform nativeOnly a serial device is enumerated by the host operating system, so on web supported is false and reason names that
examples
for _, p in stream.serialPorts().ports do if p.vendorId == 0x303A then print(p.url, p.product) end end
listen(url: string, opts: StreamListenOpts?) → PromiseHandle
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`. platform nativeOnly tcp is the transport that answers dialers and it carries a native backend, so on web the promise rejects with that reason
| arg | type | description |
|---|---|---|
| url | string | Listen URL — scheme://host:port. |
| opts | StreamListenOpts? | Per-connection capacities and the accept backlog (optional). |
examples
local pending = stream.listen("tcp://127.0.0.1:9000"); local listener = task.await(pending)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.
| arg | type | description |
|---|---|---|
| listener | string | Listener handle from stream.listen. |
examples
while true do local h = stream.accept(listener); if not h then break end; table.insert(peers, h) end
listeners( ) →
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.
examples
for _, l in stream.listeners() do if l.address == want then stream.close(l.handle) end end
streams( ) →
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.
examples
for _, s in stream.streams() do print(s.handle, s.transport, s.pending, s.owner) end
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.
| arg | type | description |
|---|---|---|
| handle | string | Stream handle from stream.open or stream.accept, or listener handle from stream.listen. |
examples
stream.write(peer, response); stream.close(peer)
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
| arg | type | description |
|---|---|---|
| handle | string | Stream handle from stream.open. |
| bytes | string | Bytes to queue, byte-safe. |
examples
local outcome = stream.write(handle, data)
read(handle: string, max: number?) → string
Drain up to max buffered inbound bytes from the stream.
| arg | type | description |
|---|---|---|
| handle | string | Stream handle from stream.open. |
| max | number? | Maximum bytes to drain (optional). Omit to drain everything buffered. |
examples
local chunk = stream.read(handle)
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.
| arg | type | description |
|---|---|---|
| handle | string |
examples
local s = stream.status(handle); print(s.pending, s.capacity)
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.
| arg | type | description |
|---|---|---|
| listener | string | Listener handle from stream.listen. |
examples
local s = stream.listenerStatus(listener); print(s.address, s.reach, s.pending)
transports( ) →
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.
examples
local t = stream.transports(); if not t.tcp.listen then warn(t.tcp.listenReason) end
StreamState = "opening" | "permissionPending" | "open" | "denied" | "closed"StreamStatus = {StreamOpenOpts = {WriteOutcome = "accepted" | "full" | "closed" | "tooLarge"TransportSupport = {SerialPortKind = "usb" | "pci" | "bluetooth" | "unknown"SerialPort = {SerialPorts = {ListenerStatus = {StreamListenOpts = {OpenListener = {OpenStream = {Sub-parts
Everything contained inside this part. Assets are composite children (clickable cards). Files are leaf payloads. Expand any row to view its source.
Showing up to 3 levels deep. Open a part to see what it holds further down.
Problems
Everything affecting this asset right now: its own problems, anything wrong inside it, and problems on its direct dependencies.
agent_score is exposed.+ quality × 0.35
+ performance × 0.25
± compat factor
Usability ratings
Did the part work as advertised when consumers tried to drop it in. Separate from upvotes: those are taste; this is "did it function".
Scoped to this part · feeds back into the world's score.