on master help sign in
Merge remote-tracking branch 'origin/routes' into reunification
commit75f8329707ce7b9e3d66ba5157cfe1709ca233db authorMatthew Bauer <matthew.bauer@obsidian.systems> dateMon 16 Jul 2018, 15:05 (8 years ago) parent92f5133ab03bde
41 files changed+1678−229
README.md+15−9 default.nix+48−27 lib/asset/manifest/src/Obelisk/Asset/Promoted.hs+8−2 lib/backend/obelisk-backend.cabal+9−1 lib/backend/src/Obelisk/Backend.hs+150−22 lib/cliapp/src/Obelisk/CliApp.hs+1 lib/cliapp/src/Obelisk/CliApp/Logging.hs+6−5 lib/cliapp/src/Obelisk/CliApp/Process.hs+12−13 lib/cliapp/src/Obelisk/CliApp/TerminalString.hs+6−6 lib/command/obelisk-command.cabal+2 lib/command/src/Obelisk/Command.hs+15−3 lib/command/src/Obelisk/Command/Deploy.hs+122−26 lib/command/src/Obelisk/Command/Nix.hs+47−6 lib/command/src/Obelisk/Command/Project.hs+2−2 lib/command/src/Obelisk/Command/Run.hs+2−3 lib/command/src/Obelisk/Command/Thunk.hs+3−3 lib/command/src/Obelisk/Command/Upgrade/Hash.hs+2−1 lib/command/src/Obelisk/Command/VmBuilder.hs+4−4 lib/executable-config/inject/obelisk-executable-config-inject.cabal+2−3 lib/executable-config/inject/src/Obelisk/ExecutableConfig/Inject.hs+8−7 lib/frontend/obelisk-frontend.cabal+19 lib/frontend/src/Obelisk/Frontend.hs+150 lib/https/default.nix−7 lib/https/github.json−6 lib/route/obelisk-route.cabal+30 lib/route/src/Obelisk/Route.hs+634 lib/route/src/Obelisk/Route/Frontend.hs+191 lib/run/obelisk-run.cabal+12−1 lib/run/src/Obelisk/Run.hs+70−28 lib/selftest/obelisk-selftest.cabal+2−1 lib/selftest/src/Obelisk/SelfTest.hs+68−40 migration/07900e6c41ab9a78917f66fcf7e0d9b8-8c84f81d9b09a5e0f028f3c6341ffb36/obelisk-handoff migration/07900e6c41ab9a78917f66fcf7e0d9b8-8c84f81d9b09a5e0f028f3c6341ffb36/obelisk-upgrade+15 migration/554c459ff2cced3e1b11241ed403890d-3543ea607791bc2e19419d22918823bd/obelisk-handoff migration/554c459ff2cced3e1b11241ed403890d-3543ea607791bc2e19419d22918823bd/obelisk-upgrade migration/554c459ff2cced3e1b11241ed403890d-6a18939264224d6ce24c7426249edfea/obelisk-handoff migration/554c459ff2cced3e1b11241ed403890d-6a18939264224d6ce24c7426249edfea/obelisk-upgrade migration/6a18939264224d6ce24c7426249edfea-e66afc8befac4d76b5780a960f768c31/obelisk-handoff migration/6a18939264224d6ce24c7426249edfea-e66afc8befac4d76b5780a960f768c31/obelisk-upgrade+15 release.nix+5−3 skeleton/.obelisk/.gitignore+3
README.md+15−9
@@ -116,22 +116,28 @@ First create a new EC2 instance:
1161161. Launch a NixOS 17.09 EC2 instance (we recommend [this AMI](https://console.aws.amazon.com/ec2/v2/home?region=us-east-1#LaunchInstanceWizard:ami=ami-40bee63a))
1171171. In the instance configuration wizard ensure that your instance has at least 1GB RAM and 10GB disk space.
1181181. When prompted save your AWS private key (`~/myaws.pem`) somewhere safe. We'll need it later during deployment.
119−1. Go to "Security Groups", select your instance's security group and under "Inbound" tab add a new rule for HTTP port 80.
119+1. Go to "Security Groups", select your instance's security group and under "Inbound" tab add a new rule for HTTP port 80 and 443.
120120
121−At this stage your instance should be booting and become accessible shortly. Note down the hostname of your instance. It should look like this:
122−
123−```
124−INSTANCE_HOSTNAME=ec2-??-??-??-??.ca-central-1.compute.amazonaws.com
125−```
121+At this stage your instance should be booting and become accessible shortly. Note down the hostname of your EC2 instance.
126122
127123Now go to your Obelisk project directory (`~/code/myapp`), and initialize a deployment config (`~/code/myapp-deploy`):
128124Your project directory must be "thunkable", i.e. something on which `ob thunk pack` can be called. Usually it will be a git repository whose current revision has been pushed upstream.
129125
130126```
131127cd ~/code/myapp
132−ob deploy init --ssh-key ~/myaws.pem --hostname ${INSTANCE_HOSTNAME} ~/code/myapp-deploy
128+SERVER=ec2-35-183-22-197.ca-central-1.compute.amazonaws.com
129+ROUTE=https://myapp.com # Publicly accessible route to your app
130+EMAIL=myname@myapp.com
131+ob deploy init \
132+ --ssh-key ~/myaws.pem \
133+ --hostname $SERVER \
134+ --route $ROUTE \
135+ --admin-email $EMAIL \
136+ ~/code/myapp-deploy
133137```
134138
139+NOTE: HTTPS is enabled by default; to disable https, pass `--disable-https` to the `ob deploy init` command above.
140+
135141Then go to that created deployment configuration directory, and initiate the deployment:
136142
137143```
@@ -139,9 +145,9 @@ cd ~/code/myapp-deploy
139145ob deploy push
140146```
141147
142−`ob deploy push` will locally build your app and then transfer it, along with all the Nix package dependencies, via ssh to the EC2 instance. It will also configure Nginx so that the public port 80 proxies to the running app.
148+`ob deploy push` will locally build your app and then transfer it, along with all the Nix package dependencies, via ssh to the EC2 instance.
143149
144−At this point you are done. Your app will be accessible at `http://${HOSTNAME}`!
150+At this point you are done. Your app will be accessible at `${ROUTE}`.
145151
146152### Deploying an updated version
147153
default.nix+48−27
@@ -5,8 +5,8 @@
55let
66 getReflexPlatform = sys: import ./dep/reflex-platform { inherit iosSdkVersion; system = sys; };
77 reflex-platform = getReflexPlatform system;
8− inherit (reflex-platform) hackGet;
9− pkgs = reflex-platform.nixpkgs;
8+ inherit (reflex-platform) hackGet nixpkgs;
9+ pkgs = nixpkgs;
1010in with pkgs.haskell.lib; with pkgs.lib;
1111let
1212 # TODO: Remove this after updating nixpkgs: https://github.com/NixOS/nixpkgs/issues/37750
@@ -85,13 +85,24 @@ let
8585 obelisk-command = (self.callCabal2nix "obelisk-command" (cleanSource ./lib/command) {});
8686 obelisk-executable-config = executableConfig.haskellPackage self;
8787 obelisk-executable-config-inject = executableConfig.platforms.web.inject self; # TODO handle platforms.{ios,android}
88+ obelisk-frontend = self.callCabal2nix "obelisk-frontend" (cleanSource ./lib/frontend) {};
8889 obelisk-migration = self.callCabal2nix "obelisk-migration" (cleanSource ./lib/migration) {};
8990 obelisk-run = self.callCabal2nix "obelisk-run" (cleanSource ./lib/run) {};
91+ obelisk-route = self.callCabal2nix "obelisk-route" (cleanSource ./lib/route) {};
9092 obelisk-selftest = self.callCabal2nix "obelisk-selftest" (cleanSource ./lib/selftest) {};
9193 obelisk-snap = self.callCabal2nix "obelisk-snap" (cleanSource ./lib/snap) {};
9294 obelisk-snap-extras = self.callCabal2nix "obelisk-snap-extras" (cleanSource ./lib/snap-extras) {};
95+
96+ dependent-sum-template = self.callCabal2nix "dependent-sum-template" (pkgs.fetchFromGitHub {
97+ owner = "mokus0";
98+ repo = "dependent-sum-template";
99+ rev = "877aea7817f8b9a7ca90374692402cc505bbab25";
100+ sha256 = "0x065vdvjnbn2fiw27kk1zbjgknfnbli9bjvn4pmdbdf8m9picpg";
101+ }) {};
93102 };
94103
104+ inherit (import ./lib/asset/assets.nix { inherit nixpkgs; }) mkAssets;
105+
95106 defaultHaskellOverrides = composeExtensions fixUpstreamPkgs addLibs;
96107in
97108with pkgs.lib;
@@ -145,7 +156,7 @@ rec {
145156 mkdir $out
146157 cd $out
147158 ln -s "${justStaticExecutables frontend}/bin/frontend.jsexe/all.js" all.unminified.js
148− closure-compiler --externs "${reflex-platform.ghcjsExternsJs}" -O ADVANCED --create_source_map="all.js.map" --source_map_format=V3 --js_output_file="all.js" all.unminified.js
159+ closure-compiler --externs "${reflex-platform.ghcjsExternsJs}" -O ADVANCED --jscomp_warning=checkVars --create_source_map="all.js.map" --source_map_format=V3 --js_output_file="all.js" all.unminified.js
149160 echo "//# sourceMappingURL=all.js.map" >> all.js
150161 '';
151162
@@ -154,30 +165,38 @@ rec {
154165 mkdir $out
155166 set -eux
156167 ln -s "${justStaticExecutables backend}"/bin/backend $out/backend
157− ln -s "${assets}" $out/static
168+ ln -s "${mkAssets assets}" $out/static.assets
158169 ln -s "${config}" $out/config
159− ln -s ${compressedJs frontend} $out/frontend.jsexe
160− ''; #TODO: run frontend.jsexe through the asset processing pipeline
170+ ln -s ${mkAssets (compressedJs frontend)} $out/frontend.jsexe.assets
171+ '';
161172
162− server = exe: hostName:
173+ server = { exe, hostName, adminEmail, routeHost, enableHttps }:
163174 let system = "x86_64-linux";
164175 nixos = import (pkgs.path + /nixos);
165− https = (import lib/https {}).module {
166− backendPort = 8000; # TODO read from config
167− # sslConfig = {
168− # hostName = "example.com";
169− # adminEmail = "webmaster@example.com";
170− # subdomains = [ "www" ];
171− # };
172− };
176+ backendPort = 8000;
173177 in nixos {
174178 inherit system;
175179 configuration = args: {
176180 imports = [
177181 (pkgs.path + /nixos/modules/virtualisation/amazon-image.nix)
178− https
179182 ];
180− networking = { inherit hostName; };
183+ networking = {
184+ inherit hostName;
185+ firewall.allowedTCPPorts = if enableHttps then [ 80 443 ] else [ 80 ];
186+ };
187+ services.nginx = {
188+ enable = true;
189+ virtualHosts."${routeHost}" = {
190+ enableACME = enableHttps;
191+ forceSSL = enableHttps;
192+ locations."/" = {
193+ proxyPass = "http://127.0.0.1:" + toString backendPort;
194+ };
195+ };
196+ };
197+ security.acme.certs = if enableHttps then {
198+ "${routeHost}".email = adminEmail;
199+ } else { };
181200 systemd.services.backend = {
182201 wantedBy = [ "multi-user.target" ];
183202 after = [ "network.target" ];
@@ -209,8 +228,9 @@ rec {
209228
210229 # An Obelisk project is a reflex-platform project with a predefined layout and role for each component
211230 project = base: projectDefinition:
212− let assets = processAssets { src = base + "/static"; };
213− configPath = base + "/config";
231+ let configPath = base + "/config";
232+ static = base + "/static";
233+ processedStatic = processAssets { src = static; };
214234 projectOut = sys: (getReflexPlatform sys).project (args@{ nixpkgs, ... }:
215235 let mkProject = { android ? null #TODO: Better error when missing
216236 , ios ? null #TODO: Better error when missing
@@ -222,7 +242,6 @@ rec {
222242 commonName = "common";
223243 staticName = "static";
224244 staticPath = base + "/static";
225− assets = processAssets { src = base + "/static"; };
226245 # The packages whose names and roles are defined by this package
227246 predefinedPackages = filterAttrs (_: x: x != null) {
228247 ${frontendName} = nullIfAbsent (base + "/frontend");
@@ -231,7 +250,7 @@ rec {
231250 };
232251 combinedPackages = predefinedPackages // packages;
233252 projectOverrides = self: super: {
234− ${staticName} = dontHaddock (self.callCabal2nix "static" assets.haskellManifest {});
253+ ${staticName} = dontHaddock (self.callCabal2nix "static" processedStatic.haskellManifest {});
235254 ${backendName} = addBuildDepend super.${backendName} self.obelisk-run;
236255 };
237256 totalOverrides = composeExtensions (composeExtensions defaultHaskellOverrides projectOverrides) overrides;
@@ -253,25 +272,27 @@ rec {
253272 android = {
254273 ${if android == null then null else frontendName} = {
255274 executableName = "frontend";
256− ${if builtins.pathExists staticPath then "assets" else null} = assets.symlinked;
275+ ${if builtins.pathExists staticPath then "assets" else null} = processedStatic.symlinked;
257276 } // android;
258277 };
259278 ios = {
260279 ${if ios == null then null else frontendName} = {
261280 executableName = "frontend";
262− ${if builtins.pathExists staticPath then "staticSrc" else null} = assets.symlinked;
281+ ${if builtins.pathExists staticPath then "staticSrc" else null} = processedStatic.symlinked;
263282 } // ios;
264283 };
265284 };
266285 in mkProject (projectDefinition args));
267− serverOn = sys:
268− serverExe (projectOut sys).ghc.backend (projectOut system).ghcjs.frontend assets.symlinked configPath;
286+ serverOn = sys: serverExe (projectOut sys).ghc.backend (projectOut system).ghcjs.frontend static configPath;
269287 linuxExe = serverOn "x86_64-linux";
270288 in projectOut system // {
271289 inherit linuxExe;
290+ # `exe` is project's backend executable, with frontend assets, config, etc.
291+ # `linuxExe` is the same but built for x86_64-linux.
272292 exe = serverOn system;
273− server = { hostName }: server linuxExe hostName;
274− obelisk = import (base + /.obelisk/impl) {};
293+ server = args@{ hostName, adminEmail, routeHost, enableHttps }:
294+ server (args // { exe = linuxExe;});
295+ obelisk = import (base + "/.obelisk/impl") {};
275296 };
276297 haskellPackageSets = {
277298 ghc = reflex-platform.ghc.override {
lib/asset/manifest/src/Obelisk/Asset/Promoted.hs+8−2
@@ -62,11 +62,17 @@ staticModuleFile moduleName paths = do
6262 , "{-# LANGUAGE FlexibleInstances #-}"
6363 , "{-# LANGUAGE KindSignatures #-}"
6464 , "{-# LANGUAGE OverloadedStrings #-}"
65+ , "{-# LANGUAGE ScopedTypeVariables #-}"
66+ , "{-# LANGUAGE TypeApplications #-}"
6567 , "module " <> moduleName <> " where"
6668 , ""
6769 , "import qualified GHC.Types"
68− , "import Data.Text ()"
70+ , "import Data.Text (Text)"
6971 , "import qualified Data.Text.Internal"
72+ , "import Data.Monoid ((<>))"
73+ , ""
74+ , "static :: forall a. StaticFile a => Text"
75+ , "static = \"static/\" <> hashedPath @a" --TODO: Use obelisk-route to generate this in a more consistent way
7076 , ""
7177 , T.pack $ pprint decs
7278 ]
@@ -95,7 +101,7 @@ staticClass :: WriterT (Seq Dec) Q StaticContext
95101staticClass = do
96102 let n x = Name (OccName x) NameS
97103 className = n "StaticFile"
98− methodName = n "static"
104+ methodName = n "hashedPath"
99105 cls = ClassD [] className [KindedTV (n "s") (ConT ''Symbol)] [] [SigD methodName (ConT ''Text)]
100106 tell $ Seq.singleton cls
101107 return $ StaticContext
lib/backend/obelisk-backend.cabal+9−1
@@ -8,10 +8,18 @@ library
88 build-depends: base,
99 bytestring,
1010 data-default,
11+ dependent-sum,
12+ dependent-sum-template,
1113 lens,
14+ mtl,
1215 obelisk-asset-serve-snap,
16+ obelisk-frontend,
17+ obelisk-route,
1318 obelisk-snap,
1419 reflex-dom,
1520 snap,
16− snap-server
21+ snap-server,
22+ text,
23+ universe
1724 exposed-modules: Obelisk.Backend
25+ ghc-options: -Wall
lib/backend/src/Obelisk/Backend.hs+150−22
@@ -1,49 +1,177 @@
1+{-# LANGUAGE FlexibleContexts #-}
2+{-# LANGUAGE FlexibleInstances #-}
3+{-# LANGUAGE GADTs #-}
4+{-# LANGUAGE KindSignatures #-}
5+{-# LANGUAGE LambdaCase #-}
6+{-# LANGUAGE MultiParamTypeClasses #-}
17{-# LANGUAGE OverloadedStrings #-}
8+{-# LANGUAGE TemplateHaskell #-}
29module Obelisk.Backend
3− ( backend
4− , BackendConfig (..)
10+ ( Backend (..)
511 -- * Re-exports
612 , Default (def)
13+ , getPageName
14+ , getRouteWith
15+ , runSnapWithCommandLineArgs
16+ , serveDefaultObeliskApp
17+ , prettifyOutput
18+ , runBackend
719 ) where
820
9−import Control.Lens
21+import Control.Monad.Except
1022import qualified Data.ByteString.Char8 as BSC8
1123import Data.Default (Default (..))
12−import Obelisk.Asset.Serve.Snap (serveAssets)
13−import Obelisk.Snap
24+import Data.Dependent.Sum
25+import Data.Functor.Sum
26+import Data.Functor.Identity
27+import Data.Text (Text)
28+import qualified Data.Text as T
29+import Data.Text.Encoding
30+import Obelisk.Asset.Serve.Snap (serveAsset)
31+import Obelisk.Frontend
32+import Obelisk.Route
33+import Obelisk.Route.Frontend
1434import Reflex.Dom
15−import System.IO (hSetBuffering, stderr, BufferMode (..))
16−import Snap (httpServe, defaultConfig, commandLineConfig, route)
35+import Snap (MonadSnap, Snap, commandLineConfig, defaultConfig, getsRequest, httpServe, rqPathInfo,
36+ rqQueryString, writeBS, writeText)
1737import Snap.Internal.Http.Server.Config (Config (accessLog, errorLog), ConfigLog (ConfigIoLog))
38+import System.IO (BufferMode (..), hSetBuffering, stderr, stdout)
1839
19−--TODO: Add a link to a large explanation of the idea of using 'def'
20−-- | Configure the operation of the Obelisk backend. For reasonable defaults,
21−-- use 'def'.
22−data BackendConfig = BackendConfig
23− { _backendConfig_head :: StaticWidget () ()
40+data Backend backendRoute frontendRoute = Backend
41+ { _backend_routeEncoder :: Encoder (Either Text) (Either Text) (R (Sum backendRoute (ObeliskRoute frontendRoute))) PageName
42+ , _backend_run :: ((R backendRoute -> Snap ()) -> IO ()) -> IO ()
2443 }
2544
26−instance Default BackendConfig where
27− def = BackendConfig (return ())
45+-- | The static assets provided must contain a compiled GHCJS app that corresponds exactly to the Frontend provided
46+data GhcjsApp route = GhcjsApp
47+ { _ghcjsApp_compiled :: !StaticAssets
48+ , _ghcjsApp_value :: !(Frontend route)
49+ }
2850
51+{-
52+--TODO: Expose the encoder check phase
2953-- | Start an Obelisk backend
30−backend :: BackendConfig -> IO ()
54+backend
55+ :: ( Universe (R route) --TODO: This seems wrong - should be Universe (Some route)
56+ , OrdTag route Identity
57+ , ShowTag route Identity
58+ , check ~ Either Text --TODO: Replace with MonadError Text check
59+ )
60+ => BackendConfig route
61+ -> check (IO ())
3162backend cfg = do
63+ getRequestRoute <- checkGetRequestRoute $ _backendConfig_routeEncoder cfg --TODO: Report error better
64+ return $ do
65+ prettifyOutput
66+
67+ runSnapWithCommandLineArgs $ do
68+ getRequestRoute >>= \case
69+ Left e -> writeText e
70+ Right r -> serveDefaultObeliskApp (_backendConfig_frontend cfg) r
71+-}
72+
73+-- | Serve a frontend, which must be the same frontend that Obelisk has built and placed in the default location
74+--TODO: The frontend should be provided together with the asset paths so that this isn't so easily breakable; that will probably make this function obsolete
75+serveDefaultObeliskApp :: MonadSnap m => Frontend (R appRoute) -> R (ObeliskRoute appRoute) -> m ()
76+serveDefaultObeliskApp frontend = serveObeliskApp defaultStaticAssets frontendApp
77+ where frontendApp = GhcjsApp
78+ { _ghcjsApp_compiled = defaultFrontendGhcjsAssets
79+ , _ghcjsApp_value = frontend
80+ }
81+
82+prettifyOutput :: IO ()
83+prettifyOutput = do
3284 -- Make output more legible by decreasing the likelihood of output from
3385 -- multiple threads being interleaved
86+ hSetBuffering stdout LineBuffering
3487 hSetBuffering stderr LineBuffering
3588
89+defaultStaticAssets :: StaticAssets
90+defaultStaticAssets = StaticAssets
91+ { _staticAssets_processed = "static.assets"
92+ , _staticAssets_unprocessed = "static"
93+ }
94+
95+defaultFrontendGhcjsAssets :: StaticAssets
96+defaultFrontendGhcjsAssets = StaticAssets
97+ { _staticAssets_processed = "frontend.jsexe.assets"
98+ , _staticAssets_unprocessed = "frontend.jsexe"
99+ }
100+
101+runSnapWithCommandLineArgs :: Snap () -> IO ()
102+runSnapWithCommandLineArgs a = do
36103 -- Get the web server configuration from the command line
37104 cmdLineConf <- commandLineConfig defaultConfig
38− headHtml <- fmap snd $ renderStatic $ _backendConfig_head cfg
39105 let httpConf = cmdLineConf
40106 { accessLog = Just $ ConfigIoLog BSC8.putStrLn
41107 , errorLog = Just $ ConfigIoLog BSC8.putStrLn
42108 }
43− appCfg = def & appConfig_initialHead .~ headHtml
44109 -- Start the web server
45− httpServe httpConf $ route
46− [ ("", serveApp "" appCfg)
47− , ("", serveAssets "frontend.jsexe.assets" "frontend.jsexe") --TODO: Can we prevent naming conflicts between frontend.jsexe and static?
48− , ("", serveAssets "static.assets" "static")
49− ]
110+ httpServe httpConf a
111+
112+getPageName :: (MonadSnap m, MonadError Text parse) => m (parse PageName)
113+getPageName = do
114+ p <- getsRequest rqPathInfo
115+ q <- getsRequest rqQueryString
116+ return $ _validEncoder_decode pageNameValidEncoder
117+ ( "/" <> T.unpack (decodeUtf8 p)
118+ , "?" <> T.unpack (decodeUtf8 q)
119+ )
120+
121+getRouteWith :: (MonadSnap m, MonadError Text parse) => ValidEncoder parse route PageName -> m (parse route)
122+getRouteWith e = do
123+ pageName <- getPageName
124+ return $ pageName >>= _validEncoder_decode e
125+
126+serveObeliskApp :: MonadSnap m => StaticAssets -> GhcjsApp (R appRoute) -> R (ObeliskRoute appRoute) -> m ()
127+serveObeliskApp staticAssets frontendApp = \case
128+ ObeliskRoute_App appRouteComponent :=> Identity appRouteRest -> serveGhcjsApp frontendApp $ GhcjsAppRoute_App appRouteComponent :/ appRouteRest
129+ ObeliskRoute_Resource resComponent :=> Identity resRest -> case resComponent :=> Identity resRest of
130+ ResourceRoute_Static :=> Identity pathSegments -> serveStaticAssets staticAssets pathSegments
131+ ResourceRoute_Ghcjs :=> Identity pathSegments -> serveGhcjsApp frontendApp $ GhcjsAppRoute_Resource :/ pathSegments
132+ ResourceRoute_JSaddleWarp :=> Identity _ -> do
133+ let msg = "Error: Obelisk.Backend received jsaddle request"
134+ liftIO $ putStrLn $ T.unpack msg
135+ writeText msg
136+
137+serveStaticAssets :: MonadSnap m => StaticAssets -> [Text] -> m ()
138+serveStaticAssets assets pathSegments = serveAsset (_staticAssets_processed assets) (_staticAssets_unprocessed assets) $ T.unpack $ T.intercalate "/" pathSegments
139+
140+data StaticAssets = StaticAssets
141+ { _staticAssets_processed :: !FilePath
142+ , _staticAssets_unprocessed :: !FilePath
143+ }
144+ deriving (Show, Read, Eq, Ord)
145+
146+data GhcjsAppRoute :: (* -> *) -> * -> * where
147+ GhcjsAppRoute_App :: appRouteComponent a -> GhcjsAppRoute appRouteComponent a
148+ GhcjsAppRoute_Resource :: GhcjsAppRoute appRouteComponent [Text]
149+
150+--TODO: Don't assume we're being served at "/"
151+serveGhcjsApp :: MonadSnap m => GhcjsApp (R appRouteComponent) -> R (GhcjsAppRoute appRouteComponent) -> m ()
152+serveGhcjsApp app = \case
153+ GhcjsAppRoute_App appRouteComponent :=> Identity appRouteRest -> do
154+ indexHtml <- liftIO $ fmap snd $ renderStatic $ fmap fst $ runEventWriterT $ flip runRoutedT (pure $ appRouteComponent :/ appRouteRest) $ blankLoader $ _frontend_head $ _ghcjsApp_value app
155+ --TODO: We should probably have a "NullEventWriterT" or a frozen reflex timeline
156+ writeBS $ "<!DOCTYPE html>\n" <> indexHtml
157+ GhcjsAppRoute_Resource :=> Identity pathSegments -> serveStaticAssets (_ghcjsApp_compiled app) pathSegments
158+
159+blankLoader :: DomBuilder t m => m () -> m ()
160+blankLoader headHtml = el "html" $ do
161+ el "head" $ do
162+ elAttr "base" ("href" =: "/") blank --TODO: Figure out the base URL from the routes
163+ headHtml
164+ el "body" $ do
165+ --TODO: Hash the all.js path
166+ elAttr "script" ("language" =: "javascript" <> "src" =: "ghcjs/all.js" <> "defer" =: "defer") blank
167+
168+runBackend :: Backend fullRoute frontendRoute -> Frontend (R frontendRoute) -> IO ()
169+runBackend backend frontend = case checkEncoder $ _backend_routeEncoder backend of
170+ Left e -> fail $ "backend error:\n" <> T.unpack e
171+ Right validFullEncoder -> _backend_run backend $ \serveRoute -> do
172+ runSnapWithCommandLineArgs $ do
173+ getRouteWith validFullEncoder >>= \case
174+ Left e -> writeText e
175+ Right r -> case r of
176+ InL backendRoute :=> Identity a -> serveRoute $ backendRoute :/ a
177+ InR obeliskRoute :=> Identity a -> serveDefaultObeliskApp frontend $ obeliskRoute :/ a
lib/cliapp/src/Obelisk/CliApp.hs+1
@@ -30,6 +30,7 @@ module Obelisk.CliApp
3030 , Severity (..)
3131
3232 -- .Process
33+ , ProcessFailed (..)
3334 , readProcessAndLogStderr
3435 , callProcessAndLogOutput
3536 , createProcess_
lib/cliapp/src/Obelisk/CliApp/Logging.hs+6−5
@@ -26,7 +26,7 @@ import System.Console.ANSI (Color (Red, White, Yellow), ColorIntensity (Vivid),
2626 ConsoleIntensity (FaintIntensity), ConsoleLayer (Foreground),
2727 SGR (SetColor, SetConsoleIntensity), clearLine)
2828import System.Exit (ExitCode (..), exitWith)
29−import System.IO (BufferMode (NoBuffering), hFlush, hReady, hSetBuffering, stdin, stdout)
29+import System.IO (BufferMode (NoBuffering), hFlush, hReady, hSetBuffering, stderr, stdin, stdout)
3030
3131import qualified Obelisk.CliApp.TerminalString as TS
3232import Obelisk.CliApp.Types
@@ -137,12 +137,13 @@ withExitFailMessage msg f = f `catch` \(e :: ExitCode) -> do
137137writeLog:: (MonadIO m, MonadMask m) => Bool -> Bool -> WithSeverity Text -> m ()
138138writeLog withNewLine noColor (WithSeverity severity s)
139139 | noColor && severity <= Warning = liftIO $ putFn $ T.pack (show severity) <> ": " <> s
140− | not noColor && severity <= Error = TS.putStrWithSGR errorColors withNewLine s
141− | not noColor && severity <= Warning = TS.putStrWithSGR warningColors withNewLine s
142− | not noColor && severity >= Debug = TS.putStrWithSGR debugColors withNewLine s
140+ | not noColor && severity <= Error = TS.putStrWithSGR errorColors h withNewLine s
141+ | not noColor && severity <= Warning = TS.putStrWithSGR warningColors h withNewLine s
142+ | not noColor && severity >= Debug = TS.putStrWithSGR debugColors h withNewLine s
143143 | otherwise = liftIO $ putFn s
144144 where
145− putFn = if withNewLine then T.putStrLn else T.putStr
145+ putFn = if withNewLine then (T.hPutStrLn h) else (T.hPutStr h)
146+ h = if severity <= Error then stderr else stdout
146147 errorColors = [SetColor Foreground Vivid Red]
147148 warningColors = [SetColor Foreground Vivid Yellow]
148149 debugColors = [SetColor Foreground Vivid White, SetConsoleIntensity FaintIntensity]
lib/cliapp/src/Obelisk/CliApp/Process.hs+12−13
@@ -9,7 +9,8 @@
99-- | An extension of `System.Process` that integrates with logging (`Obelisk.CLI.Logging`)
1010-- and is thus spinner friendly.
1111module Obelisk.CliApp.Process
12− ( readProcessAndLogStderr
12+ ( ProcessFailed (..)
13+ , readProcessAndLogStderr
1314 , callProcessAndLogOutput
1415 , createProcess
1516 , createProcess_
@@ -19,9 +20,8 @@ module Obelisk.CliApp.Process
1920
2021import Control.Applicative (liftA2)
2122import Control.Monad (void, (<=<))
22−import Control.Monad.Catch (MonadMask)
23−import Control.Monad.IO.Class (liftIO)
24−import Control.Monad.Reader (MonadIO)
23+import Control.Monad.Catch (Exception, MonadMask, throwM)
24+import Control.Monad.IO.Class (MonadIO, liftIO)
2525import qualified Data.ByteString.Char8 as BSC
2626import Data.Function (fix)
2727import Data.Monoid ((<>))
@@ -38,10 +38,14 @@ import System.Process (CreateProcess, ProcessHandle, StdStream (CreatePipe), cmd
3838import qualified System.Process as Process
3939
4040import Control.Monad.Log (Severity (..))
41−import Obelisk.CliApp.Logging (failWith, putLog, putLogRaw)
41+import Obelisk.CliApp.Logging (putLog, putLogRaw)
4242import Obelisk.CliApp.Types (Cli)
4343
44−-- | Like `System.Process.readProcess` but logs the stderr instead of letting the external process inherit it.
44+data ProcessFailed = ProcessFailed Process.CmdSpec Int -- exit code
45+ deriving Show
46+
47+instance Exception ProcessFailed
48+
4549readProcessAndLogStderr
4650 :: (MonadIO m, MonadMask m, Cli m)
4751 => Severity -> CreateProcess -> m String
@@ -104,7 +108,6 @@ withProcess
104108 :: (MonadIO m, MonadMask m, Cli m)
105109 => CreateProcess -> (Handle -> Handle -> m ()) -> m (Handle, Handle)
106110withProcess process f = do -- TODO: Use bracket.
107− let procTitle = T.pack $ show $ cmdspec process
108111 -- FIXME: Using `withCreateProcess` here leads to something operating illegally on closed handles.
109112 (_, Just out, Just err, p) <- createProcess $ process
110113 { std_out = CreatePipe , std_err = CreatePipe }
@@ -112,12 +115,8 @@ withProcess process f = do -- TODO: Use bracket.
112115 f out err -- Pass the handles to the passed function
113116
114117 liftIO (waitForProcess p) >>= \case
115− ExitSuccess -> return ()
116− ExitFailure code -> do
117− -- Log an error. We also fail immediately; however we should probably let the caller control that.
118− failWith $ procTitle <> " failed with exit code: " <> T.pack (show code)
119− return (out, err) -- Return the handles
120−
118+ ExitSuccess -> return (out, err)
119+ ExitFailure code -> throwM $ ProcessFailed (cmdspec process) code
121120
122121-- Create an input stream from the file handle, associating each item with the given severity.
123122streamHandle :: Severity -> Handle -> IO (InputStream (Severity, BSC.ByteString))
lib/cliapp/src/Obelisk/CliApp/TerminalString.hs+6−6
@@ -18,9 +18,9 @@ import Data.Semigroup ((<>))
1818import Data.Text (Text)
1919import qualified Data.Text as T
2020import qualified Data.Text.IO as T
21−import System.Console.ANSI (Color, ColorIntensity (Vivid), ConsoleLayer (Foreground), SGR (Reset, SetColor),
22− SGR, setSGR, setSGRCode)
21+import System.Console.ANSI
2322import qualified System.Console.Terminal.Size as TerminalSize
23+import System.IO (Handle)
2424
2525-- | Printable text on terminals
2626--
@@ -65,11 +65,11 @@ colorizeText color s = mconcat
6565 ]
6666
6767-- | Safely print the string with the given ANSI control codes, resetting in the end.
68−putStrWithSGR :: MonadIO m => [SGR] -> Bool -> Text -> m ()
69−putStrWithSGR sgr withNewLine s = liftIO $ bracket_ (setSGR sgr) reset $ T.putStr s
68+putStrWithSGR :: MonadIO m => [SGR] -> Handle -> Bool -> Text -> m ()
69+putStrWithSGR sgr h withNewLine s = liftIO $ bracket_ (hSetSGR h sgr) reset $ T.hPutStr h s
7070 where
71− reset = setSGR [Reset] >> newline -- New line should come *after* reset (to reset cursor color).
72− newline = when withNewLine $ T.putStrLn ""
71+ reset = hSetSGR h [Reset] >> newline -- New line should come *after* reset (to reset cursor color).
72+ newline = when withNewLine $ T.hPutStrLn h ""
7373
7474-- | Code for https://en.wikipedia.org/wiki/Enquiry_character
7575enquiryCode :: String
lib/command/obelisk-command.cabal+2
@@ -27,6 +27,8 @@ library
2727 , io-streams
2828 , exceptions
2929 , logging-effect
30+ , lens
31+ , modern-uri
3032 , monad-loops
3133 , mtl
3234 , network
lib/command/src/Obelisk/Command.hs+15−3
@@ -8,6 +8,7 @@
88module Obelisk.Command where
99
1010import Control.Monad
11+import Control.Monad.Catch (catch)
1112import Control.Monad.IO.Class (liftIO)
1213import qualified Data.Binary as Binary
1314import Data.Bool (bool)
@@ -15,7 +16,6 @@ import qualified Data.ByteString.Base16 as Base16
1516import qualified Data.ByteString.Lazy as LBS
1617import Data.List
1718import Data.Maybe (catMaybes, listToMaybe)
18−import Data.Monoid
1919import Data.Text (Text)
2020import qualified Data.Text as T
2121import Data.Text.Encoding
@@ -37,7 +37,7 @@ import Obelisk.Command.Run
3737import Obelisk.Command.Thunk
3838import Obelisk.Command.Upgrade
3939import Obelisk.Command.Upgrade.Hash (getDirectoryHash, getHashAtGitRevision)
40−import Obelisk.Command.Utils (getObeliskExe)
40+import Obelisk.Command.Utils
4141import qualified Obelisk.Command.VmBuilder as VmBuilder
4242import Obelisk.Migration (Hash)
4343
@@ -174,6 +174,9 @@ deployInitOpts = DeployInitOpts
174174 <$> strArgument (action "directory" <> metavar "DEPLOYDIR" <> help "Path to a directory that it will create")
175175 <*> strOption (long "ssh-key" <> action "file" <> metavar "SSHKEY" <> help "Path to an ssh key that it will symlink to")
176176 <*> some (strOption (long "hostname" <> metavar "HOSTNAME" <> help "hostname of the deployment target"))
177+ <*> strOption (long "route" <> metavar "PUBLICROUTE" <> help "Publicly accessible URL of your app")
178+ <*> strOption (long "admin-email" <> metavar "ADMINEMAIL" <> help "Email address where administrative alerts will be sent")
179+ <*> flag True False (long "disable-https" <> help "Disable automatic https configuration for the backend")
177180 <*> strOption (long "upstream" <> value "origin" <> metavar "REMOTE" <> help "git remote to use for the src thunk" <> showDefault)
178181
179182type TeamID = String
@@ -194,6 +197,9 @@ data DeployInitOpts = DeployInitOpts
194197 { _deployInitOpts_outputDir :: FilePath
195198 , _deployInitOpts_sshKey :: FilePath
196199 , _deployInitOpts_hostname :: [String]
200+ , _deployInitOpts_route :: String
201+ , _deployInitOpts_adminEmail :: String
202+ , _deployInitOpts_enableHttps :: Bool
197203 , _deployInitOpts_remote :: String
198204 }
199205 deriving Show
@@ -291,6 +297,8 @@ main' argsCfg = do
291297 ]
292298
293299 (mainWithHandOff argsCfg <=< parseHandoff) =<< liftIO getArgs
300+ `catch`
301+ \(ProcessFailed p code) -> failWith $ "Process exited with code " <> tshow code <> "; " <> tshow p
294302
295303-- Type representing the result of a handoff calculation.
296304data HandOff m
@@ -373,7 +381,11 @@ ob = \case
373381 getThunkPtr' False root (T.pack $ _deployInitOpts_remote deployOpts)
374382 let sshKeyPath = _deployInitOpts_sshKey deployOpts
375383 hostname = _deployInitOpts_hostname deployOpts
376− deployInit thunkPtr (root </> "config") deployDir sshKeyPath hostname
384+ route = _deployInitOpts_route deployOpts
385+ adminEmail = _deployInitOpts_adminEmail deployOpts
386+ enableHttps = _deployInitOpts_enableHttps deployOpts
387+ deployInit thunkPtr (root </> "config") deployDir
388+ sshKeyPath hostname route adminEmail enableHttps
377389 DeployCommand_Push remoteBuilder -> deployPush "." $ case remoteBuilder of
378390 Nothing -> pure []
379391 Just RemoteBuilder_ObeliskVM -> (:[]) <$> VmBuilder.getNixBuildersArg
lib/command/src/Obelisk/Command/Deploy.hs+122−26
@@ -2,15 +2,17 @@
22{-# LANGUAGE LambdaCase #-}
33{-# LANGUAGE OverloadedStrings #-}
44{-# LANGUAGE ScopedTypeVariables #-}
5+{-# LANGUAGE ViewPatterns #-}
56module Obelisk.Command.Deploy where
67
8+import Control.Lens
79import Control.Monad
10+import Control.Monad.Catch (Exception (displayException), MonadThrow, throwM, try)
811import Control.Monad.IO.Class (liftIO)
912import Data.Bits
1013import Data.Default
1114import qualified Data.Map as Map
1215import Data.Maybe
13−import Data.Monoid
1416import qualified Data.Text as T
1517import qualified Data.Text.IO as T
1618import System.Directory
@@ -18,6 +20,9 @@ import System.Environment (getEnvironment)
1820import System.FilePath
1921import System.Posix.Files
2022import System.Process (delegate_ctlc, env, proc)
23+import Text.URI (URI)
24+import qualified Text.URI as URI
25+import Text.URI.Lens
2126
2227import Obelisk.App (MonadObelisk)
2328import Obelisk.CliApp (Severity (..), callProcessAndLogOutput, failWith, putLog, withSpinner)
@@ -26,30 +31,48 @@ import Obelisk.Command.Project
2631import Obelisk.Command.Thunk
2732import Obelisk.Command.Utils
2833
29−deployInit :: MonadObelisk m => ThunkPtr -> FilePath -> FilePath -> FilePath -> [String] -> m ()
30−deployInit thunkPtr configDir deployDir sshKeyPath hostnames = do
31− hasConfigDir <- liftIO $ do
32− createDirectoryIfMissing True deployDir
33− doesDirectoryExist configDir
34− localKey <- liftIO (doesFileExist sshKeyPath) >>= \case
35− False -> failWith $ T.pack $ "ob deploy init: file does not exist: " <> sshKeyPath
36− True -> pure $ deployDir </> "ssh_key"
37− callProcessAndLogOutput (Notice, Error) $ proc "cp" [sshKeyPath, localKey]
38− liftIO $ setFileMode localKey $ ownerReadMode .|. ownerWriteMode
39− liftIO $ writeFile (deployDir </> "backend_hosts") $ unlines hostnames
34+deployInit
35+ :: MonadObelisk m
36+ => ThunkPtr
37+ -> FilePath
38+ -> FilePath
39+ -> FilePath
40+ -> [String] -- ^ hostnames
41+ -> String -- ^ route
42+ -> String -- ^ admin email
43+ -> Bool -- ^ enable https
44+ -> m ()
45+deployInit thunkPtr configDir deployDir sshKeyPath hostnames route adminEmail enableHttps = do
46+ (hasConfigDir, localKey) <- withSpinner ("Preparing " <> T.pack deployDir) $ do
47+ hasConfigDir <- liftIO $ do
48+ createDirectoryIfMissing True deployDir
49+ doesDirectoryExist configDir
50+ localKey <- liftIO (doesFileExist sshKeyPath) >>= \case
51+ False -> failWith $ T.pack $ "ob deploy init: file does not exist: " <> sshKeyPath
52+ True -> pure $ deployDir </> "ssh_key"
53+ callProcessAndLogOutput (Notice, Error) $
54+ proc "cp" [sshKeyPath, localKey]
55+ liftIO $ setFileMode localKey $ ownerReadMode .|. ownerWriteMode
56+ return $ (hasConfigDir, localKey)
57+ withSpinner "Validating configuration" $ do
58+ void $ getHostFromRoute enableHttps route -- make sure that hostname is present
4059 forM_ hostnames $ \hostname -> do
4160 putLog Notice $ "Verifying host keys (" <> T.pack hostname <> ")"
61+ -- Note: we can't use a spinner here as this function will prompt the user.
4262 verifyHostKey (deployDir </> "backend_known_hosts") localKey hostname
43− when hasConfigDir $ do
44− callProcessAndLogOutput (Notice, Error) $ proc "cp"
45− [ "-r"
46− , "-T"
47− , configDir
48− , deployDir </> "config"
49− ]
50− liftIO $ createThunk (deployDir </> "src") thunkPtr
51− liftIO $ setupObeliskImpl deployDir
52− initGit deployDir
63+ when hasConfigDir $ withSpinner "Importing project configuration" $ do
64+ callProcessAndLogOutput (Notice, Error) $
65+ proc "cp" [ "-r" , "-T" , configDir , deployDir </> "config"]
66+ withSpinner "Writing deployment configuration" $ do
67+ writeDeployConfig deployDir "backend_hosts" $ unlines hostnames
68+ writeDeployConfig deployDir "enable_https" $ show enableHttps
69+ writeDeployConfig deployDir "admin_email" adminEmail
70+ writeDeployConfig deployDir ("config" </> "common" </> "route") $ route
71+ withSpinner "Creating source thunk (./src)" $ liftIO $ do
72+ createThunk (deployDir </> "src") thunkPtr
73+ setupObeliskImpl deployDir
74+ withSpinner ("Initializing git repository (" <> T.pack deployDir <> ")") $
75+ initGit deployDir
5376
5477setupObeliskImpl :: FilePath -> IO ()
5578setupObeliskImpl deployDir = do
@@ -59,18 +82,26 @@ setupObeliskImpl deployDir = do
5982
6083deployPush :: MonadObelisk m => FilePath -> m [String] -> m ()
6184deployPush deployPath getNixBuilders = do
62− let backendHosts = deployPath </> "backend_hosts"
63− host <- liftIO $ fmap (T.unpack . T.strip) $ T.readFile backendHosts
85+ host <- readDeployConfig deployPath "backend_hosts"
86+ adminEmail <- readDeployConfig deployPath "admin_email"
87+ enableHttps <- read <$> readDeployConfig deployPath "enable_https"
88+ route <- readDeployConfig deployPath $ "config" </> "common" </> "route"
89+ routeHost <- getHostFromRoute enableHttps route
6490 let srcPath = deployPath </> "src"
6591 build = do
6692 builders <- getNixBuilders
6793 buildOutput <- nixBuild $ def
6894 { _nixBuildConfig_target = Target
6995 { _target_path = srcPath
70− , _target_attr = Just "server"
96+ , _target_attr = Just "server.system"
7197 }
7298 , _nixBuildConfig_outLink = OutLink_None
73− , _nixBuildConfig_args = pure $ Arg "hostName" host
99+ , _nixBuildConfig_args =
100+ [ strArg "hostName" host
101+ , strArg "adminEmail" adminEmail
102+ , strArg "routeHost" routeHost
103+ , boolArg "enableHttps" enableHttps
104+ ]
74105 , _nixBuildConfig_builders = builders
75106 }
76107 return $ listToMaybe $ lines buildOutput
@@ -114,6 +145,7 @@ deployPush deployPath getNixBuilders = do
114145 ["-C", deployPath, "add", "."]
115146 callProcessAndLogOutput (Debug, Error) $ proc "git"
116147 ["-C", deployPath, "commit", "-m", "New deployment"]
148+ putLog Notice $ "Deployed => " <> T.pack route
117149 where
118150 callProcess' envMap cmd args = do
119151 processEnv <- Map.toList . (envMap <>) . Map.fromList <$> liftIO getEnvironment
@@ -131,6 +163,14 @@ deployMobile platform mobileArgs = withProjectRoot "." $ \root -> do
131163 result <- nixBuildAttrWithCache srcDir $ platform <> ".frontend"
132164 callProcessAndLogOutput (Notice, Error) $ proc (result </> "bin" </> "deploy") mobileArgs
133165
166+-- | Simplified deployment configuration mechanism. At one point we may revisit this.
167+writeDeployConfig :: MonadObelisk m => FilePath -> FilePath -> String -> m ()
168+writeDeployConfig deployDir fname = liftIO . writeFile (deployDir </> fname)
169+
170+readDeployConfig :: MonadObelisk m => FilePath -> FilePath -> m String
171+readDeployConfig deployDir fname = liftIO $ do
172+ fmap (T.unpack . T.strip) $ T.readFile $ deployDir </> fname
173+
134174verifyHostKey :: MonadObelisk m => FilePath -> FilePath -> String -> m ()
135175verifyHostKey knownHostsPath keyPath hostName =
136176 callProcessAndLogOutput (Notice, Warning) $ proc "ssh" $
@@ -145,3 +185,59 @@ sshArgs knownHostsPath keyPath askHostKeyCheck =
145185 , "-o", "StrictHostKeyChecking=" <> if askHostKeyCheck then "ask" else "yes"
146186 , "-i", keyPath
147187 ]
188+
189+-- common/route validation
190+-- TODO: move these to executable-config once the typed-config stuff is done.
191+
192+data InvalidRoute
193+ = InvalidRoute_NotHttps URI
194+ | InvalidRoute_MissingScheme URI
195+ | InvalidRoute_MissingHost URI
196+ | InvalidRoute_HasPort URI
197+ | InvalidRoute_HasPath URI
198+ deriving Show
199+
200+instance Exception InvalidRoute where
201+ displayException = \case
202+ InvalidRoute_MissingScheme uri -> route uri "must have an URI scheme"
203+ InvalidRoute_NotHttps uri -> route uri "must be HTTPS"
204+ InvalidRoute_MissingHost uri -> route uri "must contain a hostname"
205+ InvalidRoute_HasPort uri -> route uri "cannot specify port"
206+ InvalidRoute_HasPath uri -> route uri "cannot contain path"
207+ where
208+ route uri err = T.unpack $ "Route (" <> URI.render uri <> ") " <> err
209+
210+-- | Get the hostname from a https route
211+--
212+-- Fail if the route is invalid (i.e, no host present or scheme is not https)
213+getHostFromRoute
214+ :: MonadObelisk m
215+ => Bool -- ^ Ensure https?
216+ -> String
217+ -> m String
218+getHostFromRoute mustBeHttps route = do
219+ result :: Either InvalidRoute String <- try $ do
220+ validateCommonRouteAndGetHost mustBeHttps =<< URI.mkURI (T.strip $ T.pack route)
221+ either (failWith . T.pack . displayException) pure result
222+
223+validateCommonRouteAndGetHost
224+ :: (MonadThrow m, MonadObelisk m)
225+ => Bool -- ^ Ensure https?
226+ -> URI
227+ -> m String
228+validateCommonRouteAndGetHost mustBeHttps uri = do
229+ case uri ^? uriScheme of
230+ Just (Just (URI.unRText -> s)) -> case (mustBeHttps, s) of
231+ (False, _) -> pure ()
232+ (True, "https") -> pure ()
233+ _ -> throwM $ InvalidRoute_NotHttps uri
234+ _ -> throwM $ InvalidRoute_MissingScheme uri
235+ case uri ^. uriPath of
236+ [] -> pure ()
237+ _path -> throwM $ InvalidRoute_HasPath uri
238+ case uri ^? uriAuthority . _Right . authPort of
239+ Just (Just _port) -> throwM $ InvalidRoute_HasPort uri
240+ _ -> pure ()
241+ case uri ^? uriAuthority . _Right . authHost of
242+ Nothing -> throwM $ InvalidRoute_MissingHost uri
243+ Just sslHost -> return $ T.unpack $ URI.unRText sslHost
lib/command/src/Obelisk/Command/Nix.hs+47−6
@@ -1,19 +1,28 @@
11{-# LANGUAGE FlexibleContexts #-}
2+{-# LANGUAGE LambdaCase #-}
23{-# LANGUAGE MultiParamTypeClasses #-}
34{-# LANGUAGE OverloadedStrings #-}
45{-# LANGUAGE ScopedTypeVariables #-}
56module Obelisk.Command.Nix
67 ( nixBuild
8+ , withNixRemoteCheck
79 , NixBuildConfig (..)
810 , Target (..)
911 , OutLink (..)
1012 , Arg (..)
13+ , boolArg
14+ , strArg
1115 ) where
1216
17+import Control.Monad.Catch (catch, throwM)
18+import Control.Monad.IO.Class (liftIO)
19+import Data.Bool (bool)
1320import Data.Default
1421import Data.List (intercalate)
1522import Data.Monoid ((<>))
1623import qualified Data.Text as T
24+import System.Directory
25+import System.Environment
1726import System.Process (proc)
1827
1928import Obelisk.App (MonadObelisk)
@@ -40,10 +49,21 @@ instance Default Target where
4049 , _target_attr = Nothing
4150 }
4251
43−data Arg = Arg
44− { _arg_key :: String
45− , _arg_value :: String
46− }
52+data Arg
53+ = Arg_Str String String
54+ | Arg_Expr String String
55+ deriving (Eq, Show)
56+
57+strArg :: String -> String -> Arg
58+strArg k = Arg_Str k
59+
60+boolArg :: String -> Bool -> Arg
61+boolArg k = Arg_Expr k . bool "false" "true"
62+
63+cliFromArgs :: [Arg] -> [String]
64+cliFromArgs = concatMap $ \case
65+ Arg_Str k v -> ["--argstr", k, v]
66+ Arg_Expr k v -> ["--arg", k, v]
4767
4868data NixBuildConfig = NixBuildConfig
4969 { _nixBuildConfig_target :: Target
@@ -57,7 +77,7 @@ instance Default NixBuildConfig where
5777
5878nixBuild :: MonadObelisk m => NixBuildConfig -> m FilePath
5979nixBuild cfg = withSpinner' ("Running nix-build on " <> desc) (Just $ const $ "Built " <> desc) $ do
60− readProcessAndLogStderr Debug $ proc "nix-build" $ mconcat
80+ withNixRemoteCheck $ readProcessAndLogStderr Debug $ proc "nix-build" $ mconcat
6181 [[path], attrArg, args, outLink, buildersArg]
6282 where
6383 path = _target_path $ _nixBuildConfig_target cfg
@@ -65,7 +85,7 @@ nixBuild cfg = withSpinner' ("Running nix-build on " <> desc) (Just $ const $ "B
6585 attrArg = case attr of
6686 Nothing -> []
6787 Just a -> ["-A", a]
68− args = mconcat [["--argstr", k, v] | Arg k v <- _nixBuildConfig_args cfg]
88+ args = cliFromArgs $ _nixBuildConfig_args cfg
6989 outLink = case _nixBuildConfig_outLink cfg of
7090 OutLink_Default -> []
7191 OutLink_None -> ["--no-out-link"]
@@ -74,3 +94,24 @@ nixBuild cfg = withSpinner' ("Running nix-build on " <> desc) (Just $ const $ "B
7494 buildersArg = case _nixBuildConfig_builders cfg of
7595 [] -> []
7696 builders -> ["--builders", intercalate ";" builders]
97+
98+-- | If a nix command fails, and this may be related to the NIX_REMOTE issue,
99+-- tell the user what to do.
100+--
101+-- Added: June, 2018. Consider removing this eventually.
102+withNixRemoteCheck :: MonadObelisk m => m a -> m a
103+withNixRemoteCheck f = f `catch` \e@(ProcessFailed _ _) -> do
104+ liftIO (lookupEnv "NIX_REMOTE") >>= \case
105+ Just _ -> throwM e
106+ Nothing -> liftIO (writable <$> getPermissions "/nix/var/nix/db") >>= \case
107+ True -> throwM e
108+ False -> do
109+ putLog Error "!!! "
110+ putLog Error "!!! A nix command failed to run. You might need to set the NIX_REMOTE environment variable"
111+ putLog Error "!!! to `daemon`. To do this, run the following before running obelisk:"
112+ putLog Error "!!! "
113+ putLog Error "!!! export NIX_REMOTE=daemon"
114+ putLog Error "!!! "
115+ putLog Error "!!! For details, see https://github.com/NixOS/nixpkgs/issues/5713"
116+ putLog Error "!!! "
117+ throwM e
lib/command/src/Obelisk/Command/Project.hs+2−2
@@ -20,7 +20,6 @@ import Control.Monad.IO.Class (liftIO)
2020import Control.Monad.State
2121import Data.Bits
2222import Data.Function (on)
23−import Data.Monoid
2423import qualified Data.Text as T
2524import System.Directory
2625import System.FilePath
@@ -35,6 +34,7 @@ import GitHub.Data.Name (Name)
3534import Obelisk.App (MonadObelisk)
3635import Obelisk.CliApp
3736import Obelisk.Command.Thunk
37+import Obelisk.Command.Nix (withNixRemoteCheck)
3838--TODO: Make this module resilient to random exceptions
3939
4040--TODO: Don't hardcode this
@@ -201,7 +201,7 @@ inImpureProjectShell shellName command = withProjectRoot "." $ \root ->
201201 projectShell root False shellName command
202202
203203projectShell :: MonadObelisk m => FilePath -> Bool -> String -> String -> m ()
204−projectShell root isPure shellName command = do
204+projectShell root isPure shellName command = withNixRemoteCheck $ do
205205 (_, _, _, ph) <- createProcess_ "runNixShellAttr" $ setCwd (Just root) $ proc "nix-shell" $
206206 [ "--pure" | isPure ] <>
207207 [ "-A"
lib/command/src/Obelisk/Command/Run.hs+2−3
@@ -13,9 +13,8 @@ import Data.Either
1313import Data.List
1414import Data.List.NonEmpty as NE
1515import Data.Maybe
16−import Data.Monoid
1716import qualified Data.Text as T
18−import Distribution.PackageDescription.Parsec (ParseResult (..), parseGenericPackageDescription)
17+import Distribution.PackageDescription.Parsec (parseGenericPackageDescription)
1918import Distribution.Parsec.ParseResult (runParseResult)
2019import Distribution.Types.BuildInfo
2120import Distribution.Types.CondTree
@@ -123,7 +122,7 @@ runGhcid dotGhci mcmd = callCommand $ unwords $ "ghcid" : opts
123122 where
124123 opts =
125124 [ "-W"
126− , "--command='ghci -ghci-script " <> dotGhci <> "' "
125+ , "--command='ghci -Wall -ghci-script " <> dotGhci <> "' "
127126 , "--reload=config"
128127 , "--outputfile=ghcid-output.txt"
129128 ] <> testCmd
lib/command/src/Obelisk/Command/Thunk.hs+3−3
@@ -50,7 +50,6 @@ import Data.List.NonEmpty (NonEmpty (..))
5050import qualified Data.List.NonEmpty as NonEmpty
5151import qualified Data.Map as Map
5252import Data.Maybe
53−import Data.Monoid
5453import Data.Set (Set)
5554import qualified Data.Set as Set
5655import Data.Text (Text)
@@ -74,6 +73,7 @@ import System.Process (proc)
7473
7574import Obelisk.App (MonadObelisk)
7675import Obelisk.CliApp
76+import Obelisk.Command.Nix (withNixRemoteCheck)
7777import Obelisk.Command.Utils
7878
7979--TODO: Support symlinked thunk data
@@ -135,13 +135,13 @@ commitNameToRef (N c) = Ref.fromHex $ encodeUtf8 c
135135getNixSha256ForUriUnpacked :: MonadObelisk m => URI -> m NixSha256
136136getNixSha256ForUriUnpacked uri =
137137 withExitFailMessage ("nix-prefetch-url: Failed to determine sha256 hash of URL " <> T.pack (show uri)) $ do
138− fmap T.pack $ readProcessAndLogStderr Debug $
138+ fmap T.pack $ withNixRemoteCheck $ readProcessAndLogStderr Debug $
139139 proc "nix-prefetch-url" ["--unpack" , "--type" , "sha256" , show uri]
140140
141141nixPrefetchGit :: MonadObelisk m => URI -> Text -> Bool -> m NixSha256
142142nixPrefetchGit uri rev fetchSubmodules =
143143 withExitFailMessage ("nix-prefetch-git: Failed to determine sha256 hash of Git repo " <> T.pack (show uri) <> " at " <> rev) $ do
144− out <- readProcessAndLogStderr Debug $
144+ out <- withNixRemoteCheck $ readProcessAndLogStderr Debug $
145145 proc "nix-prefetch-git" $ filter (/="")
146146 [ "--url", show uri
147147 , "--rev", T.unpack rev
lib/command/src/Obelisk/Command/Upgrade/Hash.hs+2−1
@@ -23,6 +23,7 @@ import System.Process (cwd, proc)
2323
2424import Obelisk.App (MonadObelisk)
2525import Obelisk.CliApp
26+import Obelisk.Command.Nix (withNixRemoteCheck)
2627import Obelisk.Command.Utils
2728
2829import Obelisk.Migration
@@ -87,7 +88,7 @@ getHashAtGitRevision revs excludes dir = withSystemTempDirectory "obelisk-hashre
8788 return result
8889
8990nixHash :: MonadObelisk m => FilePath -> m Hash
90−nixHash dir = withSpinnerNoTrail "Running `nix-hash`" $
91+nixHash dir = withSpinnerNoTrail "Running `nix-hash`" $ withNixRemoteCheck $
9192 readProc $ proc "nix-hash" [dir]
9293
9394-- | Clean up the following files in the git working copy
lib/command/src/Obelisk/Command/VmBuilder.hs+4−4
@@ -16,13 +16,13 @@ import qualified Data.Text as T
1616import qualified Data.Text.IO as T
1717import System.Directory (createDirectoryIfMissing)
1818import System.Exit (ExitCode)
19−import System.FilePath ((</>), (<.>))
19+import System.FilePath ((<.>), (</>))
2020import qualified System.Info
2121import System.Process (proc)
2222
2323import Obelisk.App (MonadObelisk, getObeliskUserStateDir)
24−import Obelisk.CliApp (Severity (..), callProcessAndLogOutput, failWith, readProcessAndLogStderr,
25− withExitFailMessage, withSpinner)
24+import Obelisk.CliApp
25+import Obelisk.Command.Nix (withNixRemoteCheck)
2626
2727-- | Generate the `--builders` argument string to enable the VM builder after ensuring it is available.
2828getNixBuildersArg :: MonadObelisk m => m String
@@ -131,7 +131,7 @@ getDockerBuilderStateDir = liftA2 (</>) getObeliskUserStateDir (pure "nix-docker
131131-- | Run a test build to see if a Linux build can finish successfully.
132132testLinuxBuild :: MonadObelisk m => FilePath -> m Bool
133133testLinuxBuild stateDir =
134− fmap (isRight :: Either ExitCode () -> Bool) $ try $
134+ fmap (isRight :: Either ExitCode () -> Bool) $ try $ withNixRemoteCheck $
135135 callProcessAndLogOutput (Debug, Debug) $ proc "nix-build"
136136 [ "-E", "(import <nixpkgs> { system = \"x86_64-linux\"; }).writeText \"test\" builtins.currentTime"
137137 , "--builders", nixBuildersArgString stateDir
lib/executable-config/inject/obelisk-executable-config-inject.cabal+2−3
@@ -7,11 +7,10 @@ library
77 hs-source-dirs: src
88
99 build-depends:
10− base,
10+ base,
1111 bytestring,
1212 filepath,
1313 reflex-dom,
14− text,
15− transformers
14+ text
1615
1716 exposed-modules: Obelisk.ExecutableConfig.Inject
lib/executable-config/inject/src/Obelisk/ExecutableConfig/Inject.hs+8−7
@@ -1,17 +1,18 @@
11{-# LANGUAGE OverloadedStrings #-}
22module Obelisk.ExecutableConfig.Inject where
33
4−import Data.ByteString (ByteString)
5−import qualified Data.ByteString as BS
64import Data.Semigroup ((<>))
5+import Data.ByteString (ByteString)
76import qualified Data.Text as T
8−import qualified Data.Text.Encoding as T
7+import qualified Data.Text.IO as T
98import Reflex.Dom
109import System.FilePath ((</>))
1110
1211inject :: FilePath -> IO ByteString
1312inject item = do
14− f <- BS.readFile $ "config" </> item
15− fmap snd $ renderStatic $
16− elAttr "script" ("type" =: "text/plain" <> "id" =: ("config-" <> T.pack item)) $
17− text $ T.decodeUtf8 f
13+ f <- T.readFile $ "config" </> item
14+ fmap snd $ renderStatic $ injectPure item f
15+
16+injectPure :: DomBuilder t m => FilePath -> T.Text -> m ()
17+injectPure item content = elAttr "script" ("type" =: "text/plain" <> "id" =: ("config-" <> T.pack item)) $
18+ text content
lib/frontend/obelisk-frontend.cabalnew file+19
@@ -0,0 +1,19 @@
1+name: obelisk-frontend
2+version: 0.1
3+cabal-version: >= 1.2
4+build-type: Simple
5+
6+library
7+ hs-source-dirs: src
8+ build-depends: base,
9+ dependent-sum,
10+ ghcjs-dom,
11+ jsaddle,
12+ lens,
13+ obelisk-route,
14+ ref-tf,
15+ reflex,
16+ reflex-dom-core,
17+ text,
18+ transformers
19+ exposed-modules: Obelisk.Frontend
lib/frontend/src/Obelisk/Frontend.hsnew file+150
@@ -0,0 +1,150 @@
1+{-# LANGUAGE ConstraintKinds #-}
2+{-# LANGUAGE FlexibleContexts #-}
3+{-# LANGUAGE FlexibleInstances #-}
4+{-# LANGUAGE LambdaCase #-}
5+{-# LANGUAGE MultiParamTypeClasses #-}
6+{-# LANGUAGE RankNTypes #-}
7+{-# LANGUAGE ScopedTypeVariables #-}
8+{-# LANGUAGE TemplateHaskell #-}
9+{-# LANGUAGE TypeFamilies #-}
10+{-# LANGUAGE UndecidableInstances #-}
11+module Obelisk.Frontend
12+ ( ObeliskWidget
13+ , Frontend (..)
14+ , runFrontend
15+ ) where
16+
17+import Prelude hiding ((.))
18+
19+import Control.Category
20+import Control.Concurrent
21+import Control.Lens
22+import Control.Monad
23+import Control.Monad.Fix
24+import Control.Monad.IO.Class
25+import Control.Monad.Ref
26+import Control.Monad.Trans.Class
27+import Data.Dependent.Sum (DSum (..))
28+import Data.Functor.Sum
29+import Data.IORef
30+import Data.Monoid hiding (Sum)
31+import Data.Text (Text)
32+import GHCJS.DOM hiding (bracket, catch)
33+import GHCJS.DOM.Document
34+import GHCJS.DOM.Node
35+import qualified GHCJS.DOM.Types as DOM
36+import Language.Javascript.JSaddle (JSM)
37+import Obelisk.Route.Frontend
38+import Reflex.Dom.Core
39+import Reflex.Host.Class
40+import qualified Reflex.TriggerEvent.Base as TriggerEvent
41+
42+makePrisms ''Sum
43+
44+type ObeliskWidget t route m =
45+ ( DomBuilder t m
46+ , MonadFix m
47+ , MonadHold t m
48+ , MonadSample t (Performable m)
49+ , MonadReflexCreateTrigger t m
50+ , PostBuild t m
51+ , PerformEvent t m
52+ , MonadIO m
53+ , MonadIO (Performable m)
54+ , TriggerEvent t m
55+-- , HasDocument m --TODO: Would need StaticDomBuilderT to have this
56+ , MonadRef m
57+ , Ref m ~ Ref IO
58+ , MonadRef (Performable m)
59+ , Ref (Performable m) ~ Ref IO
60+ , EventWriter t (Endo route) m
61+ )
62+
63+data Frontend route = Frontend
64+ { _frontend_head :: !(forall t m. ObeliskWidget t route m => RoutedT t route m ())
65+ , _frontend_body :: !(forall t m x. (MonadWidget t m, HasJS x m, EventWriter t (Endo route) m) => RoutedT t route m ())
66+ , _frontend_title :: !(route -> Text)
67+ , _frontend_notFoundRoute :: !(Text -> route) --TODO: Instead, maybe we should just require that the `parse` Monad for routeEncoder be `Identity`
68+ }
69+
70+type Widget' x = ImmediateDomBuilderT DomTimeline (DomCoreWidget x)
71+
72+-- | A widget that isn't attached to any particular part of the DOM hierarchy
73+type FloatingWidget x = TriggerEventT DomTimeline (DomCoreWidget x)
74+
75+type DomCoreWidget x = PostBuildT DomTimeline (WithJSContextSingleton x (PerformEventT DomTimeline DomHost))
76+
77+--TODO: Upstream
78+instance HasJS x m => HasJS x (EventWriterT t w m) where
79+ type JSX (EventWriterT t w m) = JSX m
80+ liftJS = lift . liftJS
81+
82+--TODO: Rename
83+{-# INLINABLE attachWidget''' #-}
84+attachWidget'''
85+ :: (EventChannel -> PerformEventT DomTimeline (SpiderHost Global) (IORef (Maybe (EventTrigger DomTimeline ()))))
86+ -> IO ()
87+attachWidget''' w = runDomHost $ do
88+ events <- liftIO newChan
89+ (postBuildTriggerRef, fc@(FireCommand fire)) <- hostPerformEventT $ w events
90+ mPostBuildTrigger <- readRef postBuildTriggerRef
91+ forM_ mPostBuildTrigger $ \postBuildTrigger -> fire [postBuildTrigger :=> Identity ()] $ return ()
92+ liftIO $ processAsyncEvents events fc
93+
94+--TODO: This is a collection of random stuff; we should make it make some more sense and then upstream to reflex-dom-core
95+runWithHeadAndBody
96+ :: ( (forall c. Widget' () c -> FloatingWidget () c) -- "Append to head"
97+ -> (forall c. Widget' () c -> FloatingWidget () c) -- "Append to body"
98+ -> FloatingWidget () ()
99+ )
100+ -> JSM ()
101+runWithHeadAndBody app = withJSContextSingletonMono $ \jsSing -> do
102+ globalDoc <- currentDocumentUnchecked
103+ headFragment <- createDocumentFragment globalDoc
104+ bodyFragment <- createDocumentFragment globalDoc
105+ unreadyChildren <- liftIO $ newIORef 0
106+ let commit = do
107+ headElement <- getHeadUnchecked globalDoc
108+ replaceElementContents headElement headFragment
109+ bodyElement <- getBodyUnchecked globalDoc
110+ replaceElementContents bodyElement bodyFragment
111+ liftIO $ attachWidget''' $ \events -> flip runWithJSContextSingleton jsSing $ do
112+ (postBuild, postBuildTriggerRef) <- newEventWithTriggerRef
113+ let appendImmediateDom :: DOM.DocumentFragment -> Widget' () c -> FloatingWidget () c
114+ appendImmediateDom df w = do
115+ events' <- TriggerEvent.askEvents
116+ lift $ do
117+ doc <- getOwnerDocumentUnchecked df
118+ let builderEnv = ImmediateDomBuilderEnv
119+ { _immediateDomBuilderEnv_document = doc
120+ , _immediateDomBuilderEnv_parent = toNode df
121+ , _immediateDomBuilderEnv_unreadyChildren = unreadyChildren
122+ , _immediateDomBuilderEnv_commitAction = commit
123+ }
124+ runImmediateDomBuilderT w builderEnv events'
125+ flip runPostBuildT postBuild $ flip runTriggerEventT events $ app (appendImmediateDom headFragment) (appendImmediateDom bodyFragment)
126+ liftIO (readIORef unreadyChildren) >>= \case
127+ 0 -> DOM.liftJSM commit
128+ _ -> return ()
129+ return postBuildTriggerRef
130+
131+runFrontend :: forall backendRoute route. ValidEncoder (Either Text) (R (Sum backendRoute (ObeliskRoute route))) PageName -> Frontend (R route) -> JSM ()
132+runFrontend validFullEncoder frontend = do
133+ let ve = validFullEncoder . prismValidEncoder (rPrism $ _InR . _ObeliskRoute_App)
134+ runMyRouteViewT
135+ :: ( TriggerEvent t m
136+ , PerformEvent t m
137+ , MonadHold t m
138+ , DOM.MonadJSM m
139+ , DOM.MonadJSM (Performable m)
140+ , MonadFix m
141+ )
142+ => RoutedT t (R route) (EventWriterT t (Endo (R route)) m) a
143+ -> m a
144+ runMyRouteViewT = runRouteViewT
145+ ve
146+ (_frontend_title frontend)
147+ (_frontend_notFoundRoute frontend)
148+ runWithHeadAndBody $ \appendHead appendBody -> runMyRouteViewT $ do
149+ mapRoutedT (mapEventWriterT appendHead) $ _frontend_head frontend
150+ mapRoutedT (mapEventWriterT appendBody) $ _frontend_body frontend
lib/https/default.nixdeleted−7
@@ -1,7 +0,0 @@
1−# DO NOT HAND-EDIT THIS FILE
2−import ((import <nixpkgs> {}).fetchFromGitHub (
3− let json = builtins.fromJSON (builtins.readFile ./github.json);
4− in { inherit (json) owner repo rev sha256;
5− private = json.private or false;
6− }
7−))
lib/https/github.jsondeleted−6
@@ -1,6 +0,0 @@
1−{
2− "owner": "obsidiansystems",
3− "repo": "obelisk-https",
4− "rev": "5bc5cd81b669a0ea1b4415a1be8fd8c5a0680f0b",
5− "sha256": "105n7pxykz02n8ilgjznssmxa8hszclg21nqiyqdr64bcrxjyxf4"
6−}
lib/route/obelisk-route.cabalnew file+30
@@ -0,0 +1,30 @@
1+name: obelisk-route
2+version: 0.1
3+cabal-version: >= 1.2
4+build-type: Simple
5+
6+library
7+ hs-source-dirs: src
8+ build-depends: base,
9+ categories,
10+ constraints,
11+ containers,
12+ dependent-map,
13+ dependent-sum,
14+ dependent-sum-template,
15+ either,
16+ ghcjs-dom,
17+ jsaddle,
18+ lens,
19+ monad-control,
20+ mtl,
21+ network-uri,
22+ ref-tf,
23+ reflex,
24+ reflex-dom,
25+ text,
26+ transformers,
27+ universe
28+ exposed-modules: Obelisk.Route
29+ Obelisk.Route.Frontend
30+ ghc-options: -Wall -fwarn-redundant-constraints
lib/route/src/Obelisk/Route.hsnew file+634
@@ -0,0 +1,634 @@
1+{-# LANGUAGE ApplicativeDo #-}
2+{-# LANGUAGE ConstraintKinds #-}
3+{-# LANGUAGE EmptyCase #-}
4+{-# LANGUAGE EmptyDataDecls #-}
5+{-# LANGUAGE FlexibleContexts #-}
6+{-# LANGUAGE FlexibleInstances #-}
7+{-# LANGUAGE GADTs #-}
8+{-# LANGUAGE KindSignatures #-}
9+{-# LANGUAGE LambdaCase #-}
10+{-# LANGUAGE MultiParamTypeClasses #-}
11+{-# LANGUAGE OverloadedStrings #-}
12+{-# LANGUAGE PatternSynonyms #-}
13+{-# LANGUAGE RankNTypes #-}
14+{-# LANGUAGE ScopedTypeVariables #-}
15+{-# LANGUAGE TemplateHaskell #-}
16+{-# LANGUAGE TypeFamilies #-}
17+{-# LANGUAGE TypeOperators #-}
18+{-# LANGUAGE ViewPatterns #-}
19+module Obelisk.Route
20+ ( R
21+ , pattern (:/)
22+ , PageName
23+ , Encoder (..) --TODO: unsafe
24+ , checkEncoder
25+ , ValidEncoder (..) --TODO: unsafe
26+ , ValidEncoderFunc (..)
27+ , mapSome
28+ , pathComponentEncoder
29+ , enumEncoder
30+ , enum1Encoder
31+ , endValidEncoder
32+ , pathOnlyValidEncoder
33+ , singletonListValidEncoder
34+ , unpackTextEncoder
35+ , prefixTextEncoder
36+ , intercalateTextEncoder
37+ , listToNonEmptyEncoder
38+ , prefixNonemptyTextEncoder
39+ , joinPairTextEncoder
40+ , toListMapEncoder
41+ , shadowEncoder
42+ , prismValidEncoder
43+ , rPrism
44+ , obeliskRouteEncoder
45+ , obeliskRouteComponentEncoder
46+ , checkObeliskRouteRestEncoder
47+ , pageNameValidEncoder
48+ , catchValidEncoder
49+ , ObeliskRoute (..)
50+ , _ObeliskRoute_App
51+ , _ObeliskRoute_Resource
52+ , ResourceRoute (..)
53+ , JSaddleWarpRoute (..)
54+ , jsaddleWarpRouteEncoder
55+ , IndexOnlyRoute (..)
56+ , indexOnlyRouteComponentEncoder
57+ , indexOnlyRouteRestEncoder
58+ , someSumEncoder
59+ , Void1
60+ , void1Encoder
61+ ) where
62+
63+import Prelude hiding ((.), id)
64+
65+import Control.Applicative
66+import Control.Category (Category (..))
67+import qualified Control.Categorical.Functor as Cat
68+import Control.Categorical.Bifunctor
69+import Control.Lens (Identity (..), Prism', makePrisms, itraverse, imap, prism, (^.), re, matching, (^?))
70+import Control.Monad.Except
71+import Data.Dependent.Sum (DSum (..), ShowTag (..))
72+import Data.Dependent.Map (DMap)
73+import qualified Data.Dependent.Map as DMap
74+import Data.Either.Validation (Validation (..))
75+import Data.Foldable
76+import Data.Functor.Sum
77+import Data.GADT.Compare
78+import Data.GADT.Compare.TH
79+import Data.GADT.Show
80+import Data.GADT.Show.TH
81+import Data.List.NonEmpty (NonEmpty (..))
82+import Data.Map (Map)
83+import qualified Data.Map as Map
84+import Data.Maybe
85+import Data.Set (Set)
86+import qualified Data.Set as Set
87+import Data.Some (Some)
88+import qualified Data.Some as Some
89+import Data.Text (Text)
90+import qualified Data.Text as T
91+import Data.Universe
92+
93+-- Design goals:
94+-- No start-up time on the frontend (not yet met)
95+-- Able to ensure that there aren't overlapping routes prior to deployment
96+-- Easy to write common types of parsers
97+-- Completeness checking
98+
99+-- Laws:
100+-- We statically know that all valid Routes can be turned into valid URIs
101+-- - This means we need to know whether there are any overlaps, e.g. with the static file namespace
102+
103+--TODO:
104+-- Backend:
105+-- Redirect the user to a canonical route
106+-- Pre-render
107+-- Frontend:
108+-- Intercept links that can be kept within the app
109+-- Fragments
110+
111+--------------------------------------------------------------------------------
112+-- Subroutes/paths
113+--------------------------------------------------------------------------------
114+
115+type R f = DSum f Identity --TODO: Better name
116+
117+--TODO: COMPLETE pragma
118+infixr 5 :/
119+pattern (:/) :: f a -> a -> R f
120+pattern a :/ b = a :=> Identity b
121+
122+mapSome :: (forall a. f a -> g a) -> Some f -> Some g
123+mapSome f (Some.This a) = Some.This $ f a
124+
125+--------------------------------------------------------------------------------
126+-- Encoder fundamentals
127+--------------------------------------------------------------------------------
128+
129+newtype Encoder check parse decoded encoded = Encoder { unEncoder :: check (ValidEncoder parse decoded encoded) }
130+
131+-- | Law:
132+-- forall p. _validEncoder_decode ve . _validEncoder_encode ve p == pure
133+-- Note that the reverse may not be the case: when parsing, a route may be canonicalized, and erroneous routes may be collapsed to a single 404 route. However, as a consequence of the law, encode . decode must be idemponent.
134+data ValidEncoder parse decoded encoded = ValidEncoder
135+ { _validEncoder_decode :: !(encoded -> parse decoded) -- Can fail; can lose information; must always succeed on outputs of `_validEncoder_encode` and result in the original value
136+ , _validEncoder_encode :: !(decoded -> encoded) -- Must be injective
137+ }
138+
139+checkEncoder :: Encoder check parse decoded encoded -> check (ValidEncoder parse decoded encoded)
140+checkEncoder = unEncoder
141+
142+instance (Applicative check, Monad parse) => Category (Encoder check parse) where
143+ id = Encoder $ pure id
144+ Encoder f . Encoder g = Encoder $ liftA2 (.) f g
145+
146+instance Monad parse => Category (ValidEncoder parse) where
147+ id = ValidEncoder
148+ { _validEncoder_decode = pure
149+ , _validEncoder_encode = id
150+ }
151+ f . g = ValidEncoder
152+ { _validEncoder_decode = _validEncoder_decode g <=< _validEncoder_decode f
153+ , _validEncoder_encode = _validEncoder_encode f . _validEncoder_encode g
154+ }
155+
156+instance Monad parse => PFunctor (,) (ValidEncoder parse) (ValidEncoder parse) where
157+ first f = bimap f id
158+instance Monad parse => QFunctor (,) (ValidEncoder parse) (ValidEncoder parse) where
159+ second g = bimap id g
160+instance Monad parse => Bifunctor (,) (ValidEncoder parse) (ValidEncoder parse) (ValidEncoder parse) where
161+ bimap f g = ValidEncoder
162+ { _validEncoder_encode = bimap (_validEncoder_encode f) (_validEncoder_encode g)
163+ , _validEncoder_decode = \(a, b) -> liftA2 (,) (_validEncoder_decode f a) (_validEncoder_decode g b)
164+ }
165+
166+instance (Applicative check, Monad parse) => PFunctor (,) (Encoder check parse) (Encoder check parse) where
167+ first f = bimap f id
168+instance (Applicative check, Monad parse) => QFunctor (,) (Encoder check parse) (Encoder check parse) where
169+ second g = bimap id g
170+instance (Applicative check, Monad parse) => Bifunctor (,) (Encoder check parse) (Encoder check parse) (Encoder check parse) where
171+ bimap f g = Encoder $ liftA2 bimap (checkEncoder f) (checkEncoder g)
172+
173+instance (Traversable f, Monad check, Monad parse) => Cat.Functor f (Encoder check parse) (Encoder check parse) where
174+ fmap e = Encoder $ do
175+ ve <- checkEncoder e
176+ pure $ ValidEncoder
177+ { _validEncoder_encode = fmap $ _validEncoder_encode ve
178+ , _validEncoder_decode = traverse $ _validEncoder_decode ve
179+ }
180+
181+--------------------------------------------------------------------------------
182+-- Specific instances of encoders
183+--------------------------------------------------------------------------------
184+
185+-- | A path and query string, in which trailing slashes don't matter in the path
186+-- and duplicate query parameters are not allowed
187+type PageName = ([Text], Map Text Text)
188+
189+newtype Flip f a b = Flip { unFlip :: f b a }
190+
191+pathComponentEncoder :: forall check parse p. (Monad check, Monad parse) => (Encoder check parse (Some p) (Maybe Text)) -> (forall a. p a -> ValidEncoder parse a PageName) -> Encoder check parse (R p) PageName
192+pathComponentEncoder this rest = chainEncoder (lensEncoder (\(_, b) a -> (a, b)) Prelude.fst consValidEncoder) this rest
193+
194+--NOTE: Naming convention in this module is to always talk about things in the *encoding* direction, never in the *decoding* direction
195+
196+chainEncoder
197+ :: forall check parse p r b.
198+ ( Monad check
199+ , Monad parse
200+ )
201+ => ValidEncoder parse (b, r) r
202+ -> Encoder check parse (Some p) b
203+ -> (forall a. p a -> ValidEncoder parse a r)
204+ -> Encoder check parse (R p) r
205+chainEncoder cons this rest = Encoder $ do
206+ thisValid <- checkEncoder this
207+ pure $ ValidEncoder
208+ { _validEncoder_decode = \v -> do
209+ (here, following) <- _validEncoder_decode cons v
210+ Some.This r <- _validEncoder_decode thisValid here
211+ (r :/) <$> _validEncoder_decode (rest r) following
212+ , _validEncoder_encode = \(r :/ s) ->
213+ _validEncoder_encode cons (_validEncoder_encode thisValid $ Some.This r, _validEncoder_encode (rest r) s)
214+ }
215+
216+--TODO: Do this in terms of a lens instead
217+lensEncoder :: Monad parse => (b -> [a] -> b) -> (b -> [a]) -> ValidEncoder parse (c, [a]) [a] -> ValidEncoder parse (c, b) b
218+lensEncoder set get g = ValidEncoder
219+ { _validEncoder_encode = \(ma, b) -> set b $ _validEncoder_encode g (ma, get b)
220+ , _validEncoder_decode = \b -> do
221+ (ma, la) <- _validEncoder_decode g $ get b
222+ pure (ma, set b la)
223+ }
224+
225+consValidEncoder :: Applicative parse => ValidEncoder parse (Maybe a, [a]) [a] --TODO: Really shouldn't *always* have the [a], even in the Nothing case
226+consValidEncoder = ValidEncoder
227+ { _validEncoder_encode = \(h, t) -> maybeToList h <> t
228+ , _validEncoder_decode = pure . \case
229+ [] -> (Nothing, [])
230+ h:t -> (Just h, t)
231+ }
232+
233+tshow :: Show a => a -> Text
234+tshow = T.pack . show
235+
236+shadowEncoder
237+ :: ( Universe a
238+ , MonadError Text check
239+ , Show a
240+ , Show b
241+ , Show c
242+ , check ~ parse --TODO: Get rid of this
243+ )
244+ => Encoder check parse a c -- ^ Overlaps; should have a small number of possible routes
245+ -> Encoder check parse b c -- ^ Gets overlapped
246+ -> Encoder check parse (Either a b) c
247+shadowEncoder f g = Encoder $ do
248+ vf <- checkEncoder f
249+ vg <- checkEncoder g
250+ let gCanParse c = catchError (Just <$> _validEncoder_decode vg c) (\_ -> pure Nothing)
251+ overlaps <- fmap catMaybes $ forM universe $ \a -> do
252+ let c = _validEncoder_encode vf a
253+ mb <- gCanParse c
254+ pure $ fmap (\b -> (a, b, c)) mb
255+ case overlaps of
256+ [] -> pure ()
257+ _ -> throwError $ "shadowEncoder: overlap detected: " <> T.unlines
258+ (flip fmap overlaps $ \(a, b, c) -> "first encoder encodes " <> tshow a <> " as " <> tshow c <> ", which second encoder decodes as " <> tshow b)
259+ pure $ ValidEncoder
260+ { _validEncoder_encode = \case
261+ Left a -> _validEncoder_encode vf a
262+ Right b -> _validEncoder_encode vg b
263+ , _validEncoder_decode = \c -> (Left <$> _validEncoder_decode vf c) `catchError` \_ -> Right <$> _validEncoder_decode vg c
264+ }
265+
266+enum1Encoder
267+ :: ( Universe (Some p)
268+ , GShow p
269+ , GCompare p
270+ , Ord r
271+ , MonadError Text check
272+ , MonadError Text parse
273+ , Show r
274+ )
275+ => (forall a. p a -> r) -> Encoder check parse (Some p) r
276+enum1Encoder f = enumEncoder $ \(Some.This p) -> f p
277+
278+-- | Encode an enumerable, bounded type. WARNING: Don't use this on types that
279+-- have a large number of values - it will use a lot of memory.
280+enumEncoder :: forall parse check p r. (Universe p, Show p, Ord p, Ord r, MonadError Text parse, MonadError Text check, Show r) => (p -> r) -> Encoder check parse p r
281+enumEncoder f = Encoder $ do
282+ let reversed = Map.fromListWith (<>) [ (f p, Set.singleton p) | p <- universe ]
283+ checkSingleton k vs = case Set.toList vs of
284+ [] -> error "enumEncoder: empty reverse mapping; should be impossible"
285+ [e] -> Success e
286+ _ -> Failure $ Map.singleton k vs
287+ showRedundant :: r -> Set p -> [Text]
288+ showRedundant k vs = (" " <> tshow k <> " can decode to any of:")
289+ : fmap ((" "<>) . tshow) (Set.toList vs)
290+ case itraverse checkSingleton reversed :: Validation (Map r (Set p)) (Map r p) of
291+ Failure ambiguousEntries -> throwError $ T.unlines $
292+ "enumEncoder: ambiguous encodings detected:" : concat (Map.elems $ imap showRedundant ambiguousEntries)
293+ Success m -> pure $ ValidEncoder
294+ { _validEncoder_decode = \r -> case Map.lookup r m of
295+ Just a -> pure a
296+ Nothing -> throwError $ "enumEncoder: not recognized: " <> tshow r --TODO: Report this as a better type
297+ , _validEncoder_encode = f
298+ }
299+
300+endValidEncoder :: (MonadError Text parse, Show r, Eq r) => r -> ValidEncoder parse () r
301+endValidEncoder expected = ValidEncoder
302+ { _validEncoder_decode = \obtained ->
303+ if obtained == expected
304+ then pure ()
305+ else throwError $ "endValidEncoder: expected " <> tshow expected <> ", got " <> tshow obtained
306+ , _validEncoder_encode = \_ -> expected
307+ }
308+
309+pathOnlyValidEncoder :: (MonadError Text parse) => ValidEncoder parse [Text] PageName
310+pathOnlyValidEncoder = ValidEncoder
311+ { _validEncoder_decode = \(path, query) ->
312+ if query == mempty
313+ then pure path
314+ else throwError "pathOnlyValidEncoder: query was provided"
315+ , _validEncoder_encode = \path -> (path, mempty)
316+ }
317+
318+singletonListValidEncoder :: (MonadError Text parse) => ValidEncoder parse a [a]
319+singletonListValidEncoder = ValidEncoder
320+ { _validEncoder_decode = \case
321+ [a] -> pure a
322+ l -> throwError $ "singletonListValidEncoder: expected one item, got " <> tshow (length l)
323+ , _validEncoder_encode = (:[])
324+ }
325+
326+--TODO: To know this is reversible, we must know that the separator isn't included anywhere in the input text
327+intercalateTextEncoder :: (MonadError Text check, Applicative parse) => Text -> Encoder check parse (NonEmpty Text) Text
328+intercalateTextEncoder = Encoder . \case
329+ "" -> throwError "splitOnTextEncoder: empty split string"
330+ separator -> pure $ ValidEncoder
331+ { _validEncoder_encode = T.intercalate separator . toList
332+ , _validEncoder_decode = \r -> pure $ case T.splitOn separator r of
333+ [] -> error "intercalateTextEncoder: Data.Text.splitOn should never return an empty list"
334+ h : t -> h :| t
335+ }
336+
337+listToNonEmptyEncoder :: (Applicative check, Applicative parse, Monoid a, Eq a) => Encoder check parse [a] (NonEmpty a)
338+listToNonEmptyEncoder = Encoder $ pure $ ValidEncoder
339+ { _validEncoder_encode = \case
340+ [] -> mempty :| []
341+ h : t -> h :| t
342+ , _validEncoder_decode = \(h :| t) -> pure $
343+ if h == mempty
344+ then []
345+ else h : t
346+ }
347+
348+prefixTextEncoder :: (Applicative check, MonadError Text parse) => Text -> Encoder check parse Text Text
349+prefixTextEncoder p = Encoder $ pure $ ValidEncoder
350+ { _validEncoder_encode = mappend p
351+ , _validEncoder_decode = \v -> case T.stripPrefix p v of
352+ Nothing -> throwError $ "prefixTextEncoder: wrong prefix; expected " <> tshow p <> ", got " <> tshow (T.take (T.length p) v)
353+ Just stripped -> pure stripped
354+ }
355+
356+prefixNonemptyTextEncoder :: (Applicative check, MonadError Text parse) => Text -> Encoder check parse Text Text
357+prefixNonemptyTextEncoder p = Encoder $ pure $ ValidEncoder
358+ { _validEncoder_encode = \case
359+ "" -> ""
360+ v -> p <> v
361+ , _validEncoder_decode = \case
362+ "" -> pure ""
363+ v -> case T.stripPrefix p v of
364+ Nothing -> throwError $ "prefixTextEncoder: wrong prefix; expected " <> tshow p
365+ Just stripped -> pure stripped
366+ }
367+
368+unpackTextEncoder :: (Applicative check, Applicative parse) => Encoder check parse Text String
369+unpackTextEncoder = Encoder $ pure $ ValidEncoder
370+ { _validEncoder_encode = T.unpack
371+ , _validEncoder_decode = pure . T.pack
372+ }
373+
374+toListMapEncoder :: (Applicative check, Applicative parse, Ord k) => Encoder check parse (Map k v) [(k, v)]
375+toListMapEncoder = Encoder $ pure $ ValidEncoder
376+ { _validEncoder_encode = Map.toList
377+ , _validEncoder_decode = pure . Map.fromList --TODO: Should we be stricter about repeated keys?
378+ }
379+
380+joinPairTextEncoder :: (MonadError Text check, MonadError Text parse) => Text -> Encoder check parse (Text, Text) Text
381+joinPairTextEncoder = Encoder . \case
382+ "" -> throwError "joinPairTextEncoder: empty separator"
383+ separator -> pure $ ValidEncoder
384+ { _validEncoder_encode = \(k, v) -> k <> separator <> v
385+ , _validEncoder_decode = \r ->
386+ let (kt, vt) = T.breakOn separator r
387+ in case vt of
388+ -- The separator was not found
389+ "" -> throwError $ "joinPairTextEncoder: separator not found; expected " <> tshow separator
390+ _ -> return (kt, T.drop (T.length separator) vt)
391+ }
392+
393+--TODO: Rewrite this by composing the given prism with a lens on the first element of the DSum
394+-- Or, more likely, the user can compose it themselves
395+rPrism :: forall f g. (forall a. Prism' (f a) (g a)) -> Prism' (R f) (R g)
396+rPrism p = prism (\(g :/ x) -> g ^. re p :/ x) (\(f :/ x) -> bimap (:/ x) (:/ x) $ matching p f)
397+
398+-- An encoder that only works on the items available via the prism
399+prismValidEncoder :: MonadError Text parse => Prism' b a -> ValidEncoder parse a b
400+prismValidEncoder p = ValidEncoder
401+ { _validEncoder_encode = (^. re p)
402+ , _validEncoder_decode = \r -> case r ^? p of
403+ Just a -> pure a
404+ Nothing -> throwError "prismValidEncoder: value is not present in the prism"
405+ }
406+
407+
408+-- | Encode a PageName into a path and query string, suitable for use in the
409+-- 'URI' type
410+pageNameValidEncoder :: MonadError Text parse => ValidEncoder parse PageName (String, String)
411+pageNameValidEncoder = ve
412+ where Right ve = checkEncoder $ bimap
413+ (unpackTextEncoder . prefixTextEncoder "/" . intercalateTextEncoder "/" . listToNonEmptyEncoder)
414+ (unpackTextEncoder . prefixNonemptyTextEncoder "?" . intercalateTextEncoder "&" . listToNonEmptyEncoder . Cat.fmap (joinPairTextEncoder "=") . toListMapEncoder)
415+
416+catchValidEncoder :: (e -> a) -> ValidEncoder (Either e) a b -> ValidEncoder Identity a b
417+catchValidEncoder recover ve = ve
418+ { _validEncoder_decode = \a -> pure $ case _validEncoder_decode ve a of
419+ Right r -> r
420+ Left err -> recover err
421+ }
422+
423+--------------------------------------------------------------------------------
424+-- Actual obelisk route info
425+--------------------------------------------------------------------------------
426+
427+data ObeliskRoute :: (* -> *) -> * -> * where
428+ -- We need to have the `f a` as an argument here, because otherwise we have no way to specifically check for overlap between us and the given encoder
429+ ObeliskRoute_App :: f a -> ObeliskRoute f a
430+ ObeliskRoute_Resource :: ResourceRoute a -> ObeliskRoute f a
431+
432+data ResourceRoute :: * -> * where
433+ ResourceRoute_Static :: ResourceRoute [Text] -- This [Text] represents the *path in our static files directory*, not necessarily the URL path that the asset gets served at (although that will often be "/static/this/text/thing")
434+ ResourceRoute_Ghcjs :: ResourceRoute [Text]
435+ ResourceRoute_JSaddleWarp :: ResourceRoute (R JSaddleWarpRoute)
436+
437+--TODO: Generate this
438+instance Universe (Some ResourceRoute) where
439+ universe =
440+ [ Some.This ResourceRoute_Static
441+ , Some.This ResourceRoute_Ghcjs
442+ , Some.This ResourceRoute_JSaddleWarp
443+ ]
444+
445+--TODO: Figure out a way to check this
446+obeliskComponentEncoder :: (Applicative check, Applicative parse) => Encoder check parse (Some (ObeliskRoute f)) (Either (Some ResourceRoute) (Some f))
447+obeliskComponentEncoder = Encoder $ pure $ ValidEncoder
448+ { _validEncoder_encode = \(Some.This o) -> case o of
449+ ObeliskRoute_App r -> Right $ Some.This r
450+ ObeliskRoute_Resource r -> Left $ Some.This r
451+ , _validEncoder_decode = pure . \case
452+ Right (Some.This r) -> Some.This $ ObeliskRoute_App r
453+ Left (Some.This r) -> Some.This $ ObeliskRoute_Resource r
454+ }
455+
456+newtype ValidEncoderFunc parse p r = ValidEncoderFunc { runValidEncoderFunc :: forall a. p a -> ValidEncoder parse a r }
457+
458+checkSomeUniverseEncoder
459+ :: forall check parse p r.
460+ ( Universe (Some p)
461+ , GCompare p
462+ , Monad check
463+ )
464+ => (forall a. p a -> Encoder check parse a r)
465+ -> check (ValidEncoderFunc parse p r)
466+checkSomeUniverseEncoder f = do
467+ validEncoders :: DMap p (Flip (ValidEncoder parse) r) <- DMap.fromList <$> traverse (\(Some.This p) -> (p :=>) . Flip <$> checkEncoder (f p)) universe
468+ pure $ ValidEncoderFunc $ \p -> unFlip $ DMap.findWithDefault (error "checkSomeUniverseEncoder: ValidEncoder not found (should be impossible)") p validEncoders
469+
470+obeliskRouteEncoder
471+ :: forall check parse appRoute.
472+ ( Universe (Some appRoute)
473+ , GCompare appRoute
474+ , GShow appRoute
475+ , MonadError Text check
476+ , check ~ parse --TODO: Get rid of this
477+ )
478+ => (Encoder check parse (Some appRoute) (Maybe Text))
479+ -> (forall a. appRoute a -> Encoder check parse a PageName)
480+ -> Encoder check parse (R (ObeliskRoute appRoute)) PageName
481+obeliskRouteEncoder appComponentEncoder appRouteEncoder = Encoder $ do
482+ let componentEncoder = obeliskRouteComponentEncoder appComponentEncoder
483+ restEncoder <- checkObeliskRouteRestEncoder appRouteEncoder
484+ checkEncoder $ pathComponentEncoder componentEncoder $ runValidEncoderFunc restEncoder
485+
486+checkObeliskRouteRestEncoder
487+ :: ( MonadError Text check
488+ , MonadError Text parse
489+ , Universe (Some appRoute)
490+ , GCompare appRoute
491+ )
492+ => (forall a. appRoute a -> Encoder check parse a PageName)
493+ -> check (ValidEncoderFunc parse (ObeliskRoute appRoute) PageName)
494+checkObeliskRouteRestEncoder appRouteEncoder = do
495+ appRouteValidEncoder <- checkSomeUniverseEncoder appRouteEncoder
496+ resourceRouteValidEncoder <- checkSomeUniverseEncoder resourceRouteEncoder
497+ return $ ValidEncoderFunc $ \case
498+ ObeliskRoute_App appRoute -> runValidEncoderFunc appRouteValidEncoder appRoute
499+ ObeliskRoute_Resource resRoute -> runValidEncoderFunc resourceRouteValidEncoder resRoute
500+
501+obeliskRouteComponentEncoder
502+ :: ( MonadError Text parse
503+ , GShow appRoute
504+ , check ~ parse
505+ )
506+ => Encoder check parse (Some appRoute) (Maybe Text) -> Encoder check parse (Some (ObeliskRoute appRoute)) (Maybe Text)
507+obeliskRouteComponentEncoder appComponentEncoder = (resourceComponentEncoder `shadowEncoder` appComponentEncoder) . obeliskComponentEncoder
508+
509+resourceComponentEncoder :: (MonadError Text check, MonadError Text parse) => Encoder check parse (Some ResourceRoute) (Maybe Text)
510+resourceComponentEncoder = enum1Encoder $ \case
511+ ResourceRoute_Static -> Just "static"
512+ ResourceRoute_Ghcjs -> Just "ghcjs"
513+ ResourceRoute_JSaddleWarp -> Just "jsaddle"
514+
515+resourceRouteEncoder :: (MonadError Text check, MonadError Text parse) => ResourceRoute a -> Encoder check parse a PageName
516+resourceRouteEncoder = \case
517+ ResourceRoute_Static -> Encoder $ pure pathOnlyValidEncoder
518+ ResourceRoute_Ghcjs -> Encoder $ pure pathOnlyValidEncoder
519+ ResourceRoute_JSaddleWarp -> jsaddleWarpRouteEncoder
520+
521+data JSaddleWarpRoute :: * -> * where
522+ JSaddleWarpRoute_JavaScript :: JSaddleWarpRoute ()
523+ JSaddleWarpRoute_WebSocket :: JSaddleWarpRoute ()
524+ JSaddleWarpRoute_Sync :: JSaddleWarpRoute [Text]
525+
526+instance Universe (Some JSaddleWarpRoute) where
527+ universe =
528+ [ Some.This JSaddleWarpRoute_JavaScript
529+ , Some.This JSaddleWarpRoute_WebSocket
530+ , Some.This JSaddleWarpRoute_Sync
531+ ]
532+
533+jsaddleWarpRouteComponentEncoder :: (MonadError Text check, MonadError Text parse) => Encoder check parse (Some JSaddleWarpRoute) (Maybe Text)
534+jsaddleWarpRouteComponentEncoder = enum1Encoder $ \case
535+ JSaddleWarpRoute_JavaScript -> Just "jsaddle.js"
536+ JSaddleWarpRoute_WebSocket -> Nothing
537+ JSaddleWarpRoute_Sync -> Just "sync"
538+
539+jsaddleWarpRouteEncoder :: (MonadError Text check, MonadError Text parse) => Encoder check parse (R JSaddleWarpRoute) PageName
540+jsaddleWarpRouteEncoder = pathComponentEncoder jsaddleWarpRouteComponentEncoder $ \case
541+ JSaddleWarpRoute_JavaScript -> endValidEncoder mempty
542+ JSaddleWarpRoute_WebSocket -> endValidEncoder mempty
543+ JSaddleWarpRoute_Sync -> pathOnlyValidEncoder
544+
545+instance ShowTag appRoute Identity => ShowTag (ObeliskRoute appRoute) Identity where
546+ showTaggedPrec = \case
547+ ObeliskRoute_App a -> showTaggedPrec a
548+ ObeliskRoute_Resource r -> showTaggedPrec r
549+
550+instance ShowTag ResourceRoute Identity where
551+ showTaggedPrec = \case
552+ ResourceRoute_Static -> showsPrec
553+ ResourceRoute_Ghcjs -> showsPrec
554+ ResourceRoute_JSaddleWarp -> showsPrec
555+
556+instance ShowTag JSaddleWarpRoute Identity where
557+ showTaggedPrec = \case
558+ JSaddleWarpRoute_JavaScript -> showsPrec
559+ JSaddleWarpRoute_WebSocket -> showsPrec
560+ JSaddleWarpRoute_Sync -> showsPrec
561+
562+instance Universe (Some appRoute) => Universe (Some (ObeliskRoute appRoute)) where
563+ universe = mconcat
564+ [ mapSome ObeliskRoute_App <$> universe
565+ , mapSome ObeliskRoute_Resource <$> universe
566+ ]
567+
568+instance GShow appRoute => GShow (ObeliskRoute appRoute) where
569+ gshowsPrec prec = \case
570+ ObeliskRoute_App appRoute -> showParen (prec > 10) $
571+ showString "ObeliskRoute_App " . gshowsPrec 11 appRoute
572+ ObeliskRoute_Resource appRoute -> showParen (prec > 10) $
573+ showString "ObeliskRoute_Resource " . gshowsPrec 11 appRoute
574+
575+
576+data IndexOnlyRoute :: * -> * where
577+ IndexOnlyRoute :: IndexOnlyRoute ()
578+
579+instance Universe (Some IndexOnlyRoute) where
580+ universe = [Some.This IndexOnlyRoute]
581+
582+indexOnlyRouteComponentEncoder :: (MonadError Text check, MonadError Text parse) => Encoder check parse (Some IndexOnlyRoute) (Maybe Text)
583+indexOnlyRouteComponentEncoder = enum1Encoder $ \case
584+ IndexOnlyRoute -> Nothing
585+
586+indexOnlyRouteRestEncoder :: (Applicative check, MonadError Text parse) => IndexOnlyRoute a -> Encoder check parse a PageName
587+indexOnlyRouteRestEncoder = \case
588+ IndexOnlyRoute -> Encoder $ pure $ endValidEncoder mempty --TODO: Allow anything to parse
589+
590+instance ShowTag IndexOnlyRoute Identity where
591+ showTaggedPrec = \case
592+ IndexOnlyRoute -> showsPrec
593+
594+someSumEncoder :: (Applicative check, Applicative parse) => Encoder check parse (Some (Sum a b)) (Either (Some a) (Some b))
595+someSumEncoder = Encoder $ pure $ ValidEncoder
596+ { _validEncoder_encode = \(Some.This t) -> case t of
597+ InL l -> Left $ Some.This l
598+ InR r -> Right $ Some.This r
599+ , _validEncoder_decode = pure . \case
600+ Left (Some.This l) -> Some.This (InL l)
601+ Right (Some.This r) -> Some.This (InR r)
602+ }
603+
604+data Void1 :: * -> * where {}
605+
606+instance Universe (Some Void1) where
607+ universe = []
608+
609+void1Encoder :: (Applicative check, MonadError Text parse) => Encoder check parse (Some Void1) a
610+void1Encoder = Encoder $ pure $ ValidEncoder
611+ { _validEncoder_encode = \case
612+ Some.This f -> case f of {}
613+ , _validEncoder_decode = \_ -> throwError "void1Encoder: can't decode anything"
614+ }
615+
616+instance GShow Void1 where
617+ gshowsPrec _ = \case {}
618+
619+makePrisms ''ObeliskRoute
620+--TODO: Prevent deriveGShow from creating this warning:
621+-- Defined but not used: ‘p’
622+deriveGShow ''ResourceRoute
623+deriveGEq ''ResourceRoute
624+deriveGCompare ''ResourceRoute
625+deriveGShow ''JSaddleWarpRoute
626+deriveGEq ''JSaddleWarpRoute
627+deriveGCompare ''JSaddleWarpRoute
628+deriveGShow ''IndexOnlyRoute
629+deriveGEq ''IndexOnlyRoute
630+deriveGCompare ''IndexOnlyRoute
631+deriveGEq ''Void1
632+deriveGCompare ''Void1
633+
634+--TODO: decodeURIComponent as appropriate
lib/route/src/Obelisk/Route/Frontend.hsnew file+191
@@ -0,0 +1,191 @@
1+{-# LANGUAGE ApplicativeDo #-}
2+{-# LANGUAGE CPP #-}
3+{-# LANGUAGE RecursiveDo #-}
4+{-# LANGUAGE StandaloneDeriving #-}
5+{-# LANGUAGE ConstraintKinds #-}
6+{-# LANGUAGE FlexibleContexts #-}
7+{-# LANGUAGE FlexibleInstances #-}
8+{-# LANGUAGE GADTs #-}
9+{-# LANGUAGE KindSignatures #-}
10+{-# LANGUAGE LambdaCase #-}
11+{-# LANGUAGE MultiParamTypeClasses #-}
12+{-# LANGUAGE OverloadedStrings #-}
13+{-# LANGUAGE PatternSynonyms #-}
14+{-# LANGUAGE RankNTypes #-}
15+{-# LANGUAGE ScopedTypeVariables #-}
16+{-# LANGUAGE TemplateHaskell #-}
17+{-# LANGUAGE TypeFamilies #-}
18+{-# LANGUAGE TypeOperators #-}
19+{-# LANGUAGE ViewPatterns #-}
20+{-# LANGUAGE GeneralizedNewtypeDeriving #-}
21+{-# LANGUAGE UndecidableInstances #-}
22+module Obelisk.Route.Frontend
23+ ( module Obelisk.Route
24+ , pattern (:~)
25+ , Routed
26+ , RoutedT
27+ , runRoutedT
28+ , askRoute
29+ , withRoutedT
30+ , mapRoutedT
31+ , subRoute
32+ , subRoute_
33+ , runRouteViewT
34+ ) where
35+
36+import Prelude hiding ((.), id)
37+
38+import Obelisk.Route
39+
40+import Control.Category (Category (..), (.))
41+import Control.Category.Cartesian
42+import Control.Lens hiding (Bifunctor, bimap, universe)
43+import Control.Monad.Fix
44+import Control.Monad.Ref
45+import Control.Monad.Trans
46+import Control.Monad.Trans.Control
47+import Control.Monad.Trans.Reader
48+import Data.Coerce
49+import Data.Dependent.Sum (DSum (..))
50+import Data.GADT.Compare
51+import Data.Monoid
52+import Data.Text (Text)
53+import Data.Functor.Compose
54+import Reflex.Class
55+import Reflex.Host.Class
56+import Reflex.PostBuild.Class
57+import Reflex.TriggerEvent.Class
58+import Reflex.PerformEvent.Class
59+import Reflex.EventWriter.Class
60+import Reflex.EventWriter.Base
61+import Reflex.Dynamic
62+import Reflex.Dom.Builder.Class
63+import Data.Type.Coercion
64+import Language.Javascript.JSaddle --TODO: Get rid of this - other platforms can also be routed
65+import Reflex.Dom.Core
66+import qualified GHCJS.DOM.Types as DOM
67+import Network.URI
68+
69+import Unsafe.Coerce
70+
71+infixr 5 :~
72+pattern (:~) :: Reflex t => f a -> Dynamic t a -> DSum f (Compose (Dynamic t) Identity)
73+pattern a :~ b <- a :=> (coerceDynamic . getCompose -> b)
74+
75+class Routed t r m where
76+ askRoute :: m (Dynamic t r)
77+
78+instance Monad m => Routed t r (RoutedT t r m) where
79+ askRoute = RoutedT ask
80+
81+newtype RoutedT t r m a = RoutedT { unRoutedT :: ReaderT (Dynamic t r) m a }
82+ deriving (Functor, Applicative, Monad, MonadFix, MonadTrans, NotReady t, MonadHold t, MonadSample t, PostBuild t, TriggerEvent t, HasJSContext, MonadIO, MonadReflexCreateTrigger t, HasDocument)
83+
84+#ifndef ghcjs_HOST_OS
85+deriving instance MonadJSM m => MonadJSM (RoutedT t r m)
86+#endif
87+
88+instance PerformEvent t m => PerformEvent t (RoutedT t r m) where
89+ type Performable (RoutedT t r m) = Performable m
90+ performEvent = lift . performEvent
91+ performEvent_ = lift . performEvent_
92+
93+instance MonadRef m => MonadRef (RoutedT t r m) where
94+ type Ref (RoutedT t r m) = Ref m
95+ newRef = lift . newRef
96+ readRef = lift . readRef
97+ writeRef r = lift . writeRef r
98+
99+instance HasJS x m => HasJS x (RoutedT t r m) where
100+ type JSX (RoutedT t r m) = JSX m
101+ liftJS = lift . liftJS
102+
103+deriving instance EventWriter t w m => EventWriter t w (RoutedT t r m)
104+
105+instance MonadTransControl (RoutedT t r) where
106+ type StT (RoutedT t r) a = StT (ReaderT (Dynamic t r)) a
107+ liftWith = defaultLiftWith RoutedT unRoutedT
108+ restoreT = defaultRestoreT RoutedT
109+
110+instance DomBuilder t m => DomBuilder t (RoutedT t r m) where
111+ type DomBuilderSpace (RoutedT t r m) = DomBuilderSpace m
112+
113+instance Adjustable t m => Adjustable t (RoutedT t r m) where
114+ runWithReplace a0 a' = RoutedT $ runWithReplace (coerce a0) $ coerceEvent a'
115+ traverseIntMapWithKeyWithAdjust f a0 a' = RoutedT $ traverseIntMapWithKeyWithAdjust (coerce f) (coerce a0) $ coerce a'
116+ traverseDMapWithKeyWithAdjust f a0 a' = RoutedT $ traverseDMapWithKeyWithAdjust (\k v -> coerce $ f k v) (coerce a0) $ coerce a'
117+ traverseDMapWithKeyWithAdjustWithMove f a0 a' = RoutedT $ traverseDMapWithKeyWithAdjustWithMove (\k v -> coerce $ f k v) (coerce a0) $ coerce a'
118+
119+runRoutedT :: RoutedT t r m a -> Dynamic t r -> m a
120+runRoutedT = runReaderT . unRoutedT
121+
122+mapRoutedT :: (m a -> n a) -> RoutedT t r m a -> RoutedT t r n a
123+mapRoutedT f = RoutedT . mapReaderT f . unRoutedT
124+
125+withRoutedT :: (Dynamic t r -> Dynamic t r') -> RoutedT t r' m a -> RoutedT t r m a
126+withRoutedT f = RoutedT . withReaderT f . unRoutedT
127+
128+subRoute_ :: (MonadFix m, MonadHold t m, GEq r, Adjustable t m) => (forall a. r a -> RoutedT t a m ()) -> RoutedT t (R r) m ()
129+subRoute_ f = factorRouted $ strictDynWidget_ $ \(c :=> r') -> do
130+ runRoutedT (f c) r'
131+
132+subRoute :: (MonadFix m, MonadHold t m, GEq r, Adjustable t m) => (forall a. r a -> RoutedT t a m b) -> RoutedT t (R r) m (Dynamic t b)
133+subRoute f = factorRouted $ strictDynWidget $ \(c :=> r') -> do
134+ runRoutedT (f c) r'
135+
136+dsumValueCoercion :: Coercion f g -> Coercion (DSum k f) (DSum k g)
137+dsumValueCoercion Coercion = Coercion
138+
139+dynamicIdentityCoercion :: Coercion (Compose (Dynamic t) Identity) (Dynamic t)
140+dynamicIdentityCoercion = unsafeCoerce (Coercion :: Coercion (Identity ()) ()) --TODO: Is it possible to prove this?
141+
142+factorRouted :: (Reflex t, MonadFix m, MonadHold t m, GEq f) => RoutedT t (DSum f (Dynamic t)) m a -> RoutedT t (DSum f Identity) m a
143+factorRouted r = RoutedT $ ReaderT $ \d -> do
144+ d' <- factorDyn d
145+ runRoutedT r $ (coerceWith (dynamicCoercion $ dsumValueCoercion dynamicIdentityCoercion) d')
146+
147+-- | WARNING: The input 'Dynamic' must be fully constructed when this is run
148+strictDynWidget :: (MonadSample t m, MonadHold t m, Adjustable t m) => (a -> m b) -> RoutedT t a m (Dynamic t b)
149+strictDynWidget f = RoutedT $ ReaderT $ \r -> do
150+ r0 <- sample $ current r
151+ (result0, result') <- runWithReplace (f r0) $ f <$> updated r
152+ holdDyn result0 result'
153+
154+strictDynWidget_ :: (MonadSample t m, MonadHold t m, Adjustable t m) => (a -> m ()) -> RoutedT t a m ()
155+strictDynWidget_ f = RoutedT $ ReaderT $ \r -> do
156+ r0 <- sample $ current r
157+ (_, _) <- runWithReplace (f r0) $ f <$> updated r
158+ pure ()
159+
160+runRouteViewT
161+ :: forall t m r a.
162+ ( TriggerEvent t m
163+ , PerformEvent t m
164+ , MonadHold t m
165+ , MonadJSM m
166+ , MonadJSM (Performable m)
167+ , MonadFix m
168+ )
169+ => (ValidEncoder (Either Text) r PageName)
170+ -> (r -> Text)
171+ -> (Text -> r) -- ^ 404 page
172+ -> RoutedT t r (EventWriterT t (Endo r) m) a
173+ -> m a
174+runRouteViewT routeValidEncoder routeToTitle error404 a = do
175+ rec historyState <- manageHistory $ HistoryCommand_PushState <$> setState
176+ let route :: Dynamic t r
177+ route = fmap (runIdentity . _validEncoder_decode (catchValidEncoder error404 $ pageNameValidEncoder . routeValidEncoder) . (uriPath &&& uriQuery) . _historyItem_uri) historyState
178+ (result, changeState) <- runEventWriterT $ runRoutedT a route
179+ let f oldRoute change =
180+ let newRoute = appEndo change oldRoute
181+ (newPath, newQuery) = _validEncoder_encode (pageNameValidEncoder . routeValidEncoder) newRoute
182+ in HistoryStateUpdate
183+ { _historyStateUpdate_state = DOM.SerializedScriptValue jsNull
184+ , _historyStateUpdate_title = routeToTitle newRoute
185+ , _historyStateUpdate_uri = Just $ nullURI
186+ { uriPath = newPath
187+ , uriQuery = newQuery
188+ }
189+ }
190+ setState = attachWith f (current route) changeState
191+ return result
lib/run/obelisk-run.cabal+12−1
@@ -10,6 +10,9 @@ library
1010 attoparsec
1111 , base
1212 , bytestring
13+ , dependent-sum
14+ , dependent-sum-template
15+ , ghcjs-dom
1316 , http-client
1417 , http-reverse-proxy
1518 , http-types
@@ -17,16 +20,24 @@ library
1720 , jsaddle-warp
1821 , lens
1922 , modern-uri
23+ , mtl
2024 , network
25+ , obelisk-backend
2126 , obelisk-executable-config
27+ , obelisk-frontend
28+ , obelisk-route
2229 , process
30+ , ref-tf
31+ , reflex
2332 , reflex-dom
33+ , snap-core
2434 , streaming-commons
2535 , text
36+ , universe
2637 , wai
2738 , wai-websockets
2839 , warp
2940 , websockets
3041 exposed-modules:
3142 Obelisk.Run
32− ghc-options: -Wall
43+ ghc-options: -Wall -fwarn-redundant-constraints
lib/run/src/Obelisk/Run.hs+70−28
@@ -1,20 +1,43 @@
1+{-# LANGUAGE ConstraintKinds #-}
2+{-# LANGUAGE EmptyCase #-}
3+{-# LANGUAGE EmptyDataDecls #-}
4+{-# LANGUAGE FlexibleContexts #-}
5+{-# LANGUAGE FlexibleInstances #-}
6+{-# LANGUAGE GADTs #-}
7+{-# LANGUAGE KindSignatures #-}
18{-# LANGUAGE LambdaCase #-}
9+{-# LANGUAGE MultiParamTypeClasses #-}
210{-# LANGUAGE OverloadedStrings #-}
11+{-# LANGUAGE Rank2Types #-}
12+{-# LANGUAGE RecursiveDo #-}
313{-# LANGUAGE ScopedTypeVariables #-}
14+{-# LANGUAGE TemplateHaskell #-}
15+{-# LANGUAGE TypeFamilies #-}
16+{-# LANGUAGE UndecidableInstances #-}
17+{-# OPTIONS_GHC -fno-warn-orphans #-} -- Due to instance HasJS x (EventWriterT t w m)
418module Obelisk.Run where
519
20+import Prelude hiding ((.), id)
21+
22+import Control.Category
623import Control.Concurrent
724import Control.Exception
825import Control.Lens ((^?), _Just, _Right)
926import qualified Data.Attoparsec.ByteString.Char8 as A
1027import Data.ByteString (ByteString)
1128import qualified Data.ByteString.Char8 as BSC
29+import qualified Data.ByteString.Lazy as LBS
1230import qualified Data.ByteString.Lazy.Char8 as BSLC
31+import Data.Dependent.Sum (DSum (..))
32+import Data.Functor.Identity
33+import Data.Functor.Sum
1334import Data.List (uncons)
1435import Data.Maybe
1536import Data.Semigroup ((<>))
1637import Data.Streaming.Network (bindPortTCP)
38+import Data.Text (Text)
1739import qualified Data.Text as T
40+import Data.Text.Encoding
1841import Language.Javascript.JSaddle.Run (syncPoint)
1942import Language.Javascript.JSaddle.WebSockets
2043import Network.HTTP.Client (Manager, defaultManagerSettings, newManager)
@@ -25,10 +48,11 @@ import Network.Wai (Application)
2548import qualified Network.Wai as W
2649import Network.Wai.Handler.Warp
2750import Network.Wai.Handler.Warp.Internal (settingsHost, settingsPort)
28−import Network.Wai.Handler.WebSockets (isWebSocketsReq)
2951import Network.WebSockets (ConnectionOptions)
3052import Network.WebSockets.Connection (defaultConnectionOptions)
3153import Obelisk.ExecutableConfig (get)
54+import Obelisk.Frontend
55+import Obelisk.Route.Frontend
3256import Reflex.Dom.Core
3357import System.Environment
3458import System.IO
@@ -36,17 +60,31 @@ import System.Process
3660import Text.URI (URI)
3761import qualified Text.URI as URI
3862import Text.URI.Lens
63+import Snap.Core (writeText)
64+import Obelisk.Backend
3965
40−run :: Int -- ^ Port to run the backend
41− -> IO () -- ^ Backend
42− -> (StaticWidget () (), Widget () ()) -- ^ Frontend widget (head, body)
43− -> IO ()
66+run
67+ :: Int -- ^ Port to run the backend
68+ -> Backend fullRoute frontendRoute -- ^ Backend
69+ -> Frontend (R frontendRoute) -- ^ Frontend
70+ -> IO ()
4471run port backend frontend = do
72+ prettifyOutput
4573 let handleBackendErr (e :: IOException) = hPutStrLn stderr $ "backend stopped; make a change to your code to reload - error " <> show e
46− backendTid <- forkIO $ handle handleBackendErr $ withArgs ["--quiet", "--port", show port] backend
47− putStrLn $ "Backend running on port " <> show port
48− let conf = defRunConfig { _runConfig_redirectPort = port }
49− runWidget conf frontend `finally` killThread backendTid
74+ --TODO: Use Obelisk.Backend.runBackend; this will require separating the checking and running phases
75+ case checkEncoder $ _backend_routeEncoder backend of
76+ Left e -> hPutStrLn stderr $ "backend error:\n" <> T.unpack e
77+ Right validFullEncoder -> do
78+ backendTid <- forkIO $ handle handleBackendErr $ withArgs ["--quiet", "--port", show port] $ do
79+ _backend_run backend $ \serveRoute -> do
80+ runSnapWithCommandLineArgs $ do
81+ getRouteWith validFullEncoder >>= \case
82+ Left e -> writeText e
83+ Right r -> case r of
84+ InL backendRoute :=> Identity a -> serveRoute $ backendRoute :/ a
85+ InR obeliskRoute :=> Identity a -> serveDefaultObeliskApp frontend $ obeliskRoute :/ a
86+ let conf = defRunConfig { _runConfig_redirectPort = port }
87+ runWidget conf frontend validFullEncoder `finally` killThread backendTid
5088
5189getConfigRoute :: IO (Maybe URI)
5290getConfigRoute = get "common/route" >>= \case
@@ -60,8 +98,8 @@ getConfigRoute = get "common/route" >>= \case
6098defAppUri :: URI
6199defAppUri = fromMaybe (error "defAppUri") $ URI.mkURI "http://127.0.0.1:8000"
62100
63−runWidget :: RunConfig -> (StaticWidget () (), Widget () ()) -> IO ()
64−runWidget conf (h, b) = do
101+runWidget :: RunConfig -> Frontend (R route) -> ValidEncoder (Either Text) (R (Sum backendRoute (ObeliskRoute route))) PageName -> IO ()
102+runWidget conf frontend validFullEncoder = do
65103 uri <- fromMaybe defAppUri <$> getConfigRoute
66104 let port = fromIntegral $ fromMaybe 80 $ uri ^? uriAuthority . _Right . authPort . _Just
67105 redirectHost = _runConfig_redirectHost conf
@@ -74,31 +112,35 @@ runWidget conf (h, b) = do
74112 close
75113 (\skt -> do
76114 man <- newManager defaultManagerSettings
77− app <- obeliskApp defaultConnectionOptions h b (fallbackProxy redirectHost redirectPort man)
115+ app <- obeliskApp defaultConnectionOptions frontend validFullEncoder $ fallbackProxy redirectHost redirectPort man
78116 runSettingsSocket settings skt app)
79117
80−obeliskApp :: ConnectionOptions -> StaticWidget () () -> Widget () () -> Application -> IO Application
81−obeliskApp opts h b backend = do
82− html <- BSLC.fromStrict <$> indexHtml h
83− let entryPoint = mainWidget' b >> syncPoint
84− jsaddle <- jsaddleOr opts entryPoint $ \req sendResponse -> case (W.requestMethod req, W.pathInfo req) of
85− ("GET", []) -> sendResponse $ W.responseLBS H.status200 [("Content-Type", "text/html")] html
86− ("GET", ["jsaddle.js"]) -> sendResponse $ W.responseLBS H.status200 [("Content-Type", "application/javascript")] $ jsaddleJs False
87− _ -> backend req sendResponse
88− -- Workaround jsaddleOr wanting to handle all websockets requests without
89− -- having a chance for run to proxy non jsaddle websocket requests to the
90− -- backend.
91− return $ \req sendResponse -> do
92− if isWebSocketsReq req && not (null $ W.pathInfo req)
93− then backend req sendResponse
94− else jsaddle req sendResponse
118+obeliskApp :: forall route backendRoute. ConnectionOptions -> Frontend (R route) -> ValidEncoder (Either Text) (R (Sum backendRoute (ObeliskRoute route))) PageName -> Application -> IO Application
119+obeliskApp opts frontend validFullEncoder backend = do
120+ let entryPoint = do
121+ runFrontend validFullEncoder frontend
122+ syncPoint
123+ Right (jsaddleWarpRouteValidEncoder :: ValidEncoder (Either Text) (R JSaddleWarpRoute) PageName) <- return $ checkEncoder jsaddleWarpRouteEncoder
124+ jsaddle <- jsaddleWithAppOr opts entryPoint $ \_ sendResponse -> sendResponse $ W.responseLBS H.status500 [("Content-Type", "text/plain")] "obeliskApp: jsaddle got a bad URL"
125+ return $ \req sendResponse -> case _validEncoder_decode validFullEncoder (W.pathInfo req, mempty) of --TODO: Query strings
126+ Left e -> sendResponse $ W.responseLBS H.status404 [("Content-Type", "text/plain")] $ LBS.fromStrict $ encodeUtf8 e
127+ Right r -> case r of
128+ InR (ObeliskRoute_Resource ResourceRoute_JSaddleWarp) :=> Identity jsaddleRoute -> case jsaddleRoute of
129+ JSaddleWarpRoute_JavaScript :/ () -> sendResponse $ W.responseLBS H.status200 [("Content-Type", "application/javascript")] $ jsaddleJs' (Just "http://localhost:8000/jsaddle") False
130+ _ -> flip jsaddle sendResponse $ req
131+ { W.pathInfo = fst $ _validEncoder_encode jsaddleWarpRouteValidEncoder jsaddleRoute
132+ }
133+ InR (ObeliskRoute_App appRouteComponent) :=> Identity appRouteRest -> do
134+ html <- fmap BSLC.fromStrict $ indexHtml $ fmap fst $ runEventWriterT $ flip runRoutedT (pure $ appRouteComponent :/ appRouteRest) $ _frontend_head frontend
135+ sendResponse $ W.responseLBS H.status200 [("Content-Type", "text/html")] html
136+ _ -> backend req sendResponse
95137
96138indexHtml :: StaticWidget () () -> IO ByteString
97139indexHtml h = do
98140 ((), bs) <- renderStatic $ el "html" $ do
99141 el "head" $ h
100142 el "body" $ return ()
101− elAttr "script" ("src" =: "/jsaddle.js") $ return ()
143+ elAttr "script" ("src" =: "/jsaddle/jsaddle.js") $ return ()
102144 return $ "<!DOCTYPE html>" <> bs
103145
104146-- | like 'bindPortTCP' but reconnects on exception
lib/selftest/obelisk-selftest.cabal+2−1
@@ -6,7 +6,6 @@ build-type: Simple
66cabal-version: >=1.8
77
88library
9− default-language: Haskell2010
109 hs-source-dirs: src
1110 build-depends:
1211 base
@@ -16,6 +15,7 @@ library
1615 , http-client
1716 , http-types
1817 , HUnit
18+ , obelisk-cliapp
1919 , obelisk-run
2020 , process
2121 , network
@@ -24,6 +24,7 @@ library
2424 , text
2525 exposed-modules:
2626 Obelisk.SelfTest
27+ ghc-options: -Wall
2728
2829executable obelisk-selftest
2930 main-is: src-bin/obelisk-selftest.hs
lib/selftest/src/Obelisk/SelfTest.hs+68−40
@@ -1,14 +1,15 @@
11{-# LANGUAGE LambdaCase #-}
22{-# LANGUAGE OverloadedStrings #-}
3+{-# LANGUAGE PatternGuards #-}
4+{-# LANGUAGE ScopedTypeVariables #-}
35{-# LANGUAGE TypeApplications #-}
46module Obelisk.SelfTest where
57
6−import Control.Exception (Exception, throw, bracket)
8+import Control.Exception (bracket, throw)
79import Control.Monad
810import Control.Monad.IO.Class
911import Data.Bool (bool)
1012import Data.Function (fix)
11−import Data.Maybe (isJust)
1213import Data.Semigroup (Semigroup, (<>))
1314import qualified Data.Set as Set
1415import Data.String
@@ -21,15 +22,17 @@ import qualified Network.Socket as Socket
2122import Shelly
2223import System.Directory (withCurrentDirectory)
2324import System.Environment
24−import System.Exit (ExitCode (..), exitWith)
25+import System.Exit (ExitCode (..))
2526import System.Info
2627import System.IO (Handle, hClose)
2728import System.IO.Temp
29+import System.Process (readProcessWithExitCode)
2830import System.Timeout
2931import Test.Hspec
3032import Test.HUnit.Base
31−import System.Process (readProcessWithExitCode)
3233
34+import Obelisk.CliApp hiding (runCli)
35+import qualified Obelisk.CliApp as CliApp
3336import Obelisk.Run (getConfigRoute)
3437
3538data ObRunState
@@ -50,18 +53,44 @@ commit msg = void $ run "git"
5053 , doubleQuotes msg
5154 ]
5255
56+runCli :: MonadIO m => CliT IO a -> m a
57+runCli f = liftIO $ do
58+ c <- newCliConfig Notice False False
59+ CliApp.runCli c f
60+
61+tshow :: Show a => a -> Text
62+tshow = T.pack . show
63+
64+-- | Like `shelly` but used when running `ob` commands
65+--
66+-- This tests the NIX_REMOTE workaround, whether that gets printed
67+-- on failure, and whether retrying with the suggested workaround
68+-- leads to the test succeeding.
69+shellyOb :: MonadIO m => (Sh a -> Sh a) -> Sh a -> m a
70+shellyOb f obTest = shelly $ f $ do
71+ obTest `catch_sh` \(e :: RunFailed) -> do
72+ obStderr <- lastStderr
73+ if T.isInfixOf "export NIX_REMOTE" obStderr
74+ then do
75+ runCli $ do
76+ putLog Notice "Detected NIX_REMOTE suggestion in stderr of failed ob"
77+ putLog Warning "Retrying this test with NIX_REMOTE=daemon"
78+ setenv "NIX_REMOTE" "daemon"
79+ f obTest
80+ else throw e
81+
5382main :: IO ()
5483main = do
55− isVerbose <- getArgs >>= \case
56− ["-v"] -> pure True
57− [] -> putStrLn "Tests may take longer to run if there are unbuilt derivations: use -v for verbose output" *> pure False
58− _ -> putStrLn "Usage: selftest [-v]" *> exitWith (ExitFailure 2)
84+ -- Note: you can pass hspec arguments as well, eg: `-m <pattern>`
85+ isVerbose <- (elem "-v") <$> getArgs
86+ unless isVerbose $
87+ putStrLn "Tests may take longer to run if there are unbuilt derivations: use -v for verbose output"
5988 let verbosity = bool silently verbosely isVerbose
6089 obeliskImpl <- fromString <$> getEnv "OBELISK_IMPL"
6190 httpManager <- HTTP.newManager HTTP.defaultManagerSettings
6291 withSystemTempDirectory "blank-project" $ \blankProject ->
6392 hspec $ do
64− let shelly_ = void . shelly . verbosity
93+ let shelly_ = void . shellyOb verbosity
6594
6695 inProj :: Sh a -> IO ()
6796 inProj = shelly_ . chdir (fromString blankProject)
@@ -75,7 +104,7 @@ main = do
75104 revParseHead = T.strip <$> run "git" ["rev-parse", "HEAD"]
76105
77106 commitAll = do
78− run "git" ["add", "."]
107+ run_ "git" ["add", "."]
79108 commit "checkpoint"
80109 revParseHead
81110
@@ -92,31 +121,32 @@ main = do
92121 it "works with symlink" $ inTmp $ \_ -> run "ob" ["init", "--symlink", obeliskImpl]
93122
94123 it "doesn't create anything when given an invalid impl" $ inTmp $ \tmp -> do
95− errExit False $ run "ob" ["init", "--symlink", "/dev/null"]
124+ void $ errExit False $ run "ob" ["init", "--symlink", "/dev/null"]
96125 ls tmp >>= liftIO . assertEqual "" []
97126
98127 it "produces a valid route config" $ inTmp $ \tmp -> do
99− run "ob" ["init"]
128+ run_ "ob" ["init"]
100129 liftIO $ withCurrentDirectory (T.unpack $ toTextIgnore tmp) $ getConfigRoute `shouldNotReturn` Nothing
101130
102131 describe "ob run" $ do
103132 it "works in root directory" $ inTmp $ \_ -> do
104− run "ob" ["init"]
133+ run_ "ob" ["init"]
105134 testObRunInDir Nothing httpManager
106135 it "works in sub directory" $ inTmp $ \_ -> do
107− run "ob" ["init"]
136+ run_ "ob" ["init"]
108137 testObRunInDir (Just "frontend") httpManager
109138
110139 describe "obelisk project" $ parallel $ do
111140 it "can build obelisk command" $ shelly_ $ run "nix-build" ["-A", "command" , obeliskImpl]
112141 it "can build obelisk skeleton" $ shelly_ $ run "nix-build" ["-A", "skeleton", obeliskImpl]
113142 it "can build obelisk shell" $ shelly_ $ run "nix-build" ["-A", "shell", obeliskImpl]
114− it "can build everything" $ shelly_ $ run "nix-build" [obeliskImpl]
143+ -- See https://github.com/obsidiansystems/obelisk/issues/101
144+ -- it "can build everything" $ shelly_ $ run "nix-build" [obeliskImpl]
115145
116146 describe "blank initialized project" $ do
117147 it "can be created" $ inProj $ do
118− run "ob" ["init"]
119− run "git" ["init"]
148+ run_ "ob" ["init"]
149+ run_ "git" ["init"]
120150 commitAll
121151
122152 it "can build ghc.backend" $ inProj $ do
@@ -131,7 +161,7 @@ main = do
131161 forM_ ["ghc", "ghcjs"] $ \compiler -> do
132162 let
133163 shell = "shells." <> compiler
134− inShell cmd = run "nix-shell" ["-A", fromString shell, "--run", cmd]
164+ inShell cmd' = run "nix-shell" ["-A", fromString shell, "--run", cmd']
135165 it ("can enter " <> shell) $ inProj $ inShell "exit"
136166 it ("can build in " <> shell) $ inProj $ inShell $ "cabal new-build --" <> fromString compiler <> " all"
137167
@@ -156,15 +186,15 @@ main = do
156186 assertRevNE e eu
157187
158188 it "can pack and unpack plain git repos" $ do
159− withSystemTempDirectory "git-repo" $ \dir -> shelly_ $ do
189+ shelly_ $ withSystemTempDirectory "git-repo" $ \dir -> do
160190 let repo = toTextIgnore $ dir </> ("repo" :: String)
161− run_ "git" ["clone", "https://git.haskell.org/packages/primitive.git", repo]
191+ run_ "git" ["clone", "https://github.com/haskell/process.git", repo]
162192 origHash <- chdir (fromText repo) revParseHead
163193
164194 run_ "ob" ["thunk", "pack", repo]
165195 packedFiles <- Set.fromList <$> ls (fromText repo)
166− liftIO $ assertEqual "" packedFiles $
167− Set.fromList $ (repo </>) <$> ["default.nix", "git.json", ".attr-cache" :: String]
196+ liftIO $ assertEqual "" packedFiles $ Set.fromList $ (repo </>) <$>
197+ ["default.nix", "github.json", ".attr-cache" :: String]
168198
169199 run_ "ob" ["thunk", "unpack", repo]
170200 chdir (fromText repo) $ do
@@ -174,7 +204,7 @@ main = do
174204 testThunkPack $ fromText repo
175205
176206 it "aborts thunk pack when there are uncommitted files" $ inProj $ do
177− unpack
207+ void $ unpack
178208 testThunkPack (blankProject </> thunk)
179209
180210-- | Run `ob run` in the given directory (maximum of one level deep)
@@ -191,33 +221,31 @@ testObRunInDir mdir httpManager = handle_sh (\case ExitSuccess -> pure (); e ->
191221 maybe id (\_ -> chdir "..") mdir $ alterRouteTo newUri stdout
192222 (_, runningUri) <- handleObRunStdout httpManager stdout
193223 if runningUri /= newUri
194− then errorExit $ "Reloading failed: expected " <> newUri <> " but got " <> runningUri
195− else exit 0
224+ then errorExit $ "Reloading failed: expected " <> newUri <> " but got " <> runningUri
225+ else exit 0
196226
197227testThunkPack :: Shelly.FilePath -> Sh ()
198−testThunkPack path = withTempFile (T.unpack $ toTextIgnore path) "test-file" $ \file handle -> do
199− let try_sh :: Exception e => Sh a -> Sh (Either e a)
200− try_sh a = catch_sh (a >>= pure . Right) (pure . Left)
201− pack' = readProcessWithExitCode "ob" ["thunk", "pack", T.unpack $ toTextIgnore path] ""
202− ensureThunkPackFails err = liftIO $ pack' >>= \case
203− (code, out, _err)
228+testThunkPack path' = withTempFile (T.unpack $ toTextIgnore path') "test-file" $ \file handle -> do
229+ let pack' = readProcessWithExitCode "ob" ["thunk", "pack", T.unpack $ toTextIgnore path'] ""
230+ ensureThunkPackFails q = liftIO $ pack' >>= \case
231+ (code, out, err)
204232 | code == ExitSuccess -> fail "ob thunk pack succeeded when it should have failed"
205− | err `T.isInfixOf` T.pack out -> pure ()
206− | otherwise -> fail $ "ob thunk pack failed for an unexpected reason: " <> show out
207− git = chdir path . run "git"
233+ | q `T.isInfixOf` T.pack (out <> err) -> pure ()
234+ | otherwise -> fail $ "ob thunk pack failed for an unexpected reason: " <> show out <> "\nstderr: " <> err
235+ git = chdir path' . run "git"
208236 -- Untracked files
209− ensureThunkPackFails "unsaved modifications"
210− git ["add", T.pack file]
237+ ensureThunkPackFails "Untracked files"
238+ void $ git ["add", T.pack file]
211239 -- Uncommitted files (staged)
212− ensureThunkPackFails "unsaved modifications"
213− chdir path $ commit "test commit"
240+ ensureThunkPackFails "unsaved"
241+ chdir path' $ commit "test commit"
214242 -- Non-pushed commits in any branch
215243 ensureThunkPackFails "not been pushed"
216244 -- Uncommitted files (unstaged)
217245 liftIO $ T.hPutStrLn handle "test file" >> hClose handle
218− ensureThunkPackFails "unsaved modifications"
246+ ensureThunkPackFails "modified"
219247 -- Existing stashes
220− git ["stash"]
248+ void $ git ["stash"]
221249 ensureThunkPackFails "has stashes"
222250
223251-- | Blocks until a non-empty line is available
migration/07900e6c41ab9a78917f66fcf7e0d9b8-8c84f81d9b09a5e0f028f3c6341ffb36/obelisk-handoffnew file

No changes to the content.

migration/07900e6c41ab9a78917f66fcf7e0d9b8-8c84f81d9b09a5e0f028f3c6341ffb36/obelisk-upgradenew file+15
@@ -0,0 +1,15 @@
1+# ob deploy: HTTPs support
2+
3+Obelisk deployments now support HTTPs. You must upgrade the deployment repo
4+associated with the obelisk project as follows:
5+
6+```
7+cd /your/project's/deployment/repo
8+echo True > enable_https
9+echo admin@yoursite.com > admin_email
10+echo https://yoursite.com/ > config/common/route
11+git add ... && git commit ...
12+ob deploy push
13+```
14+
15+If you do not wish to enable HTTPs, write `False` to `enable_https` instead.
migration/554c459ff2cced3e1b11241ed403890d-3543ea607791bc2e19419d22918823bd/obelisk-handoffnew file

No changes to the content.

migration/554c459ff2cced3e1b11241ed403890d-3543ea607791bc2e19419d22918823bd/obelisk-upgradenew file

No changes to the content.

migration/554c459ff2cced3e1b11241ed403890d-6a18939264224d6ce24c7426249edfea/obelisk-handoffnew file

No changes to the content.

migration/554c459ff2cced3e1b11241ed403890d-6a18939264224d6ce24c7426249edfea/obelisk-upgradenew file

No changes to the content.

migration/6a18939264224d6ce24c7426249edfea-e66afc8befac4d76b5780a960f768c31/obelisk-handoffnew file

No changes to the content.

migration/6a18939264224d6ce24c7426249edfea-e66afc8befac4d76b5780a960f768c31/obelisk-upgradenew file+15
@@ -0,0 +1,15 @@
1+# ob deploy: HTTPs support
2+
3+Obelisk deployments now support HTTPs. You must upgrade the deployment repo
4+associated with the obelisk project as follows:
5+
6+```
7+cd /your/project's/deployment/repo
8+echo True > enable_https
9+echo admin@yoursite.com > admin_email
10+echo https://yoursite.com/ > config/common/route
11+git add ... && git commit ...
12+ob deploy push
13+```
14+
15+If you do not wish to enable HTTPs, write `False` to `enable_https` instead.
release.nix+5−3
@@ -26,14 +26,16 @@ with pkgs;
2626 nix # obelisk uses `nix-hash`
2727 ];
2828 } ''
29− set -xe
29+ set -e
3030 # Fix permissions so `removePathForcibly` works on copied-to-tmp paths
3131 cp -a ${self.pathGit} $TMPDIR/obelisk
3232 cd $TMPDIR/obelisk
3333 chmod -R u+w .
3434
35− ob internal hash
36− ob internal verify-migration
35+ (set -x;
36+ ob internal hash;
37+ ob internal verify-migration;
38+ )
3739
3840 mkdir -p $out
3941 touch $out/done
skeleton/.obelisk/.gitignorenew file+3
@@ -0,0 +1,3 @@
1+/impl
2+!/impl/default.nix
3+!/impl/github.json