Index: frontend/node_modules/bfj/src/datastream.js
===================================================================
--- frontend/node_modules/bfj/src/datastream.js	(revision 9af201e94b5a79beb92a30858fc54de4bc3b9449)
+++ frontend/node_modules/bfj/src/datastream.js	(revision 9af201e94b5a79beb92a30858fc54de4bc3b9449)
@@ -0,0 +1,17 @@
+'use strict'
+
+const check = require('check-types')
+const BfjStream = require('./stream')
+const util = require('util')
+
+util.inherits(DataStream, BfjStream)
+
+module.exports = DataStream
+
+function DataStream (read, options) {
+  if (check.not.instanceStrict(this, DataStream)) {
+    return new DataStream(read, options)
+  }
+
+  return BfjStream.call(this, read, { ...options, objectMode: true })
+}
Index: frontend/node_modules/bfj/src/error.js
===================================================================
--- frontend/node_modules/bfj/src/error.js	(revision 9af201e94b5a79beb92a30858fc54de4bc3b9449)
+++ frontend/node_modules/bfj/src/error.js	(revision 9af201e94b5a79beb92a30858fc54de4bc3b9449)
@@ -0,0 +1,22 @@
+'use strict'
+
+module.exports = { create }
+
+function create (actual, expected, line, column) {
+  const error = new Error(
+    /* eslint-disable prefer-template */
+    'JSON error: encountered `' + actual +
+    '` at line ' + line +
+    ', column ' + column +
+    ' where `' + expected +
+    '` was expected.'
+    /* eslint-enable prefer-template */
+  )
+
+  error.actual = actual
+  error.expected = expected
+  error.lineNumber = line
+  error.columnNumber = column
+
+  return error
+}
Index: frontend/node_modules/bfj/src/eventify.js
===================================================================
--- frontend/node_modules/bfj/src/eventify.js	(revision 9af201e94b5a79beb92a30858fc54de4bc3b9449)
+++ frontend/node_modules/bfj/src/eventify.js	(revision 9af201e94b5a79beb92a30858fc54de4bc3b9449)
@@ -0,0 +1,309 @@
+'use strict'
+
+const check = require('check-types')
+const EventEmitter = require('events').EventEmitter
+const events = require('./events')
+const promise = require('./promise')
+
+const invalidTypes = {
+  undefined: true, // eslint-disable-line no-undefined
+  function: true,
+  symbol: true
+}
+
+module.exports = eventify
+
+/**
+ * Public function `eventify`.
+ *
+ * Returns an event emitter and asynchronously traverses a data structure
+ * (depth-first), emitting events as it encounters items. Sanely handles
+ * promises, buffers, maps and other iterables. The event emitter is
+ * decorated with a `pause` method that can be called to pause processing.
+ *
+ * @param data:       The data structure to traverse.
+ *
+ * @option promises:  'resolve' or 'ignore', default is 'resolve'.
+ *
+ * @option buffers:   'toString' or 'ignore', default is 'toString'.
+ *
+ * @option maps:      'object' or 'ignore', default is 'object'.
+ *
+ * @option iterables:  'array' or 'ignore', default is 'array'.
+ *
+ * @option circular:   'error' or 'ignore', default is 'error'.
+ *
+ * @option yieldRate:  The number of data items to process per timeslice,
+ *                     default is 16384.
+ *
+ * @option Promise:      The promise constructor to use, defaults to bluebird.
+ **/
+function eventify (data, options = {}) {
+  const coercions = {}
+  const emitter = new EventEmitter()
+  const Promise = promise(options)
+  const references = new Map()
+
+  let count = 0
+  let disableCoercions = false
+  let ignoreCircularReferences
+  let ignoreItems
+  let pause
+  let yieldRate
+
+  emitter.pause = () => {
+    let resolve
+    pause = new Promise(res => resolve = res)
+    return () => {
+      pause = null
+      count = 0
+      resolve()
+    }
+  }
+  parseOptions()
+  setImmediate(begin)
+
+  return emitter
+
+  function parseOptions () {
+    parseCoercionOption('promises')
+    parseCoercionOption('buffers')
+    parseCoercionOption('maps')
+    parseCoercionOption('iterables')
+
+    if (Object.keys(coercions).length === 0) {
+      disableCoercions = true
+    }
+
+    if (options.circular === 'ignore') {
+      ignoreCircularReferences = true
+    }
+
+    check.assert.maybe.positive(options.yieldRate)
+    yieldRate = options.yieldRate || 16384
+  }
+
+  function parseCoercionOption (key) {
+    if (options[key] !== 'ignore') {
+      coercions[key] = true
+    }
+  }
+
+  function begin () {
+    return proceed(data)
+      .catch(error => emit(events.error, error))
+      .then(() => emit(events.end))
+  }
+
+  function proceed (datum) {
+    if (++count % yieldRate !== 0) {
+      return coerce(datum).then(after)
+    }
+
+    return new Promise((resolve, reject) => {
+      setImmediate(() => {
+        coerce(datum)
+          .then(after)
+          .then(resolve)
+          .catch(reject)
+      })
+    })
+
+    function after (coerced) {
+      if (isInvalid(coerced)) {
+        return
+      }
+
+      if (coerced === false || coerced === true || coerced === null) {
+        return literal(coerced)
+      }
+
+      if (Array.isArray(coerced)) {
+        return array(coerced)
+      }
+
+      const type = typeof coerced
+
+      switch (type) {
+        case 'number':
+          return value(coerced, type)
+        case 'string':
+          return value(escapeString(coerced), type)
+        default:
+          return object(coerced)
+      }
+    }
+  }
+
+  function coerce (datum) {
+    if (disableCoercions || check.primitive(datum)) {
+      return Promise.resolve(datum)
+    }
+
+    if (check.thenable(datum)) {
+      return coerceThing(datum, 'promises', coercePromise).then(coerce)
+    }
+
+    if (check.instanceStrict(datum, Buffer)) {
+      return coerceThing(datum, 'buffers', coerceBuffer)
+    }
+
+    if (check.instanceStrict(datum, Map)) {
+      return coerceThing(datum, 'maps', coerceMap)
+    }
+
+    if (
+      check.iterable(datum) &&
+      check.not.string(datum) &&
+      check.not.array(datum)
+    ) {
+      return coerceThing(datum, 'iterables', coerceIterable)
+    }
+
+    if (check.function(datum.toJSON)) {
+      return Promise.resolve(datum.toJSON())
+    }
+
+    return Promise.resolve(datum)
+  }
+
+  function coerceThing (datum, thing, fn) {
+    if (coercions[thing]) {
+      return fn(datum)
+    }
+
+    return Promise.resolve()
+  }
+
+  function coercePromise (p) {
+    return p
+  }
+
+  function coerceBuffer (buffer) {
+    return Promise.resolve(buffer.toString())
+  }
+
+  function coerceMap (map) {
+    const result = {}
+
+    return coerceCollection(map, result, (item, key) => {
+      result[key] = item
+    })
+  }
+
+  function coerceCollection (coll, target, push) {
+    coll.forEach(push)
+
+    return Promise.resolve(target)
+  }
+
+  function coerceIterable (iterable) {
+    const result = []
+
+    return coerceCollection(iterable, result, item => {
+      result.push(item)
+    })
+  }
+
+  function isInvalid (datum) {
+    const type = typeof datum
+    return !! invalidTypes[type] || (
+      type === 'number' && ! isValidNumber(datum)
+    )
+  }
+
+  function isValidNumber (datum) {
+    return datum > Number.NEGATIVE_INFINITY && datum < Number.POSITIVE_INFINITY
+  }
+
+  function literal (datum) {
+    return value(datum, 'literal')
+  }
+
+  function value (datum, type) {
+    return emit(events[type], datum)
+  }
+
+  function emit (event, eventData) {
+    return (pause || Promise.resolve())
+      .then(() => emitter.emit(event, eventData))
+      .catch(err => {
+        try {
+          emitter.emit(events.error, err)
+        } catch (_) {
+          // When calling user code, anything is possible
+        }
+      })
+  }
+
+  function array (datum) {
+    // For an array, collection:object and collection:array are the same.
+    return collection(datum, datum, 'array', item => {
+      if (isInvalid(item)) {
+        return proceed(null)
+      }
+
+      return proceed(item)
+    })
+  }
+
+  function collection (obj, arr, type, action) {
+    let ignoreThisItem
+
+    return Promise.resolve()
+      .then(() => {
+        if (references.has(obj)) {
+          ignoreThisItem = ignoreItems = true
+
+          if (! ignoreCircularReferences) {
+            return emit(events.dataError, new Error('Circular reference.'))
+          }
+        } else {
+          references.set(obj, true)
+        }
+      })
+      .then(() => emit(events[type]))
+      .then(() => item(0))
+
+    function item (index) {
+      if (index >= arr.length) {
+        if (ignoreThisItem) {
+          ignoreItems = false
+        }
+
+        if (ignoreItems) {
+          return Promise.resolve()
+        }
+
+        return emit(events.endPrefix + events[type])
+          .then(() => references.delete(obj))
+      }
+
+      if (ignoreItems) {
+        return item(index + 1)
+      }
+
+      return action(arr[index])
+        .then(() => item(index + 1))
+    }
+  }
+
+  function object (datum) {
+    // For an object, collection:object and collection:array are different.
+    return collection(datum, Object.keys(datum), 'object', key => {
+      const item = datum[key]
+
+      if (isInvalid(item)) {
+        return Promise.resolve()
+      }
+
+      return emit(events.property, escapeString(key))
+        .then(() => proceed(item))
+    })
+  }
+
+  function escapeString (string) {
+    string = JSON.stringify(string)
+    return string.substring(1, string.length - 1)
+  }
+}
Index: frontend/node_modules/bfj/src/events.js
===================================================================
--- frontend/node_modules/bfj/src/events.js	(revision 9af201e94b5a79beb92a30858fc54de4bc3b9449)
+++ frontend/node_modules/bfj/src/events.js	(revision 9af201e94b5a79beb92a30858fc54de4bc3b9449)
@@ -0,0 +1,18 @@
+'use strict'
+
+module.exports = {
+  array: 'arr',
+  object: 'obj',
+  property: 'pro',
+  string: 'str',
+  number: 'num',
+  literal: 'lit',
+  endPrefix: 'end-',
+  end: 'end',
+  error: 'err'
+}
+
+module.exports.endArray = module.exports.endPrefix + module.exports.array
+module.exports.endObject = module.exports.endPrefix + module.exports.object
+module.exports.endLine = `${module.exports.endPrefix}line`
+module.exports.dataError = `${module.exports.error}-data`
Index: frontend/node_modules/bfj/src/index.js
===================================================================
--- frontend/node_modules/bfj/src/index.js	(revision 9af201e94b5a79beb92a30858fc54de4bc3b9449)
+++ frontend/node_modules/bfj/src/index.js	(revision 9af201e94b5a79beb92a30858fc54de4bc3b9449)
@@ -0,0 +1,14 @@
+'use strict'
+
+module.exports = {
+  walk: require('./walk'),
+  match: require('./match'),
+  parse: require('./parse'),
+  unpipe: require('./unpipe'),
+  read: require('./read'),
+  eventify: require('./eventify'),
+  streamify: require('./streamify'),
+  stringify: require('./stringify'),
+  write: require('./write'),
+  events: require('./events')
+}
Index: frontend/node_modules/bfj/src/jsonstream.js
===================================================================
--- frontend/node_modules/bfj/src/jsonstream.js	(revision 9af201e94b5a79beb92a30858fc54de4bc3b9449)
+++ frontend/node_modules/bfj/src/jsonstream.js	(revision 9af201e94b5a79beb92a30858fc54de4bc3b9449)
@@ -0,0 +1,17 @@
+'use strict'
+
+const check = require('check-types')
+const BfjStream = require('./stream')
+const util = require('util')
+
+util.inherits(JsonStream, BfjStream)
+
+module.exports = JsonStream
+
+function JsonStream (read, options) {
+  if (check.not.instanceStrict(this, JsonStream)) {
+    return new JsonStream(read, options)
+  }
+
+  return BfjStream.call(this, read, { ...options, encoding: 'utf8' })
+}
Index: frontend/node_modules/bfj/src/match.js
===================================================================
--- frontend/node_modules/bfj/src/match.js	(revision 9af201e94b5a79beb92a30858fc54de4bc3b9449)
+++ frontend/node_modules/bfj/src/match.js	(revision 9af201e94b5a79beb92a30858fc54de4bc3b9449)
@@ -0,0 +1,286 @@
+'use strict'
+
+const check = require('check-types')
+const DataStream = require('./datastream')
+const events = require('./events')
+const Hoopy = require('hoopy')
+const jsonpath = require('jsonpath')
+const walk = require('./walk')
+
+const DEFAULT_BUFFER_LENGTH = 1024
+
+module.exports = match
+
+/**
+ * Public function `match`.
+ *
+ * Asynchronously parses a stream of JSON data, returning a stream of items
+ * that match the argument. Note that if a value is `null`, it won't be matched
+ * because `null` is used to signify end-of-stream in node.
+ *
+ * @param stream:         Readable instance representing the incoming JSON.
+ *
+ * @param selector:       Regular expression, string or predicate function used to
+ *                        identify matches. If a regular expression or string is
+ *                        passed, only property keys are tested. If a predicate is
+ *                        passed, both the key and the value are passed to it as
+ *                        arguments.
+ *
+ * @option minDepth:      Number indicating the minimum depth to apply the selector
+ *                        to. The default is `0`, but setting it to a higher value
+ *                        can improve performance and reduce memory usage by
+ *                        eliminating the need to actualise top-level items.
+ *
+ * @option numbers:       Boolean, indicating whether numerical keys (e.g. array
+ *                        indices) should be coerced to strings before testing the
+ *                        match. Only applies if the `selector` argument is a string
+ *                        or regular expression.
+ *
+ * @option ndjson:        Set this to true to parse newline-delimited JSON,
+ *                        default is `false`.
+ *
+ * @option yieldRate:     The number of data items to process per timeslice,
+ *                        default is 16384.
+ *
+ * @option bufferLength:  The length of the match buffer, default is 1024.
+ *
+ * @option highWaterMark: If set, will be passed to the readable stream constructor
+ *                        as the value for the highWaterMark option.
+ *
+ * @option Promise:       The promise constructor to use, defaults to bluebird.
+ **/
+function match (stream, selector, options = {}) {
+  const keys = []
+  const scopes = []
+  const properties = []
+  const emitter = walk(stream, options)
+  const matches = new Hoopy(options.bufferLength || DEFAULT_BUFFER_LENGTH)
+  let streamOptions
+  const { highWaterMark } = options
+  if (highWaterMark) {
+    streamOptions = { highWaterMark }
+  }
+  const results = new DataStream(read, streamOptions)
+
+  let selectorFunction, selectorPath, selectorString, resume
+  let coerceNumbers = false
+  let awaitPush = true
+  let isEnded = false
+  let length = 0
+  let index = 0
+
+  const minDepth = options.minDepth || 0
+  check.assert.greaterOrEqual(minDepth, 0)
+
+  if (check.function(selector)) {
+    selectorFunction = selector
+    selector = null
+  } else if (check.string(selector)) {
+    check.assert.nonEmptyString(selector)
+
+    if (selector.startsWith('$.')) {
+      selectorPath = jsonpath.parse(selector)
+      check.assert.identical(selectorPath.shift(), {
+        expression: {
+          type: 'root',
+          value: '$',
+        },
+      })
+      selectorPath.forEach((part) => {
+        check.assert.equal(part.scope, 'child')
+      })
+    } else {
+      selectorString = selector
+      coerceNumbers = !! options.numbers
+    }
+
+    selector = null
+  } else {
+    check.assert.instanceStrict(selector, RegExp)
+    coerceNumbers = !! options.numbers
+  }
+
+  emitter.on(events.array, array)
+  emitter.on(events.object, object)
+  emitter.on(events.property, property)
+  emitter.on(events.endArray, endScope)
+  emitter.on(events.endObject, endScope)
+  emitter.on(events.string, value)
+  emitter.on(events.number, value)
+  emitter.on(events.literal, value)
+  emitter.on(events.end, end)
+  emitter.on(events.error, error)
+  emitter.on(events.dataError, dataError)
+
+  return results
+
+  function read () {
+    if (awaitPush) {
+      awaitPush = false
+
+      if (isEnded) {
+        if (length > 0) {
+          after()
+        }
+
+        return endResults()
+      }
+    }
+
+    if (resume) {
+      const resumeCopy = resume
+      resume = null
+      resumeCopy()
+      after()
+    }
+  }
+
+  function after () {
+    if (awaitPush || resume) {
+      return
+    }
+
+    let i
+
+    for (i = 0; i < length && ! resume; ++i) {
+      if (! results.push(matches[i + index])) {
+        pause()
+      }
+    }
+
+    if (i === length) {
+      index = length = 0
+    } else {
+      length -= i
+      index += i
+    }
+  }
+
+  function pause () {
+    resume = emitter.pause()
+  }
+
+  function endResults () {
+    if (! awaitPush) {
+      results.push(null)
+    }
+  }
+
+  function array () {
+    scopes.push([])
+  }
+
+  function object () {
+    scopes.push({})
+  }
+
+  function property (name) {
+    keys.push(name)
+
+    if (scopes.length < minDepth) {
+      return
+    }
+
+    properties.push(name)
+  }
+
+  function endScope () {
+    if (selectorPath) {
+      keys.pop()
+    }
+    value(scopes.pop())
+  }
+
+  function value (v) {
+    let key
+
+    if (scopes.length < minDepth) {
+      return
+    }
+
+    if (scopes.length > 0) {
+      const scope = scopes[scopes.length - 1]
+
+      if (Array.isArray(scope)) {
+        key = scope.length
+      } else {
+        key = properties.pop()
+      }
+
+      scope[key] = v
+    }
+
+    if (v === null) {
+      return
+    }
+
+    if (selectorFunction) {
+      if (selectorFunction(key, v, scopes.length)) {
+        push(v)
+      }
+    } else if (selectorPath) {
+      if (isSelectorPathSatisfied([ ...keys, key ])) {
+        push(v)
+      }
+    } else {
+      if (coerceNumbers && typeof key === 'number') {
+        key = key.toString()
+      }
+
+      if ((selectorString && selectorString === key) || (selector && selector.test(key))) {
+        push(v)
+      }
+    }
+  }
+
+  function isSelectorPathSatisfied (path) {
+    if (selectorPath.length !== path.length) {
+      return false
+    }
+
+    return selectorPath.every(({ expression, operation }, i) => {
+      if (
+        (operation === 'member' && expression.type === 'identifier') ||
+        (operation === 'subscript' && (
+          expression.type === 'string_literal' ||
+          expression.type === 'numeric_literal'
+        ))
+      ) {
+        return path[i] === expression.value
+      }
+
+      if (
+        operation === 'subscript' &&
+        expression.type === 'wildcard' &&
+        expression.value === '*'
+      ) {
+        return true
+      }
+
+      return false
+    })
+  }
+
+  function push (v) {
+    if (length + 1 === matches.length) {
+      pause()
+    }
+
+    matches[index + length++] = v
+
+    after()
+  }
+
+  function end () {
+    isEnded = true
+    endResults()
+  }
+
+  function error (e) {
+    results.emit('error', e)
+  }
+
+  function dataError (e) {
+    results.emit('dataError', e)
+  }
+}
Index: frontend/node_modules/bfj/src/memory.js
===================================================================
--- frontend/node_modules/bfj/src/memory.js	(revision 9af201e94b5a79beb92a30858fc54de4bc3b9449)
+++ frontend/node_modules/bfj/src/memory.js	(revision 9af201e94b5a79beb92a30858fc54de4bc3b9449)
@@ -0,0 +1,45 @@
+'use strict'
+
+const PROPERTIES = [ 'rss', 'heapTotal', 'heapUsed', 'external' ]
+
+let memory
+
+module.exports = {
+  initialise,
+  update,
+  report
+}
+
+function initialise () {
+  memory = PROPERTIES.reduce((result, name) => {
+    result[name] = {
+      sum: 0,
+      hwm: 0
+    }
+    return result
+  }, { count: 0 })
+}
+
+function update () {
+  const currentMemory = process.memoryUsage()
+  PROPERTIES.forEach(name => updateProperty(name, currentMemory))
+}
+
+function updateProperty (name, currentMemory) {
+  const m = memory[name]
+  const c = currentMemory[name]
+  m.sum += c
+  if (c > m.hwm) {
+    m.hwm = c
+  }
+}
+
+function report () {
+  PROPERTIES.forEach(name => reportProperty(name))
+}
+
+function reportProperty (name) {
+  const m = memory[name]
+  // eslint-disable-next-line no-console
+  console.log(`mean ${name}: ${m.sum / memory.count}; hwm: ${m.hwm}`)
+}
Index: frontend/node_modules/bfj/src/parse.js
===================================================================
--- frontend/node_modules/bfj/src/parse.js	(revision 9af201e94b5a79beb92a30858fc54de4bc3b9449)
+++ frontend/node_modules/bfj/src/parse.js	(revision 9af201e94b5a79beb92a30858fc54de4bc3b9449)
@@ -0,0 +1,181 @@
+'use strict'
+
+const check = require('check-types')
+const events = require('./events')
+const promise = require('./promise')
+const walk = require('./walk')
+
+module.exports = parse
+
+const NDJSON_STATE = new Map()
+
+/**
+ * Public function `parse`.
+ *
+ * Returns a promise and asynchronously parses a stream of JSON data. If
+ * there are no errors, the promise is resolved with the parsed data. If
+ * errors occur, the promise is rejected with the first error.
+ *
+ * @param stream:     Readable instance representing the incoming JSON.
+ *
+ * @option reviver:   Transformation function, invoked depth-first.
+ *
+ * @option yieldRate: The number of data items to process per timeslice,
+ *                    default is 16384.
+ *
+ * @option Promise:   The promise constructor to use, defaults to bluebird.
+ *
+ * @option ndjson:    Set this to true to parse newline-delimited JSON. In
+ *                    this case, each call will be resolved with one value
+ *                    from the stream. To parse the entire stream, calls
+ *                    should be made sequentially one-at-a-time until the
+ *                    returned promise resolves to `undefined`.
+ **/
+function parse (stream, options = {}) {
+  const Promise = promise(options)
+
+  try {
+    check.assert.maybe.function(options.reviver, 'Invalid reviver option')
+  } catch (err) {
+    return Promise.reject(err)
+  }
+
+  const errors = []
+  const scopes = []
+  const reviver = options.reviver
+  const shouldHandleNdjson = !! options.ndjson
+
+  let emitter, resolve, reject, scopeKey
+  if (shouldHandleNdjson && NDJSON_STATE.has(stream)) {
+    const state = NDJSON_STATE.get(stream)
+    NDJSON_STATE.delete(stream)
+    emitter = state.emitter
+    setImmediate(state.resume)
+  } else {
+    emitter = walk(stream, options)
+  }
+
+  emitter.on(events.array, array)
+  emitter.on(events.object, object)
+  emitter.on(events.property, property)
+  emitter.on(events.string, value)
+  emitter.on(events.number, value)
+  emitter.on(events.literal, value)
+  emitter.on(events.endArray, endScope)
+  emitter.on(events.endObject, endScope)
+  emitter.on(events.end, end)
+  emitter.on(events.error, error)
+  emitter.on(events.dataError, error)
+
+  if (shouldHandleNdjson) {
+    emitter.on(events.endLine, endLine)
+  }
+
+  return new Promise((res, rej) => {
+    resolve = res
+    reject = rej
+  })
+
+  function array () {
+    if (errors.length > 0) {
+      return
+    }
+
+    beginScope([])
+  }
+
+  function beginScope (parsed) {
+    if (errors.length > 0) {
+      return
+    }
+
+    if (scopes.length > 0) {
+      value(parsed)
+    }
+
+    scopes.push(parsed)
+  }
+
+  function value (v) {
+    if (errors.length > 0) {
+      return
+    }
+
+    if (scopes.length === 0) {
+      return scopes.push(v)
+    }
+
+    const scope = scopes[scopes.length - 1]
+
+    if (scopeKey) {
+      scope[scopeKey] = v
+      scopeKey = null
+    } else {
+      scope.push(v)
+    }
+  }
+
+  function object () {
+    if (errors.length > 0) {
+      return
+    }
+
+    beginScope({})
+  }
+
+  function property (name) {
+    if (errors.length > 0) {
+      return
+    }
+
+    scopeKey = name
+  }
+
+  function endScope () {
+    if (errors.length > 0) {
+      return
+    }
+
+    if (scopes.length > 1) {
+      scopes.pop()
+    }
+  }
+
+  function end () {
+    if (shouldHandleNdjson) {
+      const resume = emitter.pause()
+      emitter.removeAllListeners()
+      NDJSON_STATE.set(stream, { emitter, resume })
+    }
+
+    if (errors.length > 0) {
+      return reject(errors[0])
+    }
+
+    if (reviver) {
+      scopes[0] = transform(scopes[0], '')
+    }
+
+    resolve(scopes[0])
+  }
+
+  function transform (obj, key) {
+    if (obj && typeof obj === 'object') {
+      Object.entries(obj).forEach(([ k, v ]) => {
+        obj[k] = transform(v, k)
+      })
+    }
+
+    return reviver(key, obj)
+  }
+
+  function error (e) {
+    errors.push(e)
+  }
+
+  function endLine () {
+    if (scopes.length > 0) {
+      end()
+    }
+  }
+}
Index: frontend/node_modules/bfj/src/promise.js
===================================================================
--- frontend/node_modules/bfj/src/promise.js	(revision 9af201e94b5a79beb92a30858fc54de4bc3b9449)
+++ frontend/node_modules/bfj/src/promise.js	(revision 9af201e94b5a79beb92a30858fc54de4bc3b9449)
@@ -0,0 +1,3 @@
+'use strict'
+
+module.exports = (options = {}) => options.Promise || require('bluebird')
Index: frontend/node_modules/bfj/src/read.js
===================================================================
--- frontend/node_modules/bfj/src/read.js	(revision 9af201e94b5a79beb92a30858fc54de4bc3b9449)
+++ frontend/node_modules/bfj/src/read.js	(revision 9af201e94b5a79beb92a30858fc54de4bc3b9449)
@@ -0,0 +1,26 @@
+'use strict'
+
+const fs = require('fs')
+const parse = require('./parse')
+
+module.exports = read
+
+/**
+ * Public function `read`.
+ *
+ * Returns a promise and asynchronously parses a JSON file read from disk. If
+ * there are no errors, the promise is resolved with the parsed data. If errors
+ * occur, the promise is rejected with the first error.
+ *
+ * @param path:       Path to the JSON file.
+ *
+ * @option reviver:   Transformation function, invoked depth-first.
+ *
+ * @option yieldRate: The number of data items to process per timeslice,
+ *                    default is 16384.
+ *
+ * @option Promise:   The promise constructor to use, defaults to bluebird.
+ **/
+function read (path, options) {
+  return parse(fs.createReadStream(path, options), { ...options, ndjson: false })
+}
Index: frontend/node_modules/bfj/src/stream.js
===================================================================
--- frontend/node_modules/bfj/src/stream.js	(revision 9af201e94b5a79beb92a30858fc54de4bc3b9449)
+++ frontend/node_modules/bfj/src/stream.js	(revision 9af201e94b5a79beb92a30858fc54de4bc3b9449)
@@ -0,0 +1,23 @@
+'use strict'
+
+const util = require('util')
+const Readable = require('stream').Readable
+const check = require('check-types')
+
+util.inherits(BfjStream, Readable)
+
+module.exports = BfjStream
+
+function BfjStream (read, options) {
+  if (check.not.instanceStrict(this, BfjStream)) {
+    return new BfjStream(read)
+  }
+
+  check.assert.function(read, 'Invalid read implementation')
+
+  this._read = function () { // eslint-disable-line no-underscore-dangle
+    read()
+  }
+
+  return Readable.call(this, options)
+}
Index: frontend/node_modules/bfj/src/streamify.js
===================================================================
--- frontend/node_modules/bfj/src/streamify.js	(revision 9af201e94b5a79beb92a30858fc54de4bc3b9449)
+++ frontend/node_modules/bfj/src/streamify.js	(revision 9af201e94b5a79beb92a30858fc54de4bc3b9449)
@@ -0,0 +1,283 @@
+'use strict'
+
+const check = require('check-types')
+const eventify = require('./eventify')
+const events = require('./events')
+const JsonStream = require('./jsonstream')
+const Hoopy = require('hoopy')
+const promise = require('./promise')
+const tryer = require('tryer')
+
+const DEFAULT_BUFFER_LENGTH = 1024
+
+module.exports = streamify
+
+/**
+ * Public function `streamify`.
+ *
+ * Asynchronously serialises a data structure to a stream of JSON
+ * data. Sanely handles promises, buffers, maps and other iterables.
+ *
+ * @param data:           The data to transform.
+ *
+ * @option space:         Indentation string, or the number of spaces
+ *                        to indent each nested level by.
+ *
+ * @option promises:      'resolve' or 'ignore', default is 'resolve'.
+ *
+ * @option buffers:       'toString' or 'ignore', default is 'toString'.
+ *
+ * @option maps:          'object' or 'ignore', default is 'object'.
+ *
+ * @option iterables:     'array' or 'ignore', default is 'array'.
+ *
+ * @option circular:      'error' or 'ignore', default is 'error'.
+ *
+ * @option yieldRate:     The number of data items to process per timeslice,
+ *                        default is 16384.
+ *
+ * @option bufferLength:  The length of the buffer, default is 1024.
+ *
+ * @option highWaterMark: If set, will be passed to the readable stream constructor
+ *                        as the value for the highWaterMark option.
+ *
+ * @option Promise:       The promise constructor to use, defaults to bluebird.
+ **/
+function streamify (data, options = {}) {
+  const emitter = eventify(data, options)
+  const json = new Hoopy(options.bufferLength || DEFAULT_BUFFER_LENGTH)
+  const Promise = promise(options)
+  const space = normaliseSpace(options)
+  let streamOptions
+  const { highWaterMark } = options
+  if (highWaterMark) {
+    streamOptions = { highWaterMark }
+  }
+  const stream = new JsonStream(read, streamOptions)
+
+  let awaitPush = true
+  let index = 0
+  let indentation = ''
+  let isEnded
+  let isPaused = false
+  let isProperty
+  let length = 0
+  let mutex = Promise.resolve()
+  let needsComma
+
+  emitter.on(events.array, noRacing(array))
+  emitter.on(events.object, noRacing(object))
+  emitter.on(events.property, noRacing(property))
+  emitter.on(events.string, noRacing(string))
+  emitter.on(events.number, noRacing(value))
+  emitter.on(events.literal, noRacing(value))
+  emitter.on(events.endArray, noRacing(endArray))
+  emitter.on(events.endObject, noRacing(endObject))
+  emitter.on(events.end, noRacing(end))
+  emitter.on(events.error, noRacing(error))
+  emitter.on(events.dataError, noRacing(dataError))
+
+  return stream
+
+  function read () {
+    if (awaitPush) {
+      awaitPush = false
+
+      if (isEnded) {
+        if (length > 0) {
+          after()
+        }
+
+        return endStream()
+      }
+    }
+
+    if (isPaused) {
+      after()
+    }
+  }
+
+  function after () {
+    if (awaitPush) {
+      return
+    }
+
+    let i
+
+    for (i = 0; i < length && ! awaitPush; ++i) {
+      if (! stream.push(json[i + index], 'utf8')) {
+        awaitPush = true
+      }
+    }
+
+    if (i === length) {
+      index = length = 0
+    } else {
+      length -= i
+      index += i
+    }
+  }
+
+  function endStream () {
+    if (! awaitPush) {
+      stream.push(null)
+    }
+  }
+
+  function noRacing (handler) {
+    return eventData => mutex = mutex.then(() => handler(eventData))
+  }
+
+  function array () {
+    return beforeScope()
+      .then(() => addJson('['))
+      .then(() => afterScope())
+  }
+
+  function beforeScope () {
+    return before(true)
+  }
+
+  function before (isScope) {
+    if (isProperty) {
+      isProperty = false
+
+      if (space) {
+        return addJson(' ')
+      }
+
+      return Promise.resolve()
+    }
+
+    return Promise.resolve()
+      .then(() => {
+        if (needsComma) {
+          if (isScope) {
+            needsComma = false
+          }
+
+          return addJson(',')
+        }
+
+        if (! isScope) {
+          needsComma = true
+        }
+      })
+      .then(() => {
+        if (space && indentation) {
+          return indent()
+        }
+      })
+  }
+
+  function addJson (chunk) {
+    if (length + 1 <= json.length) {
+      json[index + length++] = chunk
+      after()
+      return Promise.resolve()
+    }
+
+    isPaused = true
+    return new Promise(resolve => {
+      const unpause = emitter.pause()
+      tryer({
+        interval: -10,
+        until () {
+          return length + 1 <= json.length
+        },
+        pass () {
+          isPaused = false
+          json[index + length++] = chunk
+          resolve()
+          setImmediate(unpause)
+        }
+      })
+    })
+  }
+
+  function indent () {
+    return addJson(`\n${indentation}`)
+  }
+
+  function afterScope () {
+    needsComma = false
+
+    if (space) {
+      indentation += space
+    }
+  }
+
+  function object () {
+    return beforeScope()
+      .then(() => addJson('{'))
+      .then(() => afterScope())
+  }
+
+  function property (name) {
+    return before()
+      .then(() => addJson(`"${name}":`))
+      .then(() => {
+        isProperty = true
+      })
+  }
+
+  function string (s) {
+    return value(`"${s}"`)
+  }
+
+  function value (v) {
+    return before()
+      .then(() => addJson(`${v}`))
+  }
+
+  function endArray () {
+    return beforeScopeEnd()
+      .then(() => addJson(']'))
+      .then(() => afterScopeEnd())
+  }
+
+  function beforeScopeEnd () {
+    if (space) {
+      indentation = indentation.substr(space.length)
+
+      return indent()
+    }
+
+    return Promise.resolve()
+  }
+
+  function afterScopeEnd () {
+    needsComma = true
+  }
+
+  function endObject () {
+    return beforeScopeEnd()
+      .then(() => addJson('}'))
+      .then(() => afterScopeEnd())
+  }
+
+  function end () {
+    after()
+
+    isEnded = true
+    endStream()
+  }
+
+  function error (err) {
+    stream.emit('error', err)
+  }
+
+  function dataError (err) {
+    stream.emit('dataError', err)
+  }
+}
+
+function normaliseSpace (options) {
+  if (check.positive(options.space)) {
+    return new Array(options.space + 1).join(' ')
+  }
+
+  if (check.nonEmptyString(options.space)) {
+    return options.space
+  }
+}
Index: frontend/node_modules/bfj/src/stringify.js
===================================================================
--- frontend/node_modules/bfj/src/stringify.js	(revision 9af201e94b5a79beb92a30858fc54de4bc3b9449)
+++ frontend/node_modules/bfj/src/stringify.js	(revision 9af201e94b5a79beb92a30858fc54de4bc3b9449)
@@ -0,0 +1,67 @@
+'use strict'
+
+const promise = require('./promise')
+const streamify = require('./streamify')
+
+module.exports = stringify
+
+/**
+ * Public function `stringify`.
+ *
+ * Returns a promise and asynchronously serialises a data structure to a
+ * JSON string. Sanely handles promises, buffers, maps and other iterables.
+ *
+ * @param data:          The data to transform
+ *
+ * @option space:        Indentation string, or the number of spaces
+ *                       to indent each nested level by.
+ *
+ * @option promises:     'resolve' or 'ignore', default is 'resolve'.
+ *
+ * @option buffers:      'toString' or 'ignore', default is 'toString'.
+ *
+ * @option maps:         'object' or 'ignore', default is 'object'.
+ *
+ * @option iterables:    'array' or 'ignore', default is 'array'.
+ *
+ * @option circular:     'error' or 'ignore', default is 'error'.
+ *
+ * @option yieldRate:     The number of data items to process per timeslice,
+ *                        default is 16384.
+ *
+ * @option bufferLength:  The length of the buffer, default is 1024.
+ *
+ * @option highWaterMark: If set, will be passed to the readable stream constructor
+ *                        as the value for the highWaterMark option.
+ *
+ * @option Promise:       The promise constructor to use, defaults to bluebird.
+ **/
+function stringify (data, options) {
+  const json = []
+  const Promise = promise(options)
+  const stream = streamify(data, options)
+
+  let resolve, reject
+
+  stream.on('data', read)
+  stream.on('end', end)
+  stream.on('error', error)
+  stream.on('dataError', error)
+
+  return new Promise((res, rej) => {
+    resolve = res
+    reject = rej
+  })
+
+  function read (chunk) {
+    json.push(chunk)
+  }
+
+  function end () {
+    resolve(json.join(''))
+  }
+
+  function error (e) {
+    reject(e)
+  }
+}
Index: frontend/node_modules/bfj/src/unpipe.js
===================================================================
--- frontend/node_modules/bfj/src/unpipe.js	(revision 9af201e94b5a79beb92a30858fc54de4bc3b9449)
+++ frontend/node_modules/bfj/src/unpipe.js	(revision 9af201e94b5a79beb92a30858fc54de4bc3b9449)
@@ -0,0 +1,37 @@
+'use strict'
+
+const stream = require('stream')
+const check = require('check-types')
+const parse = require('./parse')
+
+module.exports = unpipe
+
+/**
+ * Public function `unpipe`.
+ *
+ * Returns a writeable stream that can be passed to stream.pipe, then parses JSON
+ * data read from the stream. If there are no errors, the callback is invoked with
+ * the result as the second argument. If errors occur, the first error is passed to
+ * the callback as the first argument.
+ *
+ * @param callback:   Function that will be called after parsing is complete.
+ *
+ * @option reviver:   Transformation function, invoked depth-first.
+ *
+ * @option discard:   The number of characters to process before discarding them
+ *                    to save memory. The default value is `1048576`.
+ *
+ * @option yieldRate: The number of data items to process per timeslice,
+ *                    default is 16384.
+ **/
+function unpipe (callback, options) {
+  check.assert.function(callback, 'Invalid callback argument')
+
+  const jsonstream = new stream.PassThrough()
+
+  parse(jsonstream, { ...options, ndjson: false })
+    .then(data => callback(null, data))
+    .catch(error => callback(error))
+
+  return jsonstream
+}
Index: frontend/node_modules/bfj/src/walk.js
===================================================================
--- frontend/node_modules/bfj/src/walk.js	(revision 9af201e94b5a79beb92a30858fc54de4bc3b9449)
+++ frontend/node_modules/bfj/src/walk.js	(revision 9af201e94b5a79beb92a30858fc54de4bc3b9449)
@@ -0,0 +1,720 @@
+'use strict'
+
+const check = require('check-types')
+const error = require('./error')
+const EventEmitter = require('events').EventEmitter
+const events = require('./events')
+const promise = require('./promise')
+
+const terminators = {
+  obj: '}',
+  arr: ']'
+}
+
+const escapes = {
+  /* eslint-disable quote-props */
+  '"': '"',
+  '\\': '\\',
+  '/': '/',
+  'b': '\b',
+  'f': '\f',
+  'n': '\n',
+  'r': '\r',
+  't': '\t'
+  /* eslint-enable quote-props */
+}
+
+module.exports = initialise
+
+/**
+ * Public function `walk`.
+ *
+ * Returns an event emitter and asynchronously walks a stream of JSON data,
+ * emitting events as it encounters tokens. The event emitter is decorated
+ * with a `pause` method that can be called to pause processing.
+ *
+ * @param stream:     Readable instance representing the incoming JSON.
+ *
+ * @option yieldRate: The number of data items to process per timeslice,
+ *                    default is 16384.
+ *
+ * @option Promise:   The promise constructor to use, defaults to bluebird.
+ *
+ * @option ndjson:    Set this to true to parse newline-delimited JSON.
+ **/
+function initialise (stream, options = {}) {
+  check.assert.instanceStrict(stream, require('stream').Readable, 'Invalid stream argument')
+
+  const currentPosition = {
+    line: 1,
+    column: 1
+  }
+  const emitter = new EventEmitter()
+  const handlers = {
+    arr: value,
+    obj: property
+  }
+  const json = []
+  const lengths = []
+  const previousPosition = {}
+  const Promise = promise(options)
+  const scopes = []
+  const yieldRate = options.yieldRate || 16384
+  const shouldHandleNdjson = !! options.ndjson
+
+  let index = 0
+  let isStreamEnded = false
+  let isWalkBegun = false
+  let isWalkEnded = false
+  let isWalkingString = false
+  let hasEndedLine = true
+  let count = 0
+  let resumeFn
+  let pause
+  let cachedCharacter
+
+  stream.setEncoding('utf8')
+  stream.on('data', readStream)
+  stream.on('end', endStream)
+  stream.on('error', err => {
+    emitter.emit(events.error, err)
+    endStream()
+  })
+
+  emitter.pause = () => {
+    let resolve
+    pause = new Promise(res => resolve = res)
+    return () => {
+      pause = null
+      count = 0
+
+      if (shouldHandleNdjson && isStreamEnded && isWalkEnded) {
+        emit(events.end)
+      } else {
+        resolve()
+      }
+    }
+  }
+
+  return emitter
+
+  function readStream (chunk) {
+    addChunk(chunk)
+
+    if (isWalkBegun) {
+      return resume()
+    }
+
+    isWalkBegun = true
+    value()
+  }
+
+  function addChunk (chunk) {
+    json.push(chunk)
+
+    const chunkLength = chunk.length
+    lengths.push({
+      item: chunkLength,
+      aggregate: length() + chunkLength
+    })
+  }
+
+  function length () {
+    const chunkCount = lengths.length
+
+    if (chunkCount === 0) {
+      return 0
+    }
+
+    return lengths[chunkCount - 1].aggregate
+  }
+
+  function value () {
+    /* eslint-disable no-underscore-dangle */
+    if (++count % yieldRate !== 0) {
+      return _do()
+    }
+
+    return new Promise(resolve => {
+      setImmediate(() => _do().then(resolve))
+    })
+
+    function _do () {
+      return awaitNonWhitespace()
+        .then(next)
+        .then(handleValue)
+        .catch(() => {})
+    }
+    /* eslint-enable no-underscore-dangle */
+  }
+
+  function awaitNonWhitespace () {
+    return wait()
+
+    function wait () {
+      return awaitCharacter()
+        .then(step)
+    }
+
+    function step () {
+      if (isWhitespace(character())) {
+        return next().then(wait)
+      }
+    }
+  }
+
+  function awaitCharacter () {
+    let resolve, reject
+
+    if (index < length()) {
+      return Promise.resolve()
+    }
+
+    if (isStreamEnded) {
+      setImmediate(endWalk)
+      return Promise.reject()
+    }
+
+    resumeFn = after
+
+    return new Promise((res, rej) => {
+      resolve = res
+      reject = rej
+    })
+
+    function after () {
+      if (index < length()) {
+        return resolve()
+      }
+
+      reject()
+
+      if (isStreamEnded) {
+        setImmediate(endWalk)
+      }
+    }
+  }
+
+  function character () {
+    if (cachedCharacter) {
+      return cachedCharacter
+    }
+
+    if (lengths[0].item > index) {
+      return cachedCharacter = json[0][index]
+    }
+
+    const len = lengths.length
+    for (let i = 1; i < len; ++i) {
+      const { aggregate, item } = lengths[i]
+      if (aggregate > index) {
+        return cachedCharacter = json[i][index + item - aggregate]
+      }
+    }
+  }
+
+  function isWhitespace (char) {
+    switch (char) {
+      case '\n':
+        if (shouldHandleNdjson && scopes.length === 0) {
+          return false
+        }
+      case ' ':
+      case '\t':
+      case '\r':
+        return true
+    }
+
+    return false
+  }
+
+  function next () {
+    return awaitCharacter().then(after)
+
+    function after () {
+      const result = character()
+
+      cachedCharacter = null
+      index += 1
+      previousPosition.line = currentPosition.line
+      previousPosition.column = currentPosition.column
+
+      if (result === '\n') {
+        currentPosition.line += 1
+        currentPosition.column = 1
+      } else {
+        currentPosition.column += 1
+      }
+
+      if (index > lengths[0].aggregate) {
+        json.shift()
+
+        const difference = lengths.shift().item
+        index -= difference
+
+        lengths.forEach(len => len.aggregate -= difference)
+      }
+
+      return result
+    }
+  }
+
+  function handleValue (char) {
+    if (shouldHandleNdjson && scopes.length === 0) {
+      if (char === '\n') {
+        hasEndedLine = true
+        return emit(events.endLine)
+          .then(value)
+      }
+
+      if (! hasEndedLine) {
+        return fail(char, '\n', previousPosition)
+          .then(value)
+      }
+
+      hasEndedLine = false
+    }
+
+    switch (char) {
+      case '[':
+        return array()
+      case '{':
+        return object()
+      case '"':
+        return string()
+      case '0':
+      case '1':
+      case '2':
+      case '3':
+      case '4':
+      case '5':
+      case '6':
+      case '7':
+      case '8':
+      case '9':
+      case '-':
+      case '.':
+        return number(char)
+      case 'f':
+        return literalFalse()
+      case 'n':
+        return literalNull()
+      case 't':
+        return literalTrue()
+      default:
+        return fail(char, 'value', previousPosition)
+          .then(value)
+    }
+  }
+
+  function array () {
+    return scope(events.array, value)
+  }
+
+  function scope (event, contentHandler) {
+    return emit(event)
+      .then(() => {
+        scopes.push(event)
+        return endScope(event)
+      })
+      .then(contentHandler)
+  }
+
+  function emit (...args) {
+    return (pause || Promise.resolve())
+      .then(() => {
+        try {
+          emitter.emit(...args)
+        } catch (err) {
+          try {
+            emitter.emit(events.error, err)
+          } catch (_) {
+            // When calling user code, anything is possible
+          }
+        }
+      })
+  }
+
+  function endScope (scp) {
+    return awaitNonWhitespace()
+      .then(() => {
+        if (character() === terminators[scp]) {
+          return emit(events.endPrefix + scp)
+            .then(() => {
+              scopes.pop()
+              return next()
+            })
+            .then(endValue)
+        }
+      })
+      .catch(endWalk)
+  }
+
+  function endValue () {
+    return awaitNonWhitespace()
+      .then(after)
+      .catch(endWalk)
+
+    function after () {
+      if (scopes.length === 0) {
+        if (shouldHandleNdjson) {
+          return value()
+        }
+
+        return fail(character(), 'EOF', currentPosition)
+          .then(value)
+      }
+
+      return checkScope()
+    }
+
+    function checkScope () {
+      const scp = scopes[scopes.length - 1]
+      const handler = handlers[scp]
+
+      return endScope(scp)
+        .then(() => {
+          if (scopes.length > 0) {
+            return checkCharacter(character(), ',', currentPosition)
+          }
+        })
+        .then(result => {
+          if (result) {
+            return next()
+          }
+        })
+        .then(handler)
+    }
+  }
+
+  function fail (actual, expected, position) {
+    return emit(
+      events.dataError,
+      error.create(
+        actual,
+        expected,
+        position.line,
+        position.column
+      )
+    )
+  }
+
+  function checkCharacter (char, expected, position) {
+    if (char === expected) {
+      return Promise.resolve(true)
+    }
+
+    return fail(char, expected, position)
+      .then(false)
+  }
+
+  function object () {
+    return scope(events.object, property)
+  }
+
+  function property () {
+    return awaitNonWhitespace()
+      .then(next)
+      .then(propertyName)
+  }
+
+  function propertyName (char) {
+    return checkCharacter(char, '"', previousPosition)
+      .then(() => walkString(events.property))
+      .then(awaitNonWhitespace)
+      .then(next)
+      .then(propertyValue)
+  }
+
+  function propertyValue (char) {
+    return checkCharacter(char, ':', previousPosition)
+      .then(value)
+  }
+
+  function walkString (event) {
+    let isEscaping = false
+    const str = []
+
+    isWalkingString = true
+
+    return next().then(step)
+
+    function step (char) {
+      if (isEscaping) {
+        isEscaping = false
+
+        return escape(char).then(escaped => {
+          str.push(escaped)
+          return next().then(step)
+        })
+      }
+
+      if (char === '\\') {
+        isEscaping = true
+        return next().then(step)
+      }
+
+      if (char !== '"') {
+        str.push(char)
+        return next().then(step)
+      }
+
+      isWalkingString = false
+      return emit(event, str.join(''))
+    }
+  }
+
+  function escape (char) {
+    if (escapes[char]) {
+      return Promise.resolve(escapes[char])
+    }
+
+    if (char === 'u') {
+      return escapeHex()
+    }
+
+    return fail(char, 'escape character', previousPosition)
+      .then(() => `\\${char}`)
+  }
+
+  function escapeHex () {
+    let hexits = []
+
+    return next().then(step.bind(null, 0))
+
+    function step (idx, char) {
+      if (isHexit(char)) {
+        hexits.push(char)
+      }
+
+      if (idx < 3) {
+        return next().then(step.bind(null, idx + 1))
+      }
+
+      hexits = hexits.join('')
+
+      if (hexits.length === 4) {
+        return String.fromCharCode(parseInt(hexits, 16))
+      }
+
+      return fail(char, 'hex digit', previousPosition)
+        .then(() => `\\u${hexits}${char}`)
+    }
+  }
+
+  function string () {
+    return walkString(events.string).then(endValue)
+  }
+
+  function number (firstCharacter) {
+    let digits = [ firstCharacter ]
+
+    return walkDigits().then(addDigits.bind(null, checkDecimalPlace))
+
+    function addDigits (step, result) {
+      digits = digits.concat(result.digits)
+
+      if (result.atEnd) {
+        return endNumber()
+      }
+
+      return step()
+    }
+
+    function checkDecimalPlace () {
+      if (character() === '.') {
+        return next()
+          .then(char => {
+            digits.push(char)
+            return walkDigits()
+          })
+          .then(addDigits.bind(null, checkExponent))
+      }
+
+      return checkExponent()
+    }
+
+    function checkExponent () {
+      if (character() === 'e' || character() === 'E') {
+        return next()
+          .then(char => {
+            digits.push(char)
+            return awaitCharacter()
+          })
+          .then(checkSign)
+          .catch(fail.bind(null, 'EOF', 'exponent', currentPosition))
+      }
+
+      return endNumber()
+    }
+
+    function checkSign () {
+      if (character() === '+' || character() === '-') {
+        return next().then(char => {
+          digits.push(char)
+          return readExponent()
+        })
+      }
+
+      return readExponent()
+    }
+
+    function readExponent () {
+      return walkDigits().then(addDigits.bind(null, endNumber))
+    }
+
+    function endNumber () {
+      return emit(events.number, parseFloat(digits.join('')))
+        .then(endValue)
+    }
+  }
+
+  function walkDigits () {
+    const digits = []
+
+    return wait()
+
+    function wait () {
+      return awaitCharacter()
+        .then(step)
+        .catch(atEnd)
+    }
+
+    function step () {
+      if (isDigit(character())) {
+        return next().then(char => {
+          digits.push(char)
+          return wait()
+        })
+      }
+
+      return { digits, atEnd: false }
+    }
+
+    function atEnd () {
+      return { digits, atEnd: true }
+    }
+  }
+
+  function literalFalse () {
+    return literal([ 'a', 'l', 's', 'e' ], false)
+  }
+
+  function literal (expectedCharacters, val) {
+    let actual, expected, invalid
+
+    return wait()
+
+    function wait () {
+      return awaitCharacter()
+        .then(step)
+        .catch(atEnd)
+    }
+
+    function step () {
+      if (invalid || expectedCharacters.length === 0) {
+        return atEnd()
+      }
+
+      return next().then(afterNext)
+    }
+
+    function atEnd () {
+      return Promise.resolve()
+        .then(() => {
+          if (invalid) {
+            return fail(actual, expected, previousPosition)
+          }
+
+          if (expectedCharacters.length > 0) {
+            return fail('EOF', expectedCharacters.shift(), currentPosition)
+          }
+
+          return done()
+        })
+        .then(endValue)
+    }
+
+    function afterNext (char) {
+      actual = char
+      expected = expectedCharacters.shift()
+
+      if (actual !== expected) {
+        invalid = true
+      }
+
+      return wait()
+    }
+
+    function done () {
+      return emit(events.literal, val)
+    }
+  }
+
+  function literalNull () {
+    return literal([ 'u', 'l', 'l' ], null)
+  }
+
+  function literalTrue () {
+    return literal([ 'r', 'u', 'e' ], true)
+  }
+
+  function endStream () {
+    isStreamEnded = true
+
+    if (isWalkBegun) {
+      return resume()
+    }
+
+    endWalk()
+  }
+
+  function resume () {
+    if (resumeFn) {
+      resumeFn()
+      resumeFn = null
+    }
+  }
+
+  function endWalk () {
+    if (isWalkEnded) {
+      return Promise.resolve()
+    }
+
+    isWalkEnded = true
+
+    return Promise.resolve()
+      .then(() => {
+        if (isWalkingString) {
+          return fail('EOF', '"', currentPosition)
+        }
+      })
+      .then(popScopes)
+      .then(() => emit(events.end))
+  }
+
+  function popScopes () {
+    if (scopes.length === 0) {
+      return Promise.resolve()
+    }
+
+    return fail('EOF', terminators[scopes.pop()], currentPosition)
+      .then(popScopes)
+  }
+}
+
+function isHexit (character) {
+  return isDigit(character) ||
+    isInRange(character, 'A', 'F') ||
+    isInRange(character, 'a', 'f')
+}
+
+function isDigit (character) {
+  return isInRange(character, '0', '9')
+}
+
+function isInRange (character, lower, upper) {
+  const code = character.charCodeAt(0)
+
+  return code >= lower.charCodeAt(0) && code <= upper.charCodeAt(0)
+}
Index: frontend/node_modules/bfj/src/write.js
===================================================================
--- frontend/node_modules/bfj/src/write.js	(revision 9af201e94b5a79beb92a30858fc54de4bc3b9449)
+++ frontend/node_modules/bfj/src/write.js	(revision 9af201e94b5a79beb92a30858fc54de4bc3b9449)
@@ -0,0 +1,55 @@
+'use strict'
+
+const fs = require('fs')
+const promise = require('./promise')
+const streamify = require('./streamify')
+
+module.exports = write
+
+/**
+ * Public function `write`.
+ *
+ * Returns a promise and asynchronously serialises a data structure to a
+ * JSON file on disk. Sanely handles promises, buffers, maps and other
+ * iterables.
+ *
+ * @param path:           Path to the JSON file.
+ *
+ * @param data:           The data to transform.
+ *
+ * @option space:         Indentation string, or the number of spaces
+ *                        to indent each nested level by.
+ *
+ * @option promises:      'resolve' or 'ignore', default is 'resolve'.
+ *
+ * @option buffers:       'toString' or 'ignore', default is 'toString'.
+ *
+ * @option maps:          'object' or 'ignore', default is 'object'.
+ *
+ * @option iterables:     'array' or 'ignore', default is 'array'.
+ *
+ * @option circular:      'error' or 'ignore', default is 'error'.
+ *
+ * @option yieldRate:     The number of data items to process per timeslice,
+ *                        default is 16384.
+ *
+ * @option bufferLength:  The length of the buffer, default is 1024.
+ *
+ * @option highWaterMark: If set, will be passed to the readable stream constructor
+ *                        as the value for the highWaterMark option.
+ *
+ * @option Promise:       The promise constructor to use, defaults to bluebird.
+ **/
+function write (path, data, options) {
+  const Promise = promise(options)
+
+  return new Promise((resolve, reject) => {
+    streamify(data, options)
+      .pipe(fs.createWriteStream(path, options))
+      .on('finish', () => {
+        resolve()
+      })
+      .on('error', reject)
+      .on('dataError', reject)
+  })
+}
