Skip to content

fix: persistent profile across headed↔headless so IndexedDB auth survives#1

Open
gabelul wants to merge 1 commit into
aboul3ata:mainfrom
gabelul:fix/persistent-context-indexeddb
Open

fix: persistent profile across headed↔headless so IndexedDB auth survives#1
gabelul wants to merge 1 commit into
aboul3ata:mainfrom
gabelul:fix/persistent-context-indexeddb

Conversation

@gabelul
Copy link
Copy Markdown

@gabelul gabelul commented May 6, 2026

Problem

The browse tool drops IndexedDB on every state transfer between headless and headed contexts. Sites that store auth in IndexedDB (Supabase, Firebase, Adobe SSO) lose their session on every handoffresume, and on every server restart.

Concrete repro: log in to Mobbin via handoff (Mobbin uses Supabase Auth, JWT stored in IndexedDB). Run resume. Run goto https://mobbin.com/browse/ios/apps. Snapshot — UI shows logged out.

Root cause

Two architectural mismatches:

  1. launch() uses chromium.launch() + browser.newContext() — an ephemeral context with no userDataDir. State has nowhere to persist.
  2. handoff() uses launchPersistentContext('~/.gstack/chromium-profile', …) — state DOES land on disk, but the next headless launch can't see it.
  3. The bridge (saveState/restoreState) only round-trips cookies + localStorage + sessionStorage. IndexedDB is silently dropped.
  4. resume() is a no-op that just clears refs — it doesn't actually swap back to headless, leaving callers stuck in headed mode.

Fix

Make every browser context share the same persistent profile dir on disk. State (including IndexedDB, ServiceWorkers, etc.) transfers automatically because both contexts read the same files.

  • launch()launchPersistentContext(~/.gstack/chromium-profile, { headless: true, … }). Adopts the auto-created blank page as tab 1 instead of opening a duplicate.
  • handoff() → close the headless context BEFORE opening headed (Chromium profile is single-writer). Drop the saveState/restoreState calls — they're unnecessary now.
  • resume() → now async. Closes headed and re-launches headless against the same profile dir. Previous behaviour (no-op) preserved when called outside headed mode.
  • meta-commands.tsawait bm.resume().

Side effects

  • All storage (cookies, localStorage, sessionStorage, IndexedDB, ServiceWorkers) now persists across server restarts. This is the user's natural mental model for a browser, and matches what handoff was already doing for headed mode.
  • The saveState/restoreState machinery is still used by recreateContext() (for user-agent changes) and untouched there.
  • One process owns ~/.gstack/chromium-profile at a time. If a second browse server starts while the first is alive, it'll fail on the profile lock — same behaviour as Chrome itself with concurrent profile use.

Verification

# Cold start (no prior session)
$B goto https://mobbin.com
$B handoff "Log in to Mobbin"
# (user logs in via visible Chrome — Supabase JWT written to IndexedDB on disk)
$B resume
$B snapshot -i | head -20  # ✅ shows "Invite & earn" (authed UI)

# Restart server
pkill -f browse
$B goto https://mobbin.com/browse/ios/apps
$B snapshot -i | head -20  # ✅ still shows authed UI — no re-handoff needed

Tested on macOS arm64 with Bun 1.3.12 + Playwright 1.59.1.

Diff

3 files changed, 135 insertions(+), 65 deletions(-)

The headless browser used `chromium.launch()` + `browser.newContext()`
(ephemeral context, no userDataDir), while `handoff()` used
`launchPersistentContext` against `~/.gstack/chromium-profile`. The
state-transfer machinery (saveState/restoreState) only round-tripped
cookies + localStorage + sessionStorage — IndexedDB was silently dropped
on every swap.

Sites that store auth in IndexedDB (Supabase, Firebase) lost their
session every time we swapped headed↔headless or every time the server
restarted. Mobbin in particular: log in via handoff, IDB token written
to disk, resume → fresh ephemeral context → no IDB → logged out.

Fix:
- `launch()` now uses `launchPersistentContext` with the same
  userDataDir as `handoff()` and `launchHeaded()`. All storage
  (cookies, localStorage, sessionStorage, IndexedDB, ServiceWorkers)
  lives on disk in the shared profile.
- `handoff()` closes the headless context BEFORE opening headed
  (Chromium profile is single-writer). No save/restore needed —
  disk state transfers automatically.
- `resume()` is now async and actually swaps headed→headless via
  the same profile dir. Previously it was a no-op that just cleared
  refs, which left users stuck in headed mode.
- `meta-commands.ts` awaits the now-async `bm.resume()`.

Verified end-to-end: Mobbin "Invite & earn" UI (auth-only) renders
in headless mode on a fresh server start, with no fresh handoff
needed. The IndexedDB Supabase JWT from a prior handoff persists on
disk and is read on launch.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant