1/**
2 * Shared Node.js implementation of Effect's `Terminal` service.
3 *
4 * `NodeTerminal` adapts Node's `readline` APIs plus the current process
5 * `stdin` and `stdout` streams into {@link Terminal.Terminal}. The service can
6 * display output, read a line, stream key input, and read terminal dimensions.
7 * `make` manages readline and TTY raw mode in a scope, while `layer` provides
8 * the default service that ends key input on Ctrl+C or Ctrl+D.
9 *
10 * @since 4.0.0
11 */
12import type * as Cause from "effect/Cause"
13import * as Effect from "effect/Effect"
14import * as Layer from "effect/Layer"
15import * as Option from "effect/Option"
16import { badArgument, type PlatformError } from "effect/PlatformError"
17import * as Predicate from "effect/Predicate"
18import * as Queue from "effect/Queue"
19import * as RcRef from "effect/RcRef"
20import type * as Scope from "effect/Scope"
21import * as Terminal from "effect/Terminal"
22import * as readline from "node:readline"
23
24/**
25 * Creates a scoped process-backed `Terminal` using Node `readline`, enabling
26 * TTY raw mode while in scope and using the supplied predicate to decide when
27 * key input should end.
28 *
29 * @category constructors
30 * @since 4.0.0
31 */
32export const make: (
33 shouldQuit?: (input: Terminal.UserInput) => boolean
34) => Effect.Effect<Terminal.Terminal, never, Scope.Scope> = Effect.fnUntraced(
35 function*(shouldQuit: (input: Terminal.UserInput) => boolean = defaultShouldQuit) {
36 const stdin = process.stdin
37 const stdout = process.stdout
38 const lines = yield* Queue.make<string, Cause.Done>()
39
40 // stdin "end" fires once per process, so remember end-of-input for readers
41 // created after the event (Bun never sets `readableEnded`).
42 let inputEnded = stdin.readableEnded
43 let readlineActive = false
44 const onStdinEnd = () => {
45 inputEnded = true
46 if (!readlineActive) {
47 Queue.endUnsafe(lines)
48 }
49 }
50 stdin.once("end", onStdinEnd)
51 yield* Effect.addFinalizer(() => Effect.sync(() => stdin.off("end", onStdinEnd)))
52
53 const rlRef = yield* RcRef.make({
54 acquire: Effect.acquireRelease(
55 Effect.sync(() => {
56 const rl = readline.createInterface({ input: stdin, escapeCodeTimeout: 50 })
57 const onLine = (line: string) => Queue.offerUnsafe(lines, line)
58 const onClose = () => {
59 readlineActive = false
60 Queue.endUnsafe(lines)
61 }
62 readlineActive = true
63 readline.emitKeypressEvents(stdin, rl)
64 rl.on("line", onLine)
65 rl.once("close", onClose)
66
67 if (stdin.isTTY) {
68 stdin.setRawMode(true)
69 }
70 return { rl, onClose, onLine }
71 }),
72 ({ rl, onClose, onLine }) =>
73 Effect.sync(() => {
74 readlineActive = false
75 rl.off("line", onLine)
76 rl.off("close", onClose)
77 if (stdin.isTTY) {
78 stdin.setRawMode(false)
79 }
80 rl.close()
81 if (inputEnded) {
82 Queue.endUnsafe(lines)
83 }
84 })
85 ),
86 idleTimeToLive: "10 millis"
87 })
88
89 const columns = Effect.sync(() => stdout.columns ?? 0)
90 const rows = Effect.sync(() => stdout.rows ?? 0)
91
92 const readInput = Effect.gen(function*() {
93 const queue = yield* Queue.make<Terminal.UserInput, Cause.Done>()
94 const handleKeypress = (s: string | undefined, k: readline.Key) => {
95 const userInput = {
96 input: Option.fromUndefinedOr(s),
97 key: { name: k.name ?? "", ctrl: !!k.ctrl, meta: !!k.meta, shift: !!k.shift }
98 }
99 Queue.offerUnsafe(queue, userInput)
100 if (shouldQuit(userInput)) {
101 Queue.endUnsafe(queue)
102 }
103 }
104 // Deno's `process.stdin` shim does not keep the event loop alive, so a
105 // program blocked on input can exit before `end` is ever delivered. A
106 // timer holds the loop open for as long as this reader is active.
107 const keepAlive = setInterval(() => {}, 2147483647)
108 // Without this, consumers (e.g. `Prompt.run`) hang forever on closed stdin.
109 const handleEnd = () => {
110 clearInterval(keepAlive)
111 Queue.endUnsafe(queue)
112 }
113 yield* Effect.addFinalizer(() =>
114 Effect.sync(() => {
115 clearInterval(keepAlive)
116 stdin.off("keypress", handleKeypress)
117 stdin.off("end", handleEnd)
118 })
119 )
120 stdin.on("keypress", handleKeypress)
121 if (inputEnded) {
122 handleEnd()
123 } else {
124 yield* RcRef.get(rlRef)
125 stdin.once("end", handleEnd)
126 }
127 return queue as Queue.Dequeue<Terminal.UserInput, Cause.Done>
128 })
129
130 const readLine = Effect.suspend(() =>
131 Queue.poll(lines).pipe(
132 Effect.flatMap(Option.match({
133 onNone: () => Effect.scoped(Effect.andThen(RcRef.get(rlRef), Queue.take(lines))),
134 onSome: Effect.succeed
135 })),
136 Effect.mapError(() => new Terminal.QuitError({}))
137 )
138 )
139
140 const display = (prompt: string) =>
141 Effect.uninterruptible(
142 Effect.callback<void, PlatformError>((resume) => {
143 stdout.write(prompt, (err) =>
144 Predicate.isNullish(err)
145 ? resume(Effect.void)
146 : resume(Effect.fail(
147 badArgument({
148 module: "Terminal",
149 method: "display",
150 description: "Failed to write prompt to stdout",
151 cause: err
152 })
153 )))
154 })
155 )
156
157 return Terminal.make({
158 columns,
159 rows,
160 readInput,
161 readLine,
162 display
163 })
164 }
165)
166
167/**
168 * Provides the default process-backed `Terminal` service, ending key input on
169 * Ctrl+C or Ctrl+D.
170 *
171 * @category layers
172 * @since 4.0.0
173 */
174export const layer: Layer.Layer<Terminal.Terminal> = Layer.effect(Terminal.Terminal, make(defaultShouldQuit))
175
176function defaultShouldQuit(input: Terminal.UserInput) {
177 return input.key.ctrl && (input.key.name === "c" || input.key.name === "d")
178}