webadb.online’s screencast stream uses MediaSource to feed the
video element. MediaSource requires the page to be cross-origin
isolated. So does SharedArrayBuffer. So does OffscreenCanvas
with a worker. So do most of the high-performance browser APIs
that turn the browser into a real runtime rather than a document
viewer.
Cross-origin isolation is gated by two HTTP response headers:
1 | Cross-Origin-Opener-Policy: same-origin |
The browser will set window.crossOriginIsolated === true only
when both are present, and the COEP policy is recursive — every
resource you load has to opt in. If you forget one script tag, one
iframe, one stylesheet, one image, the whole page goes into a
half-isolated state where MediaSource mysteriously throws.
This post is the writeup of how we get isolation on without
breaking Google Analytics, which is a same-origin script but
originates cross-origin assets.
What “isolated” actually means
crossOriginIsolated is the property. When it’s true:
SharedArrayBufferworks (otherwise throws on construction).Atomics.wait()/Atomics.notify()work.- High-resolution timers (
performance.now()≥ 5μs instead of
100μs). MediaSourceandWebCodecswork without throwingCannot use MediaSource on this document.performance.measureUserAgentSpecificMemory()works.
The screencast panel needs MediaSource. The logcat panel needs
high-res timers to estimate line rates without drifting. The file
manager’s progress UI wants SharedArrayBuffer to share state
across the worker that streams uploads and the React thread that
paints. So we want isolation on, by default.
The headers, where they live
next.config.mjs:
1 | headers: [ |
Two policies in play:
- COOP (
same-origin): my page may only share a browsing
context with same-origin pages. If a popup opened by my page
navigates elsewhere, the popup gets a fresh context. This is
fine for webadb — no popups at all. - COEP (
require-corp): every resource my page loads must
sendCross-Origin-Resource-Policy: same-origin/same-site
/cross-origin, or be a same-origin load. There is no
default-deny in older browsers but Chromium enforces it.
The GA4 problem
GA4 is loaded with this snippet:
1 | <script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX"></script> |
Two cross-origin requests happen:
- The script tag loads
gtag/js. Google does sendCross-Origin-Resource-Policy: cross-originon this, so it
passes. - The script itself then loads measurement resources from
*.analytics.google.com,*.googletagmanager.com, and
sometimes a third-party endpoint. Some of those don’t
send a CORP header that passes therequire-corpcheck.
The cleanest fix that doesn’t break GA4 is to flip the script
to crossorigin="anonymous" and rely on the GA endpoints that
do send CORP. That gets us 95% of cases.
The remaining 5% is the times GA loads a tag-manager config from
a third-party endpoint that hasn’t been CORP’d yet. When that
happens, the page still loads (the request succeeds), but the
response is opaque — GA can’t read the body. We don’t get
analytics for that request. The page still works, MediaSource
still works, the user doesn’t see anything.
The CDO (Cross-Origin-Opener-Policy-Report-Only) endpoint
pattern lets us collect reports on which subresources fail CORP
without breaking the page. We don’t have it set up yet — adding
it is on the list.
Why not credentialless
COEP has a credentialless mode that relaxes the CORP
requirement: any cross-origin resource is allowed as long as it
doesn’t carry credentials (no cookies, no client certs). This is
a strict relaxation — Google Analytics works because it doesn’t
need credentials for measurement requests.
1 | <meta http-equiv="Cross-Origin-Embedder-Policy" content="credentialless" /> |
We tried this in staging and saw two regressions:
- WebUSB device picker stops working. The picker is itself
a Chrome-internal page (chrome://device-internals/) that
runs with credentials in some configurations. Credentialless
pages can’t talk to it. - Some WebUSB drivers’ WebUSB backend pages also start
requiring credentials for the device-init handshake.
So require-corp is what we ship. The tradeoff is that GA4’s
fan-in endpoints sometimes go opaque; we accept that.
The dev-server gotcha
next dev doesn’t apply headers from next.config.mjs to its
dev server. They only get applied on next build + the static
export. This bites everyone the first time: dev server
isolation looks fine, prod doesn’t. Fix is inscripts/dev-server-headers.mjs if you need it during local
development.
Verifying isolation is on
Three things to check after any change:
1 | // From the browser console on webadb.online |
If any of these fails, the headers didn’t reach the page. The
usual causes are:
- The headers were added to
next.config.mjsbutnext build
wasn’t run, so they’re still in the dev server (which
ignores them). - A new external resource was added (analytics endpoint,
third-party widget) and it doesn’t send CORP. - A service-worker script (which has its own origin handling)
is interfering.
We have a Playwright test in tests/isolation.spec.ts that hits
the production URL and asserts all three of the above, plus
that the screencast panel can actually open a MediaSource
without throwing. It runs on every deploy.
What isolation enables on webadb
Just to enumerate the things that depend on it, since the list
is non-obvious:
| API | Used by |
|---|---|
MediaSource |
screencast video element |
SharedArrayBuffer |
logcat ring buffer shared with a worker |
Atomics.wait |
logcat worker’s backpressure signal |
High-res performance.now() |
screencast frame-rate estimation, logcat QPS |
WebCodecs (queued) |
future video encoder for screencast recording |
If you’re building on the codebase, run the test before you
merge anything that adds an external resource — every new
external host has to play ball with COEP, and the failure mode
is “everything looks fine locally, prod mysteriously breaks”.