Skip to content

feat: v0.2.0 — DOM-race hardening, tables, iframes, Shadow DOM - #1

Merged
rrader26 merged 7 commits into
mainfrom
feat/v0.2.0-hardening-tables-iframes
Apr 26, 2026
Merged

feat: v0.2.0 — DOM-race hardening, tables, iframes, Shadow DOM#1
rrader26 merged 7 commits into
mainfrom
feat/v0.2.0-hardening-tables-iframes

Conversation

@rrader26

Copy link
Copy Markdown
Contributor

v0.2.0 — three improvements driven by real-world deployment

These changes were proven in production at thinkbrowse.io before being upstreamed. We hit each issue running agentmark in a real agent loop and decided the fixes belong here, in the canonical reference implementation, so other consumers benefit too.

1. DOM-race hardening

  • Null-safe document.body access in the extractor. The crash trace was Cannot read properties of null (reading 'children') mid-navigation — domcontentloaded can fire before the new body is attached.
  • waitForFunction(() => document.body) in the wait strategy before mutation observers attach.
  • Body-existence guards inside the observer script + the aggressive-mode scroll script.

2. Type-import alignment: playwrightplaywright-core

The library imported Page from the full playwright package. Server-side consumers using the smaller playwright-core hit a nominal-type mismatch and had to cast through any. Switched all import type { Page } from 'playwright''playwright-core'. Same runtime, cleaner contract.

Peer dep + tsconfig adjusted accordingly (DOM lib added so () => document.body arrow functions type-check without leaking any).

3. Semantic table support

Tables previously fell through generic-container handling and emerged as flat cell-text streams. LLMs had to infer rows/columns from sequence — fragile and breaks on empty cells.

New emitTable walker recognizes <table>, picks up <thead> / first-row-of-<th> as headers, walks <tbody> rows, and renders to GitHub-flavored markdown:

| Plan | Price  | Limit      |
|---|---|---|
| Free | $0/mo  | 100 calls  |
| Pro  | $29/mo | 50k calls  |

LLMs read this natively. New BodySegment kind: { kind: 'table', headers?, rows, caption? }. Tests cover: header synthesis, captions, pipe/newline escaping, short-row padding.

4. Shadow DOM piercing + iframe handling

  • processNode walks el.shadowRoot.children before tag-specific handling. Web components (Salesforce Lightning, MS 365, every Lit/Polymer app) become visible. Open shadow roots only.
  • Same-origin iframes: walk contentDocument.body inline so consumers see iframe content as part of the parent.
  • Cross-origin iframes: contentDocument access throws SecurityError; emit [IFRAME:n] tag with src URL + title so consumers know it's there. New IFRAME_OPEN / IFRAME_CLOSE boundary tags.

Test plan

  • All existing tests pass
  • New tests for table rendering: headers, captions, escaping
  • (manual) Verify with a couple of real pages including data tables
  • CI green

Versioning

Bumped to 0.2.0 (minor — additive features, no breaking changes for existing consumers).

* dom-extractor.ts: null-safe `document.body` access. Crash trace
  was `Cannot read properties of null (reading 'children')` mid-
  navigation; the wait-strategy didn't always catch the race.
* wait-strategy.ts: explicit `waitForFunction(() => document.body)`
  before observer attaches. Body-existence guards inside the
  observer script + scroll-to-bottom too.
* converter.ts / wait/* / mutation-observer: switch type imports
  from `playwright` to `playwright-core`. The reference
  implementation was carrying a peer dep on the larger `playwright`
  package; consumers using `playwright-core` (lighter, server-side
  default) hit nominal-type-mismatch errors at the boundary.
* tsconfig.lib.json: include DOM lib so `document` references in
  arrow functions passed to `page.waitForFunction` type-check
  without leaking `any`.
* package.json: peerDep flips `playwright` → `playwright-core`.
Closes the largest "structured DOM data" gap. Tables previously fell
through generic-container handling and emerged as flat cell text with
no row/column structure preserved — LLMs had to *infer* tabular shape
from sequential text.

* dom-extractor.ts: new `emitTable(table)` walker. Picks up:
  - <caption> or aria-label as caption
  - <thead><tr><th> as headers (or first row if all <th>)
  - tbody rows (or direct <tr> children if no tbody)
  - cell text from <td>/<th> via .textContent, whitespace-collapsed
  - 200 rows × 30 cols cap for snapshot-size safety
* New BodySegment kind: `{ kind: 'table', headers?, rows, caption? }`
* body-builder.ts: renders to GitHub-flavored markdown — pipes,
  separator row, escapes `|` inside cells, normalizes newlines to
  spaces. LLMs read GFM tables natively.
* Tests for headers, header synthesis, captions, escaping, padding.
Shadow DOM piercing:
* In `processNode`, walk `el.shadowRoot.children` before tag-specific
  handling. Modern web components (Salesforce Lightning, MS 365,
  every Lit/Polymer/Stencil app) hide interactive content behind
  shadow roots. Without piercing, the extractor saw an empty
  `<my-button>` with no text or actions.
* Open shadow roots only — closed ones are deliberately unreachable
  to script (browser security model).

Iframe traversal:
* Same-origin iframes: walk `contentDocument.body` inline so the
  consumer sees iframe content as part of the parent. Most embedded
  forms / docs / maps fit this case.
* Cross-origin iframes: `contentDocument` access throws SecurityError;
  emit an `[IFRAME:n]` tag with src URL + title so the consumer at
  least knows the iframe exists. (Cross-frame action dispatch is
  consumer-side concern.)
* Adds `IFRAME_OPEN` / `IFRAME_CLOSE` boundary tags around inlined
  content.
…extractor

Three fixes from CI run #24966542292:

1. `typescript` was missing from devDependencies even though the
   build script runs `tsc`. Added `^5.4.0`.
2. `package-lock.json` didn't exist, breaking `npm ci` in the workflow.
   Generated.
3. The third commit on this branch (iframes + Shadow DOM) overwrote
   the table type addition + emitTable helper + processNode branch
   from the second commit (table support). Re-applied them on top so
   the BodySegment union, the helpers, and the processNode handler
   are all in dom-extractor.ts together.

Verified locally: `npm run build` is clean, all 90 tests pass
(including the 5 new table tests).
`npm ci` fails when the lockfile was generated on a different OS
than the CI runner — the rollup native bindings (and other
platform-specific optional deps) get omitted from the lockfile and
then can't be found at install time. Known npm bug:
npm/cli#4828

Switch both CI jobs to `npm install --no-audit --no-fund` which
fetches platform-appropriate optional deps. Slightly less
reproducible but cross-platform CI works again.

Until the npm bug is fixed, this is the standard workaround used
by vitest, rollup, esbuild, swc, and many other native-bindings
projects.
…und)

Standard practice for publishable npm libraries: no committed
lockfile, downstream consumers resolve their own deps. Doubly
necessary here because of the npm cross-platform optional-deps bug
(npm/cli#4828) — lockfiles generated on
one OS omit the native binding packages other OSes need (rollup,
esbuild, swc), breaking CI when the lockfile + runner OS mismatch.

Adding lockfiles to .gitignore alongside node_modules and dist.
@rrader26
rrader26 merged commit bd208e5 into main Apr 26, 2026
3 checks passed
@rrader26
rrader26 deleted the feat/v0.2.0-hardening-tables-iframes branch May 10, 2026 19:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants