-
Notifications
You must be signed in to change notification settings - Fork 123
APP-8173: Make local testing for viam apps easier #5036
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
Changes from all commits
7a8dec7
c09bfac
33ac6df
24b36e2
e0bbcc8
5927b19
7b267e5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -2479,6 +2479,24 @@ Note: There is no progress meter while copying is in progress. | |
UsageText: createUsageText("module", nil, false, true), | ||
HideHelpCommand: true, | ||
Subcommands: []*cli.Command{ | ||
{ | ||
Name: "local-app-testing", | ||
Usage: "test your viam application locally", | ||
UsageText: createUsageText("module local-app-testing", []string{"port", "app-url"}, false, false), | ||
Flags: []cli.Flag{ | ||
&cli.IntFlag{ | ||
Name: "port", | ||
Usage: "port to run the local server on (default: 8000)", | ||
Value: 8000, | ||
}, | ||
&cli.StringFlag{ | ||
Name: "app-url", | ||
Usage: "url where local app is running", | ||
Required: true, | ||
}, | ||
Comment on lines
+2492
to
+2496
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [q] why do we need this? why isn't it just localhost? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. feedback from sync emily call: can we make them both port otherwise it seems like theyre more connected |
||
}, | ||
Action: createCommandWithT[localAppTestingArgs](LocalAppTestingAction), | ||
}, | ||
{ | ||
Name: "create", | ||
Usage: "create & register a module on app.viam.com", | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,210 @@ | ||
package cli | ||
|
||
import ( | ||
"context" | ||
"errors" | ||
"fmt" | ||
"io" | ||
"net/http" | ||
"net/http/httputil" | ||
"net/url" | ||
"os" | ||
"path/filepath" | ||
"regexp" | ||
"runtime" | ||
"strconv" | ||
"strings" | ||
"time" | ||
|
||
"github.com/urfave/cli/v2" | ||
) | ||
|
||
// localAppTestingArgs contains the arguments for the local-app-testing command. | ||
type localAppTestingArgs struct { | ||
Port int `json:"port"` | ||
AppURL string `json:"app-url"` | ||
} | ||
|
||
// LocalAppTestingAction is the action for the local-app-testing command. | ||
func LocalAppTestingAction(ctx *cli.Context, args localAppTestingArgs) error { | ||
htmlPath, err := getHTMLFilePath() | ||
if err != nil { | ||
return err | ||
} | ||
|
||
server := setupHTTPServer(htmlPath, args.Port, args.AppURL, ctx.App.Writer) | ||
serverURL := fmt.Sprintf("http://localhost:%d", args.Port) | ||
|
||
printf(ctx.App.Writer, "Starting server to locally test viam apps on %s", serverURL) | ||
printf(ctx.App.Writer, "Proxying local app from: %s", args.AppURL) | ||
printf(ctx.App.Writer, "Press Ctrl+C to stop the server") | ||
|
||
if err := startServerInBackground(server, ctx.App.Writer); err != nil { | ||
return fmt.Errorf("failed to start server: %w", err) | ||
} | ||
|
||
if err := openbrowser(serverURL); err != nil { | ||
printf(ctx.App.Writer, "Warning: Could not open browser: %v", err) | ||
} | ||
|
||
<-ctx.Context.Done() | ||
|
||
if err := server.Shutdown(context.Background()); err != nil { | ||
return fmt.Errorf("error shutting down server: %w", err) | ||
} | ||
|
||
return nil | ||
} | ||
|
||
// getHTMLFilePath returns the absolute path to module_local_viam_apps_test.html. | ||
func getHTMLFilePath() (string, error) { | ||
_, currentFile, _, ok := runtime.Caller(0) | ||
if !ok { | ||
return "", errors.New("error getting current file path") | ||
} | ||
sourceDir := filepath.Dir(currentFile) | ||
|
||
htmlPath := filepath.Join(sourceDir, "module_local_viam_apps_test.html") | ||
absPath, err := filepath.Abs(htmlPath) | ||
if err != nil { | ||
return "", fmt.Errorf("error getting absolute path: %w", err) | ||
} | ||
|
||
if _, err := os.Stat(absPath); os.IsNotExist(err) { | ||
return "", fmt.Errorf("module_local_viam_apps_test.html not found at: %s", absPath) | ||
} | ||
|
||
return absPath, nil | ||
} | ||
|
||
// setupHTTPServer creates and configures an HTTP server with the given HTML file. | ||
func setupHTTPServer(htmlPath string, port int, targetURL string, writer io.Writer) *http.Server { | ||
targetURLParsed, err := url.Parse(targetURL) | ||
if err != nil { | ||
printf(writer, "Error parsing target URL: %v", err) | ||
return nil | ||
} | ||
proxy := httputil.NewSingleHostReverseProxy(targetURLParsed) | ||
|
||
// Modify the director to properly handle the /machine/ prefix and machine IDs | ||
originalDirector := proxy.Director | ||
proxy.Director = func(req *http.Request) { | ||
// Store the original path before modifying it | ||
originalPath := req.URL.Path | ||
|
||
// Strip cache validation headers to force fresh responses | ||
req.Header.Del("If-Modified-Since") | ||
req.Header.Del("If-None-Match") | ||
req.Header.Del("Cache-Control") | ||
|
||
// Handle the pattern /machine/{machineId}/... or /machine/{machineId} | ||
// Strip /machine and the machine ID, keeping the rest of the path | ||
pathParts := strings.SplitN(originalPath, "/", 4) // Split into max 4 parts | ||
if len(pathParts) >= 3 && pathParts[1] == "machine" { | ||
// We have /machine/{machineId}/... or /machine/{machineId} | ||
if len(pathParts) >= 4 { | ||
// /machine/{machineId}/rest-of-path | ||
req.URL.Path = "/" + pathParts[3] | ||
} else { | ||
// /machine/{machineId} - no additional path | ||
req.URL.Path = "/" | ||
} | ||
} else { | ||
// Fallback: just strip /machine prefix | ||
req.URL.Path = strings.TrimPrefix(originalPath, "/machine") | ||
if req.URL.Path == "" { | ||
req.URL.Path = "/" | ||
} | ||
} | ||
|
||
originalDirector(req) | ||
// Store the original path in the request context for later use | ||
req.Header.Set("X-Original-Path", originalPath) | ||
} | ||
|
||
// Add response interceptor | ||
proxy.ModifyResponse = func(resp *http.Response) error { | ||
contentType := resp.Header.Get("Content-Type") | ||
isHTML := strings.Contains(strings.ToLower(contentType), "text/html") | ||
|
||
// Add cache-busting headers to prevent 304 responses | ||
resp.Header.Set("Cache-Control", "no-cache, no-store, must-revalidate") | ||
resp.Header.Set("Pragma", "no-cache") | ||
resp.Header.Set("Expires", "0") | ||
|
||
if isHTML { | ||
// Get the original path from the request header | ||
originalPath := resp.Request.Header.Get("X-Original-Path") | ||
if originalPath == "" { | ||
originalPath = "/machine" // fallback | ||
} | ||
|
||
body, err := io.ReadAll(resp.Body) | ||
if err != nil { | ||
return fmt.Errorf("error reading response body: %w", err) | ||
} | ||
defer func() { | ||
if err := resp.Body.Close(); err != nil { | ||
printf(writer, "Error closing response body: %v", err) | ||
} | ||
}() | ||
|
||
// inject a <base> tag into the <head> to ensure all relative URLs resolve correctly | ||
// under the current machine-specific path (e.g., /machine/:machineId/). | ||
// The regex (?i)<head[^>]*> matches the opening <head> tag (case-insensitively), | ||
// allowing for optional attributes like <head lang="en"> | ||
re := regexp.MustCompile(`(?i)<head[^>]*>`) | ||
newBody := re.ReplaceAllStringFunc(string(body), func(match string) string { | ||
// Use a more robust base href that works for different types of relative URLs | ||
// For machine-specific paths, we want to ensure the base includes the full machine path | ||
baseHref := originalPath | ||
if !strings.HasSuffix(baseHref, "/") { | ||
baseHref += "/" | ||
} | ||
|
||
baseTag := fmt.Sprintf(`<base href="%s">`, baseHref) | ||
return match + "\n" + baseTag | ||
}) | ||
|
||
resp.Body = io.NopCloser(strings.NewReader(newBody)) | ||
resp.ContentLength = int64(len(newBody)) | ||
resp.Header.Set("Content-Length", strconv.Itoa(len(newBody))) | ||
} | ||
|
||
return nil | ||
} | ||
|
||
http.Handle("/machine/", proxy) | ||
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { | ||
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0") | ||
w.Header().Set("Pragma", "no-cache") | ||
w.Header().Set("Expires", "0") | ||
w.Header().Set("Content-Type", "text/html; charset=utf-8") | ||
|
||
http.ServeFile(w, r, htmlPath) | ||
}) | ||
|
||
return &http.Server{ | ||
Addr: fmt.Sprintf(":%d", port), | ||
ReadHeaderTimeout: time.Minute * 5, | ||
} | ||
} | ||
|
||
// startServerInBackground starts the HTTP server in a goroutine and returns any startup errors. | ||
func startServerInBackground(server *http.Server, writer io.Writer) error { | ||
errChan := make(chan error, 1) | ||
go func() { | ||
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { | ||
printf(writer, "Error starting server: %v", err) | ||
errChan <- err | ||
} | ||
close(errChan) | ||
}() | ||
|
||
select { | ||
case err := <-errChan: | ||
return err | ||
case <-time.After(100 * time.Millisecond): | ||
return nil // Server started successfully | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nit] subcommand seems longer than necessary? e.g.
local-test