diff --git a/va-host/README.md b/va-host/README.md
index c452356..fc13a75 100644
--- a/va-host/README.md
+++ b/va-host/README.md
@@ -44,7 +44,11 @@ Two things this is not:
- `table` goes into the editor route: `#/editor/
`.
- `parameters[].label` is the mapping key. DC matches `va_parmap` entries against the parameter **label** (not `name`, which is the report's own bookkeeping), case-insensitively, splitting each entry at its last `-`. `column` here becomes `va_parmap=-`; a parameter with no `column` is sent to the app but not mapped.
- `dataType` is `string` or `number` and is preserved in the message: a numeric prompt and a month-name date string take different paths in DC's clause builder.
-- `mode` is `refresh` (reload the frame per apply - the report-refresh path) or `prompt` (post into the loaded frame - an in-place prompt change). `timing` is `both` (default), `early` or `load`.
+- `mode` is `refresh` (reload the frame per apply - the report-refresh path) or `prompt` (post into the loaded frame - an in-place prompt change). `timing` is `both` (default), `early` or `load`: `early` posts only before the frame loads, `load` only after it has, and `both` does each, which is what VA does for a report with a stored prompt.
+
+## What the app receives
+
+The message is the DDC shape VA sends, one entry per prompt, and `dataType` is honoured rather than cosmetic: a prompt configured as `number` arrives as `42`, not `"42"`, because DC's clause builder branches on the type and a numeric column compared with a quoted value is the failure this pair exists to catch. A prompt whose configured value is not numeric is left as it is rather than becoming `NaN`.
## Deploying
@@ -73,12 +77,19 @@ On the deployments this emulates, the VA report and the DC app share a domain. T
## What to look for in the log
- `early replay: the app captured the message after N post(s)` before `frame loaded` - the pre-bootstrap path, which is the one a report refresh exercises and the one that a single post on load cannot reach.
-- With `mode=prompt`, `posted one message:` and no `mounting` line - the metadata path, for an in-place prompt change.
+- `posted on load (timing=both)` after `frame loaded` - the second copy VA sends, read on the app's ordinary path.
+- With `timing=load`: no replay lines at all, then `frame loaded` followed by `posted on load (timing=load)`.
+- With `mode=prompt`: `posted one message:` and no `mounting` line - the metadata path, for an in-place prompt change.
+- If the replay gives up and also says the app does not expose `window.__vaLastMessage`, the probe has changed rather than the delivery: that global is how the replay knows to stop early.
In the embedded app, the FILTER bar shows the clause the report produced, e.g.
```
-FILTER : (( SOME_DROPDOWN = 'Option 2' AND SOME_DATE = '12FEB1960'd ))
+FILTER : (( SOME_DROPDOWN = 'Option 2' AND SOME_DATE = '12FEB1960'd AND SOME_NUM = 42 ))
```
-A date arriving as a character value instead of a date literal is the defect this pair exists to catch.
+A date arriving as a character value instead of a date literal, or a number arriving quoted, is the defect this pair exists to catch.
+
+## Nothing here reaches a DC release artefact
+
+`frontend.zip` is built from `client/dist` and `sasjs_server.json.zip` from `sasjs/sasjsbuild`; `va-host/` is outside both trees and is not referenced by `sasjsconfig.json`. Checked by building them: `sasjs c -t server && sasjs b -t server` produces a `server.json` file tree of 100 members (40 under `/services/web`) with no `va-host` entry, and `client/dist` holds 40 files, none of them `va-host`. The separation is a property of the build inputs, not a convention someone has to remember.
diff --git a/va-host/deploy.mjs b/va-host/deploy.mjs
index 48c4974..04c70ae 100644
--- a/va-host/deploy.mjs
+++ b/va-host/deploy.mjs
@@ -40,9 +40,16 @@ const pass = arg('pass', process.env.SASJS_PASS)
const jar = new Map()
const rememberCookies = (response) => {
- const headers = response.headers.getSetCookie
- ? response.headers.getSetCookie()
- : []
+ // Node only exposes getSetCookie from 18.14. Feature-detecting it silently
+ // would drop every cookie and turn the failure into an unauthenticated 401
+ // from the deploy call, with nothing pointing at the cause.
+ if (typeof response.headers.getSetCookie !== 'function') {
+ throw new Error(
+ `this Node (${process.version}) cannot read Set-Cookie headers - deploy from Node 18.14 or later`
+ )
+ }
+
+ const headers = response.headers.getSetCookie()
for (const header of headers) {
const [pair] = header.split(';')
diff --git a/va-host/host.js b/va-host/host.js
index e3d4c5b..76c544d 100644
--- a/va-host/host.js
+++ b/va-host/host.js
@@ -36,6 +36,8 @@
var frameLoaded = false
var earlyTimer = null
var attempts = 0
+ var pendingPayload = null
+ var pendingTiming = 'both'
function log(message) {
var time = new Date().toISOString().slice(11, 23)
@@ -47,6 +49,23 @@
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 {
@@ -57,22 +76,36 @@
data: [],
columns: [],
parameters: promptNames().map(function (parameter) {
- var field = PROMPTS.querySelector(
- 'select[data-name="' + parameter.name + '"]'
- )
+ 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: parameter.dataType || 'string',
- value: field ? field.value : parameter.default
+ 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.
*
@@ -123,6 +156,21 @@
}
}
+ /**
+ * 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)
@@ -151,7 +199,11 @@
log('early replay: the app captured the message after ' + attempts + ' post(s)')
stopEarlyReplay()
} else if (Date.now() > deadline) {
- log('early replay: gave up after ' + attempts + ' post(s), no capture seen')
+ 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'
+ )
stopEarlyReplay()
}
}, interval)
@@ -159,20 +211,43 @@
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 ' + childUrl())
+ log('mounting ' + url)
log('sandbox: ' + (sandbox || '(none)'))
- FRAME.setAttribute('src', childUrl())
+ 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')
}
}
@@ -237,6 +312,19 @@
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) {
+ postToFrame(pendingPayload)
+ log(
+ 'posted on load (timing=' +
+ pendingTiming +
+ '): ' +
+ JSON.stringify(pendingPayload).slice(0, 200)
+ )
+ }
})
window.addEventListener('message', function (event) {
diff --git a/va-host/report.json b/va-host/report.json
index 9cfe097..d500dab 100644
--- a/va-host/report.json
+++ b/va-host/report.json
@@ -24,6 +24,14 @@
"column": "SOME_DATE",
"options": ["12FEB1960", "01DEC2021", "August 02, 1971"],
"default": "12FEB1960"
+ },
+ {
+ "name": "pr3",
+ "label": "Some number",
+ "dataType": "number",
+ "column": "SOME_NUM",
+ "options": ["42", "1613.001"],
+ "default": "42"
}
]
}