-
-
Notifications
You must be signed in to change notification settings - Fork 414
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
RFC for embedded static resource support #350
base: master
Are you sure you want to change the base?
Conversation
This code allows to embed static content such as javascript and CSS into the executable at compile time so that it does not need to be distributed along with the server. In addition, this module supports processing of these resources before they are embedded, such as javascript or CSS minification. Finally, there is a development mode which will recompute each resource on every request, so allow a simple browser refresh to reload potentially changed javascript or CSS. Documentation is in the haddock comment in Servant.Server.Embedded.hs
👍 Really cool feature! Having totally self-contained web-apps is a very rare thing indeed! |
Wow, great work! An issue with having it as a separate package is that, if it's to have instances for all the core classes, it'll need to depend on all the core packages, making e.g. One thing we've been talking about is starting a |
+1 for me for self-contained web apps, and compile-time file embedding would certainly be very useful in But, I think the part minifiers, compressors, CSS transpilers etc. belong to a different package, as you said. And maybe it's worth to talk about the general idea of "serving static files" here. For example, on our API's, we were serving the JS client of our server on some endpoint. But in order to set
In order to simplify this, I wrote these instances (simplified):
And then I was able to define API's like this
I told you about this, because I think the TH file embedding also fits here as a separate type, something like:
Maybe this RFC can cover serving static content (but not from an external file) with a specific content-type too. |
@wuzzeb: I very much like the idea of embedding static files into the executable. I've been using that feature from I do wonder however: what is the advantage of having all the static files represented in the type of the API? What type MyJsonRestAPI =
... -- lots of JSON REST routes
type MyServerAPI =
MyJsonRestApi :<|>
Raw -- to serve some html, css and javascript that uses the API I don't see how this would benefit from having all the static files explicitly represented in the type? I.e. I probably don't need to be able to generate type safe client functions to fetch all the static assets individually. Also I don't want all the css files to show up in generated documentation, be it by These static file combinators would also force you to manually keep them in sync with the files on your disk. What I like very much about Generally I'm wondering if it wouldn't be good to solve these things purely in |
What I could imagine is a combinator like this: type MyApi =
MyJsonRestApi :<|>
"static" :> StaticAssets
instance HasServer StaticAssets where
type Server StaticAssets = StaticAssetsConfig
route = ...
data StaticAssetsConfig
= FromDisk FilePath
| Embedded Embedded -- from wai-app-static If I think about the different interpretations, that feels like the right amount of information that should be in the API specification. So generated documentation can say something like:
And we could have: instance HasClient StaticAssets where
type Client StaticAssets = FilePath -> ExcepT ServantError IO ByteString So that you can easily generate a client function that allows to fetch assets like this: |
One obvious advantage is type-safe links to static assets. Maybe the solution is explicit routes, but with TH to generate them from a directory? |
But where would you want to have type-safe links to static assets? I guess if you're generating html files within haskell... Personally I would prefer to have this functionality in a templating library or an html generation library and de-coupled from |
I tend to agree with @soenkehahn on this. Awesome work from @wuzzeb, but I don't see the advantages of this yet to be in core. Seems more like a specified use case that would be great as a contrib lib that someone can pull in when needed. |
Sorry, was busy the past few days. My first approach was much closer to For static resources, the recommendation is to tell the browser to cache them and then change the URL used to access. For example, see here for example. This means that when the server refers to a static resource, it must change the URL each time the content changes.
and the etag must change each time the content changes. So in order for the server to generate such links, it needs to know the computed etag. With my first approach which used I agree, if you didn't care about etags and proper caching, a solution which hid all the resources behind a single type in the servant type would be the best approach. |
Changing the url on file changes for better caching would be amazing - I've been impressed by that feature of yesod/wai-app-static, and would be pretty happy to see it in |
@wuzzeb: Btw, I would love to see something like |
It seems to me that perfect caching (with I'm in favor of not merging (and closing) this PR. If however |
Will this ever be merged into master? Seems like something I could use on a project I'm working on. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Very impressive PR, very fan of the work you've been doing.
I asked for some changes, and one optional quality-of-life suggestion at the end. If you're still good to work on the Pull Request, we can have that for the next major release of Servant, which is being planned.
import Distribution.Simple | ||
main = defaultMain |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Unneeded, this is the default
-- After creating the 'EmbeddedEntry', 'embed' will create a haskell variable to hold the | ||
-- 'EmbeddedEntry'. The name of the haskell variable is the 'EntryVarName' passed to the function | ||
-- which creates the generator. | ||
embed :: Bool -- ^ development mode? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could we avoid Boolean blindness on the first parameter? :)
@@ -0,0 +1,76 @@ | |||
-- | This module contains 'Generators' for processing and embedding CSS. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Very interesting idea, would love automated tests to make that we keep up with the nodejs ecosystem.
-- | Use <https://github.com/mishoo/UglifyJS2 UglifyJS2> to compress javascript. | ||
-- Assumes @node_modules\/uglifyjs\/bin\/uglifyjs@ exists so just run @npm install uglifyjs@. It | ||
-- uses options @[\"-m\", \"-c\"]@ to both mangle and compress. | ||
uglifyJs :: BL.ByteString -> IO BL.ByteString | ||
uglifyJs = compressTool "./node_modules/uglifyjs/bin/uglifyjs" ["-m", "-c"] | ||
|
||
-- | Use <http://yui.github.io/yuicompressor/ YUI Compressor> to compress javascript. | ||
-- Assumes a script @yuicompressor@ is located in the path. If not, you can still | ||
-- use something like | ||
-- | ||
-- > compressTool "java" ["-jar", "/path/to/yuicompressor.jar", "--type", "js"] | ||
yuiJavascript :: BL.ByteString -> IO BL.ByteString | ||
yuiJavascript = compressTool "yuicompressor" ["--type", "js"] | ||
|
||
-- | Use <http://yui.github.io/yuicompressor/ YUI Compressor> to compress CSS. | ||
-- Assumes a script @yuicompressor@ is located in the path. | ||
yuiCSS :: BL.ByteString -> IO BL.ByteString | ||
yuiCSS = compressTool "yuicompressor" ["--type", "css"] | ||
|
||
-- | Use <https://developers.google.com/closure/compiler/ Closure> to compress | ||
-- javascript using the default options. Assumes a script @closure@ is located in | ||
-- the path. If not, you can still run using | ||
-- | ||
-- > compressTool "java" ["-jar", "/path/to/compiler.jar"] | ||
closureJs :: BL.ByteString -> IO BL.ByteString | ||
closureJs = compressTool "closure" [] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Again, good ideas, any chance we could have some kind of pre-flight checks to make sure that if the required JS tools are unavailable, either we crash the app, or do nothing (with the adequate warning)
@@ -0,0 +1,47 @@ | |||
module Servant.Server.Embedded.Ghcjs ( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would advise you to remove this part for now. As things stand today, GHCJS cannot be a tier-1 target for the Servant ecosystem.
-- | For each entry, the template haskell code will produce a variable of type @'EmbeddedEntry' | ||
-- mime@. The variable name is specified by a value of type 'EntryVarName', so the string must | ||
-- be a valid haskell identifier (start with a lower case letter, no spaces, etc.). | ||
type EntryVarName = String |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would the entirety of Shakespear's work in Mandarin be a valid EntryVarName? If not, I would advise that you use a more appropriate newtype. If you need a good and lightweight wrapper for ASCII text, I can advise that you use this library: hackage.haskell.org/package/text-ascii
|
||
-- | An etag is used to return 304 not modified responses and cache control headers. | ||
-- If the content changes, the etag must change as well. | ||
newtype Etag = Etag B.ByteString |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could you please deriving instances for the usual suspects Eq, Ord, Show
(when applicable ofc).
data EmbeddedEntry (mime :: Symbol) = EmbeddedEntry { | ||
eeEtag :: Maybe Etag | ||
, eeApp :: Application | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could you please deriving instances for the usual suspects Eq, Ord, Show (when applicable ofc).
import qualified Data.Text.Encoding as T | ||
|
||
-- | Endpoint for embedded content. | ||
data EmbeddedContent (mime :: Symbol) = EmbeddedContent |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could you please deriving instances for the usual suspects Eq, Ord, Show (when applicable ofc).
instance HasServer (EmbeddedContent mime) config where | ||
type ServerT (EmbeddedContent mime) m = EmbeddedEntry mime | ||
route Proxy _ entry = LeafRouter $ \request respond -> do | ||
r <- runDelayed entry | ||
case r of | ||
Route e -> (eeApp e) request (respond . Route) | ||
Fail a -> respond $ Fail a | ||
FailFatal e -> respond $ FailFatal e | ||
|
||
instance HasLink (EmbeddedContent mime) where | ||
type MkLink (EmbeddedContent mime) = Maybe Etag -> URI | ||
toLink Proxy lnk metag = uri { uriQuery = q } | ||
where | ||
uri = linkURI lnk | ||
q = case (uriQuery uri, metag) of | ||
("", Just (Etag etag)) -> "?etag=" ++ T.unpack (T.decodeUtf8 etag) | ||
(query, Just (Etag etag)) -> query ++ "&etag=" ++ T.unpack (T.decodeUtf8 etag) | ||
(query, _) -> query |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That's optional, but do you see potential forbidden instances that we could hijack with a TypeError
type family in order to provide better development experience?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just few suggestions from reading the docs..
-- > :<|> "static" :> "css" :> "mysite.css" :> EmbeddedContent "text/css" | ||
-- | ||
-- Then, decide on a generator for each 'EmbeddedContent'. There are several generators which embed | ||
-- files directly, minifiy files, and use 3rd party tools like less and postcss. You can also |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
-- files directly, minifiy files, and use 3rd party tools like less and postcss. You can also | |
-- files directly, minify files, and use 3rd party tools like less and postcss. You can also |
-- so that it does not need to be distributed along with the server. In addition, this module | ||
-- supports processing of these resources before they are embedded, such as javascript or CSS | ||
-- minification. Finally, there is a development mode which will recompute each resource on every | ||
-- request, so allow a simple browser refresh to reload potentially changed javascript or CSS. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
-- request, so allow a simple browser refresh to reload potentially changed javascript or CSS. | |
-- request, to allow a simple browser refresh to reload potentially changed javascript or CSS. |
-- disk or postcss will be re-executed on each request. Thus when the DEVELOPMENT flag is true, a | ||
-- browser refresh will reload and recompute the resources from disk. | ||
-- | ||
-- When the DEVELOPMENT define is false, instead at compile time the resource will be loaded, the |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
-- When the DEVELOPMENT define is false, instead at compile time the resource will be loaded, the | |
-- When the DEVELOPMENT define is false, instead the resource will be loaded at compile time, the |
Servant already uses
wai-app-static
for the very simpleserveDirectory
which just produces aServer Raw
. This isn't ideal because the routes for individual files are not represented in the type. Also,wai-app-static
has the more sophisticated ability to embed resources into the executable at compile time (full disclosure, I was the original author of this code inwai-app-static
although since then several people have contributed towai-app-static
).This pull request is my RFC for an approach to extend
serveDirectory
to be able to specify individual resources in the API type for better exposure of the static resources that are available. Along the way, I also add support for embedding the static content at compile time.At the moment I added all this into a new project, but that was just for ease of testing. If you like this approach, some of the types should be moved into Servant.API, other code merged into servant-server, and perhaps the more exotic generators such as compiling with lesscss and ghcjs moved into a new project. I can morph this code around once I get some feedback.
I went through about 4 iterations on the design before I found one I was happy with. My first iteration used
wai-app-static
directly but it had to do A LOT with template haskell and as always using as little template haskell as possible is good. I then realized that we only need a few internal functions fromwai-app-static
, so the later evolutions of the design don't depend onwai-app-static
anymore. I then went through several iterations on how the combinators look. I have started using it in my own servant-based application and the combinators are working well, but I'm still open to improvements.I wrote haddock documentation, so for more details you should see the documentation, especially the haddock comment in Servant.Server.Embedded.hs