Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,6 @@
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
<script type="module" src="./src/main.tsx"></script>
</body>
</html>
10 changes: 8 additions & 2 deletions app/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef } from "react";
import { Helmet } from "react-helmet";
import { BrowserRouter, Route, Routes } from "react-router-dom";
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
import Callback from "./routes/callback";
import RunRoute from "./routes/run";
import RunsRoute from "./routes/runs";
Expand Down Expand Up @@ -58,6 +58,7 @@ import {
getConfiguredAgentEndpoint,
getConfiguredDefaultRunnerEndpoint,
} from "./lib/appConfig";
import { APP_ROUTE_PATHS, getAppRouterBasename } from "./lib/appBase";

const queryClient = new QueryClient();

Expand All @@ -73,10 +74,15 @@ export interface AppProps {
}

function AppRouter() {
const basename = getAppRouterBasename();
return (
<BrowserRouter>
<BrowserRouter basename={basename === "/" ? undefined : basename}>
<Routes>
<Route path="/" element={<MainPage />} />
<Route
path={APP_ROUTE_PATHS.indexEntry}
element={<Navigate replace to={APP_ROUTE_PATHS.home} />}
/>
<Route path="/runs" element={<RunsRoute />} />
<Route path="/runs/:runName" element={<RunRoute />} />
<Route path="/runs/:runName/edit" element={<MainPage />} />
Expand Down
5 changes: 2 additions & 3 deletions app/src/auth/oidcConfig.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { googleClientManager } from "../lib/googleClientManager";
import { getOidcCallbackUrl } from "../lib/appBase";

export type OidcConfig = {
discoveryUrl: string;
Expand Down Expand Up @@ -118,9 +119,7 @@ export class OidcConfigManager {
clientId: sanitizeString(stored?.clientId) ?? "",
clientSecret: sanitizeString(stored?.clientSecret),
scope: sanitizeString(stored?.scope) ?? "",
redirectUri:
sanitizeString(stored?.redirectUri) ??
new URL("/oidc/callback", window.location.origin).toString(),
redirectUri: sanitizeString(stored?.redirectUri) ?? getOidcCallbackUrl(),
extraAuthParams:
mergedExtra && Object.keys(mergedExtra).length > 0
? mergedExtra
Expand Down
5 changes: 3 additions & 2 deletions app/src/components/NotFound.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Container, Heading, Link, Text } from "@radix-ui/themes";
import { Link as RouterLink } from "react-router-dom";

const NotFound = () => {
return (
Expand All @@ -14,8 +15,8 @@ const NotFound = () => {
</Text>
<Text as="p" mt="4">
Click{" "}
<Link href="/" underline="always">
here
<Link asChild underline="always">
<RouterLink to="/">here</RouterLink>
</Link>{" "}
to go back home.
</Text>
Expand Down
63 changes: 63 additions & 0 deletions app/src/lib/appBase.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";

import {
APP_ROUTE_PATHS,
deriveAppBasePath,
getAppPath,
getOidcCallbackUrl,
normalizeAppIndexUrl,
resolveAppUrl,
} from "./appBase";

describe("deriveAppBasePath", () => {
it("uses the bundled asset location outside development", () => {
expect(
deriveAppBasePath({
dev: false,
pathname: "/runme-dev-assets/runs",
moduleUrl:
"https://storage.googleapis.com/runme-dev-assets/index.BdN4INbO.js",
}),
).toBe("/runme-dev-assets/");
});

it("falls back to the current document path in development", () => {
expect(
deriveAppBasePath({
dev: true,
pathname: "/runme-dev-assets/index.html",
}),
).toBe("/runme-dev-assets/");
});
});

describe("app base URL helpers", () => {
it("resolves app-relative paths beneath the mounted base path", () => {
window.history.replaceState(null, "", "/runme-dev-assets/index.html");
const origin = window.location.origin;

expect(getAppPath(APP_ROUTE_PATHS.oidcCallback)).toBe(
"/runme-dev-assets/oidc/callback",
);
expect(resolveAppUrl("configs/app-configs.yaml").toString()).toBe(
`${origin}/runme-dev-assets/configs/app-configs.yaml`,
);
expect(getOidcCallbackUrl()).toBe(
`${origin}/runme-dev-assets/oidc/callback`,
);
});

it("normalizes index.html entry URLs to the directory path", () => {
window.history.replaceState(
null,
"",
"/runme-dev-assets/index.html?doc=foo#section-1",
);

normalizeAppIndexUrl();

expect(window.location.pathname).toBe("/runme-dev-assets/");
expect(window.location.search).toBe("?doc=foo");
expect(window.location.hash).toBe("#section-1");
});
});
121 changes: 121 additions & 0 deletions app/src/lib/appBase.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
const ROOT_PATH = "/";

export const APP_ROUTE_PATHS = {
home: "/",
indexEntry: "/index.html",
authStatus: "/auth/status",
oidcCallback: "/oidc/callback",
runs: "/runs",
run: (runName: string) => `/runs/${runName}`,
editRun: (runName: string) => `/runs/${runName}/edit`,
} as const;

function ensureTrailingSlash(pathname: string): string {
if (!pathname || pathname === ROOT_PATH) {
return ROOT_PATH;
}
return pathname.endsWith("/") ? pathname : `${pathname}/`;
}

function stripTrailingSlash(pathname: string): string {
if (!pathname || pathname === ROOT_PATH) {
return ROOT_PATH;
}
return pathname.endsWith("/") ? pathname.slice(0, -1) : pathname;
}

function getBasePathFromModuleUrl(moduleUrl: string): string {
const url = new URL(moduleUrl);
if (!["http:", "https:"].includes(url.protocol)) {
return ROOT_PATH;
}
return ensureTrailingSlash(new URL(".", url).pathname);
}

function getBasePathFromLocation(pathname: string): string {
if (!pathname || pathname === ROOT_PATH) {
return ROOT_PATH;
}

const lastSlashIndex = pathname.lastIndexOf("/");
const lastSegment = pathname.slice(lastSlashIndex + 1);
if (lastSegment === "index.html") {
return ensureTrailingSlash(pathname.slice(0, -lastSegment.length));
}
if (lastSegment.includes(".")) {
return ensureTrailingSlash(pathname.slice(0, lastSlashIndex + 1));
}
return ROOT_PATH;
}

export function deriveAppBasePath(options?: {
dev?: boolean;
moduleUrl?: string;
pathname?: string;
}): string {
if (options?.dev) {
return getBasePathFromLocation(options.pathname ?? ROOT_PATH);
}
if (options?.moduleUrl) {
const fromModuleUrl = getBasePathFromModuleUrl(options.moduleUrl);
if (fromModuleUrl !== ROOT_PATH) {
return fromModuleUrl;
}
}
return getBasePathFromLocation(options?.pathname ?? ROOT_PATH);
}

let cachedAppBasePath: string | null = null;

export function getAppBasePath(): string {
if (cachedAppBasePath) {
return cachedAppBasePath;
}

if (typeof window === "undefined") {
return ROOT_PATH;
}

cachedAppBasePath = deriveAppBasePath({
dev: import.meta.env.DEV,
moduleUrl: import.meta.url,
pathname: window.location.pathname,
});
return cachedAppBasePath;
}

export function getAppRouterBasename(): string {
return stripTrailingSlash(getAppBasePath());
}

export function resolveAppUrl(path = ""): URL {
if (typeof window === "undefined") {
return new URL(path || ROOT_PATH, "http://localhost");
}
const baseUrl = new URL(getAppBasePath(), window.location.origin);
const normalizedPath = path.replace(/^\/+/, "");
return new URL(normalizedPath, baseUrl);
}

export function getAppPath(path = ""): string {
const url = resolveAppUrl(path);
return `${url.pathname}${url.search}${url.hash}`;
}

export function getOidcCallbackUrl(): string {
return resolveAppUrl(APP_ROUTE_PATHS.oidcCallback).toString();
}

export function normalizeAppIndexUrl(): void {
if (typeof window === "undefined") {
return;
}

const { pathname, search, hash } = window.location;
if (!pathname.endsWith("/index.html")) {
return;
}

const normalizedPath = pathname.slice(0, -"/index.html".length) || ROOT_PATH;
window.history.replaceState(null, "", `${ensureTrailingSlash(normalizedPath)}${search}${hash}`);
}
10 changes: 4 additions & 6 deletions app/src/lib/appConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import YAML from "yaml";

import type { OidcConfig } from "../auth/oidcConfig";
import { OIDC_STORAGE_KEY, oidcConfigManager } from "../auth/oidcConfig";
import { getOidcCallbackUrl, resolveAppUrl } from "./appBase";
import type { GoogleOAuthClientConfig } from "./googleClientManager";
import {
GOOGLE_CLIENT_STORAGE_KEY,
Expand All @@ -22,7 +23,7 @@ const LEGACY_RUNNERS_STORAGE_KEY = "aisre/runners";
const DEFAULT_RUNNER_NAME_STORAGE_KEY = "runme/defaultRunner";
const LEGACY_DEFAULT_RUNNER_NAME_STORAGE_KEY = "aisre/defaultRunner";
const DEFAULT_RUNNER_NAME = "default";
export const APP_CONFIG_PATH_DEFAULT = "/configs/app-configs.yaml";
export const APP_CONFIG_PATH_DEFAULT = "configs/app-configs.yaml";

export interface OidcGenericRuntimeConfig {
clientId: string;
Expand Down Expand Up @@ -347,7 +348,7 @@ export function getDefaultAppConfigUrl(): string {
if (typeof window === "undefined") {
return APP_CONFIG_PATH_DEFAULT;
}
return new URL(APP_CONFIG_PATH_DEFAULT, window.location.origin).toString();
return resolveAppUrl(APP_CONFIG_PATH_DEFAULT).toString();
}

export function applyAppConfig(
Expand Down Expand Up @@ -393,10 +394,7 @@ export function applyAppConfig(
}
if (Object.keys(oidcConfig).length > 0) {
if (!oidcConfig.redirectUri && typeof window !== "undefined") {
oidcConfig.redirectUri = new URL(
"/oidc/callback",
window.location.origin,
).toString();
oidcConfig.redirectUri = getOidcCallbackUrl();
}
try {
oidc = oidcConfigManager.setConfig(oidcConfig);
Expand Down
3 changes: 3 additions & 0 deletions app/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
maybeSetAppConfig,
setAppConfig,
} from "./lib/appConfig";
import { normalizeAppIndexUrl } from "./lib/appBase";

type AppConfigApi = {
getDefaultConfigUrl: () => string;
Expand Down Expand Up @@ -48,6 +49,8 @@ const noopBridge: RendererContext<void> = {
};
setContext(noopBridge);

normalizeAppIndexUrl();

// Initialize auth, then render
maybeSetAppConfig().finally(() => {
getBrowserAdapter()
Expand Down
3 changes: 2 additions & 1 deletion app/src/routes/callback.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useEffect } from "react";
import { useNavigate } from "react-router-dom";

import { getBrowserAdapter } from "../browserAdapter.client";
import { APP_ROUTE_PATHS } from "../lib/appBase";

/**
* Apps running in-browser auth still need to implement a handler at a callback URL. The
Expand All @@ -18,7 +19,7 @@ export default function Callback() {
if (controller.signal.aborted) return;

// Navigate back to the main page after handling the callback
navigate("/");
navigate(APP_ROUTE_PATHS.home);
});

// If the user navigates away on their own, cancel the post-callback navigation above
Expand Down
1 change: 1 addition & 0 deletions app/vite.config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import svgr from "vite-plugin-svgr";

// https://vite.dev/config/
export default defineConfig({
base: "./",
optimizeDeps: {
exclude: ["@runmedev/renderers"],
},
Expand Down
Loading