Skip to content

Commit 23530e8

Browse files
johnstcnhugodutka
andauthored
fix: improve support for running agentapi under a subpath (#40)
Co-authored-by: Hugo Dutka <hugo@coder.com>
1 parent a8c8cdb commit 23530e8

File tree

5 files changed

+99
-10
lines changed

5 files changed

+99
-10
lines changed

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ BASE_PATH ?= /magic-base-path-placeholder
66

77
$(CHAT_SOURCES_STAMP): $(CHAT_SOURCES)
88
@echo "Chat sources changed. Running build steps..."
9-
cd chat && BASE_PATH=${BASE_PATH} bun run build
9+
cd chat && NEXT_PUBLIC_BASE_PATH="${BASE_PATH}" bun run build
1010
rm -rf lib/httpapi/chat && mkdir -p lib/httpapi/chat && touch lib/httpapi/chat/marker
1111
cp -r chat/out/. lib/httpapi/chat/
1212
touch $@

chat/next.config.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import type { NextConfig } from "next";
2-
const basePath = process.env.BASE_PATH ?? "/chat";
2+
let basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? "/chat";
3+
if (basePath.endsWith("/")) {
4+
basePath = basePath.slice(0, -1);
5+
}
36

47
const nextConfig: NextConfig = {
58
// Enable static exports

chat/src/components/chat-provider.tsx

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,13 +51,47 @@ interface ChatContextValue {
5151

5252
const ChatContext = createContext<ChatContextValue | undefined>(undefined);
5353

54+
const useAgentAPIUrl = (): string => {
55+
const searchParams = useSearchParams();
56+
const paramsUrl = searchParams.get("url");
57+
if (paramsUrl) {
58+
return paramsUrl;
59+
}
60+
const basePath = process.env.NEXT_PUBLIC_BASE_PATH;
61+
if (!basePath) {
62+
throw new Error(
63+
"agentAPIUrl is not set. Please set the url query parameter to the URL of the AgentAPI or the NEXT_PUBLIC_BASE_PATH environment variable."
64+
);
65+
}
66+
// NOTE(cian): We use '../' here to construct the agent API URL relative
67+
// to the chat's location. Let's say the app is hosted on a subpath
68+
// `/@admin/workspace.agent/apps/ccw/`. When you visit this URL you get
69+
// redirected to `/@admin/workspace.agent/apps/ccw/chat/embed`. This serves
70+
// this React application, but it needs to know where the agent API is hosted.
71+
// This will be at the root of where the application is mounted e.g.
72+
// `/@admin/workspace.agent/apps/ccw/`. Previously we used
73+
// `window.location.origin` but this assumes that the application owns the
74+
// entire origin.
75+
// See: https://github.com/coder/coder/issues/18779#issuecomment-3133290494 for more context.
76+
let chatURL: string = new URL(basePath, window.location.origin).toString();
77+
// NOTE: trailing slashes and relative URLs are tricky.
78+
// https://developer.mozilla.org/en-US/docs/Web/API/URL_API/Resolving_relative_references#current_directory_relative
79+
if (!chatURL.endsWith("/")) {
80+
chatURL += "/";
81+
}
82+
const agentAPIURL = new URL("..", chatURL).toString();
83+
if (agentAPIURL.endsWith("/")) {
84+
return agentAPIURL.slice(0, -1);
85+
}
86+
return agentAPIURL;
87+
};
88+
5489
export function ChatProvider({ children }: PropsWithChildren) {
5590
const [messages, setMessages] = useState<(Message | DraftMessage)[]>([]);
5691
const [loading, setLoading] = useState<boolean>(false);
5792
const [serverStatus, setServerStatus] = useState<ServerStatus>("unknown");
5893
const eventSourceRef = useRef<EventSource | null>(null);
59-
const searchParams = useSearchParams();
60-
const agentAPIUrl = searchParams.get("url") || window.location.origin;
94+
const agentAPIUrl = useAgentAPIUrl();
6195

6296
// Set up SSE connection to the events endpoint
6397
useEffect(() => {

lib/httpapi/server.go

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import (
66
"fmt"
77
"log/slog"
88
"net/http"
9+
"net/url"
10+
"strings"
911
"sync"
1012
"time"
1113

@@ -33,6 +35,7 @@ type Server struct {
3335
agentio *termexec.Process
3436
agentType mf.AgentType
3537
emitter *EventEmitter
38+
chatBasePath string
3639
}
3740

3841
func (s *Server) GetOpenAPI() string {
@@ -95,14 +98,20 @@ func NewServer(ctx context.Context, agentType mf.AgentType, process *termexec.Pr
9598
agentio: process,
9699
agentType: agentType,
97100
emitter: emitter,
101+
chatBasePath: strings.TrimSuffix(chatBasePath, "/"),
98102
}
99103

100104
// Register API routes
101-
s.registerRoutes(chatBasePath)
105+
s.registerRoutes()
102106

103107
return s
104108
}
105109

110+
// Handler returns the underlying chi.Router for testing purposes.
111+
func (s *Server) Handler() http.Handler {
112+
return s.router
113+
}
114+
106115
func (s *Server) StartSnapshotLoop(ctx context.Context) {
107116
s.conversation.StartSnapshotLoop(ctx)
108117
go func() {
@@ -116,7 +125,7 @@ func (s *Server) StartSnapshotLoop(ctx context.Context) {
116125
}
117126

118127
// registerRoutes sets up all API endpoints
119-
func (s *Server) registerRoutes(chatBasePath string) {
128+
func (s *Server) registerRoutes() {
120129
// GET /status endpoint
121130
huma.Get(s.api, "/status", s.getStatus, func(o *huma.Operation) {
122131
o.Description = "Returns the current status of the agent."
@@ -158,7 +167,7 @@ func (s *Server) registerRoutes(chatBasePath string) {
158167
s.router.Handle("/", http.HandlerFunc(s.redirectToChat))
159168

160169
// Serve static files for the chat interface under /chat
161-
s.registerStaticFileRoutes(chatBasePath)
170+
s.registerStaticFileRoutes()
162171
}
163172

164173
// getStatus handles GET /status
@@ -305,14 +314,20 @@ func (s *Server) Stop(ctx context.Context) error {
305314
}
306315

307316
// registerStaticFileRoutes sets up routes for serving static files
308-
func (s *Server) registerStaticFileRoutes(chatBasePath string) {
309-
chatHandler := FileServerWithIndexFallback(chatBasePath)
317+
func (s *Server) registerStaticFileRoutes() {
318+
chatHandler := FileServerWithIndexFallback(s.chatBasePath)
310319

311320
// Mount the file server at /chat
312321
s.router.Handle("/chat", http.StripPrefix("/chat", chatHandler))
313322
s.router.Handle("/chat/*", http.StripPrefix("/chat", chatHandler))
314323
}
315324

316325
func (s *Server) redirectToChat(w http.ResponseWriter, r *http.Request) {
317-
http.Redirect(w, r, "/chat/embed", http.StatusTemporaryRedirect)
326+
rdir, err := url.JoinPath(s.chatBasePath, "embed")
327+
if err != nil {
328+
s.logger.Error("Failed to construct redirect URL", "error", err)
329+
http.Error(w, "Failed to redirect", http.StatusInternalServerError)
330+
return
331+
}
332+
http.Redirect(w, r, rdir, http.StatusTemporaryRedirect)
318333
}

lib/httpapi/server_test.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import (
66
"fmt"
77
"io"
88
"log/slog"
9+
"net/http"
10+
"net/http/httptest"
911
"os"
1012
"sort"
1113
"testing"
@@ -73,3 +75,38 @@ func TestOpenAPISchema(t *testing.T) {
7375

7476
require.Equal(t, currentSchema, diskSchema)
7577
}
78+
79+
func TestServer_redirectToChat(t *testing.T) {
80+
cases := []struct {
81+
name string
82+
chatBasePath string
83+
expectedResponseCode int
84+
expectedLocation string
85+
}{
86+
{"default base path", "/chat", http.StatusTemporaryRedirect, "/chat/embed"},
87+
{"custom base path", "/custom", http.StatusTemporaryRedirect, "/custom/embed"},
88+
}
89+
for _, tc := range cases {
90+
t.Run(tc.name, func(t *testing.T) {
91+
t.Parallel()
92+
tCtx := logctx.WithLogger(context.Background(), slog.New(slog.NewTextHandler(os.Stdout, nil)))
93+
s := httpapi.NewServer(tCtx, msgfmt.AgentTypeClaude, nil, 0, tc.chatBasePath)
94+
tsServer := httptest.NewServer(s.Handler())
95+
t.Cleanup(tsServer.Close)
96+
97+
client := &http.Client{
98+
CheckRedirect: func(req *http.Request, via []*http.Request) error {
99+
return http.ErrUseLastResponse
100+
},
101+
}
102+
resp, err := client.Get(tsServer.URL + "/")
103+
require.NoError(t, err, "unexpected error making GET request")
104+
t.Cleanup(func() {
105+
_ = resp.Body.Close()
106+
})
107+
require.Equal(t, tc.expectedResponseCode, resp.StatusCode, "expected %d status code", tc.expectedResponseCode)
108+
loc := resp.Header.Get("Location")
109+
require.Equal(t, tc.expectedLocation, loc, "expected Location %q, got %q", tc.expectedLocation, loc)
110+
})
111+
}
112+
}

0 commit comments

Comments
 (0)