app-manager

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

Usage no npm install needed!

<script type="module">
  import appManager from 'https://cdn.skypack.dev/app-manager';
</script>

README

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.

{
  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.

{
  resource: '/app/some-path?query=value', // The pathname and query string currently displayed in the browser
  title: 'My Page', // The current document page title
  historyState: null, // The current contents of the history state object
  eventTitle: 'hc-initialise', // The name of the event (as below) that caused the current action to occur
  route, // The config for the current route (as below)
  prevRoute, // The config for the route that was previous to the current one. Is null when app is first loaded.
  ...additionalState, // Any additional state returned from the getAdditionalState function passed into options (as below)
}

Route

A route determines which fragments are displayed on which path.

{
  path: '/app/:path', // Path or...
  paths: ['/app/:path1', '/app/:path2'], // ...paths that uniquely identifies this app. Analogous to a route in express.
  fragment: 'FRAGMENT_NAME', // Fragment name or...
  fragments: ['APP_FRAGMENT_NAME', 'HEADER_FRAGMENT_NAME'], // ...ordered array of fragment names to be displayed for that route. If two fragments occupy the same slot, the first will take precedence.
}

Slot

A slot is a wrapper for a DOM element on a page.

{
  querySelector: '.app', // Uniquely returns a single DOM element when passed to document.querySelector.
  async getLoadingMarkup(state) {
    // 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.

    return /* @html */`<img src="/spinner.gif" alt="Loading ${state.route.name}" />`
  },
  async getErrorMarkup(state) {
    // 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.

    return /* @html */`<p class="error">An error occurred while browsing to ${state.route.name}.`;
  },
  async ssrGetErrorMarkup(querySelector, state, ...otherArgs) {
    // 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.

    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: 'SLOT_NAME', // Slot name or...
  slots: ['LEFT_SLOT_NAME', 'RIGHT_SLOT_NAME'], // ...ordered array of slot names. Fragment will be loaded in the first empty slot possible.
  async loadScript(state) {
    // * Optional function (only needed if using app-manager to manage client-side lifecycle) that fetches the script (as below) for your fragment.

    // ...

    return script;
  },
  async ssrGetMarkup(querySelector, state, ...otherArgs) {
    // * 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 below), and any arguments you pass into the 'appManagerServer.getSlotsMarkup' function.

    // ...

    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:

{
  version: 6 // Lets app-manager know which schema to expect from your script
  async hydrate(container, state) {
    // * 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 below)

    const appState = window['__your_app_state__']
    ReactDOM.hydrate(container, <YourApp {...appState} />);
  },
  async render(container, state) {
    // * 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 below)

    const props = await getInitialStateFromServer(state.params);

    ReactDOM.render(container, <YourApp {...props} />);
  },
  async onStateChange(container, state) {
    // * 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 below)

    await updateAppState(state);
  },
  async unmount(container, 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 below)

    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.

{
  importTimeout: 4000, // Timeout for loading scripts and finding elements in the DOM. Defaults to 4000
  ssrHaltOnError: false, // Boolean value that determines whether the getSlotsMarkup function should throw on error or call the slot's ssrGetErrorMarkup. Defaults to false.
  parentQuerySelector: '.app', // 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.
  async getRouteName(state) {
    // 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

    const params = await deriveParamsFromResource(state.resource);

    return params.routeName;
  },
  async getAdditionalState(state) {
    // Function that allows you to return an object of arbitrary values that will be passed into the state object

    const params = await deriveParamsFromResource(state.resource);

    return {
      params,
    };
  },
  async getElement(container, querySelector) {
    // 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)

    return container.querySelector(querySelector);
  },
  async getLayout(state) {
    // 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.

    return /* @html */`<div class="app"></div>`;
  }.
}