| 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 | */ |
| 12 | import type * as Cause from "effect/Cause" |
| 13 | import * as Effect from "effect/Effect" |
| 14 | import * as Layer from "effect/Layer" |
| 15 | import * as Option from "effect/Option" |
| 16 | import { badArgument, type PlatformError } from "effect/PlatformError" |
| 17 | import * as Predicate from "effect/Predicate" |
| 18 | import * as Queue from "effect/Queue" |
| 19 | import * as RcRef from "effect/RcRef" |
| 20 | import type * as Scope from "effect/Scope" |
| 21 | import * as Terminal from "effect/Terminal" |
| 22 | import * 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 | */ |
| 32 | export 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 | */ |
| 174 | export const layer: Layer.Layer<Terminal.Terminal> = Layer.effect(Terminal.Terminal, make(defaultShouldQuit)) |
| 175 | |
| 176 | function defaultShouldQuit(input: Terminal.UserInput) { |
| 177 | return input.key.ctrl && (input.key.name === "c" || input.key.name === "d") |
| 178 | } |