packages feed

jsaddle-terminal-0.1.0.0: src/Language/Javascript/JSaddle/Terminal.hs

{-# LANGUAGE CPP #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE ScopedTypeVariables #-}
-- | Run a jsaddle app from inside a terminal (e.g. a leksah / tmux pane).
--
-- 'run' handshakes with a watching IDE (leksah) over the process's OWN
-- stdin/stdout — see "Language.Javascript.JSaddle.Terminal.Protocol" — and,
-- when the IDE answers, tunnels jsaddle 'Batch'/'Results' JSON over that
-- channel; the UI renders in an IDE-hosted iframe.  No sockets are opened
-- here, so this works when the pane's machine is only reachable through
-- the IDE's existing ssh+tmux connection, and it works on every platform the
-- IDE runs on (POSIX termios / Windows console raw mode).
--
-- When nothing answers (not a tty, or the handshake times out) there is
-- nothing to tunnel to, so it just reports that and stops.  (An earlier
-- version fell back to serving the app over jsaddle-warp; that has been
-- removed.)
module Language.Javascript.JSaddle.Terminal
  ( run
  , runWith
  , Config(..)
  , defaultConfig
  ) where

import Control.Concurrent (forkIO, threadDelay)
import Control.Concurrent.MVar (newMVar, withMVar)
import Control.Concurrent.STM
       (atomically, newTChanIO, readTChan, writeTChan, TChan)
import Control.Exception (bracket_, catch, SomeException)
import Control.Monad (forever, when)
import Data.Aeson (encode, decodeStrict')
import qualified Data.ByteString as BS
import qualified Data.ByteString.Char8 as BSC
import qualified Data.ByteString.Lazy as LBS
import Data.IORef (newIORef, readIORef, writeIORef)
import System.Environment (lookupEnv)
import System.IO
       (stdin, stdout, stderr, hSetBinaryMode, hSetBuffering, hPutStrLn,
        BufferMode(..), hIsTerminalDevice, hFlush)
import System.Timeout (timeout)
#ifdef mingw32_HOST_OS
import Data.Bits ((.&.), (.|.), complement)
import Graphics.Win32.Misc (getStdHandle, sTD_INPUT_HANDLE, sTD_OUTPUT_HANDLE)
import System.Win32.Console
       (getConsoleMode, setConsoleMode, eNABLE_ECHO_INPUT, eNABLE_LINE_INPUT,
        eNABLE_PROCESSED_INPUT, eNABLE_VIRTUAL_TERMINAL_INPUT,
        eNABLE_VIRTUAL_TERMINAL_PROCESSING)
import System.Win32.Process (getCurrentProcessId)
#else
import System.Posix.IO (stdInput)
import System.Posix.Process (getProcessID)
import System.Posix.Terminal
       (getTerminalAttributes, setTerminalAttributes, withoutMode,
        withMinInput, withTime, TerminalAttributes, TerminalMode(..),
        TerminalState(Immediately))
#endif

import Language.Javascript.JSaddle (JSM)
import Language.Javascript.JSaddle.Run (runJavaScript)
import qualified Language.Javascript.JSaddle.Types as JS (Results)

import Language.Javascript.JSaddle.Terminal.Protocol

data Config = Config
  { helloIntervalUs :: Int   -- ^ µs between HELLO retries (default 500ms)
  , helloAttempts   :: Int   -- ^ retries before falling back (default 6 ≈ 3s)
  } deriving Show

defaultConfig :: Config
defaultConfig = Config
  { helloIntervalUs = 500000
  , helloAttempts   = 6
  }

-- | 'runWith' 'defaultConfig'.
run :: JSM () -> IO ()
run = runWith defaultConfig

-- | Why the tunnel session ended.
data Outcome = NoIde | Finished

-- | Debug tracing to a file, enabled by @JSADDLE_TERMINAL_DEBUG=<path>@
-- (stderr would be swallowed by the tunnel's raw tty).
debugLog :: String -> IO ()
debugLog msg = do
  mb <- lookupEnv "JSADDLE_TERMINAL_DEBUG"
  case mb of
    Nothing -> pure ()
    Just p  -> appendFile p (msg <> "\n") `catch` \(_ :: SomeException) -> pure ()

runWith :: Config -> JSM () -> IO ()
runWith cfg jsm = do
  ttyIn  <- hIsTerminalDevice stdin
  ttyOut <- hIsTerminalDevice stdout
  if not (ttyIn && ttyOut)
    then noIde "stdin/stdout is not a terminal"
    else do
      hSetBinaryMode stdin True
      hSetBuffering stdin NoBuffering
      hSetBinaryMode stdout True
      hSetBuffering stdout NoBuffering
      outcome <- withRawMode (tunnel cfg jsm)
      case outcome of
        NoIde    -> noIde "no IDE answered the handshake"
        Finished -> pure ()

-- | Nothing is driving the tunnel and there is no fallback server, so report
-- why and stop (the app is meant to be launched from inside leksah).
noIde :: String -> IO ()
noIde why = hPutStrLn stderr $
  "jsaddle-terminal: " <> why <> "; nothing to do (run this app from inside leksah)"

#ifdef mingw32_HOST_OS
-- | Windows console raw mode — the direct analog of the POSIX termios bracket.
-- Input: drop line editing, echo and processed-input (so Ctrl-C arrives as a
-- raw 0x03 rather than a signal, matching the reader), and enable VT input so
-- keys arrive as escape sequences.  Output: enable VT processing so the app's
-- ANSI escapes are interpreted.  Both modes are restored on exit.
withRawMode :: IO a -> IO a
withRawMode act = do
  hIn  <- getStdHandle sTD_INPUT_HANDLE
  hOut <- getStdHandle sTD_OUTPUT_HANDLE
  inSaved  <- getConsoleMode hIn
  outSaved <- getConsoleMode hOut
  let rawIn  = (inSaved .&. complement
                  (eNABLE_LINE_INPUT .|. eNABLE_ECHO_INPUT .|. eNABLE_PROCESSED_INPUT))
                 .|. eNABLE_VIRTUAL_TERMINAL_INPUT
      rawOut = outSaved .|. eNABLE_VIRTUAL_TERMINAL_PROCESSING
  -- One bracket per handle, nested: with both mode changes in a single acquire
  -- action, a failure setting the OUTPUT mode would skip the release action
  -- altogether and leave the console stuck in raw INPUT mode.
  bracket_ (setConsoleMode hIn rawIn) (setConsoleMode hIn inSaved) $
    bracket_ (setConsoleMode hOut rawOut) (setConsoleMode hOut outSaved) act

-- | A per-run identifier for the HELLO payload (see 'handshake').
getRunPid :: IO Integer
getRunPid = toInteger <$> getCurrentProcessId
#else
-- | POSIX raw tty: no echo, no canonical mode, no signals, no flow control,
-- and — crucially — no IEXTEN (macOS @stty raw@ leaves it on, and its
-- VDISCARD / VLNEXT would eat 0x0f/0x16 from injected input), no CR mapping,
-- no output processing.  Restored on exit.
withRawMode :: IO a -> IO a
withRawMode act = do
  saved <- getTerminalAttributes stdInput
  bracket_
    (setTerminalAttributes stdInput (rawMode saved) Immediately)
    (setTerminalAttributes stdInput saved Immediately)
    act

rawMode :: TerminalAttributes -> TerminalAttributes
rawMode attrs =
  withTime (withMinInput cleared 1) 0
  where
    cleared = foldl withoutMode attrs
      [ EnableEcho, EchoErase, EchoKill, EchoLF
      , ProcessInput          -- ICANON
      , KeyboardInterrupts    -- ISIG
      , ExtendedFunctions     -- IEXTEN
      , StartStopOutput       -- IXON
      , MapCRtoLF             -- ICRNL
      , ProcessOutput         -- OPOST
      , InterruptOnBreak, CheckParity, StripHighBit
      ]

-- | A per-run identifier for the HELLO payload (see 'handshake').
getRunPid :: IO Integer
getRunPid = toInteger <$> getProcessID
#endif

-- | The tunnel session, inside the 'withRawMode' bracket (termios on POSIX,
-- console mode on Windows).
tunnel :: Config -> JSM () -> IO Outcome
tunnel cfg jsm = do
  wlock <- newMVar ()
  let writeFrame f = withMVar wlock $ \_ -> do
        BS.hPut stdout (encodeOsc f)
        hFlush stdout
  frames <- newTChanIO
  -- Reader: the only stdin consumer.  A raw 0x03 (Ctrl-C — ISIG is off) is
  -- outside any frame (frames are printable+RS+LF), so treat it as a close.
  _ <- forkIO $ readerLoop frames
  ok <- handshake cfg writeFrame frames
  debugLog ("handshake: " <> (if ok then "ACKed" else "timed out"))
  if not ok
    then pure NoIde
    else do
      (processResults, processSyncResults, start)
        <- runJavaScript
             (writeFrame . Frame Batch . LBS.toStrict . encode)
             jsm
      _ <- forkIO start
      let loop = atomically (readTChan frames) >>= \case
            Frame Results p -> case decodeStrict' p :: Maybe JS.Results of
              Just r  -> processResults r >> loop
              Nothing -> protoErr "undecodable RESULTS"
            Frame Sync p -> case decodeStrict' p :: Maybe JS.Results of
              -- processSyncResults blocks on the batch loop; never run it
              -- on the dispatch thread.
              Just r  -> do
                _ <- forkIO $
                  processSyncResults r
                    >>= writeFrame . Frame SyncReply . LBS.toStrict . encode
                loop
              Nothing -> protoErr "undecodable SYNC"
            Frame Close _ -> do
                debugLog "dispatch: CLOSE"
                writeFrame (Frame Bye "{}")
            f -> do
                debugLog ("dispatch: ignoring " <> show (frameType f))
                loop
          protoErr msg = do
            debugLog ("protocol error: " <> msg)
            hPutStrLn stderr ("jsaddle-terminal: protocol error: " <> msg)
            writeFrame (Frame Bye "{}")
      loop
      pure Finished

-- | Read stdin, scan RS frames into the channel; Ctrl-C injects Close.
readerLoop :: TChan Frame -> IO ()
readerLoop frames = do
  st <- newIORef emptyRsScan
  forever $ do
    chunk <- BS.hGetSome stdin 4096
    when (BS.null chunk) $ threadDelay 50000   -- EOF-ish; don't spin
    when (0x03 `BS.elem` chunk) $ do
      debugLog "reader: Ctrl-C"
      atomically $ writeTChan frames (Frame Close "{}")
    s <- readIORef st
    let (fs, s') = scanRs s chunk
    writeIORef st s'
    mapM_ (\f -> do debugLog ("reader: frame " <> show (frameType f))
                    atomically (writeTChan frames f)) fs

-- | Emit HELLO until an ACK (True), a CLOSE (False → fallback is still the
-- sane response) or the retry budget runs out (False).
--
-- The HELLO payload carries a per-run identifier (the pid): retries of the
-- SAME run are byte-identical, so the IDE can tell a retry still in flight
-- when its ACK lands (ignore/re-ACK) from a genuinely restarted app (tear
-- down the old iframe and rebuild) — without it, a late retry made the IDE
-- rebuild mid-handshake and drop the first batch.
handshake :: Config -> (Frame -> IO ()) -> TChan Frame -> IO Bool
handshake cfg writeFrame frames = do
    pid <- getRunPid
    let hello = Frame Hello $
          "{\"proto\":1,\"caps\":[\"sync\"],\"run\":" <> BSC.pack (show pid) <> "}"
        go 0 = pure False
        go n = do
          writeFrame hello
          r <- timeout (helloIntervalUs cfg) waitAck
          case r of
            Just b  -> pure b
            Nothing -> go (n - 1 :: Int)
        waitAck = atomically (readTChan frames) >>= \case
          Frame Ack _   -> pure True
          Frame Close _ -> pure False
          _             -> waitAck
    go (helloAttempts cfg)