Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions packages/core/src/output-strategy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { EventEmitter } from "events"
import { expect, test } from "bun:test"
import type { Pointer } from "bun:ffi"

import { createOutputStrategy } from "./output-strategy"
import type { RenderLib } from "./zig"
import { createCapturingStdout } from "./testing/stdout-mocks"

test("javascript output strategy flushes via provided write function", () => {
const frame = Buffer.from("frame-bytes")
const writes: Buffer[] = []

const stdout = createCapturingStdout()
stdout.write = () => {
throw new Error("stdout.write should not be hit when writeToTerminal is provided")
}

const stdin = new EventEmitter() as unknown as NodeJS.ReadStream

const libMock = {
setWriteTarget: () => {},
getWriteBufferLength: () => frame.length,
copyWriteBuffer: (_renderer: Pointer, target: Uint8Array) => {
target.set(frame)
return frame.length
},
} as unknown as RenderLib

const writeToTerminal = (chunk: any) => {
writes.push(Buffer.from(chunk))
return true
}

const strategy = createOutputStrategy("javascript", {
stdout,
stdin,
lib: libMock,
rendererPtr: 0 as Pointer,
writeToTerminal,
emitFlush: () => {},
onDrain: () => {},
})

strategy.flush("test")

expect(writes).toHaveLength(1)
expect(writes[0].toString()).toBe(frame.toString())
})
184 changes: 184 additions & 0 deletions packages/core/src/output-strategy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import type { Pointer } from "bun:ffi"
import type { RenderLib } from "./zig"

export type StdoutWrite = NodeJS.WriteStream["write"]

enum NativeWriteTarget {
TTY = 0,
BUFFER = 1,
}

export interface OutputStrategyOptions {
stdout: NodeJS.WriteStream
stdin: NodeJS.ReadStream
lib: RenderLib
rendererPtr: Pointer
writeToTerminal: StdoutWrite
emitFlush: (event: { bytes: number; reason: string }) => void
onDrain: () => void
}

export interface OutputStrategy {
flush(reason: string): void
canRender(): boolean
setup(useAlternateScreen: boolean, processCapabilityResponse: (data: string) => void): Promise<void>
teardown(): void
render(force: boolean): void
destroy(): void
}

async function setupTerminalWithCapabilities(
stdin: NodeJS.ReadStream,
onCapability: (data: string) => void,
setupFn: () => void,
): Promise<void> {
await new Promise<void>((resolve) => {
const timeout = setTimeout(() => {
stdin.off("data", capListener)
resolve()
}, 100)
const capListener = (str: string) => {
clearTimeout(timeout)
onCapability(str)
stdin.off("data", capListener)
resolve()
}
stdin.on("data", capListener)
setupFn()
})
}

class NativeOutputStrategy implements OutputStrategy {
constructor(private options: OutputStrategyOptions) {}

flush(_reason: string): void {
// no-op - native handles flushing
}

canRender(): boolean {
return true // never blocked
}

async setup(useAlternateScreen: boolean, processCapabilityResponse: (data: string) => void): Promise<void> {
await setupTerminalWithCapabilities(this.options.stdin, processCapabilityResponse, () =>
this.options.lib.setupTerminal(this.options.rendererPtr, useAlternateScreen),
)
}

teardown(): void {
// no-op - handled elsewhere
}

render(force: boolean): void {
this.options.lib.render(this.options.rendererPtr, force)
}

destroy(): void {
// no-op - nothing to clean up
}
}

class JavaScriptOutputStrategy implements OutputStrategy {
private nativeWriteBuffer: Uint8Array = new Uint8Array(0)
private awaitingDrain: boolean = false
private drainListener: (() => void) | null = null

constructor(private options: OutputStrategyOptions) {
options.lib.setWriteTarget(options.rendererPtr, NativeWriteTarget.BUFFER)
}

flush(reason: string): void {
const chunk = this.readNativeBuffer()
if (!chunk || chunk.length === 0) {
return
}
const wrote = this.options.writeToTerminal(chunk)
this.options.emitFlush({ bytes: chunk.length, reason })
if (!wrote) {
this.scheduleDrain()
}
}

canRender(): boolean {
return !this.awaitingDrain
}

async setup(useAlternateScreen: boolean, processCapabilityResponse: (data: string) => void): Promise<void> {
await setupTerminalWithCapabilities(
this.options.stdin,
(str: string) => {
processCapabilityResponse(str)
this.flush("capabilities")
},
() => {
this.options.lib.setupTerminalToBuffer(this.options.rendererPtr, useAlternateScreen)
this.flush("setup")
},
)
}

teardown(): void {
this.options.lib.teardownTerminalToBuffer(this.options.rendererPtr)
this.flush("teardown")
}

render(force: boolean): void {
this.options.lib.renderIntoWriteBuffer(this.options.rendererPtr, force)
this.flush("frame")
}

destroy(): void {
if (this.awaitingDrain && this.drainListener) {
this.options.stdout.off?.("drain", this.drainListener)
this.drainListener = null
this.awaitingDrain = false
}
}

private ensureNativeWriteBufferSize(size: number): void {
if (this.nativeWriteBuffer.length >= size) {
return
}
const nextSize = Math.max(size, this.nativeWriteBuffer.length > 0 ? this.nativeWriteBuffer.length * 2 : 4096)
this.nativeWriteBuffer = new Uint8Array(nextSize)
}

private readNativeBuffer(): Uint8Array | null {
const length = this.options.lib.getWriteBufferLength(this.options.rendererPtr)
if (!length) {
return null
}
this.ensureNativeWriteBufferSize(length)
const copied = this.options.lib.copyWriteBuffer(this.options.rendererPtr, this.nativeWriteBuffer)
if (!copied) {
return null
}
return this.nativeWriteBuffer.subarray(0, copied)
}

private scheduleDrain(): void {
if (this.awaitingDrain || typeof this.options.stdout.once !== "function") {
return
}
this.awaitingDrain = true
this.drainListener = this.handleDrain
this.options.stdout.once("drain", this.handleDrain)
}

private handleDrain = (): void => {
this.awaitingDrain = false
if (this.drainListener) {
this.drainListener = null
}
this.options.onDrain()
}
}

export type OutputMode = "native" | "javascript"

export function createOutputStrategy(mode: OutputMode, options: OutputStrategyOptions): OutputStrategy {
if (mode === "javascript") {
return new JavaScriptOutputStrategy(options)
}
return new NativeOutputStrategy(options)
}
29 changes: 29 additions & 0 deletions packages/core/src/renderer.stdout.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { expect, test } from "bun:test"

import { capture } from "./console"
import { createTestRenderer } from "./testing/test-renderer"
import { createCapturingStdout } from "./testing/stdout-mocks"

test("javascript mode keeps stdout interception active and capture records writes", async () => {
const mockStdout = createCapturingStdout()
const originalWrite = mockStdout.write

const { renderer } = await createTestRenderer({
outputMode: "javascript",
stdout: mockStdout,
disableStdoutInterception: false,
})

try {
expect(mockStdout.write).not.toBe(originalWrite)

capture.claimOutput()
mockStdout.write("external log\n")
expect(capture.claimOutput()).toBe("external log\n")

;(renderer as any).writeOut("frame bytes\n")
expect(mockStdout.written).toContain("frame bytes\n")
} finally {
renderer.destroy()
}
})
Loading
Loading