Rendered Fetch#
Decision#
fetchSource/crawlSource (@kontourai/traverse/fetch) add an OPT-IN
renderImpl seam (issue #41) so a caller can ingest an SPA/JS-rendered page
without traverse core taking a browser dependency. Traverse ships zero new
runtime dependencies for this: a caller plugs in any renderer (Playwright,
Puppeteer, a remote rendering service, a test stub) that satisfies the
RenderImpl signature.
1. Render policy and compatibility#
SourceConfig.renderPolicy is the orchestration control:
| Policy | Attempts | Validator rule | Selected result |
|---|---|---|---|
never |
One plain attempt | Exact-resource revalidation is unchanged | Plain result |
always |
One rendered attempt | Revalidation is suppressed; no validator-store read, conditional header, 304, or validator capture | Rendered result; a missing renderer is typed invalid-config |
on-shell-warning |
One plain attempt, then zero or one rendered attempt | Only the plain attempt may use validators; the render retry receives no revalidation state | A successful rendered snapshot wins; a failed or unavailable renderer falls back to the successful plain snapshot |
The deprecated SourceConfig.render?: boolean spelling remains compatible:
with no renderPolicy, render: true maps to always and render: false or
an absent value maps to never. When both keys are supplied, they must agree
semantically: true only agrees with always, and false only agrees with
never. All other combinations return a typed invalid-config result before
I/O. This is an additive, semver-minor API change; the legacy key is not
removed.
2. RenderImpl signature and the timeoutMs hint#
type RenderImpl = (
url: string,
opts: { userAgent: string; timeoutMs: number },
) => Promise<RenderResult>;
interface RenderResult {
html: string;
finalUrl?: string;
status?: number;
warnings?: string[];
}
Unlike FetchLike, fetchSource does NOT wrap renderImpl in its own
timeout race — there is no AbortSignal/cancellation parameter. timeoutMs
is passed as a DOCUMENTED HINT the caller's implementation is responsible for
enforcing (e.g. a Playwright navigation timeout). This is a deliberate,
accepted scope limit, not an oversight: traverse core has no way to
cancel/abort a caller-owned rendering process (e.g. a headless browser
tab) the way it can abort its own fetch() call.
3. Robots ordering: checked once, before any render#
robots.txt is checked EXACTLY ONCE, against the requested URL, before
renderImpl is ever invoked — reusing the same loadRobots/isPathAllowed
calls the wire-fetch redirect loop already uses (no re-implementation). A
robots-denied result never calls the injected renderImpl.
This is a NARROWER guarantee than plain fetchSource's per-redirect-hop
check: a rendered fetch has no manual redirect loop for traverse to hook
per-hop checks into, since renderImpl (not traverse) owns all navigation
internally, including any client-side redirect it may follow. This is an
accepted, documented scope limit: robots is NOT re-checked against any
client-side redirect/navigation the renderer itself follows.
4. Snapshot shape and the rendered marker#
A successful render becomes a normal Snapshot:
contentTypeis always"html".body= the rendered HTML (renderResult.html) verbatim.bodyHash = sha256Hex(body)— the hash of the UTF-8 ofbody, the basis every snapshot without response bytes uses (seedocs/decisions/text-snapshot-bytes.md); a serialized DOM has no response bytes to hash.rendered: true— an additive, presence-is-the-marker field (never explicitfalseon a wire snapshot), mirroringbodyBytesmarking binary content andnotModifiedmarking a 304.url = renderResult.finalUrl ?? requestedUrl— mirrors what a plain fetch'ssnapshot.urlrecords (the final URL the body actually came from).status = renderResult.status ?? 200.redirectsstays UNSET for a rendered snapshot — traverse does not fabricate a redirect chain from something the renderer does not report. This is an accepted gap, not silently glossed over: a caller that needs the renderer's own navigation chain must get it fromrenderResultdirectly (out of scope forSnapshotitself).
5. Provenance / sourceRef: no change#
buildSnapshotSourceRef/parseSnapshotSourceRef are NOT changed for this
seam — no new query param is added. The sourceRef contract already fully
identifies the exact bytes (sourceId + url + sha256 + fetchedAt);
store.get(sourceId, bodyHash) returns the same snapshot, which already
carries rendered honestly. A sourceRef-embedded flag would be a second
place to keep in sync with the snapshot's own field, for no new capability.
If a future consumer needs render-provenance without a store round-trip
(e.g. a log/UI reading raw sourceRef strings with no snapshot access), that
is the trigger to revisit this — not built preemptively here.
6. Validators never enter a rendered attempt#
HTTP validators (etag/lastModified/conditional GET) are skipped entirely
for every rendered attempt — a renderer has no real HTTP response headers to
report, so no If-None-Match/If-Modified-Since is sent and etag/
lastModified stay unset. This includes the second attempt under
on-shell-warning: stored validators and conditional headers belong only to
the initial plain attempt, and revalidate is suppressed before rendering.
A trustworthy plain 304 notModified is final and never triggers rendering.
An explicit warning still records when revalidation was configured but is
inert for an always rendered fetch.
7. Headers and retries are also not forwarded — warn, don't silently drop#
RenderImpl receives only { userAgent, timeoutMs } (see decision 2) — there
is no headers parameter and no retry wrapper around the call. Two caller
config knobs are therefore INERT for a rendered fetch, same category as
revalidate above:
SourceConfig.headers(any caller-supplied extra headers) are never forwarded torenderImpl— the renderer implementation owns its own request headers/auth entirely; traverse has no hook to inject them into a caller-owned rendering process.SourceConfig.retriesnever applies —fetchSource's bounded/jittered retry loop (requestWithRetries) wraps only the directFetchLikepath; arenderImplfailure maps straight toadapter-error(decision 8 below) with no retry attempt.
Mirroring the revalidate treatment, fetchSource pushes an explicit
FetchResult.warnings note for each ONLY when the caller actually set
something — a caller-supplied non-empty headers object, and/or an explicit
retries value — never on fetchSource's own internal defaults (e.g. the
Accept header it adds, or the default retry count when retries is left
unset). A rendered fetch configured with neither gets no such warning. As
with revalidate, this is a deliberate warn-and-document scope limit, not a
gap silently absorbed: header forwarding and a render-retry loop are both
explicitly out of scope for this seam (see Non-goals).
8. Error mapping — no new FetchErrorKind#
renderImplthrowing maps to the existingadapter-errorkind — mirrors the yt-dlp external-tool-failure precedent (src/fetch/youtube.ts) verbatim: "the external acquisition tool ran but failed".renderImplresolving with astatusoutside[200, 300)maps to the existinghttp-errorkind with thatstatus— mirrors the direct-fetch non-2xx branch.render: truewith norenderImplconfigured maps toinvalid-config(see decision 1).- Robots-denied still uses the existing
robots-deniedkind, checked beforerenderImplruns at all (see decision 3).
No new FetchErrorKind value is introduced for render failures; the
exported union is unchanged from before this seam.
9. crawlSource composition#
render joins the existing list of crawl-wide FETCH BEHAVIOR fields a
discovered page's SourceConfig inherits from the seed in crawl.ts
(alongside minDelayMs, timeoutMs, retries, headers, userAgent,
respectRobots, revalidate — see docs/decisions/crawl-frontier.md
decision 6). renderImpl itself needs no crawl.ts change: it already
flows through the existing CrawlOptions.fetchOptions: FetchSourceOptions
forwarding, unchanged. discoverSameHostLinks composes unchanged too — it
operates on Snapshot.body gated on contentType === "html", both of which
a rendered snapshot satisfies exactly like a wire-fetched one.
This rendered-fetch slice did not add crawl/extract composition. Separately,
an exported crawlAndExtract has since shipped in src/fetch/crawl-extract.ts;
it composes @kontourai/forage's crawl with extract(). That does not change
the caller-injected renderer boundary here: Traverse still bundles no browser
or renderer lifecycle and crawlSource remains a fetch-layer driver.
10. Shell classification, winner, fallback, and audit#
on-shell-warning classifies a successful, fresh, unrendered HTML snapshot
during fetch orchestration, using the same bounded HTML preparation and
inspection path extraction uses. It escalates only for the exact pure warning
prefix js-shell-suspected:. The distinct
js-shell-suspected-embedded-state-available: warning defined by
ADR 0005 does not escalate. Neither
do first-attempt errors, binary/text snapshots, already-rendered snapshots, or
trustworthy 304 results. Classification happens before extraction, so a
discarded plain shell never causes a provider pass.
The renderer is invoked at most once per public fetchSource call. Any
successful rendered snapshot wins regardless of its size or eventual proposal
count. Render failure or absence under on-shell-warning falls back to the
successful first snapshot; always without a renderer remains
invalid-config. FetchResult.renderEscalation is typed audit metadata, not a
second result bag. The selected top-level snapshot/error and warnings remain
authoritative. A discarded attempt's shell warnings are not merged into the
successful rendered attempt's extraction-facing warning channel, while
firstSnapshotRef and renderError make replacement or fallback auditable.
11. Cost guards and ownership#
Rendering happens inside acquire() (compose.ts), strictly before the
resulting snapshot is handed to extract(). maxProviderCalls/
maxTotalTokens accounting is entirely extract()-side and never sees
whether a snapshot was rendered or wire-fetched — compose.ts needed no
code change for this seam.
Traverse owns deterministic attempt selection, but the caller owns renderer
cost and lifecycle. Traverse adds no browser dependency, passes the existing
timeoutMs hint, and never retries renderImpl; callers choose and pay for
Playwright, Puppeteer, a remote rendering service, or another implementation.
12. Consumer migration boundary#
The policy seam is ready for the original downstream ingestion pipeline, its check coordinator, and the check-runner package itself to forward policy and consume Traverse's one selected result. Those migrations are follow-up lanes: this change does not modify consumer repositories, preserve consumer-specific telemetry shapes, or raise their Traverse dependency. Consumers should remove local shell parsing, second-fetch coordination, and proposal-count winner rules only when they adopt the minor release containing this seam.
Non-goals (explicit, matching the issue)#
- Shipping any Playwright/browser dependency in traverse core.
- Screenshot/visual capture.
- Auth/session flows.
- Adding crawl/extract composition to this rendered-fetch slice. A separate,
exported
crawlAndExtractinsrc/fetch/crawl-extract.tshas since shipped to compose@kontourai/forage's crawl withextract(); that does not add a bundled browser or renderer lifecycle. - Enforcing
timeoutMsaroundrenderImplitself (see decision 2 — passed as a hint only). - Re-checking robots against a renderer's own client-side navigation (see decision 3).
- Forwarding
SourceConfig.headerstorenderImpl, or retrying arenderImplfailure (see decision 7 — warn-and-document only).
Boundary#
Rendering MECHANICS (invoking a caller's RenderImpl, mapping its result
into a Snapshot) live in traverse; the renderer ITSELF (Playwright setup,
browser lifecycle, navigation strategy) is entirely the caller's — traverse
never instantiates or manages a browser.