Files
dc/va-host/host.js
T
dc 90784b342f
Build / Build-and-ng-test (pull_request) Successful in 5m9s
Lighthouse Checks / lighthouse (pull_request) Successful in 8m59s
Build / Build-and-test-development (pull_request) Successful in 33m54s
fix(va-host): deliver the load-mount message once, and document report.json from the file
Two review findings:

- The load handler re-posted pendingPayload on every load event of the frame,
  so an in-app navigation or a self-reload posted a filter that belonged to
  the mount which had already delivered it, and logged it as that mount's.
  The handler now takes the payload (clearing pendingPayload and
  pendingTiming) before posting, so a later load has nothing to send.

- The README illustrated report.json with one parameter while the committed
  file carries three, and the prose below reads as though one parameter were
  the shape. The example block is gone: the file is the example, and the keys
  are documented as a table with the parameter rules kept underneath.
2026-11-03 23:45:40 +00:00

427 lines
15 KiB
JavaScript

/*
* A parent report page that emulates a SAS Visual Analytics report embedding
* Data Controller as data-driven content.
*
* Why this exists: on a real deployment the VA report and the DC app share a
* domain, and the report passes its prompts to the embedded object as a DDC
* message. There is no way to open that report without VA, so this page stands
* in for it: the dropdowns are the report's prompts, and the iframe is framed
* with VA's sandbox attribute set and fed the same message shape.
*
* What it deliberately does NOT reproduce: VA's own UI, the report's layout, and
* the way VA computes a prompt's value. It reproduces the contract that DC sees.
*
* Nothing here is part of the DC build. The host is not the child: shipping it
* in DC's deployment would put a test harness on customer servers and let DC be
* opened top-level as a page that frames itself.
*
* Kept to ES5-style script (no modules, no build step) because it is served as a
* plain file by SASjs Server, and because the server's CSP allows scripts from
* 'self' only - no inline script, no inline handlers.
*/
;(function () {
'use strict'
var FRAME = document.getElementById('frame')
var LOG = document.getElementById('log')
var PROMPTS = document.getElementById('prompts')
var heading = document.getElementById('heading')
var modeSelect = document.getElementById('mode')
var timingSelect = document.getElementById('timing')
// VA's own attribute set, overridden by report.json when it says otherwise.
var defaultSandbox = FRAME.getAttribute('sandbox')
var report = null
var frameLoaded = false
var earlyTimer = null
var attempts = 0
var pendingPayload = null
var pendingTiming = 'both'
// the origin the frame is allowed to receive a message on, and the document it
// had when the current replay started (see startEarlyReplay)
var targetOrigin = null
var mountDocument = null
function log(message) {
var time = new Date().toISOString().slice(11, 23)
LOG.textContent += '[' + time + '] ' + message + '\n'
LOG.scrollTop = LOG.scrollHeight
}
function promptNames() {
return report && report.parameters ? report.parameters : []
}
/**
* The select a prompt renders, found by walking the selects.
*
* Deliberately not `querySelector('[data-name="..."]')`: a name from the
* config that carries a quote or a bracket would make that selector throw and
* abort the whole apply, leaving the page with no frame at all.
*/
function promptField(name) {
var selects = PROMPTS.querySelectorAll('select')
for (var i = 0; i < selects.length; i++) {
if (selects[i].getAttribute('data-name') === name) return selects[i]
}
return null
}
/** The value the report would send for each prompt, right now. */
function message() {
return {
version: '1',
resultName: 'dd1',
rowCount: 0,
availableRowCount: 0,
data: [],
columns: [],
parameters: promptNames().map(function (parameter) {
var field = promptField(parameter.name)
var dataType = parameter.dataType || 'string'
var raw = field ? field.value : parameter.default
return {
name: parameter.name,
// The label is the key DC maps on (`va_parmap`), so it is config, not
// something derived from the name.
label: parameter.label,
dataType: dataType,
// A numeric prompt carries a number in VA's message, and DC's clause
// builder branches on the type: sending "42" instead of 42 quotes a
// value that the backend compares as a number.
value: coerce(raw, dataType)
}
})
}
}
/** A prompt value in the shape its dataType promises. */
function coerce(value, dataType) {
if (dataType !== 'number') return value
var asNumber = Number(value)
// a prompt whose configured value is not numeric stays as it is rather than
// becoming NaN in the message
return value !== null && value !== '' && !isNaN(asNumber) ? asNumber : value
}
/**
* The embedded object's URL.
*
* `embed=va` arms DC's earliest listener (it is inert without it), and each
* parameter with a `column` becomes a `va_parmap` entry - `<label>-<column>`,
* split at the last hyphen, matched case-insensitively against the parameter
* label.
*/
function childUrl() {
var params = promptNames().filter(function (parameter) {
return parameter.column
})
var query = ['embed=va'].concat(
params.map(function (parameter) {
return (
'va_parmap=' +
encodeURIComponent(parameter.label + '-' + parameter.column)
)
})
)
return (
report.server.replace(/\/$/, '') +
'/AppStream/' +
report.app +
'/#/editor/' +
encodeURIComponent(report.table) +
'?' +
query.join('&')
)
}
function postToFrame(payload) {
var target = FRAME.contentWindow
if (!target) return
// The frame is the app on report.server, so that origin is the only one that
// should receive the filter clause. '*' would deliver into whatever document
// occupies the frame, including one that navigated elsewhere, and a
// misconfigured report.server would widen delivery instead of failing.
if (!targetOrigin) {
log(
'cannot post: report.server is not a URL, so the frame origin is unknown - ' +
JSON.stringify(report.server)
)
return
}
target.postMessage(payload, targetOrigin)
}
/** The frame's origin, from the config; null when report.server is not a URL. */
function frameOrigin() {
try {
return new URL(report.server).origin
} catch (error) {
return null
}
}
/** True once DC has published what its earliest listener captured. */
function frameBuffered() {
try {
return !!(FRAME.contentWindow && FRAME.contentWindow.__vaLastMessage)
} catch (error) {
return false
}
}
/**
* Whether the frame exposes the property the replay uses to stop early.
*
* It is a private global on the app's side, so it can go away without notice.
* Saying so when it is absent keeps "no capture seen" from reading as a
* harness failure when the probe itself is what changed.
*/
function captureProbeVisible() {
try {
return !!(FRAME.contentWindow && '__vaLastMessage' in FRAME.contentWindow)
} catch (error) {
return false
}
}
function stopEarlyReplay() {
if (earlyTimer === null) return
clearInterval(earlyTimer)
earlyTimer = null
}
/**
* Deliver the message the way VA does on a report refresh: repeatedly, from
* the moment the frame is created, so one copy lands after the app's earliest
* listener exists and before the app bootstraps. That window is the whole
* point - a single post on load reaches the metadata path instead.
*/
function startEarlyReplay(payload) {
var interval = report.intervalMs || 50
var deadline = Date.now() + (report.replayTimeoutMs || 30000)
attempts = 0
// The document on screen when this mount started. A remount to the same URL
// reloads the frame, and until the reload lands the OLD document is still
// there with the __vaLastMessage it captured on the previous mount - reading
// that would log a capture this mount never made. A capture only counts once
// the document has been replaced.
mountDocument = FRAME.contentDocument || null
log('early replay: posting every ' + interval + 'ms until the app captures it')
earlyTimer = setInterval(function () {
// The capture and the deadline are both tested before the frame guard: a
// detached or mid-navigation frame leaves contentWindow null, and a bare
// return there would skip them and keep this timer alive for the life of
// the page. A capture only counts once something has been posted in this
// replay, so a buffer left by the previous load of the same frame cannot
// be read as one that belongs to this mount.
if (attempts > 0 && frameBuffered() && FRAME.contentDocument !== mountDocument) {
log('early replay: the app captured the message after ' + attempts + ' post(s)')
stopEarlyReplay()
return
}
if (Date.now() > deadline) {
log(
captureProbeVisible()
? 'early replay: gave up after ' + attempts + ' post(s), no capture seen'
: 'early replay: gave up after ' + attempts + ' post(s) - the app does not expose window.__vaLastMessage, so this replay cannot tell whether it captured'
)
if (attempts === 0) {
log('early replay: the frame was never postable, so nothing was sent')
}
stopEarlyReplay()
return
}
if (!FRAME.contentWindow) return
attempts++
postToFrame(payload)
}, interval)
}
function mount(payload, timing) {
frameLoaded = false
pendingPayload = payload
pendingTiming = timing
stopEarlyReplay()
var sandbox = report.sandbox || defaultSandbox
var url = childUrl()
var sameUrl = FRAME.getAttribute('src') === url
if (sandbox) FRAME.setAttribute('sandbox', sandbox)
else FRAME.removeAttribute('sandbox')
log('mounting ' + url)
log('sandbox: ' + (sandbox || '(none)'))
if (sameUrl) {
// Re-applying with the same prompts yields the same URL, and setting a
// frame's src to the value it already has does not reload it - which
// would post nothing in 'load' mode and leave the previous state on
// screen. Reload explicitly instead.
try {
FRAME.contentWindow.location.reload()
} catch (error) {
FRAME.setAttribute('src', 'about:blank')
FRAME.setAttribute('src', url)
}
} else {
FRAME.setAttribute('src', url)
}
if (timing === 'early' || timing === 'both') {
log('timing: ' + timing)
startEarlyReplay(payload)
} else {
// timing=load delivers on the frame's own load event, which is the
// metadata path: the app has bootstrapped and reads the message there.
// Without this the mode advertised in the controls posts nothing at all.
log('timing: load - the message goes in when the frame has loaded')
}
}
/** Post once into the frame that is already up - a prompt change. */
function postOnce(payload) {
if (!frameLoaded) {
log('the frame has not loaded yet, so a prompt change has nowhere to land')
return
}
postToFrame(payload)
log('posted one message: ' + JSON.stringify(payload).slice(0, 200))
}
function apply() {
if (!report) return
var payload = message()
var timing = timingSelect.value
log('prompts: ' + JSON.stringify(payload.parameters))
if (modeSelect.value === 'prompt') postOnce(payload)
else mount(payload, timing)
}
function renderPrompts() {
PROMPTS.textContent = ''
promptNames().forEach(function (parameter) {
var label = document.createElement('label')
label.textContent = parameter.label
var select = document.createElement('select')
select.setAttribute('data-name', parameter.name)
;(parameter.options || []).forEach(function (option) {
var element = document.createElement('option')
element.value = option
element.textContent = option
if (option === parameter.default) element.selected = true
select.appendChild(element)
})
label.appendChild(select)
PROMPTS.appendChild(label)
})
}
FRAME.addEventListener('load', function () {
frameLoaded = true
// A capture that lands in the last few milliseconds before load would never
// be seen by the interval, and the log would read as though the early path
// failed when it had not. Check once here, and log the capture BEFORE the
// load line so the ordering in the log is the ordering that happened.
if (earlyTimer !== null && frameBuffered() && FRAME.contentDocument !== mountDocument) {
log(
'early replay: the app captured the message after ' + attempts + ' post(s)'
)
}
stopEarlyReplay()
log('frame loaded')
// timing=load and timing=both deliver on load as well: the app is up, so
// this is the metadata path, and for 'both' it is the second copy VA sends.
// timing=early deliberately stops at the captured copy.
if (pendingTiming !== 'early' && pendingPayload) {
// Taken, not read: a later load event on the same frame - the app
// navigating, or a self-reload - must not re-post a filter that belonged
// to the mount which has already had its delivery.
var delivered = pendingPayload
var deliveredTiming = pendingTiming
pendingPayload = null
pendingTiming = null
postToFrame(delivered)
log(
'posted on load (timing=' +
deliveredTiming +
'): ' +
JSON.stringify(delivered).slice(0, 200)
)
}
})
window.addEventListener('message', function (event) {
if (event.source !== FRAME.contentWindow) return
log(
'from frame: ' +
(typeof event.data === 'object'
? JSON.stringify(event.data)
: String(event.data)
).slice(0, 300)
)
})
document.getElementById('apply').addEventListener('click', apply)
document.getElementById('clear').addEventListener('click', function () {
LOG.textContent = ''
})
fetch('report.json')
.then(function (response) {
if (!response.ok) throw new Error('report.json answered ' + response.status)
return response.json()
})
.then(function (config) {
report = config
targetOrigin = frameOrigin()
if (!targetOrigin) {
log('report.server is not a URL, so nothing can be posted: ' + JSON.stringify(report.server))
} else if (window.location.origin !== targetOrigin) {
// the probe reads the frame's own global, which a cross-origin frame
// does not expose - say so rather than logging a capture that never was
log('warning: this page is on ' + window.location.origin + ' and the frame on ' + targetOrigin + ' - the capture probe cannot read across origins')
}
heading.textContent = report.title || 'VA report emulation'
document.title = report.title || 'VA report emulation'
timingSelect.value = report.timing || 'both'
modeSelect.value = report.mode || 'refresh'
renderPrompts()
log('report: ' + (report.title || '(untitled)') + ' -> ' + report.app)
// A page that renders a report should show one without a click, the way
// opening the report does.
apply()
})
.catch(function (error) {
log('could not load report.json: ' + error.message)
})
})()