This library exposes a relatively simple implementation of SSR using Surplus.
Either install through npm
:
npm i --save surplus-ssr
or clone this repository and import index.js
.
The default export of the library is a function that produces a middleware function for use with server applications (e.g. express).
Return a middleware function for SSR using the given root path (path to the root
of the content to server) and the given getState
function, which should return
the current state given the request object (described more in Writing
Content).
Options may be an object with the following keys:
clientJS
(boolean
) - whether to emit client-side javascript (default:true
)pageRoot
(string
) - the root, relative torootPath
, of pages and directories to serve (default:"pages"
)compile
(array
) - the steps to take when compiling files (default:[ssr.COMPILE_SURPLUS]
). Should be an array of functions that take the file source as an argument and returns the modified/compiled source. The special valuessr.COMPILE_SURPLUS
can also be specified, which will use the surplus compiler installed inrootPath
. Compiled results are cached.
The middleware function returned is of the typical form (taking two arguments, a request and a response). It does not use the common third argument (a chaining function), so technically isn't middleware per-se. This could easily be changed if requested.
The function uses the version of surplus installed in rootPath
to compile the
surplus expressions, and runs any code within rootPath
(node modules are
loaded from rootPath
).
The function returned by the above also has a property called middleware
that
acts as true middleware and adds a respondWithPage
function to the response
object. This function makes less assumptions about the request path to
resource/entrypoint mapping, and is useful if you don't like the default way
that routing is done. It must be called with the path to the entrypoint as an
argument, where the path is relative to rootPath
. Any additional arguments are
forwarded to the function exported by the page, if possible. This will run the
code at the given path and appropriately bundle and send the response. For
example:
var srv = express();
srv.use(ssr("path/to/root", getState).middleware);
srv.get('/my-path', (req, res) => {
res.respondWithPage("my-path-page.js", "page-argument");
});
All modules and content needed to render pages should appear within rootPath
.
For this reason, it's almost always appropriate to initialize a node project
within rootPath
, and within that project install s-js
and surplus
. These
are required for the SSR to function properly.
If using the default ssr middleware function, page entry-points are expected in
the options.pageRoot
directory (default "pages"
). So this directory must
exist, and should contain the request path layout that you expect. The default
middleware does the following mapping:
your_url.com/page -> {options.pageRoot}/page.js | {options.pageRoot}/page/index.js
In words: if the page exists as a javascript file (with the .js
extension) it
is used, otherwise if it exists as a directory and index.js
exists in the
directory, that is used. Otherwise the request fails.
All code that is used for the webpage must use CommonJS/UMD modules. The loader
has not yet been extended to support the experimental node ES6 modules. The code
must also be appropriate to run directly on the client side. I have not used
pre-processing in my workflows, but in theory that should be possible. Importing
modules must be done using require()
just like other nodejs code.
One important difference right now is that require()
is always relative to
rootPath
; relative paths from the current file are not yet supported. In my
opinion, this makes some things much clearer. Importing external code from
node_modules works as usual (e.g. require("d3")
), but, for instance, if you
have a components
directory in rootPath
, then importing files from there
should always be done with require("components/path-to-file")
.
The following global variables will be available, both on the server side and the client side:
S
- the loaded S.js module.Surplus
- the loaded Surplus module.STATE
- the state returned from thegetState
function passed to the middleware constructor. ThegetState
function is passed a single argument, the request object.isServer
(boolean
) - Whether the code is running on the server or not (client).
This means that your pages don't all need to require("s-js")
and
require("surplus")
; you may just use them as if they were already imported.
Pages must export an object, or a function that returns an object, with the following keys:
body
- the body of the page, which must be a DOM element supportingouterHTML
.head
(optional) - an array of DOM elements (e.g.<link />
) that should be in the head of the response.
If the page exports a function, that function will be called with any additional
arguments passed to the middleware respondWithPage
function.
I've used d3 for (fairly complex) server-side SVG rendering, and it seems to work well.
If options.clientJS
is true (the default), all code for the page is bundled
with the page, and upon load (if the page supports javascript) the content of
the page will be replaced with a 'live' surplus/s-js version. All dependencies
of a page are tracked and bundled together, but no minification is done. If it
is false, no javascript will be bundled, which can save on response size.
I've been able to use websockets and REST APIs without any issue here. It's fairly simple to create S.js computations around the original server state, and then update those with data that comes over websockets or from other events. I've found this approach to work quite well even with high update rates and fairly large state objects (on the order of tens of thousands of individual pieces of state, however you may define that...).
By default, the surplus compiler is run on all source files. If
options.compile
is specified, those compile steps will be used on source
files. This is useful, for instance, to run the Typescript compiler or Babel
stages on source files when developing. Typically for release builds, you'll
want to set options.compile
to an empty array (or false
) and
pre-process/compile the files as part of the production build.
const express = require('express');
const ssr = require('surplus-ssr');
const path = require('path');
const babel = require('babel-core');
const listen_port = 8080;
// Root is in the 'public' folder
const publicRoot = path.join(__dirname, "public");
// Create server
const srv = express();
// Create view state to be used to render the page
// This state provides an object defining the number of overall page loads and
// the load time.
let page_loads = 0;
const getState = () => {
page_loads++;
return {
load_time: new Date().toString(),
page_loads: page_loads
};
};
// Babel compiler, using babel-preset-env to target a set of browsers and
// perform some minification.
const compile_babel = s => {
return babel.transform(s, {
ast: false,
babelrc: false,
comments: false,
minified: true,
presets: [
["env", {
"targets": {
"browsers": [">0.5%", "not op_mini all", "not dead", "last 1 version"]
}
}]
]
}).code;
};
// Middleware options
const ssropts = {
compile: [ssr.COMPILE_SURPLUS, compile_babel]
};
// Render and serve page contents
// Could omit 'middleware', in which case the following srv.get() calls could be
// left out as well, and pages/index.js would work as expected. But pages/getId.js
// wouldn't get arguments in that situation.
srv.use(ssr(publicRoot, getState, ssropts).middleware);
srv.get('/', (req, res) => { res.respondWithPage("pages/index.js"); });
srv.get('/getId/:id', (req, res) => {
res.respondWithPage("pages/getId.js", req.params.id);
});
// Run server
const inst = srv.listen(listen_port);
Shows the number of page loads, the server-side page load time, and an updating time based on the client's clock (if JS is enabled).
const now = S.value(new Date());
if (!isServer) {
// Update displayed time every second on the client
setInterval(() => {
now(new Date());
}, 1000);
}
module.exports = {
body: S.root(() => (
<div>
<h1>Hello, World</h1>
<p>Number of loads: {STATE.page_loads}</p>
<p>Loaded at: {STATE.load_time}</p>
<p>Right now, it is {now()}</p>
</div>
))
};
Echoes whatever the parameter to the page is.
module.exports = (id) => {
return {
body: <div><p>Id is {id}</p></div>
};
};
- Partially pre-render pages - it would be useful and powerful to pre-render all pages as much as possible, such that when a request comes in the minimal amount of processing needs to be done (i.e. things that depend on state are rendered). This will require changes to or a custom implementation of the server-side DOM.
- Re-hydrate on the client side rather than replacing all content - with some changes to the surplus compiler, it should be possible to use the DOM that was rendered on the server side with live S.js functions on the client side.
- Rewrite in Typescript - to better match the S.js and Surplus code.