Skip to content

fix: correctly serves websites on start/run #863

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Apr 15, 2025
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
2 changes: 2 additions & 0 deletions .github/workflows/dashboard-run-test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ jobs:

- name: Run Tests
uses: cypress-io/github-action@v5
env:
CYPRESS_NITRIC_TEST_TYPE: "run"
with:
install: false
wait-on: "http://localhost:49152"
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/dashboard-start-test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ jobs:
wait-on-timeout: 180
working-directory: cli/pkg/dashboard/frontend
browser: chrome
env:
CYPRESS_NITRIC_TEST_TYPE: "start"

- uses: actions/upload-artifact@v4
if: failure()
Expand Down
168 changes: 131 additions & 37 deletions pkg/cloud/websites/websites.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import (
"net/url"
"os"
"path/filepath"
"slices"
"strings"
"sync"

Expand Down Expand Up @@ -56,7 +57,6 @@ type (
type LocalWebsiteService struct {
websiteRegLock sync.RWMutex
state State
port int
getApiAddress GetApiAddress
isStartCmd bool

Expand All @@ -75,12 +75,12 @@ func (l *LocalWebsiteService) SubscribeToState(fn func(State)) {
}

// register - Register a new website
func (l *LocalWebsiteService) register(website Website) {
func (l *LocalWebsiteService) register(website Website, port int) {
l.websiteRegLock.Lock()
defer l.websiteRegLock.Unlock()

// Emulates the CDN URL used in a deployed environment
publicUrl := fmt.Sprintf("http://localhost:%d/%s", l.port, strings.TrimPrefix(website.BasePath, "/"))
publicUrl := fmt.Sprintf("http://localhost:%d/%s", port, strings.TrimPrefix(website.BasePath, "/"))

l.state[website.Name] = Website{
WebsitePb: website.WebsitePb,
Expand All @@ -95,9 +95,9 @@ func (l *LocalWebsiteService) register(website Website) {

type staticSiteHandler struct {
website *Website
port int
devURL string
isStartCmd bool
server *http.Server
}

func (h staticSiteHandler) serveProxy(res http.ResponseWriter, req *http.Request) {
Expand All @@ -117,6 +117,17 @@ func (h staticSiteHandler) serveProxy(res http.ResponseWriter, req *http.Request
return
}

// Strip the base path from the request path before proxying
if h.website.BasePath != "/" {
// redirect to base if path is / and there is no query string
if req.RequestURI == "/" {
http.Redirect(res, req, h.website.BasePath, http.StatusFound)
return
}

req.URL.Path = strings.TrimPrefix(req.URL.Path, h.website.BasePath)
}

// Reverse proxy request
proxy := httputil.NewSingleHostReverseProxy(targetUrl)

Expand Down Expand Up @@ -152,7 +163,7 @@ func (h staticSiteHandler) serveStatic(res http.ResponseWriter, req *http.Reques
}

if fi.IsDir() {
http.ServeFile(res, req, filepath.Join(h.website.OutputDirectory, h.website.IndexDocument))
http.ServeFile(res, req, filepath.Join(path, h.website.IndexDocument))

return
}
Expand All @@ -171,21 +182,9 @@ func (h staticSiteHandler) ServeHTTP(res http.ResponseWriter, req *http.Request)
h.serveStatic(res, req)
}

// Start - Start the local website service
func (l *LocalWebsiteService) Start(websites []Website) error {
newLis, err := netx.GetNextListener(netx.MinPort(5000))
if err != nil {
return err
}

l.port = newLis.Addr().(*net.TCPAddr).Port

_ = newLis.Close()

mux := http.NewServeMux()

// Register the API proxy handler
mux.HandleFunc("/api/{name}/", func(res http.ResponseWriter, req *http.Request) {
// createAPIPathHandler creates a handler for API proxy requests
func (l *LocalWebsiteService) createAPIPathHandler() http.HandlerFunc {
return func(res http.ResponseWriter, req *http.Request) {
apiName := req.PathValue("name")

apiAddress := l.getApiAddress(apiName)
Expand All @@ -201,31 +200,126 @@ func (l *LocalWebsiteService) Start(websites []Website) error {
req.URL.Path = targetPath

proxy.ServeHTTP(res, req)
}
}

// createServer creates and configures an HTTP server with the given mux
func (l *LocalWebsiteService) createServer(mux *http.ServeMux, port int) *http.Server {
return &http.Server{
Addr: fmt.Sprintf(":%d", port),
Handler: mux,
}
}

// startServer starts the given server in a goroutine and handles errors
func (l *LocalWebsiteService) startServer(server *http.Server, errChan chan error, errMsg string) {
go func() {
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
select {
case errChan <- fmt.Errorf(errMsg, err):
default:
}
}
}()
}

// Start - Start the local website service
func (l *LocalWebsiteService) Start(websites []Website) error {
errChan := make(chan error, 1)

startPort := 5000

slices.SortFunc(websites, func(a, b Website) int {
return strings.Compare(a.BasePath, b.BasePath)
})

// Register the SPA handler for each website
for i := range websites {
website := &websites[i]
spa := staticSiteHandler{website: website, port: l.port, devURL: website.DevURL, isStartCmd: l.isStartCmd}
if l.isStartCmd {
// In start mode, create individual servers for each website
for i := range websites {
website := &websites[i]

if website.BasePath == "/" {
// Get a new listener for each website, incrementing the port each time
newLis, err := netx.GetNextListener(netx.MinPort(startPort + i))
if err != nil {
return err
}

port := newLis.Addr().(*net.TCPAddr).Port
_ = newLis.Close()

mux := http.NewServeMux()

// Register the API proxy handler for this website
mux.HandleFunc("/api/{name}/", l.createAPIPathHandler())

// Create the SPA handler for this website
spa := staticSiteHandler{
website: website,
devURL: website.DevURL,
isStartCmd: l.isStartCmd,
}

// Register the SPA handler
mux.Handle("/", spa)
} else {
mux.Handle(website.BasePath+"/", http.StripPrefix(website.BasePath+"/", spa))

// Create and start the server
server := l.createServer(mux, port)

// Store the server in the handler for potential cleanup
spa.server = server

// Register the website with its port
l.register(*website, port)

// Start the server in a goroutine
l.startServer(server, errChan, "failed to start server for website %s: %w")
}
} else {
// For static serving, use a single server
newLis, err := netx.GetNextListener(netx.MinPort(startPort))
if err != nil {
return err
}
}

// Start the server with the multiplexer
go func() {
addr := fmt.Sprintf(":%d", l.port)
if err := http.ListenAndServe(addr, mux); err != nil {
fmt.Printf("Failed to start server: %s\n", err)
port := newLis.Addr().(*net.TCPAddr).Port
_ = newLis.Close()

mux := http.NewServeMux()

// Register the API proxy handler
mux.HandleFunc("/api/{name}/", l.createAPIPathHandler())

// Register the SPA handler for each website
for i := range websites {
website := &websites[i]
spa := staticSiteHandler{
website: website,
devURL: website.DevURL,
isStartCmd: l.isStartCmd,
}

if website.BasePath == "/" {
mux.Handle("/", spa)
} else {
mux.Handle(website.BasePath+"/", http.StripPrefix(website.BasePath+"/", spa))
}
}
}()

// Register the websites
for _, website := range websites {
l.register(website)
// Register all websites with the same port
for _, website := range websites {
l.register(website, port)
}

// Create and start the server
server := l.createServer(mux, port)

// Start the server in a goroutine
l.startServer(server, errChan, "failed to start static server: %w")
}

// Return the first error that occurred, if any
if err := <-errChan; err != nil {
return err
}

return nil
Expand Down
26 changes: 19 additions & 7 deletions pkg/dashboard/frontend/cypress/e2e/websites.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,17 +20,29 @@ describe('Websites Spec', () => {
cy.get(`[data-rct-item-id="${id}"]`).click()
cy.get('h2').should('contain.text', id)

const pathMap = {
'vite-website': '',
'docs-website': 'docs',
let originMap = {}

if (Cypress.env('NITRIC_TEST_TYPE') === 'run') {
originMap = {
'vite-website': 'http://localhost:5000',
'docs-website': 'http://localhost:5000',
}
} else {
originMap = {
'vite-website': 'http://localhost:5000',
'docs-website': 'http://localhost:5001',
}
}

const url = `http://localhost:5000/${pathMap[id]}`
const pathMap = {
'vite-website': '/',
'docs-website': '/docs',
}

// check iframe url
cy.get('iframe').should('have.attr', 'src', url)
cy.get('iframe').should('have.attr', 'src', originMap[id] + pathMap[id])

cy.visit(url)
cy.visit(originMap[id] + pathMap[id])

const titleMap = {
'vite-website': 'Hello Nitric!',
Expand All @@ -39,7 +51,7 @@ describe('Websites Spec', () => {

const title = titleMap[id]

cy.origin('http://localhost:5000', { args: { title } }, ({ title }) => {
cy.origin(originMap[id], { args: { title } }, ({ title }) => {
cy.get('h1').should('have.text', title)
})
})
Expand Down
2 changes: 1 addition & 1 deletion pkg/project/project.go
Original file line number Diff line number Diff line change
Expand Up @@ -827,7 +827,7 @@ func fromProjectConfiguration(projectConfig *ProjectConfiguration, localConfig *
}

if websiteSpec.ErrorPage == "" {
websiteSpec.ErrorPage = "index.html"
websiteSpec.ErrorPage = "404.html"
} else if !strings.HasSuffix(websiteSpec.ErrorPage, ".html") {
return nil, fmt.Errorf("invalid error page %s, must end with .html", websiteSpec.ErrorPage)
}
Expand Down