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]
}
}
}
})
})
})
})