Skip to content

Commit

Permalink
Add Aff-based API (#35)
Browse files Browse the repository at this point in the history
* Add bindings to AbortController/AbortSignal

* Add signal variant of question'

* Add Aff-based APIs

* Add changelog entry
  • Loading branch information
JordanMartinez authored Jul 21, 2023
1 parent 76aa1fb commit 61731ba
Show file tree
Hide file tree
Showing 8 changed files with 214 additions and 1 deletion.
10 changes: 9 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
5 changes: 5 additions & 0 deletions src/Node/Errors/AbortController.js
Original file line number Diff line number Diff line change
@@ -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;
29 changes: 29 additions & 0 deletions src/Node/Errors/AbortController.purs
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions src/Node/Errors/AbortSignal.js
Original file line number Diff line number Diff line change
@@ -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();
61 changes: 61 additions & 0 deletions src/Node/Errors/AbortSignal.purs
Original file line number Diff line number Diff line change
@@ -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)
1 change: 1 addition & 0 deletions src/Node/ReadLine.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
28 changes: 28 additions & 0 deletions src/Node/ReadLine.purs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ module Node.ReadLine
, crlfDelay
, escapeCodeTimeout
, tabSize
, signal
, closeH
, lineH
, historyH
Expand All @@ -31,6 +32,7 @@ module Node.ReadLine
, prompt
, prompt'
, question
, question'
, resume
, setPrompt
, getPrompt
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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` <string> A statement or query to write to output, prepended to the prompt.
-- | - `options` <Object>
-- | - `signal` <AbortSignal> Optionally allows the question() to be canceled using an AbortController.
-- | - `callback` <Function> 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
Expand Down
75 changes: 75 additions & 0 deletions src/Node/ReadLine/Aff.purs
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit 61731ba

Please sign in to comment.