Script for managing the lifecycles of multiple apps on a single page


License
ISC
Install
npm install app-manager@0.27.0

Documentation

Build Status

app-manager

Script for managing the lifecycles of multiple apps on a single page

Use Case

  • Your app has been built with a microservices architecture.
  • Your front-end code has become sufficiently complex that it warrants splitting up and/or is isomorphically rendered
  • You wish to keep your app 'feeling' like an SPA

Minimal Example

config.js

export default {
  slots: {
    APP: {
      querySelector: '.app',
    },
  },

  fragments: {
    EXAMPLE1_FRAGMENT: {
      slot: 'APP',
      async loadScript(state) {
        return fetchScriptForExample1(state);
      },
      async ssrGetMarkup(querySelector, state, query) {
        return /* @html */`
          <div class="${querySelector.slice(1)}">
            ${await fetchMarkupForExample1(state, query)}
          </div>
        `;
      },
    },

    EXAMPLE2_FRAGMENT: {
      slot: 'APP',
      async loadScript(state) {
        return fetchScriptForExample2(state);
      },
      async ssrGetMarkup(querySelector, state, query) {
        return /* @html */`
          <div class="${querySelector.slice(1)}">
            ${await fetchMarkupForExample2(state, query)}
          </div>
        `;
      },
    }
  },

  routes: {
    EXAMPLE1_APP: {
      path: '/apps/example1',
      fragment: 'EXAMPLE1_FRAGMENT',
    },

    EXAMPLE2_APP: {
      path: '/apps/example2',
      fragment: 'EXAMPLE2_FRAGMENT',
    }
  }
}

client.js

import appManager from 'app-manager';

import config from './config';

// ...

const options = {
  importTimeout: 3000,
};

appManager(config, eventEmitter, options);

server.js

import appManagerServer from 'app-manager/server';

import config from './config';

const { getSlotsMarkup } = appManagerServer(config);

// ...

app.get('/apps/*', async (req, res, next) => {
  try {
    const renderedMarkup = await getSlotsMarkup(req.originalUrl, req.query);

    return res.send(/* @html */`
      <!DOCTYPE html>
      <html>
        <body>
          ${renderedMarkup.APP}
          <script src="/static/main.js"></script>
        </body>
      </html>
    `);
  } catch (err) {
    next(err);
  }
});

Api

app-manager exports a function that takes three parameters:

  • config - describes your app to app-manager
  • events - a event emitter module with the same API as the native node.js module
  • options - Optional options object

Config

An object containing maps of the slots, fragments, and routes that comprise your app. Provided values are illustrative.

{
  slots: {
    SLOT_NAME: slot, // As below
  },
  fragments: {
    FRAGMENT_NAME: fragment, // As below
  },
  routes: {
    ROUTE_NAME: route, // As below
  },
}

State

The state object is passed as a parameter into almost every function within and without app-manager. It contains the useful current state of the browser and the derived state of the application. Provided values are illustrative.

{
  // The pathname and query string currently displayed in the browser
  resource: '/app/some-path?query=value',

  // The current document page title
  title: 'My Page',

  // The current contents of the history state object
  historyState: null,

  // The name of the event (as below) that caused the current action to occur
  eventTitle: 'hc-initialise',

   // The config for the current route (as below)
  route,

  // The config for the route that was previous to the current one. Is null when app is first loaded.
  prevRoute,

  // Any additional state returned from the getAdditionalState function passed into options (as below)
  ...additionalState,
}

Route

A route determines which fragments are displayed on which path. Provided values are illustrative.

{
  // Path, or paths that uniquely identifies this app. Analogous to a route in express.
  path: '/app/:path',
  paths: ['/app/:path1', '/app/:path2'],

  // Fragment name or ordered array of fragment names to be displayed for that route.
  // If two fragments occupy the same slot, the first will take precedence.
  fragment: 'FRAGMENT_NAME',
  fragments: ['APP_FRAGMENT_NAME', 'HEADER_FRAGMENT_NAME'],
}

Slot

A slot is a wrapper for a DOM element on a page. Provided values are illustrative.

{
  // Uniquely returns a single DOM element when passed to document.querySelector.
  querySelector: '.app',

  // Optional function that should return some markup to be displayed in a slot
  // between a fragment being unmounted and a new one being mounted in its place.
  async getLoadingMarkup(state) {
    return /* @html */`<img src="/spinner.gif" alt="Loading ${state.route.name}" />`
  },

  // Optional function that returns a markup string to be displayed in a slot
  // if one of the lifecycle methods for a script throws an error.
  async getErrorMarkup(state) {
    return /* @html */`<p class="error">An error occurred while browsing to ${state.route.name}.`;
  },

  // Optional function that's called if options.ssrHaltOnError is false and ssrGetMarkup
  // for a fragment which is being loaded into this slot throws an error.
  // Should return a markup string.
  async ssrGetErrorMarkup(querySelector, state, ...otherArgs) {
    return /* @html */`
      <div class="${querySelector.slice(1)}">
        <p class="error">An error occurred while loading ${state.route.name}.</p>
      </div>
    `;
  },
},

Fragment

A fragment is the container for your script.

{
  // Slot name or ordered array of slot names.
  // Fragment will be loaded in the first empty slot possible.
  slot: 'SLOT_NAME',
  slots: ['LEFT_SLOT_NAME', 'RIGHT_SLOT_NAME'],

  // Optional function (only needed if using app-manager to manage client-side lifecycle)
  // that fetches the script (as below) for your fragment.
  async loadScript(state) {
    return script;
  },

  // Optional function (only needed if using app-manager for server-side rendering)
  // that fetches the markup needed to render your fragment into a DOM.
  //
  // Should contain any serialised app state you wish to use to hydrate the
  // app on the client, any inline styles, etc.
  //
  // Function is called with the querySelector of the slot into which it is being mounted,
  // state (as above), and any arguments you pass into 'appManagerServer.getSlotsMarkup'.
  async ssrGetMarkup(querySelector, state, ...otherArgs) {
    return markupString;
  },
}

Script

A script is the entry-point to your code.

It should contain the lifecycle methods to be called as the user browses around your site. Provided values are illustrative.

{
  // Lets app-manager know which schema to expect from your script
  version: 6,

  // Optional function that will be called if the parent fragment is included
  // on the page on first load
  //
  // If rendering your app isomorphically, you will likely wish to read app state from the DOM here.
  //
  // Function is called with the element into which the fragment is to be mounted and state (as above)
  async hydrate(container, state) {
    const appState = window['__your_app_state__']
    ReactDOM.hydrate(container, <YourApp {...appState} />);
  },

  // Optional function that will be called if the user browses onto an path for which
  // the parent fragment is to be mounted.
  //
  // If your app relies on initial state, you should fetch it here
  //
  // Function is called with the element into which the fragment is to be mounted and state (as above)
  async render(container, state) {
    const props = await getInitialStateFromServer(state.params);

    ReactDOM.render(container, <YourApp {...props} />);
  },

  // Optional function that will be called when an event is fired from the history api,
  // and the fragment is to remain mounted on the page
  //
  // If your fragment has multiple views that should be routed between (for example with path params),
  // this lifecycle method will be where routing is managed.
  //
  // Function is called with state (as above)
  async onStateChange(container, state) {
    await updateAppState(state);
  },

  // Optional function that will be called when the user browses away from an path
  // on which the parent fragment is mounted.
  //
  // Useful to clear up any event listeners, and to reset any mutable state.
  //
  // Function is called with the element into which the fragment is to be mounted and state (as above)
  async unmount(container, state) {
    ReactDOM.unmountComponentAtNode(container);
  },
}

Events

Suggested: EventEmitter3

{
  emit(eventTitle, data) {
    // ...
  },
  on(eventTitle, callback) {
    // ...
  },
  removeListener(eventTitle, callback) {
    // ...
  },
}

List of events emitted by app-manager

  • hc-halted
  • hc-error
  • hc-warning
  • hc-beforeunload
  • hc-popstate
  • hc-replacestate
  • hc-pushstate
  • hc-statechange
  • hc-initialise

Options

All properties in the options object are optional. Provided values are illustrative.

{
  // Timeout for loading scripts and finding elements in the DOM. Defaults to 4000
  importTimeout: 4000,

  // Boolean value that determines whether the getSlotsMarkup function should throw on error
  // or call the slot's ssrGetErrorMarkup. Defaults to false.
  ssrHaltOnError: false,

  // Query selector that is passed into the getElement function  (below) to find
  // the parent element of your app. If not provided, document.body is used.
  parentQuerySelector: '.app',

  // Function that takes a state object and returns the name of a route derived from that state.
  // Defaults to a function that scans the list of routes passed in config and
  // performs an equality check between the current resource (pathname + query) and each route's path
  async getRouteName(state) {
    const params = await deriveParamsFromResource(state.resource);

    return params.routeName;
  },

  // Function that allows you to return an object of arbitrary values that will
  // be passed into the state object
  async getAdditionalState(state) {
    const params = await deriveParamsFromResource(state.resource);

    return {
      params,
    };
  },

  // Function that takes a parent DOM Element and the query selector for the slot we're
  // trying to find on the page, and should return a DOM Element.
  // Defaults to container.querySelector(querySelector)
  async getElement(container, querySelector) {
    return container.querySelector(querySelector);
  },

  // Function that is called when browsing onto an app-manager controlled page without hydrating.
  // Allows you to set the default static DOM into which the app will be rendered.
  // Useful when nesting app-manager instances.
  async getLayout(state) {
    return /* @html */`<div class="app"></div>`;
  }.
}