From 61731ba0cce36701f7cc0244c0b72fe6230d12d5 Mon Sep 17 00:00:00 2001 From: JordanMartinez Date: Fri, 21 Jul 2023 16:07:35 -0700 Subject: [PATCH] Add Aff-based API (#35) * Add bindings to AbortController/AbortSignal * Add signal variant of question' * Add Aff-based APIs * Add changelog entry --- CHANGELOG.md | 10 +++- src/Node/Errors/AbortController.js | 5 ++ src/Node/Errors/AbortController.purs | 29 +++++++++++ src/Node/Errors/AbortSignal.js | 6 +++ src/Node/Errors/AbortSignal.purs | 61 ++++++++++++++++++++++ src/Node/ReadLine.js | 1 + src/Node/ReadLine.purs | 28 +++++++++++ src/Node/ReadLine/Aff.purs | 75 ++++++++++++++++++++++++++++ 8 files changed, 214 insertions(+), 1 deletion(-) create mode 100644 src/Node/Errors/AbortController.js create mode 100644 src/Node/Errors/AbortController.purs create mode 100644 src/Node/Errors/AbortSignal.js create mode 100644 src/Node/Errors/AbortSignal.purs create mode 100644 src/Node/ReadLine/Aff.purs diff --git a/CHANGELOG.md b/CHANGELOG.md index 38e24c3..71c6277 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,15 +31,23 @@ New features: - crlfDelay - escapeCodeTimeout - tabSize -- Added missing APIs (#35 by @JordanMartinez) +- Added missing APIs (#35, #36 by @JordanMartinez) - `pause`/`resume` + - `question'` - `getPrompt` - `write` exposed as `writeData` and `writeKey` - `line`, `cursor` - `getCursorPos`, `clearLine` variants, `clearScreenDown` variants - `cursorTo` variants, `moveCursor` variants - `emitKeyPressEvents` +- Added `Aff`-based convenience methods (#36 by @JordanMartinez) + + - `question` + - `question'` + - `countLines` + - `blockUntilClosed` +- Added bindings for `AbortController`/`AbortSignal` (#36 by @JordanMartinez) Bugfixes: diff --git a/src/Node/Errors/AbortController.js b/src/Node/Errors/AbortController.js new file mode 100644 index 0000000..c0eef88 --- /dev/null +++ b/src/Node/Errors/AbortController.js @@ -0,0 +1,5 @@ +const newImpl = () => new AbortController(); +export { newImpl as new }; +export const abortImpl = (controller) => controller.abort(); +export const abortReasonImpl = (controller, reason) => controller.abort(reason); +export const signal = (controller) => controller.signal; diff --git a/src/Node/Errors/AbortController.purs b/src/Node/Errors/AbortController.purs new file mode 100644 index 0000000..a300f98 --- /dev/null +++ b/src/Node/Errors/AbortController.purs @@ -0,0 +1,29 @@ +module Node.Errors.AbortController + ( AbortController + , new + , abort + , abort' + , signal + ) where + +import Prelude + +import Effect (Effect) +import Effect.Uncurried (EffectFn1, EffectFn2, runEffectFn1, runEffectFn2) +import Node.Errors.AbortSignal (AbortSignal) + +foreign import data AbortController :: Type + +foreign import new :: Effect (AbortController) + +abort :: AbortController -> Effect Unit +abort c = runEffectFn1 abortImpl c + +foreign import abortImpl :: EffectFn1 (AbortController) (Unit) + +abort' :: forall a. AbortController -> a -> Effect Unit +abort' c reason = runEffectFn2 abortReasonImpl c reason + +foreign import abortReasonImpl :: forall a. EffectFn2 (AbortController) (a) (Unit) + +foreign import signal :: AbortController -> AbortSignal diff --git a/src/Node/Errors/AbortSignal.js b/src/Node/Errors/AbortSignal.js new file mode 100644 index 0000000..21fdaf7 --- /dev/null +++ b/src/Node/Errors/AbortSignal.js @@ -0,0 +1,6 @@ +export const newAbort = () => AbortSignal.abort(); +export const newAbortReasonImpl = (reason) => AbortSignal.abort(reason); +export const timeoutImpl = (delay) => AbortSignal.timeout(delay); +export const abortedImpl = (sig) => sig.aborted; +export const reasonImpl = (sig) => sig.reason; +export const throwIfAbortedImpl = (sig) => sig.throwIfAborted(); diff --git a/src/Node/Errors/AbortSignal.purs b/src/Node/Errors/AbortSignal.purs new file mode 100644 index 0000000..ed8c85b --- /dev/null +++ b/src/Node/Errors/AbortSignal.purs @@ -0,0 +1,61 @@ +module Node.Errors.AbortSignal + ( AbortSignal + , toEventEmitter + , newAbort + , newAbort' + , newTimeout + , abortH + , aborted + , reason + , throwIfAborted + ) where + +import Prelude + +import Data.Time.Duration (Milliseconds) +import Effect (Effect) +import Effect.Uncurried (EffectFn1, runEffectFn1) +import Foreign (Foreign) +import Node.EventEmitter (EventEmitter, EventHandle(..)) +import Node.EventEmitter.UtilTypes (EventHandle0) +import Unsafe.Coerce (unsafeCoerce) + +foreign import data AbortSignal :: Type + +toEventEmitter :: AbortSignal -> EventEmitter +toEventEmitter = unsafeCoerce + +foreign import newAbort :: Effect (AbortSignal) + +newAbort' :: forall a. a -> Effect AbortSignal +newAbort' reason' = runEffectFn1 newAbortReasonImpl reason' + +foreign import newAbortReasonImpl :: forall a. EffectFn1 (a) (AbortSignal) + +newTimeout :: Milliseconds -> Effect AbortSignal +newTimeout delay = runEffectFn1 timeoutImpl delay + +foreign import timeoutImpl :: EffectFn1 (Milliseconds) (AbortSignal) + +-- | The 'abort' event is emitted when the abortController.abort() method is called. The callback is invoked with a single object argument with a single type property set to 'abort': +-- | +-- | We recommended that code check that the `abortSignal.aborted` attribute is false before adding an 'abort' event listener. +-- | +-- | Any event listeners attached to the AbortSignal should use the { once: true } option (or, if using the EventEmitter APIs to attach a listener, use the once() method) to ensure that the event listener is removed as soon as the 'abort' event is handled. Failure to do so may result in memory leaks. +abortH :: EventHandle0 AbortSignal +abortH = EventHandle "abort" identity + +aborted :: AbortSignal -> Effect Boolean +aborted sig = runEffectFn1 abortedImpl sig + +foreign import abortedImpl :: EffectFn1 (AbortSignal) (Boolean) + +reason :: AbortSignal -> Effect Foreign +reason sig = runEffectFn1 reasonImpl sig + +foreign import reasonImpl :: EffectFn1 (AbortSignal) (Foreign) + +throwIfAborted :: AbortSignal -> Effect Unit +throwIfAborted sig = runEffectFn1 throwIfAbortedImpl sig + +foreign import throwIfAbortedImpl :: EffectFn1 (AbortSignal) (Unit) diff --git a/src/Node/ReadLine.js b/src/Node/ReadLine.js index 8792a35..722a1d9 100644 --- a/src/Node/ReadLine.js +++ b/src/Node/ReadLine.js @@ -25,6 +25,7 @@ export const pauseImpl = (rl) => rl.pause(); export const promptImpl = (rl) => rl.prompt(); export const promptOptsImpl = (rl, cursor) => rl.prompt(cursor); export const questionImpl = (rl, text, cb) => rl.question(text, cb); +export const questionOptsCbImpl = (rl, text, opts, cb) => rl.question(text, opts, cb); export const resumeImpl = (rl) => rl.resume(); export const setPromptImpl = (rl, prompt) => rl.setPrompt(prompt); export const getPromptImpl = (rl) => rl.getPrompt(); diff --git a/src/Node/ReadLine.purs b/src/Node/ReadLine.purs index 6c225d9..d08b1c9 100644 --- a/src/Node/ReadLine.purs +++ b/src/Node/ReadLine.purs @@ -18,6 +18,7 @@ module Node.ReadLine , crlfDelay , escapeCodeTimeout , tabSize + , signal , closeH , lineH , historyH @@ -31,6 +32,7 @@ module Node.ReadLine , prompt , prompt' , question + , question' , resume , setPrompt , getPrompt @@ -64,6 +66,7 @@ import Data.Time.Duration (Milliseconds) import Effect (Effect) import Effect.Uncurried (EffectFn1, EffectFn2, EffectFn3, EffectFn4, mkEffectFn1, runEffectFn1, runEffectFn2, runEffectFn3, runEffectFn4) import Foreign (Foreign) +import Node.Errors.AbortSignal (AbortSignal) import Node.EventEmitter (EventEmitter, EventHandle(..)) import Node.EventEmitter.UtilTypes (EventHandle0, EventHandle1) import Node.Process (stdin, stdout) @@ -182,6 +185,9 @@ escapeCodeTimeout = opt "escapeCodeTimeout" tabSize :: Option InterfaceOptions Int tabSize = opt "tabSize" +signal :: Option InterfaceOptions AbortSignal +signal = opt "signal" + -- | The 'close' event is emitted when one of the following occur: -- | -- | - The `rl.close()` method is called and the readline.Interface instance has relinquished control over the input and output streams; @@ -314,6 +320,28 @@ question text cb iface = runEffectFn3 questionImpl iface text cb foreign import questionImpl :: EffectFn3 (Interface) (String) ((String -> Effect Unit)) Unit +-- | Writes a query to the output, waits +-- | for user input to be provided on input, then invokes +-- | the callback function +-- | +-- | Args: +-- | - `query` A statement or query to write to output, prepended to the prompt. +-- | - `options` +-- | - `signal` Optionally allows the question() to be canceled using an AbortController. +-- | - `callback` A callback function that is invoked with the user's input in response to the query. +-- | +-- | The `rl.question()` method displays the query by writing it to the output, waits for user input to be provided on input, then invokes the callback function passing the provided input as the first argument. +-- | +-- | When called, `rl.question()` will resume the input stream if it has been paused. +-- | +-- | If the readline.Interface was created with output set to null or undefined the query is not written. +-- | +-- | The callback function passed to `rl.question()` does not follow the typical pattern of accepting an Error object or null as the first argument. The callback is called with the provided answer as the only argument. +question' :: String -> { signal :: AbortSignal } -> (String -> Effect Unit) -> Interface -> Effect Unit +question' text opts cb iface = runEffectFn4 questionOptsCbImpl iface text opts cb + +foreign import questionOptsCbImpl :: EffectFn4 (Interface) (String) { signal :: AbortSignal } ((String -> Effect Unit)) Unit + -- | The rl.resume() method resumes the input stream if it has been paused. resume :: Interface -> Effect Unit resume iface = runEffectFn1 resumeImpl iface diff --git a/src/Node/ReadLine/Aff.purs b/src/Node/ReadLine/Aff.purs new file mode 100644 index 0000000..8c6a0a5 --- /dev/null +++ b/src/Node/ReadLine/Aff.purs @@ -0,0 +1,75 @@ +module Node.ReadLine.Aff + ( question + , question' + , blockUntilClosed + , countLines + ) where + +import Prelude + +import Data.Either (Either(..)) +import Effect.Aff (Aff, effectCanceler, error, makeAff, nonCanceler) +import Effect.Class (liftEffect) +import Effect.Exception (Error, throw) +import Effect.Ref as Ref +import Effect.Uncurried (mkEffectFn1) +import Node.Errors.AbortController (AbortController, abort', signal) +import Node.Errors.AbortSignal (abortH, aborted) +import Node.EventEmitter (EventHandle(..), on, once) +import Node.EventEmitter.UtilTypes (EventHandle1) +import Node.ReadLine (Interface, closeH, lineH) +import Node.ReadLine as RL + +-- | Blocks until receives user input. There is no way to cancel this. +question :: String -> Interface -> Aff String +question txt iface = makeAff \done -> do + RL.question txt (done <<< Right) iface + pure nonCanceler + +-- | Blocks until receives user input. An `AbortController` can be used to cancel this. +-- | If the `AbortSignal` is aborted outside of this function, this computation +-- | will produce an error. If the `AbortSignal` is already aborted, this will throw an error. +question' :: String -> AbortController -> Interface -> Aff String +question' txt controller iface = do + let sig = signal controller + -- Node docs: + -- > We recommended that code check that the `abortSignal.aborted` + -- > attribute is `false` before adding an 'abort' event listener. + liftEffect do + alreadyAborted <- aborted sig + when alreadyAborted do + throw "Signal was already aborted before calling 'question'" + makeAff \done -> do + rmAbortListener <- sig # once abortH do + done $ Left $ error "Signal was aborted after calling 'question'" + RL.question' txt { signal: sig } (done <<< Right) iface + pure $ effectCanceler do + rmAbortListener + abort' controller "Cancelled" + +blockUntilClosed :: Interface -> Aff Unit +blockUntilClosed iface = makeAff \done -> do + rmListener <- iface # once closeH (done $ Right unit) + pure $ effectCanceler rmListener + +-- Note: I'm not sure if this is needed, but it's not clear +-- from the Node docs that a `close` event will occur +-- if there's an error in either the `input` or `output` streams. +-- Moreover, `EventEmitter` docs say it's best practices to listen +-- for `error` events. +-- > As a best practice, listeners should always be added for the 'error' events. +errorH :: EventHandle1 Interface Error +errorH = EventHandle "error" mkEffectFn1 + +countLines :: Interface -> Aff Int +countLines iface = makeAff \done -> do + countRef <- Ref.new 0 + rmErrListener <- iface # once errorH (done <<< Left) + rmCloseListener <- iface # once closeH do + rmErrListener + done <<< Right =<< Ref.read countRef + rmLineListener <- iface # on lineH \_ -> Ref.modify_ (_ + 1) countRef + pure $ effectCanceler do + rmErrListener + rmCloseListener + rmLineListener