Skip to content

Commit 7cc467e

Browse files
committed
fix(dashboard/contract): derive shell endpoints from runtime config so non-default base paths work
Reported: hosting Forge on port 7901 with the dashboard mounted at a non-default base produced 404s on /api/dashboard/v1 because the React shell hardcoded both that path and the React Router basename to /dashboard. Server: extension.go's makeShellSPAHandler now buffers index.html and injects a small <script> just before </head> that exposes window.__FORGE_DASHBOARD__ = { basePath, contractBase, shellBase }, derived from e.config.BasePath. Shell: - runtime/config.ts (new): reads the injected globals at module load and exports basePath / contractBase / shellBase. Falls back to /dashboard so Vite dev mode + unit tests + direct module imports still resolve. - contract/client.ts, contract/sse.ts: default baseURL now comes from contractBase instead of the hardcoded literal. - auth/principal.ts: /principal fetch is `${contractBase}/principal`. - App.tsx: BrowserRouter basename comes from shellBase. Test setup pins __FORGE_DASHBOARD__ to /api/dashboard/v1 so the existing MSW handlers keep matching. 24 React tests + 37 Go packages green; pnpm lint and pnpm build clean.
1 parent 2f9709d commit 7cc467e

7 files changed

Lines changed: 81 additions & 8 deletions

File tree

extensions/dashboard/contract/shell/src/App.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { useContractGraph } from "./contract/hooks";
1212
import { LoadingNode, ErrorNode } from "./runtime/fallbacks";
1313
import { usePrincipalStore } from "./auth/principal";
1414
import { useThemeStore } from "@/lib/theme";
15+
import { shellBase } from "./runtime/config";
1516

1617
const DEFAULT_CONTRIBUTOR = "core-contract";
1718

@@ -55,7 +56,7 @@ export function App() {
5556
return (
5657
<QueryClientProvider client={queryClient}>
5758
<IntentRegistryProvider value={registry}>
58-
<BrowserRouter basename="/dashboard/contract/app">
59+
<BrowserRouter basename={shellBase}>
5960
<Routes>
6061
<Route path="*" element={<PageRoute />} />
6162
</Routes>

extensions/dashboard/contract/shell/src/auth/principal.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { create } from "zustand";
2+
import { contractBase } from "../runtime/config";
23
import type { Principal } from "../contract/types";
34

45
interface PrincipalState {
@@ -14,7 +15,7 @@ export const usePrincipalStore = create<PrincipalState>((set) => ({
1415
error: null,
1516
async load(fetcher = fetch) {
1617
try {
17-
const res = await fetcher("/api/dashboard/v1/principal", { credentials: "include" });
18+
const res = await fetcher(`${contractBase}/principal`, { credentials: "include" });
1819
if (!res.ok) {
1920
set({ loaded: true, error: `HTTP ${res.status}`, principal: null });
2021
return;

extensions/dashboard/contract/shell/src/contract/client.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { contractBase } from "../runtime/config";
12
import type {
23
ContractError,
34
EnvelopeResponse,
@@ -41,7 +42,7 @@ export class ContractClient {
4142
private csrfToken: string | null = null;
4243

4344
constructor(opts: ClientOptions = {}) {
44-
this.baseURL = opts.baseURL ?? "/api/dashboard/v1";
45+
this.baseURL = opts.baseURL ?? contractBase;
4546
this.explicitFetcher = opts.fetcher;
4647
}
4748

extensions/dashboard/contract/shell/src/contract/sse.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { contractBase } from "../runtime/config";
12
import type { StreamEvent } from "./types";
23

34
export interface SubscriptionMuxOptions {
@@ -24,7 +25,7 @@ export class SubscriptionMux {
2425
private active = new Map<string, PendingSub>();
2526

2627
constructor(opts: SubscriptionMuxOptions = {}) {
27-
this.baseURL = opts.baseURL ?? "/api/dashboard/v1";
28+
this.baseURL = opts.baseURL ?? contractBase;
2829
this.explicitEventSource = opts.eventSource;
2930
this.explicitFetcher = opts.fetcher;
3031
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
// Runtime configuration injected by the Go server before the bundle loads.
2+
//
3+
// The dashboard extension may be mounted at any base path (e.g. /dashboard,
4+
// /admin, /ops) — even rebased behind a reverse proxy. The Go SPA handler
5+
// inlines a <script> tag in index.html that sets window.__FORGE_DASHBOARD__
6+
// with the resolved paths. The shell reads it once at module load and uses
7+
// the values for the API client baseURL and the React Router basename.
8+
//
9+
// Falls back to /dashboard so unit tests, Vite dev mode, and direct module
10+
// imports keep working without server-side injection.
11+
12+
interface InjectedConfig {
13+
basePath?: string;
14+
contractBase?: string;
15+
shellBase?: string;
16+
}
17+
18+
declare global {
19+
interface Window {
20+
__FORGE_DASHBOARD__?: InjectedConfig;
21+
}
22+
}
23+
24+
const FALLBACK_BASE = "/dashboard";
25+
26+
function readInjected(): InjectedConfig {
27+
if (typeof window === "undefined") return {};
28+
return window.__FORGE_DASHBOARD__ ?? {};
29+
}
30+
31+
const injected = readInjected();
32+
33+
export const basePath: string = injected.basePath ?? FALLBACK_BASE;
34+
export const contractBase: string = injected.contractBase ?? `${FALLBACK_BASE}/api/dashboard/v1`;
35+
export const shellBase: string = injected.shellBase ?? `${FALLBACK_BASE}/contract/app`;

extensions/dashboard/contract/shell/test/setup.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,16 @@ import "@testing-library/jest-dom/vitest";
22
import { afterEach } from "vitest";
33
import { cleanup } from "@testing-library/react";
44

5+
// Pin the injected runtime config so tests target /api/dashboard/v1
6+
// (matching the existing MSW handlers) instead of inheriting the production
7+
// fallback /dashboard/api/dashboard/v1 from runtime/config.ts. Must run before
8+
// any module under test imports runtime/config; setupFiles guarantees that.
9+
(window as unknown as { __FORGE_DASHBOARD__: Record<string, string> }).__FORGE_DASHBOARD__ = {
10+
basePath: "",
11+
contractBase: "/api/dashboard/v1",
12+
shellBase: "/dashboard/contract/app",
13+
};
14+
515
// jsdom polyfills required by Radix UI primitives.
616
class ResizeObserverStub {
717
observe() {}

extensions/dashboard/extension.go

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
package dashboard
22

33
import (
4+
"bytes"
45
"context"
6+
"encoding/json"
57
"errors"
68
"fmt"
7-
"io"
89
"io/fs"
910
"net/http"
1011
"net/url"
@@ -1730,17 +1731,40 @@ func (e *Extension) makeShellStaticHandler(shellFS fs.FS, stripPrefix string) ht
17301731

17311732
// makeShellSPAHandler returns the SPA index.html for any path under
17321733
// /{base}/contract/app/*. React Router handles the client-side routing.
1734+
//
1735+
// The handler injects a small bootstrap script just before </head> that
1736+
// surfaces the configured BasePath to the shell. This lets the React shell
1737+
// derive its API endpoint and Router basename at runtime instead of baking
1738+
// /dashboard into the bundle — required when the dashboard is mounted at a
1739+
// non-default base (e.g. /admin) or rebased behind a reverse proxy.
17331740
func (e *Extension) makeShellSPAHandler(shellFS fs.FS) http.HandlerFunc {
17341741
return func(w http.ResponseWriter, r *http.Request) {
1735-
f, err := shellFS.Open("index.html")
1742+
raw, err := fs.ReadFile(shellFS, "index.html")
17361743
if err != nil {
17371744
http.Error(w, "shell index missing — has `pnpm build` been run inside extensions/dashboard/contract/shell?", http.StatusInternalServerError)
17381745
return
17391746
}
1740-
defer f.Close()
1747+
// Build the inline bootstrap. Marshal through JSON so the basePath is
1748+
// safely string-escaped even if it ever contains odd characters.
1749+
cfg := map[string]string{
1750+
"basePath": e.config.BasePath,
1751+
"contractBase": e.config.BasePath + "/api/dashboard/v1",
1752+
"shellBase": e.config.BasePath + "/contract/app",
1753+
}
1754+
cfgJSON, _ := json.Marshal(cfg)
1755+
bootstrap := []byte("<script>window.__FORGE_DASHBOARD__=" + string(cfgJSON) + ";</script>")
1756+
1757+
out := raw
1758+
if idx := bytes.Index(out, []byte("</head>")); idx >= 0 {
1759+
out = append(out[:idx:idx], append(bootstrap, out[idx:]...)...)
1760+
} else {
1761+
// No </head> (unlikely with Vite output) — prepend the bootstrap so
1762+
// it still runs before any module script.
1763+
out = append(bootstrap, out...)
1764+
}
17411765
w.Header().Set("Content-Type", "text/html; charset=utf-8")
17421766
w.Header().Set("Cache-Control", "no-cache")
1743-
_, _ = io.Copy(w, f)
1767+
_, _ = w.Write(out)
17441768
}
17451769
}
17461770

0 commit comments

Comments
 (0)