All files / src/app EmbedChannel.ts

95.55% Statements 129/135
93.42% Branches 71/76
93.75% Functions 15/16
95.55% Lines 129/135

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380                                                                                                                                                          2x 2x   2x     2x   2x   2x   2x   2x           2x                   2x                                                                               2x 17x 17x 17x 17x 17x 17x 21x 20x 18x 18x 21x 16x 16x     24x 24x 24x 22x 21x   24x                 2x                 2x                 2x 2x   2x   2x   2x   2x 21x   21x 21x 21x     2x 22x 20x 20x       20x 20x 20x 20x 20x 22x 22x 22x 22x 22x 22x     2x 1x 1x 1x 1x 1x 1x                 2x 6x 6x                 2x                           2x 27x 27x   2x       27x 24x 24x 27x           27x 27x         27x 27x   27x       6x 6x 6x 6x       3x 2x 2x 2x 3x 3x 3x   12x 12x     5x 5x 5x 5x         12x 12x 27x             2x 15x 14x 14x 14x 14x 1x 15x   2x 4x 4x 3x 3x 4x 4x   2x 20x 20x 16x 20x 20x 14x 14x 20x 2x                 2x 19x 19x      
/**
 * *Go here* and *tell me where you are*, across the frame boundary.
 *
 * An embedded viewer that can only be linked to is a picture. The thing a host
 * page actually wants is the other half: a table of loci beside the frame whose
 * rows move the map, and a caption under it that follows the map when the reader
 * moves it themselves. Both are one `postMessage` channel, and this is it.
 *
 * ## The protocol, in six messages
 *
 * Out, from the frame:
 *
 * - `{ type: 'chromosomix:ready', protocol, version, commit, accepts }` — once,
 *   when the window is mounted. It carries **nothing about the reader's
 *   session**, which is what lets it be the one message posted before anybody
 *   has identified themselves. `accepts` lists the messages this window takes,
 *   because adding one leaves the protocol's number alone: a host that needs
 *   `add` reads it here, and a frame whose `ready` has no list — every build
 *   before `add` — takes `where` and `navigate` only.
 * - `{ type: 'chromosomix:state', state }` — the view state (§5.5), as the same
 *   object the URL hash is written from, or `null` for the file picker.
 * - `{ type: 'chromosomix:rendered', report }` — the picture on screen is
 *   finished, and what it is (`perf/rendered.ts`): once a navigation has been
 *   drawn with everything beside it, its maps at the resolutions they were
 *   drawn at. What a host takes a screenshot on, or times.
 *
 * In, from the host:
 *
 * - `{ type: 'chromosomix:where' }` — answered with `state`.
 * - `{ type: 'chromosomix:navigate', state }` — go here. A **patch**: the keys
 *   given are laid over the view state that is on screen, so `{ locus: … }` moves
 *   the browser without the host having to know what else is in the state, a key
 *   set to `null` is *removed*, and `state: null` is the picker.
 * - `{ type: 'chromosomix:add', files }` — one more file in this tab, for each
 *   address in `files`, in order: what the header's `+` does with an address
 *   typed into it (§5.5) — a map beside the map, a track among the tracks, a
 *   workspace's tabs — and on an empty tab the first opens as its file. The
 *   view state has no word for a track, so this is how a host that frames a map
 *   puts its tracks beside it. Answered with `state` once every file is in.
 *
 * Messages are carried out **in the order they came**. An `add` opens files,
 * which takes a while, and a `navigate` sent after one waits for it: a host
 * that adds a 1D browser's tracks and then asks for a locus is asking for the
 * locus *on those tracks*, not on the picker they are still opening over.
 *
 * ## Who is answered, and when
 *
 * The channel answers **the window that framed this one** — the immediate
 * parent, not an arbitrary ancestor and not another frame — and it only ever
 * speaks after that window has spoken. `ready` is the single exception and is
 * the reason the handshake terminates: the host cannot know when the frame is
 * listening, so the frame says so, and says nothing else until it is addressed.
 *
 * Replies and pushes then go to the origin the host spoke from, never to `*`.
 * That matters for one case which is easy to miss: a reader can open **their
 * own** file in an embed — a private bucket URL, a dropped path — and the state
 * that follows names it. The host page already knows the URL it framed; it has
 * no business learning the one the reader typed unless it is the page that asked
 * to be told.
 *
 * The exception is an opaque origin — a `file://` host, or a sandboxed frame
 * without `allow-same-origin`, both of which report `"null"` and cannot be named
 * as a target. There the reply goes to the same window with no origin named,
 * because that is the only address it has, and a message posted straight back at
 * the window that just spoke is as narrow as `*` ever gets.
 *
 * ## Why a patch is parsed rather than believed
 *
 * The payload is an object from another origin, so it is exactly as untrusted as
 * the URL hash — and the hash already has a reader that validates every key,
 * clamps what needs clamping and drops what it does not recognise. So a patch is
 * merged, **serialised, and parsed back** through `state/ViewState.ts`. Nothing
 * here decides what a legal view state is; `parseViewState` does, as it does for
 * a pasted link, a restored tab, and — through `viewStateOf`, which this shares
 * with them — a workspace file and a share link. One parser, every road.
 */
 
import { RENDERED, type RenderedReport } from '../perf/rendered'
import { serialiseViewState, viewStateOf } from '../state/ViewState'
import type { ViewState } from '../types'
import { COMMIT, VERSION } from '../version'
 
/** The frame is mounted and listening. Carries nothing about the session. */
export const EMBED_READY = 'chromosomix:ready'
/** Where the view is now, in answer or unprompted. */
export const EMBED_STATE = 'chromosomix:state'
/** Ask where the view is. */
export const EMBED_WHERE = 'chromosomix:where'
/** Move the view. */
export const EMBED_NAVIGATE = 'chromosomix:navigate'
/** Put files in this tab, by their addresses. */
export const EMBED_ADD = 'chromosomix:add'
 
/**
 * The most addresses one `add` is taken for: a deck of maps and a column of
 * tracks, and not a host's whole bucket listing in one message.
 */
export const EMBED_ADD_MAX = 64
 
/**
 * The protocol's own version, carried by `ready`.
 *
 * Bumped when a message changes meaning, never when one is *added* — the same
 * rule `CONFIG_VERSION` states, for the same reason: a host written against
 * version 1 ignores a message it has never heard of, and would be broken by a
 * key that quietly started meaning something else.
 */
export const EMBED_PROTOCOL = 1
 
/** What the channel needs of the window it is driving. */
export interface EmbedHost {
  /** The view state on screen, or `null` for the picker. */
  state(): ViewState | null
  /** Go here. `null` is the picker. */
  navigate(state: ViewState | null): void
  /**
   * Put the files at these addresses in this tab, as the header's `+` does,
   * once whatever is opening has opened. Absent where a window cannot take one.
   */
  add?(files: string[]): Promise<void>
}
 
/** The half of `Window` a message is posted to; a test hands over a spy. */
export interface MessageTarget {
  postMessage(message: unknown, targetOrigin: string): void
}
 
/** The half of `Window` messages arrive on. */
export interface MessageSource {
  addEventListener(type: 'message', listener: (event: MessageEvent) => void): void
  removeEventListener(type: 'message', listener: (event: MessageEvent) => void): void
}
 
/**
 * Lay a patch over a view state, then read the result as if it were a link.
 *
 * Only string, number and boolean values are laid over, so an array, an object
 * or a function in the patch leaves the key as it was rather than being
 * stringified into a value — and the merged state is then read by
 * `viewStateOf`, the one reader every view state from outside goes through (a
 * workspace file's and a share link's too), which reads it as the hash body the
 * address bar would have carried, whatever the host sent.
 *
 * `null` **removes** a key, which is the only way a host can say "no locus" —
 * `undefined` does not survive `postMessage`'s structured clone, so it cannot be
 * the vocabulary for it.
 */
export function mergeState(
  current: ViewState | null,
  patch: Record<string, unknown> | null,
): ViewState | null {
  if (patch === null) return null
  const merged: Record<string, unknown> = { ...(current ?? {}) }
  for (const [key, value] of Object.entries(patch)) {
    if (value === null || value === undefined) delete merged[key]
    else if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
      merged[key] = value
    }
  }
  return viewStateOf(merged)
}
 
/** `true` for a message shaped like one of ours, whoever sent it. */
function isEmbedMessage(data: unknown): data is { type: string; state?: unknown } {
  return (
    typeof data === 'object' &&
    data !== null &&
    typeof (data as { type?: unknown }).type === 'string'
  )
}
 
/**
 * One frame's end of the conversation.
 *
 * Built by {@link embedChannel} in the app and by tests directly, which is why
 * the parent and the event source are arguments rather than reached for: the
 * whole protocol is then assertable without a second window existing.
 */
export class EmbedChannel {
  /**
   * Who has spoken, and how to answer them. `null` until somebody has.
   *
   * The origin is what `postMessage` is given as its target, so the opaque case
   * is not a second field but a different value in this one: `'*'`, aimed at the
   * same window. Every send then reads one thing, and there is no pair of fields
   * that can disagree about who the host is.
   */
  private reply: { target: MessageTarget; origin: string } | null = null
  /**
   * The last state posted, serialised.
   *
   * A `navigate` is answered *and* produces a view change, which is two reasons
   * to send the same thing; and a repaint that leaves the state alone is no
   * reason at all. Comparing the string the address bar would carry collapses
   * both, and is the same equality the hash writer uses one layer up.
   */
  private sent: string | null = null
  private listening = false
  /** Messages taken and not yet carried out — the `add`s opening, and what came after them. */
  private pending = 0
  /** The last of them to finish; the next waits for it. */
  private last: Promise<void> = Promise.resolve()
 
  private readonly onMessage = (event: MessageEvent): void => this.receive(event)
 
  constructor(
    private readonly host: EmbedHost,
    /** The window that framed this one, or `null` when nothing did. */
    private readonly parent: MessageTarget | null,
    private readonly events: MessageSource,
  ) {}
 
  /** Listen, and announce. Safe to call twice. */
  start(): void {
    if (this.listening || this.parent === null) return
    this.listening = true
    this.events.addEventListener('message', this.onMessage)
    // To `*`, and the only message that ever is. A host cannot name an origin it
    // has not been told about, and this carries nothing worth withholding: which
    // build is in the frame, and that it is ready to be addressed.
    this.parent.postMessage(
      {
        type: EMBED_READY,
        protocol: EMBED_PROTOCOL,
        version: VERSION,
        ...(COMMIT === '' ? {} : { commit: COMMIT }),
        accepts: [EMBED_WHERE, EMBED_NAVIGATE, ...(this.host.add === undefined ? [] : [EMBED_ADD])],
      },
      '*',
    )
  }
 
  /** Stop listening. The host is forgotten with the listener. */
  stop(): void {
    if (!this.listening) return
    this.listening = false
    this.events.removeEventListener('message', this.onMessage)
    this.reply = null
    this.sent = null
  }
 
  /**
   * The view moved.
   *
   * Called on every view change, and silent until somebody has asked to be told
   * — which is what keeps an embed that nobody is driving from posting a
   * message per pan.
   */
  moved(): void {
    this.send()
  }
 
  /**
   * The picture on screen is finished (`perf/rendered.ts`).
   *
   * Told to the host that has spoken and to nobody before it, like every
   * message but `ready`: the report carries the view state, and that names
   * whatever file the reader opened.
   */
  rendered(report: RenderedReport): void {
    const to = this.reply
    if (to === null) return
    to.target.postMessage({ type: RENDERED, report }, to.origin)
  }
 
  /**
   * `true` when this message came from the window that framed us.
   *
   * The two casts are to one type the DOM keeps apart for good reasons that are
   * not this one: `event.source` is a `MessageEventSource` and `parent` is
   * narrowed here to the one method this class calls on it. Identity is the
   * whole question, and both are the same object when the answer is yes.
   */
  private fromParent(event: MessageEvent): boolean {
    return (event.source as unknown) === (this.parent as unknown)
  }
 
  private receive(event: MessageEvent): void {
    // The parent and nothing else. A message from a sibling frame, from a
    // pop-up, or from this window's own code is not the host addressing us, and
    // an embed has exactly one host.
    if (this.parent === null || !this.fromParent(event)) return
    const data = event.data
    if (!isEmbedMessage(data)) return
    if (data.type !== EMBED_WHERE && data.type !== EMBED_NAVIGATE && data.type !== EMBED_ADD) return
 
    // A `navigate` carries a patch or an explicit `null` and nothing else.
    // Anything else is a typo rather than a question — refused **before** the
    // host is recorded, so a malformed message neither moves the view nor
    // subscribes its sender to what the reader does next.
    const patch = data.type === EMBED_NAVIGATE ? data.state : undefined
    if (data.type === EMBED_NAVIGATE && patch !== null && typeof patch !== 'object') return
 
    // Whoever has spoken is who gets told from now on. An origin that cannot be
    // named is answered at the window itself; see the header on why that is as
    // narrow as it looks.
    const named = event.origin !== 'null' && event.origin !== ''
    this.reply = { target: this.parent, origin: named ? event.origin : '*' }
 
    if (data.type === EMBED_ADD) {
      // Strings only, and a bounded number of them: the addresses are another
      // origin's, and what each one is — and whether it is an address this
      // window will fetch at all — is the host's `add` to decide.
      const files = (data as { files?: unknown }).files
      const list = Array.isArray(files) ? files.filter((file): file is string => typeof file === 'string') : []
      const add = this.host.add
      if (list.length === 0 || add === undefined) return
      // Answered whether or not the view moved: a host waits on this to know
      // the files are in, and a state it has already been told is still the
      // answer to that.
      const answer = (): void => {
        this.sent = null
        this.send()
      }
      this.inTurn(() => add.call(this.host, list.slice(0, EMBED_ADD_MAX)).then(answer, answer))
      return
    }
 
    this.inTurn(() => {
      if (data.type === EMBED_NAVIGATE) {
        // Merged with the state on screen when its turn comes, not when it
        // arrived: after an `add`, that is the tracks the add opened.
        this.host.navigate(
          patch === null ? null : mergeState(this.host.state(), patch as Record<string, unknown>),
        )
      }
      // Both messages are answered with the state, and `navigate` is answered
      // *after* it has been applied — so what comes back is where the view
      // actually went, which for a patch the parser trimmed is not always where
      // the host asked to go.
      this.send()
    })
  }
 
  /**
   * Carry out a message now if nothing before it is still running, or after
   * the last thing that is. A `where` or a `navigate` with nothing ahead of it
   * is answered before `receive` returns, as it always was.
   */
  private inTurn(work: () => void | Promise<void>): void {
    if (this.pending === 0) {
      const started = work()
      if (started !== undefined) this.hold(started)
      return
    }
    this.hold(this.last.then(work))
  }
 
  private hold(work: Promise<void>): void {
    this.pending += 1
    const settled = (): void => {
      this.pending -= 1
    }
    this.last = work.then(settled, settled)
  }
 
  private send(): void {
    const to = this.reply
    if (to === null) return
    const state = this.host.state()
    const key = state === null ? '' : serialiseViewState(state)
    if (key === this.sent) return
    this.sent = key
    to.target.postMessage({ type: EMBED_STATE, state }, to.origin)
  }
}
 
/**
 * The channel for this page, or `null` where there is nobody to talk to.
 *
 * `null` in a top-level window, which is the ordinary case and not a failure:
 * the app is then the whole page and the host it would be answering is the
 * reader.
 */
export function embedChannel(host: EmbedHost): EmbedChannel | null {
  if (typeof window === 'undefined') return null
  if (window.parent === window.self || window.parent === null) return null
  return new EmbedChannel(host, window.parent, window)
}