feat(connect-ui): non-root base path via relative base + runtime basepath#6802
feat(connect-ui): non-root base path via relative base + runtime basepath#6802macko911 wants to merge 35 commits into
Conversation
Self-hosters behind a reverse proxy that routes by path (everything under a shared prefix, no per-service subdomain) could not serve Connect UI: the bundle was built with a root-relative base, so assets resolved at the origin root and the app failed to load. Build the bundle with a placeholder base and rewrite it to the configured path at container start (derived from NANGO_PUBLIC_CONNECT_URL, or an explicit NANGO_CONNECT_UI_BASE_PATH override; defaults to "/"). The prebuilt image needs no rebuild. The router basepath is read from import.meta.env.BASE_URL so client-side routing works under the sub-path.
Address review feedback on base path handling: - Insert the base path with a function replacer so a literal `$` in it isn't read as a replacement pattern ($&, $1, ...). - Strip any query or fragment mistakenly passed in the explicit NANGO_CONNECT_UI_BASE_PATH override. - Extract the BASE_URL -> router basepath derivation into a pure function and cover it, resolveBasePath, and the query/fragment case with tests.
The connect_ui deploy workflow builds the bundle and syncs it straight to static hosting (e.g. connect.nango.dev). Since the build now emits a placeholder base, that upload would ship unresolved placeholders and break the hosted UI. Run set-base-path.js after the build so the placeholder is rewritten to "/" (the origin root) before upload. Also clarify the surrounding comments: the rewrite is mandatory before serving (not a no-op for root), and note the docs/self-hoster follow-up.
Document serving Connect UI under a non-root base path via NANGO_PUBLIC_CONNECT_URL (with the NANGO_CONNECT_UI_BASE_PATH override) and the reverse-proxy prefix-stripping requirement.
Expose the base-path rewrite as `npm run -w @nangohq/connect-ui set-base-path` and use it in the entrypoint and the connect_ui deploy workflow. Gives self-hosters who serve the static bundle themselves an ergonomic command instead of a raw node invocation.
Self-hosters serving Connect UI's static files from their own host must run the base-path rewrite before uploading; the built assets carry a placeholder base until then.
The base path is written verbatim into the built HTML/CSS/JS, so a malformed value could emit broken or unsafe asset URLs. Reject anything outside a safe URL-path character set so misconfiguration fails loudly at rewrite time instead. Also guards the JS template-literal context, where a "$" or backtick in the base could otherwise break out of the string.
Run the base-path rewrite as the first step of serve:unsafe so any deployment that serves the static bundle with that command works — not just the FLAG_SERVE_CONNECT_UI entrypoint. This covers self-hosters who run Connect UI as a standalone container (command = serve:unsafe) without the server entrypoint, and removes the manual pre-step for them. The entrypoint no longer needs a separate set-base-path call.
The rewrite runs for both FLAG_SERVE_CONNECT_UI and a standalone serve:unsafe container; the manual step is only for uploading dist to a static host/CDN.
The connect_link returned by POST /connect/sessions and .../reconnect is the shareable "open Connect UI" link. It was built as connectUrl plus the session token, ignoring any base path, so a self-hoster serving Connect UI under a sub-path got a broken link even when the embedded iframe worked. Build it via buildConnectUiSessionLink, which applies the base path from NANGO_PUBLIC_CONNECT_URL (or NANGO_CONNECT_UI_BASE_PATH).
The rewrite kept a pristine .dist-template copy so it could be re-run with a different base path in place. Every deployment starts from a freshly built dist (ephemeral containers, deploy builds fresh), and the base path is fixed per container via env vars, so that never happens. Simplify to a one-shot placeholder replace. Also move the pure resolveBasePath tests to a node test and split vitest into browser and node projects so the script has real coverage.
Validate NANGO_CONNECT_UI_BASE_PATH in the ENVS schema so a malformed value fails at startup instead of throwing when a connect session is created. buildConnectUiSessionLink now takes the connect URL and base path as arguments, and the controllers pass the server's validated envs rather than the util reading process.env. Also cross-reference the base-path contract between the util and the connect-ui rewrite script, which can't share code across the package boundary.
Extract the base-path source (override → connect URL path → root) into a small helper with early returns instead of reassigning a `raw` variable, and drop the TODO comment (tracked on the PR/ticket instead).
The browser project's include already scopes to src/**/*.test.tsx, but exclude the node test glob explicitly (preserving vitest's defaults) so a node script test can't be pulled into the chromium runner.
Deriving the base path from NANGO_PUBLIC_CONNECT_URL silently fell back to root when the URL was set but unparseable, masking a misconfiguration. Throw instead so it fails loudly at startup, consistent with the base path character validation.
Drop NANGO_CONNECT_UI_BASE_PATH. The base path is by definition the path of NANGO_PUBLIC_CONNECT_URL — the frontend SDK loads the Connect UI iframe from that URL, so it must already include any sub-path. A separate override could only duplicate or drift from it. This also simplifies the session link: since the public URL already includes the sub-path, buildConnectUiSessionLink just appends the session token (the base-path normalization it did was only needed for the override), and the controllers pass the validated envs value.
The startup rewrite edits files in dist in place, so a read-only root filesystem would fail before serving.
Keep it, but out of the rewrite paragraph so it's easier to spot.
The session link is just NANGO_PUBLIC_CONNECT_URL + the session token,
and that URL already includes any sub-path, so master's inline
`new URL(`${connectUrl}?session_token=...`)` was already correct. Remove
the buildConnectUiSessionLink util and revert the two controllers.
A read-only root filesystem still works if dist is a writable volume or tmpfs; only dist needs to be writable.
An empty tmpfs/volume mounted over dist shadows the image's prebuilt assets, so the rewrite finds nothing. State the requirement (dist must be writable at runtime) without prescribing a workaround that breaks.
Replace the placeholder-base rewrite with vite base './' and a router basepath derived from document.baseURI at load: assets resolve relative to the document URL, so the same prebuilt bundle serves from any base path with no startup rewrite, no writable dist, and no CI step. URL shapes are unchanged. An inline guard in index.html self-heals a missing trailing slash at the base root, and serve:unsafe gains -s so in-app refreshes at deep routes keep working.
Relative asset paths only resolve when the document path ends with a slash. Normalize everywhere Nango produces a Connect UI document URL: the frontend SDK iframe, the dashboard preview iframe, and the server-generated connect_link (new buildConnectUiSessionLink helper). The CSP sources get the same treatment since CSP path matching is exact without a trailing slash.
The telemetry CORS check and the dashboard preview message listener compared against NANGO_PUBLIC_CONNECT_URL verbatim. Browser origins never contain a path, so both silently failed once the URL included a sub-path.
Preview Deploy
|
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Workflows to automatically generate PRs for you. |
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Workflows to automatically generate PRs for you. |
Replace the route-allowlist guard with an onerror hook on the entry script: retry with a trailing slash only when the asset load actually failed. No route names to keep in sync with index.html, and documents served at real routes are never misredirected. Vite drops extra attributes when rewriting the entry script tag, so a small build plugin re-attaches the hook.
Drop the redundant CORS comment; explain why the webapp preview normalization stays inline (@nangohq/utils reads process.env on import and is not a webapp dependency).
There was a problem hiding this comment.
6 issues found and verified against the latest diff
Confidence score: 3/5
- The highest-risk path is deep-link refresh handling across
packages/connect-ui/src/lib/routes.ts,packages/connect-ui/vite.config.ts, andpackages/connect-ui/index.html: trailing-slash URLs like/nango/connect/integrations/can resolve under the wrong base and load no entry module, so users may see the root page or a blank UI on refresh. Align route normalization and asset base/retry behavior for already-trailing-slash deep routes before merging. - In
packages/server/lib/middleware/security.ts, removing slashless Connect UI iframe sources can block valid existing embed URLs before client-side self-healing runs, causing Connect UI not to render in host apps. Keep both configured slashless and normalized prefix entries inframeSrcto preserve compatibility. - In
packages/utils/lib/connect-ui.ts,NANGO_PUBLIC_CONNECT_URLvalues with?/#are treated as part of the base path, so generated session links and CSP base sources can inherit unintended URL state. Strip query/fragment components when deriving the Connect UI base before merging. docs/guides/platform/self-hosting.mdxcurrently overstates slashless recovery coverage; as implemented it only recovers afterindex.htmlis served and an entry asset fails, not initial document HTTP failures. Update the guide wording so operators set correct expectations and troubleshoot the right layer.
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="docs/guides/platform/self-hosting.mdx">
<violation number="1" location="docs/guides/platform/self-hosting.mdx:223">
P2: Slashless recovery only works after `index.html` has been served and its relative entry asset fails; it cannot recover an HTTP failure for the initial document itself. Please describe this as entry-asset recovery and retain the requirement that the host serve the base URL/fallback.</violation>
</file>
<file name="packages/utils/lib/connect-ui.ts">
<violation number="1" location="packages/utils/lib/connect-ui.ts:9">
P2: A `NANGO_PUBLIC_CONNECT_URL` containing `?` or `#` is treated as part of the Connect UI base, so generated session links retain unrelated URL state and the CSP base source is not limited to the configured path. Clearing `url.search` and `url.hash` before normalizing the pathname keeps the override as a base URL only.
(Based on your team's feedback about stripping query and fragment components from the Connect UI base override.) [FEEDBACK_USED].</violation>
</file>
<file name="packages/connect-ui/src/lib/routes.ts">
<violation number="1" location="packages/connect-ui/src/lib/routes.ts:40">
P2: A deep-route refresh with a trailing slash is mounted under the wrong basepath: `/nango/connect/integrations/` yields `/nango/connect/integrations`, so the router can render the root page rather than `IntegrationsList`. Base-path derivation needs to canonicalize or otherwise distinguish trailing-slash route suffixes before creating the router.</violation>
</file>
<file name="packages/connect-ui/index.html">
<violation number="1" location="packages/connect-ui/index.html:13">
P2: An entry-module failure on a supported deep route such as `/nango/connect/integrations` is rewritten to `/nango/connect/integrations/` because this condition does not exclude known routes. That trailing-slash URL resolves the relative assets under `integrations/assets`, so recovery can turn an asset failure into a persistent blank page; the route allowlist should be applied here.</violation>
</file>
<file name="packages/connect-ui/vite.config.ts">
<violation number="1" location="packages/connect-ui/vite.config.ts:30">
P2: Refreshing a trailing-slash deep URL such as `/nango/connect/integrations/` leaves the UI blank: `base: './'` resolves assets beneath the route directory, and the retry handler does not normalize an already-trailing-slash path. Normalizing trailing-slash route URLs (or using an asset base independent of the current route) would avoid this failure.</violation>
</file>
<file name="packages/server/lib/middleware/security.ts">
<violation number="1" location="packages/server/lib/middleware/security.ts:40">
P2: Slashless Connect UI iframe URLs are now blocked before the inline trailing-slash guard can self-heal them. Keeping both the configured slashless source and the normalized prefix source in `frameSrc` would preserve compatibility with older SDKs while still allowing the subpath's assets and routes.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
|
|
||
| Your reverse proxy must strip the base path prefix before forwarding to Connect UI: a request for `/nango/connect/assets/app.js` must reach Connect UI as `/assets/app.js`. | ||
|
|
||
| URLs loading Connect UI at a sub-path must end with a trailing slash (`…/connect/`) — the Nango SDK and the generated links produce this automatically, and Connect UI itself retries with a trailing slash if the initial load fails. |
There was a problem hiding this comment.
P2: Slashless recovery only works after index.html has been served and its relative entry asset fails; it cannot recover an HTTP failure for the initial document itself. Please describe this as entry-asset recovery and retain the requirement that the host serve the base URL/fallback.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At docs/guides/platform/self-hosting.mdx, line 223:
<comment>Slashless recovery only works after `index.html` has been served and its relative entry asset fails; it cannot recover an HTTP failure for the initial document itself. Please describe this as entry-asset recovery and retain the requirement that the host serve the base URL/fallback.</comment>
<file context>
@@ -208,6 +208,22 @@ Nango Connect is available by default at `http://localhost:3009`.
+
+Your reverse proxy must strip the base path prefix before forwarding to Connect UI: a request for `/nango/connect/assets/app.js` must reach Connect UI as `/assets/app.js`.
+
+URLs loading Connect UI at a sub-path must end with a trailing slash (`…/connect/`) — the Nango SDK and the generated links produce this automatically, and Connect UI itself retries with a trailing slash if the initial load fails.
+
+If you serve Connect UI's built `dist` from your own static host, enable SPA fallback (rewrite unknown paths to `index.html`) so in-app refreshes work.
</file context>
| URLs loading Connect UI at a sub-path must end with a trailing slash (`…/connect/`) — the Nango SDK and the generated links produce this automatically, and Connect UI itself retries with a trailing slash if the initial load fails. | |
| URLs loading Connect UI at a sub-path should end with a trailing slash (`…/connect/`) — the Nango SDK and the generated links produce this automatically. The built UI can recover when the slashless document is served but its relative entry asset fails; otherwise the host must serve the base URL and provide the SPA fallback described below. |
| * or without one, so normalize before using it as a document URL. | ||
| */ | ||
| export function connectUrlAsDocumentBase(raw: string = connectUrl): URL { | ||
| const url = new URL(raw); |
There was a problem hiding this comment.
P2: A NANGO_PUBLIC_CONNECT_URL containing ? or # is treated as part of the Connect UI base, so generated session links retain unrelated URL state and the CSP base source is not limited to the configured path. Clearing url.search and url.hash before normalizing the pathname keeps the override as a base URL only.
(Based on your team's feedback about stripping query and fragment components from the Connect UI base override.) .
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/utils/lib/connect-ui.ts, line 9:
<comment>A `NANGO_PUBLIC_CONNECT_URL` containing `?` or `#` is treated as part of the Connect UI base, so generated session links retain unrelated URL state and the CSP base source is not limited to the configured path. Clearing `url.search` and `url.hash` before normalizing the pathname keeps the override as a base URL only.
(Based on your team's feedback about stripping query and fragment components from the Connect UI base override.) .</comment>
<file context>
@@ -0,0 +1,20 @@
+ * or without one, so normalize before using it as a document URL.
+ */
+export function connectUrlAsDocumentBase(raw: string = connectUrl): URL {
+ const url = new URL(raw);
+ if (!url.pathname.endsWith('/')) {
+ url.pathname += '/';
</file context>
| // the base root ('{base}/') or at a depth-1 route under it ('{base}/integrations'), and no route ends | ||
| // with a trailing slash, so resolving '.' against document.baseURI yields the base path. | ||
| export function basepathFromDocumentBaseURI(baseURI: string): string | undefined { | ||
| const pathname = new URL('.', baseURI).pathname; |
There was a problem hiding this comment.
P2: A deep-route refresh with a trailing slash is mounted under the wrong basepath: /nango/connect/integrations/ yields /nango/connect/integrations, so the router can render the root page rather than IntegrationsList. Base-path derivation needs to canonicalize or otherwise distinguish trailing-slash route suffixes before creating the router.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/connect-ui/src/lib/routes.ts, line 40:
<comment>A deep-route refresh with a trailing slash is mounted under the wrong basepath: `/nango/connect/integrations/` yields `/nango/connect/integrations`, so the router can render the root page rather than `IntegrationsList`. Base-path derivation needs to canonicalize or otherwise distinguish trailing-slash route suffixes before creating the router.</comment>
<file context>
@@ -31,4 +31,15 @@ export const goRouter = createRoute({
+// the base root ('{base}/') or at a depth-1 route under it ('{base}/integrations'), and no route ends
+// with a trailing slash, so resolving '.' against document.baseURI yields the base path.
+export function basepathFromDocumentBaseURI(baseURI: string): string | undefined {
+ const pathname = new URL('.', baseURI).pathname;
+ // TanStack Router wants the basepath without a trailing slash; root stays default.
+ return pathname === '/' ? undefined : pathname.replace(/\/$/, '');
</file context>
| // served at real routes (deep-route refresh via SPA fallback) are never misredirected. | ||
| window.__nangoBasePathRetry = function () { | ||
| var path = location.pathname; | ||
| if (!path.endsWith('/')) { |
There was a problem hiding this comment.
P2: An entry-module failure on a supported deep route such as /nango/connect/integrations is rewritten to /nango/connect/integrations/ because this condition does not exclude known routes. That trailing-slash URL resolves the relative assets under integrations/assets, so recovery can turn an asset failure into a persistent blank page; the route allowlist should be applied here.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/connect-ui/index.html, line 13:
<comment>An entry-module failure on a supported deep route such as `/nango/connect/integrations` is rewritten to `/nango/connect/integrations/` because this condition does not exclude known routes. That trailing-slash URL resolves the relative assets under `integrations/assets`, so recovery can turn an asset failure into a persistent blank page; the route allowlist should be applied here.</comment>
<file context>
@@ -2,6 +2,19 @@
+ // served at real routes (deep-route refresh via SPA fallback) are never misredirected.
+ window.__nangoBasePathRetry = function () {
+ var path = location.pathname;
+ if (!path.endsWith('/')) {
+ location.replace(path + '/' + location.search + location.hash);
+ }
</file context>
| // inline script in index.html) and all routes staying depth-1 without trailing slashes — from a | ||
| // deep-route document ('{base}/integrations'), relative assets resolve one level up, back to the | ||
| // base. The dev server stays at root. | ||
| base: command === 'build' ? './' : '/', |
There was a problem hiding this comment.
P2: Refreshing a trailing-slash deep URL such as /nango/connect/integrations/ leaves the UI blank: base: './' resolves assets beneath the route directory, and the retry handler does not normalize an already-trailing-slash path. Normalizing trailing-slash route URLs (or using an asset base independent of the current route) would avoid this failure.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/connect-ui/vite.config.ts, line 30:
<comment>Refreshing a trailing-slash deep URL such as `/nango/connect/integrations/` leaves the UI blank: `base: './'` resolves assets beneath the route directory, and the retry handler does not normalize an already-trailing-slash path. Normalizing trailing-slash route URLs (or using an asset base independent of the current route) would avoid this failure.</comment>
<file context>
@@ -5,11 +5,30 @@ import react from '@vitejs/plugin-react-swc';
+ // inline script in index.html) and all routes staying depth-1 without trailing slashes — from a
+ // deep-route document ('{base}/integrations'), relative assets resolve one level up, back to the
+ // base. The dev server stays at root.
+ base: command === 'build' ? './' : '/',
+ plugins: [react(), svgr(), tailwindcss(), basePathRetry()] as UserConfig['plugins'],
resolve: {
</file context>
| hostApi, | ||
| hostWs.href, | ||
| connectUrl, | ||
| connectUrlCspSource, |
There was a problem hiding this comment.
P2: Slashless Connect UI iframe URLs are now blocked before the inline trailing-slash guard can self-heal them. Keeping both the configured slashless source and the normalized prefix source in frameSrc would preserve compatibility with older SDKs while still allowing the subpath's assets and routes.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/server/lib/middleware/security.ts, line 40:
<comment>Slashless Connect UI iframe URLs are now blocked before the inline trailing-slash guard can self-heal them. Keeping both the configured slashless source and the normalized prefix source in `frameSrc` would preserve compatibility with older SDKs while still allowing the subpath's assets and routes.</comment>
<file context>
@@ -33,15 +37,23 @@ export function securityMiddlewares(): RequestHandler[] {
hostApi,
hostWs.href,
- connectUrl,
+ connectUrlCspSource,
'https://*.posthog.com',
'https://*.stripe.com',
</file context>
Problem
Connect UI's built assets are referenced from the domain root (
/assets/…), so self-hosters serving it under a shared path prefix (e.g.example.com/nango/connect/) get 404s and a blank page. PR #6765 solves this with a build-time placeholder rewritten indistat container startup, but that requires a writabledistat runtime, leaves the artifact broken until the rewrite runs, and goes stale if the env changes on a container restart.Solution
Build with a relative base and derive the router basepath from the document URL at load, so the same prebuilt bundle serves from any base path with zero rewriting — no writable filesystem, no CI step, nothing baked in, and URL shapes unchanged from today.
base: './': assets resolve relative to the document URL.new URL('.', document.baseURI)— works at the base root and at depth-1 route refreshes;import.meta.env.BASE_URLis useless with a relative base (it's the literal'./').serve:unsafegains-s(SPA fallback) so an in-app refresh at/integrationskeeps working; custom static hosts need the equivalent rewrite-to-index rule.onerrorhook on the entry script redirects to the slashed URL only when the asset load actually failed — no route names inindex.htmlto keep in sync, no false redirects on deep-route refreshes. (A slashless sub-path URL is e2e-confirmed to white-screen without it.)connect_link(newbuildConnectUiSessionLinkin utils), and CSP sources (path matching is exact without a trailing slash).Originheader against the full URL, and the dashboard preview's message listener did the same.set-base-pathscripts + tests, the deploy workflow step, and the writable-dist docs caveats (net −180 lines).Fixes NAN-6242
Testing
/integrationsunder the prefix, and root deployment all boot with assets 200.ts-build, connect-ui browser tests (incl.basepathFromDocumentBaseURIcases pinning root, sub-path, deep-refresh, and the documented slashless misconfiguration), utils unit tests, and a build inspection (relative refs, no placeholder) all pass.Trade-offs vs the sibling prototype