packages/@repo/debug-proxy/README.md
An internal development tool for working on Sanity Studio in this monorepo (private, never published). It's a local debugging proxy that sits between the studio (or any Sanity client) and the Sanity API, for manually exercising clients under adverse network conditions:
It can also be used to quickly verify how clients respond to backend changes by modifying API responses before forwarding them to the client.
For this proxy to work, <projectId>.localhost must resolve to your local machine. Chrome and Firefox already route *.localhost to 127.0.0.1, but Safari (and possibly other browsers) do not. For those, either add <projectId>.localhost 127.0.0.1 to /etc/hosts or set up dnsmasq.
# From the repo root — starts the proxy and the test studio with its
# production workspace pointed at the proxy
pnpm dev:proxy # studio talks to the proxy over HTTP/2 + TLS (:3051)
pnpm dev:proxy:http1 # studio talks to the proxy over plain HTTP/1.1 (:3050)
HTTP/2 is the default since it matches what the studio sees in production. It requires the proxy's TLS certificate to be accepted — see HTTP/2 and certificate trust below for the one-time setup. dev:proxy:http1 works without any setup but, being a legacy protocol, will trip the studio's slow-connection/legacy-HTTP detection.
# From the repo root
pnpm --filter @repo/debug-proxy dev # watch mode
pnpm --filter @repo/debug-proxy start # one-off
By default the CLI starts a single listener: https://localhost:3051 — HTTP/2 with HTTP/1.1 fallback via ALPN (TLS). Pass --http1 to also serve a plain cleartext HTTP/1.1 listener on http://localhost:3050. Listeners always bind loopback (127.0.0.1) — the proxy injects your API token on every request, so it is deliberately not reachable from other machines.
Configuration is via CLI flags (--help for the full list):
--port / --http1-port — listener ports (defaults 3051 / 3050)--http1 — also serve the plain HTTP/1.1 listener--force-http1 — don't offer h2 in the TLS handshake, forcing clients down to HTTP/1.1 over TLS; useful for testing how the studio handles a legacy protocol (e.g. the isUsingLegacyHttp warning)--api-host — upstream API (api.sanity.io or api.sanity.work for staging)--listener-ttl — disconnect SSE listeners after N seconds to simulate flaky connections--sse-faults, --drop-probability, --reset-probability, --org-401Pass flags through pnpm like so:
pnpm --filter @repo/debug-proxy dev --sse-faults --drop-probability 0.2
The one exception is the API token, which is a secret and stays out of argv: set SANITY_TOKEN in the shell or in a .env file in this directory (see .env.example). It is injected as Authorization: Bearer and required for write operations or private datasets when using cookie-based auth.
Browsers only speak HTTP/2 over TLS, so the :3051 listener terminates TLS. Because the proxy is addressed by arbitrary <projectId>.localhost hostnames — and browsers reject *.localhost wildcard certificates (a wildcard needs at least two labels under it) — no single static cert can cover them all. Instead, the proxy mints a certificate for each hostname on demand (via the TLS SNI callback) and caches it in .certs/sni/:
With mkcert installed (recommended, one-time setup):
brew install mkcert && mkcert -install
Minted certs are signed by mkcert's locally-trusted CA, so every project host works with zero warnings and zero per-project configuration.
Without mkcert, minted certs are self-signed: open https://<projectId>.localhost:3051/v1/ping in the browser once per host and accept the warning ("Advanced" → "Proceed"), then reload the studio.
If a key.pem/cert.pem pair exists in .certs/, the proxy prefers it for any hostname it actually covers and mints for the rest.
The upstream hop (proxy → api.sanity.io) is always HTTP/1.1 — the protocol that matters for debugging is the one the browser negotiates with the proxy.
pnpm dev:proxy sets SANITY_STUDIO_USE_DEBUG_PROXY=true (HTTP/2 over TLS, apiHost: 'https://localhost:3051'); pnpm dev:proxy:http1 sets it to http1 (plain HTTP/1.1, apiHost: 'http://localhost:3050'). See envConfig in dev/test-studio/sanity.config.ts. You can also set the env var yourself when starting the studio, e.g. to run the proxy with custom fault scenarios:
SANITY_STUDIO_USE_DEBUG_PROXY=true pnpm dev
For any other Sanity client:
const proxiedClient = client.withConfig({apiHost: 'http://localhost:3050'})
Instead of the env-driven CLI, you can embed a configured proxy programmatically:
import {
createDebugProxy,
createSSEProxy,
dropMutations,
duplicateMutations,
isListenEndpoint,
randomLatency,
} from '@repo/debug-proxy'
const proxy = createDebugProxy({
port: 3050,
apiHost: 'api.sanity.io',
token: process.env.SANITY_TOKEN,
routes: [
{
// Apply SSE fault scenarios to the listener endpoint
match: isListenEndpoint(),
handler: createSSEProxy((events$) =>
events$.pipe(duplicateMutations(0.2), randomLatency(100, 2_000), dropMutations(0.1)),
),
},
],
})
await proxy.listen()
// ...later
await proxy.close()
Routes are matched in order — the first route whose match returns true wins. Requests that match no route fall through to a transparent pass-through proxy (override it via defaultHandler).
createDebugProxy(config) — the server factory; returns {server, listen, close, port}.createRequestProxy({transformHeaders?, transformBody?}) — the core proxy primitive (RxJS operators over response headers/body).createSSEProxy(operator?) — builds on createRequestProxy for streaming endpoints; parses the byte stream into discrete SSEEvents.randomLatency, sendReset, duplicateMutations, dropMutations, shuffleEventDelivery.urlIncludes, isListenEndpoint, isGetOrgIdEndpoint, anyOf, allOf.A scenario is just an RxJS operator over the stream of parsed SSE events (Observable<SSEEvent>, where an SSEEvent is a message, comment, or retry). createSSEProxy parses the upstream byte stream into discrete events, runs them through your operator, and re-serializes whatever comes out — so a scenario can delay, drop, duplicate, reorder, or rewrite events with plain RxJS.
Say you want to simulate the API occasionally sending mutation events with an empty payload:
// src/scenarios.ts
/** Replace mutation payloads with `{}` at the given probability. */
export function truncateMutations(probability: number): MonoTypeOperatorFunction<SSEEvent> {
return map((event) =>
event.type === 'message' && event.message.event === 'mutation' && Math.random() < probability
? {...event, message: {...event.message, data: '{}'}}
: event,
)
}
Then:
Export it from src/index.ts alongside the other scenarios.
Wire it up — either compose it into the listener route in src/cli.ts (optionally behind a new flag, following the --drop-probability pattern) or pass it in a custom route when using the library API:
{match: isListenEndpoint(), handler: createSSEProxy((events$) => events$.pipe(truncateMutations(0.1)))}
Conventions worth keeping: key off event.message.event === 'mutation' (or whichever event type you're targeting) and pass everything else through untouched — the welcome handshake event in particular must reach the client for the listener to work. Scenarios compose with pipe(...), so prefer several small single-purpose operators over one configurable mega-operator.
Upstream protocol: the proxy always talks HTTP/1.1 to the upstream API. The browser-facing protocol (HTTP/1.1 on :3050, HTTP/2 on :3051) is what clients observe and react to; the upstream hop is not part of what's being simulated.
WebSockets: upgrade requests (e.g. the bifur client's wss://…/socket/…) are tunneled transparently to the upstream — the handshake is forwarded and raw bytes are piped both ways — so socket connections work through the proxy, but routes and fault scenarios don't apply to them.