Skip to content

fix(web-ui): resolve the base path at runtime for reverse proxies - #6096

Open
caioocesar wants to merge 1 commit into
SunshineStream:masterfrom
caioocesar:fix/web-ui-reverse-proxy-base
Open

caioocesar wants to merge 1 commit into
SunshineStream:masterfrom
caioocesar:fix/web-ui-reverse-proxy-base

Conversation

@caioocesar

@caioocesar caioocesar commented Nov 13, 2026 •

Copy link
Copy Markdown

Description

Since the single page application refactor (#5731), the web UI is built with base: '/' and index.html hard-codes <base href="/" />. Assets, API requests and router links therefore always resolve against the domain root. When a reverse proxy serves the UI under a sub-path (for example https://example.com/sunshine/ → https://host:47990/), the browser requests https://example.com/assets/index-*.js and the UI does not load. The multi-page UI supported this through relative paths (#3173).

Changes:

  • index.html: replace the fixed <base href="/" /> with a small inline script that derives the base path from the current URL. It drops the last path segment and, for routes visited with a trailing slash (the server accepts /logout/ and /welcome/), also drops the known route name. Examples: / → /, /config → /, /logout/ → /, /sunshine/ → /sunshine/, /sunshine/troubleshooting/ → /sunshine/.
  • vite.config.js: base: './', so the built index.html references ./assets/... and ./images/..., which resolve against that <base>. Vite already rewrites public image URLs used in templates relative to the bundle.
  • vue-router needs no change: createWebHistory() without an argument reads the base from the <base> element.
  • The remaining root-absolute API URLs (Troubleshooting.vue, the cover image in Apps.vue) and the logout URL and request in Navbar.vue are now relative to the base.
  • router.js exports routes so a test can check that the route list in index.html stays in sync.

Without a proxy, URLs resolve exactly as before for every route.

Known limitations, outside this frontend-only change:

  • The server still sends root-absolute Location headers (/welcome and /, in confighttp.cpp). A sub-path proxy has to rewrite them, for example with nginx proxy_redirect / /sunshine/;.
  • An unknown path with more than one segment that does not start with a route name (e.g. /foo/bar) now resolves the base to /foo/ and the assets do not load. Before, the router redirected it to /. Unknown single-segment paths and unknown paths under a route (e.g. /apps/foo) still resolve to /.

Screenshot

No visual change.

Issues Fixed or Closed

Roadmap Issues

Testing

  • New tests/web/base_path.test.js (13 tests). It runs the inline script from index.html against 10 paths, with and without a sub-path. It also checks that the script's route list matches the top-level routes in router.js, that index.html no longer hard-codes a <base> element, and that no bundled source requests /api/... from the domain root.
  • tests/web/Troubleshooting.test.js: the expected URLs now use ./api/....
  • Without the fix (source changes stashed, tests kept), the new and updated tests fail: 20 failures across base_path.test.js and Troubleshooting.test.js. With the fix they pass.
  • npx vitest run on Windows: 229 passed, 1 failed. The failure is ExternalAudio.test.js › "provides the standard documentation table…". It also fails on an unmodified checkout on Windows and appears to come from CRLF line endings in docs/configuration.md, so it is unrelated.
  • npx vite build: succeeds. The generated index.html contains the inline script and references ./assets/index-*.js, ./assets/index-*.css and ./images/sunshine.ico. The navbar logo compiles to new URL('../images/logo-sunshine-45.png', import.meta.url).
  • Browser check behind a simulated reverse proxy (headless Chrome over CDP; not part of this PR). A small Node server stood in for confighttp.cpp routing: explicit /api, /assets and /images routes, /logout/? and /welcome/?, and the GET fallback to index.html with 404 under /api, /assets and /images. A second Node server proxied it under /sunshine/ and /a/b/sunshine/, like nginx location /sunshine/ { proxy_pass http://backend/; }. Results with this branch:
    • Direct access: /, /config, /welcome, /unknown, /troubleshooting/, /logout/ and /apps/unknown load. The UI mounts and the API calls go to /api/....
    • Behind the proxy: /sunshine/, /sunshine/config, /sunshine/apps, /sunshine/pin, /sunshine/unknown, /sunshine/troubleshooting/, /sunshine/logout/, /sunshine/apps/unknown, /a/b/sunshine/ and /a/b/sunshine/troubleshooting load. Assets, the navbar logo, app covers and every API call stay under the prefix.
    • Navbar navigation, a page reload on a sub-route, logout (the XHR goes to /sunshine/ and the redirect to /sunshine/logout) and the logout page's login link all stay under the prefix.
    • The same harness against an upstream master build loads directly, but behind the proxy every page fails with 404 on /assets/index-*.js and /assets/index-*.css, which reproduces web-ui not requesting relative path for reverse proxy purpose #6048.
    • Side effect: for URLs with a trailing slash after a route (e.g. /logout/), Chrome's preload scanner requests <route>/assets/index-*.js once before the inline script inserts <base>. The server answers with index.html, the browser discards it, and the module then loads from the correct path. The page works, at the cost of one extra request.
    • As noted above, /foo/bar does not load with this branch (base /foo/), while master redirects it to /. A proxied path without a trailing slash (/sunshine) also resolves to base /. nginx redirects /sunshine to /sunshine/ for a location /sunshine/ block.
  • Not run: the C++ build and tests, and a check against a real Sunshine server behind nginx. The server code is unchanged.

Type of Change

  • feat: New feature (non-breaking change which adds functionality)
  • fix: Bug fix (non-breaking change which fixes an issue)
  • docs: Documentation only changes
  • style: Changes that do not affect the meaning of the code (white-space, formatting, missing semicolons, etc.)
  • refactor: Code change that neither fixes a bug nor adds a feature
  • perf: Code change that improves performance
  • test: Adding missing tests or correcting existing tests
  • build: Changes that affect the build system or external dependencies
  • ci: Changes to CI configuration files and scripts
  • chore: Other changes that don't modify src or test files
  • revert: Reverts a previous commit
  • BREAKING CHANGE: Introduces a breaking change (can be combined with any type above)

Checklist

  • Code follows the style guidelines of this project
  • Code has been self-reviewed
  • Code has been commented, particularly in hard-to-understand areas
  • Code docstring/documentation-blocks for new or existing methods/components have been added or updated
  • Tests have been added or updated for new or modified functionality, including regression tests for bug fixes

AI Usage

See our AI usage policy.

  • AI tools were used while preparing this PR

The code, tests and this description were written by an AI agent (Claude Code) and published from my account.

This pull request was generated by Claude Code, model Claude Opus 5.5, low thinking level

🤖 Generated with Claude Code

@caioocesar

Copy link
Copy Markdown
Author

This pull request was generated by Claude Code, model Claude Opus 5.5, low thinking level

The single page application refactor switched Vite to `base: '/'` and added
`<base href="/">`, so assets, API requests and routes always resolve against
the domain root. Behind a reverse proxy that serves the UI under a sub-path
(e.g. /sunshine/), the browser requests /assets/... and the UI fails to load.

Resolve the base path from the current URL in a small inline script, build
assets relative to it, and make the remaining root-absolute API URLs relative.
vue-router picks the base up from the <base> element.

Fixes SunshineStream#6048

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@ReenigneArcher ReenigneArcher left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed bdd7b52e2d1a1c40a76ad4fa76e4f50858126c44. Requesting changes for the two base-path issues below. The original root-relative base is still present on current master, so #6048 remains relevant.

Validation: executed the actual inline script in an isolated Node harness and checked resulting asset URLs, including ordinary paths, route-name mount prefixes and repeated leading slashes. I did not run the full Vitest suite, a Vite build, or a real Sunshine/nginx deployment. CI, CodeQL and common lint await maintainer approval; that is a verification limit, not a contributor defect. GitHub currently reports the PR as mergeable without conflicts.

Review completed by Codex | Model: unavailable | Thinking: unavailable

segments.pop();
}
var base = document.createElement('base');
base.href = segments.join('/') + '/';

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Keep the generated base on the document's origin

If the pathname starts with repeated slashes, this assignment can produce a network-path reference rather than a root-relative path. Relative built scripts, styles and API URLs then resolve against a different origin. I verified the inline script's output and URL resolution, not an end-to-end exploit or the deployed server/proxy's normalization. The previous fixed / base did not have this behavior.

Ensure the output has exactly one leading slash before assigning it. This focused guard preserves the ordinary / and /sunshine/config cases in an isolated script check:

Suggested change
base.href = segments.join('/') + '/';
base.href = ('/' + segments.join('/') + '/').replace(/^\/+/, '/');

Also add a regression in tests/web/base_path.test.js that exercises repeated leading slashes and asserts that resolving the returned base against a document URL preserves its origin. Run npx vitest run tests/web/base_path.test.js. The suggestion has not been tested in a real Sunshine/proxy deployment and does not address the separate prefix ambiguity.

Review completed by Codex | Model: unavailable | Thinking: unavailable

Comment on lines +16 to +18
// A route visited with a trailing slash (for example /logout/) leaves its name as the last segment.
if (segments.length > 1 && routes.indexOf(segments[segments.length - 1]) !== -1) {
segments.pop();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Do not discard a proxy prefix just because it matches a route

For a proxy mounted at /apps/, both /apps/ and /apps/config produce base /; for a proxy mounted at /proxy/config/, both /proxy/config/ and /proxy/config/apps produce /proxy/. Assets and API calls therefore leave the configured mount and the UI still fails to load. I reproduced these outputs with this inline script. These are valid mount prefixes and known routes, not the documented unknown-path limitation.

The same pathname can represent either a direct route or a proxy mount, so a route-name heuristic cannot distinguish every case. Please provide an explicit base-prefix override (or another unambiguous source of the mount prefix) rather than stripping a matching segment unconditionally. Once that input is available, extend tests/web/base_path.test.js to cover configured /apps/ and /proxy/config/ mounts at their roots and at /config or /apps respectively, while retaining direct /apps/ and /logout/ coverage. Assert both the selected base and that an asset/API URL stays under it. A directly applicable one-line replacement would be misleading here because the required prefix input is not present yet.

Review completed by Codex | Model: unavailable | Thinking: unavailable

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

web-ui not requesting relative path for reverse proxy purpose

2 participants