osh-pages
v0.0.14
Published
openscihub.org: manage web pages isomorphically
Downloads
17
Readme
Pages
A framework for building isomorphic web apps that are wrappers around resource APIs. Consider this the controller layer, gluing APIs (the model layer) to the display (view layer) with a keen eye on the holy grail.
Build 'em like they used to, have 'em work like they should.
What you have to do:
- Write isomorphic interfaces to APIs (this is often accomplished via an isomorphic HTTP request library...ahem...SuperAgent)
- Find or build a html/DOM rendering and diffing library...ahem...ReactJS
- Hook them together using Pages.
What you get:
- Server-side rendering/handling of all GET/POST actions.
- Snappy initial page loads.
- AJAX navigation and submission without touching an
<a/>
onclick or<form/>
onsubmit (that's isomorphic). - Built-in js module bundling and loading (via dynapack).
Installation
npm install osh-pages
Usage
Consider the following files
server.js
routes.js
view-user.js
The contents of each are printed below; let's start with routes.js
as it should look the most familiar:
module.exports = {
'view-user': {
path: '/users/<username>',
params: {
username: /^[a-z]+$/
}
}
// other routes here...
};
Each entry in the route map is a config object for an osh-route.
The module, view-user.js
exports a page prototype, which defines
the lifecycle methods for a page.
var request = require('superagent'); // isomorphic!..mostly
module.exports = {
/**
* Read data from various APIs. In this case, we
* pull some user data from a fictitious API.
*/
read: function(pages, done) {
var page = this;
request.get('https://api.mysite.com/users/' + this.props.username)
.end(function(res) {
page.setState({
fullname: (
res.ok ?
res.body.fullname :
'Unknown user'
),
title: (
res.ok ?
this.props.username :
'Not found'
)
});
done();
});
},
/**
* Not a lifecycle method; separated from renderToString and
* renderToDocument for code reuse.
*/
renderBody: function() {
return (
'<h1>' + this.escape(this.state.fullname) + '</h1>'
);
},
/**
* Lifecycle method. Render on the server.
*/
renderToString: function() {
return (
'<!DOCTYPE html>' +
'<head>' +
'<title>' + this.state.title + '</title>' +
'</head>' +
'<body>' +
this.renderBody() +
this.renderAjax() +
'</body>' +
'</html>'
);
},
/**
* Lifecycle method. Render on the browser. Use a DOM renderer
* with diffing, like ReactJS, rather than what is done here.
*/
renderToDocument: function() {
document.body.innerHTML = this.renderBody();
document.title = this.state.title;
},
/**
* Lifecycle method. Enhance HTML with js in the browser.
*/
run: function() {
// Initialize onclick handlers and other goodies.
}
};
Finally, you serve the app using Express, and optionally use the built-in
bundler (based on dynapack) to
enable client-side AJAX rendering and navigation. The following is
server.js
:
var express = require('express');
var serveStatic = require('serve-static');
var Pages = require('osh-pages');
var app = express();
var pages = Pages({basedir: __dirname});
// Paths are relative to basedir
pages.routes('./routes');
pages.set('view-user', './view-user');
pages.bundle({
output: './bundles',
prefix: '/js/'
});
app.use(pages);
app.use('/js', serveStatic(__dirname + '/bundles'));
pages.on('bundled', function() {
app.listen(3333);
});
Documentation
Pages(opts)
- options
basedir
: All configuration parameters that are specified as relative paths are assumed relative to this directory.- default:
process.cwd()
- required: yes
- type:
String
- default:
routes
: See documentation for routes().
Call this to create a new pages instance on the server; it returns an express middleware function augmented with the following setup methods.
pages.routes(path)
path
: Path to a module that exports a routes object; the sitemap. Can be relative tobasedir
set in constructor.- required: yes
- type: String
Example routes.js:
module.exports = {
'user': {
path: '/users/<username>',
params: {
username: /^[a-z]+$/
}
},
'article': {
path: '/articles/<articleId>',
params: {
articleId: /^\w+$/
}
}
};
pages.set(name, page)
name
: The name of a route exported by the routes module given to routes().- type: String
- required: yes
page
: Path to a module that exports a Page prototype. Can be relative tobasedir
set in constructor.- type: String
- required: yes
Register page logic with a route. You need to register by way of a module path so that Pages can bundle your javascript.
pages.fn(name, fn)
name
: The name of the server function.fn
: The server function.- Signature:
fn(opts, done)
whereopts
is POJO data anddone
is a callback. Pass error and result as first and second arg, respectively, todone
.
- Signature:
Register a server function (or remote procedure) with the Pages instance. A server function is callable within a Page's read/write methods and runs on only the server (when called in the browser, an AJAX request handles the function call for you, being careful to send and check a CSRF token for security).
Use server functions when a task needs to be performed privately, like authenticating with an OAuth2-capable API server.
Within the server function, this
has the following properties:
this.session
: The current Session instance.
Example:
pages.fn('refreshAccessToken', function(opts, done) {
// For persisting the refresh token.
var session = this.session;
request.post('https://api.api.api/oauth/token')
.auth('thewebs', 'sshh')
.send({
grant_type: 'refresh_token',
refresh_token: session.secrets.refreshToken
})
.end(function(err, res) {
if (err) done(err);
else {
session.setSecrets({refreshToken: res.body.refresh_token});
done(null, res.body.access_token);
}
});
});
pages.bundle(opts)
- options
output
: Output directory for bundles. Can be relative to basedir.- type: String
- default:
'/bundles'
prefix
: Prefix for script urls. If serving scripts from the same express app that houses the pages instance, this value should match the mount path (with a trailing slash).
Bundle up javascript using Dynapack. Options are passed to Dynapack after resolving any relative paths.
When bundling has finished, the 'bundled'
event is fired on the pages
instance.
Example:
pages.bundle({
output: __dirname + '/bundles',
prefix: '/js/'
});
app.use(pages);
app.use('/js', serveStatic(__dirname + '/bundles'));
pages.on('bundled', function() {
app.listen(3333);
});
Page
A Page prototype registered with the pages.set() method should implement the following API. Lifecycle methods are required to do anything useful.
Lifecycle methods
These methods should be defined on a Page prototype. Just remember, read/write/render/run/fun/profit/glory/gratitude/humility (that got out of hand...).
read(pages, render)
Called on a GET request for the Page.
Using information in this.props
and the given pages
object, gather data
from APIs and make calls to this.setState(state)
to prepare the page for
rendering. Call the render
callback without arguments when ready to render
the page.
The pages
object contains the following properties to help with optimization
and managing session state:
pages.session
: The current Session instance.pages.current
: The currently rendered page. If you are not managing your own caching, use this to migrate state from the old page to the new page without requerying an API. The only properties available are:pages.current.name
pages.current.props
pages.current.state
It also houses every server function registered with pages.fn().
The render
callback doubles as a redirector; passing it either a URI or a
name/props pair will skip rendering of the current page and either send a 302
response (if running on the server) or begin an AJAX GET of the indicated page
(if running in the browser). For example, if there was an error fetching data
from an API, you can redirect to a not-found page via:
module.exports = {
read: function(pages, render) {
var page = this;
var session = pages.session;
var current = pages.current;
if (current && current.props.username === 'beatrix') {
this.setState({
user: current.state.user
});
render();
}
else {
api.getUser('beatrix', function(err, user) {
if (err) {
render('404', {msg: err.message});
// Assuming the '404' route path is simply: '/not-found', the
// following would be equivalent:
//render('/not-found?msg=' + encodeURIComponent(err.message));
}
else {
page.setState({user: user});
render();
}
});
}
}
// ...
};
where '404'
is the name of a route.
write(pages, redirect)
Called when the page is POSTed to. This method stands alone; render methods are not called after write, because pages should not be returned from POST requests, only redirects (see this wonderful treatise on the topic). It is possible to have a page prototype that consists only of a write method (do this to create a route that serves only POST requests).
The pages
object contains the following properties to help with
managing session state:
pages.session
: The current Session instance.
It also houses every server function registered with pages.fn().
Inside the write method, this.payload
is used to access the data that was
POSTed from the form. Standard urlencoded forms will result in a simple
this.payload
object, where keys are form input names. For example, submission
of the form:
<form>
<input name="greeting" type="text" value="hello"/>
<input type="submit"/>
</form>
would result in a payload object (shown as json):
{
"greeting": "hello"
}
If the form encoding was multipart/form-data
(for
file uploads), then the this.payload
object will be a readable stream which
can be piped to a superagent request to some API. If not piping, the payload
can be split up by listening for 'field'
events (this.payload
is also an
event emitter in this case).
The given redirect method should be called with a name and props object like,
redirect('view-user', {username: 'tory'});
or with a uri
redirect('/users/tory');
In the following contrived example, the write method is enacting a POST request that will attempt to change the full name of a user:
var Page = module.exports = {
// ...
write: function(session, redirect) {
request.post('https://api.mysite.com/users/' + session.state.username)
.set('x-api-key', session.state.apiKey)
.send({
fullname: this.payload.fullname
})
.end(function(res) {
if (res.ok) {
redirect('view-user', {
username: session.state.username
});
}
else {
redirect('update-user-form', {
// Some error message from the API server:
msg: res.body.message
});
}
});
}
};
renderToString(pages)
Called on the server for initial renders (subsequent page visits in the
session are rendered on the client using renderToDocument). This should
return the entire page html, including <!DOCTYPE html>
, <head>
, and
whatnot.
The pages
object passed to this method has the following
properties:
pages.csrf
: Properties required by osh-pages when submitting forms to protect against cross-site request forgeries. The following strings should be set as attributes on a hidden<input>
element that appears first in any<form>
groups. Each property name matches the<input>
attribute name on which it should be set.pages.csrf.name
: Field name recognized by osh-pages.pages.csrf.value
: The csrf token.
pages.uri(name, props)
: Get a URI from route name/props pairs for creating links.
It is common to define methods on your Page prototype that will be shared between renderToString and renderToDocument to keep your code DRY (e.g. renderBody, renderTitle, etc.).
Example:
module.exports = {
// ...
renderToString: function() {
return (
'<!DOCTYPE html>' +
'<head>' +
this.escape(this.renderTitle()) +
'</head>' +
'<body>' +
this.renderBody() +
this.renderAjax() +
'</body>'
'</html>'
);
}
};
render(pages)
Update the document to show the current page. A very basic implementation (that would defeat the purpose of AJAX navigation) might be:
module.exports = {
// ...
renderBody: function(pages) {
return 'so much html...';
},
render: function(pages) {
document.body.innerHTML = this.renderBody(pages);
document.title = this.renderTitle();
}
};
A more performant version would find the smallest difference between the currently rendered page and the page to render, and update only those elements of the document that need it. ReactJS provides automatic DOM diffing and is a good choice here (in fact, this library was built with React rendering in mind); in principle, any DOM diffing/rendering tool would work here.
Event handlers should not be attached to the document in this step. Instead they should be attached in the run() lifecycle method, which gets called both on initial page load and after each AJAX render. In general, it is okay to push rendering into the run method (at the risk of re-rendering your initial page), but not okay to push progressive enhancement into the render method.
Note: In the case of a view library like React, which provides rendering and progressive enhancement, simply defer rendering to run() (although setting some DOM, like document.title, might be more appropriate for render()). Up to you.
recoverState(pages)
If stashing was disabled in Page.read(), the state that was not stashed should be recovered from the server-rendered HTML in this method. This is called once per browser session, on initial page load.
For example, an API might return a large chunk of raw HTML. Rather than use the automatic Pages stashing and recovery, it would be more efficient to read the HTML from the document on initial page load.
Example:
module.exports = {
read: function(pages, render) {
var page = this;
// Important... see this later in the docs. Turns off all
// stashing.
page.stash(false);
request.get('https://api.blog.com/posts/42')
.end(function(res) {
page.setState({
blogPost: res.body
});
render();
});
},
renderToString: function(pages) {
return: (
'<div id="post">' + this.state.blogPost + '</div>'
);
},
recoverState: function(pages) {
this.setState({
blogPost: document.getElementById('post').innerHTML
});
},
run: function(pages) {
// and we have it...
console.log(this.state.blogPost);
}
};
run(pages)
Attach event handlers to the DOM. Or use a view library (like ReactJS) that handles progressive enhancement, DOM diffing/rendering, and event handling.
Instance methods
These methods are used from within the lifecycle methods described above.
setState(state)
Call this in Page.read() to set downloaded state on the page instance.
stash(boolean)
Toggle stashing of state set with setState(). By default, stashing is turned on, so that all the state set in a Page's read lifecycle method is available in the browser on initial page load (without the need for requerying APIs).
renderAjax()
Call this in renderToString to enable AJAX/progressive enhancement. It
includes <script>
elements for the javascript bundles generated by Dynapack
and a <span>
for transporting state that was stashed in a Page's read method
for reuse in the browser.
Session
This is passed in to the read/write lifecycle methods.
Pages reference
This is the object that is passed in to a Page's lifecycle methods.
csrf
session
A function used for setting session state which doubles as a container for the existing session state. For example,
read: function(pages, render) {
// Set session state
pages.session({username: 'tory'});
// Get session state
pages.session.username; // 'tory'
// ...
}
secrets
properties
All available on this
.
- props
- state
- csrf
- request
name
The string label given to the page at registration.
pages
This is a reference to the Pages instance in which it was registered. Use this for navigation in the browser (by calling
A Page consists of props, data, and display. Props identify the page, data is a display-neutral representation of the page, and display takes the props and data and generates/modifies html.
Props are used to
- Build urls
- Submit data requests
- Fetch display logic
A set of props uniquely defines a webpage.