From d7c130fca9ad04f912caa4c9b552229cac0b6b50 Mon Sep 17 00:00:00 2001
From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
Date: Wed, 5 Aug 2026 12:03:12 +0100
Subject: [PATCH] Serve SPA shell for deep frontend routes (#7145)
# Description of Changes
stops the /new urls crashing page on f5
---
## Checklist
### General
- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings
### Documentation
- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)
### Translations (if applicable)
- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)
### UI Changes (if applicable)
- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)
### Testing (if applicable)
- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
---------
Co-authored-by: Reece Browne <74901996+reecebrowne@users.noreply.github.com>
---
.../web/ReactRoutingController.java | 89 +++++++++++++++++++
.../web/ReactRoutingControllerTest.java | 89 +++++++++++++++++++
frontend/editor/scripts/lint/theme-lint.mjs | 6 +-
3 files changed, 183 insertions(+), 1 deletion(-)
diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java b/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java
index 1e05ec17b2..7689d109fe 100644
--- a/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java
+++ b/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java
@@ -5,17 +5,26 @@ import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
+import java.util.List;
+import java.util.Set;
import java.util.regex.Pattern;
import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.core.Ordered;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.http.CacheControl;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
+import org.springframework.http.converter.StringHttpMessageConverter;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.servlet.function.RouterFunction;
+import org.springframework.web.servlet.function.RouterFunctions;
+import org.springframework.web.servlet.function.ServerResponse;
+import org.springframework.web.servlet.function.support.RouterFunctionMapping;
import org.springframework.web.util.HtmlUtils;
import org.springframework.web.util.JavaScriptUtils;
@@ -32,6 +41,33 @@ public class ReactRoutingController {
private static final Pattern BASE_HREF_PATTERN =
Pattern.compile("");
+ // First path segments owned by the backend or static assets, never SPA routes.
+ // Mirrors the exclusion regexes on forwardRootPaths/forwardNestedPaths below.
+ private static final Set NON_SPA_FIRST_SEGMENTS =
+ Set.of(
+ "api",
+ "static",
+ "pipeline",
+ "pdfjs",
+ "pdfjs-legacy",
+ "pdfium",
+ "vendor",
+ "fonts",
+ "images",
+ "css",
+ "js",
+ "assets",
+ "locales",
+ "modern-logo",
+ "classic-logo",
+ "Login",
+ "og_images",
+ "samples");
+
+ // After the annotated controllers (order 0), before the resource chain
+ // (LOWEST_PRECEDENCE - 1).
+ private static final int SPA_FALLBACK_ORDER = Ordered.LOWEST_PRECEDENCE - 2;
+
@Value("${server.servlet.context-path:/}")
private String contextPath;
@@ -256,6 +292,59 @@ public class ReactRoutingController {
return serveIndexHtml(request);
}
+ // The regex mappings above only cover 1- and 2-segment paths (Spring path variables cannot
+ // span '/'), so deep SPA links like /processor/pipelines/new 404d on direct navigation.
+ //
+ // Registered as its own mapping rather than exposed as a bare RouterFunction @Bean:
+ // Spring's own RouterFunctionMapping is ordered -1, ahead of the annotated controllers at
+ // order 0, so a plain bean would shadow every dot-free backend route the denylist below
+ // does not name (/v1/api-docs, /error, /actuator, ...). LOWEST_PRECEDENCE - 2 puts it after
+ // the controllers and before the resource chain (LOWEST_PRECEDENCE - 1), which is the only
+ // position where a catch-all fallback is safe.
+ @Bean
+ public RouterFunctionMapping spaDeepLinkFallbackMapping() {
+ RouterFunction fallback =
+ RouterFunctions.route(
+ request -> {
+ HttpServletRequest servletRequest = request.servletRequest();
+ return "GET".equals(servletRequest.getMethod())
+ && isSpaFallbackRoute(
+ stripContextPath(
+ servletRequest.getContextPath(),
+ servletRequest.getRequestURI()));
+ },
+ request ->
+ ServerResponse.ok()
+ .cacheControl(CacheControl.noCache().mustRevalidate())
+ .contentType(MediaType.TEXT_HTML)
+ .body(serveIndexHtml(request.servletRequest()).getBody()));
+ RouterFunctionMapping mapping = new RouterFunctionMapping(fallback);
+ mapping.setOrder(SPA_FALLBACK_ORDER);
+ mapping.setMessageConverters(
+ List.of(new StringHttpMessageConverter(StandardCharsets.UTF_8)));
+ return mapping;
+ }
+
+ // Dot-free paths only, so requests for real files still fall through to the resource
+ // handlers. This is a denylist, so it is only safe because the mapping above runs after
+ // the annotated controllers - see spaDeepLinkFallbackMapping.
+ static boolean isSpaFallbackRoute(String path) {
+ if (path == null || path.isEmpty() || "/".equals(path) || path.indexOf('.') >= 0) {
+ return false;
+ }
+ String[] segments = (path.startsWith("/") ? path.substring(1) : path).split("/");
+ return segments.length > 0
+ && !segments[0].isEmpty()
+ && !NON_SPA_FIRST_SEGMENTS.contains(segments[0]);
+ }
+
+ private static String stripContextPath(String contextPath, String uri) {
+ if (contextPath != null && !contextPath.isBlank() && uri.startsWith(contextPath)) {
+ return uri.substring(contextPath.length());
+ }
+ return uri;
+ }
+
private String buildFallbackHtml() {
String baseUrl = contextPath.endsWith("/") ? contextPath : contextPath + "/";
diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/web/ReactRoutingControllerTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/web/ReactRoutingControllerTest.java
index 2df3fb5587..41909e3e0a 100644
--- a/app/core/src/test/java/stirling/software/SPDF/controller/web/ReactRoutingControllerTest.java
+++ b/app/core/src/test/java/stirling/software/SPDF/controller/web/ReactRoutingControllerTest.java
@@ -4,12 +4,24 @@ import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.mock;
import java.lang.reflect.Field;
+import java.util.List;
+import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
+import org.springframework.core.Ordered;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
+import org.springframework.http.converter.StringHttpMessageConverter;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.web.servlet.function.EntityResponse;
+import org.springframework.web.servlet.function.HandlerFunction;
+import org.springframework.web.servlet.function.RouterFunction;
+import org.springframework.web.servlet.function.ServerRequest;
+import org.springframework.web.servlet.function.ServerResponse;
+import org.springframework.web.servlet.function.support.RouterFunctionMapping;
+import org.springframework.web.util.ServletRequestPathUtils;
import jakarta.servlet.http.HttpServletRequest;
@@ -175,6 +187,83 @@ class ReactRoutingControllerTest {
assertNotNull(response.getBody());
}
+ // --- deep-link SPA fallback (router function) ---
+
+ @Test
+ void isSpaFallbackRoute_acceptsDeepSpaPaths() {
+ assertTrue(ReactRoutingController.isSpaFallbackRoute("/processor/pipelines/new"));
+ assertTrue(ReactRoutingController.isSpaFallbackRoute("/processor/pipelines/123/runs/456"));
+ assertTrue(ReactRoutingController.isSpaFallbackRoute("/workflow/sign/some-token"));
+ assertTrue(ReactRoutingController.isSpaFallbackRoute("/processor/pipelines/new/"));
+ // "pipelines" must not be swallowed by the "pipeline" exclusion
+ assertTrue(ReactRoutingController.isSpaFallbackRoute("/pipelines"));
+ }
+
+ @Test
+ void isSpaFallbackRoute_rejectsBackendStaticAndFilePaths() {
+ assertFalse(ReactRoutingController.isSpaFallbackRoute("/api/v1/some/endpoint"));
+ assertFalse(ReactRoutingController.isSpaFallbackRoute("/pipeline"));
+ assertFalse(ReactRoutingController.isSpaFallbackRoute("/pipeline/anything"));
+ assertFalse(ReactRoutingController.isSpaFallbackRoute("/assets/deep/path"));
+ assertFalse(ReactRoutingController.isSpaFallbackRoute("/processor/pipelines/file.js"));
+ assertFalse(ReactRoutingController.isSpaFallbackRoute("/branding/sub/logo.png"));
+ assertFalse(ReactRoutingController.isSpaFallbackRoute("/"));
+ assertFalse(ReactRoutingController.isSpaFallbackRoute(""));
+ assertFalse(ReactRoutingController.isSpaFallbackRoute(null));
+ }
+
+ @Test
+ void spaDeepLinkFallback_servesIndexForDeepRoute() throws Exception {
+ controller.init();
+ RouterFunction router = routerOf(controller.spaDeepLinkFallbackMapping());
+
+ ServerRequest deepRequest = serverRequest("GET", "/processor/pipelines/new");
+ Optional> handler = router.route(deepRequest);
+ assertTrue(handler.isPresent());
+
+ ServerResponse response = handler.get().handle(deepRequest);
+ assertEquals(HttpStatus.OK, response.statusCode());
+ assertInstanceOf(EntityResponse.class, response);
+ Object body = ((EntityResponse>) response).entity();
+ assertTrue(body.toString().contains("Stirling PDF"));
+ }
+
+ @Test
+ void spaDeepLinkFallback_ignoresApiFilesAndNonGet() {
+ controller.init();
+ RouterFunction router = routerOf(controller.spaDeepLinkFallbackMapping());
+
+ assertTrue(router.route(serverRequest("GET", "/api/v1/policies/run")).isEmpty());
+ assertTrue(router.route(serverRequest("GET", "/branding/sub/logo.png")).isEmpty());
+ assertTrue(router.route(serverRequest("POST", "/processor/pipelines/new")).isEmpty());
+ }
+
+ @Test
+ void spaDeepLinkFallback_runsAfterControllersAndBeforeResources() {
+ controller.init();
+ int order = controller.spaDeepLinkFallbackMapping().getOrder();
+
+ // A catch-all denylist is only safe below every annotated controller; Spring's own
+ // RouterFunctionMapping sits at -1, which would shadow /v1/api-docs, /error and friends.
+ assertTrue(order > 0, "SPA fallback must run after annotated controllers");
+ assertTrue(
+ order < Ordered.LOWEST_PRECEDENCE - 1,
+ "SPA fallback must run before the static-resource chain");
+ }
+
+ private static RouterFunction routerOf(RouterFunctionMapping mapping) {
+ @SuppressWarnings("unchecked")
+ RouterFunction router =
+ (RouterFunction) mapping.getRouterFunction();
+ return router;
+ }
+
+ private static ServerRequest serverRequest(String method, String uri) {
+ MockHttpServletRequest servletRequest = new MockHttpServletRequest(method, uri);
+ ServletRequestPathUtils.parseAndCache(servletRequest);
+ return ServerRequest.create(servletRequest, List.of(new StringHttpMessageConverter()));
+ }
+
// --- context path handling ---
@Test
diff --git a/frontend/editor/scripts/lint/theme-lint.mjs b/frontend/editor/scripts/lint/theme-lint.mjs
index 0ae53a3ce9..42f6153119 100644
--- a/frontend/editor/scripts/lint/theme-lint.mjs
+++ b/frontend/editor/scripts/lint/theme-lint.mjs
@@ -314,6 +314,10 @@ function check() {
const violations = [];
const primitiveValues = new Map();
const lineOf = (text, index) => text.slice(0, index).split("\n").length;
+ // path.relative emits backslashes on Windows; normalize so the PRIMITIVES
+ // comparison below matches and printed paths stay POSIX-style.
+ const posixRel = (name) =>
+ relative(process.cwd(), join(THEME, name)).replaceAll("\\", "/");
// Fail if a theme .css exists that isn't registered above (readdir is only
// compared here — never used to build a path passed to readFileSync).
@@ -321,7 +325,7 @@ function check() {
for (const name of readdirSync(THEME)) {
if (name.endsWith(".css") && !known.has(name)) {
violations.push({
- file: relative(process.cwd(), join(THEME, name)),
+ file: posixRel(name),
line: 1,
msg: `unregistered theme CSS — add "${name}" to THEME_FILES in theme-lint.mjs`,
});