Skip to content

Commit ab86767

Browse files
committed
🤖 feat: add shared Coder app-proxy path parser
Audit: - backendBaseUrl now re-exports the shared parser. - RouterContext and App preserve the cached app-proxy prefix on browser history writes. - main.tsx and terminal popouts resolve service-worker.js and terminal.html through document.baseURI with the cached prefix. - terminal.html uses a relative terminal window module script so it stays under the proxy base path. - Grep found no raw browser-runtime /api, /auth, /orpc, /service-worker.js, or /terminal.html literals outside stories after the changes. Validation: - bun test src/common/appProxyBasePath.test.ts src/browser/utils/backendBaseUrl.test.ts - make typecheck
1 parent 797d5a2 commit ab86767

9 files changed

Lines changed: 162 additions & 27 deletions

File tree

src/browser/App.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ import { createProjectRefs } from "@/common/utils/multiProject";
100100
import { MULTI_PROJECT_SIDEBAR_SECTION_ID } from "@/common/constants/multiProject";
101101
import { WORKSPACE_DEFAULTS } from "@/constants/workspaceDefaults";
102102
import { isDesktopMode } from "@/browser/hooks/useDesktopTitlebar";
103+
import { prependInitialAppProxyBasePath } from "@/browser/utils/frontendBasePath";
103104
import { LoadingScreen } from "@/browser/components/LoadingScreen/LoadingScreen";
104105

105106
function RootRouteShell(props: {
@@ -871,7 +872,10 @@ function AppInner() {
871872
const handlePopState = () => {
872873
// Re-push the correct URL from MemoryRouter, not the popped browser URL
873874
const { pathname, search, hash } = locationRef.current;
874-
const correctUrl = `${window.location.origin}${pathname}${search}${hash}`;
875+
const correctUrl = new URL(
876+
prependInitialAppProxyBasePath(`${pathname}${search}${hash}`),
877+
window.location.origin
878+
);
875879
window.history.pushState({ mux: true }, "", correctUrl);
876880
};
877881

src/browser/contexts/RouterContext.tsx

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ import {
99
} from "react";
1010
import { MemoryRouter, useLocation, useNavigate, useSearchParams } from "react-router-dom";
1111
import { readPersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState";
12+
import {
13+
prependInitialAppProxyBasePath,
14+
stripInitialAppProxyBasePathFromPathname,
15+
} from "@/browser/utils/frontendBasePath";
1216
import {
1317
LAST_VISITED_ROUTE_KEY,
1418
LAUNCH_BEHAVIOR_KEY,
@@ -177,7 +181,8 @@ function isRestorableRoute(route: unknown): route is string {
177181

178182
/** Get the initial route, falling back to the compatibility root entrypoint when needed. */
179183
function getInitialRoute(): string {
180-
const isStorybook = window.location.pathname.endsWith("iframe.html");
184+
const routePathname = stripInitialAppProxyBasePathFromPathname(window.location.pathname);
185+
const isStorybook = routePathname.endsWith("iframe.html");
181186
const isStandalone = isStandalonePwa();
182187
const navigationType = getStartupNavigationType();
183188
const launchBehavior = !isStandalone
@@ -195,7 +200,7 @@ function getInitialRoute(): string {
195200
// routes are special: fresh launches may ignore them, but explicit restore-style navigations
196201
// such as hard reload/back-forward should reopen the same chat.
197202
if (window.location.protocol !== "file:" && !isStorybook) {
198-
const url = window.location.pathname + window.location.search;
203+
const url = routePathname + window.location.search;
199204
// Only use URL if it's a valid route (starts with /, not just "/" or empty)
200205
if (url.startsWith("/") && url !== "/") {
201206
if (!url.startsWith("/workspace/")) {
@@ -254,13 +259,15 @@ function useUrlSync(): void {
254259
updatePersistedState(LAST_VISITED_ROUTE_KEY, url);
255260
}
256261

262+
const currentRoutePathname = stripInitialAppProxyBasePathFromPathname(window.location.pathname);
257263
// Skip in Storybook (conflicts with story navigation)
258-
if (window.location.pathname.endsWith("iframe.html")) return;
264+
if (currentRoutePathname.endsWith("iframe.html")) return;
259265
// Skip in Electron (file:// reloads always boot through index.html; we restore via localStorage above)
260266
if (window.location.protocol === "file:") return;
261267

262-
if (url !== window.location.pathname + window.location.search + window.location.hash) {
263-
window.history.replaceState(null, "", url);
268+
const browserUrl = prependInitialAppProxyBasePath(url);
269+
if (browserUrl !== window.location.pathname + window.location.search + window.location.hash) {
270+
window.history.replaceState(null, "", browserUrl);
264271
}
265272
}, [location.pathname, location.search, location.hash]);
266273
}

src/browser/main.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { installWindowOpenLocalhostProxyNormalization } from "@/browser/utils/wi
55
import { AppLoader } from "@/browser/components/AppLoader/AppLoader";
66
import { initTelemetry, trackAppStarted } from "@/common/telemetry";
77
import { initTitlebarInsets } from "@/browser/hooks/useDesktopTitlebar";
8+
import { resolveBrowserAssetUrl } from "@/browser/utils/frontendBasePath";
89

910
// Initialize telemetry on app startup
1011
try {
@@ -56,7 +57,7 @@ if ("serviceWorker" in navigator) {
5657
if (isHttpProtocol) {
5758
window.addEventListener("load", () => {
5859
navigator.serviceWorker
59-
.register("/service-worker.js")
60+
.register(resolveBrowserAssetUrl("service-worker.js"))
6061
.then((registration) => {
6162
console.log("Service Worker registered:", registration);
6263
})

src/browser/utils/backendBaseUrl.ts

Lines changed: 2 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -10,25 +10,9 @@
1010
* prefix, so the frontend must include it when constructing URLs.
1111
*/
1212

13-
// Non-greedy so we match the *first* "/apps/<slug>" segment in nested routes.
14-
const APP_PROXY_BASE_PATH_RE = /(.*?\/apps\/[^/]+)(?:\/|$)/;
13+
import { getAppProxyBasePathFromPathname } from "@/common/appProxyBasePath";
1514

16-
/**
17-
* Returns the path prefix up to and including `/apps/<slug>`.
18-
*
19-
* Examples:
20-
* - "/@u/ws/apps/mux/" -> "/@u/ws/apps/mux"
21-
* - "/@u/ws/apps/mux/settings" -> "/@u/ws/apps/mux"
22-
*/
23-
export function getAppProxyBasePathFromPathname(pathname: string): string | null {
24-
const match = APP_PROXY_BASE_PATH_RE.exec(pathname);
25-
if (!match) {
26-
return null;
27-
}
28-
29-
const basePath = match[1];
30-
return basePath.endsWith("/") ? basePath.slice(0, -1) : basePath;
31-
}
15+
export { getAppProxyBasePathFromPathname } from "@/common/appProxyBasePath";
3216

3317
function stripTrailingSlashes(url: string): string {
3418
return url.replace(/\/+$/, "");
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { getAppProxyBasePathFromPathname, stripAppProxyBasePath } from "@/common/appProxyBasePath";
2+
3+
export const INITIAL_APP_PROXY_BASE_PATH =
4+
typeof window === "undefined" ? null : getAppProxyBasePathFromPathname(window.location.pathname);
5+
6+
function normalizeRootRelativePath(pathname: string): string {
7+
return pathname.startsWith("/") ? pathname : `/${pathname}`;
8+
}
9+
10+
export function stripInitialAppProxyBasePathFromPathname(pathname: string): string {
11+
if (!INITIAL_APP_PROXY_BASE_PATH) {
12+
return pathname;
13+
}
14+
15+
const strippedPathname = stripAppProxyBasePath(pathname);
16+
return strippedPathname.basePath === INITIAL_APP_PROXY_BASE_PATH
17+
? strippedPathname.routePathname
18+
: pathname;
19+
}
20+
21+
export function prependInitialAppProxyBasePath(pathname: string): string {
22+
const rootRelativePathname = normalizeRootRelativePath(pathname);
23+
return INITIAL_APP_PROXY_BASE_PATH
24+
? `${INITIAL_APP_PROXY_BASE_PATH}${rootRelativePathname}`
25+
: rootRelativePathname;
26+
}
27+
28+
export function resolveBrowserAssetUrl(pathname: string): string {
29+
const proxiedPathname = prependInitialAppProxyBasePath(pathname);
30+
return typeof document === "undefined"
31+
? proxiedPathname
32+
: new URL(proxiedPathname, document.baseURI).toString();
33+
}

src/browser/utils/terminal.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import type { RouterClient } from "@orpc/server";
1010
import type { AppRouter } from "@/node/orpc/router";
11+
import { resolveBrowserAssetUrl } from "@/browser/utils/frontendBasePath";
1112

1213
type APIClient = RouterClient<AppRouter>;
1314

@@ -37,8 +38,9 @@ export function openTerminalPopout(api: APIClient, workspaceId: string, sessionI
3738
// In browser mode, we must open the window client-side
3839
// The backend cannot open a window on the user's client
3940
const params = new URLSearchParams({ workspaceId, sessionId });
41+
const terminalUrl = resolveBrowserAssetUrl(`terminal.html?${params.toString()}`);
4042
window.open(
41-
`/terminal.html?${params.toString()}`,
43+
terminalUrl,
4244
`terminal-${workspaceId}-${Date.now()}`,
4345
"width=1000,height=600,popup=yes"
4446
);
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { describe, expect, test } from "bun:test";
2+
3+
import { getAppProxyBasePathFromPathname, stripAppProxyBasePath } from "./appProxyBasePath";
4+
5+
describe("appProxyBasePath", () => {
6+
test("detects the app proxy base path", () => {
7+
expect(getAppProxyBasePathFromPathname("/@u/ws/apps/mux/")).toBe("/@u/ws/apps/mux");
8+
expect(getAppProxyBasePathFromPathname("/@u/ws/apps/mux/settings/general")).toBe(
9+
"/@u/ws/apps/mux"
10+
);
11+
expect(getAppProxyBasePathFromPathname("/@u/ws.agent/apps/mux/")).toBe("/@u/ws.agent/apps/mux");
12+
expect(getAppProxyBasePathFromPathname("/@u/ws/agent/apps/mux/foo")).toBe(
13+
"/@u/ws/agent/apps/mux"
14+
);
15+
expect(getAppProxyBasePathFromPathname("/@u/ws/apps/mux")).toBe("/@u/ws/apps/mux");
16+
});
17+
18+
test("rejects root routes and unanchored app segments", () => {
19+
expect(getAppProxyBasePathFromPathname("/projects/apps/other")).toBeNull();
20+
expect(getAppProxyBasePathFromPathname("/")).toBeNull();
21+
expect(getAppProxyBasePathFromPathname("/settings")).toBeNull();
22+
expect(getAppProxyBasePathFromPathname("//bad.example/@u/ws/apps/mux")).toBeNull();
23+
});
24+
25+
test("strips the app proxy base path from prefix-only requests", () => {
26+
expect(stripAppProxyBasePath("/@u/ws/apps/mux/")).toEqual({
27+
basePath: "/@u/ws/apps/mux",
28+
routePathname: "/",
29+
});
30+
expect(stripAppProxyBasePath("/@u/ws/apps/mux")).toEqual({
31+
basePath: "/@u/ws/apps/mux",
32+
routePathname: "/",
33+
});
34+
});
35+
36+
test("strips the app proxy base path and preserves the route suffix", () => {
37+
expect(stripAppProxyBasePath("/@u/ws/apps/mux/settings/general")).toEqual({
38+
basePath: "/@u/ws/apps/mux",
39+
routePathname: "/settings/general",
40+
});
41+
expect(stripAppProxyBasePath("/@u/ws.agent/apps/mux/")).toEqual({
42+
basePath: "/@u/ws.agent/apps/mux",
43+
routePathname: "/",
44+
});
45+
expect(stripAppProxyBasePath("/@u/ws/agent/apps/mux/foo")).toEqual({
46+
basePath: "/@u/ws/agent/apps/mux",
47+
routePathname: "/foo",
48+
});
49+
});
50+
51+
test("returns the original pathname when no app proxy base path is present", () => {
52+
expect(stripAppProxyBasePath("/projects/apps/other")).toEqual({
53+
basePath: null,
54+
routePathname: "/projects/apps/other",
55+
});
56+
expect(stripAppProxyBasePath("/")).toEqual({ basePath: null, routePathname: "/" });
57+
expect(stripAppProxyBasePath("/settings")).toEqual({
58+
basePath: null,
59+
routePathname: "/settings",
60+
});
61+
expect(stripAppProxyBasePath("//bad.example/@u/ws/apps/mux")).toEqual({
62+
basePath: null,
63+
routePathname: "//bad.example/@u/ws/apps/mux",
64+
});
65+
});
66+
});

src/common/appProxyBasePath.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
const APP_PROXY_BASE_PATH_RE = /^\/@[^/]+\/[^/]+(?:\/[^/]+)?\/apps\/[^/]+(?:\/|$)/;
2+
3+
function hasUnsafeLeadingDoubleSlash(pathname: string): boolean {
4+
return pathname.startsWith("//");
5+
}
6+
7+
function stripTrailingSlash(pathname: string): string {
8+
return pathname.length > 1 && pathname.endsWith("/") ? pathname.slice(0, -1) : pathname;
9+
}
10+
11+
export function getAppProxyBasePathFromPathname(pathname: string): string | null {
12+
if (hasUnsafeLeadingDoubleSlash(pathname)) {
13+
return null;
14+
}
15+
16+
const match = APP_PROXY_BASE_PATH_RE.exec(pathname);
17+
if (!match) {
18+
return null;
19+
}
20+
21+
return stripTrailingSlash(match[0]);
22+
}
23+
24+
export function stripAppProxyBasePath(pathname: string): {
25+
basePath: string | null;
26+
routePathname: string;
27+
} {
28+
const basePath = getAppProxyBasePathFromPathname(pathname);
29+
if (!basePath) {
30+
return { basePath: null, routePathname: pathname };
31+
}
32+
33+
const routePathname = pathname.slice(basePath.length);
34+
return {
35+
basePath,
36+
routePathname: routePathname.length === 0 ? "/" : routePathname,
37+
};
38+
}

terminal.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,6 @@
1919
</head>
2020
<body>
2121
<div id="root"></div>
22-
<script type="module" src="/src/browser/terminal-window.tsx"></script>
22+
<script type="module" src="src/browser/terminal-window.tsx"></script>
2323
</body>
2424
</html>

0 commit comments

Comments
 (0)