NativeSpanFeed
NativeSpanFeed is an advanced wrapper that passes bytes from a native Zig byte feed to JavaScript callbacks as
borrowed views. It is not a Node.js stream and has no read(), write(), or pipe() method.
Most applications must not create one. Pass streams to createCliRenderer() as described in
Renderer custom output. The renderer uses checked Session output,
not this TypeScript feed wrapper.
Direct use is for a native integration that already produces bytes through a native span-feed pointer.
Renderer output uses Sessions#
const renderer = await createCliRenderer({
stdin,
stdout,
width: columns,
height: rows,
exitOnCtrlC: false,
exitSignals: [],
})For a custom stdout, the renderer’s NativeSession driver copies each output ticket into a host-owned buffer.
The driver calls the original stdout.write() and acknowledges each ticket after its write callback succeeds.
It also handles output pressure and terminal restoration.
For process.stdout in the main process, native code writes the bytes directly.
The native Session stores its output in a native feed internally. Its output never reaches you as a borrowed
NativeSpanFeed.onData() view.
Read How Core uses native for the renderer’s contract.
Public wrapper API#
type DataHandler = (data: Uint8Array) => void | Promise<void>
class NativeSpanFeed {
static create(options?: NativeSpanFeedOptions): NativeSpanFeed
static attach(streamPtr: Pointer, options?: NativeSpanFeedOptions): NativeSpanFeed
readonly streamPtr: Pointer
onData(handler: DataHandler): () => void
onError(handler: (code: number) => void): () => void
isBackpressured(): boolean
drainAll(): void
idle(): Promise<void>
close(): void
}create() allocates, registers, and attaches a native feed. If attach fails, it unregisters the callback and destroys
the new native feed.
attach() registers and attaches an existing pointer. After attach succeeds, the wrapper owns that feed and
close() destroys it. If attach fails, the wrapper unregisters its callback but does not destroy the caller’s pointer.
The options argument to attach() is currently ignored. Configure an existing native feed before attachment.
Low-level example#
import { NativeSpanFeed } from "@opentui/core"
const feed = NativeSpanFeed.create({
chunkSize: 64 * 1024,
initialChunks: 2,
maxBytes: 8n * 1024n * 1024n,
})
const ownedChunks: Uint8Array[] = []
const offData = feed.onData((bytes) => {
ownedChunks.push(bytes.slice())
})
try {
// Pass feed.streamPtr to the native producer here.
await feed.idle()
} finally {
offData()
feed.close()
}The wrapper does not expose a producer API. A native producer or the lower-level library facade writes, reserves, and
commits spans through streamPtr.
Options and defaults#
type GrowthPolicy = "grow" | "block"
interface NativeSpanFeedOptions {
chunkSize?: number
initialChunks?: number
maxBytes?: bigint
growthPolicy?: GrowthPolicy
autoCommitOnFull?: boolean
spanQueueCapacity?: number
}| Option | Default | Native behavior |
|---|---|---|
chunkSize |
64 * 1024 |
Bytes in each chunk. An explicit 0 normalizes to 64 KiB. |
initialChunks |
2 |
Chunks allocated at creation. An explicit 0 normalizes to one. |
maxBytes |
0n |
Cap on chunk bytes. Zero means no cap. |
growthPolicy |
"grow" |
Adds chunks and doubles the span ring. "block" returns no-space status instead. |
autoCommitOnFull |
true |
Commits a full chunk while a copy write continues. |
spanQueueCapacity |
0 |
Zero selects the native default of 4096 span entries. |
A nonzero maxBytes or the "block" policy also fixes the span ring at spanQueueCapacity entries. A commit that
needs another entry in a full ring returns -1. Only a "grow" feed without maxBytes doubles the ring when it fills.
These defaults apply only to feeds that you create. A Session feed uses fixed, bounded output storage and reserves separate capacity for terminal control bytes.
Borrowed callback memory#
Each onData argument aliases native chunk memory. It is not an owned copy.
- A synchronous handler can use the view only during that handler. Copy it before returning if data must survive.
- A handler that returns a promise can use the view until that promise settles.
- A chunk remains pinned until all promises returned for that span settle.
- Promise rejection still releases the chunk.
close()and native destruction invalidate all old views andstreamPtr.
Handlers receive the same mutable view. A handler can change bytes seen by later handlers. Treat the view as readonly.
The wrapper releases each delivered span by its ring slot and release ID. Native code counts unreleased spans per chunk
in a u8. At 255 for one chunk, the producer moves to another chunk instead of increasing that count further.
Drain and backpressure#
Committed spans normally trigger drainAll() when at least one data handler exists. Data committed before a handler
sets a pending flag. Adding the first handler then drains it.
drainAll() synchronously drains batches of 256 span descriptors until the current queue is empty. A reentrant
drainAll() call from a handler does nothing while the outer drain is active.
isBackpressured() is true when any of these states exists:
- An asynchronous data handler is pending.
- Committed data waits for a data handler.
- Native stats report an outstanding span: one that is queued or delivered but not yet released.
This JavaScript state does not force the native producer to block under the default "grow" policy. The producer can
allocate more chunks until maxBytes, allocation failure, or another native limit stops it.
idle() resolves when no callback or drain is active, no asynchronous handler or pending-data flag remains, and no
span is outstanding. If data waits without a handler, idle() does not resolve until a handler drains it or the feed
closes.
Handler errors#
A synchronous handler exception does not skip the remaining handlers for that span. OpenTUI records the first exception and releases the chunk reference.
In Bun, the wrapper can throw that exception from the native callback. In Node.js, an exception cannot cross the FFI callback safely, so the wrapper throws it from a queued microtask. Code around the native producer call cannot catch that later Node.js exception.
Rejected handler promises are consumed through Promise.allSettled(). The wrapper releases the chunk but does not
send the rejection to onError().
onError() exposes native Error-event plumbing. The current Zig feed does not emit that event. Do not rely on
onError() for no-space, allocation, handler, or promise errors.
Native producer statuses#
The native feed maps producer failures to integer statuses:
| Status | Meaning |
|---|---|
0 |
Success |
-1 |
No space in a bounded feed, or an unavailable reservation |
-2 |
The next chunk would exceed maxBytes |
-3 |
Invalid pointer, state, or argument |
-4 |
Allocation failed |
-5 |
A reservation or pending write makes the operation busy |
Copy writes with autoCommitOnFull: false return no space if the write crosses the remaining chunk boundary. A write
that exactly fills the chunk succeeds. The producer must explicitly commit a partial chunk.
Only one zero-copy reservation can exist. The producer must call the native reserved-commit operation, including on its failure paths, before close can succeed.
Close and destruction#
close() closes the native feed, then destroys it. After destruction succeeds, the wrapper drops its native event handler,
data handlers, and error handlers. Later close() calls do nothing.
If a callback, drain, or asynchronous handler is active, close() defers final destruction. It queues a microtask and
retries after active asynchronous handlers settle.
If native close or destruction returns a nonzero status, the wrapper leaves the feed alive and returns without throwing.
This can occur while a native reservation is active. Commit the reservation, including with zero bytes when needed.
Then call close() again. The wrapper has no method that completes a native reservation.
Calling close() from a data handler stops after the current span and can drop queued spans when final destruction
runs. Drain required data before closing.
For a renderer, call renderer.destroy(), then await renderer.closed before you release its transport.
Renderer shutdown does not use NativeSpanFeed.close().
Read Lifecycle and cleanup for renderer shutdown ordering.
Stats type#
@opentui/core exports the native data shape:
interface NativeSpanFeedStats {
bytesWritten: bigint
spansCommitted: bigint
chunks: number
pendingSpans: number
outstandingSpans: number
outstandingBytes: bigint
}NativeSpanFeed has no public stats method. Do not call feed.getStats() or read feed.stats. The lower-level library
facade can query native stats, but that facade is not the wrapper API documented here.
Runtime and diagnostics#
The wrapper uses native callbacks and borrowed native memory. It follows the Bun and Node.js FFI support in Runtime and platform support.
Use Troubleshooting for stalled custom output, shutdown bytes, or callback failures. Use API and symbol index to distinguish the wrapper from lower-level exports.
Next#
- Renderer custom output is the supported normal-use path.
- Lifecycle and cleanup defines renderer and transport shutdown.
- Runtime and platform support defines native FFI support.
- Troubleshooting covers output and terminal restoration failures.