Skip to content

Instantly share code, notes, and snippets.

@julesfern
Created April 28, 2011 09:09
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save julesfern/946059 to your computer and use it in GitHub Desktop.
Save julesfern/946059 to your computer and use it in GitHub Desktop.

SPA Single page architecture lib design "Spah"

Assumptions:

  • Host API is RESTish
  • All host-client transfer is with JSON and all client-host transfer is with POST and multipart POST.
  • Client-side progressive enhancements are the responsibility of the developer
  • The server-side portion of the library will be ruby-based, and the client-side portion will be jquery-based, but the DTOs used to communicate between two will be thoroughly documented to allow drop-in replacement on either side.

Goals:

  1. Unified templating system - no duplication of templates between server-side and client-side apps
  2. All URLs requested must be proper resource URLs.
  3. Stateful transfer of view state at a developer-defined level of detail between page renders when performing synchronous actions such as POSTs.

Implicit requirements:

  1. Server-side app provides an API endpoint or set of HTML nodes in the app layout containing the raw templates
  2. Every basic request for an HTML document must be answered with, at the very least, a very close approximation of the resulting client-side generated markup.
    • This enables functionality in no-js environments
    • This enables things like multipart POST requests to operate without sneaky iframe tricks, since the app state will be restored when the page renders after a successful post.
  3. Graceful boot from any state. Once a page has rendered as in requirement #2, client-side scripts must be able to pick up the application state and apply progressive enhancements as appropriate.

Basic design:

Based on requirements:

  • Web apps must be cold-bootable from any URL, allowing:
    • graceful failure for no-js and JS runtime errors
    • synchronous actions such as file uploads to take place without upsetting the asynchronous parts of the client
  • Web apps should be document based - a genuinely useful document must be served from the server for each gettable resource
    • n.b. RESTful responses with no response body are acceptable for post-and-redirect actions with no associated view, such as tagging

Broken into loose tech points:

  • The server-side app, as usual, handles all validations, authentication and session management

  • All inbound sessions start with a full GET request to a resource URL e.g. /projects/100/tickets/50, which is routed to the relevant controller and action. At this point the paradigm changes from regular rails development.

  • Rather than rendering a different configuration of layout, template and partials for each controller action, all calls to an HTML action at first render the same master HTML template (which may use erb or haml for rendering convenience), and the renderer is passed a ViewState object which determines how the HTML will be populated.

  • All conditional logic and loops are embedded in the HTML as data attributes, allowing both client and server access to the same logic code. All conditions and loops are based on the content of the ViewState.

  • The ViewState has structure arbitrary to the developer's application, but for general purposes can be considered to be a hash that may contain arrays, strings, booleans or other hashes.

      Tails' ViewState might look like this for a project view:
    
      {
        "view-type": "project",
        "authenticated-user": {"id": 1, "name": "Bob Foo", "avatar-key": 16}, 
        "project": {"type": "Project", "id": 1, "name": "Videojuicer", "list-view-tab": 0, "list-view-query-result": [
          {"type": "Ticket", "id": 100, "ticket_number": 9, "title": "dosnt work lul", "description": "FIX PLS", "tags": ["foo", "bar", "foo bar"]},
          {"type": "Ticket", "id": 101...},
        ]}
      }
    
      Whilst for the outer product pages it may look like:
    
      {
        "view-type": "product-site",
        "authenticated-user": null,
        "product_page": "home"
      }
    
  • During a cold boot, a ViewState is created from the default values and modified by the controller action, before being passed into the renderer which will post-process and populate the HTML file based on any embedded declarative logic statements. The ViewState can always be found marshalled in the data-viewstate attribute on the body element. Spah will update the data-viewstate attribute automatically during warm loads.

  • During a warm load, the ViewState is sent from the client to the server, modified and returned. This action is triggered by any request with a content-accept header of application/spah-viewstate. Controllers may return the modified viewstate in a #respond_to block using format.spa.

  • Any time a modified ViewState is received by the client during a warm load operation, conditions within the document are re-evaluated and the resulting actions are performed. Yes, the secret to the strategy is to provide both Javascript and Ruby implementations of:

    • The ViewState parser

    • The ViewState query language, which is an xpath-style means of probing a ViewState for values.

    • The ViewState callback set, which is a set of declarative instructions that may added to elements and executed by either the client or server when the conditions on that node are met.

    • Here's an example node set:

          <div id="project-root" data-if="//view-type != 'project'" data-then="hide">
            <!-- This node will be hidden from sight if the view-type is not "project" -->
        
            <h1 data-populate="//project/name">
              <!-- This node will be populated with the contents of the viewstate -->
            </h1>
        
            <a href="admin" class="admin" data-if="//project/user_id != //authenticated-user/id" data-then="remove">
              <!-- This node will be removed from the DOM if the user is not the administrator -->
              You are logged in as an administrator.
            </a>
        
            <ul class="tickets" data-if="//project/list-view-query-result" data-then="populate('//project/list-view-query-result', 'tickets/single')">
              <!-- This node is populated with a render of the 'tickets/single' template for each item in the specified path -->
              <!-- You could also append limit and offset arguments to the populate function. ViewState paths are always acceptable arguments. -->
            </ul>
        
          </div>
      
  • Other example queries may be:

    • "/key" returns true if there is a key "key" in the root of the ViewState.
    • "key" returns true if there is any key "key" in the ViewState
    • "/container//ancestor" returns true if there is any key "ancestor" below a key "container" in the viewstate
    • "/container[attr=value]" returns true if there is an item "container" at the root containing a hash which defines the key "attr"
    • "/container//[attr=value]" returns true if there is an item "container" at the root containing any item which defines the key "attr"
  • In addition to re-evaluating document conditions on receipt of a modified ViewState, the client may define client-only Javascript responders for changes to the ViewState using the jQuery plugin:

      $(element).addViewStateResponder("//path/to/listen", function(viewstate, subtree, previousviewstate) {
        if(subtree) {
          // The subtree was modified or added
        } else {
          // The subtree was removed
        }
      })
    
  • The embedded document conditions are intended to allow the server to deliver a meaningful document for any resource request, even to clients without javascript support.

  • The jQuery responders are intended to provide for more advanced "actual intended" behaviour such as lazy loading, effects, context management and more.

  • Once cold-booted in a JS environment, the application takes over history management and intercepts navigation actions by inserting the new location into the history manager stack and sending the current ViewState to that location as an SPA request, to be modified and returned in the background. Shebang links can be used as a fallback.

  • Synchronous actions such as file uploads may be managed as well:

    • In a no-js fallback environment the form will include the initial ViewState as a parameter to be processed by the server so that the next page may be rendered in the correct state.
    • In a with-js warm-booted environment the form will include the up-to-date ViewState as a parameter, allowing the state to be handed over to the server for an effective cold-restart once the file upload action has completed.
  • The ViewState may contain rich sets of objects such as large object arrays. To minimise outbound bandwidth use, the JS library can reduce the ViewState before marshalling it to the server.

    • Convention: All model-type objects in the viewstate contain a "type" attribute e.g. "type": "Ticket"

    • Viewstate reduction algorithm affects changes to the viewstate before any server interaction:

          // Reduces all types to just the "id" attribute before marshalling to the server, except tickets which are marshalled with both ID and ticket_number
          Spah.ViewState.Reducer.configureTypes({"*": ["id"], "Ticket": ["id", "ticket_number"]});
          // This may also be done imperatively:
          Spah.ViewState.Reducer.setPathReducer("//project", function(content, path) {
            return content; // no reduction happens with this return. 
            // Path reducers prevent the simple type-based reducer from running on the specified path. Only one reducer per path, except in the example below:
          })
          
          Spah.ViewState.Reducer.setPathReducer("//project/ticket-query-result", function(content, path) {
            // this reducer will be run after the path reducer defined above, provided the results of the query on this reducer still produce results after the first reducer has run.
            return content;
          });
          
          // The run operation is such that all path reducer queries are run before any reduction is made, and the matching elements sorted topologically.
          // This sorted list then executes the reduction functions against itself, using the last-defined reducer for each ViewState path.
          // Paths which were removed by a previous reducer are not executed against.
          
          // Reduction is called automatically when fetching the ViewState for marshalling with either:
          Spah.marshalViewState(); //-> as object
          Spah.marshalViewStateJSON(); // -> marshalled as JSON string
      
  • Templates are assumed to always work with the ViewState or a part of the ViewState as a payload - no native AR objects, just hashes, arrays, strings and bools.

    • Mustache is an ideal template language for this.
    • Templates may be called from the Rails app's view directory and should use the .html.mustache extension
    • Templates may be handed to the client in two ways:
      • Asynchronously loaded during boot. The server-side application must provide an action for this.
      • Included in the bottom of the document as <script type="text/mustache" id="tickets/single">theContent</script>
    • In either case, the server-side library will index all usable templates on boot and make the raw template code available for handover.
  • Authentication errors may be handled through several channels:

    • Straight-up denial codes from the server (40X, 50X) may be listened for on the client:

          // Should accept status argument in forms: int status OR array statuses OR string statusMask e.g. "40X"
          $(document).addViewStateErrorHandler([403, 404], function(viewstate, status) {
            MyApp.UI.showNotification("The requested resource couldn't be found, or you don't have permission to access it.");
          });
      
    • Redirections will be followed by the client automatically. Redirect responses won't modify the existing viewstate, but the viewstate will be supplied to each redirect location until a success and a modified ViewState are returned, or until the redirect limit is reached.

  • In order to remain perceptually responsive, the Spah loader may make provisional changes to the viewstate which will be rewound in the event of a request failure. Here's how you'd set up a link that makes provisional ViewState modifications:

      // HTML:
      <a href="/projects/1" class="project">Tails</a>
      // JS:
      // Do not add the loader itself in here - Spah's init method will treat any link as async unless it has the data-async="false" attribute
      $("a.project").beforeViewStateRequest(function() {
        $(this).eagerUpdateViewState({"/project": "loading"}); // runs Spah.ViewState.modifyUntilLoad(this, {"//project": "loading"});
        // Keys in the provided hash arg are evaluated using the standard ViewState query language, all matching ViewState nodes are updated
        // If the loader associated with this link fails, the modification will be rewound. Else, the modifications will be overwritten with the server-modified ViewState.
      });
      
      // The expected result would be for this code to trigger:
      $("#project").addViewStateResponder("//project", function(viewstate, subtree, previousviewstate) {
        if(subtree == "loading") {
          MyApp.UI.showProjectLoadingNotification();
        }
      });
    

Goals reached by the above design:

  • Mimics good web development practices - vital markup goes in the HTML, enhancements are added progressively by the JS.
  • Stateful view management achieved
  • No template logic duplicated - all template logic embedded in markup and usable by both client and server.
  • Server gets full control of security and validations.
  • Server gets ability to perform complex modifications to the user view without compromising RESTfulness or introducing unnecessary controller actions.
  • Data bindings are achievable using the ViewState query language and sensible document semantics - any time new properties are received for an object all DOM elements referring to that object may be updated.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment