Why this server exists
The Yuno Web SDK historically ships with ayuno- prefixed surface: window.Yuno, the yuno-sdk-ready event, and yuno-* CSS classes and data attributes. The white-label release introduces a parallel surface:
To verify the rename actually shipped, the SDK must be loaded from a host that is not
*.y.uno. If it loads from a Yuno origin, branded leaks would slip through unnoticed. This server is the harness: it serves the SDK from localhost:9090 (or any non-Yuno hostname you point it at) and forwards every request to the real Yuno upstreams transparently.
How it works
- The browser only ever talks to the proxy origin. No
*.y.unorequest originates from the page itself — otherwise the SDK is not really being tested against a foreign host. - All upstream domains are reachable from the proxy. SDK assets, the card-form micro-app, the 3DS micro-app, the REST API, and the WebSocket service are all separate hostnames that must each be configurable.
- CORS is owned by the proxy, not the upstream. Upstream CORS would respond with
api.y.unoorigins; the proxy strips those and writes its own, echoing the caller.
Architecture
A single Node.js process built on three libraries:express— routing and middleware for HTTP.node-fetch— outbound HTTP to upstreams, for streaming bodies and header control.http-proxy— WebSocket upgrades, because Express middleware never seesUpgraderequests.
Request lifecycle
-
CORS middleware runs first. It echoes the caller’s
Origin, handlesOPTIONSpreflight, and setsAccess-Control-Allow-Credentials: true. The proxy is the CORS authority. -
Local routes match before anything else:
/,/static/*,/whitelabel-info. These never touch an upstream. -
Backend pass-through matches
/v1/*and/v2/*(the Yuno REST surface used by the SDK) and forwards vianode-fetchtoBACKEND_URL. Hop-by-hop headers are stripped;access-control-*headers from the upstream are dropped so they cannot override the proxy’s own CORS. The browserCookieheader is also dropped on these forwards — the partner page’s localhost cookies (session, analytics, URL-valued ones, …) don’t belong on a cross-origin call toapi.y.unoand can trip the upstream WAF with a403; the API authenticates via the public API key. -
SDK asset catch-all matches every remaining
GET/HEAD. A small dispatcher (pickSdkUpstream) picks the right upstream based on path, checked top to bottom so the 3DS/card rules win first:- 3DS micro-app paths (
/challenge.html,/redirect.html,/session-id.html, and/assets/(challenge|redirect|session-id|validate-url)*) →SDK_3DS_UPSTREAM. - Card micro-app paths (
/v<semver>/pages/*,/v<semver>/assets/*) →SDK_CARD_UPSTREAM. - Static-asset paths (
/icons/*,/css/*,/brands/*,/c2p/*) →SDK_STATIC_UPSTREAM(sdk.prod.y.uno). - Icon-asset paths (
/sdk-web/*,/flags/*, and bare root brand images like/Visa.png,/boleto_logosimbolo.png) →SDK_ICONS_UPSTREAM(icons.prod.y.uno). - Everything else →
SDK_UPSTREAM.
icons.prod.y.uno/sdk.prod.y.uno, bypassing the white-label host. Recent SDK builds host-swap them onto the proxy origin (path preserved), so the proxy must forward them back out to the two CDNs by path prefix. These upstreams are always*.prod.y.unoregardless of the target environment. - 3DS micro-app paths (
-
WebSocket upgrades are handled on the underlying
http.Server, not Express./checkout-websocket-notification-ms/ws/*→BACKEND_WS_URL; everything else follows the same SDK / card / 3DS split as HTTP.
Version normalization
The main SDK bundle is versioned (/v1.7.4/main.js). Two complications:
- Partner pages may hardcode a version that the upstream no longer publishes.
- The “current” version drifts over time.
/v<x>/* SDK request to that version before sending it upstream. Card-app and 3DS paths are not rewritten — they have their own publish cadences.
SDK_MAIN_JS — pinning the SDK version
SDK_MAIN_JS decides which build of the SDK every browser loads. It does two related things:
- Template injection. The proxy serves
pages/index.htmlwith the literal__SDK_MAIN_JS__placeholder replaced by this path. A partner page can write<script src="__SDK_MAIN_JS__">and get e.g./v1.9.1/main.js. - Version normalization target. The proxy parses the
/v<x>/segment out ofSDK_MAIN_JSand rewrites any incoming/v<other>/main.js(and adjacent/v<other>/*.js,/v<other>/*.css) request to that same version. So ifSDK_MAIN_JS=/v1.9.1/main.js, a request for/v1.5.0/main.jsis silently fetched as/v1.9.1/main.jsfromSDK_UPSTREAM.
Resolution order at boot
SDK_MAIN_JS explicitly is the only way to skip the upstream fetch entirely — useful when versions.json is unreachable, you want a deterministic startup, or the upstream’s latest is not what you want to test against.
Format
<semver> matches [\d.]+(?:-[\w.]+)? — dot-separated numbers with an optional pre-release suffix.
The path must start with
/v and contain /main.js for normalization to work. If the regex does not match, the path is used for template injection but version rewriting is disabled.
Common use cases
Test a specific SDK release end-to-end:v1.9.1, and any hardcoded /v<other>/*.js in partner pages is silently rewritten to v1.9.1 against SDK_UPSTREAM.
Quiet startup (no upstream versions.json call):
SDK_UPSTREAM is a local file server that does not expose versions.json.
Reproduce a customer bug on an older version:
v1.7.4 is still published on SDK_UPSTREAM, every browser that hits the proxy loads it — no rebuild required.
Gotchas
- Card and 3DS upstreams are not affected.
SDK_MAIN_JSonly pins the main SDK bundle’s version. The card micro-app (/v<x>/pages/*) and the 3DS micro-app keep their own version segments. - Setting it disables auto-detection. If a newer SDK version is published upstream, you keep loading the pinned version until you change the env var and restart.
- Restart required. The value is read once at boot. No hot reload.
- Mismatch with upstream is silent. Pinning to a version the upstream does not publish results in a
404for/v<your-pin>/main.js. Check the network tab to spot it. - No query strings or hash. Path only.
Building a server like this
1. Bootstrap the project
Use
node-fetch@2 (CommonJS) unless you have a build step — v3 is ESM-only.2. Define your upstream map
SDK_CARD_UPSTREAM and SDK_3DS_UPSTREAM default to SDK_UPSTREAM when unset, and BACKEND_WS_URL defaults to BACKEND_URL — keep this fallback behaviour, it makes single-env testing easier. SDK_STATIC_UPSTREAM / SDK_ICONS_UPSTREAM are the exception: they default to the fixed sdk.prod.y.uno / icons.prod.y.uno CDNs (the SDK host-swaps these from *.prod.y.uno regardless of environment), not to SDK_UPSTREAM.
3. Set up CORS as the proxy’s responsibility
*) so credentialed requests work, and short-circuit preflights. When you later forward upstream responses, strip every access-control-* header from the upstream or it will override yours.
4. Filter hop-by-hop headers in both directions
Per RFC 7230 §6.1:content-length is the classic bug — node-fetch sets it, but if you also copy the original value the response truncates.
5. Forward the API surface
express.json() has already consumed the request body, so for non-GET methods you re-serialize req.body. Don’t try to req.pipe(upstream) after the body is consumed.
Drop the
Cookie header before forwarding. The partner page’s browser cookies belong to the proxy origin (session, analytics, URL-valued ones like spage) and a genuine cross-origin call to api.y.uno would never carry them — forwarding them only risks the upstream WAF rejecting the request with a 403. The API authenticates via the public API key, not cookies. (WebSocket upgrades are the exception — see step 7.)6. Catch-all proxy for SDK assets
7. Handle WebSocket upgrades on the http.Server
Express middleware does not seeUpgrade requests. Hook into the http server directly:
http-proxy negotiates ws:// vs wss:// from the target URL scheme.
8. Wire up environment variables
Usedotenv. Document per-environment values, not just defaults. For Yuno:
- SDK services follow
<service>[.<env>].y.uno—sdk-web.y.uno,sdk-web.staging.y.uno,sdk-web.dev.y.uno. - API surface uses
api[-<env>].y.uno—api.y.uno,api-staging.y.uno,api-dev.y.uno. Note the hyphen, not a dot. - The WebSocket service has no
api-prefix —<env>.y.unodirectly (y.uno,staging.y.uno,dev.y.uno).
.env.example so users don’t guess.
9. Ship a minimal landing page
Serveindex.html at / and a /whitelabel-info JSON endpoint that returns the resolved upstream config. The landing page calls the JSON endpoint and renders it — anyone debugging the proxy sees exactly what’s routed where without grepping logs.
Pitfalls and edge cases
- Route order matters. The SDK catch-all is registered last. Anything you
app.useafter it never runs. - CORS authority must be the proxy. Letting upstream
access-control-*headers through means credentialed cross-origin requests randomly fail when the upstream’s allow-list does not includelocalhost. - Hop-by-hop headers are bidirectional. Strip them on both legs.
content-lengthis the one that bites. - Body consumption is a one-shot. Once
express.json()parses a request, the underlying stream is drained. Re-serialize fromreq.bodyif you need to forward aPOST. - Streaming, not buffering, for assets.
upstream.body.pipe(res)keeps memory flat even for multi-MB bundles. - Version normalization is
SDK_UPSTREAM-only. Card and 3DS micro-apps have their own version segments — rewriting them breaks the upstream URL. The static / icons CDNs aren’t versioned, so they aren’t normalized either. - Static CDN assets are host-swapped too. Recent SDK builds rewrite their hard-coded
icons.prod.y.uno/sdk.prod.y.unoasset URLs onto the white-label host, so the proxy must forward/icons,/css,/brands,/c2p→sdk.prod.y.unoand/sdk-web,/flags, bare/*.png→icons.prod.y.uno. Older SDK builds load these straight from the CDN and never reach the proxy. Exercising this end-to-end needs a recent SDK build and a partner page that inits with{ apiUrl: '<proxy origin>' }(orassetUrl). - Don’t forward the partner page’s cookies to the API. Strip
Cookieon/v1//v2forwards — the browser’s localhost cookies (session, analytics, URL-valued ones likespage) don’t belong on a cross-origin call toapi.y.unoand can trip the upstream WAF with a403. The API authenticates via the public API key. - WebSocket auth is whatever headers
http-proxysees. Do not stripcookieorauthorizationfrom WS upgrades — the cookie strip applies only to the HTTP/v1//v2backend forwards. .envis gitignored. Always keep.env.examplein sync — it is the spec for anyone cloning the repo.
Verification checklist
Once your proxy is running, verify in browser DevTools:- Network tab — no requests to any
*.y.unohost originate from the merchant page. All traffic is to the proxy origin. window.SdkPaymentsis defined;window.Yunois either undefined or a legacy alias.sdk-payments-readyevent fires ondocumentafter the SDK boots.- No
yuno-*DOM tokens in the rendered output, except for the trust-mark allow-list (yuno-badge,secure-by-yuno,yuno-typography-provider,yuno-c2p,yuno-redirect,yuno-debug,data-yuno-session-id).
Reference implementation
The full source lives in theyuno-sdk-web/white-label-proxy-server repository. About 320 lines in server.js — the whole thing fits in one file because there is no business logic, just routing and header hygiene.
See also
- White Label (Web SDK) — the SDK-side overrides this harness verifies.
- yuno-payments/yuno-sdk-web/tree/main/white-label-proxy-server — full source.
- RFC 7230 §6.1 — Connection-specific headers
- http-proxy WebSocket docs