src/app/services/va-messaging.service.ts

Metadata

Index

Properties
Methods

Methods

dataColumns
dataColumns(msg: VaMessage)

Columns that carry grid data — excludes usage: "brush" columns, which encode linked-selection state rather than data.

Parameters :
Name Type Optional
msg VaMessage No
Returns : literal type[]
Private isTrustedEarlyOrigin
isTrustedEarlyOrigin(origin: string)

Origin check for the pre-bootstrap replay. Mirrors isTrustedSource: the live path trusts a message whose event.source IS the parent frame; for a captured message we cannot reference its source Window, so we trust an origin that is this window's origin or the embedding frame's origin (document.referrer). Unverifiable/absent referrer -> reject.

Parameters :
Name Type Optional
origin string No
Returns : boolean
Private isTrustedSource
isTrustedSource(event: MessageEvent)

Validates that a message comes from a trusted source: our hosting frame (the normal SAS VA data-driven content case) or the same origin (local dev / tests). Untrusted messages — e.g. from an unrelated window or a malicious iframe — are rejected.

Parameters :
Name Type Optional
event MessageEvent No
Returns : boolean
latestMessage
latestMessage()

The true latest DDC message, from the always-on window.__vaLastMessage buffer (set in va-early.js). That listener is registered at page parse and never removed, so it has NO gaps — including the window during an editor reload when the app's own listener is briefly absent. So a message that arrives mid-reload (e.g. a rapid filter change) is still here.

Returns : VaMessage | null
onData
onData(callback: (msg: VaMessage) => void)

Registers a window message listener and invokes callback for every valid DDC message. The latest message itself is read via latestMessage() from the always-on window.__vaLastMessage buffer, not stored here. Returns an unsubscribe function that removes the listener.

Parameters :
Name Type Optional
callback function No
Returns : void
Private parseData
parseData(data: any)

Parses a raw message payload (from a MessageEvent or the early buffer) into a VaMessage, or null when it is not a recognisable DDC message.

Parameters :
Name Type Optional
data any No
Returns : VaMessage | null
parseMessage
parseMessage(event: MessageEvent)

Parses a raw window MessageEvent into a VaMessage, or null when it is not a recognisable DDC message (e.g. unrelated postMessage traffic).

Parameters :
Name Type Optional
event MessageEvent No
Returns : VaMessage | null
Private replayEarlyMessages
replayEarlyMessages(callback: (msg: VaMessage) => void)

Replays the latest VA message captured by the early window.__vaLastMessage listener (set up in va-early.js), closing the pre-bootstrap race. Only runs when actually framed (DDC context).

Parameters :
Name Type Optional
callback function No
Returns : void
sendInstructionalMessage
sendInstructionalMessage(message: string)

Back-channel (DDC → VA) — custom instructional message, shown in the report designer when the assigned data does not meet the visual's requirements.

Parameters :
Name Type Optional
message string No
Returns : void
Private sendMessage
sendMessage(message: any)
Parameters :
Name Type Optional
message any No
Returns : void
sendSelection
sendSelection(rows: number[])

Back-channel (DDC → VA) — selection / source-of-action. Designed per the SAS contract; not wired into the UI in v1.

Parameters :
Name Type Optional
rows number[] No
Returns : void

Properties

Private earlyDrained
Type : unknown
Default value : false

True once the pre-bootstrap early buffer has been drained (drain-once).

filterSignature
Type : string | null
Default value : null

Cross-reload state. Applying a VA filter navigates to /editor/

/, which recreates the editor component, so the desired filter + column visibility are held on this root singleton and re-applied after the reload.

Private parentOrigin
Type : string | null
Default value : null

Origin of the last trusted inbound message — used as the explicit targetOrigin for the back-channel so payloads are never broadcast to an unintended parent.

Private resultName
Type : string | null
Default value : null

Result name from the most recent message, needed for the back-channel.

visibleColumns
Type : string[] | null
Default value : null

DC variable names that should be visible (the rest are hidden).

import { Injectable } from '@angular/core'

/**
 * SAS Visual Analytics data-driven content (DDC) postMessage interface.
 *
 * Contract (from the official SAS docs and the sample repo
 * https://github.com/sassoftware/sas-visualanalytics-thirdpartyvisualizations):
 * VA shares data with the data-driven content object via window.postMessage().
 * The content registers a `message` listener; `event.data` is a JSON object.
 * To act as the source of an action (selection / brushing) or to show an
 * instructional message, the content posts a message back to `window.parent`,
 * echoing the `resultName`.
 */

export interface VaColumn {
  /** Internal VA column id, e.g. "bi184". */
  name: string
  /** Human label, e.g. "dealer". May be absent (e.g. on brush columns). */
  label?: string
  type: string
  /** "categorical" | "quantitative" | "brush" | ... */
  usage?: string
  aggregation?: string
  format?: any
}

export interface VaParameter {
  /** VA internal id, e.g. "pr273". NOT the DC column. */
  name: string
  /** Human label — equals the DC column name in our setup, e.g. "some_dropdown". */
  label?: string
  /** "number" | "string" — VA's value type (used to decide numeric vs char). */
  dataType?: string
  type?: string
  format?: any
  value: any
}

export interface VaMessage {
  version?: string
  /** Required to echo back on any message to VA. */
  resultName: string
  rowCount: number
  availableRowCount?: number
  /** Row-major 2-D array; cell order matches `columns`. Measures are unformatted. */
  data: any[][]
  columns: VaColumn[]
  /** Only the parameters used by the query are returned. */
  parameters: VaParameter[]
}

@Injectable({ providedIn: 'root' })
export class VaMessagingService {
  /** Result name from the most recent message, needed for the back-channel. */
  private resultName: string | null = null

  /**
   * Origin of the last trusted inbound message — used as the explicit
   * targetOrigin for the back-channel so payloads are never broadcast to an
   * unintended parent.
   */
  private parentOrigin: string | null = null

  /**
   * Validates that a message comes from a trusted source: our hosting frame
   * (the normal SAS VA data-driven content case) or the same origin (local dev /
   * tests). Untrusted messages — e.g. from an unrelated window or a malicious
   * iframe — are rejected.
   */
  private isTrustedSource(event: MessageEvent): boolean {
    if (window.parent !== window && event.source === window.parent) return true
    return !!event.origin && event.origin === window.location.origin
  }

  /**
   * Cross-reload state. Applying a VA filter navigates to
   * /editor/<table>/<FILTER_RK>, which recreates the editor component, so the
   * desired filter + column visibility are held on this root singleton and
   * re-applied after the reload.
   */
  filterSignature: string | null = null
  /** DC variable names that should be visible (the rest are hidden). */
  visibleColumns: string[] | null = null

  /** True once the pre-bootstrap early buffer has been drained (drain-once). */
  private earlyDrained = false

  /**
   * Registers a window `message` listener and invokes `callback` for every
   * valid DDC message. The latest message itself is read via `latestMessage()`
   * from the always-on `window.__vaLastMessage` buffer, not stored here.
   * Returns an unsubscribe function that removes the listener.
   */
  onData(callback: (msg: VaMessage) => void): () => void {
    const handler = (event: MessageEvent) => {
      if (!this.isTrustedSource(event)) return
      const parsed = this.parseMessage(event)
      if (parsed) {
        this.resultName = parsed.resultName
        if (event.origin) this.parentOrigin = event.origin
        callback(parsed)
      }
    }

    window.addEventListener('message', handler, false)
    // Replay any DDC message captured by the early listener before
    // the app (and this listener) existed. VA pushes its message once on iframe
    // load — which happens before Angular bootstraps — so without this the very
    // first message is lost entirely and the editor loads unfiltered.
    this.replayEarlyMessages(callback)
    return () => window.removeEventListener('message', handler, false)
  }

  /**
   * The true latest DDC message, from the always-on `window.__vaLastMessage`
   * buffer (set in va-early.js). That listener is registered at page parse and
   * never removed, so it has NO gaps — including the window during an editor
   * reload when the app's own listener is briefly absent. So a message that
   * arrives mid-reload (e.g. a rapid filter change) is still here.
   */
  latestMessage(): VaMessage | null {
    const captured = (window as unknown as { __vaLastMessage?: any })
      .__vaLastMessage
    return captured ? this.parseData(captured.data) : null
  }

  /**
   * Replays the latest VA message captured by the early `window.__vaLastMessage`
   * listener (set up in va-early.js), closing the pre-bootstrap race. Only runs
   * when actually framed (DDC context).
   */
  private replayEarlyMessages(callback: (msg: VaMessage) => void): void {
    if (window.parent === window) return
    // Drain only once per page lifetime. This service is a root singleton, so
    // the flag persists across editor reloads — without it every reload would
    // re-replay the captured message and fight the live filter (loop).
    if (this.earlyDrained) return
    this.earlyDrained = true
    const captured = (window as unknown as { __vaLastMessage?: any })
      .__vaLastMessage
    // The early listener (va-early.js) captures from any origin, so re-apply
    // the live-path trust rule here before acting on it: only replay a message
    // that came from our own origin or from the frame that embedded us (whose
    // URL is document.referrer). Without this, a same-origin sibling frame
    // could inject a crafted DDC message that the live isTrustedSource check
    // would have rejected.
    if (!captured || typeof captured.origin !== 'string') return
    if (!this.isTrustedEarlyOrigin(captured.origin)) return
    const parsed = this.parseData(captured.data)
    if (!parsed) return
    this.resultName = parsed.resultName
    this.parentOrigin = captured.origin
    callback(parsed)
  }

  /**
   * Origin check for the pre-bootstrap replay. Mirrors isTrustedSource: the
   * live path trusts a message whose event.source IS the parent frame; for a
   * captured message we cannot reference its source Window, so we trust an
   * origin that is this window's origin or the embedding frame's origin
   * (document.referrer). Unverifiable/absent referrer -> reject.
   */
  private isTrustedEarlyOrigin(origin: string): boolean {
    if (origin === window.location.origin) return true
    if (!document.referrer) return false
    try {
      return new URL(document.referrer).origin === origin
    } catch {
      return false
    }
  }

  /**
   * Parses a raw window MessageEvent into a VaMessage, or null when it is not a
   * recognisable DDC message (e.g. unrelated postMessage traffic).
   */
  parseMessage(event: MessageEvent): VaMessage | null {
    return this.parseData(event && event.data)
  }

  /**
   * Parses a raw message payload (from a MessageEvent or the early buffer) into
   * a VaMessage, or null when it is not a recognisable DDC message.
   */
  private parseData(data: any): VaMessage | null {
    if (!data || typeof data !== 'object') return null
    if (typeof data.resultName !== 'string') return null
    if (!Array.isArray(data.columns) || !Array.isArray(data.data)) return null

    return {
      version: data.version,
      resultName: data.resultName,
      rowCount: data.rowCount,
      availableRowCount: data.availableRowCount,
      data: data.data,
      columns: data.columns,
      parameters: Array.isArray(data.parameters) ? data.parameters : []
    }
  }

  /**
   * Columns that carry grid data — excludes `usage: "brush"` columns, which
   * encode linked-selection state rather than data.
   */
  dataColumns(msg: VaMessage): { column: VaColumn; index: number }[] {
    return msg.columns
      .map((column, index) => ({ column, index }))
      .filter(({ column }) => column.usage !== 'brush')
  }

  /**
   * Back-channel (DDC → VA) — selection / source-of-action. Designed per the
   * SAS contract; not wired into the UI in v1.
   */
  sendSelection(rows: number[]): void {
    if (this.resultName === null) return
    this.sendMessage({
      resultName: this.resultName,
      selections: rows.map((row) => ({ row }))
    })
  }

  /**
   * Back-channel (DDC → VA) — custom instructional message, shown in the report
   * designer when the assigned data does not meet the visual's requirements.
   */
  sendInstructionalMessage(message: string): void {
    if (this.resultName === null) return
    this.sendMessage({ resultName: this.resultName, message })
  }

  private sendMessage(message: any): void {
    // Prefer the validated origin captured from the inbound VA message; only
    // fall back to the referrer/own location when we have not yet received one.
    const target =
      this.parentOrigin ||
      (window.location !== window.parent.location
        ? document.referrer
        : document.location.href)
    if (!target) return
    window.parent.postMessage(message, target)
  }
}

results matching ""

    No results matching ""