fusionjs / fusion-core

Migrated to https://github.com/fusionjs/fusionjs
MIT License
630 stars 45 forks source link
fusion fusionjs

fusion-core

Build status

The fusion-core package provides a generic entry point class for FusionJS applications that is used by the FusionJS runtime. It also provides primitives for implementing server-side code, and utilities for assembling plugins into an application to augment its functionality.

If you're using React, you should use the fusion-react package instead of fusion-core.


Table of contents


Usage

// main.js
import React from 'react';
import ReactDOM from 'react-dom';
import {renderToString} from 'react-dom/server';
import App from 'fusion-core';

const el = <div>Hello</div>;

const render = el =>
  __NODE__
    ? `<div id="root">${renderToString(el)}</div>`
    : ReactDOM.render(el, document.getElementById('root'));

export default function() {
  return new App(el, render);
}

API

App

import App from 'fusion-core';

A class that represents an application. An application is responsible for rendering (both virtual dom and server-side rendering). The functionality of an application is extended via plugins.

Constructor

const app: App = new App(el: any, render: Plugin<Render>|Render);

app.register

app.register(plugin: Plugin);
app.register(token: Token, plugin: Plugin);
app.register(token: Token, value: any);

Call this method to register a plugin or configuration value into a Fusion.js application.

You can optionally pass a token as the first argument to associate the plugin/value to the token, so that they can be referenced by other plugins within Fusion.js' dependency injection system.

app.middleware

app.middleware((deps: Object<string, Token>), (deps: Object) => Middleware);
app.middleware((middleware: Middleware));

This method is a shortcut for registering middleware plugins. Typically, you should write middlewares as plugins so you can organize different middlewares into different files.

app.enhance

app.enhance(token: Token, value: any => Plugin | Value);

This method is useful for composing / enhancing functionality of existing tokens in the DI system.

app.cleanup

await app.cleanup();

Calls all plugin cleanup methods. Useful for testing.


Dependency registration

ElementToken
import App, {ElementToken} from 'fusion-core';
app.register(ElementToken, element);

The element token is used to register the root element with the fusion app. This is typically a react/preact element.

RenderToken
import ReactDOM from 'react-dom';
import {renderToString} from 'react-dom/server';
const render = el =>
  __NODE__
    ? renderToString(el)
    : ReactDOM.render(el, document.getElementById('root'));
import App, {RenderToken} from 'fusion-core';
const app = new App();
app.register(RenderToken, render);

The render token is used to register the render function with the fusion app. This is a function that knows how to render your application on the server/browser, and allows fusion-core to remain agnostic of the virtualdom library.

SSRDeciderToken
import App, {SSRDeciderToken} from 'fusion-core';
app.enhance(SSRDeciderToken, SSRDeciderEnhancer);

Ths SSRDeciderToken can be enhanced to control server rendering logic.

HttpServerToken
import App, {HttpServerToken} from 'fusion-core';
app.register(HttpServerToken, server);

The HttpServerToken is used to register the current server as a dependency that can be utilized from plugins that require access to it. This is normally not required but is available for specific usage cases.


Plugin

A plugin encapsulates some functionality into a single coherent package that exposes a programmatic API and/or installs middlewares into an application.

Plugins can be created via createPlugin

type Plugin {
  deps: Object<string, Token>,
  provides: (deps: Object) => any,
  middleware: (deps: Object, service: any) => Middleware,
  cleanup: ?(service: any) => void
}
createPlugin
import {createPlugin} from 'fusion-core';

Creates a plugin that can be registered via app.register()

const plugin: Plugin = createPlugin({
  deps: Object,
  provides: (deps: Object) => any,
  middleware: (deps: Object, service: any) => Middleware,
  cleanup: ?(service: any) => void
});

Token

A token is a label that can be associated to a plugin or configuration when they are registered to an application. Other plugins can then import them via dependency injection, by mapping a object key in deps to a token

type Token {
  name: string,
  ref: mixed,
  type: number,
  optional: ?Token,
}
createToken
const token:Token = createToken(name: string);

Memoization

import {memoize} from 'fusion-core';

Sometimes, it's useful to maintain the same instance of a plugin associated with a request lifecycle. For example, session state.

Fusion.js provides a memoize utility function to memoize per-request instances.

const memoized = {from: memoize((fn: (ctx: Context) => any))};

Idiomatically, Fusion.js plugins provide memoized instances via a from method. This method is meant to be called from a middleware:

createPlugin({
  deps: {Session: SessionToken},
  middleware({Session}) {
    return (ctx, next) => {
      const state = Session.from(ctx);
    }
  }
}

Middleware

type Middleware = (ctx: Context, next: () => Promise) => Promise

A middleware function is essentially a Koa middleware, a function that takes two argument: a ctx object that has some FusionJS-specific properties, and a next callback function. However, it has some additional properties on ctx and can run both on the server and the browser.

In FusionJS, the next() call represents the time when virtual dom rendering happens. Typically, you'll want to run all your logic before that, and simply have a return next() statement at the end of the function. Even in cases where virtual DOM rendering is not applicable, this pattern is still the simplest way to write a middleware.

In a few more advanced cases, however, you might want to do things after virtual dom rendering. In that case, you can call await next() instead:

const middleware = () => async (ctx, next) => {
  // this happens before virtual dom rendering
  const start = new Date();

  await next();

  // this happens after virtual rendeing, but before the response is sent to the browser
  console.log('timing: ', new Date() - start);
};

Plugins can add dependency injected middlewares.

// fusion-plugin-some-api
const APIPlugin = createPlugin({
  deps: {
    logger: LoggerToken,
  },
  provides: ({logger}) => {
    return new APIClient(logger);
  },
  middleware: ({logger}, apiClient) => {
    return async (ctx, next) => {
      // do middleware things...
      await next();
      // do middleware things...
    };
  },
});
Context

Middlewares receive a ctx object as their first argument. This object has a property called element in both server and client.

Additionally, when server-side rendering a page, FusionJS sets ctx.template to an object with the following properties:

When a request does not require a server-side render, ctx.body follows regular Koa semantics.

In the server, ctx also exposes the same properties as a Koa context

Sanitization

html

import {html} from 'fusion-core';

A template tag that creates safe HTML objects that are compatible with ctx.template.head and ctx.template.body. Template string interpolations are escaped. Use this function to prevent XSS attacks.

const sanitized: SanitizedHTML = html`<meta name="viewport" content="width=device-width, initial-scale=1">`

escape

import {escape} from 'fusion-core';

Escapes HTML

const escaped:string = escape(value: string)

unescape

import {unescape} from 'fusion-core';

Unescapes HTML

const unescaped:string = unescape(value: string)

dangerouslySetHTML

import {dangerouslySetHTML} from 'fusion-core';

A function that blindly creates a trusted SanitizedHTML object without sanitizing against XSS. Do not use this function unless you have manually sanitized your input and written tests against XSS attacks.

const trusted:string = dangerouslySetHTML(value: string)

Examples

Dependency injection

To use plugins, you need to register them with your Fusion.js application. You do this by calling app.register with the plugin and a token for that plugin. The token is a value used to keep track of what plugins are registered, and to allow plugins to depend on one another.

You can think of Tokens as names of interfaces. There's a list of common tokens in the fusion-tokens package.

Here's how you create a plugin:

import {createPlugin} from 'fusion-core';
// fusion-plugin-console-logger
const ConsoleLoggerPlugin = createPlugin({
  provides: () => {
    return console;
  },
});

And here's how you register it:

// src/main.js
import ConsoleLoggerPlugin from 'fusion-plugin-console-logger';
import {LoggerToken} from 'fusion-tokens';
import App from 'fusion-core';

export default function main() {
  const app = new App(...);
  app.register(LoggerToken, ConsoleLoggerPlugin);
  return app;
}

Now let's say we have a plugin that requires a logger. We can map logger to LoggerToken to inject the logger provided by ConsoleLoggerPlugin to the logger variable.

// fusion-plugin-some-api
import {createPlugin} from 'fusion-core';
import {LoggerToken} from 'fusion-tokens';

const APIPlugin = createPlugin({
  deps: {
    logger: LoggerToken,
  },
  provides: ({logger}) => {
    logger.log('Hello world');
    return new APIClient(logger);
  },
});

The API plugin is declaring that it needs a logger that matches the API documented by the LoggerToken. The user then provides an implementation of that logger by registering the fusion-plugin-console-logger plugin with the LoggerToken.

Implementing HTTP endpoints

You can use a plugin to implement a RESTful HTTP endpoint. To achieve this, run code conditionally based on the URL of the request

app.middleware(async (ctx, next) => {
  if (ctx.method === 'GET' && ctx.path === '/api/v1/users') {
    ctx.body = await getUsers();
  }
  return next();
});

Serialization and hydration

A plugin can be atomically responsible for serialization/deserialization of data from the server to the client.

The example below shows a plugin that grabs the project version from package.json and logs it in the browser:

// plugins/version-plugin.js
import fs from 'fs';
import {html, unescape, createPlugin} from 'fusion-core'; // html sanitization

export default createPlugin({
  middleware: () => {
    const data = __NODE__ && JSON.parse(fs.readFileSync('package.json').toString());
    return async (ctx, next) => {
      if (__NODE__) {
        ctx.template.head.push(html`<meta id="app-version" content="${data.version}">`);
        return next();
      } else {
        const version = unescape(document.getElementById('app-version').content);
        console.log(`Version: ${version}`);
        return next();
      }
    });
  }
});

We can then consume the plugin like this:

// main.js
import React from 'react';
import App from 'fusion-core';
import VersionPlugin from './plugins/version-plugin';

const root = <div>Hello world</div>;

const render = el =>
  __NODE__ ? renderToString(el) : render(el, document.getElementById('root'));

export default function() {
  const app = new App(root, render);
  app.register(VersionPlugin);
  return app;
}

HTML sanitization

Default-on HTML sanitization is important for preventing security threats such as XSS attacks.

Fusion automatically sanitizes htmlAttrs and title. When pushing HTML strings to head or body, you must use the html template tag to mark your HTML as sanitized:

import {html} from 'fusion-core';

const middleware = (ctx, next) => {
  if (ctx.element) {
    const userData = await getUserData();
    // userData can't be trusted, and is automatically escaped
    ctx.template.body.push(html`<div>${userData}</div>`)
  }
  return next();
}

If userData above was <script>alert(1)</script>, ththe string would be automatically turned into <div>\u003Cscript\u003Ealert(1)\u003C/script\u003E</div>. Note that only userData is escaped, but the HTML in your code stays intact.

If your HTML is complex and needs to be broken into smaller strings, you can also nest sanitized HTML strings like this:

const notUserData = html`<h1>Hello</h1>`;
const body = html`<div>${notUserData}</div>`;

Note that you cannot mix sanitized HTML with unsanitized strings:

ctx.template.body.push(html`<h1>Safe</h1>` + 'not safe'); // will throw an error when rendered

Also note that only template strings can have template tags (i.e. html`<div></div>`). The following are NOT valid Javascript: html"<div></div>" and html'<div></div>'.

If you get an Unsanitized html. You must use html`[your html here]` error, remember to prepend the html template tag to your template string.

If you have already taken steps to sanitize your input against XSS and don't wish to re-sanitize it, you can use dangerouslySetHTML(string) to let Fusion render the unescaped dynamic string.

Enhancing a dependency

If you wanted to add a header to every request sent using the registered fetch.

app.register(FetchToken, window.fetch);
app.enhance(FetchToken, fetch => {
  return (url, params = {}) => {
    return fetch(url, {
      ...params,
      headers: {
        ...params.headers,
        'x-test': 'test',
      },
    });
  };
});

You can also return a Plugin from the enhancer function, which provides the enhanced value, allowing the enhancer to have dependencies and even middleware.

app.register(FetchToken, window.fetch);
app.enhance(FetchToken, fetch => {
  return createPlugin({
    provides: () => (url, params = {}) => {
      return fetch(url, {
        ...params,
        headers: {
          ...params.headers,
          'x-test': 'test',
        },
      });
    },
  });
});

Controlling SSR behavior

By default we do not perfrom SSR for any paths that match the following extensions: js, gif, jpg, png, pdf and json. You can control SSR behavior by enhancing the SSRDeciderToken. This will give you the ability to apply custom logic around which routes go through the renderer. You may enhance the SSRDeciderToken with either a function, or a plugin if you need dependencies.

import {SSRDeciderToken} from 'fusion-core';
app.enhance(SSRDeciderToken, decide => ctx =>
  decide(ctx) && !ctx.path.match(/ignore-ssr-route/)
);