diff --git a/psc-bundle/Main.hs b/psc-bundle/Main.hs index 5e214847d6..92ff4f281a 100644 --- a/psc-bundle/Main.hs +++ b/psc-bundle/Main.hs @@ -15,7 +15,7 @@ import Control.Monad.Error.Class import Control.Monad.Trans.Except import Control.Monad.IO.Class -import System.FilePath (takeFileName, takeDirectory) +import System.FilePath (takeDirectory) import System.FilePath.Glob (glob) import System.Exit (exitFailure) import System.IO (stderr, stdout, hPutStrLn, hSetEncoding, utf8) @@ -37,14 +37,6 @@ data Options = Options , optionsNamespace :: String } deriving Show --- | Given a filename, assuming it is in the correct place on disk, infer a ModuleIdentifier. -guessModuleIdentifier :: (MonadError ErrorMessage m) => FilePath -> m ModuleIdentifier -guessModuleIdentifier filename = ModuleIdentifier (takeFileName (takeDirectory filename)) <$> guessModuleType (takeFileName filename) - where - guessModuleType "index.js" = pure Regular - guessModuleType "foreign.js" = pure Foreign - guessModuleType name = throwError $ UnsupportedModulePath name - -- | The main application function. -- This function parses the input files, performs dead code elimination, filters empty modules -- and generates and prints the final Javascript bundle. diff --git a/psci/Main.hs b/psci/Main.hs index e41723e682..8dc6c9d5df 100644 --- a/psci/Main.hs +++ b/psci/Main.hs @@ -1,9 +1,13 @@ {-# LANGUAGE DataKinds #-} {-# LANGUAGE DoAndIfThenElse #-} +{-# LANGUAGE GADTs #-} {-# LANGUAGE GeneralizedNewtypeDeriving #-} {-# LANGUAGE MultiParamTypeClasses #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE PatternGuards #-} {-# LANGUAGE RecordWildCards #-} {-# LANGUAGE ScopedTypeVariables #-} +{-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TupleSections #-} module Main (main) where @@ -11,10 +15,22 @@ module Main (main) where import Prelude () import Prelude.Compat +import Data.FileEmbed (embedStringFile) import Data.Monoid ((<>)) +import Data.String (IsString(..)) +import Data.Text (Text, unpack) +import Data.Traversable (for) import Data.Version (showVersion) -import Control.Applicative (many) +import Control.Applicative (many, (<|>)) +import Control.Concurrent (forkIO) +import Control.Concurrent.MVar (MVar, newEmptyMVar, putMVar, takeMVar, + tryPutMVar) +import Control.Concurrent.STM (TVar, atomically, newTVarIO, writeTVar, + readTVarIO, + TChan, newBroadcastTChanIO, dupTChan, + readTChan, writeTChan) +import Control.Exception (fromException) import Control.Monad import Control.Monad.IO.Class (liftIO) import Control.Monad.Trans.Class @@ -23,21 +39,33 @@ import Control.Monad.Trans.State.Strict (StateT, evalStateT) import Control.Monad.Trans.Reader (ReaderT, runReaderT) import qualified Language.PureScript as P +import qualified Language.PureScript.Bundle as Bundle import Language.PureScript.Interactive +import Network.HTTP.Types.Header (hContentType, hCacheControl, + hPragma, hExpires) +import Network.HTTP.Types.Status (status200, status404, status503) +import qualified Network.Wai as Wai +import qualified Network.Wai.Handler.Warp as Warp +import qualified Network.Wai.Handler.WebSockets as WS +import qualified Network.WebSockets as WS + import qualified Options.Applicative as Opts import qualified Paths_purescript as Paths import System.Console.Haskeline +import System.IO.UTF8 (readUTF8File) import System.Exit +import System.FilePath (()) import System.FilePath.Glob (glob) +import System.Process (readProcessWithExitCode) -- | Command line options data PSCiOptions = PSCiOptions { psciMultiLineMode :: Bool , psciInputFile :: [FilePath] - , psciInputNodeFlags :: [String] + , psciBackend :: Backend } multiLineMode :: Opts.Parser Bool @@ -60,10 +88,21 @@ nodeFlagsFlag = Opts.option parser $ where parser = words <$> Opts.str +port :: Opts.Parser Int +port = Opts.option Opts.auto $ + Opts.long "port" + <> Opts.short 'p' + <> Opts.help "The web server port" + +backend :: Opts.Parser Backend +backend = + (browserBackend <$> port) + <|> (nodeBackend <$> nodeFlagsFlag) + psciOptions :: Opts.Parser PSCiOptions psciOptions = PSCiOptions <$> multiLineMode <*> many inputFile - <*> nodeFlagsFlag + <*> backend version :: Opts.Parser (a -> a) version = Opts.abortOption (Opts.InfoMsg (showVersion Paths.version)) $ @@ -92,6 +131,195 @@ getCommand singleLineMode = handleInterrupt (return (Right Nothing)) $ do go :: [String] -> InputT m String go ls = maybe (return . unlines $ reverse ls) (go . (:ls)) =<< getInputLine " " +-- | Make a JavaScript bundle for the browser. +bundle :: IO (Either Bundle.ErrorMessage String) +bundle = runExceptT $ do + inputFiles <- liftIO (glob (".psci_modules" "node_modules" "*" "*.js")) + input <- for inputFiles $ \filename -> do + js <- liftIO (readUTF8File filename) + mid <- Bundle.guessModuleIdentifier filename + length js `seq` return (mid, js) + Bundle.bundle input [] Nothing "PSCI" + +indexJS :: IsString string => string +indexJS = $(embedStringFile "psci/static/index.js") + +indexPage :: IsString string => string +indexPage = $(embedStringFile "psci/static/index.html") + +-- | All of the functions required to implement a PSCi backend +data Backend = forall state. Backend + { _backendSetup :: IO state + -- ^ Initialize, and call the continuation when the backend is ready + , _backendEval :: state -> String -> IO () + -- ^ Evaluate JavaScript code + , _backendReload :: state -> IO () + -- ^ Reload the compiled code + , _backendShutdown :: state -> IO () + -- ^ Shut down the backend + } + +-- | Commands which can be sent to the browser +data BrowserCommand + = Eval (MVar String) + -- ^ Evaluate the latest JS + | Reload + -- ^ Reload the page + +-- | State for the browser backend +data BrowserState = BrowserState + { browserCommands :: TChan BrowserCommand + -- ^ A channel which receives data when the compiled JS has + -- been updated + , browserShutdownNotice :: MVar () + -- ^ An MVar which becomes full when the server should shut down + , browserIndexJS :: TVar (Maybe String) + -- ^ A TVar holding the latest compiled JS + , browserBundleJS :: TVar (Maybe String) + -- ^ A TVar holding the latest bundled JS + } + +browserBackend :: Int -> Backend +browserBackend serverPort = Backend setup evaluate reload shutdown + where + setup :: IO BrowserState + setup = do + shutdownVar <- newEmptyMVar + cmdChan <- newBroadcastTChanIO + indexJs <- newTVarIO Nothing + bundleJs <- newTVarIO Nothing + + let + handleWebsocket :: WS.PendingConnection -> IO () + handleWebsocket pending = do + conn <- WS.acceptRequest pending + -- Fork a thread to keep the connection alive + WS.forkPingThread conn 10 + -- Clone the command channel + cmdChanCopy <- atomically $ dupTChan cmdChan + -- Listen for commands + forever $ do + cmd <- atomically $ readTChan cmdChanCopy + case cmd of + Eval resultVar -> void $ do + WS.sendTextData conn ("eval" :: Text) + result <- WS.receiveData conn + -- With many connected clients, all but one of + -- these attempts will fail. + tryPutMVar resultVar (unpack result) + Reload -> do + WS.sendTextData conn ("reload" :: Text) + + shutdownHandler :: IO () -> IO () + shutdownHandler stopServer = void . forkIO $ do + () <- takeMVar shutdownVar + stopServer + + onException :: Maybe Wai.Request -> SomeException -> IO () + onException req ex + | Just (_ :: WS.ConnectionException) <- fromException ex + = return () -- ignore websocket disconnects + | otherwise = Warp.defaultOnException req ex + + staticServer :: Wai.Application + staticServer req respond = + case Wai.pathInfo req of + [] -> + respond $ Wai.responseLBS status200 + [(hContentType, "text/html")] + indexPage + ["js", "index.js"] -> + respond $ Wai.responseLBS status200 + [(hContentType, "application/javascript")] + indexJS + ["js", "latest.js"] -> do + may <- readTVarIO indexJs + case may of + Nothing -> + respond $ Wai.responseLBS status503 [] "Service not available" + Just js -> + respond $ Wai.responseLBS status200 + [ (hContentType, "application/javascript") + , (hCacheControl, "no-cache, no-store, must-revalidate") + , (hPragma, "no-cache") + , (hExpires, "0") + ] + (fromString js) + ["js", "bundle.js"] -> do + may <- readTVarIO bundleJs + case may of + Nothing -> + respond $ Wai.responseLBS status503 [] "Service not available" + Just js -> + respond $ Wai.responseLBS status200 + [ (hContentType, "application/javascript")] + (fromString js) + _ -> respond $ Wai.responseLBS status404 [] "Not found" + + let browserState = BrowserState cmdChan shutdownVar indexJs bundleJs + createBundle browserState + + putStrLn $ "Serving http://localhost:" <> show serverPort <> "/. Waiting for connections..." + _ <- forkIO $ Warp.runSettings ( Warp.setInstallShutdownHandler shutdownHandler + . Warp.setPort serverPort + . Warp.setOnException onException + $ Warp.defaultSettings + ) $ + WS.websocketsOr WS.defaultConnectionOptions + handleWebsocket + staticServer + return browserState + + createBundle :: BrowserState -> IO () + createBundle state = do + putStrLn "Bundling Javascript..." + ejs <- bundle + case ejs of + Left err -> do + putStrLn (unlines (Bundle.printErrorMessage err)) + exitFailure + Right js -> do + atomically $ writeTVar (browserBundleJS state) (Just js) + + reload :: BrowserState -> IO () + reload state = do + createBundle state + atomically $ writeTChan (browserCommands state) Reload + + shutdown :: BrowserState -> IO () + shutdown state = putMVar (browserShutdownNotice state) () + + evaluate :: BrowserState -> String -> IO () + evaluate state js = liftIO $ do + resultVar <- newEmptyMVar + atomically $ do + writeTVar (browserIndexJS state) (Just js) + writeTChan (browserCommands state) (Eval resultVar) + result <- takeMVar resultVar + putStrLn result + +nodeBackend :: [String] -> Backend +nodeBackend nodeArgs = Backend setup eval reload shutdown + where + setup :: IO () + setup = return () + + eval :: () -> String -> IO () + eval _ _ = do + writeFile indexFile "require('$PSCI')['$main']();" + process <- findNodeProcess + result <- traverse (\node -> readProcessWithExitCode node (nodeArgs ++ [indexFile]) "") process + case result of + Just (ExitSuccess, out, _) -> putStrLn out + Just (ExitFailure _, _, err) -> putStrLn err + Nothing -> putStrLn "Couldn't find node.js" + + reload :: () -> IO () + reload _ = return () + + shutdown :: () -> IO () + shutdown _ = return () + -- | Get command line options and drop into the REPL main :: IO () main = getOpt >>= loop @@ -106,27 +334,31 @@ main = getOpt >>= loop exitFailure (externs, env) <- ExceptT . runMake . make $ modules return (modules, externs, env) - case e of - Left errs -> putStrLn (P.prettyPrintMultipleErrors P.defaultPPEOptions errs) >> exitFailure - Right (modules, externs, env) -> do - historyFilename <- getHistoryFilename - let settings = defaultSettings { historyFile = Just historyFilename } - initialState = PSCiState [] [] (zip (map snd modules) externs) - config = PSCiConfig inputFiles psciInputNodeFlags env - runner = flip runReaderT config - . flip evalStateT initialState - . runInputT (setComplete completion settings) - putStrLn prologueMessage - runner go - where - go :: InputT (StateT PSCiState (ReaderT PSCiConfig IO)) () - go = do - c <- getCommand (not psciMultiLineMode) - case c of - Left err -> outputStrLn err >> go - Right Nothing -> go - Right (Just QuitPSCi) -> outputStrLn quitMessage - Right (Just c') -> do - handleInterrupt (outputStrLn "Interrupted.") - (withInterrupt (lift (handleCommand c'))) - go + case psciBackend of + Backend setup eval reload (shutdown :: state -> IO ()) -> do + case e of + Left errs -> putStrLn (P.prettyPrintMultipleErrors P.defaultPPEOptions errs) >> exitFailure + Right (modules, externs, env) -> do + historyFilename <- getHistoryFilename + let settings = defaultSettings { historyFile = Just historyFilename } + initialState = PSCiState [] [] (zip (map snd modules) externs) + config = PSCiConfig inputFiles env + runner = flip runReaderT config + . flip evalStateT initialState + . runInputT (setComplete completion settings) + + go :: state -> InputT (StateT PSCiState (ReaderT PSCiConfig IO)) () + go state = do + c <- getCommand (not psciMultiLineMode) + case c of + Left err -> outputStrLn err >> go state + Right Nothing -> go state + Right (Just QuitPSCi) -> do + outputStrLn quitMessage + liftIO $ shutdown state + Right (Just c') -> do + handleInterrupt (outputStrLn "Interrupted.") + (withInterrupt (lift (handleCommand (liftIO . eval state) (liftIO (reload state)) c'))) + go state + putStrLn prologueMessage + setup >>= runner . go diff --git a/psci/static/index.html b/psci/static/index.html new file mode 100644 index 0000000000..f749b8ae22 --- /dev/null +++ b/psci/static/index.html @@ -0,0 +1,10 @@ + + + + PureScript Interactive + + + + + + diff --git a/psci/static/index.js b/psci/static/index.js new file mode 100644 index 0000000000..08b5f1ea19 --- /dev/null +++ b/psci/static/index.js @@ -0,0 +1,63 @@ +var get = function get(uri, callback, onError) { + var request = new XMLHttpRequest(); + request.addEventListener('load', function() { + callback(request.responseText); + }); + request.addEventListener('error', onError); + request.open('GET', uri); + request.send(); +}; +var evaluate = function evaluate(js) { + var buffer = []; + // Save the old console.log function + var oldLog = console.log; + console.log = function(s) { + // Push log output into a temporary buffer + // which will be returned to PSCi. + buffer.push(s); + }; + // Replace any require(...) statements with lookups on the PSCI object. + var replaced = js.replace(/require\("[^"]*"\)/g, function(s) { + return "PSCI['" + s.substring(12, s.length - 2) + "']"; + }); + // Wrap the module and evaluate it. + var wrapped = + [ 'var module = {};' + , '(function(module) {' + , replaced + , '})(module);' + , 'return module.exports["$main"] && module.exports["$main"]();' + ].join('\n'); + new Function(wrapped)(); + // Restore console.log + console.log = oldLog; + return buffer.join('\n'); +}; +window.onload = function() { + var socket = new WebSocket('ws://0.0.0.0:' + location.port); + var evalNext = function reload() { + get('js/latest.js', function(response) { + try { + var result = evaluate(response); + socket.send(result); + } catch (ex) { + socket.send(ex.stack); + } + }, function(err) { + socket.send('Error sending JavaScript'); + }); + }; + socket.onopen = function() { + console.log('Connected'); + socket.onmessage = function(event) { + switch (event.data) { + case 'eval': + evalNext(); + break; + case 'reload': + location.reload(); + break; + } + }; + }; +}; diff --git a/purescript.cabal b/purescript.cabal index 62ec0354b9..3adf8cb599 100644 --- a/purescript.cabal +++ b/purescript.cabal @@ -79,6 +79,8 @@ extra-source-files: examples/passing/*.purs , examples/docs/bower_components/purescript-prelude/src/*.purs , examples/docs/bower.json , examples/docs/src/*.purs + , psci/static/index.html + , psci/static/index.js , tests/support/package.json , tests/support/bower.json , tests/support/setup-win.cmd @@ -335,18 +337,27 @@ executable psci purescript -any, base-compat >=0.6.0, boxes >= 0.1.4 && < 0.2.0, + bytestring -any, containers -any, directory -any, filepath -any, + file-embed -any, Glob -any, haskeline >= 0.7.0.0, + http-types == 0.9.*, mtl -any, optparse-applicative >= 0.12.1, parsec -any, process -any, + stm >= 0.2.4.0, + text -any, time -any, transformers -any, - transformers-compat -any + transformers-compat -any, + wai == 3.*, + wai-websockets == 3.*, + warp == 3.*, + websockets >= 0.9 && <0.10 main-is: Main.hs buildable: True hs-source-dirs: psci diff --git a/src/Language/PureScript/Bundle.hs b/src/Language/PureScript/Bundle.hs index 316652c8c1..bdc6d9067d 100644 --- a/src/Language/PureScript/Bundle.hs +++ b/src/Language/PureScript/Bundle.hs @@ -6,6 +6,7 @@ -- and generates the final Javascript bundle. module Language.PureScript.Bundle ( bundle + , guessModuleIdentifier , ModuleIdentifier(..) , moduleName , ModuleType(..) @@ -32,6 +33,8 @@ import Language.JavaScript.Parser.AST import qualified Paths_purescript as Paths +import System.FilePath (takeFileName, takeDirectory) + -- | The type of error messages. We separate generation and rendering of errors using a data -- type, in case we need to match on error types later. data ErrorMessage @@ -58,6 +61,14 @@ data ModuleIdentifier = ModuleIdentifier String ModuleType deriving (Show, Read, moduleName :: ModuleIdentifier -> String moduleName (ModuleIdentifier name _) = name +-- | Given a filename, assuming it is in the correct place on disk, infer a ModuleIdentifier. +guessModuleIdentifier :: MonadError ErrorMessage m => FilePath -> m ModuleIdentifier +guessModuleIdentifier filename = ModuleIdentifier (takeFileName (takeDirectory filename)) <$> guessModuleType (takeFileName filename) + where + guessModuleType "index.js" = pure Regular + guessModuleType "foreign.js" = pure Foreign + guessModuleType name = throwError $ UnsupportedModulePath name + -- | A piece of code is identified by its module and its name. These keys are used to label vertices -- in the dependency graph. type Key = (ModuleIdentifier, String) diff --git a/src/Language/PureScript/Interactive.hs b/src/Language/PureScript/Interactive.hs index 766099f3b7..f186a4dc29 100644 --- a/src/Language/PureScript/Interactive.hs +++ b/src/Language/PureScript/Interactive.hs @@ -39,8 +39,7 @@ import Language.PureScript.Interactive.Parser as Interactive import Language.PureScript.Interactive.Printer as Interactive import Language.PureScript.Interactive.Types as Interactive -import System.Exit -import System.Process (readProcessWithExitCode) +import System.FilePath (()) -- | Pretty-print errors printErrors :: MonadIO m => P.MultipleErrors -> m () @@ -92,25 +91,28 @@ make ms = do -- | Performs a PSCi command handleCommand :: (MonadReader PSCiConfig m, MonadState PSCiState m, MonadIO m) - => Command + => (String -> m ()) -> m () -handleCommand ShowHelp = liftIO $ putStrLn helpMessage -handleCommand ResetState = handleResetState -handleCommand (Expression val) = handleExpression val -handleCommand (Import im) = handleImport im -handleCommand (Decls l) = handleDecls l -handleCommand (TypeOf val) = handleTypeOf val -handleCommand (KindOf typ) = handleKindOf typ -handleCommand (BrowseModule moduleName) = handleBrowse moduleName -handleCommand (ShowInfo QueryLoaded) = handleShowLoadedModules -handleCommand (ShowInfo QueryImport) = handleShowImportedModules -handleCommand QuitPSCi = P.internalError "`handleCommand QuitPSCi` was called. This is a bug." + -> Command + -> m () +handleCommand _ _ ShowHelp = liftIO $ putStrLn helpMessage +handleCommand _ r ResetState = handleResetState r +handleCommand c _ (Expression val) = handleExpression c val +handleCommand _ _ (Import im) = handleImport im +handleCommand _ _ (Decls l) = handleDecls l +handleCommand _ _ (TypeOf val) = handleTypeOf val +handleCommand _ _ (KindOf typ) = handleKindOf typ +handleCommand _ _ (BrowseModule moduleName) = handleBrowse moduleName +handleCommand _ _ (ShowInfo QueryLoaded) = handleShowLoadedModules +handleCommand _ _ (ShowInfo QueryImport) = handleShowImportedModules +handleCommand _ _ QuitPSCi = P.internalError "`handleCommand QuitPSCi` was called. This is a bug." -- | Reset the application state handleResetState :: (MonadReader PSCiConfig m, MonadState PSCiState m, MonadIO m) => m () -handleResetState = do + -> m () +handleResetState reload = do modify $ updateImportedModules (const []) . updateLets (const []) files <- asks psciLoadedFiles @@ -120,30 +122,25 @@ handleResetState = do return (map snd modules, externs) case e of Left errs -> printErrors errs - Right (modules, externs) -> modify (updateLoadedExterns (const (zip modules externs))) + Right (modules, externs) -> do + modify (updateLoadedExterns (const (zip modules externs))) + reload -- | Takes a value expression and evaluates it with the current state. --- --- TODO: factor out the Node process runner, so that we can use PSCi in other settings. handleExpression :: (MonadReader PSCiConfig m, MonadState PSCiState m, MonadIO m) - => P.Expr + => (String -> m ()) + -> P.Expr -> m () -handleExpression val = do +handleExpression evaluate val = do st <- get let m = createTemporaryModule True st val - nodeArgs <- asks ((++ [indexFile]) . psciNodeFlags) e <- liftIO . runMake $ rebuild (map snd (psciLoadedExterns st)) m case e of Left errs -> printErrors errs Right _ -> do - liftIO $ writeFile indexFile "require('$PSCI')['$main']();" - process <- liftIO findNodeProcess - result <- liftIO $ traverse (\node -> readProcessWithExitCode node nodeArgs "") process - case result of - Just (ExitSuccess, out, _) -> liftIO $ putStrLn out - Just (ExitFailure _, _, err) -> liftIO $ putStrLn err - Nothing -> liftIO $ putStrLn "Couldn't find node.js" + js <- liftIO $ readFile (modulesDir "$PSCI" "index.js") + evaluate js -- | -- Takes a list of declarations and updates the environment, then run a make. If the declaration fails, diff --git a/src/Language/PureScript/Interactive/Types.hs b/src/Language/PureScript/Interactive/Types.hs index 1c20721efe..deae8c6f80 100644 --- a/src/Language/PureScript/Interactive/Types.hs +++ b/src/Language/PureScript/Interactive/Types.hs @@ -13,7 +13,6 @@ import qualified Language.PureScript as P -- data PSCiConfig = PSCiConfig { psciLoadedFiles :: [FilePath] - , psciNodeFlags :: [String] , psciEnvironment :: P.Environment } deriving Show diff --git a/stack-ghc-8.0.yaml b/stack-ghc-8.0.yaml index f131e996b3..d56763ecc2 100644 --- a/stack-ghc-8.0.yaml +++ b/stack-ghc-8.0.yaml @@ -3,3 +3,5 @@ packages: - '.' extra-deps: - pipes-http-1.0.2 +- wai-websockets-3.0.0.9 +- websockets-0.9.6.2