Remote browsers
A server that launches every browser itself pays for every browser, and can only render the engines it can install. A remote target moves the browser elsewhere: the runner connects to a browser over a WebSocket instead of launching one, and everything else (the capture pipeline, diffs, baselines, review) is unchanged. The decisions are in ADR 0007.
With only remote targets in browsers, the host needs no installed browsers at all (playwright-core or puppeteer-core only). It is not idle: diffing, parameter extraction, unzipping and, for some targets, serving the Storybook over the connection still run on it, and every screenshot comes back over the WebSocket.
Targets and baselines
Section titled “Targets and baselines”A remote target is named <engine>@<name>, for example chromium@grid. The name comes from the service’s name option or its fixed default (grid, azure, browserstack), never from anything that changes (an OS, a browser version, an endpoint).
- A remote target has its own baselines. It is never compared with a local render of the same engine: different hosts, fonts and GPU paths would turn every comparison into noise or into false environment migrations. Moving a project from local to remote is a “new engine” bootstrap, like enabling Firefox.
- The name is stored in every snapshot and baseline key, so renaming a target starts fresh baselines for it. A name is lowercase letters, digits and hyphens, starts with a letter or digit, and is at most 32 characters. Two entries of one service (Windows and macOS on one provider) need an explicit
nameon at least one; a clash fails when the runner is created, naming both entries. - Projects choose targets in Settings → Capture like engines. Saving a target the runner does not offer, or that is unavailable, is refused with the reason. Remote capture is opt-in per project: the Storybook, possibly unreleased UI, is rendered by whoever runs the browser.
Declaring them
Section titled “Declaring them”Remote services are entries of the runner’s browsers list, imported from the /remote subpath of the runner package. Each is plain configuration; no vendor SDK is needed.
import { createPlaywrightCaptureRunner, local } from "@storyshelf/runner-playwright";import { azurePlaywright, playwrightServer } from "@storyshelf/runner-playwright/remote";
const runner = createPlaywrightCaptureRunner({ browsers: [ "chromium", // local; the first target is the default for new projects playwrightServer({ name: "grid", endpoint: "ws://playwright:3000/storyshelf", sessions: 8, // concurrency cap, see below }), // → chromium@grid, firefox@grid, webkit@grid azurePlaywright({ endpoint: process.env.PLAYWRIGHT_SERVICE_URL!, token: process.env.PLAYWRIGHT_SERVICE_ACCESS_TOKEN!, sessions: 20, }), // → chromium@azure, firefox@azure, webkit@azure ],});
createShelfApp({ database, storage, testing: { runner } });Credentials are operator-level: they are passed in the server’s code, from the environment or a secret manager. They are never stored per project, and never logged: the endpoint (it can carry a key) and every header value are removed from the messages the runner surfaces.
| Preset | Package | Protocol | Reaches the Storybook through |
|---|---|---|---|
playwrightServer(...) | @storyshelf/runner-playwright/remote | Playwright | exposeNetwork (default) |
azurePlaywright(...) | @storyshelf/runner-playwright/remote | Playwright | exposeNetwork |
browserstack(...) | @storyshelf/runner-playwright/remote | Playwright | a Storybook URL |
remote({ connect, ... }) | @storyshelf/runner-playwright/remote | either | as the connection says |
cdp(...) | @storyshelf/runner-puppeteer/remote | CDP | a Storybook URL |
remote() is the generic constructor every preset is sugar over, so a provider without a preset needs no new release:
remote({ name: "mygrid", engines: ["chromium", "firefox", "webkit"], connect: (engine) => ({ protocol: "playwright", // or "cdp" (Chromium only) endpoint: "wss://…", headers: { authorization: "Bearer …" }, exposeNetwork: "<loopback>", // omit when the service cannot route back to the host }), sessions: 4, platform: "linux", // reported in the fingerprint when the page cannot tell});Presets validate when the runner is created, not when the first build runs: a bad name, an empty engines, an engine the connection cannot serve (firefox over CDP, or on a Chromium-only runner), a non-WebSocket endpoint, or two entries with one target all throw. engines picks a subset of what a service offers.
The presets other than playwrightServer follow each vendor’s published connection format and have been checked against a mock, not against a live account (BrowserStack and Azure accounts are not available to this project’s tests); report differences you meet.
How the browser reaches the Storybook
Section titled “How the browser reaches the Storybook”The Storybook is extracted on the host and served on 127.0.0.1, which a remote browser cannot open on its own. There are two ways in (ADR 0007, decision 5):
exposeNetwork(Playwright’s own protocol, on a self-hosted Playwright server and Azure Playwright Workspaces): the runner connects withexposeNetwork: "<loopback>", and the remote browser’s requests for127.0.0.1are routed back through the connection to the host, so the static server works unchanged and nothing new is exposed. Every asset crosses the WebSocket, so a remote render is slower per page than a local one.- A Storybook URL (CDP-only services, every Puppeteer remote target, and providers that cannot route back such as BrowserStack): the runner is given an absolute, signed URL to the build’s published Storybook and navigates there. This needs a
secretand a confirmed public origin (a detected, unconfirmed address does not count). Until both exist such a target isunavailableon System → Capture browsers with the reason (“no secret: set the server secret, it signs the Storybook URL the remote browser opens” or “no public origin: set PUBLIC_BASE_URL, or confirm the detected origin on the System page”), settings saves naming it are refused, and a build that still reaches one (a project saved before the origin went away) fails at once with that reason before anything renders: no story runs on the other targets first, and no partial result is stored. Fix the reason and retry the build.
Signed Storybook URLs
Section titled “Signed Storybook URLs”For a reach: "url" target the job mints a URL of the form <public origin>/_s/<token>/ for each render pass and for the parameter pre-pass; the URL is always the build’s directory root, ending in a slash, with no iframe.html, query or fragment; the runner opens <url>iframe.html and the assets beside it, and refuses any other form before it connects. GET /_s/<token>/<file> serves static files of that one build and nothing else: no session is needed, the token is the only credential.
- What a token grants. Read access to the files of one build of one project (
<projectId>:<buildId>:<expiry>, signed with HMAC-SHA-256). Not the wrapper page, not a directory listing, not the API, not another build, not another route. A token for a build whose project does not match, or a build that was deleted, answers404. - Key. Derived from the server
secretwith a fixed context, so a token cannot be mistaken for a session or any other signature. During a rotation a token signed withpreviousSecretis also accepted; oncepreviousSecretis removed, outstanding tokens stop working. - Lifetime. Derived from the render: the pass’s render budget (15 minutes, or 15 seconds per capture for a big matrix of stories, viewports and engines) plus 5 minutes, at least 2 hours and at most 24 hours. A project can set its own lifetime instead, in whole seconds from 1050 to 86400, under Settings → Capture (project admins) or as
storybookUrlTtlSecondsonPATCH /api/v1/projects/:slug; clear it to return to the derived value. The floor is one minimum slice (50 captures at 15 seconds each) plus the 5 minute grace, so a render slice can never outlive its token; a shorter value is refused (400) with the floor in the message. A fresh token is minted whenever a render pass starts (a retried pass, a crash-isolation pass and a shard that waited in the queue each get their own), so the lifetime only has to outlast one pass. A pass whose budget would outlast the lifetime (about 5700 captures at the 24 hour ceiling, fewer with a project override; a slice is never smaller than 50 captures) is rendered in slices of stories, each with a freshly minted URL, so a huge build never dies when its token does; the cost is one browser start per slice. A token that claims more than 24 hours is refused even when it is correctly signed. - Rate limit. Each client IP may make 6000 requests a minute to the route (a render fetches thousands of assets from one farm address).
testing.storybookUrlRateLimitchanges it. Over the limit the route answers429withRetry-Afterbefore it looks at the token, so the answer says nothing about whether a token is valid; raise the limit when a large farm shares one outbound address. - Every refusal looks the same. A malformed, tampered, expired or unknown token, a path outside the build and a missing file all answer an identical
404; the reason is only in the server’s debug log. - Responses carry
Cache-Control: private, no-store,Referrer-Policy: no-referrer,X-Robots-Tag: noindex, nofollowandX-Content-Type-Options: nosniff. A served file also answersAccess-Control-Allow-Origin: *(never with credentials), so a sandboxed frame without cookies can load the module scripts of a Storybook through a signed URL; a refusal carries no CORS header. - One reader. The route serves files through the same handler and the same access rule as the published Storybook routes (a signed token, or a build anyone may open, or a signed-in viewer), so paths, the manifest and content types behave identically.
/_s/is not the/_/short link. - Without
testingthe route answers404as if it did not exist; a hosting-only instance never mints or serves one. - The token is kept out of logs. The request log, the OpenTelemetry
url.fullattribute, attempt logs, failure text and error serializers replace/_s/<token>with/_s/[redacted].
What the provider sees. The remote service receives the URL, and a hosted service may record it (BrowserStack stores the URLs a session visits, for example). The token is the only secret in it and it expires, so keep the lifetime short, and treat a provider’s session logs as holding a read link to that build for that long. Use a self-hosted farm with exposeNetwork when a Storybook must not leave your network at all.
What is measured in the browser
Section titled “What is measured in the browser”The capture fingerprint (see Capture) of a local render comes from the host: its platform and its fc-list fonts. For a remote render both describe the wrong machine, and a fingerprint that cannot see the remote would let a provider-side image change through without a diff, and on the default branch rewrite the baselines as a migration. So a remote render reports its environment from the browser it drove:
browserVersion: what the connection reports.platform: the OS the page reports (navigator.userAgentData, elsenavigator.platform), normalised (linux,windows,macos), falling back to the service’s configuredplatform. Neverprocess.platform.deviceScaleFactor:window.devicePixelRatioof the page.fontsHash: a digest over a fixed list of 44 common font families, each tested in a blank page by measuring text against the generic family it would fall back to.fc-listis not used. Changing that list would re-baseline every remote target, so it is part of the fingerprint’s contract.
A field the runner cannot determine (a page that would not open) is omitted, and an unknown fingerprint component is not read as a change. Provider browser builds move: pin the browser version in the preset where it has one (browserVersion on BrowserStack), because every move is a baseline migration on the default branch.
Sessions and failures
Section titled “Sessions and failures”sessionsis the plan’s parallel-session limit. For a remote target the runner renders at most that many pages at once, instead of sizing pages from this host’s CPU and memory. The cap is per target and per process: concurrent shards of one target share one limiter, so they do not multiply it. An explicitconcurrencycan lower a render’s share, never raise it. It defaults to 1; set it to what you pay for.sessionsis per app instance. When several instances share one provider plan, divide the limit among them. For a self-hosted farm put the cap on the farm (below). A shared limit across instances is not built.- Connection-level failures (a refused or dropped connection, a timeout, “no capacity”, 429 and 5xx answers) are retried with exponential backoff (500 ms, doubling, three retries by default;
connectRetries,connectBackoffMsandconnectTimeoutMsonremote()) before the render sees them, so a provider outage is not treated as a story crash. When the retries are spent the job fails at once with the reason (aTargetLaunchError), not through crash isolation. - A failure that no retry fixes (refused credentials, a missing endpoint, mismatched versions) also marks the target
unavailablewith the reason on System → Capture browsers, until the server restarts. A service that is merely down does not: it stays offered, and the next build tries again. - A browser that dies while rendering is a crash like a local one and goes through crash isolation as usual.
- Browser recycling (
recycleBrowserEvery) opens a new remote session instead of relaunching.
Self-hosted farm
Section titled “Self-hosted farm”The official Playwright image running playwright run-server, pinned to exactly the playwright-core version of the runner:
docker run --rm --init -p 3000:3000 --ipc=host --user pwuser --workdir /home/pwuser \ mcr.microsoft.com/playwright:v1.63.0-noble \ npx -y playwright@1.63.0 run-server --port 3000 --host 0.0.0.0 --path /storyshelf-4f1c --max-clients 8then playwrightServer({ endpoint: "ws://<host>:3000/storyshelf-4f1c", sessions: 8 }). Both the image tag and the playwright@ version must equal the runner’s playwright-core (1.63.0); a test in the runner package fails when this page drifts from it.
Playwright connect needs the client and the server on the same minor version. When they differ the server refuses the handshake, and the runner reports, with the endpoint removed: Playwright version mismatch: the server runs v1.40 but this runner's playwright-core is 1.63.0; run the server at exactly 1.63.0. The target is marked unavailable; upgrade whichever side is behind.
What a bare run-server does, measured against the pinned version:
- No authentication. Anyone who can reach the port and knows
--pathcan start browsers. A wrong path is refused at the handshake (400), so a long random--pathis the only gate it has. Keep the port off the internet: put it on a private network, or behind a reverse proxy that checks a token (playwrightServer({ token })sendsauthorization: Bearer …) and terminates TLS (wss://). - A client cap that queues.
--max-clientsbounds concurrent connections, but a connection over the cap is not refused: it waits until a slot frees, which the runner sees as a connect timeout (30 s by default, then retried). Set--max-clientsto at least the number of renders that can run at once (one connection per render, sotesting.concurrencytimes the shards in flight), and raiseconnectTimeoutMsif renders outnumber it.sessionsstill bounds the pages each target renders. - Browsers on the server. The image carries Chromium, Firefox and WebKit. Size it for the pages you allow: roughly 100 to 300 MB each.
For several pools, declare one playwrightServer per pool with its own name and endpoint (firefox@pool-b, chromium@pool-a): each target maps to its endpoint, so no job routing is needed.
Other services
Section titled “Other services”- Browserless and other CDP services:
cdp({ name, endpoint, headers, sessions })on the Puppeteer runner, orremote({ connect: () => ({ protocol: "cdp", ... }) })on the Playwright runner (Chromium only, and noticeably lower fidelity than Playwright’s own protocol, which is acceptable because the target is its own engine with its own baselines). Both need a Storybook URL. - Azure Playwright Workspaces:
azurePlaywright({ endpoint, token, os, sessions }), with the workspace’s browser endpoint (PLAYWRIGHT_SERVICE_URL) and access token; it passesosand the API version as query parameters. - BrowserStack:
browserstack({ username, accessKey, os, browserVersion, engines, sessions }); the credentials travel in the connection’s capabilities and are scrubbed from messages. Needs a Storybook URL. - Real devices and real Safari go through WebDriver and Appium, not a connect endpoint, and are not covered.