Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
77 changes: 72 additions & 5 deletions internal/registry/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"embed"
"fmt"
"io"
"io/fs"
"log/slog"
"net/http"
Expand All @@ -24,16 +25,82 @@ import (
//go:embed all:ui/dist
var embeddedUI embed.FS

// createUIHandler creates an HTTP handler for serving the embedded UI files
// newUIHandler builds the try-files HTTP handler from any fs.FS.
// Separated from createUIHandler to allow unit testing with a fake filesystem.
//
// Routing mirrors NGINX's try_files $uri $uri.html /index.html:
// 1. Exact file match (e.g. _next/static/chunk.abc123.js)
// 2. <path>.html match (Next.js static export: /deployed -> deployed.html)
// 3. Missing path with a file extension -> 404 (avoids serving index.html for broken asset refs)
// 4. Anything else -> index.html (SPA client-side routing fallback)
func newUIHandler(uiFS fs.FS) (http.Handler, error) {
fileServer := http.FileServer(http.FS(uiFS))

return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Normalize trailing slash so /foo and /foo/ resolve consistently.
// TrailingSlashMiddleware only covers API routes, not UI routes.
path := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/"), "/")

// 1. Try the exact path as a file (not a directory).
if f, err := uiFS.Open(path); err == nil {
info, err := f.Stat()
f.Close()
if err == nil && !info.IsDir() {
fileServer.ServeHTTP(w, r)
return
}
}

// 2. Try <path>.html (Next.js static export: /deployed -> deployed.html).
if path != "" {
if f, err := uiFS.Open(path + ".html"); err == nil {
f.Close()
r2 := r.Clone(r.Context())
r2.URL.Path = "/" + path + ".html"
fileServer.ServeHTTP(w, r2)
return
}
}

Comment thread
inFocus7 marked this conversation as resolved.
// 3. If the last path segment has a file extension, it's a missing asset — 404.
if strings.Contains(path[strings.LastIndex(path, "/")+1:], ".") {
http.NotFound(w, r)
return
}

// 4. SPA fallback: serve index.html.
// Use ServeContent directly to avoid http.FileServer's built-in redirect
// of any path ending in "/index.html" back to "/", which would loop.
// Ref: https://pkg.go.dev/net/http#ServeFile
indexFile, err := uiFS.Open("index.html")
if err != nil {
http.Error(w, "index.html not found", http.StatusNotFound)
return
}
defer indexFile.Close()

stat, err := indexFile.Stat()
if err != nil {
http.Error(w, "failed to stat index.html", http.StatusInternalServerError)
return
}
Comment thread
inFocus7 marked this conversation as resolved.

rs, ok := indexFile.(io.ReadSeeker)
if !ok {
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
http.ServeContent(w, r, "index.html", stat.ModTime(), rs)
}), nil
}

// createUIHandler creates an HTTP handler for serving the embedded UI files.
func createUIHandler() (http.Handler, error) {
// Extract the ui/dist subdirectory from the embedded filesystem
uiFS, err := fs.Sub(embeddedUI, "ui/dist")
if err != nil {
return nil, err
}

// Create a file server for the UI
return http.FileServer(http.FS(uiFS)), nil
return newUIHandler(uiFS)
}

// TrailingSlashMiddleware redirects requests with trailing slashes to their canonical form
Expand Down
97 changes: 97 additions & 0 deletions internal/registry/api/server_ui_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package api

import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"testing/fstest"
)

// testUIFS simulates the Next.js static export layout:
// - index.html root page
// - deployed.html page route (Next.js writes /deployed -> deployed.html)
// - deployed/ directory of internal Next.js route metadata files
// - _next/static/... hashed asset files
var testUIFS = fstest.MapFS{
"index.html": {Data: []byte("<html>index</html>")},
"deployed.html": {Data: []byte("<html>deployed</html>")},
"deployed/__next._full.txt": {Data: []byte("internal")},
"deployed/__next._index.txt": {Data: []byte("internal")},
"_next/static/chunk.abc123.js": {Data: []byte("console.log('chunk')")},
"_next/static/style.abc123.css": {Data: []byte("body{}")},
}

func TestNewUIHandler(t *testing.T) {
handler, err := newUIHandler(testUIFS)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

tests := []struct {
name string
path string
wantStatus int
wantBodyContains string
}{
{
name: "root serves index.html",
path: "/",
wantStatus: http.StatusOK,
wantBodyContains: "<html>index</html>",
},
{
name: "page route maps to .html file",
path: "/deployed",
wantStatus: http.StatusOK,
wantBodyContains: "<html>deployed</html>",
},
{
name: "trailing slash on page route resolves same as without",
path: "/deployed/",
wantStatus: http.StatusOK,
wantBodyContains: "<html>deployed</html>",
},
{
name: "existing static asset served directly",
path: "/_next/static/chunk.abc123.js",
wantStatus: http.StatusOK,
wantBodyContains: "console.log('chunk')",
},
{
name: "missing asset with extension returns 404",
path: "/missing.js",
wantStatus: http.StatusNotFound,
},
{
name: "missing asset in subdirectory returns 404",
path: "/_next/static/missing.css",
wantStatus: http.StatusNotFound,
},
{
name: "unknown route without extension falls back to index.html",
path: "/unknown-page",
wantStatus: http.StatusOK,
wantBodyContains: "<html>index</html>",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, tt.path, nil)
w := httptest.NewRecorder()

handler.ServeHTTP(w, req)

if w.Code != tt.wantStatus {
t.Errorf("status = %d, want %d", w.Code, tt.wantStatus)
}
if tt.wantBodyContains != "" {
body := w.Body.String()
if !strings.Contains(body, tt.wantBodyContains) {
t.Errorf("body %q does not contain %q", body, tt.wantBodyContains)
}
}
})
}
}
Loading