npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

outlive

v0.1.0

Published

Solari cloud browser sessions die after ~10 minutes. outlive checkpoints the session, notices the death, relaunches, and re-enters your task where it left off.

Readme

outlive

Solari cloud browser sessions end about ten minutes after they start. outlive checkpoints the session, notices the death, launches a new browser, and calls your task again where it left off.

CI npm License: MIT

Read this first, because it is the whole shape of the thing. A checkpoint is cookies, localStorage and the current URL. That is all that survives. The DOM is gone, anything the page held in JavaScript is gone, a half-typed form is gone, and your task function starts again from the top. A task that is not safe to run twice has to be written so that it is — checkpoint straight after the step that must not repeat, and check for that step's effect on re-entry. outlive puts this in the API rather than hiding it: your task is called again, with ctx.attempt and ctx.resumedFrom.

npm install outlive
import { Solari } from "@solarisdk/browser"
import { outlive } from "outlive"

const solari = new Solari({ apiKey: process.env.SOLARI_API_KEY })

const rows = await outlive(solari, async (page, ctx) => {
  if (!ctx.resumedFrom) {
    await page.goto("https://bank.example/login")
    await signIn(page)          // password + 2FA, the expensive part
    await ctx.checkpoint()      // never do that twice
  }
  return await scrapeForTwentyMinutes(page)   // outlives the session
}, { checkpointEveryMs: 30_000, maxRelaunches: 5 })
  • The session dying is not an error. It is expected, it is handled, and it costs about two seconds.
  • Liveness comes from the connection, never from the API. GET /sessions/:id reported active for a session that had been dead for five minutes. outlive uses the disconnected event, isConnected(), and the "Browser closed" message substring as a last resort.
  • One wide event per run, quiet by default: outcome, relaunches, checkpoints, lost work, total time.
  • One runtime dependency, @solarisdk/browser. playwright-core is a peer dependency and is used for types only — outlive imports no value from it. Note that the SDK actually returns patchright-core's Page: patchright is a Playwright fork with the same runtime surface, and the two declarations differ only in optional-property variance, so the object you get behaves exactly like the Page the types describe. handraise makes the same choice, so the series is consistent; the cost is that a consumer who has no Playwright installed gets playwright-core pulled in for its .d.ts files.

Part 2 of a series with handraise, which handles the other way a Solari session stops being useful: it needs a human.

Measured

Against the live API. Method and raw data in benchmarks/ and docs/measurements/.

  • 5/5 tasks of 12 minutes completed. Baseline 0/5. Ten task runs, fifteen browser sessions, arms concurrent and alternating, against one live Aurora Bank instance: sign in with a real TOTP, then poll the account page every 20 s until 36 polls have succeeded — 720 s of sleeps against a session that lives ~600 s. Every baseline run reached poll 29 or 30 and threw goto: Browser closed.
  • 5 relaunches, no failures. Median lost work 4.9 s per run, worst single death 15.0 s, at a 30 s checkpoint interval. No session was left unreleased, no checkpoint capture failed.
  • The overhead does not show up in the wall clock. outlive's median run was 756 s. The baseline's own pace — 29 polls in 624 s, 21.5 s each — extrapolates to ~774 s for 36, so surviving a session death cost less than the run-to-run variation. The cost that is measurable is the lost work above.
  • 2121 ms to replace a browser: launch + context from storageState + goto. The checkpoint itself is 200 ms and 1640 bytes.

An earlier run of the same bench drew a browser that was dead 1.5 s after launch() returned. The baseline lost a whole run to it; outlive relaunched and finished. Sessions do not only die of old age.

The live end-to-end test (bun run test:e2e) is the same claim without a control: sign in once, then poll for fourteen minutes. Latest run — 40/40 polls, one relaunch, one TOTP typed, 27 checkpoints, 2.1 s of work repeated, the session ending at 608 s. An earlier run of the same test took two deaths, one of them at 193 s, and finished the same way.

Why this has to exist

Solari browser sessions have no timeoutMs, no keep-alive call, no resume() and no option that asks for a longer life. Six measured sessions died at 604–617 s from creation — one at 319 s — and it made no difference whether they were idle, pinged every 25 s, or streaming a CDP screencast at 14 fps. The idle one lived longest. expiresAt sits at creation + 5 h and is never reached. The full measurement is in handraise's measurement 04.

So any agent task longer than about ten minutes dies part-way through, and because the control plane keeps calling the corpse active, it dies quietly.

What survives, exactly

| Thing | Survives a relaunch | |---|---| | Cookies, including HttpOnly ones | ✅ | | localStorage | ✅ | | The page's URL | ✅ | | A logged-in application session | ✅ (measured 3/3) | | The DOM, in-page JavaScript state, scroll position | ❌ | | A half-filled form | ❌ | | IndexedDB, service workers, sessionStorage | ❌ | | Local variables inside your task function | ❌ — it starts again | | Local variables in the scope around your task | ✅ — they are yours |

Two things about the re-entry itself, because neither is obvious:

Your task is not cancelled when its browser dies — it is abandoned. A promise cannot be interrupted, so the old entry keeps running until its next page call throws. For that window, two entries of your task are running at the same time. Page calls from the abandoned one fail harmlessly and its ctx.checkpoint() is retired, but anything it does outside the browser — a database write, an HTTP POST, a counter — still happens. This is the same constraint as "safe to run again", one layer down.

A death waits 250 ms before it becomes the outcome. The disconnected event arrives about 1.5 s before a page call would notice, so a task whose last call already succeeded is usually still resolving. Waiting lets it finish instead of throwing the work away. It is a threshold, not a guarantee: a task that spends longer than that parsing or writing after its final page call is re-entered anyway. Correct, because tasks must be re-runnable — but it is the expensive outcome the grace exists to avoid, so checkpoint before a long non-browser tail.

That last row is how a task keeps a running total across relaunches: keep it in your own closure, not inside the task.

const rows: Row[] = []                       // survives: it is yours
await outlive(solari, async (page, ctx) => {
  for (const url of remaining(rows)) {
    await page.goto(url)
    rows.push(await scrape(page))
    await ctx.checkpoint()
  }
  return rows
})

API

outlive(solari, task, options?)

Runs task and resolves with whatever it returns. outlive owns the browsers: it launches them, replaces them, and closes every one of them before returning. The Solari client stays yours and is never closed.

It throws three ways:

  • your value, unchanged and by identity, if your task rejected with something that was not a session death — outlive does not retry your bugs, and it does not coerce your rejection into an Error either. Reject with an object and you get that object back;
  • OutliveError with code: "gave_up", if the browser died more than maxRelaunches times;
  • OutliveError with code: "invalid_option", before anything is launched, if an option is not a number outlive can use.

Do not close the browser yourself. outlive owns it, and page.context() .browser().close() is indistinguishable from the platform ending the session: outlive will treat it as a death and relaunch.

task(page, ctx)

| ctx | | |---|---| | attempt | 1 on the first entry, 2 after the first relaunch, … This is the re-entry signal. | | resumedFrom | { url, checkpointAt } when a checkpoint was restored into this browser. Absent on the first entry — and also after a death that happened before the first checkpoint existed, which is why attempt and not this is the signal | | checkpoint() | capture cookies + localStorage + URL now. Never throws; resolves true if a checkpoint was written, false if it was not |

page is a playwright-core Page, already navigated to resumedFrom.url when there is one.

options

| Option | Default | | |---|---|---| | checkpointEveryMs | 30_000 | automatic capture interval | | maxRelaunches | 5 | ~5 sessions ≈ one hour of task | | relaunchBackoffMs | 2_000 | wait after a launch that failed; a death relaunches at once | | launch | {} | passed to solari.launch() for every browser. { retries: 2, probe: true } is worth considering: one browser in ten came back already dead | | viewport | 1280×800 | | | startUrl | – | navigated to before the first entry only | | navigationTimeoutMs | 45_000 | cap on the resume goto | | diagnostics | "safe" | "full" puts raw URLs and raw error text in the logs and the event. See Security | | onEvent | – | called once per run with the wide event | | logger | quietLogger | consoleLogger, noopLogger, or your own sink |

The wide event

One per run, on every path including the ones that throw:

{
  "runId": "3f2a91c7-6b40-4a1e-9d2c-8f5e0b71c4aa",
  "outcome": "completed",
  "relaunches": 1,
  "checkpointCount": 24,
  "lostWorkMs": 2315,
  "maxLostWorkMs": 2315,
  "totalMs": 751943,
  "attempts": 2,
  "releaseFailed": 0
}

Those counters are run 3 of the outlive arm in benchmarks/survival.json, copied from the file by scripts/sync-readme-event.ts. Only runId is invented, because the bench does not log it.

outcome is completed, failed (your task threw, and you get that error unchanged) or gave_up (out of relaunches — you get an OutliveError with code: "gave_up").

lostWorkMs is defined exactly: at each death, the milliseconds between the last completed checkpoint and the moment the death was detected, summed over the run. Not the moment the browser finished closing, and not including the 250 ms grace. If the newest checkpoint is older than the entry that died, the measurement starts at the entry instead, so no second of wall clock is counted twice. maxLostWorkMs is the worst single death by the same rule. Lower checkpointEveryMs to buy them down — a capture costs ~200 ms.

releaseFailed counts sessions outlive could not confirm were released after a close() failed. It should always be 0; anything else means a browser is still holding a slot.

It carries no secret outlive itself holds: no API key, no cookie values, no checkpoint, no URL. errorName and errorCode are the classifications you would group by, and error is the message after every URL has lost its path and query, credential-shaped name=value pairs have been blanked, and the whole thing has been clipped to 300 characters.

That last part is a net, not a guarantee — error is text your task produced, and outlive cannot know every shape a secret takes. diagnostics: "full" skips the scan entirely; use it where you trust the sink.

How death is detected

Three signals, and the control plane is not one of them:

  1. browser.raw.once("disconnected") — fires first, about 1.5 s before anything else notices.
  2. browser.isConnected() — local socket state, free, truthful.
  3. the message substring "Browser closed" — the last resort, for a call that threw before either of the above was checked.

patchright's TargetClosedError has no code and no status; its constructor.name is minified and its name is plain "Error". The substring is the only stable marker, which is why it is third and not first.

Choosing checkpointEveryMs

A checkpoint costs ~200 ms on the same connection your task is using, so the default of 30 s spends about 0.7 % of a busy task's time. It is a target interval, not a cap: a capture can fail — the browser is already dying, the round trip times out — and then the next death costs more than one interval. await ctx.checkpoint() returns false when that happens, which is the only way to know. Two rules beat any interval:

  • checkpoint after anything expensive or non-repeatable (a login, a payment, a page that took a minute to reach);
  • keep progress in your own scope, so a re-entry skips what is already done.

Design decisions

Security

See SECURITY.md. In short: a checkpoint contains session cookies. It is held in memory for the length of the run and never written to disk, never logged and never sent anywhere except back into the replacement browser. Log lines and the wide event carry the checkpoint's hostname, never its URL, because a Solari preview URL carries a bearer token in its query string. Any text outlive did not write — your task's error message, an SDK failure — has its URLs reduced to hostnames and its credential-shaped name=value pairs blanked before it is logged. That is a net, not a proof: outlive cannot know every shape a secret takes in your own messages. diagnostics: "full" turns all of it off, deliberately and explicitly. If you persist a checkpoint yourself, treat it as a credential.

Development

bun install
bun run lint          # biome + oxlint (anti-slop) + the embedded test app
bun run typecheck
bun test src/ test-app/
bun run build && node scripts/dist-smoke.mjs
bun run test:e2e      # live: waits ~14 min for a real session to die
bun run bench         # live: 2 × 5 tasks of 12 minutes

The live scripts need SOLARI_API_KEY in .env and spend plan quota. Check bun --env-file=.env scripts/cleanup-sandboxes.ts before and after: the plan allows two concurrent sandboxes and the bench holds one of them.

MIT © Simon Doba