diff --git a/public/service-worker.js b/public/service-worker.js
index 5bc7122d68..49c99e6684 100644
--- a/public/service-worker.js
+++ b/public/service-worker.js
@@ -1,6 +1,6 @@
// mux Service Worker for PWA support
-const CACHE_NAME = "mux-v1";
-const urlsToCache = ["/", "/index.html"];
+const CACHE_NAME = "mux-v2";
+const urlsToCache = ["./", "./index.html"];
// Install event - cache core assets
self.addEventListener("install", (event) => {
diff --git a/src/node/orpc/server.test.ts b/src/node/orpc/server.test.ts
index 03057bb92d..10eeae2d4c 100644
--- a/src/node/orpc/server.test.ts
+++ b/src/node/orpc/server.test.ts
@@ -155,6 +155,13 @@ function countOccurrences(value: string, needle: string): number {
return value.split(needle).length - 1;
}
+function expectSlashlessRootRedirectBeforeBase(html: string, baseHref: string): void {
+ const redirectIndex = html.indexOf("location.replace(location.origin+pathname");
+ const baseIndex = html.indexOf(`;
@@ -231,7 +238,7 @@ describe("createOrpcServer", () => {
expect(uiRes.status).toBe(200);
const uiText = await uiRes.text();
expect(uiText).toContain("mux");
- expect(uiText).toContain(' {
const rootRes = await fetch(`${server.baseUrl}/`);
expect(rootRes.status).toBe(200);
const rootHtml = await rootRes.text();
- expect(countOccurrences(rootHtml, '')).toBe(1);
+ expect(countOccurrences(rootHtml, '')).toBe(1);
+ expectSlashlessRootRedirectBeforeBase(rootHtml, "./");
+
+ const doubleSlashRes = await fetch(`${server.baseUrl}//attacker.example`);
+ expect(doubleSlashRes.status).toBe(200);
+ const doubleSlashHtml = await doubleSlashRes.text();
+ expect(doubleSlashHtml).not.toContain("location.replace(location.origin+pathname");
+
+ const deepRouteRes = await fetch(`${server.baseUrl}/some/spa/route`);
+ expect(deepRouteRes.status).toBe(200);
+ const deepRouteHtml = await deepRouteRes.text();
+ expect(deepRouteHtml).toContain('');
+ expect(deepRouteHtml).not.toContain("location.replace(location.pathname");
+
+ const directoryRouteRes = await fetch(`${server.baseUrl}/some/spa/route/`);
+ expect(directoryRouteRes.status).toBe(200);
+ expect(await directoryRouteRes.text()).toContain('');
const forwardedPrefixRes = await fetch(`${server.baseUrl}/some/spa/route`, {
headers: { "X-Forwarded-Prefix": APP_PROXY_BASE_PATH },
@@ -297,6 +320,23 @@ describe("createOrpcServer", () => {
expect(prefixedAssetRes.status).toBe(200);
expect(await prefixedAssetRes.text()).toBe(await rootAssetRes.text());
+ const prefixedRootRes = await fetch(`${server.baseUrl}${APP_PROXY_BASE_PATH}`);
+ expect(prefixedRootRes.status).toBe(200);
+ const prefixedRootHtml = await prefixedRootRes.text();
+ expect(prefixedRootHtml).toContain(``);
+ expectSlashlessRootRedirectBeforeBase(prefixedRootHtml, `${APP_PROXY_BASE_PATH}/`);
+
+ const coderUrlRes = await fetch(
+ `${server.baseUrl}/@admin/mux-workspace-095801.main/apps/mux/?token=redacted`
+ );
+ expect(coderUrlRes.status).toBe(200);
+ const coderUrlHtml = await coderUrlRes.text();
+ expect(coderUrlHtml).toContain('');
+ expectSlashlessRootRedirectBeforeBase(
+ coderUrlHtml,
+ "/@admin/mux-workspace-095801.main/apps/mux/"
+ );
+
const prefixedSpaRes = await fetch(`${server.baseUrl}${APP_PROXY_BASE_PATH}/settings`);
expect(prefixedSpaRes.status).toBe(200);
expect(await prefixedSpaRes.text()).toContain(``);
@@ -323,7 +363,7 @@ describe("createOrpcServer", () => {
const falsePositiveRes = await fetch(`${server.baseUrl}/projects/apps/other`);
expect(falsePositiveRes.status).toBe(200);
- expect(await falsePositiveRes.text()).toContain('');
+ expect(await falsePositiveRes.text()).toContain('');
} finally {
await close();
}
@@ -477,8 +517,10 @@ describe("createOrpcServer", () => {
expect(uiRes.status).toBe(200);
const uiText = await uiRes.text();
+ expect(rootHtml).toContain('";'
diff --git a/src/node/orpc/server.ts b/src/node/orpc/server.ts
index 61bc2fa98b..c879acf922 100644
--- a/src/node/orpc/server.ts
+++ b/src/node/orpc/server.ts
@@ -123,17 +123,30 @@ function escapeHtmlAttribute(value: string): string {
.replaceAll(">", ">");
}
-function injectBaseHref(indexHtml: string, baseHref: string): string {
+const SLASHLESS_ROOT_REDIRECT_SCRIPT =
+ '';
+
+function injectBaseHref(
+ indexHtml: string,
+ baseHref: string,
+ options: { includeSlashlessRootRedirect?: boolean } = {}
+): string {
// Avoid double-injecting if the HTML already has a base tag.
if (/ tag (supports
and ).
const escapedBaseHref = escapeHtmlAttribute(baseHref);
return indexHtml.replace(
/]*>/i,
- (match) => `${match}\n `
+ (match) => `${match}${slashlessRootRedirect}\n `
);
}
@@ -579,9 +592,31 @@ function getDirectAppProxyHandlerPrefix(
: routePrefix;
}
+function getRoutePathnameForBaseHref(req: express.Request): string | null {
+ return getPathnameFromRequestUrl(req.url);
+}
+
+function getRelativeBaseHrefFromRoutePathname(routePathname: string): string {
+ const pathname = routePathname.startsWith("/") ? routePathname : `/${routePathname}`;
+ const segments = pathname.split("/").slice(1);
+ const depth = Math.max(0, segments.length - 1);
+ return depth === 0 ? "./" : `./${"../".repeat(depth)}`;
+}
+
+function shouldInjectSlashlessRootRedirect(req: express.Request): boolean {
+ return getRoutePathnameForBaseHref(req) === "/";
+}
+
function getPublicBaseHref(req: express.Request, res: express.Response): string {
const publicBasePath = getPublicBasePathForRequest(req, res, { allowReferer: true });
- return publicBasePath === "/" ? "/" : `${publicBasePath}/`;
+ if (publicBasePath !== "/") {
+ return `${publicBasePath}/`;
+ }
+
+ // User rationale: when a reverse proxy strips the app prefix without forwarding
+ // headers, a relative climb still lets the browser resolve assets from the
+ // public app root. Root-hosted deep links resolve correctly too.
+ return getRelativeBaseHrefFromRoutePathname(getRoutePathnameForBaseHref(req) ?? "/");
}
function getPublicAppRootPath(req: express.Request, res: express.Response): string {
@@ -1545,7 +1580,9 @@ export async function createOrpcServer({
if (rawSpaIndexHtml !== null) {
const spaIndexHtml = injectProxyUriTemplate(
- injectBaseHref(rawSpaIndexHtml, getPublicBaseHref(req, res)),
+ injectBaseHref(rawSpaIndexHtml, getPublicBaseHref(req, res), {
+ includeSlashlessRootRedirect: shouldInjectSlashlessRootRedirect(req),
+ }),
getBrowserProxyUriTemplate()
);
varyPublicBasePathHeaders(res);