on master help sign in
Bring obelisk-asset into this repo, with improvements
e612f62Ryan Trinkle
default.nix+17−6
@@ -28,7 +28,7 @@ let #TODO: Upstream
28
28
29 # The haskell environment used to build Obelisk itself, e.g. the 'ob' command
29 # The haskell environment used to build Obelisk itself, e.g. the 'ob' command
30 ghcObelisk = reflex-platform.ghc.override {
30 ghcObelisk = reflex-platform.ghc.override {
31− overrides = composeExtensions addLibs (self: super: {
31+ overrides = composeExtensions defaultHaskellOverrides (self: super: {
32 mkDerivation = args: super.mkDerivation (args // {
32 mkDerivation = args: super.mkDerivation (args // {
33 enableLibraryProfiling = profiling;
33 enableLibraryProfiling = profiling;
34 });
34 });
@@ -53,14 +53,21 @@ let #TODO: Upstream
53 });
53 });
54 };
54 };
55
55
56+ fixUpstreamPkgs = self: super: {
57+ heist = doJailbreak super.heist; #TODO: Move up to reflex-platform; create tests for r-p supported packages
58+ };
59+
56 addLibs = self: super: {
60 addLibs = self: super: {
57− obelisk-asset-serve = self.callCabal2nix "obelisk-asset-serve" (hackGet ./lib/asset + "/serve") {};
58 obelisk-asset-manifest = self.callCabal2nix "obelisk-asset-manifest" (hackGet ./lib/asset + "/manifest") {};
61 obelisk-asset-manifest = self.callCabal2nix "obelisk-asset-manifest" (hackGet ./lib/asset + "/manifest") {};
62+ obelisk-asset-serve-snap = self.callCabal2nix "obelisk-asset-serve-snap" (hackGet ./lib/asset + "/serve-snap") {};
59 obelisk-backend = self.callCabal2nix "obelisk-backend" ./lib/backend {};
63 obelisk-backend = self.callCabal2nix "obelisk-backend" ./lib/backend {};
60− obelisk-snap = self.callCabal2nix "obelisk-snap" ./lib/snap {};
61− obelisk-selftest = self.callCabal2nix "obelisk-selftest" ./lib/selftest {};
62 obelisk-command = self.callCabal2nix "obelisk-command" ./lib/command {};
64 obelisk-command = self.callCabal2nix "obelisk-command" ./lib/command {};
65+ obelisk-selftest = self.callCabal2nix "obelisk-selftest" ./lib/selftest {};
66+ obelisk-snap = self.callCabal2nix "obelisk-snap" ./lib/snap {};
67+ obelisk-snap-extras = self.callCabal2nix "obelisk-snap-extras" ./lib/snap-extras {};
63 };
68 };
69+
70+ defaultHaskellOverrides = composeExtensions fixUpstreamPkgs addLibs;
64in
71in
65with pkgs.lib;
72with pkgs.lib;
66rec {
73rec {
@@ -113,10 +120,9 @@ rec {
113 };
120 };
114 combinedPackages = predefinedPackages // packages;
121 combinedPackages = predefinedPackages // packages;
115 projectOverrides = self: super: {
122 projectOverrides = self: super: {
116− heist = doJailbreak super.heist; #TODO: Move up to reflex-platform; create tests for r-p supported packages
117 ${staticName} = dontHaddock (self.callCabal2nix "static" assets.haskellManifest {});
123 ${staticName} = dontHaddock (self.callCabal2nix "static" assets.haskellManifest {});
118 };
124 };
119− overrides = composeExtensions addLibs projectOverrides;
125+ overrides = composeExtensions defaultHaskellOverrides projectOverrides;
120 in {
126 in {
121 inherit overrides;
127 inherit overrides;
122 packages = combinedPackages;
128 packages = combinedPackages;
@@ -145,4 +151,9 @@ rec {
145 };
151 };
146 };
152 };
147 in mkProject (projectDefinition args));
153 in mkProject (projectDefinition args));
154+ haskellPackageSets = {
155+ ghc = reflex-platform.ghc.override {
156+ overrides = defaultHaskellOverrides;
157+ };
158+ };
148}
159}
lib/asset/README.mdnew file+29
@@ -0,0 +1,29 @@
1+## obelisk-asset
2+
3+The 'assets.nix' file contains nix expressions that are a mutually
4+recursive set of attributes used to create static asset directories
5+with hashable file encodings. This file should be incorporated within
6+your project's 'default.nix' file. (See ./example/default.nix , line 2)
7+```
8+{assets ? import ./../assets.nix { inherit nixpkgs; }
9+}:
10+```
11+
12+The 'mkAssets' and 'mkAssetsWith' functions within 'assets.nix' only
13+need to be passed an encoding('zopfliEncodings', 'gzipEncodings',
14+'noEncodings') and a static directory of your choice as arguments.
15+
16+In this example, 'mkAssets' is used on a directory that has a .png
17+file inside of it. (See ./example/default.nix, line 11)
18+```
19+myAssets = assets.mkAssets ./static;
20+```
21+
22+Once you have successfully incorporated 'assets.nix' into your
23+project's nix file(s), use nix-build to generate a symlink of
24+your 'mkAssets' expression. The following command from
25+example/run-example (line 3) will generate an immutable symlink
26+that holds hashed static assets.
27+```bash
28+nix-build -o static.assets -A myAssets
29+```
lib/asset/assets.nixnew file+251
@@ -0,0 +1,251 @@
1+# Asset generation pipeline using Nix, which generates a directory structure to be served via obelisk-asset-serve-*
2+{ nixpkgs }:
3+
4+with nixpkgs.lib;
5+
6+rec {
7+
8+# Default encoding generation function for this platform; usually zopfliEncodings, but gzipEncodings on darwin due to zopfli not building on darwin.
9+#
10+# defaultEncodings :: String -> Derivation
11+defaultEncodings =
12+ # zopfli isn't supported on macOS as of nixpkgs eafd703a63
13+ if nixpkgs.stdenv.isDarwin then gzipEncodings else zopfliEncodings;
14+
15+# Encoding generation function which uses zopfli to encode the asset with very high compression efficiency, at the cost of CPU time compressing.
16+# Generates gzip, compress/zlib, and deflate outputs all using zopfli with 5 iterations.
17+#
18+# zopfliEncodings :: String -> Derivation
19+zopfliEncodings = file:
20+ nixpkgs.stdenv.mkDerivation {
21+ name = "encodings";
22+
23+ input = file;
24+
25+ builder = builtins.toFile "builder.sh" ''
26+ source "$stdenv/setup"
27+
28+ mkdir -p "$out"
29+
30+ ln -s "$input" "$out/identity"
31+
32+ zopfli -c --i5 --gzip "$input" >"$out/gzip"
33+ zopfli -c --i5 --zlib "$input" >"$out/compress"
34+ zopfli -c --i5 --deflate "$input" >"$out/deflate"
35+ '';
36+
37+ buildInputs = [
38+ nixpkgs.zopfli
39+ ];
40+ };
41+
42+# Encoding generation function which uses gzip to encode the asset with decent compression efficiency and a small CPU cost. Only generates a gzip output.
43+#
44+# gzipEncodings :: String -> Derivation
45+gzipEncodings = file:
46+ nixpkgs.stdenv.mkDerivation {
47+ name = "encodings";
48+
49+ input = file;
50+
51+ builder = builtins.toFile "builder.sh" ''
52+ source "$stdenv/setup"
53+
54+ mkdir -p "$out"
55+
56+ ln -s "$input" "$out/identity"
57+
58+ gzip -c7 "$input" > "$out/gzip"
59+ '';
60+
61+ buildInputs = [
62+ nixpkgs.gzip
63+ ];
64+ };
65+
66+# Encoding generation function which doesn't do any compression.
67+#
68+# noEncodings :: String -> Derivation
69+noEncodings = file:
70+ nixpkgs.stdenv.mkDerivation {
71+ name = "encodings";
72+
73+ input = file;
74+
75+ builder = builtins.toFile "builder.sh" ''
76+ source "$stdenv/setup"
77+
78+ mkdir -p "$out"
79+
80+ ln -s "$input" "$out/identity"
81+ '';
82+
83+ buildInputs = [
84+ ];
85+ };
86+
87+# Convert an attrset into a list of name/value pairs.
88+#
89+# setToList :: AttrSet a -> [{ name :: String, value :: a }]
90+setToList = mapAttrsToList (name: value: { inherit name value; });
91+
92+# Apply some function (chunkDerivations, for example) to the values in a list of name/value pairs then reattach the names.
93+#
94+# mapSnds :: ([b] -> [b']) -> [{ fst :: a, snd :: b }] -> [{ fst :: a, snd :: b' }]
95+mapSnds = f: l: zipLists (map (x: x.fst) l) (f (map (x: x.snd) l));
96+
97+# Chunk derivations in groups of up to a given size to avoid https://github.com/NixOS/nix/issues/875
98+#
99+# It works by creating a dependent chain of derivations that must be built to finish evaluation of the overall nix expression, without evaluating too many.
100+# Based on the current operation of nix, n should not be greater than 1012, and should probably be substantially less.
101+#
102+# chunkDerivations :: Int -> [a] -> [a]
103+chunkDerivations = n: l:
104+ let a = take n l;
105+ b = drop n l;
106+ go = x: if x == [] then builtins.toFile "emptyList.nix" "[]" else chunkDerivations2 n (take n x) (go (drop n x));
107+
108+ # makeChunkDerivation :: Int -> [a] -> ExprFile [a] -> ExprFile [a]
109+ makeChunkDerivation = chunk': next:
110+ let chunk = map (path: "(import ${builtins.unsafeDiscardOutputDependency path.drvPath}).${path.outputName}") chunk';
111+ in builtins.seq (builtins.toPath next) (nixpkgs.stdenv.mkDerivation {
112+ name = "chunkDerivations";
113+ inherit chunk next;
114+ preferLocalBuild = true;
115+ builder = builtins.toFile "chunkDerivations.sh" ''
116+ source "$stdenv/setup"
117+
118+ echo $chunk
119+
120+ echo "[ $chunk ] ++ (import $next)" >"$out"
121+ '';
122+ });
123+ in a ++ import (go b);
124+
125+# Recursively map some function over a @AttrSet DirEntry@, unioning the results of the application at each level.
126+#
127+# unionMapFilesWithName :: ({name :: String, value :: DirEntry} -> [{name :: String, value :: a}]) -> AttrSet DirEntry -> AttrSet DirEntry
128+unionMapFilesWithName = f: d:
129+ let go = {name, value}:
130+ if value.type == "directory" then [{
131+ inherit name;
132+ value = {
133+ inherit (value) type;
134+ contents = unionMapFilesWithName f value.contents;
135+ };
136+ }] else setToList (f {
137+ inherit name value;
138+ });
139+ in builtins.listToAttrs (builtins.concatLists (map go (setToList d)));
140+
141+# Read a directory structure recursively into an @AttrSet DirEntry@.
142+#
143+# readDirRecursive :: String -> AttrSet DirEntry
144+readDirRecursive = dir:
145+ let d = filterAttrs (n: d: !(hasPrefix "." n)) (builtins.readDir dir);
146+ go = name:
147+ let path = dir + "/${name}";
148+ type = d.${name};
149+ in if type == "directory" then {
150+ inherit name;
151+ value = {
152+ inherit type;
153+ contents = readDirRecursive path;
154+ };
155+ } else {
156+ inherit name;
157+ value = {
158+ inherit type path;
159+ };
160+ };
161+ in builtins.listToAttrs (map go (builtins.attrNames d));
162+
163+# Render a string as a double quoted string with any internal double quotes or backslashes escaped using backslash, appropriate for a nix expression.
164+#
165+# doubleQuoteString :: String -> String
166+doubleQuoteString = s: "\"" + builtins.replaceStrings ["\\" "\""] ["\\\\" "\\\""] s + "\"";
167+
168+# Given an @AttrSet DirEntry@ generate a derivation with symlink entries for each @DirEntry@ given.
169+#
170+# dirToPath :: AttrSet DirEntry -> Derivation
171+dirToPath = contents:
172+ let pairsToBashArray = xs: builtins.concatStringsSep " " (map (np: "[" + doubleQuoteString np.fst + "]=" + doubleQuoteString (toString np.snd)) xs);
173+ files = pairsToBashArray (mapSnds (chunkDerivations 128) (mapAttrsToList (fst: entry: { inherit fst; snd = toPath entry; }) contents));
174+ filesIsLarge = builtins.stringLength files > 65536;
175+ in
176+ nixpkgs.stdenv.mkDerivation {
177+ name = "toPath";
178+ inherit files;
179+ passAsFile = optional filesIsLarge "files";
180+ preferLocalBuild = true;
181+ builder = builtins.toFile "builder.sh" ''
182+ set -e
183+ source "$stdenv/setup"
184+
185+ eval "declare -A files=(${if filesIsLarge then ''$(cat "$filesPath")'' else ''$files''})"
186+
187+ mkdir "$out"
188+
189+ for filename in "''${!files[@]}" ; do
190+ ln -s "''${files[$filename]}" "$out/$filename"
191+ done
192+ '';
193+ };
194+
195+# Given a @DirEntry@ either use 'dirToPath' to make a derivation with its contents or return the path directly.
196+toPath = x: if x.type == "directory" then dirToPath x.contents else x.path;
197+
198+# Given a file path, hash its contents as nix does and return the hash string.
199+hashFile = path:
200+ builtins.readFile (nixpkgs.runCommand "hashFile" {
201+ buildInputs = [
202+ nixpkgs.nix
203+ ];
204+ preferLocalBuild = true;
205+ inherit path;
206+ } ''
207+ nix-hash --flat --base32 --type sha256 "$path" | tr -d '\n' >"$out"
208+ '');
209+
210+# Build a DirEntry of the shape { type :: String, contents :: {DirEntry} } with type set to directory. Used to create a data model to pass to toPath.
211+#
212+# dir :: String -> DirEntry
213+dir = contents: {
214+ type = "directory";
215+ inherit contents;
216+};
217+
218+# Build a DirEntry of the shape { type :: String, path :: String } with type set to symlink. Used to create a data model to pass to toPath.
219+#
220+# symlink :: String -> DirEntry
221+symlink = path: {
222+ type = "symlink";
223+ inherit path;
224+};
225+
226+# Given a encoding generation function and a file entry resulting from readDirRecursive in the form { name :: String, value: { path :: String } },
227+# build a DirEntry for dirToPath with the various encodings of the asset for dirToPath to build into a final directory tree.
228+mkAsset = encodings: {name, value}:
229+ let nameWithHash = "${hashFile value.path}-${name}";
230+ in {
231+ ${nameWithHash} = dir {
232+ type = symlink (builtins.toFile "type" "immutable");
233+ encodings = symlink (encodings value.path);
234+ };
235+ ${name} = dir {
236+ type = symlink (builtins.toFile "type" "redirect");
237+ target = symlink (builtins.toFile "target" "${nameWithHash}");
238+ };
239+ };
240+
241+# Given an encoding generation function to use and a directory containing assets, recursively walk the directory and encode each asset.
242+#
243+# mkAssetsWith :: (String -> Derivation) -> String -> Derivation
244+mkAssetsWith = encodings: d: dirToPath (unionMapFilesWithName (mkAsset encodings) (readDirRecursive d));
245+
246+# Given an input directory containing assets, recursively walk the directory and encode each asset with the default encodings.
247+#
248+# mkAssets :: String -> Derivation
249+mkAssets = mkAssetsWith defaultEncodings;
250+
251+}
lib/asset/github.jsondeleted−7
@@ -1,7 +0,0 @@
1−{
2− "owner": "obsidiansystems",
3− "repo": "obelisk-asset",
4− "private": true,
5− "rev": "d65dece1176aa0137a83d3cee9f693698f555e24",
6− "sha256": "1x37h9vhsr9n6p5myyh3cm8j6rmwnkns6q6nz4c4dvyq61c5xw2q"
7−}
lib/asset/manifest/obelisk-asset-manifest.cabalnew file+43
@@ -0,0 +1,43 @@
1+Name: obelisk-asset-manifest
2+Version: 0.1
3+Synopsis: Project Synopsis Here
4+Description: Project Description Here
5+License: BSD3
6+Author: Obsidian Systems LLC
7+Maintainer: maintainer@obsidian.systems
8+Stability: Experimental
9+Category: Web
10+Build-type: Simple
11+Cabal-version: >= 1.8
12+
13+library
14+ hs-source-dirs: src
15+
16+ build-depends:
17+ base
18+ , bytestring
19+ , containers
20+ , directory
21+ , SHA
22+ , filepath
23+ , template-haskell
24+ , text
25+ , transformers
26+ , unix
27+ , vector
28+ exposed-modules:
29+ Obelisk.Asset.Gather
30+ Obelisk.Asset.Promoted
31+ Obelisk.Asset.Symlink
32+ Obelisk.Asset.TH
33+
34+ other-extensions: TemplateHaskell
35+ ghc-options: -Wall -Werror -fno-warn-unused-do-bind -fwarn-tabs -funbox-strict-fields -O2 -fprof-auto-calls
36+
37+executable obelisk-asset-manifest-generate
38+ hs-source-dirs: src-bin
39+ main-is: generate.hs
40+ build-depends:
41+ base
42+ , obelisk-asset-manifest
43+ , text
lib/asset/manifest/src-bin/generate.hsnew file+16
@@ -0,0 +1,16 @@
1+import qualified Data.Text as T
2+import Obelisk.Asset.Gather
3+import Obelisk.Asset.Promoted
4+import Obelisk.Asset.Symlink
5+import System.Environment
6+
7+main :: IO ()
8+main = do
9+ --TODO: Usage
10+ [root, haskellTarget, packageName, moduleName, fileTarget] <- getArgs
11+ paths <- gatherHashedPaths root
12+ writeStaticProject paths haskellTarget $ StaticConfig
13+ { _staticConfig_packageName = T.pack packageName
14+ , _staticConfig_moduleName = T.pack moduleName
15+ }
16+ copyAndSymlink paths root fileTarget
lib/asset/manifest/src/Obelisk/Asset/Gather.hsnew file+68
@@ -0,0 +1,68 @@
1+{-# LANGUAGE OverloadedStrings #-}
2+-- | Functions for collecting up files to be processed by the asset pipeline
3+module Obelisk.Asset.Gather
4+ ( gatherHashedPaths
5+ , toHashedPath
6+ ) where
7+
8+import Control.Monad (forM)
9+import Data.Bits
10+import qualified Data.ByteString.Lazy as LBS
11+import qualified Data.ByteString.Lazy.Builder as LBS
12+import Data.Char
13+import Data.Digest.Pure.SHA
14+import Data.Map (Map)
15+import qualified Data.Map as Map
16+import Data.Monoid
17+import qualified Data.Text as T
18+import Data.Text.Encoding
19+import qualified Data.Vector.Unboxed as UV
20+import Data.Word
21+import System.FilePath.Posix
22+import System.Directory
23+
24+-- | Given a path, recursively explore it, creating hashed paths for all files found
25+gatherHashedPaths
26+ :: FilePath -- ^ The root of the directory to process
27+ -> IO (Map FilePath FilePath) -- ^ A mapping of original paths to hashed paths (all relative to the root)
28+gatherHashedPaths root = go ""
29+ where
30+ go :: FilePath -> IO (Map FilePath FilePath)
31+ go subdir = do
32+ subs <- listDirectory $ root </> subdir
33+ fmap mconcat $ forM subs $ \sub -> do
34+ let relativePath = subdir </> sub
35+ isFile <- doesFileExist $ root </> relativePath
36+ if isFile
37+ then do hashedRelativePath <- toHashedPath root relativePath
38+ return $ Map.singleton relativePath hashedRelativePath
39+ else go relativePath
40+
41+-- | Given a root path and path relative to it, construct a hashed version of the relative path
42+toHashedPath
43+ :: FilePath -- ^ Root path
44+ -> FilePath -- ^ Relative path within root
45+ -> IO FilePath
46+toHashedPath root relativePath = do
47+ let path = root </> relativePath
48+ contents <- LBS.readFile path
49+ let hashPrefix = T.unpack $ decodeUtf8 $ LBS.toStrict $ toNixBase32 $ bytestringDigest $ sha256 contents
50+ (dir, filename) = splitFileName relativePath
51+ return $ normalise $ dir </> (hashPrefix <> "-" <> filename)
52+
53+-- | Convert a ByteString to base 32 in the way that Nix does
54+toNixBase32 :: LBS.ByteString -> LBS.ByteString
55+toNixBase32 x = LBS.toLazyByteString $ mconcat $ map (LBS.word8 . (symbols UV.!) . fromIntegral) vals
56+ where vals = byteStringToQuintets x
57+ symbols = UV.fromList $ map (fromIntegral . ord) $ filter (`notElem` ("eotu" :: String)) $ ['0'..'9'] <> ['a'..'z']
58+ -- See https://github.com/NixOS/nix/blob/6f1743b1a5116ca57a60b481ee4083c891b7a334/src/libutil/hash.cc#L109
59+ byteStringToQuintets :: LBS.ByteString -> [Word8]
60+ byteStringToQuintets hash = map f [len-1, len-2 .. 0]
61+ where hashSize = fromIntegral $ LBS.length hash
62+ len = (hashSize * 8 - 1) `div` 5 + 1
63+ f n = let b = n * 5
64+ (i, j) = b `divMod` 8
65+ j' = fromIntegral j
66+ --TODO: This is probably pretty slow; replace with something that doesn't use LBS.index
67+ c = ((hash `LBS.index` i) `shift` (-j')) .|. (if i >= hashSize - 1 then 0 else (hash `LBS.index` (i + 1)) `shift` (8 - j'))
68+ in c .&. 0x1f
lib/asset/manifest/src/Obelisk/Asset/Promoted.hsnew file+111
@@ -0,0 +1,111 @@
1+{-# LANGUAGE KindSignatures #-}
2+{-# LANGUAGE OverloadedStrings #-}
3+{-# LANGUAGE TemplateHaskellQuotes #-}
4+module Obelisk.Asset.Promoted
5+ ( writeStaticProject
6+ , declareStatic
7+ , StaticConfig (..)
8+ ) where
9+
10+import Obelisk.Asset.Gather
11+
12+import Data.Foldable
13+import Language.Haskell.TH (runQ, pprint)
14+import Language.Haskell.TH.Syntax hiding (lift)
15+import GHC.TypeLits
16+import Data.Sequence (Seq)
17+import qualified Data.Sequence as Seq
18+import Control.Monad.Trans.Writer
19+import System.FilePath
20+import System.Directory
21+import Data.Monoid
22+import Data.Text (Text)
23+import qualified Data.Text as T
24+import qualified Data.Text.IO as T
25+import Data.Map (Map)
26+import qualified Data.Map as Map
27+
28+data StaticConfig = StaticConfig
29+ { _staticConfig_packageName :: Text --TODO: Better type
30+ , _staticConfig_moduleName :: Text --TODO: Better type
31+ }
32+
33+writeStaticProject :: Map FilePath FilePath -> FilePath -> StaticConfig -> IO ()
34+writeStaticProject paths target cfg = do
35+ createDirectoryIfMissing True $ target </> "src"
36+ T.writeFile (target </> T.unpack (_staticConfig_packageName cfg) <.> "cabal") $ staticCabalFile cfg
37+ let modName = _staticConfig_moduleName cfg
38+ modContents <- staticModuleFile modName paths
39+ T.writeFile (target </> "src" </> T.unpack modName <.> "hs") modContents
40+
41+staticCabalFile :: StaticConfig -> Text
42+staticCabalFile cfg = T.unlines
43+ [ "name: " <> _staticConfig_packageName cfg
44+ , "version: 0"
45+ , "cabal-version: >= 1.2"
46+ , "build-type: Simple"
47+ , ""
48+ , "library"
49+ , " hs-source-dirs: src"
50+ , " build-depends:"
51+ , " base,"
52+ , " ghc-prim,"
53+ , " text"
54+ , " exposed-modules: " <> _staticConfig_moduleName cfg
55+ ]
56+
57+staticModuleFile :: Text -> Map FilePath FilePath -> IO Text
58+staticModuleFile moduleName paths = do
59+ decs <- runQ $ fmap toList $ execWriterT $ staticClassWithInstances paths
60+ return $ T.unlines
61+ [ "{-# LANGUAGE AllowAmbiguousTypes #-}"
62+ , "{-# LANGUAGE DataKinds #-}"
63+ , "{-# LANGUAGE FlexibleInstances #-}"
64+ , "{-# LANGUAGE KindSignatures #-}"
65+ , "{-# LANGUAGE OverloadedStrings #-}"
66+ , "module " <> moduleName <> " where"
67+ , ""
68+ , "import qualified GHC.Types"
69+ , "import Data.Text ()"
70+ , "import qualified Data.Text.Internal"
71+ , ""
72+ , T.pack $ pprint decs
73+ ]
74+
75+data StaticContext = StaticContext
76+ { _staticContext_className :: Name
77+ , _staticContext_methodName :: Name
78+ }
79+
80+declareStatic :: FilePath -> Q [Dec]
81+declareStatic root = do
82+ paths <- runIO $ gatherHashedPaths root
83+ -- TODO: If https://ghc.haskell.org/trac/ghc/ticket/14623 is implemented, use
84+ -- qAddDependentFile to watch the directories as well as the files
85+ forM_ (Map.keys paths) $ \original -> do
86+ qAddDependentFile $ root </> original
87+ fmap toList $ execWriterT $ staticClassWithInstances paths
88+
89+staticClassWithInstances :: Map FilePath FilePath -> WriterT (Seq Dec) Q ()
90+staticClassWithInstances paths = do
91+ ctx <- staticClass
92+ forM_ (Map.toList paths) $ \(original, hashed) -> do
93+ staticInstance ctx original hashed
94+
95+staticClass :: WriterT (Seq Dec) Q StaticContext
96+staticClass = do
97+ let n x = Name (OccName x) NameS
98+ className = n "StaticFile"
99+ methodName = n "static"
100+ cls = ClassD [] className [KindedTV (n "s") (ConT ''Symbol)] [] [SigD methodName (ConT ''Text)]
101+ tell $ Seq.singleton cls
102+ return $ StaticContext
103+ { _staticContext_className = className
104+ , _staticContext_methodName = methodName
105+ }
106+
107+staticInstance :: StaticContext -> FilePath -> FilePath -> WriterT (Seq Dec) Q ()
108+staticInstance ctx relativePath hashedPath = do
109+ let headType = ConT (_staticContext_className ctx) `AppT` LitT (StrTyLit relativePath)
110+ methodDec = ValD (VarP $ _staticContext_methodName ctx) (NormalB (LitE (StringL hashedPath))) []
111+ tell $ Seq.singleton $ InstanceD Nothing [] headType [methodDec]
lib/asset/manifest/src/Obelisk/Asset/Symlink.hsnew file+22
@@ -0,0 +1,22 @@
1+module Obelisk.Asset.Symlink
2+ ( copyAndSymlink
3+ ) where
4+
5+import Control.Monad
6+import Data.Map (Map)
7+import qualified Data.Map as Map
8+import System.Directory
9+import System.FilePath.Posix
10+import System.Posix.Files
11+
12+-- | Copy the given hashed files from one location to hashed paths in another
13+-- location, and symlink the unhashed paths to the hashed ones
14+copyAndSymlink
15+ :: Map FilePath FilePath
16+ -> FilePath
17+ -> FilePath
18+ -> IO ()
19+copyAndSymlink paths source destination = forM_ (Map.toList paths) $ \(original, hashed) -> do
20+ createDirectoryIfMissing True $ destination </> takeDirectory hashed
21+ copyFile (source </> original) (destination </> hashed)
22+ createSymbolicLink (takeFileName hashed) $ destination </> original
lib/asset/manifest/src/Obelisk/Asset/TH.hsnew file+14
@@ -0,0 +1,14 @@
1+module Obelisk.Asset.TH
2+ ( assetPath
3+ ) where
4+
5+import Obelisk.Asset.Gather
6+
7+import Language.Haskell.TH.Syntax
8+import System.FilePath.Posix
9+
10+-- | Produces a string literal with the hashed path of the file
11+assetPath :: FilePath -> FilePath -> Q Exp
12+assetPath root relativePath = do
13+ qAddDependentFile $ root </> relativePath
14+ LitE . StringL <$> runIO (toHashedPath root relativePath)
lib/asset/serve-snap/obelisk-asset-serve.cabalnew file+32
@@ -0,0 +1,32 @@
1+name: obelisk-asset-serve-snap
2+version: 0.1
3+synopsis: Serve preprocessed assets using Snap
4+license: BSD3
5+author: Obsidian Systems LLC
6+maintainer: maintainer@obsidian.systems
7+category: Web
8+build-type: Simple
9+cabal-version: >=1.2
10+
11+library
12+ hs-source-dirs: src
13+
14+ build-depends:
15+ base
16+ , attoparsec
17+ , bytestring
18+ , containers
19+ , directory
20+ , filepath
21+ , snap
22+ , snap-core
23+ , obelisk-snap-extras
24+ , text
25+ , transformers
26+ , unix
27+ exposed-modules:
28+ Obelisk.Asset.Accept
29+ Obelisk.Asset.Serve.Snap
30+
31+ other-extensions: TemplateHaskell
32+ ghc-options: -Wall -Werror -fno-warn-unused-do-bind -fwarn-tabs -funbox-strict-fields -O2 -fprof-auto-calls
lib/asset/serve-snap/src/Obelisk/Asset/Accept.hsnew file+258
@@ -0,0 +1,258 @@
1+{-# LANGUAGE CPP #-}
2+{-# LANGUAGE OverloadedStrings #-}
3+{-# LANGUAGE BangPatterns #-}
4+{-# LANGUAGE ScopedTypeVariables #-}
5+{-# LANGUAGE GeneralizedNewtypeDeriving #-}
6+
7+-- | Module containing parsers and utilities for managing @Accept-Encoding@ headers and the overall process of encoding selection.
8+--
9+-- See https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.3
10+module Obelisk.Asset.Accept
11+ ( AcceptableEncodings(..)
12+ , Encoding(..)
13+ , QValue(..)
14+ , QValueResolution
15+ , acceptEncodingBody
16+ , chooseEncoding
17+ , missingAcceptableEncodings
18+ ) where
19+
20+#ifdef __GLASGOW_HASKELL__
21+#if __GLASGOW_HASKELL__ < 710
22+import Prelude hiding (takeWhile, sequence)
23+import Data.Traversable (sequence)
24+#else
25+import Prelude hiding (takeWhile)
26+#endif
27+#else
28+import Prelude hiding (takeWhile)
29+#endif
30+
31+import Control.Applicative ((<|>), optional)
32+import Control.Arrow (second)
33+import Control.Monad (replicateM, void)
34+import Data.Attoparsec.ByteString as AttoBS
35+import Data.Attoparsec.ByteString.Char8 as AC8
36+import Data.ByteString (ByteString)
37+import qualified Data.ByteString as BS
38+import Data.Either (partitionEithers)
39+import Data.Fixed (E3, Fixed(..), resolution)
40+import Data.List (sort)
41+import Data.Map (Map)
42+import qualified Data.Map as Map
43+import Data.Maybe (catMaybes, fromMaybe, listToMaybe)
44+import Data.Monoid ((<>))
45+import Data.Ord (Down(..))
46+import Data.Proxy (Proxy(..))
47+import Data.String (IsString)
48+import Data.Word (Word8)
49+
50+
51+-- | Type of a particular named encoding technique, such as @gzip@.
52+--
53+-- See https://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.5
54+newtype Encoding = Encoding { unEncoding :: ByteString } deriving (Show, Read, Eq, Ord, IsString)
55+
56+-- | Maximum precision of a Q value, in particular 3 decimal places of precision as given by the standard.
57+type QValueResolution = E3
58+
59+-- | Type of an HTTP @qvalue@ or quality value indicating how preferred some encoding is relative to some other one.
60+--
61+-- See https://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.9
62+newtype QValue = QValue { unQValue :: Fixed QValueResolution } deriving (Show, Read, Eq, Ord)
63+
64+-- | Structure used for picking a mutually acceptable encoding, holding a default 'QValue' along with 'QValue's for a number of 'Encoding's and typically
65+-- represented in HTTP as a string like gzip; q=1.0, identity; q=0.5, *; q=0.0@.
66+--
67+-- See https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.3
68+data AcceptableEncodings = AcceptableEncodings
69+ { _acceptableEncodings_defaultQValue :: QValue
70+ -- ^The 'QValue' associated with the default encoding value @*@, indicating any unmentioned encoding.
71+ , _acceptableEncodings_byEncoding :: Map Encoding QValue
72+ -- ^'QValue's for each 'Encoding'.
73+ } deriving (Show, Read, Eq, Ord)
74+
75+-- | When no Accept-Encoding header is present, prefer identity, then gzip or compress, then anything else available.
76+missingAcceptableEncodings :: AcceptableEncodings
77+missingAcceptableEncodings = AcceptableEncodings
78+ { _acceptableEncodings_defaultQValue = QValue 0.001
79+ , _acceptableEncodings_byEncoding = Map.fromList
80+ [ (Encoding "identity", QValue 1)
81+ , (Encoding "gzip", QValue 0.5)
82+ , (Encoding "compress", QValue 0.5)
83+ ]
84+ }
85+
86+-- | Takes a list of 'Encoding's and an 'AcceptableEncodings' representing preferences and returns @Just 'Encoding'@ to use of the given list based on those
87+-- preferences. An 'Encoding' is preferred if it has a higher 'QValue' or in the case of ties if it comes first in the given list of encodings. An encoding
88+-- with a @QValue@ of 0 will never be chosen. If no encoding could be chosen, either because no encodings were given or because 0 @QValue@s suppressed them,
89+-- then @Nothing@ is returned.
90+--
91+-- See https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.3
92+chooseEncoding :: [Encoding] -> AcceptableEncodings -> Maybe Encoding
93+chooseEncoding es ae = fmap snd $ listToMaybe $ sort $ catMaybes $ zipWith f es [(1::Int)..]
94+ where f e n = case encodingQValue e ae of
95+ QValue 0 -> Nothing
96+ q -> Just ((Down q, n), e) -- Choose by quality first (in descending order), then by the server's preference order
97+
98+-- | Helper function for 'chooseEncoding' that returns the default AcceptableEncoding if requested encoding is unavailable
99+encodingQValue :: Encoding -> AcceptableEncodings -> QValue
100+encodingQValue e ae = Map.findWithDefault (_acceptableEncodings_defaultQValue ae) e $ _acceptableEncodings_byEncoding ae
101+
102+-- | Attoparsec 'Parser' for parsing an 'AcceptableEncodings' from the value of an @Accept-Encoding@ header, as specified by HTTP/1.1 / RFC2616.
103+--
104+-- See https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.3
105+acceptEncodingBody :: Parser AcceptableEncodings
106+acceptEncodingBody = do
107+ l <- hashRule (Just 1) Nothing $ do -- partially applied hashRule awaiting to be passed Parser a
108+ c <- (Nothing <$ literal "*") <|> (Just <$> contentCoding) -- Parser Nothing <|> Parser Encoding
109+ mq <- optional $ do
110+ literal ";"
111+ literal "q"
112+ literal "="
113+ qvalue
114+ let q = fromMaybe (QValue 1) mq
115+ return (c, q)
116+ let (stars, specificEncodings) = partitionEithers $ flip map l $ \(c, q) -> case c of
117+ Nothing -> Left q
118+ Just n -> Right (n, q)
119+ starQValue <- case stars of
120+ [] -> return Nothing
121+ [q] -> return $ Just q
122+ _ -> fail "acceptEncodingBody: multiple * values provided"
123+ byEncodingProvided <- sequence $ Map.fromListWithKey (\k _ _ -> fail $ "acceptEncodingBody: encoding " <> show k <> " repeated multiple times") $ map (second return) specificEncodings
124+ let defaultIdentityQValue = fromMaybe (QValue 1) starQValue -- identity has a default qvalue of 1 unless * is given a different qvalue explicitly
125+ defaultQValue = fromMaybe (QValue 0) starQValue
126+ byEncoding = Map.filter (/= defaultQValue) -- Canonicalize: qvalues equal to the default are redundant
127+ . Map.alter (Just . fromMaybe defaultIdentityQValue) "identity" -- Add implicit "identity" encoding, unless it has been explicitly added
128+ $ byEncodingProvided
129+ return $ AcceptableEncodings defaultQValue byEncoding
130+
131+-- | Parser for a 'QValue'.
132+--
133+-- See http://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.9
134+qvalue :: Parser QValue
135+qvalue = do
136+ skipMany lws
137+ q0 <|> q1
138+ where q0 = do
139+ char '0'
140+ decimals <- option [] $ do
141+ char '.'
142+ starRule (Just 0) (Just numAllowedDigits) digit
143+ return $ QValue $ MkFixed $ fromIntegral $ (read ('0' : decimals) :: Int) * 10 ^ (numAllowedDigits - length decimals)
144+ q1 = do
145+ char '1'
146+ option () $ do
147+ char '.'
148+ starRule (Just 0) (Just numAllowedDigits) $ char '0'
149+ return ()
150+ return $ QValue 1
151+ numAllowedDigits :: Int
152+ numAllowedDigits = fromIntegral $ resolution (Proxy :: Proxy QValueResolution)
153+
154+-- | Helper function used in 'acceptEncodingBody' to evaluate Encoding as ByteString
155+contentCoding :: Parser Encoding
156+contentCoding = Encoding <$> token
157+
158+-- | Helper function used in 'contentCoding' that skips lightweight spaces and consumes input while 'isTokenChar' returns True
159+token :: Parser ByteString
160+token = skipMany lws >> AttoBS.takeWhile1 isTokenChar
161+
162+-- | Helper function used in 'acceptEncodingBody', discards evaluation and returns a 'Parser ()' to continue parsing
163+literal :: ByteString -> Parser ()
164+literal s = void $ skipMany lws >> string s
165+
166+-- | Helper function used in 'token' to validate if ByteStringChar is printable but not a control or seperator char
167+isTokenChar :: Word8 -> Bool
168+isTokenChar c = isChar c && not (isCtl c || isSeparator c)
169+
170+-- | Helper function used in 'isTokenChar' to validate if ByteStringChar is a control or printable char
171+isChar :: Word8 -> Bool
172+isChar c = c <= 127
173+
174+-- | Helper function used in 'isTokenChar' to validate if ByteStringChar is a control char
175+isCtl :: Word8 -> Bool
176+isCtl c = c <= 31 || c == 127
177+
178+-- | Helper function used in 'isTokenChar' to validate if ByteStringChar is a seperator char
179+isSeparator :: Word8 -> Bool
180+isSeparator c = BS.elem c $ spVal `BS.cons` htVal `BS.cons` "()<>@,;:\\\"/[]?={}"
181+
182+-- | A version of 'liftM2' that is strict in the result of its first
183+-- action.
184+liftM2' :: (Monad m) => (a -> b -> c) -> m a -> m b -> m c
185+liftM2' f a b = do
186+ !x <- a
187+ y <- b
188+ return (f x y)
189+{-# INLINE liftM2' #-}
190+
191+-- | See http://www.w3.org/Protocols/rfc2616/rfc2616-sec2.html#sec2.1
192+starRule :: Maybe Int -> Maybe Int -> Parser a -> Parser [a]
193+starRule minNum maxNum element = do
194+ let numMandatory = fromMaybe 0 minNum
195+ mandatoryVals <- replicateM numMandatory element
196+ optionalVals <- case maxNum of
197+ Nothing -> many' element
198+ Just n -> do
199+ let countUpTo 0 _ = return []
200+ countUpTo m a = liftM2' (:) a (countUpTo (pred m) a) <|> return []
201+ countUpTo (n - numMandatory) element
202+ return $ mandatoryVals ++ optionalVals
203+
204+-- | See http://www.w3.org/Protocols/rfc2616/rfc2616-sec2.html#sec2.1
205+-- The spec is a bit ambiguous on whether extra commas and whitespace are permitted before and after the elements; this implementation permits them
206+hashRule :: forall a. Maybe Int -> Maybe Int -> Parser a -> Parser [a]
207+hashRule minNum maxNum element = do
208+ let numMandatory = fromMaybe 0 minNum
209+ sep :: Parser ()
210+ sep = do
211+ skipMany1 $ do
212+ skipMany lws
213+ char ','
214+ return ()
215+ processMandatory :: Bool -> Int -> Parser [a]
216+ processMandatory isInitial 0 = processOptional isInitial $ fmap (subtract numMandatory) maxNum
217+ processMandatory isInitial n = do
218+ if isInitial then void $ optional sep else sep
219+ liftM2' (:) (skipMany lws >> element) $ processMandatory False $ pred n
220+ processOptional :: Bool -> Maybe Int -> Parser [a]
221+ processOptional _ (Just 0) = return []
222+ processOptional isInitial n = (<|> return []) $ do
223+ if isInitial then void $ optional sep else sep
224+ liftM2' (:) (skipMany lws >> element) $ processOptional False $ fmap pred n
225+ result <- processMandatory True numMandatory
226+ _ <- optional sep
227+ skipMany lws
228+ return result
229+
230+-- | Linear whitespace
231+-- See http://www.w3.org/Protocols/rfc2616/rfc2616-sec2.html#sec2.2
232+lws :: Parser ()
233+lws = do
234+ option () $ cr >> lf
235+ starRule (Just 1) Nothing $ sp <|> ht
236+ return ()
237+
238+-- | See http://www.w3.org/Protocols/rfc2616/rfc2616-sec2.html#sec2.2
239+cr :: Parser ()
240+cr = void $ word8 13
241+
242+-- | See http://www.w3.org/Protocols/rfc2616/rfc2616-sec2.html#sec2.2
243+lf :: Parser ()
244+lf = void $ word8 10
245+
246+-- | See http://www.w3.org/Protocols/rfc2616/rfc2616-sec2.html#sec2.2
247+sp :: Parser ()
248+sp = void $ word8 spVal
249+
250+spVal :: Word8
251+spVal = 32
252+
253+-- | See http://www.w3.org/Protocols/rfc2616/rfc2616-sec2.html#sec2.2
254+ht :: Parser ()
255+ht = void $ word8 htVal
256+
257+htVal :: Word8
258+htVal = 9
lib/asset/serve-snap/src/Obelisk/Asset/Serve/Snap.hsnew file+123
@@ -0,0 +1,123 @@
1+{-# LANGUAGE OverloadedStrings #-}
2+-- | Serve preprocessed assets using Snap.
3+module Obelisk.Asset.Serve.Snap
4+ ( serveAssets
5+ , serveAssetsInPlace
6+ , getAssetPath
7+ ) where
8+
9+import Obelisk.Asset.Accept (Encoding (..), acceptEncodingBody, chooseEncoding, missingAcceptableEncodings)
10+import Obelisk.Snap.Extras
11+
12+import Snap
13+ (MonadSnap, getHeader, getsRequest, modifyResponse, pass, redirect, sendFile, setContentLength, setContentType, setHeader, setResponseCode)
14+import Snap.Util.FileServe (defaultMimeTypes, fileType, getSafePath, serveFile)
15+
16+import Control.Applicative ((<|>))
17+import Control.Exception (handleJust, try, throwIO)
18+import Control.Monad (forM, liftM)
19+import Control.Monad.IO.Class (liftIO)
20+import Data.Attoparsec.ByteString (parseOnly, endOfInput)
21+import Data.ByteString (ByteString)
22+import qualified Data.ByteString as BS
23+import Data.List (isSuffixOf, sort)
24+import Data.Monoid ((<>))
25+import qualified Data.Text as T
26+import Data.Text.Encoding (decodeUtf8, encodeUtf8)
27+import System.Directory (getDirectoryContents)
28+import System.FilePath ((</>), splitFileName, takeDirectory)
29+import System.IO.Error (isDoesNotExistError)
30+import System.Posix (getFileStatus, fileSize)
31+
32+-- | Serve static assets from an asset directory generated via @assets.nix@ or, failing that, from a regular directory.
33+--
34+-- For assets generated from @assets.nix@, redirects will be sent to the browser
35+-- from the logical asset name (e.g. @test.png@) to the unique filename for the
36+-- current version of the asset
37+-- (e.g. @0rx5yvkkgkig2pcqf4ngi1l7vh89qqajdzc6aiayaibyhvj0d853-test.png@). Hashed
38+-- asset names will be sent with permanent caching headers.
39+serveAssets :: MonadSnap m => FilePath -> FilePath -> m ()
40+serveAssets = serveAssets' True
41+
42+-- | Serve static assets from an asset directory generated via @assets.nix@ or, failing that, from a regular directory.
43+--
44+-- For assets generated from @assets.nix@, redirects will be not sent to the
45+-- browser for the logical asset name (e.g. @test.png@) but instead the current
46+-- version of the asset with to point the browser at the unique filename for the
47+-- current version of the asset.
48+serveAssetsInPlace :: MonadSnap m => FilePath -> FilePath -> m ()
49+serveAssetsInPlace = serveAssets' False
50+
51+-- | Serve static assets from an asset directory generated via @assets.nix@ or, failing that, from a regular directory.
52+--
53+-- For assets generated from @assets.nix@, the @Bool@ argument @doRedirect@ controls whether redirects will be sent to the browser if a request is made for
54+-- an unhashed asset name, e.g. @test.png@. For @True@, a redirect will be sent, yielding more round trips to the server but better caching behavior if the
55+-- asset doesn't change often. Conversely for @False@, the asset will be served "in place" but made uncacheable.
56+serveAssets' :: MonadSnap m => Bool -> FilePath -> FilePath -> m ()
57+serveAssets' doRedirect base fallback = do
58+ pRaw <- getSafePath
59+ go $ if "/" `isSuffixOf` pRaw || pRaw == "" then pRaw <> "index.html" else pRaw
60+ where
61+ go p = do
62+ assetType <- liftIO $ try $ BS.readFile $ base </> p </> "type"
63+ case assetType of
64+ Right "immutable" -> do
65+ conditionalOnModification <- getsRequest $ getHeader "If-Modified-Since"
66+ case conditionalOnModification of
67+ Nothing -> do
68+ encodedFiles <- liftM (filter (`notElem` [".", ".."])) $ liftIO $ getDirectoryContents $ base </> p </> "encodings"
69+ availableEncodings <- liftM (map snd . sort) $ forM encodedFiles $ \f -> do
70+ stat <- liftIO $ getFileStatus $ base </> p </> "encodings" </> f
71+ return (fileSize stat, Encoding $ encodeUtf8 $ T.pack f)
72+ acceptEncodingRaw <- getsRequest $ getHeader "Accept-Encoding"
73+ ae <- case acceptEncodingRaw of
74+ Nothing -> return missingAcceptableEncodings
75+ Just aer -> case parseOnly (acceptEncodingBody <* endOfInput) aer of
76+ Right ae -> return ae
77+ Left err -> error err
78+ Just (Encoding e) <- return $ chooseEncoding availableEncodings ae
79+ modifyResponse $ setHeader "Content-Encoding" e . setHeader "Vary" "Accept-Encoding"
80+ if doRedirect then cachePermanently else doNotCache --TODO: Use Etags when not redirecting
81+ let finalFilename = base </> p </> "encodings" </> T.unpack (decodeUtf8 e)
82+ stat <- liftIO $ getFileStatus finalFilename
83+ modifyResponse $ setHeader "Last-Modified" "Thu, 1 Jan 1970 00:00:00 GMT"
84+ modifyResponse $ setResponseCode 200 . setContentLength (fromIntegral $ fileSize stat) . setContentType (fileType defaultMimeTypes p)
85+ sendFile finalFilename
86+ Just _ -> cachePermanently >> modifyResponse (setResponseCode 304)
87+ Right "redirect" -> do
88+ mtarget <- liftIO $ getAssetTarget $ base </> p
89+ case mtarget of
90+ Just target -> if doRedirect
91+ then do
92+ doNotCache
93+ redirect target
94+ else go $ takeDirectory p </> T.unpack (decodeUtf8 target)
95+ Nothing -> serveFile $ fallback </> p
96+ Right unknown -> error $ T.unpack ("serveAssets': Unknown asset " <> decodeUtf8 unknown)
97+ Left err | isDoesNotExistError err -> (doNotCache >> serveFileIfExists (fallback </> p)) <|> do
98+ let (dirname, filename) = splitFileName p
99+ unhashedFilename = drop 1 $ dropWhile (/= '-') filename
100+ if null unhashedFilename then pass else do
101+ doNotCache
102+ serveFileIfExists $ fallback </> dirname </> unhashedFilename
103+ | otherwise -> liftIO $ throwIO err
104+
105+-- | If the given file exists in a hashed location, return that location. The
106+-- resulting FilePath will be relative to @base@, just like @assetPath@ is.
107+getAssetPath
108+ :: FilePath -- ^ @base@: Path to asset directory
109+ -> FilePath -- ^ @assetPath@: Path to non-hashed asset within the asset directory
110+ -> IO (Maybe FilePath) -- ^ Path to hashed asset within the asset directory, if it exists
111+getAssetPath base p = do
112+ target <- getAssetTarget $ base </> p
113+ return $ fmap ((takeDirectory p </>) . T.unpack . decodeUtf8) target
114+
115+-- | Given a file path into an asset directory prepared by @assets.nix@, read
116+-- the target path from the metadata if it exists. Returns @Nothing@ when the
117+-- asset path doesn't exist.
118+getAssetTarget :: FilePath -> IO (Maybe ByteString)
119+getAssetTarget p =
120+ handleJust
121+ (\ e -> if isDoesNotExistError e then Just () else Nothing)
122+ (\ () -> return Nothing)
123+ (liftM Just $ BS.readFile $ p </> "target")
lib/backend/obelisk-backend.cabal+1−1
@@ -9,6 +9,6 @@ library
9 snap,
9 snap,
10 snap-server,
10 snap-server,
11 data-default,
11 data-default,
12− obelisk-asset-serve,
12+ obelisk-asset-serve-snap,
13 bytestring
13 bytestring
14 exposed-modules: Obelisk.Backend
14 exposed-modules: Obelisk.Backend
lib/backend/src/Obelisk/Backend.hs+1−1
@@ -8,7 +8,7 @@ module Obelisk.Backend
8
8
9import qualified Data.ByteString.Char8 as BSC8
9import qualified Data.ByteString.Char8 as BSC8
10import Data.Default (Default (..))
10import Data.Default (Default (..))
11−import Obelisk.Asset.Serve (serveAssets)
11+import Obelisk.Asset.Serve.Snap (serveAssets)
12import System.IO (hSetBuffering, stderr, BufferMode (..))
12import System.IO (hSetBuffering, stderr, BufferMode (..))
13import Snap (httpServe, defaultConfig, commandLineConfig, route)
13import Snap (httpServe, defaultConfig, commandLineConfig, route)
14import Snap.Internal.Http.Server.Config (Config (accessLog, errorLog), ConfigLog (ConfigIoLog))
14import Snap.Internal.Http.Server.Config (Config (accessLog, errorLog), ConfigLog (ConfigIoLog))
lib/snap-extras/obelisk-snap-extras.cabalnew file+21
@@ -0,0 +1,21 @@
1+name: obelisk-snap-extras
2+version: 0.1
3+synopsys: Extra functionality for Snap that should be considered for upstreaming
4+license: BSD3
5+build-type: Simple
6+cabal-version: >=1.2
7+
8+library
9+ hs-source-dirs: src
10+ build-depends:
11+ attoparsec
12+ , base
13+ , bytestring
14+ , snap-core
15+ , unix
16+ , directory
17+ , filepath
18+ , text
19+ exposed-modules:
20+ Obelisk.Snap.Extras
21+ ghc-options: -Wall
lib/snap-extras/src/Obelisk/Snap/Extras.hsnew file+37
@@ -0,0 +1,37 @@
1+{-# LANGUAGE OverloadedStrings #-}
2+module Obelisk.Snap.Extras
3+ ( cachePermanently
4+ , doNotCache
5+ , serveFileIfExists
6+ , serveFileIfExistsAs
7+ ) where
8+
9+import Control.Monad.IO.Class
10+import Data.ByteString (ByteString)
11+import Snap.Core
12+import Snap.Util.FileServe
13+import System.Directory
14+
15+-- | Set response header for "permanent" caching
16+cachePermanently :: MonadSnap m => m ()
17+cachePermanently = do
18+ modifyResponse $ setHeader "Cache-Control" "public, max-age=315360000"
19+ modifyResponse $ setHeader "Expires" "Tue, 01 Feb 2050 00:00:00 GMT" --TODO: This should be set to "approximately one year from the time the response is sent"
20+
21+-- | Set response header to not cache
22+doNotCache :: MonadSnap m => m ()
23+doNotCache = do
24+ modifyResponse $ setHeader "Cache-Control" "no-cache, no-store, must-revalidate"
25+ modifyResponse $ setHeader "Expires" "0"
26+
27+-- | Serves the specified file if it exists; otherwise, 'pass'es
28+serveFileIfExists :: MonadSnap m => FilePath -> m ()
29+serveFileIfExists f = do
30+ exists <- liftIO $ doesFileExist f
31+ if exists then serveFile f else pass
32+
33+-- | Like 'serveFileIfExists', but with a given MIME type
34+serveFileIfExistsAs :: MonadSnap m => ByteString -> FilePath -> m ()
35+serveFileIfExistsAs mimeType f = do
36+ exists <- liftIO $ doesFileExist f
37+ if exists then serveFileAs mimeType f else pass