Appearance
Network
Every workspace runs its own copy of each service on its own port. Pomelo's networking layer makes that practical: services share one origin (so the browser never hits CORS), a backend can be retargeted local ↔ deployed without editing code, and inbound webhooks fan out to every branch at once.
Overview
Two local ports do all the work: the dev-proxy fronts browser traffic (same origin, and can retarget to a deployed backend), and the webhook relay fans inbound events out to every branch. Both land on the per-branch workspace services.
Same-origin dev-proxy
A frontend on :3000 calling a backend on :4000 is cross-origin — you fight CORS, and cookies don't behave like production. Pomelo's dev-proxy removes that: it fronts every service in a workspace under one origin, <service>.<repo>.<workspace>.localhost:8767 (the workspace part is the branch's ticket id when it has one, else the branch with / as -), and a frontend reaches a backend at the same-origin path /_pom_dev/<repo>/<service>. Same origin -> no CORS, cookies behave like production. .localhost resolves to loopback with no /etc/hosts edits.
In the hostname, <repo> is the repo's alias or name, and <branch> is the branch lowercased with anything outside a-z, 0-9 and - turned into - (feat/login becomes feat-login), or just its leading ticket key (such as proj-101) when no other workspace shares it.
Each request goes to the port the service was given. When nothing answers there, the proxy goes to whatever port the service's processes listen on, over IPv4 or IPv6, so a dev server that ignores $PORT (Vite's 5173, say) still works. A service that isn't up answers with why instead: ... is still starting (building, not listening yet) while it builds, and ... is not running in <branch> once stopped.
In a browser that answer is a page: it says whether the service is starting, stopped, not answering or unknown, shows the pom start command for a stopped one, and checks again in the background, so it opens the app by itself as soon as the service is up. Scripts, fetch and curl still get the one-line text.
Settings > Dev Services turns the proxy and the relay on or off and sets their ports (8767 and 8766 by default). Service URLs in env files use the proxy port too, so restart running services after changing it. POM_WEB_PORT overrides both, so a second copy of the app can run beside the first: the relay listens on that port + 1 and the proxy on + 2.
The Dev Requests tab (Open Requests in Settings, or Dev Requests in the command palette) lists every /_pom_dev/ request the proxy handled, page loads and failed requests on a service hostname (a page's module and asset requests are left out, so they don't push everything else out), and every webhook the relay fanned out, newest first. Filter by kind, to errors only, or by path or service. Select a row to see it beside the list:
- Request - the headers and body that came in (JSON is indented, keys in the order they were sent; Copy puts the body on the clipboard).
- Fan-out (webhooks) - each workspace it was handed to, with the status it answered and how long it took, or why it could not be reached.
- Response - the headers and body sent back.
10:40:06PROXYGET/_pom_dev/api/server/v1/me20012 ms
10:40:05WEBHOOKPOST/hooks/stripe50284 ms
10:40:04PROXYPOST/_pom_dev/api/server/v1/login40131 ms
10:40:03PROXYGET/_pom_dev/web/app/assets/main.js2004 ms
10:40:02PROXYGET/_pom_dev/api/server/v1/orders5032 ms
WEBHOOKPOST/hooks/stripe1 of 2 failed
Serviceapi/serverReceived10:40:05 - 84 ms
RequestFan-out (2)Response
HEADERSShow Hidden
content-typeapplication/jsonstripe-signaturet=1727671325,v1=5257a8c0d1user-agentStripe/1.0authorization********
BODYCopy
{ "id": "evt_1Q2xYz", "type": "invoice.paid", "data": { "object": { "id": "in_1Q2xAb", "amount_paid": 4900, "currency": "usd" } } }
Kept in memory for this session only.
Bodies are kept in memory for the session only: the first 256 KB of each, and 64 MB for the whole log (the oldest are dropped first). Authorization, cookie and token headers stay hidden until you click Show Hidden.
Reference another service's same-origin path with {{<repo>.<service>.path}} (-> /_pom_dev/<repo>/<service>), or its full URL with {{<repo>.<service>.url}}.
Switch environment without touching the URL
The frontend always calls the same-origin path /_pom_dev/api/server — it never changes. The dev-proxy is a reverse proxy: for each request it forwards /_pom_dev/<repo>/<service> to the local service by default, or to a deployed backend when a non-local profile is active. So you retarget an environment by flipping a profile (right-click the frontend service in the Services panel > Env > staging), and the browser URL — same origin, CORS-free — stays exactly the same.
Use {{<repo>.<service>.path}} (not .url) for browser calls — it's always the same-origin /_pom_dev route, so flipping profiles changes only what the dev-proxy forwards to, never what the browser requests:
yaml
repos:
web:
profiles: [local, staging]
env:
VITE_API_URL: "{{api.server.path}}" # -> /_pom_dev/api/server (same origin, always)
environments:
staging:
api.server: "https://api.acme.dev" # dev-proxy forwards there when staging is activeWebhook fan-out
Testing a feature across several branches at once? An external provider (Stripe, a Git host, an OAuth vendor) only knows one URL. Pomelo's webhook relay bridges that: a single local port receives an inbound event and fans it out to every workspace running the target service. It runs inside the app on loopback, one per machine, and routes across every open project - routes are derived from your repos and services, nothing to configure.
A request path is /<repo>/<service>/<rest...>. The relay resolves <repo>/<service> against your config — the repo by alias or name, the service by name (both are required) - strips that prefix, and forwards /<rest...> with the original query string, headers (including the provider's signature) and body unchanged.
It ACKs 200 immediately ({"ok":true,"service":"api/server","fanout":N}), then forwards the event in the background to every workspace whose service is currently listening. Each has its own database, so they process independently, and one slow or stopped branch never makes the provider retry. Stopped workspaces are skipped; a body over 32 MB is forwarded empty. The Dev Requests tab shows each delivery, so a branch that answered with an error or refused the connection stands out.
To use it, expose one public URL that forwards to http://127.0.0.1:8766, then let the provider call /<repo>/<service>/<their-path>:
bash
cloudflared tunnel --url http://127.0.0.1:8766
# or: ngrok http 8766OAuth callbacks — target one branch
Fan-out is right for events (a Stripe charge) that every branch may process. An OAuth callback (.../callback?code=...) is different: the code is single-use and must return to the one branch that started the flow, so it must not be fanned out — and it doesn't go through the relay at all.
An OAuth callback is a browser redirect, not a server-to-server call, so it needs no tunnel. Point the OAuth app's redirect URI at the workspace's dev-proxy hostname:
http://<service>.<repo>.<workspace>.localhost:8767/oauth/callbackThe browser resolves .localhost to loopback on its own and hits the dev-proxy, which reads the branch from the hostname and forwards to that one workspace. Nothing to configure — the hostname names the branch.
Requirements
The dev-proxy only routes hosts ending in .localhost, so register the .localhost URL above as an allowed redirect URI in your OAuth app (most dev/test apps allow it). Each branch is its own host — there is no single-URL-for-every-branch mode.
