Skip to content

Runtime flow

How a page becomes a tour: the content script gate, the service worker, the player, and the frame-agent relay for cross-origin frames.

flowchart LR
  subgraph page["Top frame (host page)"]
    gate["1. Content gate"]
    player["4. Player<br/>(/player.js)"]
    ghost["8. Ghost anchor<br/>data-wp-synthetic"]
  end
  subgraph xf["Cross-origin frame"]
    gate2["Content gate<br/>(allFrames)"]
    agent["6. Frame agent<br/>(/frame-agent.js)"]
  end
  subgraph sw["Service worker"]
    reg["frame.register<br/>frames:tabId"]
    man["manifest.get"]
    inj["player.inject"]
    rel["Relay<br/>locator.resolve"]
  end
  store[("LocalStore<br/>IndexedDB")]

  gate -->|"2. frame.register,<br/>manifest.get"| reg
  gate -->|"2"| man
  man --> store
  man -->|"3. entry matches path"| gate
  gate -->|"3. player.inject"| inj
  inj -->|"4. inject into frame 0"| player
  player -->|"5. tour.get,<br/>progress.get/set"| store
  player -->|"6. locator.resolve"| rel
  rel --> agent
  agent -->|"7. rect"| rel
  rel -->|"7. rect"| player
  player -->|"8. refresh every 300 ms"| ghost
  gate2 -->|"frame.register"| reg

  style store fill:#e0f2f1,stroke:#00796b
  style sw fill:#eceff1,stroke:#546e7a

Companion note

Node Description
1. Content gate content.js, about 2 kB gzip. Registered at run time with scripting.registerContentScripts for granted origins only, in all frames (allFrames, matchOriginAsFallback). Zod-free
frame.register Stores each frame of a tab in frames:<tabId> in storage.session, written through a promise chain so concurrent registrations do not overwrite each other
manifest.get Returns the published manifest entries for the app that owns the page origin
player.inject Asks the worker to inject the player
4. Player /player.js, injected by the worker into frame 0 only. Runs triggers, steps, ambient content
6. Frame agent /frame-agent.js, injected on demand into a cross-origin frame. Resolves the locator tail inside that frame and returns a rectangle
Relay The worker handles the player's locator.resolve, strips the hop it just crossed from the locator, and forwards it to the frame as the internal guidepost.frame.resolve message
8. Ghost anchor An element in the top frame positioned at the rectangle and refreshed every 300 ms. The overlay anchors to it
LocalStore IndexedDB guidepost. The worker is the only reader for the content script and player
Step Flow
1 Page loads. The gate runs in every frame of a granted origin
2 The gate sends frame.register and manifest.get to the worker
3 If a manifest entry matches the page path, the gate sends player.inject. Otherwise it stops and does no more work
4 The worker injects /player.js into frame 0
5 The player fetches content with tour.get and reads and writes progress with progress.get and progress.set
6 For an anchor in a cross-origin frame the player sends locator.resolve to the worker
7 The worker injects the frame agent if needed, the agent resolves the locator and returns a rectangle
8 The player draws against a ghost anchor in the top frame, refreshed every 300 ms

Authoring paths

  • Picker: the studio sends picker.start. The worker injects /picker.js into all frames of the tab and keeps the session in storage.session as picker:<tabId>. It re-injects on tabs.onUpdated, so a navigation does not end a session. Picks come back with picker.picked.
  • Preview: preview.play goes to the worker, which retries tabs.sendMessage for up to 3 seconds while the player starts.

Gotchas

  • The service worker is stopped after about 30 seconds idle. Nothing important lives in its memory: sessions are in storage.session, content is in IndexedDB.
  • The gate does the minimum before the worker answers: a page that matches nothing costs one manifest.get round trip and no injection.
  • Only frame 0 gets the player. Frames that are same-origin are reached directly from the player; cross-origin frames need the agent.