Proxy (BFF)
HTTP + WebSocket reverse proxy
The Proxy is a backend-for-frontend you run alongside your web app. It forwards both HTTP and WebSocket traffic to your services, rewrites paths, and — most importantly — injects auth tokens from httpOnly cookies so the browser never holds a credential.
It solves the awkward parts of a split frontend/IDP deployment: cross-origin cookies, WebSocket auth (which can't send custom headers from the browser), and silent token refresh on 401. You describe targets; the proxy handles cookie parsing, JWT extraction, path matching and reconnection.
Spin up both protocols at once with createProxyServer (a ProxyServerConfig with http + ws), or mount the HTTP and WS handlers individually inside an existing server. startWsProxyServer is a WebSocket-only convenience server. Everything imports from the nucleus-core-ts/proxy subpath (Bun-only), not the package root.
The proxy server#
ProxyServerConfig binds an HTTP proxy and a WS proxy under one port. createHttpProxyHandler and createWsProxyHandler expose the same logic as standalone handlers when you want to embed them.
1import { createProxyServer } from "nucleus-core-ts/proxy";2 3// createProxyServer serves HTTP + WS on one Bun port.4// (startWsProxyServer is WS-only and ignores an http config.)5await createProxyServer({6 port: 4000,7 http: {8 targets: [9 {10 url: process.env.IDP_URL!,11 paths: ["/auth/*", "/api/*"],12 injectTokenFromCookie: { cookieName: "access_token", headerName: "authorization" },13 tokenRefresh: { enabled: true, idpBaseUrl: process.env.IDP_URL!, retryOn401: true },14 },15 ],16 },17 ws: {18 targets: [19 {20 url: process.env.IDP_URL!,21 paths: ["/api/events/*"],22 injectTokenFromCookie: { cookieName: "access_token", queryParam: "token" },23 },24 ],25 },26});port / hostnamenumber / stringOptionalWhere the proxy listens. The WS-only startWsProxyServer listens on 4001 by default (hostname 0.0.0.0) when run separately.
4000 / 0.0.0.0devbooleanOptionalDevelopment mode — supplies the DEFAULT debug-logging flag for both the HTTP and WS handlers. Each of http/ws also accepts its own `debug` boolean (HttpProxyConfig.debug / WsProxyConfig.debug) that overrides this per-handler (debug: config.http.debug ?? dev); the flag gates info-level proxy logging.
NODE_ENV !== 'production'httpHttpProxyConfigOptionalHTTP forwarding config: an array of targets plus onRequest/onResponse/onError hooks for logging and metrics.
wsWsProxyConfigOptionalWebSocket forwarding config: targets, optional HMR allowance, unknown-path rejection, ping interval, plus onOpen(path, target) / onClose(path) / onError(error, path) lifecycle hooks for logging and metrics.
ws.allowHmrbooleanOptionalBypasses the WS proxy for Next.js HMR sockets ('/_next/webpack-hmr'). Set false to route HMR paths through the proxy like any other target.
truews.rejectUnknownbooleanOptionalA WS handshake whose path matches no target is rejected with a 404. Set false to let unmatched paths fall through instead of being closed.
truews.pingIntervalMsnumberOptionalKeep-alive ping interval in ms for proxied WS connections. 0 (the default) disables the periodic ping; set a positive value to ping the socket on that interval.
0envPath / onReadystring / functionsOptionalOptional env file to load, plus onReady({ port, hostname }) fired when the server is listening. Note: ProxyServerConfig.onError is declared on the type but NOT wired — createProxyServer never calls it and Bun.serve is given no error handler; use the per-request http/ws onError hooks (HttpProxyConfig.onError / WsProxyConfig.onError) for error handling.
Targets & routing#
Each target maps a set of glob paths to an upstream URL. paths are matched with glob-to-regex, pathRewrite reshapes the forwarded path, and changeOrigin rewrites the Host header. The same shape exists for HTTP and WS, with a few protocol-specific extras.
urlstringRequiredThe upstream service this target forwards to.
pathsstring[]RequiredGlob patterns that route to this target (e.g. '/auth/*', '/api/*'). First match wins.
Example: ["/auth/*", "/api/*"]
pathRewriteRecord<string, string>OptionalRewrite matched paths before forwarding (e.g. strip a '/api' prefix the upstream doesn't expect).
changeOriginbooleanOptionalRewrite the Host header to the target's host (and, for WS, the Origin) — needed by many upstreams. Set false to DROP the Host override: the HTTP proxy deletes the Host header and lets the upstream fetch derive it from the target URL (the original client Host is not forwarded). For WS, changeOrigin=false leaves Host unset but still forwards the original Origin.
trueheadersRecord<string, string>OptionalStatic headers added to every forwarded request on this target.
HTTP: timeoutnumberOptionalHTTP-target only (not on WsProxyTarget). Upstream request timeout in ms; the proxy aborts to a 504 Gateway Timeout when exceeded. Any non-timeout upstream failure (connection refused, DNS error, etc.) instead returns 502 Bad Gateway with the underlying error message as its body; both branches also invoke HttpProxyConfig.onError with the resulting status (504 or 502).
30000HTTP: followRedirectsbooleanOptionalHTTP-target only (not on WsProxyTarget). When true the proxy follows upstream 3xx redirects; false forwards them verbatim (redirect: 'manual').
trueauthorize (HttpProxyTarget.authorize)claim-gated reverse proxyOptionalEnforce nucleus claims on a non-nucleus upstream: serviceId, manifest { url, roleClaimsUrl (required when claimsMode='resolve'), token, refreshSec }, jwt { secret, cookieName, headerName }, claimsMode 'embed' (default) | 'resolve', and mode 'enforce' (default) | 'audit'. Also: onUnmapped 'allow' | 'deny' (default 'deny' — how to treat a request whose path matches no discovered endpoint), publicPaths (glob paths skipped by the claim gate), and godminRole (default 'godmin' — a bearer of this role bypasses the gate, including quotaSuppression). quotaSuppression.url is the opt-in ClaimGuard gate: the proxy fetches suppressed claims from that URL, subtracts them, and returns HTTP 429 with reason 'claim_guard' when a guard fires — and this 429 fires EVEN in audit mode (every other would-block only logs while auditing). See config.authorization.endpointDiscovery in the Authorization docs.
WS: securebooleanOptionalFor a WebSocket target, validates the upstream TLS certificate on the proxy→backend connection. tokenRefresh on a WS target runs a PRE-handshake refresh (when enabled + a refresh cookie is present + the access cookie is missing/expiring).
trueCookie → token injection & refresh#
This is why the proxy exists. injectTokenFromCookie lifts a token out of an httpOnly cookie and re-presents it as a header (HTTP) or query param (WS, which can't set headers from the browser). injectUserIdFromJwt HS256-verifies the JWT and forwards the verified subject as x-user-id. tokenRefresh transparently refreshes an expired token and retries the request.
injectTokenFromCookie.cookieNamestringOptionalThe cookie to read the token from, with optional fallbackCookieNames for older names.
injectTokenFromCookie.headerName / queryParamstringOptionalWhere to put the token — this distinguishes the transport LEG, not the protocol. The browser can't set headers on a WS handshake, so a query param carries the token on the browser→proxy leg; a WS target may ALSO set headerName so the proxy forwards the token as a header on the proxy→backend leg. For HTTP, headerName (e.g. 'authorization') is used throughout.
injectUserIdFromJwt{ cookieName; headerName?; secret? }OptionalHS256-verify the JWT in a cookie and forward its subject as x-user-id (or a custom header) — lets upstreams trust an identity without re-parsing tokens. HTTP targets only — injectUserIdFromJwt is a field on HttpProxyTarget and is read only in the HTTP handler; WsProxyTarget has no such field, so WS upstreams receive no x-user-id. The token is cryptographically verified with secret (falling back to authorize.jwt.secret); any inbound client-supplied header is stripped first, and with no verify secret the header is left unset (fail-closed), never trusting a raw sub.
tokenRefresh.enabled / idpBaseUrlboolean / stringOptionalTurn on silent refresh and point it at the IDP. Enabling it does TWO things. (1) A PROACTIVE pre-request refresh, gated on enabled alone (independent of retryOn401): when the request carries no access cookie but a refresh cookie IS present, the proxy refreshes BEFORE forwarding — and if that refresh fails it returns HTTP 401 'Token refresh failed. Please login again.' to the client WITHOUT ever contacting the upstream. This is the primary path for SSR requests that arrive without an access cookie. (2) The REACTIVE retry-on-401 governed by retryOn401 (below). WS targets run their own pre-handshake variant of the proactive path.
tokenRefresh.retryOn401booleanOptionalOn a 401, refresh the token and replay the original request once (the body is buffered for the replay). maxRefreshRetries is declared on the type but currently unused — the reactive retry always runs at most once.
truetokenRefresh.refreshCookieName / accessCookieNamestringOptionalWhich cookies hold the refresh and access tokens, so rotated values can be re-set after a refresh.
tokenRefresh.refreshEndpointstringOptionalThe IDP path (appended to idpBaseUrl) the proxy POSTs to when it needs a new token.
'/auth/refresh'tokenRefresh.refreshTimeoutnumberOptionalAborts the refresh fetch after this many ms so a hung IDP can't stall the request.
10000tokenRefresh.onRefreshSuccess / onRefreshFailurefunctionsOptionalOptional callbacks fired after a refresh completes — onRefreshSuccess(path) and onRefreshFailure(path, error) — for logging or metrics.
Related sections