opentui

Build terminal UIs with OpenTUI. Covers Core, frameworks, components, application APIs, testing, extensions, integrations, deployment, and public API lookup.

Install
npx skills add 'https://github.com/anomalyco/opentui/tree/main/packages/web/src/content'
Download bundle ↓
main · ac753b4Scanned 2026-09-15

Contributors

GitHub-linked commit authors for this SKILL.md at the saved revision. Co-authors and history before file renames are not included.

File history ↗

docs/reference/native-span-feed.mdx

docs/reference/native-span-feed.mdxBrowse 89 files
View on GitHub
← Back to SKILL.md

title: NativeSpanFeed description: Attach, drain, backpressure, and close the borrowed native byte feed used by custom renderer output skill: intents: [native-span-feed, custom-stdout, output-transport, backpressure]

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
}
OptionDefaultNative behavior
chunkSize64 * 1024Bytes in each chunk. An explicit 0 normalizes to 64 KiB.
initialChunks2Chunks allocated at creation. An explicit 0 normalizes to one.
maxBytes0nCap on chunk bytes. Zero means no cap.
growthPolicy"grow"Adds chunks and doubles the span ring. "block" returns no-space status instead.
autoCommitOnFulltrueCommits a full chunk while a copy write continues.
spanQueueCapacity0Zero 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 and streamPtr.

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:

StatusMeaning
0Success
-1No space under block policy or an unavailable reservation
-2The next chunk would exceed maxBytes
-3Invalid pointer, state, or argument
-4Allocation failed
-5A 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