Network.js

The complete source of src/Network.js, exactly as it ships, in reading order. Every line is present; the file is split at its own section banners.

Network.js - the HTTP client: Network.Web.Client.Request + its driver

// ============================================================================
// Network.js - the HTTP client: Network.Web.Client.Request + its driver
// ============================================================================
//
// The async workhorse for the test harness (and, later, the editor's save/compile and
// any data fetch). Request returns a Loading response state - locked until the driver
// fills it - and emits a request into the module-scoped 'request' channel. The driver
// (the terminal gather) fires the fetch and, when it resolves, sets the response state
// via addUpdates, which unlocks it and schedules a re-render. This is the whole
// forward-async story: a locked value that a driver unlocks out of band.
//
// The response is a Network.Web.Response record {status, body}. Because it is a State,
// its fields are settable through the Proxy (settability propagates) - no settableId is
// ever stored in the data. optionalDelayMs lets a test hold the loading state visible.
//
// CASE B (settable inside not-necessarily-settable): the emitted request object carries
// the response STATE itself as a field, not a settable id. The application never handles
// an id. The driver, being native, derives the settable it needs from that embedded
// state (Reactive.Reference) and, when the fetch resolves, sets the response through it.
// This is the general pattern: a settable value follows a single chain up to its source -
// either its parent rebuilds (a settable object's field) or it is a backing state/reverse
// stuffed in as a field. The response here is a backing State (chain length one).
//
// For offline testing, `data:` URLs work with fetch and resolve asynchronously with no
// server or CORS, e.g. Request('data:text/plain,Hello', 800).
// ----------------------------------------------------------------------------

Reactive.Module('Network', () => {
  Reactive.Module('Web', () => {

    Reactive.Type('Response', () => {
      Reactive.Field('status', Maths.Integer)
      Reactive.Field('body', Text)
    })

    Reactive.Module('Client', () => {

      // Request(url, optionalDelayMs) -> a Network.Web.Response state, locked (loading)
      // until the driver returns. optionalDelayMs (test aid) holds the loading state.
      Reactive.Function('Request', () => {
        const url = Reactive.Argument(Reactive.Any)
        const optionalDelayMs = Reactive.Argument(Maths.Integer)
        const empty = { status: 0, body: '' }
        const response = Reactive.Loading(Network.Web.Response)(empty)
        const delayMs = optionalDelayMs == null ? 0 : optionalDelayMs
        // The request's own read-lock (url not ready) is a forward-direction fact, computed
        // here; the response's write-lock is the driver's business (it holds the state), so
        // the driver reads it off the embedded response. No id is put into the data.
        const inputsReadLocked = Reactive.IsLocked(url)
        const nativeUrl = (url != null && typeof url === 'object' && url.optionalGettable !== undefined)
          ? (url.optionalGettable == null ? '' : url.optionalGettable.value)
          : url
        Reactive.Emit('request', { response: response, url: nativeUrl, method: 'GET', body: null, delayMs: delayMs, inputsReadLocked: inputsReadLocked })
        return response
      })

      // Post(url, body, optionalDelayMs) -> a Network.Web.Response state, locked (loading)
      // until the driver returns. Same async contract as Request but sends a POST with a
      // JSON body (Content-Type application/json). The editor save uses this.
      Reactive.Function('Post', () => {
        const url = Reactive.Argument(Reactive.Any)
        const body = Reactive.Argument(Reactive.Any)
        const optionalDelayMs = Reactive.Argument(Maths.Integer)
        const empty = { status: 0, body: '' }
        const response = Reactive.Loading(Network.Web.Response)(empty)
        const delayMs = optionalDelayMs == null ? 0 : optionalDelayMs
        // Locked until BOTH inputs are ready (the url and the body to send).
        const inputsReadLocked = Reactive.IsLocked(url) || Reactive.IsLocked(body)
        const nativeUrl = (url != null && typeof url === 'object' && url.optionalGettable !== undefined)
          ? (url.optionalGettable == null ? '' : url.optionalGettable.value)
          : url
        const nativeBody = (body != null && typeof body === 'object' && body.optionalGettable !== undefined)
          ? (body.optionalGettable == null ? '' : body.optionalGettable.value)
          : body
        Reactive.Emit('request', { response: response, url: nativeUrl, method: 'POST', body: nativeBody, delayMs: delayMs, inputsReadLocked: inputsReadLocked })
        return response
      })

      // The request driver: one independent fetch per response state id, keyed by a
      // signature so an unchanged request is not re-fetched. On resolution it sets the
      // response state (unlocked) via addUpdates.
      Reactive.Driver('request', (params, addUpdates) => {
        const RV = Reactive._internal.ReactiveValue
        const RG = Reactive._internal.ReactiveGettable
        const RS = Reactive._internal.ReactiveSettable
        const RL = Reactive._internal.ReactiveLock
        const activeSignature = {}   // settableId -> last signature fetched

        // Re-lock a response state to loading (used when a request CHANGES after it has
        // already been fetched once, so the UI shows the spinner again). The first fetch
        // does not need this - the Loading state starts locked.
        function relock(settableId) {
          const updates = {}
          updates[settableId] = new RV(new RG(new RL(undefined), { status: 0, body: '' }), new RS(settableId, undefined))
          addUpdates(updates)
        }

        function setResponse(settableId, data) {
          const updates = {}
          updates[settableId] = new RV(new RG(undefined, data), new RS(settableId, undefined))
          addUpdates(updates)
        }

        function start(request, settableId) {
          const method = request.method == null ? 'GET' : request.method
          console.log('[Network]', method, request.url, request.delayMs > 0 ? '(after ' + request.delayMs + 'ms)' : '', '-> state', settableId)
          const options = method === 'POST'
            ? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: request.body }
            : undefined
          const go = () => {
            fetch(request.url, options)
              .then((httpResponse) => {
                const status = httpResponse.status
                return httpResponse.text().then((body) => ({ status: status, body: body }))
              })
              .then((result) => {
                const preview = result.body.length > 60 ? result.body.slice(0, 60) + '...' : result.body
                console.log('[Network] response', result.status, JSON.stringify(preview), '-> unlock state', settableId)
                setResponse(settableId, { status: result.status, body: result.body })
              })
              .catch((error) => {
                console.log('[Network] error for state', settableId, ':', String(error))
                setResponse(settableId, { status: 0, body: 'Error: ' + String(error) })
              })
          }
          if (request.delayMs > 0) {
            setTimeout(go, request.delayMs)
          } else {
            go()
          }
        }

        return (emissions) => {
          const present = {}
          for (const request of emissions) {
            // The request carries the response STATE as a field (case B). The driver
            // derives the settable id and its write-lock from that embedded state - the
            // application never handled an id.
            const settableId = Reactive.Reference(request.response)
            const responseWriteLocked = Reactive.IsWriteLocked(request.response)
            present[settableId] = true
            // Coordination: do not fetch while the request inputs are read-locked (not
            // ready) or the response is write-locked (a reverse owns it). Leave the state
            // untouched so a later cycle fires it once it is unblocked.
            if (request.inputsReadLocked === true || responseWriteLocked === true) {
              console.log('[Network] holding request for state', settableId, '(inputs not ready or response write-locked)')
              continue
            }
            const signature = request.url + '|' + (request.method == null ? 'GET' : request.method) + '|' + (request.body == null ? '' : request.body) + '|' + request.delayMs
            if (activeSignature[settableId] !== signature) {
              const wasFetchedBefore = activeSignature[settableId] !== undefined
              activeSignature[settableId] = signature
              if (wasFetchedBefore) {
                relock(settableId)
              }
              start(request, settableId)
            }
          }
          for (const settableId of Object.keys(activeSignature)) {
            if (present[settableId] !== true) {
              delete activeSignature[settableId]
            }
          }
        }
      })

    })
  })
})