From e5a258a6485677e2147126e7366307d30f53e9f7 Mon Sep 17 00:00:00 2001
From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com>
Date: Thu, 9 Jul 2026 12:57:58 +0100
Subject: [PATCH] =?UTF-8?q?Dev:=20redirect=20bare=20subpath=20/app=20?=
=?UTF-8?q?=E2=86=92=20/app/=20when=20RUN=5FSUBPATH=20is=20set=20(#6934)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Problem
With `RUN_SUBPATH=app`, the app is served under base `/app/`. Vite
serves `index.html` at `/app/` and redirects `/` → `/app/`, but a bare
**`/app`** (no trailing slash) returns **404** — so you had to type
`localhost:5173/app/` to load the app. `/app` should work too.
## Fix
A small dev + preview middleware that **301-redirects `/app` → `/app/`**
(query string preserved), so either form loads the app. Only active when
`RUN_SUBPATH` is set; no-op otherwise.
Also routed the vite `base` through the same slash-stripped `runSubpath`
value the middleware uses, so a stray `RUN_SUBPATH=/app/` can't produce
a doubled `//app//` base.
## Verified (dev server + prod build, `RUN_SUBPATH=app`)
| Request | Before | After |
|---|---|---|
| `GET /app` | 404 | **301 → `/app/`** |
| `GET /app?foo=1` | 404 | **301 → `/app/?foo=1`** (query kept) |
| `GET /app/` | 200 | 200 (unchanged) |
| `GET /` | 302 → `/app/` | 302 → `/app/` (unchanged) |
Production build under the subpath still emits `` and
`/app/assets/...`. Lint + format green.
---
frontend/editor/vite.config.ts | 42 +++++++++++++++++++++++++++++++---
1 file changed, 39 insertions(+), 3 deletions(-)
diff --git a/frontend/editor/vite.config.ts b/frontend/editor/vite.config.ts
index 17c0c09282..059f98332e 100644
--- a/frontend/editor/vite.config.ts
+++ b/frontend/editor/vite.config.ts
@@ -6,7 +6,7 @@ import { constants, brotliCompress, gzip } from "node:zlib";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { defineConfig, loadEnv } from "vite";
-import type { PluginOption } from "vite";
+import type { Connect, PluginOption } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
import { viteStaticCopy } from "vite-plugin-static-copy";
@@ -129,6 +129,38 @@ function prerenderOgPlugin(): PluginOption {
};
}
+/**
+ * When the app is served under a subpath (RUN_SUBPATH → base like "/app/"), Vite
+ * serves index.html at "/app/" and redirects "/" → the base, but a bare "/app"
+ * (no trailing slash) 404s. This middleware redirects "/app" → "/app/" so either
+ * form loads the app in dev and `vite preview`. Query strings are preserved.
+ */
+function subpathBareRedirectPlugin(subpath: string): PluginOption {
+ const bare = `/${subpath}`;
+ const withSlash = `${bare}/`;
+ const redirect: Connect.NextHandleFunction = (req, res, next) => {
+ const url = req.url ?? "";
+ const q = url.indexOf("?");
+ const pathname = q === -1 ? url : url.slice(0, q);
+ if (pathname === bare) {
+ res.statusCode = 301;
+ res.setHeader("Location", withSlash + (q === -1 ? "" : url.slice(q)));
+ res.end();
+ return;
+ }
+ next();
+ };
+ return {
+ name: "subpath-bare-redirect",
+ configureServer(server) {
+ server.middlewares.use(redirect);
+ },
+ configurePreviewServer(server) {
+ server.middlewares.use(redirect);
+ },
+ };
+}
+
// NOTE: cloud/ is a SHARED layer, not a runnable build flavor — it's compiled
// into the saas and desktop builds. It has no entry here and no vite tsconfig;
// it is only typechecked standalone via editor/src/cloud/tsconfig.json
@@ -178,6 +210,9 @@ export default defineConfig(async ({ mode }) => {
const tsconfigProject = TSCONFIG_MAP[effectiveMode];
+ // Subpath the app is served under (base becomes "//"). Empty = root.
+ const runSubpath = (env.RUN_SUBPATH || "").replace(/^\/+|\/+$/g, "");
+
// Backend proxy target: default localhost:8080. Override via BACKEND_URL env var
// so the top-level dev launcher can wire a dynamically-assigned backend port.
const backendUrl = process.env.BACKEND_URL || "http://localhost:8080";
@@ -217,6 +252,7 @@ export default defineConfig(async ({ mode }) => {
return {
plugins: [
react(),
+ ...(runSubpath ? [subpathBareRedirectPlugin(runSubpath)] : []),
tsconfigPaths({
projects: [tsconfigProject],
}),
@@ -330,8 +366,8 @@ export default defineConfig(async ({ mode }) => {
// an absolute base so deep-route asset paths resolve to /assets/...
// Trailing slash required: it becomes ``, and browsers resolve
// relative URLs (manifest.json, favicon) against the base's *directory*.
- base: env.RUN_SUBPATH
- ? `/${env.RUN_SUBPATH}/`
+ base: runSubpath
+ ? `/${runSubpath}/`
: process.env.VITE_BUILD_FOR_PREVIEW === "1"
? "/"
: "./",