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:
| Legacy | White-label |
|---|---|
window.Yuno | window.SdkPayments |
yuno-sdk-ready event | sdk-payments-ready event |
yuno-* DOM tokens | sdk-payments-* DOM tokens |
*.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
-
Base-path strip runs before everything else, and only when
BASE_PATHis set. It rewritesreq.url/req.originalUrlto drop the configured sub-path so all downstream matching and forwarding sees root-relative paths. A no-op (not even registered) for a root mount. SeeBASE_PATH— sub-path mounting. -
CORS middleware runs next. 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.4/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.4/main.js, a request for/v1.5.0/main.jsis silently fetched as/v1.9.4/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.
| Value | When to use it |
|---|---|
/v1.9/main.js | Pin to a minor line (whatever the upstream publishes there) |
/v1.9.4/main.js | Pin to an exact patch — most common |
/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.4, and any hardcoded /v<other>/*.js in partner pages is silently rewritten to v1.9.4 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.
BASE_PATH — sub-path mounting
A partner gateway is often mounted under a sub-path rather than at the origin root. Zuora, for example, serves the SDK from https://host/hosted-payment-methods/hosted-payment-form/orchestrator instead of https://host/. Recent SDK builds preserve that prefix on every asset, API, and WebSocket request — the configured apiUrl / assetUrl carry it — so a request for an icon arrives as …/orchestrator/sdk-web/foo.svg, not /sdk-web/foo.svg.
Without help, those prefixed paths would never match the proxy’s root-level routing. BASE_PATH closes the gap: set it to the sub-path the proxy is “mounted” under, and the proxy strips the prefix from every incoming request — local routes, the /v1–/v2 backend forward, the asset catch-all, and WebSocket upgrades — before routing. The existing root-level rules then match unchanged.
Leave it empty for a root mount (the default — behaviour is identical to not having the feature).
What it changes
- Prefix strip. A top middleware rewrites
req.url/req.originalUrlto dropBASE_PATHso everything downstream operates on root-relative paths. WebSocket upgrades get the same strip on the underlyinghttp.Server. - Template injection. The
__SDK_MAIN_JS__placeholder is emitted with the prefix, so the served landing page points at<BASE_PATH>/v<x>/main.js. /whitelabel-inforeports the resolvedbasePath(ornullat root) alongside the upstream config.
Format
Partner page setup
WhenBASE_PATH is set, the partner page must carry the same prefix in two places:
- Load the bundle from
http://localhost:9090<BASE_PATH>/v<ver>/main.js. - Initialize with
apiUrl/assetUrl=http://localhost:9090<BASE_PATH>.
Versioned
assetUrl. You can pin a bundle version on assetUrl (…/orchestrator/v1.0). The SDK uses that path for JS chunks but strips the trailing /v<semver> when resolving host-swapped CDN assets (icons and fonts aren’t versioned), so both assetUrl = …/orchestrator and assetUrl = …/orchestrator/v1.0 route icons correctly.Gotchas
- The prefix lives in the URL, not a header. Both the bundle
<script src>and theapiUrl/assetUrlmust includeBASE_PATH, or requests land at the root and the strip is never exercised. - Strip is exact-prefix. Only
BASE_PATH,BASE_PATH/…, andBASE_PATH?…are rewritten. A path that merely resembles the prefix is left alone. - WebSocket upgrades are stripped too — on the
http.Serverupgradehandler, since Express middleware never sees them.
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
| Upstream var | Owns these paths |
|---|---|
SDK_UPSTREAM | /v<x>/main.js and everything not matched below |
SDK_CARD_UPSTREAM | /v<x>/pages/*, /v<x>/assets/* |
SDK_3DS_UPSTREAM | /challenge.html, /redirect.html, /session-id.html, /assets/(challenge|redirect|session-id|validate-url)* |
SDK_STATIC_UPSTREAM | /icons/*, /css/*, /brands/*, /c2p/* (host-swapped CDN assets) |
SDK_ICONS_UPSTREAM | /sdk-web/*, /flags/*, bare root brand images (/Visa.png, …) |
BACKEND_URL | /v1/*, /v2/* (REST API) |
BACKEND_WS_URL | /checkout-websocket-notification-ms/ws/{payment,enrollment} (WebSocket only) |
BASE_PATH | (optional) sub-path the proxy is mounted under, stripped before routing; empty = root |
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.
If you need to emulate a partner gateway mounted under a sub-path, register a strip middleware before all routes that rewrites
req.url / req.originalUrl to drop BASE_PATH (and apply the same strip on the http.Server upgrade handler). Every route below then sees root-relative paths. Skip it entirely when BASE_PATH is empty. See BASE_PATH — sub-path mounting.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 350 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