Skip to content

Latest commit

 

History

History
731 lines (561 loc) · 19.5 KB

README.md

File metadata and controls

731 lines (561 loc) · 19.5 KB

Webpages.js

A Node.js framework for API-consuming web apps that unifies AJAX client-side behavior and graceful degradation. Apps built on webpages.js should work with or without javascript in the browser, which means

  • initial page loads are snappy,
  • user actions (navigation, form submission) can precede js enhancement, and
  • pages are search-engine ready,

all through a single javascript API.

Features include:

  • Automatic AJAX for both <a/> and <form/> actions
  • Session management and CSRF protection
  • Easy integration with dynapack

Installation

npm install webpages

Usage

First, a brief overview, then the minimal example!

Overview

You write page prototypes, which are just javascript objects with the following lifecycle methods:

  • read: Get data from APIs, save it in page state.
  • recover: Extract data from the DOM.
  • render: Render the data to the DOM.
  • run: Attach DOM event listeners.
  • write: Send user data to APIs.

Then, you write a layout page prototype, whose render method returns an html string that includes <head>, <link>, etc. Its render method overwrites the render method of your regular page prototypes for the server.

Finally, you match urls to page prototypes in a routes file, and tie it all together with a webpages() instance. Bundling, AJAX, server-side rendering, and batteries included!

Example

A simple example describes it best, so here is the minimal set of files we need to get going.

routes.js
layout.js
page.js
user-page.js
server.js

The contents of each are printed below; let's start with routes.js as it should look the most familiar:

var routes = {
  // Only one route, b/c we have only one page.
  'user': {
    path: '/users/<username>',
    params: {
      username: /^[a-z]+$/
    }
  }
};

module.exports = routes;

Each entry in the route map is a config object for an osh-route.

The file layout.js exports an object that will be merged into all page prototypes on the server (i.e., layout attributes overwrite page prototype attributes). The layout should export a render function that returns the full html string:

var Layout = {
  render: function(pages) {
    return (
      '<!DOCTYPE html>' +
        '<head>' +
          '<title>' + this.state.title + '</title>' +
        '</head>' +
        '<body>' +
          this.state.body +
          this.renderAjax() +
        '</body>' +
      '</html>'
    );
  }
};

module.exports = Layout;

The file page.js defines the base page prototype and takes care of rendering (and rerendering) page state to the browser document.

var Page = {
  /**
   *  Render on the browser. Use a DOM renderer
   *  with diffing, like ReactJS, rather than what is done here.
   */

  render: function(pages) {
    document.body.innerHTML = this.state.body;
    document.title = this.state.title;
  }
};

module.exports = Page;

The module user-page.js exports a complete page prototype, grabbing data from an API server using SuperAgent (which works on the client and server--important!) and preparing the state for rendering.

var request = require('superagent'); // isomorphic!..mostly
var merge = require('xtend/immutable');
var escape = require('escape-html');
var Page = require('./page');

/**
 *  Extend our basic rendering page prototype with a read method.
 */

var UserPage = merge(Page, {
  /**
   *  Read data from various APIs. In this case, we
   *  pull some user data from a fictitious API.
   */

  read: function(pages, render) {
    var page = this;

    request.get('https://api.mysite.com/users/' + this.props.username)
    .end(function(err, res) {
      var user = res && res.body;
      page.setState({
        title: page.props.username, // see routes.js
        body: '<h1>' + escape(err ? err.message : user && user.fullname) + '</h1>'
      });
      render();
    });
  }
});

module.exports = UserPage;

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 webpages = require('webpages');
var dynapack = require('dynapack');
var dest = require('vinyl-fs').dest;

var __bundles = __dirname + '/bundles';

var pack = dynapack({prefix: '/js/'});
var app = express();

var pages = webpages({
  basedir: __dirname, // all paths are relative to basedir
  routes: './routes',
  layout: './layout',
  scripts: __bundles
});

pages.set('user', './user-page');

pages.entries().pipe(pack).pipe(dest(__bundles));
pack.scripts().pipe(dest(__bundles));

app.use(pages);
app.use('/js', serveStatic(__bundles));

pack.on('end', function() {
  app.listen(3333);
});

Documentation

webpages(opts)

Call this to create a new pages instance on the server; it returns an express middleware function augmented with the following setup methods.

For example:

var webpages = require('webpages');

var pages = webpages({
  basedir: __dirname,
  routes: './routes',
  layout: './layout',
  scripts: __dirname + '/bundles'
});

opts.basedir

  • default: process.cwd()
  • required: yes
  • type: String

All configuration parameters that are specified as relative paths are assumed relative to this directory.

opts.routes

  • required: yes
  • type: String

Path to a module that exports an object mapping route ids to Route config objects. The path can be relative to basedir set in constructor.

Example routes.js:

module.exports = {
  'user': {
    path: '/users/<username>',
    params: {
      username: /^[a-z]+$/
    }
  },
  'article': {
    path: '/articles/<articleId>',
    params: {
      articleId: /^\w+$/
    }
  }
};

opts.layout

Path to a module that exports a page prototype with a render method that returns a string of HTML. This object is merged into all page prototypes for rendering on the server.

opts.scripts

Path to the directory that contains one html file for each route/page. Each html file should contain the scripts you want to load for the corresponding page; webpages.js places the contents of this file directly in the rendered page HTML at the base of the body element.

Webpages.js relies on a naming scheme to locate each page's scripts file. For example, if you registered the route names "home" and "user", then you should have the files "home.main.html" and "user.main.html" in the scripts directory.

This behavior allows webpages.js to work almost seamlessly with dynapack and gulp.js.

pages

The following methods are available on a webpages instance:

var pages = webpages(opts);

pages.set(name, page)

  • name: The id of a route exported by the routes module.
    • type: String
    • required: yes
  • page: Path to a module that exports a page prototype. Can be relative to basedir.
    • type: String
    • required: yes

Register page logic with a route. You need to register by way of a module path so that webpages can bundle your javascript.

pages.fn(name, fn)

  • name: The name of the server function.
  • fn: The server function.
    • Signature: fn(opts, done) where opts is POJO data and done is a callback. Pass error and result as first and second arg, respectively, to done.

Register a server function (or remote procedure) with the webpages 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.

Errors returned to the done callback will have only their err.message property sent back to the client if the server function call came from the browser.

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.entries()

Returns a readable stream that outputs an "entry" javascript file (in vinyl File format) for each route/page in your app. These files were designed for piping into Dynapack in a gulp.js workflow.

var webpages = require('webpages');
var dynapack = require('dynapack');
var dest = require('vinyl-fs').dest;
var __scripts = __dirname + '/js';

var pages = webpages({
  scripts: __scripts 
  // ...
});

var pack = dynapack();

pages.entries().pipe(pack).pipe(dest(__scripts));
pack.scripts().pipe(dest(__scripts));

pack.on('end', function() {
  var app = express();
  app.use(pages);
  app.listen(8080);
});

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.

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('error', {code: 404, msg: err.message});
          // Assuming the 'error' route path is simply: '/error', the
          // following would be equivalent:
          //render('/error?code=404&msg=' + encodeURIComponent(err.message));
        }
        else {
          page.setState({user: user});
          render();
        }
      });
    }
  }

  // ...
};

where 'error' is the name of a route.

recover(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 webpages.js stashing and recovery feature, it would be more efficient to read the HTML from the document on initial page load.

For example, given the layout prototype:

var Layout = {
  render: function(pages) {
    return (
      '<div id="post">' + this.state.blogPost + '</div>'
    );
  }
};

Custom state stashing and recovery could be acheived as follows:

var MyPage = {
  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.text
      });
      render();
    });
  },

  // Called on only the browser.
  recover: function(pages) {
    this.setState({
      blogPost: document.getElementById('post').innerHTML
    });
  },

  run: function(pages) {
    // and we have it...
    console.log(this.state.blogPost);
  }
};

render(pages)

This method should exist on all page prototypes that have a read() method and on the special layout prototype.

In both versions, the pages object has the following properties:

  • pages.csrf: Properties required by webpages.js 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.
Layout.render(pages)

The render method on the layout prototype is called on the server for initial renders. This version should return the entire page html, including <!DOCTYPE html>, <head>, and whatnot.

Example:

var escape = require('escape-html');

var Layout = {
  // ...

  render: function(pages) {
    return (
      '<!DOCTYPE html>' +
        '<head>' +
          escape(this.state.title) +
        '</head>' +
        '<body>' +
          this.state.body +
          this.renderAjax() +
        '</body>'
      '</html>'
    );
  }
};

module.exports = Layout;
Page.render(pages)

Update the browser document to show the current page. A very basic implementation (that would defeat the purpose of AJAX navigation) might be:

var MyPage = {
  // ...

  render: function(pages) {
    document.body.innerHTML = this.state.body;
    document.title = this.state.title;
  }
};

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. React.js 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.

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.

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.

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
        });
      }
    });
  }
};

Instance methods

These methods are used from within the lifecycle methods described above.

setState(state)

Call this in Page.read() and Page.recover() to set downloaded or recovered 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 your layout render method 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 by way of the pages object. Use it to set small chunks of data in cookies that are persisted between pages. There are two types of state that can be set to a session, public and secret. Public state is available on pages.session.state whereas secret state is only available on this.session.secrets from within a server function.

session.setState(state)

Store public data in a browser cookie. This function can be called anywhere (browser or server) and does not set the http-only flag. This state persis

session.setSecrets(secrets)

Store secret session data in a http-only browser cookie. This method is only callable from within a server function, where the session instance is found at this.session.

License

MIT