| name | dom-mirror-overlay |
| description | Use when modifying the iframe DOM mirror (server `sanitize.ts`) or the in-iframe overlay script (`frontend/src/lib/overlay.ts`). Covers the sanitization pipeline rules, the overlay script contract, the postMessage protocol, and the cross-package sync requirement. |
Skill: DOM Mirror & Overlay
This pair of components is the trickiest part of the system. Read
docs/07-dom-mirror.md and docs/09-api-contracts.md before changing
anything here.
Sanitization pipeline (server)
Order of operations in server/src/sanitize.ts (DO NOT reorder without
updating tests):
- Remove dangerous tags:
script, noscript, iframe, object,
embed, link[rel="manifest"], link[rel*="prefetch"], SW regs.
- Strip event-handler attributes (
on*).
- Strip
javascript: URLs from href/src/action.
- Rewrite relative URLs → absolute using request
baseUrl.
Targets: href, src, srcset, poster, action, data,
style url(...).
- Inject
<base target="_top"> in <head>.
- Best-effort inline external stylesheets via
deps.fetcher. On any
failure, leave the <link> untouched.
- Append the overlay script as a plain
<script> tag at the end of
<body>. The script carries no marker attribute; the overlay IIFE
guards itself against double-install by setting
window[OVERLAY_INSTALLED_FLAG] on first run and short-circuiting
on subsequent runs. The sanitizer therefore appends
unconditionally — any pre-existing overlay <script> from a prior
sanitize pass was already removed in step 1.
Each rule has a dedicated test. When adding a rule, add the test first.
Overlay script contract
frontend/src/lib/overlay.ts exports a single overlayScript: string.
This string must be:
- A self-contained IIFE.
- No imports. No bundler magic. Plain ES2020.
- Idempotent: re-injecting it must not double-bind handlers. The IIFE
is the sole owner of the install-once invariant — it sets
window[OVERLAY_INSTALLED_FLAG] = true on first run and bails on
every subsequent run. Do not reintroduce a parallel marker (e.g. a
data-* attribute on the injected <script>); a single source of
truth keeps the two packages from drifting.
- Side-effect-free outside
document.body and window listeners it
installs.
What it does:
- On
DOMContentLoaded, post { source, type: "overlay_ready" }.
- On
dragover: compute elementFromPoint, walk up to the nearest
non-html/non-body element, apply outline + a small floating label
(positioned absolute, pointer-events: none).
- On
dragleave / drag end: clear the outline.
- On
drop: compute the selector via the shared selector.ts
algorithm (inlined into the bundle that produces the script string),
then post { source, type: "tag_dropped", selector }.
- Intercepts
click on links/forms inside the document and calls
preventDefault() so the user never accidentally navigates the
srcdoc.
Constants:
const SOURCE = "iframe-scrapper-overlay";
postMessage protocol
Parent ↔ overlay messages always carry source: "iframe-scrapper-overlay".
Anything else is ignored on both ends.
| Direction | type | payload |
|---|
| overlay → parent | overlay_ready | — |
| overlay → parent | tag_dropped | { selector: string } |
Future (not v1): place_marker, clear_marker going parent → overlay.
Cross-package sync
The overlay script lives in frontend/src/lib/overlay.ts but is served
by the sidecar inside the sanitized HTML. Two acceptable approaches:
Approach A (preferred): build-time copy.
A tiny server/scripts/sync-overlay.ts reads
frontend/src/lib/overlay.ts, extracts the overlayScript constant
content, and writes server/src/overlay.generated.ts. Run as a
prebuild/predev step in the server package. A unit test asserts
the two strings match (regenerates and diffs in the test).
Approach B: source-of-truth in shared text file.
Put the overlay JS in shared/overlay.js and have both packages import
its contents via Vite's ?raw and Node's fs.readFileSync. Adds a
shared dir but avoids generation.
Pick A for v1. Document the script in the file header.
Testing
- Sanitizer: see
docs/11-testing.md § sanitizer rules.
- Overlay: load the script string into a jsdom iframe via
iframe.srcdoc = "<html><body>" + sample + "<script>" + overlayScript + "</script></body></html>",
then drive dragover/drop events and listen for messages on
window.
- Sync test:
expect(serverOverlayCopy).toBe(frontendOverlayCopy).
Anti-patterns
- Inserting raw user input into the HTML without going through cheerio.
- Allowing scripts back in for "convenience". The mirror is a static
snapshot — period.
- Mutating
document.documentElement from the overlay. Stick to
document.body.
- Posting messages without the
source discriminator.
- Importing anything heavy into
overlay.ts — the file's content
becomes a string shipped to the browser; keep it tiny.
Checklist before marking done