Skip to main content

Discovery and Serving

How a Dev Preview session comes up: script discovery and command ranking, URL detection, port allocation, the stable proxy origin in front of your server, per-worktree sessions, and what happens to one when you close the panel.

Reviewed

Automatic discovery

Daintree scans the project for runnable commands. Detection covers nine sources:

  • package.json scripts: the runner follows your lock file (bun.lock or bun.lockb uses bun, pnpm-lock.yaml uses pnpm, yarn.lock uses yarn, otherwise npm)
  • Makefile targets (non-private, non-PHONY)
  • Justfile recipes (skips underscore-prefixed entries)
  • Taskfile.yml tasks (only those with a desc field)
  • Procfile: every NAME: command line, with the command body used verbatim
  • mise.toml: [tasks] entries with a run field, skipping hide = true and _-prefixed tasks; surfaced as mise run [name]
  • Django manage.py: common commands like runserver, migrate, test, and shell
  • composer.json scripts (non-lifecycle)
  • .devcontainer/devcontainer.json: the postStartCommand in string, array, or object form, with nohup, a trailing &, and bash -c wrappers stripped

Detection results are cached for 60 seconds and refresh on project load or when you switch worktrees. Every discovered command also feeds the QuickRun autocomplete.

How a candidate is ranked

Ranking is framework-aware before it is name-based. If your dependencies name a framework Daintree recognizes (Next.js, Remix, React Router, Nuxt, SvelteKit, Astro, Create React App, or plain Vite) and that framework's canonical script exists and actually invokes it, that script wins. This is why a Create React App project offers start rather than being pushed down the list by a dev script that does something else.

After the framework default come scripts named dev, then start, then serve. If none of those matched, a .devcontainer postStartCommand is offered next, then every other detected runner.

Note
Automatic Turbopack for Next.js 15+. When Daintree detects a Next.js dev command on a project running Next.js 15 or newer, it appends the --turbopack flag before spawning the dev server. The reason: webpack's style-loader injects CSS using unsafe-inline, which Electron's Content Security Policy blocks. Turbopack serves CSS as regular files, so styles render correctly in the embedded browser.

The version is read from node_modules/next/package.json, falling back to your package.json dependencies. On Next.js 14 or older, or when the version can't be determined, the flag is stripped rather than injected, since Turbopack only became stable for next dev in Next.js 15.

Injection is idempotent and adapts to your command form. next dev becomes next dev --turbopack. npm run dev / yarn dev / pnpm dev get -- --turbopack appended, and bun run dev gets --turbopack appended directly. Commands containing shell control characters (&&, ;, |, redirection, substitution) are left alone, because the flag would attach to the wrong program.

Turn it off per project in Project Settings > Dev Server Command with the Auto-inject --turbopack for Next.js 15+ projects checkbox (on by default). Disable it if your project relies on a custom webpack() config, a .babelrc, or loaders that aren't Turbopack-compatible (SVGR is a common one). With the toggle off, Daintree strips any --turbopack flag a previous run may have added.

Port pinning

Daintree allocates a port for each session, and for the frameworks that ignore process.env.PORT it injects the port into the command rather than hoping to detect it afterwards. Vite and SvelteKit get --port plus --strictPort, so the server fails fast on a collision instead of quietly drifting to the next free port and leaving the readiness probe watching the wrong URL. Astro and Nuxt get --port only, since they don't forward --strictPort to Vite. A command that already specifies a port is left alone, as is any command with shell control characters in it.

For frameworks Daintree can't pin, it still knows the conventional default port (3000 for Next.js, Remix, Nuxt and Rails, 5173 for Vite and SvelteKit, 4321 for Astro, 8000 for Django and Laravel, 4000 for Phoenix) and uses it to aim the readiness probe.

How the preview is served

The webview never loads http://localhost:<devPort> directly. Daintree runs a small reverse proxy on a fixed port (43000, or an OS-assigned fallback if that one is taken) and gives every panel a stable origin of the form http://dp-<token>.localhost:<proxyPort>. Each request is forwarded to whatever port that panel's dev server is on right now, resolved live.

That indirection buys three things:

  • Cookies and localStorage survive a restart. Restart the dev server and it may come back on a different port, but the origin the page was loaded from does not change, so nothing scoped to that origin is lost.
  • Host checks pass. The proxy rewrites the Host header to the upstream, so Vite and Next.js host validation sees what it expects, and it strips the Domain= attribute from upstream Set-Cookie headers so cookies bind host-only to the stable origin instead of being rejected.
  • IPv6-only and HTTPS dev servers work. The upstream is dialed as localhost, not 127.0.0.1, so a Vite server that bound IPv6-only still resolves; and a dev server running over TLS (Vite's server.https, Next.js --experimental-https, mkcert) is dialed over HTTPS rather than answered with a 502.

A stalled upstream is dropped after 30 seconds and surfaced as a classified 502, rather than leaving the webview spinning forever.

Opening the page in your real browser

Open in Browser does more than hand your default browser a URL. Daintree mints a short-lived, single-use, panel-bound HMAC token and opens a bootstrap URL carrying it. The external browser redeems the token once, picks up a session marker on the same stable origin, and lands on the page you were looking at, instead of an unauthenticated cold start. Tokens live for 60 seconds and never outlive the app process; the key is generated per run and is never persisted.

Session lifecycle

Each panel's dev-server session is keyed by project and panel, and survives panel remounts: docking, undocking or rearranging panels doesn't restart your server. If a panel remounts while the server is still starting, the last 300 lines of terminal output are replayed on the Output tab so you don't lose the context.

Across a relaunch

Running sessions are captured to a manifest at shutdown. The manifest holds spawn metadata only (working directory, command, environment, Turbopack setting) and no process handle: nothing is reattached, and the next start allocates a fresh port rather than reusing one that may now be in TIME_WAIT or taken by something else. On the next launch the panel comes back as Stopped (restored) and re-issues the command when you open it. Until then it stays visible in the dev-server dashboard as an explicit restart offer. Stopping a server yourself drops it from the manifest, so a deliberate stop stays stopped.

Lazy loading and memory

The embedded browser isn't created until the panel is first visible; background panels show a placeholder until you switch to them. A background panel may also have its webview released under memory pressure, in which case the panel shows a "Reclaimed for memory" placeholder and recreates the webview the next time you focus it.

Scroll position is saved when a panel unmounts and restored when the same URL loads again. Restarting the dev server clears the saved position, since the page content has changed.