Skip to content

Commit 06248bb

Browse files
committed
Fix: Accept capture streams in RenderOptions without a type assertion
1 parent 70af033 commit 06248bb

5 files changed

Lines changed: 159 additions & 38 deletions

File tree

readme.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2595,21 +2595,25 @@ Type: `object`
25952595
25962596
###### stdout
25972597
2598-
Type: `stream.Writable`\
2598+
Type: `InkOutputStream`\
25992599
Default: `process.stdout`
26002600
26012601
Output stream where the app will be rendered.
26022602
2603+
Any object with a `write()` method can be passed, like a stream that captures output in memory, so it doesn't have to be a terminal stream. The `InkOutputStream` type is exported for typing your own streams.
2604+
26032605
###### stdin
26042606
2605-
Type: `stream.Readable`\
2607+
Type: `InkInputStream`\
26062608
Default: `process.stdin`
26072609
26082610
Input stream where app will listen for input.
26092611
2612+
Any object with `on()` and `read()` methods can be passed. The `InkInputStream` type is exported for typing your own streams.
2613+
26102614
###### stderr
26112615
2612-
Type: `stream.Writable`\
2616+
Type: `InkOutputStream`\
26132617
Default: `process.stderr`
26142618
26152619
Error stream.

src/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
export type {RenderOptions, Instance} from './render.js';
1+
export type {
2+
RenderOptions,
3+
Instance,
4+
InkOutputStream,
5+
InkInputStream,
6+
} from './render.js';
27
export {default as render} from './render.js';
38
export type {RenderToStringOptions} from './render-to-string.js';
49
export {default as renderToString} from './render-to-string.js';

src/render.ts

Lines changed: 63 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,26 +5,64 @@ import Ink, {type Options as InkOptions, type RenderMetrics} from './ink.js';
55
import instances from './instances.js';
66
import {type KittyKeyboardOptions} from './kitty-keyboard.js';
77

8+
/**
9+
Output stream that Ink can render to.
10+
11+
Typed structurally, so any stream that captures output can be passed without a type assertion, not just a terminal stream. `process.stdout` satisfies this type.
12+
13+
Everything except `write()` is optional, because Ink checks for their presence at runtime.
14+
*/
15+
export type InkOutputStream = {
16+
columns?: number;
17+
rows?: number;
18+
isTTY?: boolean;
19+
destroyed?: boolean;
20+
writable?: boolean;
21+
writableEnded?: boolean;
22+
writableLength?: number;
23+
write(data: string, ...rest: unknown[]): unknown;
24+
on?(event: unknown, listener: unknown): unknown;
25+
off?(event: unknown, listener: unknown): unknown;
26+
};
27+
28+
/**
29+
Input stream that Ink can listen for input on.
30+
31+
Typed structurally, like `InkOutputStream`. `process.stdin` satisfies this type.
32+
*/
33+
export type InkInputStream = {
34+
isTTY?: boolean;
35+
on(event: unknown, listener: unknown): unknown;
36+
read(...args: unknown[]): unknown;
37+
setRawMode?(mode: boolean): unknown;
38+
setEncoding?(...args: unknown[]): unknown;
39+
unshift?(...args: unknown[]): unknown;
40+
addListener?(event: unknown, listener: unknown): unknown;
41+
removeListener?(event: unknown, listener: unknown): unknown;
42+
ref?(): unknown;
43+
unref?(): unknown;
44+
};
45+
846
export type RenderOptions = {
947
/**
1048
Output stream where the app will be rendered.
1149
1250
@default process.stdout
1351
*/
14-
stdout?: NodeJS.WriteStream;
52+
stdout?: InkOutputStream;
1553

1654
/**
1755
Input stream where app will listen for input.
1856
1957
@default process.stdin
2058
*/
21-
stdin?: NodeJS.ReadStream;
59+
stdin?: InkInputStream;
2260

2361
/**
2462
Error stream.
2563
@default process.stderr
2664
*/
27-
stderr?: NodeJS.WriteStream;
65+
stderr?: InkOutputStream;
2866

2967
/**
3068
If true, each update will be rendered as separate output, without replacing the previous one.
@@ -198,20 +236,23 @@ Mount a component and render the output.
198236
*/
199237
const render = (
200238
node: ReactNode,
201-
options?: NodeJS.WriteStream | RenderOptions,
239+
options?: InkOutputStream | RenderOptions,
202240
): Instance => {
241+
const {stdout, stdin, stderr, ...restOptions} = getOptions(options);
242+
203243
const inkOptions: InkOptions = {
204-
stdout: process.stdout,
205-
stdin: process.stdin,
206-
stderr: process.stderr,
207244
debug: false,
208245
exitOnCtrlC: true,
209246
patchConsole: true,
210247
maxFps: 30,
211248
incrementalRendering: false,
212249
concurrent: false,
213250
alternateScreen: false,
214-
...getOptions(options),
251+
...restOptions,
252+
// Ink internally uses Node's stream types, but only the members declared above.
253+
stdout: (stdout ?? process.stdout) as NodeJS.WriteStream,
254+
stdin: (stdin ?? process.stdin) as NodeJS.ReadStream,
255+
stderr: (stderr ?? process.stderr) as NodeJS.WriteStream,
215256
};
216257

217258
const instance: Ink = getInstance(
@@ -236,17 +277,27 @@ const render = (
236277

237278
export default render;
238279

280+
const isOutputStream = (
281+
value: InkOutputStream | RenderOptions,
282+
): value is InkOutputStream => {
283+
// Options objects never have a `write` method.
284+
return (
285+
value instanceof Stream ||
286+
('write' in value && typeof value.write === 'function')
287+
);
288+
};
289+
239290
const getOptions = (
240-
stdout: NodeJS.WriteStream | RenderOptions | undefined = {},
291+
options: InkOutputStream | RenderOptions | undefined = {},
241292
): RenderOptions => {
242-
if (stdout instanceof Stream) {
293+
if (isOutputStream(options)) {
243294
return {
244-
stdout,
295+
stdout: options,
245296
stdin: process.stdin,
246297
};
247298
}
248299

249-
return stdout;
300+
return options;
250301
};
251302

252303
const getInstance = (

test/components.tsx

Lines changed: 6 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -773,8 +773,7 @@ test('disable raw mode when all input components are unmounted', async t => {
773773

774774
const {rerender} = render(
775775
<Test renderFirstInput renderSecondInput />,
776-
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
777-
options as any,
776+
options,
778777
);
779778

780779
t.true(stdin.setRawMode.calledOnce);
@@ -829,11 +828,7 @@ test('do not disable raw mode when swapping components that use useInput', async
829828
return step === 1 ? <StepA /> : <StepB />;
830829
}
831830

832-
const {rerender} = render(
833-
<Test step={1} />,
834-
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
835-
options as any,
836-
);
831+
const {rerender} = render(<Test step={1} />, options);
837832

838833
t.true(stdin.setRawMode.calledOnce);
839834
t.true(stdin.ref.calledOnce);
@@ -887,11 +882,7 @@ test('clear pending input parser state when swapping components that use useInpu
887882
return step === 1 ? <StepA /> : <StepB />;
888883
}
889884

890-
const {rerender} = render(
891-
<Test step={1} />,
892-
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
893-
options as any,
894-
);
885+
const {rerender} = render(<Test step={1} />, options);
895886

896887
emitReadable(stdin, '\u001B[');
897888
rerender(<Test step={2} />);
@@ -942,11 +933,7 @@ test('re-ref stdin when input is used after previous unmount', t => {
942933
const onSecondMountInput = spy();
943934

944935
// First render
945-
const {unmount} = render(
946-
<Test onInput={onFirstMountInput} />,
947-
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
948-
options as any,
949-
);
936+
const {unmount} = render(<Test onInput={onFirstMountInput} />, options);
950937

951938
t.true(stdin.ref.calledOnce);
952939
t.true(stdin.setRawMode.calledOnce);
@@ -965,8 +952,7 @@ test('re-ref stdin when input is used after previous unmount', t => {
965952
// Second render with new Ink instance reusing the same stdin
966953
const {unmount: unmount2} = render(
967954
<Test onInput={onSecondMountInput} />,
968-
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
969-
options as any,
955+
options,
970956
);
971957

972958
t.true(stdin.ref.calledTwice);
@@ -1084,8 +1070,7 @@ test('render different component based on whether stdin is a TTY or not', t => {
10841070

10851071
const {rerender} = render(
10861072
<Test renderFirstInput renderSecondInput />,
1087-
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
1088-
options as any,
1073+
options,
10891074
);
10901075

10911076
t.false(stdin.setRawMode.called);

test/render.tsx

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,15 @@ import ansiEscapes from 'ansi-escapes';
1919
import stripAnsi from 'strip-ansi';
2020
import boxen from 'boxen';
2121
import delay from 'delay';
22-
import {render, Box, Text, useApp, useCursor, useInput} from '../src/index.js';
22+
import {
23+
render,
24+
Box,
25+
Text,
26+
useApp,
27+
useCursor,
28+
useInput,
29+
type RenderOptions,
30+
} from '../src/index.js';
2331
import {type RenderMetrics} from '../src/ink.js';
2432
import {bsu, esu} from '../src/write-synchronized.js';
2533
import {createStdin, emitReadable} from './helpers/create-stdin.js';
@@ -2111,3 +2119,71 @@ test.serial('bsu/esu wraps throttledLog trailing call', t => {
21112119
}
21122120
});
21132121
});
2122+
2123+
const createCaptureStream = () => {
2124+
const writes: string[] = [];
2125+
2126+
return {
2127+
columns: 100,
2128+
rows: 10,
2129+
// eslint-disable-next-line @typescript-eslint/naming-convention
2130+
isTTY: false,
2131+
write(data: string) {
2132+
writes.push(data);
2133+
},
2134+
output: () => writes.join(''),
2135+
};
2136+
};
2137+
2138+
test.serial('accept Node streams in render options', t => {
2139+
const options: RenderOptions = {
2140+
stdout: process.stdout,
2141+
stdin: process.stdin,
2142+
stderr: process.stderr,
2143+
};
2144+
2145+
t.is(options.stdout, process.stdout);
2146+
t.is(options.stdin, process.stdin);
2147+
t.is(options.stderr, process.stderr);
2148+
});
2149+
2150+
test.serial('render to a stream that only implements what Ink uses', t => {
2151+
const stdout = createCaptureStream();
2152+
const stderr = createCaptureStream();
2153+
const stdin = {
2154+
on() {},
2155+
read: () => null,
2156+
};
2157+
2158+
const {unmount} = render(<Text>Hello</Text>, {stdout, stderr, stdin});
2159+
unmount();
2160+
2161+
t.true(stdout.output().includes('Hello'));
2162+
});
2163+
2164+
test.serial('render to a stream passed as the second argument', t => {
2165+
const stdout = createCaptureStream();
2166+
2167+
const {unmount} = render(<Text>Hello</Text>, stdout);
2168+
unmount();
2169+
2170+
t.true(stdout.output().includes('Hello'));
2171+
});
2172+
2173+
test.serial(
2174+
'render to a Node stream passed as the second argument',
2175+
async t => {
2176+
const stdout = new PassThrough();
2177+
let output = '';
2178+
2179+
stdout.on('data', (chunk: Uint8Array) => {
2180+
output += textDecoder.decode(chunk);
2181+
});
2182+
2183+
const {unmount} = render(<Text>Hello</Text>, stdout);
2184+
unmount();
2185+
await delay(0);
2186+
2187+
t.true(output.includes('Hello'));
2188+
},
2189+
);

0 commit comments

Comments
 (0)