Address review feedback:
- Reuse previewText on success when the body fits inside the preview
window, avoiding a redundant second decode for small responses.
- Add regression tests for #46: full html for >4 KiB responses, a
multi-byte character straddling the preview boundary, and small
responses returned unchanged. Verified the large-body test fails
against the pre-fix Tier 1.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Since d1ebbdd (1.2.0), Tier 1 reuses previewText — the 4096-byte slice
decoded for challenge detection — as the html field on success. The
/v1 and /scrape routes serve html as solution.response, so any
non-challenged text response larger than 4 KiB comes back silently
truncated: HTTP 200, status ok, no error anywhere.
Decode the full rawBytes buffer for html instead. previewText remains
bounded and is still used only for challenge/block detection. Tiers
2-4 are unaffected (their html is the browser-rendered DOM).
Fixes#46
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`BrowserPool.restartEntry` awaited `context.close()`, `browser.close()` and the
Camoufox launch with no timeout on any of them. Camoufox hangs on close when a
content process is wedged — tiers/3.ts and tiers/4.ts already guard their
*temporary* contexts against exactly this with a 5s `Promise.race` — but the
persistent context and browser the pool owns had no such guard, and launches can
hang too.
When any one of those hangs, the entry is pinned at `restarting = true` forever.
From then on the health check hits its own `if (entry.restarting) return` guard,
so every 30s tick logs "browser N disconnected, restarting" and does nothing.
The pool silently loses that slot permanently: `restartCount` never increments,
so the restart counter sits frozen while the log implies furious activity. With
enough uptime every entry ends up in this state and the pool is inert.
Changes:
* every await in `restartEntry`, `init()` and `shutdown()` is bounded. On
timeout the entry is left unhealthy with `restarting` cleared, so the next
health-check tick retries it from scratch instead of wedging.
* `runHealthCheck` reclaims checkouts past their deadline. Previously busy
entries were skipped entirely, so an entry whose request wedged was never
examined again.
* a per-checkout `lease`, returned on the handle and passed back to
`release()`, so a request that outlives its checkout cannot free — or
recycle, via `noteTemporaryContext` — a browser the pool has since handed to
someone else.
* `release()` hands its in-flight page closes to `restartEntry` rather than
racing them, since closing a context underneath in-flight `page.close()`
calls is one way to wedge the transport in the first place.
* abandoned launches are counted and capped. A timeout can only stop *waiting*
for a launch, not cancel it, so retrying without a cap could pile up hung
Firefox processes; past the cap the entry stays down and `live` reflects it.
Timeouts are configurable (`closeTimeoutMs`, `launchTimeoutMs`, `stallAfterMs`,
`healthIntervalMs`) with the API exposing them as BROWSER_*_MS env vars.
Adds regression tests for the hung close, the hung launch, stall accounting,
budget-aware stall deadlines, disconnected-but-busy entries, and stale releases.
The hung-close and hung-launch tests both fail against the unpatched pool.
trawl returned some Akamai-fronted pages as 200 'success' with only the
~2KB sec-cpt behavioral interstitial as content, because tier detection
knew Cloudflare/Imperva but not Akamai.
- detect.ts: hasAkamaiChallenge() + 'akamai' ChallengeType (sec-if-cpt-container
/ behavioral-content markers, size-gated sensor fallback); wired into
detectChallengeType/isBlocked/needsJs.
- akamaiWait.ts (new): Akamai analogue of challengeWait/impervaWait — drives
human-like mouse motion, press-and-hold on the behavioral widget, waits for
the sensor's location.reload() into real content.
- tiers 1-4: escalate the 200 interstitial (needs-js), invalidate a stale
cached-session interstitial, dispatch the resolver, report akamai-persistent.
Additive; Cloudflare/Imperva paths untouched. Verified against Edmunds.
Splits the single [Unreleased] CHANGELOG block into dated 0.1.0-1.0.0
sections matching the milestone commits being tagged for issue #24
(numeric release tags), and bumps every package.json to 1.0.0.
Found while running trawl against a large batch of real-world URLs: several
cases where the API returned 200 with content that was actually a blocked
page, an empty challenge stub, or Firefox's own error page. Each was a
detection gap where a tier didn't recognize the failure and reported it as a
successful scrape.
- Recognize Firefox's about:neterror/about:certerror page (browser never
reached a server), Cloudflare's static "you have been blocked" WAF-deny
page, and a lean CF challenge stub (blank title/body, just the bootstrap
script) — the stub check is gated on page size since the same script
snippet also appears on ordinary, fully-loaded CF pages as bot-management
telemetry.
- Wire the existing isBlocked() status-code check (403/429/202) into Tiers 2
and 3 — previously only Tier 1 checked status code, so a generic non-CF WAF
deny that escalated to a browser tier was reported as a success.
- Bring Tier 4 up to parity with Tier 3: captcha solving and the same block
detection. Sites that need Tier 4 for IP reputation can just as easily have
an in-page captcha widget.
- Add proxyUsed: boolean to the response, set from the actual proxy used by
the winning tier — previously the only signal was inferring from tier === 4,
which doesn't distinguish "no proxy" from Tier 3's datacenter proxy.
- Attach the per-tier timings array to thrown errors via a new ScrapeError,
and return it in /scrape's error response. The array was already being
built in memory; it just never survived the throw, so failed requests gave
a flat error string with no way to see which tier failed or why.
- Add process-level uncaughtException/unhandledRejection handlers. One target
site's page threw a JS error that Camoufox/Firefox reports in a shape
playwright-core's dispatcher doesn't expect, which crashed the entire
process and dropped every in-flight request across all clients.
- Update the native API docs for the new response fields and error shape.
All additive — no existing fields changed shape. Full existing test suite
passes (58/58), and this is rebuilt/smoke-tested against latest dev.