NativeSpanFeed
NativeSpanFeed is an advanced borrowed-memory bridge from a native Zig byte feed to JavaScript callbacks. 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 owns feed setup, write-callback
backpressure, shutdown drains, and destruction.
Direct use is for a native integration that already produces bytes through a native span-feed pointer.
Renderer-managed use#
const renderer = await createCliRenderer({
stdin,
stdout,
width: columns,
height: rows,
exitOnCtrlC: false,
exitSignals: [],
})When stdout is not process.stdout and bufferedOutput is not "memory", the renderer creates a feed. It passes
each borrowed view to the original stdout.write() and keeps the native chunk pinned until the write callback runs.
During destruction, the renderer drains existing spans, destroys the native renderer, drains shutdown bytes, removes handlers, and closes the feed.
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. |
maxBytes limits chunk allocation only. It does not cap the span-ring allocation. Under "grow", the ring doubles
when it fills and has no separate byte limit.
Default renderer-managed feeds are growable and uncapped. This lets shutdown control bytes publish while earlier
chunks remain pinned. A direct integration should set a finite maxBytes when it needs a memory bound.
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 native state buffer uses one u8 reference count per chunk. At 255 pending spans 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.
- A native chunk still has a nonzero reference count.
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
chunk is pinned. 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 under block policy 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() is idempotent after destruction. It unregisters the callback and destroys the native feed after native close
succeeds.
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 returns a nonzero status, the wrapper currently 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.
After renderer.destroy() returns, a custom writable can still have callback promises in the microtask queue. Let
those write callbacks settle before destroying the underlying transport.
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
}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.