Skip to content

feat: hot reload eszip bundle when function source changes #3675

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

Open
wants to merge 5 commits into
base: develop
Choose a base branch
from
Open
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
234 changes: 65 additions & 169 deletions internal/functions/serve/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,11 @@ package serve

import (
"context"
_ "embed"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"log"

"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/network"
"github.com/docker/go-connections/nat"
"github.com/go-errors/errors"
"github.com/spf13/afero"
"github.com/spf13/viper"
"github.com/supabase/cli/internal/functions/deploy"
"github.com/supabase/cli/internal/secrets/set"
"github.com/supabase/cli/internal/utils"
"github.com/supabase/cli/internal/utils/flags"
)
Expand Down Expand Up @@ -48,6 +37,10 @@ type RuntimeOption struct {
InspectMain bool
}

const (
dockerRuntimeInspectorPort = 8083
)

func (i *RuntimeOption) toArgs() []string {
flags := []string{}
if i.InspectMode != nil {
Expand All @@ -59,179 +52,82 @@ func (i *RuntimeOption) toArgs() []string {
return flags
}

const (
dockerRuntimeServerPort = 8081
dockerRuntimeInspectorPort = 8083
)

//go:embed templates/main.ts
var mainFuncEmbed string

func Run(ctx context.Context, envFilePath string, noVerifyJWT *bool, importMapPath string, runtimeOption RuntimeOption, fsys afero.Fs) error {
return RunWithWatcher(ctx, envFilePath, noVerifyJWT, importMapPath, runtimeOption, fsys, &RealFileWatcherSetup{})
}

func RunWithWatcher(ctx context.Context, envFilePath string, noVerifyJWT *bool, importMapPath string, runtimeOption RuntimeOption, fsys afero.Fs, watcherSetup FileWatcherSetup) error {
// 1. Sanity checks.
if err := flags.LoadConfig(fsys); err != nil {
return err
}
if err := utils.AssertSupabaseDbIsRunning(); err != nil {
return err
}
// 2. Remove existing container.
_ = utils.Docker.ContainerRemove(ctx, utils.EdgeRuntimeId, container.RemoveOptions{
RemoveVolumes: true,
Force: true,
})
// Use network alias because Deno cannot resolve `_` in hostname
dbUrl := fmt.Sprintf("postgresql://postgres:postgres@%s:5432/postgres", utils.DbAliases[0])
// 3. Serve and log to console
fmt.Fprintln(os.Stderr, "Setting up Edge Functions runtime...")
if err := ServeFunctions(ctx, envFilePath, noVerifyJWT, importMapPath, dbUrl, runtimeOption, fsys); err != nil {
return err
}
if err := utils.DockerStreamLogs(ctx, utils.EdgeRuntimeId, os.Stdout, os.Stderr); err != nil {
return err
}
fmt.Println("Stopped serving " + utils.Bold(utils.FunctionsDir))
return nil
}

func ServeFunctions(ctx context.Context, envFilePath string, noVerifyJWT *bool, importMapPath string, dbUrl string, runtimeOption RuntimeOption, fsys afero.Fs) error {
// 1. Parse custom env file
env, err := parseEnvFile(envFilePath, fsys)
if err != nil {
return err
}
env = append(env,
fmt.Sprintf("SUPABASE_URL=http://%s:8000", utils.KongAliases[0]),
"SUPABASE_ANON_KEY="+utils.Config.Auth.AnonKey.Value,
"SUPABASE_SERVICE_ROLE_KEY="+utils.Config.Auth.ServiceRoleKey.Value,
"SUPABASE_DB_URL="+dbUrl,
"SUPABASE_INTERNAL_JWT_SECRET="+utils.Config.Auth.JwtSecret.Value,
fmt.Sprintf("SUPABASE_INTERNAL_HOST_PORT=%d", utils.Config.Api.Port),
)
if viper.GetBool("DEBUG") {
env = append(env, "SUPABASE_INTERNAL_DEBUG=true")
}
if runtimeOption.InspectMode != nil {
env = append(env, "SUPABASE_INTERNAL_WALLCLOCK_LIMIT_SEC=0")
}
// 2. Parse custom import map
cwd, err := os.Getwd()
if err != nil {
return errors.Errorf("failed to get working directory: %w", err)
}
if len(importMapPath) > 0 {
if !filepath.IsAbs(importMapPath) {
importMapPath = filepath.Join(utils.CurrentDirAbs, importMapPath)
}
if importMapPath, err = filepath.Rel(cwd, importMapPath); err != nil {
return errors.Errorf("failed to resolve relative path: %w", err)
}
}
binds, functionsConfigString, err := populatePerFunctionConfigs(cwd, importMapPath, noVerifyJWT, fsys)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead, we want to watch all bound directories returned from import map resolution.

watcher, watchedPath, err := watcherSetup.SetupFileWatcher()
if err != nil {
return err
}
env = append(env, "SUPABASE_INTERNAL_FUNCTIONS_CONFIG="+functionsConfigString)
// 3. Parse entrypoint script
cmd := append([]string{
"edge-runtime",
"start",
"--main-service=/root",
fmt.Sprintf("--port=%d", dockerRuntimeServerPort),
fmt.Sprintf("--policy=%s", utils.Config.EdgeRuntime.Policy),
}, runtimeOption.toArgs()...)
if viper.GetBool("DEBUG") {
cmd = append(cmd, "--verbose")
}
cmdString := strings.Join(cmd, " ")
entrypoint := []string{"sh", "-c", `cat <<'EOF' > /root/index.ts && ` + cmdString + `
` + mainFuncEmbed + `
EOF
`}
// 4. Parse exposed ports
dockerRuntimePort := nat.Port(fmt.Sprintf("%d/tcp", dockerRuntimeServerPort))
exposedPorts := nat.PortSet{dockerRuntimePort: struct{}{}}
portBindings := nat.PortMap{}
if runtimeOption.InspectMode != nil {
dockerInspectorPort := nat.Port(fmt.Sprintf("%d/tcp", dockerRuntimeInspectorPort))
exposedPorts[dockerInspectorPort] = struct{}{}
portBindings[dockerInspectorPort] = []nat.PortBinding{{
HostPort: strconv.FormatUint(uint64(utils.Config.EdgeRuntime.InspectorPort), 10),
}}
if watcher != nil {
defer watcher.Close()
}
// 5. Start container
_, err = utils.DockerStart(
ctx,
container.Config{
Image: utils.Config.EdgeRuntime.Image,
Env: env,
Entrypoint: entrypoint,
ExposedPorts: exposedPorts,
WorkingDir: utils.ToDockerPath(cwd),
// No tcp health check because edge runtime logs them as client connection error
},
container.HostConfig{
Binds: binds,
PortBindings: portBindings,
},
network.NetworkingConfig{
EndpointsConfig: map[string]*network.EndpointSettings{
utils.NetId: {
Aliases: utils.EdgeRuntimeAliases,
},
},
},
utils.EdgeRuntimeId,
)
return err
}

func parseEnvFile(envFilePath string, fsys afero.Fs) ([]string, error) {
if envFilePath == "" {
if f, err := fsys.Stat(utils.FallbackEnvFilePath); err == nil && !f.IsDir() {
envFilePath = utils.FallbackEnvFilePath
}
} else if !filepath.IsAbs(envFilePath) {
envFilePath = filepath.Join(utils.CurrentDirAbs, envFilePath)
}
env := []string{}
secrets, err := set.ListSecrets(envFilePath, fsys)
for _, v := range secrets {
env = append(env, fmt.Sprintf("%s=%s", v.Name, v.Value))
}
return env, err
}
restartChan := make(chan struct{})
errChan := make(chan error, 1)

func populatePerFunctionConfigs(cwd, importMapPath string, noVerifyJWT *bool, fsys afero.Fs) ([]string, string, error) {
slugs, err := deploy.GetFunctionSlugs(fsys)
if err != nil {
return nil, "", err
}
functionsConfig, err := deploy.GetFunctionConfig(slugs, importMapPath, noVerifyJWT, fsys)
if err != nil {
return nil, "", err
if watcher != nil && watchedPath != "" {
go runFileWatcher(ctx, watcher, watchedPath, restartChan)
} else {
log.Println("File watcher is not initialized or not watching a path; hot-reloading disabled.")
}
binds := []string{}
for slug, fc := range functionsConfig {
if !fc.Enabled {
fmt.Fprintln(os.Stderr, "Skipped serving Function:", slug)
continue
}
modules, err := deploy.GetBindMounts(cwd, utils.FunctionsDir, "", fc.Entrypoint, fc.ImportMap, fsys)
if err != nil {
return nil, "", err
}
binds = append(binds, modules...)
fc.ImportMap = utils.ToDockerPath(fc.ImportMap)
fc.Entrypoint = utils.ToDockerPath(fc.Entrypoint)
functionsConfig[slug] = fc
for i, val := range fc.StaticFiles {
fc.StaticFiles[i] = utils.ToDockerPath(val)

for {
select {
case <-ctx.Done():
fmt.Println("Stopping functions server...")
// 2. Remove existing container if any.
_ = utils.Docker.ContainerRemove(context.Background(), utils.EdgeRuntimeId, container.RemoveOptions{
RemoveVolumes: true,
Force: true,
})
return ctx.Err()
default:
// Use network alias because Deno cannot resolve `_` in hostname
dbUrl := fmt.Sprintf("postgresql://postgres:postgres@%s:5432/postgres", utils.DbAliases[0])

serviceCancel, logsDone, err := manageFunctionServices(ctx, envFilePath, noVerifyJWT, importMapPath, dbUrl, runtimeOption, fsys, errChan)
if err != nil {
return err
}

select {
case <-restartChan:
log.Println("Reloading Edge Functions due to file changes...")
if serviceCancel != nil {
serviceCancel()
}
<-logsDone
continue
case err := <-errChan:
if serviceCancel != nil {
serviceCancel()
}
<-logsDone
_ = utils.Docker.ContainerRemove(context.Background(), utils.EdgeRuntimeId, container.RemoveOptions{Force: true})
return err
case <-ctx.Done():
fmt.Println("Stopping functions server (received done signal during active service)...")
if serviceCancel != nil {
serviceCancel()
}
<-logsDone
_ = utils.Docker.ContainerRemove(context.Background(), utils.EdgeRuntimeId, container.RemoveOptions{
RemoveVolumes: true,
Force: true,
})
return ctx.Err()
}
}
}
functionsConfigBytes, err := json.Marshal(functionsConfig)
if err != nil {
return nil, "", errors.Errorf("failed to marshal config json: %w", err)
}
return utils.RemoveDuplicates(binds), string(functionsConfigBytes), nil
}
61 changes: 53 additions & 8 deletions internal/functions/serve/serve_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"os"
"path/filepath"
"testing"
"time"

"github.com/docker/docker/api/types/container"
"github.com/h2non/gock"
Expand Down Expand Up @@ -37,19 +38,39 @@ func TestServeCommand(t *testing.T) {
Reply(http.StatusOK)
apitest.MockDockerStart(utils.Docker, utils.GetRegistryImageUrl(utils.Config.EdgeRuntime.Image), containerId)
require.NoError(t, apitest.MockDockerLogs(utils.Docker, containerId, "success"))
// Run test
err := Run(context.Background(), "", nil, "", RuntimeOption{}, fsys)
// Check error
assert.NoError(t, err)

// Create a context with timeout to prevent test from hanging
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()

// Create mock file watcher setup that doesn't watch any files
mockWatcherSetup := &MockFileWatcherSetup{
MockWatcher: nil, // No watcher needed for this test
MockPath: "", // No path needed
MockError: nil, // No error
}

// Run test with timeout context and mock watcher
err := RunWithWatcher(ctx, "", nil, "", RuntimeOption{}, fsys, mockWatcherSetup)
// Check error - expect context.DeadlineExceeded because the server runs until cancelled
assert.ErrorIs(t, err, context.DeadlineExceeded)
assert.Empty(t, apitest.ListUnmatchedRequests())
})

t.Run("throws error on malformed config", func(t *testing.T) {
// Setup in-memory fs
fsys := afero.NewMemMapFs()
require.NoError(t, afero.WriteFile(fsys, utils.ConfigPath, []byte("malformed"), 0644))

// Create mock file watcher setup
mockWatcherSetup := &MockFileWatcherSetup{
MockWatcher: nil,
MockPath: "",
MockError: nil,
}

// Run test
err := Run(context.Background(), "", nil, "", RuntimeOption{}, fsys)
err := RunWithWatcher(context.Background(), "", nil, "", RuntimeOption{}, fsys, mockWatcherSetup)
// Check error
assert.ErrorContains(t, err, "toml: expected = after a key, but the document ends there")
})
Expand All @@ -64,8 +85,16 @@ func TestServeCommand(t *testing.T) {
gock.New(utils.Docker.DaemonHost()).
Get("/v" + utils.Docker.ClientVersion() + "/containers/supabase_db_test/json").
Reply(http.StatusNotFound)

// Create mock file watcher setup
mockWatcherSetup := &MockFileWatcherSetup{
MockWatcher: nil,
MockPath: "",
MockError: nil,
}

// Run test
err := Run(context.Background(), "", nil, "", RuntimeOption{}, fsys)
err := RunWithWatcher(context.Background(), "", nil, "", RuntimeOption{}, fsys, mockWatcherSetup)
// Check error
assert.ErrorIs(t, err, utils.ErrNotRunning)
})
Expand All @@ -81,8 +110,16 @@ func TestServeCommand(t *testing.T) {
Get("/v" + utils.Docker.ClientVersion() + "/containers/supabase_db_test/json").
Reply(http.StatusOK).
JSON(container.InspectResponse{})

// Create mock file watcher setup
mockWatcherSetup := &MockFileWatcherSetup{
MockWatcher: nil,
MockPath: "",
MockError: nil,
}

// Run test
err := Run(context.Background(), ".env", nil, "", RuntimeOption{}, fsys)
err := RunWithWatcher(context.Background(), ".env", nil, "", RuntimeOption{}, fsys, mockWatcherSetup)
// Check error
assert.ErrorContains(t, err, "open .env: file does not exist")
})
Expand All @@ -102,8 +139,16 @@ func TestServeCommand(t *testing.T) {
Get("/v" + utils.Docker.ClientVersion() + "/containers/supabase_db_test/json").
Reply(http.StatusOK).
JSON(container.InspectResponse{})

// Create mock file watcher setup
mockWatcherSetup := &MockFileWatcherSetup{
MockWatcher: nil,
MockPath: "",
MockError: nil,
}

// Run test
err := Run(context.Background(), ".env", cast.Ptr(true), "import_map.json", RuntimeOption{}, fsys)
err := RunWithWatcher(context.Background(), ".env", cast.Ptr(true), "import_map.json", RuntimeOption{}, fsys, mockWatcherSetup)
// Check error
assert.ErrorIs(t, err, os.ErrNotExist)
})
Expand Down
Loading
Loading