From 5beb860297727a98cdcbbe3e965c94cc8718171d Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 18 Jun 2026 11:28:05 +0200 Subject: [PATCH] fix(cli): improve local start diagnostics --- .../internal/functions/serve/serve_test.go | 7 ++++ .../functions/serve/templates/main.ts | 17 ++++++--- apps/cli-go/internal/start/start.go | 20 ++++++---- apps/cli-go/internal/start/start_test.go | 4 +- apps/cli-go/pkg/fetcher/http.go | 32 ++++++++++++++++ apps/cli-go/pkg/fetcher/http_test.go | 37 +++++++++++++++++++ 6 files changed, 101 insertions(+), 16 deletions(-) create mode 100644 apps/cli-go/pkg/fetcher/http_test.go diff --git a/apps/cli-go/internal/functions/serve/serve_test.go b/apps/cli-go/internal/functions/serve/serve_test.go index 95b3a91fdb..45bf3c1671 100644 --- a/apps/cli-go/internal/functions/serve/serve_test.go +++ b/apps/cli-go/internal/functions/serve/serve_test.go @@ -132,6 +132,13 @@ func TestServeFunctions(t *testing.T) { require.NoError(t, utils.Config.Load("testdata/config.toml", testdata)) utils.UpdateDockerIds() + t.Run("starts main service with regular remote module imports", func(t *testing.T) { + assert.Contains(t, mainFuncEmbed, `from "https://deno.land/std/http/status.ts"`) + assert.Contains(t, mainFuncEmbed, `from "https://deno.land/std/path/posix/mod.ts"`) + assert.Contains(t, mainFuncEmbed, `from "jsr:@panva/jose@6"`) + assert.Contains(t, mainFuncEmbed, `pathname === "/_internal/health"`) + }) + t.Run("runs inspect mode", func(t *testing.T) { // Setup in-memory fs fsys := afero.FromIOFS{FS: testdata} diff --git a/apps/cli-go/internal/functions/serve/templates/main.ts b/apps/cli-go/internal/functions/serve/templates/main.ts index f319c45350..00c3ea67a9 100644 --- a/apps/cli-go/internal/functions/serve/templates/main.ts +++ b/apps/cli-go/internal/functions/serve/templates/main.ts @@ -1,6 +1,5 @@ import { STATUS_CODE, STATUS_TEXT } from "https://deno.land/std/http/status.ts"; import * as posix from "https://deno.land/std/path/posix/mod.ts"; - import * as jose from "jsr:@panva/jose@6"; const SB_SPECIFIC_ERROR_CODE = { @@ -145,18 +144,24 @@ async function isValidLegacyJWT(jwtSecret: string, jwt: string): Promise { +let jwks: any | undefined; + +function getJwks(jose: any) { + if (jwks !== undefined) { + return jwks; + } try { // using injected JWKS from cli - return jose.createLocalJWKSet(JSON.parse(Deno.env.get('SUPABASE_JWKS'))); + jwks = jose.createLocalJWKSet(JSON.parse(Deno.env.get('SUPABASE_JWKS'))); } catch (error) { - return null + jwks = null; } -})(); + return jwks; +} async function isValidJWT(jwksUrl: URL, jwt: string): Promise { try { + jwks = getJwks(jose); if (!jwks) { // Loading from remote-url on fly jwks = jose.createRemoteJWKSet(new URL(jwksUrl)); diff --git a/apps/cli-go/internal/start/start.go b/apps/cli-go/internal/start/start.go index 6ce6a4434d..a6e1a30498 100644 --- a/apps/cli-go/internal/start/start.go +++ b/apps/cli-go/internal/start/start.go @@ -70,7 +70,7 @@ func Run(ctx context.Context, fsys afero.Fs, excludedContainers []string, ignore Password: utils.Config.Db.Password, Database: "postgres", } - if err := run(ctx, fsys, excludedContainers, dbConfig); err != nil { + if err := run(ctx, fsys, excludedContainers, dbConfig, ignoreHealthCheck); err != nil { if ignoreHealthCheck && start.IsUnhealthyError(err) { fmt.Fprintln(os.Stderr, err) } else { @@ -215,7 +215,7 @@ func pullImagesUsingCompose(ctx context.Context, project types.Project) error { return service.Pull(ctx, &project, api.PullOptions{IgnoreFailures: true}) } -func run(ctx context.Context, fsys afero.Fs, excludedContainers []string, dbConfig pgconn.Config, options ...func(*pgx.ConnConfig)) error { +func run(ctx context.Context, fsys afero.Fs, excludedContainers []string, dbConfig pgconn.Config, ignoreHealthCheck bool, options ...func(*pgx.ConnConfig)) error { excluded := make(map[string]bool) for _, name := range excludedContainers { excluded[name] = true @@ -1214,18 +1214,22 @@ EOF } fmt.Fprintln(os.Stderr, "Waiting for health checks...") - if utils.NoBackupVolume && slices.Contains(started, utils.StorageId) { - if err := start.WaitForHealthyService(ctx, serviceTimeout, utils.StorageId); err != nil { - return err + if err := start.WaitForHealthyService(ctx, serviceTimeout, started...); err != nil { + if ignoreHealthCheck && utils.NoBackupVolume && slices.Contains(started, utils.StorageId) { + if storageErr := start.WaitForHealthyService(ctx, serviceTimeout, utils.StorageId); storageErr == nil { + if seedErr := buckets.Run(ctx, "", false, fsys); seedErr != nil { + return seedErr + } + } } + return err + } + if utils.NoBackupVolume && slices.Contains(started, utils.StorageId) { // Disable prompts when seeding if err := buckets.Run(ctx, "", false, fsys); err != nil { return err } } - if err := start.WaitForHealthyService(ctx, serviceTimeout, started...); err != nil { - return err - } _ = phtelemetry.FromContext(ctx).Capture(ctx, phtelemetry.EventStackStarted, nil, nil) return nil } diff --git a/apps/cli-go/internal/start/start_test.go b/apps/cli-go/internal/start/start_test.go index 0dc4805ab9..27f12261fd 100644 --- a/apps/cli-go/internal/start/start_test.go +++ b/apps/cli-go/internal/start/start_test.go @@ -249,7 +249,7 @@ func TestDatabaseStart(t *testing.T) { Reply(http.StatusOK). JSON(storage.ListVectorBucketsResponse{}) // Run test - err = run(ctx, fsys, []string{}, pgconn.Config{Host: utils.DbId}, conn.Intercept) + err = run(ctx, fsys, []string{}, pgconn.Config{Host: utils.DbId}, false, conn.Intercept) // Check error assert.NoError(t, err) assert.Empty(t, apitest.ListUnmatchedRequests()) @@ -301,7 +301,7 @@ func TestDatabaseStart(t *testing.T) { // Run test exclude := ExcludableContainers() exclude = append(exclude, "invalid", exclude[0]) - err := run(context.Background(), fsys, exclude, pgconn.Config{Host: utils.DbId}) + err := run(context.Background(), fsys, exclude, pgconn.Config{Host: utils.DbId}, false) // Check error assert.NoError(t, err) assert.Empty(t, apitest.ListUnmatchedRequests()) diff --git a/apps/cli-go/pkg/fetcher/http.go b/apps/cli-go/pkg/fetcher/http.go index ac3ba91b95..3ea4ac2fe2 100644 --- a/apps/cli-go/pkg/fetcher/http.go +++ b/apps/cli-go/pkg/fetcher/http.go @@ -6,7 +6,9 @@ import ( "encoding/json" "io" "net/http" + "net/url" "slices" + "strings" "github.com/go-errors/errors" ) @@ -92,6 +94,9 @@ func (s *Fetcher) Send(ctx context.Context, method, path string, reqBody any, re // Sends request resp, err := s.client.Do(req) if err != nil { + if hint := localGatewayHint(s.server, err); len(hint) > 0 { + return nil, errors.Errorf("failed to execute http request: %w\n\n%s", err, hint) + } return nil, errors.Errorf("failed to execute http request: %w", err) } if slices.Contains(s.status, resp.StatusCode) { @@ -109,6 +114,33 @@ func (s *Fetcher) Send(ctx context.Context, method, path string, reqBody any, re return resp, nil } +func localGatewayHint(server string, err error) string { + if err == nil { + return "" + } + parsed, parseErr := url.Parse(server) + if parseErr != nil { + return "" + } + host := parsed.Hostname() + if host != "127.0.0.1" && host != "localhost" && host != "::1" { + return "" + } + message := err.Error() + if !strings.Contains(message, "malformed HTTP response") && + !strings.Contains(message, "Client.Timeout exceeded while awaiting headers") && + !strings.Contains(message, "context deadline exceeded") { + return "" + } + port := parsed.Port() + if len(port) == 0 { + return "" + } + return "The local Supabase API gateway did not return a valid HTTP response. " + + "Another process may be listening on the configured API port " + port + ". " + + "Check the port with `lsof -nP -iTCP:" + port + " -sTCP:LISTEN`, then stop the conflicting process or set a different `api.port` in supabase/config.toml." +} + func ParseJSON[T any](r io.ReadCloser) (T, error) { defer r.Close() var data T diff --git a/apps/cli-go/pkg/fetcher/http_test.go b/apps/cli-go/pkg/fetcher/http_test.go new file mode 100644 index 0000000000..a24a0fa303 --- /dev/null +++ b/apps/cli-go/pkg/fetcher/http_test.go @@ -0,0 +1,37 @@ +package fetcher + +import ( + "context" + "net" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSendSuggestsApiPortConflictForMalformedLocalResponse(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer listener.Close() + + done := make(chan struct{}) + go func() { + defer close(done) + conn, err := listener.Accept() + if err != nil { + return + } + defer conn.Close() + _, _ = conn.Write([]byte(`{"type":"Tier1","version":"1.0"}`)) + }() + + api := NewFetcher("http://" + listener.Addr().String()) + _, err = api.Send(context.Background(), "GET", "/storage/v1/bucket", nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "malformed HTTP response") + assert.Contains(t, err.Error(), "Another process may be listening on the configured API port") + assert.Contains(t, err.Error(), "lsof -nP -iTCP:") + assert.Contains(t, err.Error(), "api.port") + + <-done +}