Skip to content

feat: adding in app specific rewrite valve to allow for browser router - #3522

Draft
themaherkhalil wants to merge 2 commits into
devfrom
feat/hash-to-browser-router
Draft

feat: adding in app specific rewrite valve to allow for browser router#3522
themaherkhalil wants to merge 2 commits into
devfrom
feat/hash-to-browser-router

Conversation

@themaherkhalil

@themaherkhalil themaherkhalil commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

What

Switches the client and playground from HashRouter to BrowserRouter, so URLs read /app/<id> instead of /#/app/<id>. Runs against a stock Tomcat: nothing in Tomcat's conf/ is touched, and no mvn step is needed for a local exploded deploy.

Why this touches two layers

BrowserRouter needs two things hash routing did not:

  1. A server side fallback. A deep URL such as .../dist/app/<id>/view is not a real file, so Tomcat 404s it. The SPA shell has to be served instead.
  2. An absolute asset base. With base: "./", index.html emits ./assets/..., which the browser resolves against the current route rather than the app root. On a deep URL that fetches bundles from the wrong path and fails with Expected a JavaScript module script but the server responded with a MIME type of "text/html".

Server side

META-INF/context.xml enables Tomcat's built in RewriteValve for this webapp only, with the rules in WEB-INF/rewrite.config. Both files live in the repo and both ship in the WAR (neither is in the pom's exclude lists), so no Tomcat level configuration is required. Rules are matched against the context relative URL, so the same file works at /semoss-ui locally and /SemossWeb when deployed.

The single rule serves <pkg>/dist/index.html for any path under packages/<pkg>/dist/ whose last segment has no file extension, which covers every package's SPA at once.

Two notes for anyone editing that file later, both found the hard way:

  • %{REQUEST_FILENAME} and %{REQUEST_PATH} do not resolve usefully in a context level valve. -f and -d throw IllegalArgumentException: The resource path [null] is not valid, and a RewriteCond on %{REQUEST_PATH} silently passes for every request, so asset URLs get answered with index.html. The extension test therefore lives in the rule pattern as a negative lookahead, which is matched against the URL the valve actually rewrites.
  • Matching on "has no extension" is a whitelist, not a list of asset extensions to keep in sync. Everything the build emits has an extension, so assets are served normally, a missing asset still returns a real 404, and index.html cannot loop.

Build

vite.config.ts in both apps takes its base from VITE_BASE_URL, defaulting to / for the dev server. import.meta.env.BASE_URL is then the single source for both the asset base and the router basename, so the two cannot drift apart.

Reviewers, please check this: the committed .env.production files assume the deployed context path is /SemossWeb. CI runs the same vite build --mode production, so if that is wrong for any environment the built assets will point at the wrong path. The alternative is dropping those files and setting VITE_BASE_URL as a CI variable instead, since shell env overrides .env files.

For local work against an exploded checkout whose context path differs, add a gitignored override (*.local is already ignored):

# packages/client/.env.production.local
VITE_BASE_URL=/semoss-ui/packages/client/dist/

# packages/playground/.env.production.local
VITE_BASE_URL=/semoss-ui/packages/playground/dist/

pnpm run dev is unaffected. Development mode does not read .env.production*, and Vite's dev server already does History API fallback.

Shared route helpers

libs/shared/src/utility/router.ts adds two factories, following the existing createMcpPlatformUrl pattern rather than reading env inside shared. That matters because libs/renderer marks @semoss/shared as external in its rollup build, so a build time env read inside shared could be frozen with the wrong value.

  • createRouteHref(basename) for hrefs that cannot go through <Link> or navigate(): anchors that open a new tab, and window.open.
  • createSiblingAppHref(basename) for cross app links, deriving .../packages/<name>/dist/ from the caller's own basename.

Each app binds them once in src/utility/router.ts. The playground's platform URL now comes from createSiblingAppHref("client") instead of VITE_PLATFORM_URL, leaving one path per deployment to maintain rather than two.

Call sites converted

Anything that built a URL by hand had to change, because relative and hash hrefs no longer resolve correctly.

Client

  • share-overlay built the share URL from location.href.replace(location.hash, "#"), which prepends # to the entire URL once the hash is empty
  • hooks/useNavigate built modified click new tab URLs as origin + location.pathname + "#" + path
  • blocks-workspace-actions stripped page ids by assigning window.location.href. That was a no reload hash change before and a full document reload after, which would have dropped the preview dialog that opens on the next line
  • project-catalog and settings-index-page opened new tabs via `#${path}`
  • platform-search-app, platform-search-engine, prompt-card
  • landing-page linked to sibling apps with ../../<pkg>/dist/, which only resolved because the landing page renders at the index route

Renderer (libs/renderer)

LinkBlock and state.store both navigated between page blocks by assigning window.location.hash, which is a no-op under BrowserRouter. They now share a new resolveAppPagePath helper. LinkBlock uses useNavigate; StateStore cannot use hooks, so it takes an optional navigate supplied by Renderer and held in a ref, so routing to another page never re-triggers the app load effect. Design mode is unaffected, since it builds its store with mode: "static" and never enters that branch.

Playground

room-input, both sites in agent-selector, and the system:// resolver in tools-view.

Deliberately unchanged

  • libs/sdk embed-auth still falls back to reading the hash. It checks location.search first, which is where the param now arrives, and the fallback keeps existing embed URLs working.
  • The playground's global-nav-item and main-layout still parse #/... urls. Those read backend configured nav and embed items rather than building URLs, and both already convert to a router path, so existing configs keep working.
  • auditlog stays on HashRouter, and terminal and browser-automation have no router. None of them need an absolute base, because their document path never moves. Converting auditlog later needs the same three changes the playground got here.
  • App.tsx still normalizes legacy #!/ NCRT bookmarks. Those now land on the landing page rather than the deep route.

Existing hash links degrade rather than break: .../dist/#/app/<id> still loads the app and lands on the landing page, since the path matches the basename and the hash is ignored.

Follow up needed outside this repo

GitHubAppClient.frontendUrl() in the Semoss backend hardcodes:

return base + "/SemossWeb/packages/client/dist/#" + suffix;

Both the # and the hardcoded context path need addressing. In the backend checkout I looked at, that method has no callers and the /github/install/app endpoint the client posts to is not present, so the flow is not currently reachable, but it needs a companion change before the GitHub App install callback lands correctly.

Testing

Server side, verified against an exploded deploy at webapps/semoss-ui:

request result
/semoss-ui/packages/client/dist/ 200 text/html
/semoss-ui/packages/client/dist/app/<id>/view 200 text/html (fallback)
/semoss-ui/packages/client/dist/assets/<entry>.js 200 text/javascript
/semoss-ui/packages/client/dist/assets/nope.js 404, not HTML
/semoss-ui/packages/client/dist/index.html 200, no rewrite loop
/semoss-ui/packages/playground/dist/agent/<id> 200 text/html

In the browser, after pnpm run build from the repo root so turbo rebuilds libs/renderer: hard refresh a deep route, open an app from the catalog, copy and open a share URL, Ctrl or Cmd click a nav button and a settings card, open a prompt in a new tab, follow an MCP usage link, hit Preview in the designer, and click a link block that moves between pages.

Known gap

A route whose last segment contains a dot is not rewritten and 404s on refresh. No built in route can produce one, since every trailing param is an id, but a user defined page block route such as report.v2 would hit it. Closing it needs existence based fallback (a 404 <error-page>, which only fires after Tomcat has confirmed the file is missing) rather than the extension test.

🤖 Generated with Claude Code

@snyk-io

snyk-io Bot commented Aug 5, 2026

Copy link
Copy Markdown

Snyk checks have passed. No issues have been found so far.

Status Scan Engine Critical High Medium Low Total (0)
Open Source Security 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

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.

1 participant