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.
VA report emulation (host for the embedded DC app)
A parent page that stands in for a SAS Visual Analytics report which embeds Data Controller as data-driven content. The dropdowns are the report's prompts; the iframe is framed the way VA frames the object, and is fed the same DDC message VA sends.
Two things this is not:
- It is not 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. Nothing here is in
frontend.ziporsasjs_server.json.zip. - It is not a substitute for a real VA report. It reproduces the contract DC sees: the message shape, the delivery timing, the sandbox attribute set and the
embed=va/va_parmapURL contract. It does not reproduce VA's UI or how VA computes a prompt's value.
Files
index.html- the report page: prompt bar, delivery/timing controls, the iframe, and a log.host.js- reads the config, builds the message, mounts the frame, replays the message early, posts prompt changes.report.json- the report definition (below). The committed copy points at a local server for local runs;deploy.mjsrewrites it for the target it deploys to.deploy.mjs- deploys the three files above to a SASjs Server as an AppStream app.
report.json
The committed report.json is the working example, and it is the one to read: three parameters, because those are the three shapes that reach DC differently - a plain string, a month-name date string and a number.
| key | meaning |
|---|---|
title |
heading on the page |
server |
the server the app is served from; the frame's origin comes from it |
app |
the AppStream name of the DC deployment, not its app-loc: where one folder has several names (DataController, DataController_Main, ...) any of them serves the same build, and this is the one the URL needs |
table |
goes into the editor route: #/editor/<table> |
sandbox |
the iframe's sandbox attribute set |
mode |
refresh (reload per apply) or prompt (post into the loaded frame) |
timing |
both, early or load |
intervalMs, replayTimeoutMs |
how often the early replay posts, and for how long |
parameters[] |
name, label, dataType, column, options, default |
parameters[].labelis the mapping key. DC matchesva_parmapentries against the parameter label (notname, which is the report's own bookkeeping), case-insensitively, splitting each entry at its last-.columnhere becomesva_parmap=<label>-<column>; a parameter with nocolumnis sent to the app but not mapped.dataTypeisstringornumberand is preserved in the message: a numeric prompt and a month-name date string take different paths in DC's clause builder.modeisrefresh(reload the frame per apply - the report-refresh path) orprompt(post into the loaded frame - an in-place prompt change).timingisboth(default),earlyorload:earlyposts only before the frame loads,loadonly after it has, andbothdoes 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
# local, desktop-mode server (no login)
node deploy.mjs --server http://localhost:5000 --app clickme --table DC_JSLIB.MPE_X_TEST
# a server-mode estate (local login; credentials from SASJS_USER/SASJS_PASS)
SASJS_USER=me SASJS_PASS=... node deploy.mjs --server https://example.com \
--app DataControllerDev --table DC996664.MPE_X_TEST --name va-host \
--apploc /Public/app/va-host
# the same, with the password read from stdin so it stays out of argv and history
printf '%s' "$PASS" | node deploy.mjs --pass-stdin --server https://example.com ...
# re-check what is deployed, without deploying anything
node deploy.mjs --verify-only --server https://example.com --app DataControllerDev
There is no --pass: a password on the command line is readable from ps by every account on the host and is kept in the shell history. Use SASJS_PASS, or --pass-stdin to pipe one line in.
The script is the same route sasjs cbd uses: POST /SASjsApi/drive/deploy with a file tree plus streamWebFolder: "web" and streamServiceName, after which the server serves the folder at /AppStream/<name>/. No services are compiled, because the app is static.
The deploy is verified after it is sent. The route answering 2xx means the tree was accepted, not that the app is being served: a type: 'file' member that landed truncated still serves a 200 page, and an asset left over from a previous deploy is a 200 by definition. So the script fetches /AppStream/<name>/ and each of the three files back and compares them to the bytes it deployed, failing on any difference and naming the size and hash of both copies. A 401 from the page is reported as deployed-but-gated rather than as a failure, because on a server-mode estate that is the gate doing its job.
Two things worth knowing about that route:
type: 'file'members are written base64-decoded;type: 'service'members are plain text. A static app is therefore sent encoded (deploy.mjsdoes this). Sending plain text writes a truncated, unreadable file rather than failing.- A redeploy does not remove files the previous version had. The folder is written over. Deleting
<appLoc>first, or deploying to a fresh app-loc, is the way to avoid carrying a stale asset.
The AppStream gate is authenticateAccessToken plus a per-app grant on /AppStream/<name>, so on a server-mode estate the page answers 401 until the user is signed in - which is also what makes the embedded app work, since both are served from the same origin and the session cookie is same-site.
The other harness, and where the contract lives
There are two things in this repo that stand in for a VA report, and they are not interchangeable:
client/cypress/e2e/va-ddc-host.cy.tsdrives sasjs/server's/ddc-hostpage with a single message attiming: 'early', and asserts through the filter the mock backend stored. It is a fixture that runs in CI: no UI, one parameter, one delivery mode.va-host/is this page: a prompt bar,moderefresh/prompt,timingearly/load/both, several parameters and a log, for a person driving the embed by hand.
Neither is the contract of record. The contract is what DC reads and when: the message shape and the window.__vaLastMessage buffer in client/src/app/services/va-messaging.service.ts, and the rule that a message must be captured before the app bootstraps, which is client/src/assets/va-early.js. The URL shape both harnesses build (/AppStream/<app>/#/editor/<table>?embed=va) comes from the app's own routing and parseEmbedParam.
So a change to any of those three - the message shape, the capture timing, the embed parameters - has to be read against both harnesses; a change to one harness's plumbing is local to it. In particular, host.js reads FRAME.contentWindow.__vaLastMessage to tell whether the message was captured, and Cypress deliberately does not (it proxies the framed document, so contentWindow is not trustworthy there). That difference is the environments', not a defect in either file.
Why same origin
On the deployments this emulates, the VA report and the DC app share a domain. That is the topology to reproduce: a host on a different origin would be blocked by the server's X-Frame-Options: SAMEORIGIN and frame-ancestors 'self', and the SameSite=Strict session cookie would not travel into the frame. DC's own trust rules agree - VaMessagingService.isTrustedEarlyOrigin accepts the embedding window's own origin, and the live path accepts a message whose event.source is the parent frame.
What to look for in the log
early replay: the app captured the message after N post(s)beforeframe loaded- the pre-bootstrap path, which is the one a report refresh exercises and the one that a single post on load cannot reach.posted on load (timing=both)afterframe loaded- the second copy VA sends, read on the app's ordinary path.- With
timing=load: no replay lines at all, thenframe loadedfollowed byposted on load (timing=load). - With
mode=prompt:posted one message:and nomountingline - 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 AND SOME_NUM = 42 ))
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.