proxlane

Changelog

What changed, per package, newest first.

Written from the changesets in each pull request, so it cannot drift from what shipped. Versions move per package; there is no single release train. Last release: 2026-08-29.

Recent

The last 12 releases across every package, newest first.

  • Gateway 0.13.0Minor
    • #238 9860659 wait_for=<css selector> tells the renderer what to wait for before it snapshots the page. Rendering means the renderer ran, not that the content arrived — on a late-hydrating page the same request returns the full listing on one attempt and an empty shell on the next, and until now nothing in the request could name the finish line. It implies render=true, and it narrows the chain to providers that can express it: ScrapingBee's wait_for and Scrapfly's wait_for_selector were verified live, ScraperAPI's is its published name and the canary confirms it, and Bright Data declares it cannot — x-unblock-expect is accepted there and could not be shown to enforce a wait, so it is filtered out rather than charging for a page that did not wait.
    • #237 afd2423 One executor for every request. The gateway, proxlane scrape, pnpm record, the k6 harness and the live canary now all call createFetchTransport() from @proxlane/shared/transport, instead of each hand-rolling its own fetch. The canary's copy dropped wire.body, which only Bright Data sends, so it reported a working key as AUTH_FAILED on every run — repo:check assertion 50 and a new contract test now hold the line. proxlane scrape and pnpm record gain the capped streaming read and the timeout/abort discrimination they were missing.
  • Gateway 0.12.0Minor
    • #235 b694712 A Scrapfly response over 5MB no longer arrives as a URL marked OK. Scrapfly offloads bodies above that size to an object store and returns a pointer; parse() read it as the page, so the caller got 70 bytes with HTTP 200, the target's content-type and a charge. It is now PROVIDER_BODY_OFFLOADED, which fails over to a provider that returns the body inline.
  • CLI 0.4.8Patch
    • #237 afd2423 One executor for every request. The gateway, proxlane scrape, pnpm record, the k6 harness and the live canary now all call createFetchTransport() from @proxlane/shared/transport, instead of each hand-rolling its own fetch. The canary's copy dropped wire.body, which only Bright Data sends, so it reported a working key as AUTH_FAILED on every run — repo:check assertion 50 and a new contract test now hold the line. proxlane scrape and pnpm record gain the capped streaming read and the timeout/abort discrimination they were missing.
  • CLI 0.4.7Patch

    Dependency updates only.

  • Adapters 0.9.0Minor
    • #238 9860659 wait_for=<css selector> tells the renderer what to wait for before it snapshots the page. Rendering means the renderer ran, not that the content arrived — on a late-hydrating page the same request returns the full listing on one attempt and an empty shell on the next, and until now nothing in the request could name the finish line. It implies render=true, and it narrows the chain to providers that can express it: ScrapingBee's wait_for and Scrapfly's wait_for_selector were verified live, ScraperAPI's is its published name and the canary confirms it, and Bright Data declares it cannot — x-unblock-expect is accepted there and could not be shown to enforce a wait, so it is filtered out rather than charging for a page that did not wait.
    • #237 afd2423 One executor for every request. The gateway, proxlane scrape, pnpm record, the k6 harness and the live canary now all call createFetchTransport() from @proxlane/shared/transport, instead of each hand-rolling its own fetch. The canary's copy dropped wire.body, which only Bright Data sends, so it reported a working key as AUTH_FAILED on every run — repo:check assertion 50 and a new contract test now hold the line. proxlane scrape and pnpm record gain the capped streaming read and the timeout/abort discrimination they were missing.
  • Adapters 0.8.0Minor
    • #235 b694712 A Scrapfly response over 5MB no longer arrives as a URL marked OK. Scrapfly offloads bodies above that size to an object store and returns a pointer; parse() read it as the page, so the caller got 70 bytes with HTTP 200, the target's content-type and a charge. It is now PROVIDER_BODY_OFFLOADED, which fails over to a provider that returns the body inline.
  • Shared 0.11.0Minor
    • #237 afd2423 One executor for every request. The gateway, proxlane scrape, pnpm record, the k6 harness and the live canary now all call createFetchTransport() from @proxlane/shared/transport, instead of each hand-rolling its own fetch. The canary's copy dropped wire.body, which only Bright Data sends, so it reported a working key as AUTH_FAILED on every run — repo:check assertion 50 and a new contract test now hold the line. proxlane scrape and pnpm record gain the capped streaming read and the timeout/abort discrimination they were missing.
    • #238 9860659 wait_for=<css selector> tells the renderer what to wait for before it snapshots the page. Rendering means the renderer ran, not that the content arrived — on a late-hydrating page the same request returns the full listing on one attempt and an empty shell on the next, and until now nothing in the request could name the finish line. It implies render=true, and it narrows the chain to providers that can express it: ScrapingBee's wait_for and Scrapfly's wait_for_selector were verified live, ScraperAPI's is its published name and the canary confirms it, and Bright Data declares it cannot — x-unblock-expect is accepted there and could not be shown to enforce a wait, so it is filtered out rather than charging for a page that did not wait.
  • Shared 0.10.0Minor
    • #235 b694712 A Scrapfly response over 5MB no longer arrives as a URL marked OK. Scrapfly offloads bodies above that size to an object store and returns a pointer; parse() read it as the page, so the caller got 70 bytes with HTTP 200, the target's content-type and a charge. It is now PROVIDER_BODY_OFFLOADED, which fails over to a provider that returns the body inline.
  • Gateway 0.11.0Minor
    • #234 7d7c7ce Responses now carry X-Ignored-Params naming any query parameter the gateway does not read. js_render is ScrapingBee's spelling of render and js is Scrapfly's; sending either returned HTTP 200 with an unrendered page at a fifth of the cost, and nothing said so.
  • Gateway 0.10.3Patch

    Dependency updates only.

  • CLI 0.4.6Patch

    Dependency updates only.

  • CLI 0.4.5Patch

    Dependency updates only.

Gateway0.13.0

The proxy itself. This is what you run.

  • 0.13.0Minor
    • #238 9860659 wait_for=<css selector> tells the renderer what to wait for before it snapshots the page. Rendering means the renderer ran, not that the content arrived — on a late-hydrating page the same request returns the full listing on one attempt and an empty shell on the next, and until now nothing in the request could name the finish line. It implies render=true, and it narrows the chain to providers that can express it: ScrapingBee's wait_for and Scrapfly's wait_for_selector were verified live, ScraperAPI's is its published name and the canary confirms it, and Bright Data declares it cannot — x-unblock-expect is accepted there and could not be shown to enforce a wait, so it is filtered out rather than charging for a page that did not wait.
    • #237 afd2423 One executor for every request. The gateway, proxlane scrape, pnpm record, the k6 harness and the live canary now all call createFetchTransport() from @proxlane/shared/transport, instead of each hand-rolling its own fetch. The canary's copy dropped wire.body, which only Bright Data sends, so it reported a working key as AUTH_FAILED on every run — repo:check assertion 50 and a new contract test now hold the line. proxlane scrape and pnpm record gain the capped streaming read and the timeout/abort discrimination they were missing.
  • 0.12.0Minor
    • #235 b694712 A Scrapfly response over 5MB no longer arrives as a URL marked OK. Scrapfly offloads bodies above that size to an object store and returns a pointer; parse() read it as the page, so the caller got 70 bytes with HTTP 200, the target's content-type and a charge. It is now PROVIDER_BODY_OFFLOADED, which fails over to a provider that returns the body inline.
  • 0.11.0Minor
    • #234 7d7c7ce Responses now carry X-Ignored-Params naming any query parameter the gateway does not read. js_render is ScrapingBee's spelling of render and js is Scrapfly's; sending either returned HTTP 200 with an unrendered page at a fifth of the cost, and nothing said so.
  • 0.10.3Patch

    Dependency updates only.

  • 0.10.2Patch

    Dependency updates only.

  • 0.10.1Patch

    Dependency updates only.

  • 0.10.0Minor
    • #194 3302c1c A block no longer cools every premium tier. cd:blk now carries the tier the request asked for, so a plain request that gets blocked stops suppressing the stealth retry — the escalation most likely to work, and the reason the tier exists. The implication still runs downward: a block at stealth cools residential and plain too, because they are strictly weaker against the same defence. /health/cooldowns reports the tier. Existing armed keys are in the old format and are ignored rather than migrated; they expire on their own within the cap.
    • #193 bd8942b A cooldown no longer truncates the chain. When every provider the walk tried has failed, one cooled provider is attempted before giving up — the same single per-domain slot the existing floor uses. Previously a chain whose best provider happened to be cooling could fail on all the others and return a provider fault having never tried the one that would have worked.
  • 0.9.0Minor
    • #188 7e77a6d A challenge page served with a target 5xx is now recognised as a block instead of being reported as TARGET_ERROR. The detector only ever examined OK responses, so Cloudflare's under-attack mode — which answers 503 — came back as "the site is broken" when the truth was that the site's defences refused every provider. It also armed no cooldown, so every later request re-bought the same failures. A claimed success that returned zero bytes is no longer billed as a successful scrape.
    • #182 7d44744 The request-body cap now stops the read instead of measuring it afterwards. c.req.text() resolved only once the whole body was in memory, so an oversized POST was refused having already paid the allocation the cap exists to prevent. A client that disconnects now aborts the in-flight provider request instead of leaving the chain walking every provider for the full deadline. It is reported as its own outcome, never as PROVIDER_TIMEOUT — blaming a healthy provider for a caller hanging up would cool it and feed the health statistic a failure nobody caused.
    • #170 84e83ce Capabilities can now describe a combination a provider refuses even though it offers each part alone. ScraperAPI's sessions and premium proxies are mutually exclusive by their own documentation, and the router used to send requests asking for both. Declared as data rather than a predicate, so proxlane providers prints it.
    • #179 8c05ff9 Scrapfly's stealth tier is priced at the residential figures, which is what it costs: translate() sends the residential proxy pool for every tier above none, so a stealth request is a residential request. It was published at the datacenter base — 1x against a real 25x on the comparison page. The router now reads the cost matrix as a capability claim. A null cell means the provider does not sell that combination, and ScrapingBee's stealth-without-rendering is one — it was being routed there and paid for.
    • #171 b0bf5b4 Every attempt now records the provider's reported cost, our own table's prediction for the same request shape, and which of the two the figure came from. Responses carry X-Cost-Source: reported when the provider told us, estimated when we worked it out. This is what makes a wrong cost table findable from live traffic instead of by re-reading a vendor's pricing page.
    • #181 bb6348d A provider's own Retry-After now reaches the caller. ParsedResult.retryAfterMs had been in the contract since it landed and the chain already armed cooldowns from it, but no adapter ever set it — so a provider that capped us and said exactly how long to wait had that answer discarded, the cooldown drew a 30s jittered guess, and the caller got a bare 429. Two chain fixes: an answered request whose next candidate lost its probe claim kept only the outcome name, dropping the provider, the body and the detect rule. And a throwing health store could make the chain re-attempt — and re-pay for — a provider it had already tried.
  • 0.8.0Minor
    • #142 d8f0661 The global deadline defaults to 120s, which operations.md decided some time ago and the gateway never picked up. At 90s a three-hop chain gave the terminal provider 38s of its 70s cap, because the budget reserves time for every hop still to come. The hop that exists to rescue a failing request was the one being cut short. Callers still ask for less via timeout and never for more. The operator's deadline is the ceiling, because it bounds how long one request holds an in-flight slot.
  • 0.7.1Patch
    • #137 8acb6fb X-Chain is omitted rather than sent empty when no provider was tried. A request refused before the chain starts has no attempts, so 0.7.0 emitted a bare X-Chain:X-Provider-Used already follows "omitted, never empty" for the same reason. The homepage transcript matches the gateway again: the boot banner listed the providers in the wrong order and carried no version, and the response was missing x-chain and x-cost-unit. The order now derives from the capability table on the same page. The social card is redrawn from an SVG source, having spent six days showing a retired wordmark and claiming three providers.
  • 0.7.0Minor
    • #136 89f92ba A block cooldown that keeps failing now backs off to 6 hours instead of re-arming at a flat 15 minutes forever. A (provider, domain) that had refused a hundred times running cost 96 paid probes a day, 288 for a domain three providers block, all of it re-buying evidence already held. Account cooldowns are unchanged: a rate limit resets on its own and is private to one org. Because a fully-blocked domain would otherwise go dark for the whole backoff, an all-cooling domain now gets one forced attempt, rate-limited per domain and reported as X-Provider-Health: cooling-forced.
    • #134 e103c8f X-Chain names every attempt as provider:outcome, in order, and the request log carries it too. X-Provider-Used names the winner, so a request that failed over and then succeeded came back as a clean 200 that hid the provider which had just cost 22 seconds. Found on the live gateway: four requests timed out at one provider, all four failed over and returned 200, and identifying the culprit needed /health/cooldowns, which expires.
  • 0.6.0Minor
    • #125 fc0b684 Returning a body byte for byte is now a declared capability, and binary=true a request parameter. An image request used to return 200 with a corrupted body; it now routes only to providers that can carry bytes, or answers NO_PROVIDER_AVAILABLE. Three of the four launch providers carry binary. ScraperAPI does not — it decodes bodies as UTF-8, and its own API says so: binary_target=true answers 400, "The file type you are trying to scrape is not supported."
    • #123 ae17465 A cost number now carries its unit, and units are never summed together. Three launch providers sell credits and Bright Data bills cents, so X-Cost-Estimate was adding one provider credit to fifteen hundredths of a cent and reporting the result as a quantity. CostTable.unit is required, the gateway emits X-Cost-Unit beside the figure, and a chain that spent in two units reports mixed rather than an invented total.
  • 0.5.0Minor
    • #120 b6e7891 One NDJSON line per /v1 request, to stdout. The gateway logged nothing at all, so the moment it was reachable by anyone there was no way to answer who probed it, which domains were scraped, which provider served them, or what the outcome mix looked like. Records the target's host rather than the URL, because query strings carry credentials. PROXLANE_LOG=off to silence it; `proxlane
  • 0.4.0Minor
    • #113 7b4a27a GET /health reports the running version, so a deploy can be verified. Publishing an image is not deploying it — an orchestrator keeps serving the digest it started with — and there was no way to ask what was live.
    • #107 e0d94b2 A timeout query parameter sets the deadline for one request, capped at the server's own. integrations.md has promised it since the budget arithmetic was written and nothing read it. Every error now carries X-Outcome-Class, X-Attempts: 0 and X-Cost-Estimate: 0.000000. Four paths shipped without them, including a missing url. A forced provider that does not exist now says so and names the ones that do, rather than "no providers configured".
    • #105 1e3ea1f Retry the last provider in the chain once before giving up, on PROVIDER_ERROR and PROVIDER_TIMEOUT only. Set PROXLANE_TERMINAL_RETRIES to change it, 0 to switch it off. Everywhere else in the chain, failover is still the retry.
    • #101 401548f Bump every GitHub Action to a major that targets Node 24. GitHub was already force-running the Node 20 ones and warning on each release. Includes changesets/action v2, whose breaking change moves the custom token from an environment variable to a github-token input — left alone, the release PR would have silently reverted to the default token and arrived with no checks.
    • #102 79128ab Pin changesets/action back to v1. Its v2 requires Changesets CLI v3 and refuses to run against the v2 CLI this repo pins, and it renames every input. Moving it is a release-path migration behind a CLI major, not a version bump. v1 already targets Node 24, so it was never part of the deprecation.
    • #111 258e5fc proxlane outcomes now says what to do about an outcome, not only what it means: an action, a sentence of why, and a link to the class's docs section. The policy fields describe what the gateway does internally — failover: true on a blocked outcome means every provider was already tried — which is the opposite of what a caller reading it as "retryable" would conclude. Error responses and the CLI both link to proxlane.dev/docs/outcomes, which is live. They pointed at GitHub because docs.proxlane.dev has no DNS record; it was the subdomain that never existed, not the docs.
    • #114 f40b2c4 The release now tags the gateway image with the version it actually released. It read the working tree, which changeset version had already bumped, so every push to main published an image tagged with the pending version — 0.3.3 and 0.4.0 exist on ghcr while main has been 0.3.2 throughout — and re-pointed that tag at a new digest each time.
  • 0.3.2Patch
    • Fix the image publish. The per-architecture digest was written to a filename containing a colon, which upload-artifact rejects, so both builds succeeded and then failed at the upload step. Native arm64 itself works: it produced a digest in under a minute, against the hour the emulated build ran without finishing.
    • Publish the image on a gateway release. The image job was gated on npm having published something, but the gateway is private: true and never publishes, so every gateway-only release skipped it and ghcr fell two minor versions behind. The image is also now tagged with the gateway's own version rather than the CLI's.
  • 0.3.1Patch
    • Publish the container image for arm64 on a native arm64 runner instead of emulating it. The previous multi-arch build ran under QEMU and did not finish, which left the published image two minor versions behind npm. Self-hosters on Pi and Ampere are the reason arm64 ships at all, so the image being stale mattered most to exactly them.
  • 0.3.0Minor
    • Enforce the in-flight ceiling. Past PROXLANE_MAX_INFLIGHT concurrent /v1 requests the gateway answers 429 GATEWAY_BUSY with Retry-After and sheds, rather than queueing — a queued scrape burns its own deadline waiting and the queue is memory the ceiling bounds. /health is never shed. The variable was documented since the scaffold and read by nothing. GATEWAY_BUSY is a new outcome, class gateway. Deliberately not RATE_LIMITED, which is class provider, writes an account cooldown and fails over — all three wrong when the gateway itself is full. OutcomeClass does not grow, so a caller branching on the class is unaffected.
    • Check at boot that the gateway fits in the memory it has been given. It reads the container's limit from cgroup v2 then v1, and refuses to start when maxInflight * bodyCap * 2.5 exceeds it, printing both numbers and the ceiling that would fit. It never falls back to os.totalmem(), which reports the host's memory inside a limited container. When no limit is readable, which is normal off a container, it prints the arithmetic and starts, so pnpm dev still works. PROXLANE_MEMORY_LIMIT_MB declares a limit where there is none and overrides one where there is. proxlane doctor reports the same budget from the same code. .env.example and docs/self-hosting.md described this check for months before it existed; both now describe what it does.
    • Emit Server-Timing: gw;dur=…, up;dur=…, total;dur=… on every /v1 response. gw is gateway-internal time — the number operations.md section 1 gates p95 on, and the one a user needs when asking whether the gateway or the provider was slow. Split by subtraction, so a segment nobody instrumented lands in gw where it is visible rather than going unmeasured. Each attempt now records upstreamMs, which is wall time inside the provider call and unlike latencyMs is set even when the hop times out.
    • Fix pnpm dev for the gateway. It ran node --watch src/index.ts, which could never work: application source imports siblings as ./app.js, and Node's type stripping does not rewrite that to .ts, so the process died on its first import. It builds and runs the output now. repo:check assertion 27 fails on any script that runs bare node against src/** TypeScript.
    • Build the load harness operations.md section 9 asks for: a local mock provider that returns slow responses, 429s, huge bodies and challenge pages on demand, the real gateway wired to it over a real socket, and a k6 soak that gates on p95 of Server-Timing: gw;dur=, RSS slope from minute 10, and the concurrency ceiling actually shedding. pnpm k6:soak is implemented; 22 of 25 commands are now real. The gateway gains ./app and ./transport export paths so the harness can build the real app from the shipped artifact rather than importing source.
    • Cover the two things nothing tested. The real HTTP transport now has an e2e against a deliberately hostile server, including a regression test for the measured bug where a body trickling in after the headers ran six times its budget. And build-docker now boots the image it builds, asserting the gateway refuses to start without a key, serves /health, answers /v1 with the taxonomy, and prints its banner. selfhost:smoke runs weekly.
  • 0.2.1Patch

    Dependency updates only.

  • 0.2.0Minor
    • Accept the gateway key as Authorization: Bearer <key> alongside ?api_key=, and route POST on /v1 with the request body capped by maxBodyBytes. The query parameter stays: it is the drop-in migration surface.
    • Breaking: every non-2xx now returns one envelope — {requestId, error: {code, class, message, docs}, attempts?} — instead of {error, message} for auth and validation and {outcome, class, attempts} for a failed scrape. Response headers are unchanged.
    • Add OutcomeClass, a closed six-member classification alongside the open Outcome union, and send it as X-Outcome-Class and in the JSON error body. Branch on the class: it does not grow, so adding an outcome no longer breaks callers.
    • Every response now carries X-Request-Id and a matching requestId in the JSON body, including 401s and validation errors. A caller's own X-Request-Id is echoed when it is safe to. Adds uuidv7, monotonic and clock-regression-safe, which will also be requests.id.
  • 0.1.0Minor
    • Cooldowns are implemented: two namespaces (cd:blk by domain, cd:acct by org), exponential backoff with full jitter and a 15-minute cap, and half-open expiry where the single post-expiry probe must be claimed atomically. The gateway skips cooled providers before dividing the deadline, and returns Retry-After when every capable provider is cooling.
    • The provider order is now an explicit, operator-overridable list (PROXLANE_PROVIDER_ORDER) rather than Object.keys(REGISTRY).sort() — which was alphabetical, chosen by nobody, and decided which provider got paid first on every request.
    • Health tracking now defaults to off: its calibration assumes independent failures, and a two-regime provider with the same mean failure rate spends over 90% of its time demoted in simulation. Set PROXLANE_HEALTH=on to enable it. Every published figure has been regenerated from the simulation, and repo:check assertion 21 now fails when the prose drifts from the measurement.
    • The gateway now routes on provider health: the chain is re-ranked by state, demoted providers are dropped, the least-bad one is forced rather than refusing, and the result is reported on X-Provider-Health and GET /health/providers.
    • A cooldown now waits as long as the target's own Retry-After asks, when the provider exposes it — measured: ScrapingBee forwards it as spb-retry-after, Scrapfly exposes it in the envelope, ScraperAPI strips it. Clamped to the 15-minute cap, with the jittered backoff as the fallback.
    • The background prober lifts demoted providers back into rotation: paced by the documented backoff, leased when several replicas share a Valkey, and pointed at the same stable target the canary uses. GET /health/cooldowns shows the cooldowns actually held, split by namespace, so an operator can see why a provider is being skipped.
    • A target 429 is now TARGET_RATE_LIMITED rather than TARGET_ERROR: it returns 429 to the caller, fails over to a different egress, and arms the shared domain cooldown. Previously it armed nothing, so the next request retried immediately — which is what escalates a rate limit into a ban.
    • Six routing defects found by an independent review panel: a claimed cooldown probe was never released unless the outcome armed or was exactly OK, a concurrent success deleted the account cooldown another request had just armed, the demoted floor was computed before cooldowns and so could be routed past, the terminal hop's larger timeout went to the least healthy provider, an exhausted chain reported the previous provider's failure, and Retry-After could be 0.
    • Corrects every published health figure that disagreed between files, removes three false citations from health.ts (including one reintroducing the exact defect that file documents), pins the five constants a mutation sweep found unpinned, and strengthens repo:check assertion 21 against the bypasses a verification panel demonstrated.
    • The probe settlement fix was incomplete: it marked a claim settled whenever any cooldown key was written, so eight of sixteen outcomes stranded a probe when the claimed and written keys were in different namespaces — including a successful probe on an account cooldown, which took a working provider out of service. A lost probe claim now also re-ranks the chain, so the demoted floor can still see a usable fallback.
    • Security follow-ups: hostnames in cooldown keys are bounded to 253 characters, a trailing FQDN dot no longer creates a second key for the same site, the gateway key comparison uses timingSafeEqual rather than a length-short-circuiting loop, and PROXLANE_ORG_ID actually reaches the chain so cd:acct is namespaced per deployment.
    • Fix the default self-host deployment, which built a Redis client for an empty PROXLANE_VALKEY_URL and returned 500 from /health/providers. Compose now passes every variable the gateway reads, empty is treated as unset, and /health/providers fails open like the routing path.
    • A gateway shutting down while Valkey is unreachable no longer crashes: redis.quit() rejects on a broken socket, and each shutdown step is now independent.
    • Valkey store robustness: the observation buffer is bounded and no longer amplifies load against a struggling store, in-flight batches stay visible to reads, a throwing error reporter can no longer kill the process, the claim script fails open on an unreadable record like the JS side does, and the gateway drains on SIGTERM instead of dropping buffered work.

CLI0.4.8

The `proxlane` command.

  • 0.4.8Patch
    • #237 afd2423 One executor for every request. The gateway, proxlane scrape, pnpm record, the k6 harness and the live canary now all call createFetchTransport() from @proxlane/shared/transport, instead of each hand-rolling its own fetch. The canary's copy dropped wire.body, which only Bright Data sends, so it reported a working key as AUTH_FAILED on every run — repo:check assertion 50 and a new contract test now hold the line. proxlane scrape and pnpm record gain the capped streaming read and the timeout/abort discrimination they were missing.
  • 0.4.7Patch

    Dependency updates only.

  • 0.4.6Patch

    Dependency updates only.

  • 0.4.5Patch

    Dependency updates only.

  • 0.4.4Patch
    • #213 a43b34e Provider keys are trimmed when read from the environment. They were not, and the failure that produced is silent and expensive. Headers normalises TRAILING whitespace away, so a key ending in a space or a newline works — which is most accidents, and it teaches you whitespace is harmless here. A LEADING space survives: Authorization: Bearer <key> goes out with two spaces, the provider answers 401, and that reaches the caller as AUTH_FAILED — a taxonomy member meaning "your credential was refused", pointing at the key's value when the value is correct and its framing is not. Only one of the four adapters could show it. The other three put the key in a query string, where URLSearchParams percent-encodes the space rather than sending it, so exactly one provider looked broken and the rest looked fine — which is the most misleading shape this could have had. proxlane doctor now says when a key had surrounding whitespace, because the gateway trimming it does not stop the same value confusing someone comparing their .env against what they pasted.
  • 0.4.3Patch
    • #201 b6995dd The Quickstart now starts a gateway before telling you to call one. "Get started" is the site's primary call to action and it lands here; the page opened by asking the reader to curl https://your-gateway/…, a placeholder that resolves to nothing, and only explained how to have a gateway eighty lines further down. It never mentioned localhost at all, so the address you would actually call appeared nowhere on the page. Order is now: start it, call it, migrate, move the key out of the query string. proxlane doctor fails when no provider key is set. Each per-key check stays green when absent, because BYOK means you bring the providers you use and flagging the three you do not have trains people to skip the output. Applied to every key, that produced "13 checks, all good" for a gateway that cannot route one request. Zero keys is a different condition from one missing key, and now it has its own check with a fix line.
  • 0.4.2Patch

    Dependency updates only.

  • 0.4.1Patch
    • #170 84e83ce Capabilities can now describe a combination a provider refuses even though it offers each part alone. ScraperAPI's sessions and premium proxies are mutually exclusive by their own documentation, and the router used to send requests asking for both. Declared as data rather than a predicate, so proxlane providers prints it.
    • #186 4468690 Every published package now has a description, keywords, a homepage and a README. Four of the five rendered "ERROR: No README data found!" on npmjs.com — including @proxlane/adapters, the Apache-2.0 package this project most wants strangers to contribute to.
  • 0.4.0Minor
    • #152 dc62320 CostTable stops being base × multipliers and becomes a matrix: one cost per proxy tier and render state, exhaustive, null where the provider does not sell that combination. No provider prices multiplicatively, so every adapter had been writing the closest product it could and leaving a note about it. Scrapfly is additive and was estimated 4.17× too high on residential plus rendering; ScrapingBee's premium tier is 25 credits with rendering, not 10. `proxlane
  • 0.3.3Patch

    Dependency updates only.

  • 0.3.2Patch

    Dependency updates only.

  • 0.3.1Patch
    • #120 b6e7891 One NDJSON line per /v1 request, to stdout. The gateway logged nothing at all, so the moment it was reachable by anyone there was no way to answer who probed it, which domains were scraped, which provider served them, or what the outcome mix looked like. Records the target's host rather than the URL, because query strings carry credentials. PROXLANE_LOG=off to silence it; `proxlane
  • 0.3.0Minor
    • #111 258e5fc proxlane outcomes now says what to do about an outcome, not only what it means: an action, a sentence of why, and a link to the class's docs section. The policy fields describe what the gateway does internally — failover: true on a blocked outcome means every provider was already tried — which is the opposite of what a caller reading it as "retryable" would conclude. Error responses and the CLI both link to proxlane.dev/docs/outcomes, which is live. They pointed at GitHub because docs.proxlane.dev has no DNS record; it was the subdomain that never existed, not the docs.
    • #105 1e3ea1f Retry the last provider in the chain once before giving up, on PROVIDER_ERROR and PROVIDER_TIMEOUT only. Set PROXLANE_TERMINAL_RETRIES to change it, 0 to switch it off. Everywhere else in the chain, failover is still the retry.
  • 0.2.0Minor
    • Enforce the in-flight ceiling. Past PROXLANE_MAX_INFLIGHT concurrent /v1 requests the gateway answers 429 GATEWAY_BUSY with Retry-After and sheds, rather than queueing — a queued scrape burns its own deadline waiting and the queue is memory the ceiling bounds. /health is never shed. The variable was documented since the scaffold and read by nothing. GATEWAY_BUSY is a new outcome, class gateway. Deliberately not RATE_LIMITED, which is class provider, writes an account cooldown and fails over — all three wrong when the gateway itself is full. OutcomeClass does not grow, so a caller branching on the class is unaffected.
    • Check at boot that the gateway fits in the memory it has been given. It reads the container's limit from cgroup v2 then v1, and refuses to start when maxInflight * bodyCap * 2.5 exceeds it, printing both numbers and the ceiling that would fit. It never falls back to os.totalmem(), which reports the host's memory inside a limited container. When no limit is readable, which is normal off a container, it prints the arithmetic and starts, so pnpm dev still works. PROXLANE_MEMORY_LIMIT_MB declares a limit where there is none and overrides one where there is. proxlane doctor reports the same budget from the same code. .env.example and docs/self-hosting.md described this check for months before it existed; both now describe what it does.
    • A mark, honest SEO, and no em dashes in shipped copy. The wordmark sets the interchange station as the o in proxlane, and the standalone mark is three provider lines with a station on the middle one, which is the version that survives 16px in a browser tab. No second typeface: design.md chooses one sans and says the diagram is the display element. Adds canonical, Open Graph and Twitter tags, our own robots.txt, and a sitemap, and points the Worker at proxlane.dev. Without a robots.txt Cloudflare served its own, which was 25 lines of AI content-signal terms nobody here wrote. Removes em dashes from user-facing copy. Five of the six were real proxlane doctor output and the exit-code table, so they are fixed at source rather than edited on the page, which would have made a transcript into a mock-up.
  • 0.1.2Patch

    Dependency updates only.

  • 0.1.1Patch

    Dependency updates only.

  • 0.1.0Minor
    • proxlane doctor now diagnoses routing state: where it lives, whether an empty PROXLANE_VALKEY_URL is being read as unset, whether the replica count matches the state backing, which of health and cooldowns are on, and whether a configured Valkey is actually reachable.
    • proxlane --version now reports the package's real version. It was hardcoded to 0.0.0 and printed that from a package published as 0.0.1.
    • A target 429 is now TARGET_RATE_LIMITED rather than TARGET_ERROR: it returns 429 to the caller, fails over to a different egress, and arms the shared domain cooldown. Previously it armed nothing, so the next request retried immediately — which is what escalates a rate limit into a ban.

Adapters0.9.0

Provider adapters and the capability registry.

  • 0.9.0Minor
    • #238 9860659 wait_for=<css selector> tells the renderer what to wait for before it snapshots the page. Rendering means the renderer ran, not that the content arrived — on a late-hydrating page the same request returns the full listing on one attempt and an empty shell on the next, and until now nothing in the request could name the finish line. It implies render=true, and it narrows the chain to providers that can express it: ScrapingBee's wait_for and Scrapfly's wait_for_selector were verified live, ScraperAPI's is its published name and the canary confirms it, and Bright Data declares it cannot — x-unblock-expect is accepted there and could not be shown to enforce a wait, so it is filtered out rather than charging for a page that did not wait.
    • #237 afd2423 One executor for every request. The gateway, proxlane scrape, pnpm record, the k6 harness and the live canary now all call createFetchTransport() from @proxlane/shared/transport, instead of each hand-rolling its own fetch. The canary's copy dropped wire.body, which only Bright Data sends, so it reported a working key as AUTH_FAILED on every run — repo:check assertion 50 and a new contract test now hold the line. proxlane scrape and pnpm record gain the capped streaming read and the timeout/abort discrimination they were missing.
  • 0.8.0Minor
    • #235 b694712 A Scrapfly response over 5MB no longer arrives as a URL marked OK. Scrapfly offloads bodies above that size to an object store and returns a pointer; parse() read it as the page, so the caller got 70 bytes with HTTP 200, the target's content-type and a charge. It is now PROVIDER_BODY_OFFLOADED, which fails over to a provider that returns the body inline.
  • 0.7.5Patch
    • #231 1f955e8 A fixture shape change now opens an issue instead of reddening a scheduled run nobody watches. The canary has reported that way since it was written; record:diff never did, and on 2026-08-26 three of four adapters failed there and it went unnoticed for two days. One issue per adapter, with the shape diff in the body, and a comment rather than a duplicate on the next run. Bright Data gains the POST fixture it never had — the only adapter of four declaring post: true with no recorded evidence for it. Recorded against the live API; the target echoes the body back, so the fixture proves the body arrived rather than only that the request succeeded. Scrapfly's fixtures are re-recorded. Their request shape genuinely changed when os started being pinned, and the fixtures had not caught up. ScrapingBee's are re-recorded too, though its drift was the test target echoing a different set of request headers rather than anything ScrapingBee did.
  • 0.7.4Patch
    • #225 a0d6487 The live canary's JavaScript-render check now scrapes a page this project serves, not a third-party scraping-demo site. That target failed twice in one morning on two different providers while answering in half a second from a laptop, and the launch gate counts three consecutive scheduled greens with no way for a manual re-dispatch to repair a red one. The new marker is absent from the served HTML — the page assembles it from two halves at runtime — so a provider returning the unrendered document cannot satisfy the assertion by accident. The old marker was plain text in the source that the page also rendered, so it could. Verified live against all three providers before landing: OK with the marker present on each, at the exact cost the table predicts.
  • 0.7.3Patch
    • #217 a9c624e The live canary retries once when the TARGET failed, and says so loudly. operations.md section 9 counts three consecutive scheduled greens, and a manual re-dispatch does not repair a red one. So a third-party page having a bad minute on a Monday morning resets a three-week launch clock and nothing done afterwards fixes it. That happened twice in one morning on 2026-08-25, on two different providers, against a demo site answering in half a second from a laptop. TARGET_ERROR is the one outcome that says the failure was not the provider's, and this canary exists to ask whether the provider still behaves. Only that outcome is retried; everything the provider is blamed for is reported on the first attempt. The three tests expect OK, TARGET_NOT_FOUND and OK, so a retry cannot paper over an assertion.
  • 0.7.2Patch
    • #199 71a0421 Scrapfly now pins os instead of letting Scrapfly choose. ScraperAPI and ScrapingBee were both pinned to desktop, so the same request fetched a pinned desktop page on two providers and a provider-chosen one on the third — visible to the caller only as X-Provider-Used changing, on a gateway whose claim is that failover is invisible. It was always a leak; it got worse when their changelog added android, iphone and ipad to the values os picks among, so the unpinned set grew phones without anything changing on our side. The per-adapter test named "sets every parameter explicitly" passed throughout, because the list it iterates is hand-typed and therefore only covers the parameters somebody remembered. The check is now cross-adapter and asserts its own completeness against the registry, so a fifth adapter fails until somebody decides whether its API has a device parameter.
  • 0.7.1Patch

    Dependency updates only.

  • 0.7.0Minor
    • #175 7d5c835 Bright Data now returns the target's body for every outcome the taxonomy says carries one. A target 404 came back empty from Bright Data and full from the other three, so what a caller received depended on which provider won the chain. An error code arriving without a message header also fell through to OK — reject_block with no message was returned as a successful scrape of a challenge page. The conformance check that exists to catch exactly this only enforced the OK case.
    • #170 84e83ce Capabilities can now describe a combination a provider refuses even though it offers each part alone. ScraperAPI's sessions and premium proxies are mutually exclusive by their own documentation, and the router used to send requests asking for both. Declared as data rather than a predicate, so proxlane providers prints it.
    • #168 1248872 ScraperAPI, ScrapingBee and Scrapfly now forward a POST body to the target. All four providers document POST support and only one adapter implemented it, so a POST request reached exactly one provider and could not fail over at all. Recorded post fixtures for all three, echoed back by the target to prove the body arrived.
    • #181 bb6348d A provider's own Retry-After now reaches the caller. ParsedResult.retryAfterMs had been in the contract since it landed and the chain already armed cooldowns from it, but no adapter ever set it — so a provider that capped us and said exactly how long to wait had that answer discarded, the cooldown drew a 30s jittered guess, and the caller got a bare 429. Two chain fixes: an answered request whose next candidate lost its probe claim kept only the outcome name, dropping the provider, the body and the detect rule. And a throwing health store could make the chain re-attempt — and re-pay for — a provider it had already tried.
    • #179 8c05ff9 Scrapfly's stealth tier is priced at the residential figures, which is what it costs: translate() sends the residential proxy pool for every tier above none, so a stealth request is a residential request. It was published at the datacenter base — 1x against a real 25x on the comparison page. The router now reads the cost matrix as a capability claim. A null cell means the provider does not sell that combination, and ScrapingBee's stealth-without-rendering is one — it was being routed there and paid for.
    • #186 4468690 Every published package now has a description, keywords, a homepage and a README. Four of the five rendered "ERROR: No README data found!" on npmjs.com — including @proxlane/adapters, the Apache-2.0 package this project most wants strangers to contribute to.
  • 0.6.0Minor
    • #152 dc62320 CostTable stops being base × multipliers and becomes a matrix: one cost per proxy tier and render state, exhaustive, null where the provider does not sell that combination. No provider prices multiplicatively, so every adapter had been writing the closest product it could and leaving a note about it. Scrapfly is additive and was estimated 4.17× too high on residential plus rendering; ScrapingBee's premium tier is 25 credits with rendering, not 10. `proxlane
    • #153 2a9142d Cost tables must now carry a source URL and the date somebody last read it, no two providers may cite the same page, the scaffold's placeholder zeroes cannot ship, and a table nobody has re-read in a year fails the build. No test can verify a price without fetching a vendor's marketing site in CI, so this enforces the next best thing.
    • #151 ea6e393 ScrapingBee's country list goes from 7 codes to the 42 it actually sells on classic proxies. The seven were an example table in their docs, and the router filters the failover chain on this set, so the provider was silently ineligible for thirty-five countries. ru leaves: it is premium-only, and classic silently serves us instead of erroring. Adds CAPABILITIES, a static export of every adapter's capabilities that does not load the adapters, and cross-provider assertions over it.
    • #155 40897dc post and sessions now say what they mean: a claim about the adapter, not about the provider. Both were undocumented booleans, and a research pass against the vendors' docs reported all four as live bugs because every provider sells POST and sessions — while every value was correct, since the field describes what translate() actually wires. Tests now hold each claim to the code that implements it.
    • #156 6b89f31 A scraping API comparison at /scraping-api-comparison: pick a request shape and see what each provider charges on top of its own base rate, from their published tables. Compares multipliers, which are dimensionless, and never compares base rates across billing units. Fixes Bright Data's base cost, which was a hundred times too low — the only provider whose cost we estimate rather than read off the response. @proxlane/shared gains an ./error-body subpath so @proxlane/adapters no longer drags node:crypto into anything that imports it.
  • 0.5.1Patch

    Dependency updates only.

  • 0.5.0Minor
    • #125 fc0b684 Returning a body byte for byte is now a declared capability, and binary=true a request parameter. An image request used to return 200 with a corrupted body; it now routes only to providers that can carry bytes, or answers NO_PROVIDER_AVAILABLE. Three of the four launch providers carry binary. ScraperAPI does not — it decodes bodies as UTF-8, and its own API says so: binary_target=true answers 400, "The file type you are trying to scrape is not supported."
    • #127 ae360a9 The Bright Data adapter asks for format: 'raw' and can now return bytes. It used json because "raw returns an API 200 whatever the target did" — true of the API's status line, but x-brd-status-code carries the target's status on every raw response, so raw is a strict superset: same outcomes, plus the original bytes and the target's real charset. Three of four adapters now carry binary; ScraperAPI cannot, and says so.
    • #123 ae17465 A cost number now carries its unit, and units are never summed together. Three launch providers sell credits and Bright Data bills cents, so X-Cost-Estimate was adding one provider credit to fifteen hundredths of a cent and reporting the result as a quantity. CostTable.unit is required, the gateway emits X-Cost-Unit beside the figure, and a chain that spent in two units reports mixed rather than an invented total.
    • #126 0a63439 Scrapfly is declared binary-capable, which it always was — it reports result.format: 'binary' and base64-encodes the content, and the adapter already decoded that. The earlier false came from measuring the provider's wire response instead of the adapter's output. Conformance now asserts the binary flag against a recorded JPEG through parse, in both directions, and the fixture is required of every adapter.
    • #128 8e146cd The conformance binary check no longer passes a corrupt file whose magic bytes happen to be printable. WebP is "RIFF", PDF is "%PDF", ZIP and XLSX are "PK", GIF is "GIF8" — all survive a UTF-8 round trip intact while the rest of the file is destroyed, so a magic-only check went quiet on every one of them. It now also counts U+FFFD replacement characters, whose presence in a binary body is the corruption itself, whatever the format.
  • 0.4.0Minor
    • #104 e7dc4a4 Add a Bright Data Web Unlocker adapter, the fourth provider and the first for a service this project does not itself pay for. It reads the target's real status out of the JSON envelope rather than the raw body, and decodes Bright Data's own x-brd-error-code so a dead target is not blamed on the provider. Two pieces of shared tooling assumed a provider's parameters live in the URL, which was true of the first three by coincidence. The conformance suite and the replay transport now read the request body too, and the recorded-target matrix moved off a Cloudflare-fronted host that an unblocking provider correctly refuses to pass through.
    • #106 d3ff8c2 A host that does not resolve is now TARGET_ERROR from every provider. Scrapfly reported it as INVALID_REQUEST, which paged a human and stopped the chain; Bright Data as PROVIDER_ERROR, which cooled a healthy provider. Conformance now asserts the recorded outcome for the new dead-host fixture, which is what makes it stay fixed.
    • #108 4db45e1 Bright Data gets its own line colour. It shared slot 1 with ScraperAPI, because line was typed 1 | 2 | 3 and taking an existing slot was the only way to compile — so the two were drawn in the same colour and a failover between them was invisible. pnpm tokens:check now fails on a shared slot or a slot with no token behind it.
    • #111 258e5fc proxlane outcomes now says what to do about an outcome, not only what it means: an action, a sentence of why, and a link to the class's docs section. The policy fields describe what the gateway does internally — failover: true on a blocked outcome means every provider was already tried — which is the opposite of what a caller reading it as "retryable" would conclude. Error responses and the CLI both link to proxlane.dev/docs/outcomes, which is live. They pointed at GitHub because docs.proxlane.dev has no DNS record; it was the subdomain that never existed, not the docs.
  • 0.3.1Patch

    Dependency updates only.

  • 0.3.0Minor
    • The route diagram: one request's journey drawn from attempt data, with provider line colours assigned in the adapter registry so every surface picks them up from one place.
  • 0.2.0Minor
    • Breaking: every non-2xx now returns one envelope — {requestId, error: {code, class, message, docs}, attempts?} — instead of {error, message} for auth and validation and {outcome, class, attempts} for a failed scrape. Response headers are unchanged.
    • Add OutcomeClass, a closed six-member classification alongside the open Outcome union, and send it as X-Outcome-Class and in the JSON error body. Branch on the class: it does not grow, so adding an outcome no longer breaks callers.
  • 0.1.0Minor
    • HealthState now carries enteredAt, so the state a store persists is exactly the state the machine needs; observe and observeProbe take now and nothing else. INITIAL is replaced by initial(now). Conformance requires the fixture categories that prove an adapter can tell a target failure from its own.
    • A cooldown now waits as long as the target's own Retry-After asks, when the provider exposes it — measured: ScrapingBee forwards it as spb-retry-after, Scrapfly exposes it in the envelope, ScraperAPI strips it. Clamped to the 15-minute cap, with the jittered backoff as the fallback.
    • A target 429 is now TARGET_RATE_LIMITED rather than TARGET_ERROR: it returns 429 to the caller, fails over to a different egress, and arms the shared domain cooldown. Previously it armed nothing, so the next request retried immediately — which is what escalates a rate limit into a ban.
    • The outcome taxonomy, Outcome, FAILOVER and GatewayRequest move from @proxlane/adapters to @proxlane/shared, so the base layer no longer depends on a leaf. @proxlane/adapters re-exports all of it, so adapter authors import exactly what they imported before.
    • ScraperAPI now reads the target's status from sa-statuscode instead of matching body prose, and reports the real credit cost from sa-credit-cost — a rendered request previously billed at 1 credit instead of 10. A target returning 403 is now HARD_BLOCK rather than AUTH_FAILED, which had marked the caller's own key unhealthy.

Detection0.3.0

Block-page heuristics.

  • 0.3.0Minor
    • #188 7e77a6d A challenge page served with a target 5xx is now recognised as a block instead of being reported as TARGET_ERROR. The detector only ever examined OK responses, so Cloudflare's under-attack mode — which answers 503 — came back as "the site is broken" when the truth was that the site's defences refused every provider. It also armed no cooldown, so every later request re-bought the same failures. A claimed success that returned zero bytes is no longer billed as a successful scrape.
    • #186 4468690 Every published package now has a description, keywords, a homepage and a README. Four of the five rendered "ERROR: No README data found!" on npmjs.com — including @proxlane/adapters, the Apache-2.0 package this project most wants strangers to contribute to.
  • 0.2.0Minor
    • #162 d9525c0 Whether a detection rule has been confirmed by a real block page is now derived from stored captures rather than a hand-set boolean. pnpm corpus:verify runs every capture through the real detector and generates the table, recording each capture's SHA-256, so a claim points at an artefact. cloudflare-challenge is the first rule confirmed against the thing it describes.
    • #165 fd84d98 A Cloudflare block page is now reported as cloudflare-blocked rather than cloudflare-challenge. Real block pages carry both signatures, and rule order meant the block rule could never fire — X-Detect-Rule named the wrong reason on every Cloudflare block. Five of six detection rules are now confirmed against a real captured page.
    • #160 935ab4e pnpm capture-block turns a real HTTP response into a block-page corpus entry. plan.md §19 decides where it lands: a purpose-built scraping sandbox may enter this repository, anything else requires a private directory and is refused without one. Captures store a class of target, never a hostname, and are scrubbed of provider keys.
    • #166 30c894a imperva-incapsula no longer fires on ordinary pages. It matched _Incapsula_Resource, which is how any Incapsula-protected site loads Imperva's client script; it now keys on the structural difference — a block page frames that resource, an ordinary page scripts it — which also survives Imperva rotating the query parameter. capture-block names files by content digest, so two captures can no longer overwrite each other.
    • #149 b322019 A block page detector at /block-page-detector. Paste a response body and it runs the gateway's own detector in your browser, names the rule that fired, and reads the consequence out of the same policy table the router uses. Nothing is sent anywhere. @proxlane/detect now exports SCAN_BYTES, so a caller can say how much of a body a verdict was formed from.
    • #164 a342613 akamai-bot-manager now matches Akamai's real deny page, whose signature is HTML-entity-encoded so the previous literal could never fire on it. Four of six detection rules are now confirmed against a real captured block page, up from zero.
    • #167 2690ec7 The FAQ said the detector's rules had never seen a real block page. Five of six have now been confirmed against one, and five of six turned out to have a defect only a real page could show.
    • #163 9127601 datadome is confirmed against real DataDome block markup, taking the detector to two rules of six backed by a capture. Documents a measured false positive in imperva-incapsula: the token it matches appears on ordinary pages of Incapsula-protected sites, and only escapes firing because it fell outside the scan window on the page tested.
  • 0.1.0Minor
    • The block detector: detect(bytes, contentType, charset) returns the vendor rule that fired, or nothing. Six rules covering Cloudflare, DataDome, PerimeterX, Imperva and Akamai, each anchored to a vendor asset path rather than generic words like "captcha" — a false positive here fails over and spends a second provider's credits.
    • Documents a limitation with a real capture: a site that serves its own block page — no Cloudflare, DataDome or Imperva markup — returns 200 with nothing to fingerprint, and the detector calls it OK. A rule matching the words would flag any article about bot detection, so catching it needs a per-domain baseline rather than a string match.

Shared0.11.0

The outcome taxonomy and request types.

  • 0.11.0Minor
    • #237 afd2423 One executor for every request. The gateway, proxlane scrape, pnpm record, the k6 harness and the live canary now all call createFetchTransport() from @proxlane/shared/transport, instead of each hand-rolling its own fetch. The canary's copy dropped wire.body, which only Bright Data sends, so it reported a working key as AUTH_FAILED on every run — repo:check assertion 50 and a new contract test now hold the line. proxlane scrape and pnpm record gain the capped streaming read and the timeout/abort discrimination they were missing.
    • #238 9860659 wait_for=<css selector> tells the renderer what to wait for before it snapshots the page. Rendering means the renderer ran, not that the content arrived — on a late-hydrating page the same request returns the full listing on one attempt and an empty shell on the next, and until now nothing in the request could name the finish line. It implies render=true, and it narrows the chain to providers that can express it: ScrapingBee's wait_for and Scrapfly's wait_for_selector were verified live, ScraperAPI's is its published name and the canary confirms it, and Bright Data declares it cannot — x-unblock-expect is accepted there and could not be shown to enforce a wait, so it is filtered out rather than charging for a page that did not wait.
  • 0.10.0Minor
    • #235 b694712 A Scrapfly response over 5MB no longer arrives as a URL marked OK. Scrapfly offloads bodies above that size to an object store and returns a pointer; parse() read it as the page, so the caller got 70 bytes with HTTP 200, the target's content-type and a charge. It is now PROVIDER_BODY_OFFLOADED, which fails over to a provider that returns the body inline.
  • 0.9.0Minor
    • #213 a43b34e Provider keys are trimmed when read from the environment. They were not, and the failure that produced is silent and expensive. Headers normalises TRAILING whitespace away, so a key ending in a space or a newline works — which is most accidents, and it teaches you whitespace is harmless here. A LEADING space survives: Authorization: Bearer <key> goes out with two spaces, the provider answers 401, and that reaches the caller as AUTH_FAILED — a taxonomy member meaning "your credential was refused", pointing at the key's value when the value is correct and its framing is not. Only one of the four adapters could show it. The other three put the key in a query string, where URLSearchParams percent-encodes the space rather than sending it, so exactly one provider looked broken and the rest looked fine — which is the most misleading shape this could have had. proxlane doctor now says when a key had surrounding whitespace, because the gateway trimming it does not stop the same value confusing someone comparing their .env against what they pasted.
  • 0.8.0Minor
    • #194 3302c1c A block no longer cools every premium tier. cd:blk now carries the tier the request asked for, so a plain request that gets blocked stops suppressing the stealth retry — the escalation most likely to work, and the reason the tier exists. The implication still runs downward: a block at stealth cools residential and plain too, because they are strictly weaker against the same defence. /health/cooldowns reports the tier. Existing armed keys are in the old format and are ignored rather than migrated; they expire on their own within the cap.
  • 0.7.1Patch
    • #188 7e77a6d A challenge page served with a target 5xx is now recognised as a block instead of being reported as TARGET_ERROR. The detector only ever examined OK responses, so Cloudflare's under-attack mode — which answers 503 — came back as "the site is broken" when the truth was that the site's defences refused every provider. It also armed no cooldown, so every later request re-bought the same failures. A claimed success that returned zero bytes is no longer billed as a successful scrape.
    • #186 4468690 Every published package now has a description, keywords, a homepage and a README. Four of the five rendered "ERROR: No README data found!" on npmjs.com — including @proxlane/adapters, the Apache-2.0 package this project most wants strangers to contribute to.
    • #176 292e67b orderChain's docstring described chain order backwards. Ranking best-first puts the least healthy provider last, and the docs already recorded that; the source comment and the test named after it still claimed the reverse, and that test asserted the opposite of its own title while passing. Behaviour is unchanged — the comment was wrong, not the code.
    • #183 df7a4c0 The edge guard now refuses three IPv6 forms that carried an IPv4 address past it: RFC 2765's IPv4-translated ::ffff:0:0/96, RFC 8215's local-use NAT64 prefix 64:ff9b:1::/48, and the five RFC 6052 embedding positions other than the well-known one. http://[::ffff:0:169.254.169.254]/ reached the cloud metadata endpoint.
  • 0.7.0Minor
    • #156 6b89f31 A scraping API comparison at /scraping-api-comparison: pick a request shape and see what each provider charges on top of its own base rate, from their published tables. Compares multipliers, which are dimensionless, and never compares base rates across billing units. Fixes Bright Data's base cost, which was a hundred times too low — the only provider whose cost we estimate rather than read off the response. @proxlane/shared gains an ./error-body subpath so @proxlane/adapters no longer drags node:crypto into anything that imports it.
    • #142 d8f0661 The global deadline defaults to 120s, which operations.md decided some time ago and the gateway never picked up. At 90s a three-hop chain gave the terminal provider 38s of its 70s cap, because the budget reserves time for every hop still to come. The hop that exists to rescue a failing request was the one being cut short. Callers still ask for less via timeout and never for more. The operator's deadline is the ceiling, because it bounds how long one request holds an in-flight slot.
  • 0.6.0Minor
    • #136 89f92ba A block cooldown that keeps failing now backs off to 6 hours instead of re-arming at a flat 15 minutes forever. A (provider, domain) that had refused a hundred times running cost 96 paid probes a day, 288 for a domain three providers block, all of it re-buying evidence already held. Account cooldowns are unchanged: a rate limit resets on its own and is private to one org. Because a fully-blocked domain would otherwise go dark for the whole backoff, an all-cooling domain now gets one forced attempt, rate-limited per domain and reported as X-Provider-Health: cooling-forced.
  • 0.5.0Minor
    • #125 fc0b684 Returning a body byte for byte is now a declared capability, and binary=true a request parameter. An image request used to return 200 with a corrupted body; it now routes only to providers that can carry bytes, or answers NO_PROVIDER_AVAILABLE. Three of the four launch providers carry binary. ScraperAPI does not — it decodes bodies as UTF-8, and its own API says so: binary_target=true answers 400, "The file type you are trying to scrape is not supported."
  • 0.4.0Minor
    • #111 258e5fc proxlane outcomes now says what to do about an outcome, not only what it means: an action, a sentence of why, and a link to the class's docs section. The policy fields describe what the gateway does internally — failover: true on a blocked outcome means every provider was already tried — which is the opposite of what a caller reading it as "retryable" would conclude. Error responses and the CLI both link to proxlane.dev/docs/outcomes, which is live. They pointed at GitHub because docs.proxlane.dev has no DNS record; it was the subdomain that never existed, not the docs.
  • 0.3.0Minor
    • Add the docs site. /docs was linked from the header and the primary call to action and had no route at all, so both 404ed on the live site. Pages are markdown in apps/web/content/docs, versioned and reviewed like code, rendered to HTML at build time by a Vite plugin. Neither markdown-it nor Shiki reaches the Worker bundle. The outcome reference is generated from the taxonomy instead, because a hand-written copy of the thing callers write switch statements against is the one page that must not drift. pnpm docs:check is now real: it asserts every page has a file, a route and a nav entry, that every query parameter and response header the gateway implements is documented, that internal links resolve, and that llms.txt lists exactly the pages that exist. @proxlane/shared gains a ./outcome subpath export, so the taxonomy can be imported without pulling the edge guard and node:crypto into a browser bundle.
    • Enforce the in-flight ceiling. Past PROXLANE_MAX_INFLIGHT concurrent /v1 requests the gateway answers 429 GATEWAY_BUSY with Retry-After and sheds, rather than queueing — a queued scrape burns its own deadline waiting and the queue is memory the ceiling bounds. /health is never shed. The variable was documented since the scaffold and read by nothing. GATEWAY_BUSY is a new outcome, class gateway. Deliberately not RATE_LIMITED, which is class provider, writes an account cooldown and fails over — all three wrong when the gateway itself is full. OutcomeClass does not grow, so a caller branching on the class is unaffected.
    • Check at boot that the gateway fits in the memory it has been given. It reads the container's limit from cgroup v2 then v1, and refuses to start when maxInflight * bodyCap * 2.5 exceeds it, printing both numbers and the ceiling that would fit. It never falls back to os.totalmem(), which reports the host's memory inside a limited container. When no limit is readable, which is normal off a container, it prints the arithmetic and starts, so pnpm dev still works. PROXLANE_MEMORY_LIMIT_MB declares a limit where there is none and overrides one where there is. proxlane doctor reports the same budget from the same code. .env.example and docs/self-hosting.md described this check for months before it existed; both now describe what it does.
  • 0.2.0Minor
    • Breaking: every non-2xx now returns one envelope — {requestId, error: {code, class, message, docs}, attempts?} — instead of {error, message} for auth and validation and {outcome, class, attempts} for a failed scrape. Response headers are unchanged.
    • Add OutcomeClass, a closed six-member classification alongside the open Outcome union, and send it as X-Outcome-Class and in the JSON error body. Branch on the class: it does not grow, so adding an outcome no longer breaks callers.
    • Every response now carries X-Request-Id and a matching requestId in the JSON body, including 401s and validation errors. A caller's own X-Request-Id is echoed when it is safe to. Adds uuidv7, monotonic and clock-regression-safe, which will also be requests.id.
  • 0.1.0Minor
    • Cooldowns are implemented: two namespaces (cd:blk by domain, cd:acct by org), exponential backoff with full jitter and a 15-minute cap, and half-open expiry where the single post-expiry probe must be claimed atomically. The gateway skips cooled providers before dividing the deadline, and returns Retry-After when every capable provider is cooling.
    • Health tracking now defaults to off: its calibration assumes independent failures, and a two-regime provider with the same mean failure rate spends over 90% of its time demoted in simulation. Set PROXLANE_HEALTH=on to enable it. Every published figure has been regenerated from the simulation, and repo:check assertion 21 now fails when the prose drifts from the measurement.
    • HealthState now carries enteredAt, so the state a store persists is exactly the state the machine needs; observe and observeProbe take now and nothing else. INITIAL is replaced by initial(now). Conformance requires the fixture categories that prove an adapter can tell a target failure from its own.
    • Provider health as a CUSUM against each provider's own measured baseline: detects a slow success-rate slide that no single request's outcome can reveal, and exposes observe, orderChain and eligible for the router.
    • A cooldown now waits as long as the target's own Retry-After asks, when the provider exposes it — measured: ScrapingBee forwards it as spb-retry-after, Scrapfly exposes it in the envelope, ScraperAPI strips it. Clamped to the 15-minute cap, with the jittered backoff as the fallback.
    • A target 429 is now TARGET_RATE_LIMITED rather than TARGET_ERROR: it returns 429 to the caller, fails over to a different egress, and arms the shared domain cooldown. Previously it armed nothing, so the next request retried immediately — which is what escalates a rate limit into a ban.
    • The outcome taxonomy, Outcome, FAILOVER and GatewayRequest move from @proxlane/adapters to @proxlane/shared, so the base layer no longer depends on a leaf. @proxlane/adapters re-exports all of it, so adapter authors import exactly what they imported before.
    • Corrects every published health figure that disagreed between files, removes three false citations from health.ts (including one reintroducing the exact defect that file documents), pins the five constants a mutation sweep found unpinned, and strengthens repo:check assertion 21 against the bypasses a verification panel demonstrated.
    • ScraperAPI now reads the target's status from sa-statuscode instead of matching body prose, and reports the real credit cost from sa-credit-cost — a rendered request previously billed at 1 credit instead of 10. A target returning 403 is now HARD_BLOCK rather than AUTH_FAILED, which had marked the caller's own key unhealthy.
    • Security follow-ups: hostnames in cooldown keys are bounded to 253 characters, a trailing FQDN dot no longer creates a second key for the same site, the gateway key comparison uses timingSafeEqual rather than a length-short-circuiting loop, and PROXLANE_ORG_ID actually reaches the chain so cd:acct is namespaced per deployment.
    • Valkey-backed health and cooldown stores, so more than one gateway replica can share routing state. Set PROXLANE_VALKEY_URL to use them; unset, both stay in-process and the server still refuses to boot with PROXLANE_REPLICAS>1.