Express middleware to serve Single Page Applications with pushState urls and increased performance
![Gitter](https://badges.gitter.im/Join Chat.svg)
Linux: Windows: General:
Serve-SPA behaves like the express.static middleware. However, if a pushState url is requested Serve-SPA does not return a 404 but instead serves the matching SPA page.
Assume you have this very simple SPA served from this folder:
spa
|-- index.html
|-- app.js
At first, a visitor usually loads your SPA via the base url http://localhost:3000/
. Thus index.html is served and also app.js is loaded. Next the visitor navigates through your SPA which updates the url using pushState and e.g. arrives at http://localhost:3000/profile/me
. The visitor might now bookmark this page and open it again the next day. express.static would send a 404 for this url because the served folder does not contain a "profile" folder containing a "me" file. Serve-SPA, however, recognises http://localhost:3000/profile/me
as a pushState url and searches the folder structure for a page that matches the given url best, i.e. http://localhost:3000/
.
All you need to do to activate pushState url support is to rename your index.html
files to index.htmlt
(with a t). I.e.:
spa
|-- index.htmlt <-- Just renamed and pushState urls are supported
|-- app.js
This is how a regular SPA (using Angular.js in this case) gets loaded:
The time until the user sees the page is significantly increased by the two AJAX requests "list.html" which is a HTML template and "projects" which is the JSON data used to populate the page.
With Serve-SPA you can easily inline the template / data into the index.htmlt
so that the AJAX calls are skipped and the page gets rendered immediately:
Serve-SPA brings the power of lodash's templating to your index.htmlt
files. The above example for the regular SPA uses this html page:
<!doctype html>
<html ng-app="project">
<head>
<title>Pure Angular.js SPA</title>
<script src="/bower_components/angular/angular.min.js"></script>
<script src="/bower_components/angular-resource/angular-resource.min.js"></script>
<script src="/bower_components/angular-route/angular-route.min.js"></script>
<link rel="stylesheet" href="/bower_components/bootstrap/dist/css/bootstrap.min.css">
<script src="/scripts/app.js"></script>
<base href="/">
</head>
<body>
<div class="container">
<h1>JavaScript Projects</h1>
<div ng-view></div>
</div>
</body>
</html>
If the visitor requests http://localhost:3000/
this SPA needs the list.html
template to render. To skip the otherwise necessary AJAX call the template should be inlined. However, since other pushState urls don't need this template it should only be inlined if http://localhost:3000/
is requested. This can be accomplished with the following addition to index.htmlt
:
<body>
+ <% if (req.path === '/') { %>
+ <script type="text/ng-template" id="partials/list.html">
+ <%= require('fs').readFileSync('app/partials/list.html') %>
+ </script>
+ <% } %>
<div class="container">
<h1>JavaScript Projects</h1>
<div ng-view></div>
</div>
</body>
There are ways (e.g. using compose.js
) to implement this in a cleaner and non-blocking way but you get the idea.
If you already serve your SPA with the express.static middleware you will be able to serve it with Serve-SPA instead:
// Just replace:
app.use(express.static(appDir));
// with:
serveSpa(app, appDir);
Then you rename your index.html
files to index.htmlt
.
app
|-- blog
| |-- img
| | |-- ...
| |
-| |-- index.html
+| |-- index.htmlt
| |-- blog.css
| |-- blog.js
|
-|-- index.html
+|-- index.htmlt
|-- main.css
|-- main.js
This gives you pushState url support and templating functionality to inline HTML templates and JSON data into the HTML served for each request. If you need to e.g. fetch the data from your database beforehand you can add a compose.js
file alongside to do so:
app
|-- blog
| |-- img
| | |-- ...
| |
+| |-- compose.js // May load latest article headlines from the database
| |-- index.htmlt // May inline article headline so the browser spares an AJAX call
| |-- blog.css
| |-- blog.js
|
|-- index.htmlt
|-- main.css
|-- main.js
BTW, Serve-SPA does not make any assumptions about how your SPA is implemented client-side. Any implementation should be able to work with the changes that need to be made server-side.
Checkout the Serve-SPA Demos repo which aims to provide regular and migrated versions of SPA for well-known SPA frameworks. To e.g. see how to migrate a Angular.js based SPA make a diff between its regular implementation and the precomposed version which takes full advantage of Serve-SPA.
The module for node.js is installed via npm:
npm install serve-spa --save
Serve-SPA depends on a loosely defined version of serve-static. If you want to install a specific version please install serve-static beforehand.
var serveSpa = require('serve-spa');
serveSpa(appOrRouter, rootPath, options);
appOrRouter
should either be taken from var app = express();
or var router = express.Router();
depending on where you want to mount Serve-SPA. Using a router allows you to mount the SPA on a specific url. E.g. the SPA is mounted on http://localhost:3000/app/
if you use app.use('/app', router);
.rootPath
is the file system path to the SPA resources. The parameter is identical to the one used with app.use(express.static(rootPath))
. Also, rootPath
may be an array of paths to use multiple roots.options
is an object that allows the following attributes:
options.staticSettings
is forwarded to the serve-static middleware that is used internally. BTW, these are the same options that are passed to express.static(rootPath, staticSettings)
. See the documentation of serve-static's options for details.options.beforeAll
takes a middleware that is executed before a template is rendered. Use it to do general things that are required for all templates. If you need to to things for a particular template use a compose.js file instead.options.templateSettings
is forwarded to lodash's _.template(template, templateSettings)
. See the documentation of _.templateSettings
for details.options.require
takes a particular require function for being used within the template rendering. If the option is not given Serve-SPA provides its own require function when rendering a template. This require function might have a different module path lookup hierarchy. Thus the option allows to pass another require function that has a preferred module path lookup hierarchy.The template is a html file which may contain JavaScript mix-ins:
<!doctype html>
<html>
<head>
<title>Example template</title>
</head>
<body>
Today is: <%= (new Date()).toString() %><br/>
<ul>
<% _.forEach(['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'], function (day, i) { %>
<li<%= (new Date()).getDay() === i ? ' style="color: red;"' : '' %>>
<%= day %>
</li>
<% }); %>
</ul>
</body>
</html>
This produces:
Today is: Tue Oct 20 2015 16:09:40 GMT+0200 (CEST)<br/>
<ul>
<li>Sunday</li>
<li>Monday</li>
<li style="color: red;">Tuesday</li>
<li>Wednesday</li>
<li>Thursday</li>
<li>Friday</li>
<li>Saturday</li>
</ul>
Within the template the following variables are available:
_
: The lodash libraryrequire
: Node's require functionrequest
and req
: The request object provided by Expressresponse
and res
: The response object provided by ExpressIf the rendering fails an Error is forwarded to the next error handling middleware. You may register your own error handling middleware like this:
// First Serve-SPA
serveSpa(app, rootPath);
// Then this middleware with the additional err parameter
app.use(function (err, req, res, next) {
// If headers were already sent it gets complicated...
if (res.headersSent) {
return next(err); // ...just leave it to Express' default error handling middleware.
}
// Do your error handling:
console.error(err);
res.redirect('/guru-meditation/');
});
Put a compose.js
file into the same folder as your index.htmlt
and it will be executed right before the template is rendered. This allows to e.g. fetch data from the database to use it when rendering the template.
compose.js
must export a middleware:
var db = require('../lib/db.js');
module.exports = function (req, res, next) {
db.loadData({ for: 'me' }, function (err, data) {
if (err) {
return next(err);
}
req.data = data; // You can access the data through req.data in the template now.
next();
});
};
The middleware exported through compose.js
is used like a regular middleware. Thus the error handling works as usual.
A middleware can be passed through options.beforeAll
that is executed for all requests that result in rendering a template. If only a static file is requested this middleware is not executed.
The overall execution order is the following:
The provided middleware is used like a regular middleware. Thus the error handling works as usual.
Like express.static(...)
Serve-SPA only processes GET and HEAD requests. By default, for a HEAD request neither the beforeAll and compose.js middlewares are executed nor the index.htmlt template is rendered. However, executing the middlewares can be explicitly activated by adding callForHEAD = true
to the middleware function:
function middlewareForGETandHEAD(req, res, next) {
res.set('my-header', 'yay');
if (req.method === 'GET') {
db.load(...);
}
}
middlewareForGETandHEAD.callForHEAD = true;
The Serve-Static README explains to use multiple roots like this:
app.use(serveStatic(path.join(__dirname, '/public-optimized')));
app.use(serveStatic(path.join(__dirname, '/public')));
With Serve-SPA, however, using multiple middlewares like this is not possible anymore. Because properly handling the pushState urls makes it more complex.
To provide the same support of multiple roots, Serve-SPA takes an array of root paths. The following code is equivalent to the Serve-Static example above:
serveSpa(app, [
path.join(__dirname, '/public-optimized'),
path.join(__dirname, '/public')
]);
A url is only identified as a pushState url if no static resource in all given paths matches the url. And a pushState url is applied to the SPA that matches best as usual – as if the given directories were merged.
To set up your development environment for Serve-SPA:
cd
to the main folder,npm install
,npm install gulp -g
if you haven't installed gulp globally yet, andgulp dev
. (Or run node ./node_modules/.bin/gulp dev
if you don't want to install gulp globally.)gulp dev
watches all source files and if you save some changes it will lint the code and execute all tests. The test coverage report can be viewed from ./coverage/lcov-report/index.html
.
If you want to debug a test you should use gulp test-without-coverage
to run all tests without obscuring the code by the test coverage instrumentation.
minimatch
as advised by the Node Security Platformexpress.Router()
in compose.jsexpress.Router()
In case you never heard about the ISC license it is functionally equivalent to the MIT license.
See the LICENSE file for details.