As I continue to develop my
React app that is hosted on a Play backend, I've come across the need to support "front-end routes"; that is, URLs that look like this:
http://myapp.com/foo/bar
where there is no explicit entry for
GET /foo/bar in Play's
routes and nor is there a physical asset located in
/public/foo/bar for the Assets controller to return to the client, as we set up in the
last instalment:
# Last of all, fall through to the React app
GET / controllers.Assets.at(path="/public",file="index.html")
GET /*file controllers.Assets.at(path="/public",file)
What we'd
like is for the React application at
index.html to be served up, so that it can then consume/inspect/route from the original URL via the
Window.location API.
As it stands, the last line of
routes will match, the Assets controller will
fail to find the resource, and your configured "client error handler" will be called to deal with the 404. This is
not what we want for a "front-end route"!
We want requests that don't correspond to a physical asset to be considered a request for a virtual asset - and hence given to the React app. And after a bit of fiddling around, I've come up with a
FrontEndServingController that gives me the most efficient possible way of dealing with this.
The Gist is available for your copy-paste-and-improve pleasure, but the key points are:
The fall-through cases at the bottom of
routes become:
GET / controllers.FrontEndServingController.index
GET /*file controllers.FrontEndServingController.frontEndPath(file)
Those methods in
FrontEndServingController just being:
val index = serve(indexFile)
def frontEndPath(path: String) = serve(path)
private def serve(path: String) = {
if (physicalAssets.contains(path)) {
logger.debug(s"Serving physical resource: '$path'")
assets.at(publicDirectory, path, true)
} else {
logger.debug(s"Serving virtual resource: '$path'")
// It's some kind of "virtual resource" -
// a front-end "route" most likely
assets.at(publicDirectory, indexFile, true)
}
}
We're still using Play's excellent built-in
AssetsController to do the hard work of caching, ETags, GZipping (all the classic webserver jobs) - we have injected it as
assets using Dependency Injection - composition FTW. That
true argument tells it to use "aggressive caching" which is ideal for this scenario where the bundle files we're serving up already have a cache-busting filename.
And now the "clever" bit being a recursive scan of the
/public directory when we start up, assembling a definitive (and immutable!)
Set[String] of what's actually a physical asset path:
lazy val physicalAssets:Set[String] = {
val startingDirectory = new File(physicalPublicDirectory)
deepList(startingDirectory)
}
private def deepList(f: File): Set[String] = {
val these = f.listFiles.toSet
val inHere = these.filter(_.isFile).map { f =>
f.getPath.replace(physicalPublicDirectory, "")
}
val belowHere = these.filter(_.isDirectory).flatMap(deepList)
inHere ++ belowHere
}