Skip to content

Commit bc1902c

Browse files
authored
feat(apps): Add apps new dev server (#3923)
## Changes <!-- Brief summary of your changes that is easy to understand --> This PR introduces a new way to develop apps, that fully integrates with the new Apps Kit. It works along with this other PR https://github.com/databricks-eng/apps-sdk/pull/23 which is already merged. The idea is to provide an easy way of developing applications without having to setup all the environment variables and all the dependencies. This creates an hybrid between running the local frontend code + the remote server. It also provides integration with HMR, so when any change is make either in the frontend code or query files, they will get reflected instantly on the app. (Changes are not synced, but the user can see them) ### How does it work? You can find a diagram of how this works in the other [PR](https://github.com/databricks-eng/apps-sdk/pull/23) ## Why <!-- Why are these changes needed? Provide the context that the reviewer might be missing. For example, were there any decisions behind the change that are not reflected in the code itself? --> This is a step towards making the experience of developing Databricks apps a lot easier for users. Basically with the CLI + the Apps Kit, having a first deployment of the app into the Databricks environment, by simply running the new `dev-remote` command in the project folder, now users can easily preview their changes against the remote environment without setting anything up. ## Tests <!-- How have you tested the changes? --> - Added some tests to verify some of the functionality, path parsing, etc. - Did a lot of manual testing to verify everything works as expected <!-- If your PR needs to be included in the release notes for next release, add a separate entry in NEXT_CHANGELOG.md as part of your PR. --> --------- Co-authored-by: MarioCadenas <[email protected]>
1 parent 79fcd55 commit bc1902c

File tree

5 files changed

+1565
-0
lines changed

5 files changed

+1565
-0
lines changed

cmd/workspace/apps/dev.go

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
package apps
2+
3+
import (
4+
"bytes"
5+
"context"
6+
_ "embed"
7+
"errors"
8+
"fmt"
9+
"net"
10+
"os"
11+
"os/exec"
12+
"os/signal"
13+
"strconv"
14+
"syscall"
15+
"time"
16+
17+
"github.com/databricks/cli/cmd/root"
18+
"github.com/databricks/cli/libs/cmdctx"
19+
"github.com/databricks/cli/libs/cmdio"
20+
"github.com/spf13/cobra"
21+
)
22+
23+
//go:embed vite-server.js
24+
var viteServerScript []byte
25+
26+
const (
27+
vitePort = 5173
28+
viteReadyCheckInterval = 100 * time.Millisecond
29+
viteReadyMaxAttempts = 50
30+
)
31+
32+
func isViteReady(port int) bool {
33+
conn, err := net.DialTimeout("tcp", "localhost:"+strconv.Itoa(port), viteReadyCheckInterval)
34+
if err != nil {
35+
return false
36+
}
37+
conn.Close()
38+
return true
39+
}
40+
41+
func startViteDevServer(ctx context.Context, appURL string, port int) (*exec.Cmd, chan error, error) {
42+
// Pass script through stdin, and pass arguments in order <appURL> <port (optional)>
43+
viteCmd := exec.Command("node", "-", appURL, strconv.Itoa(port))
44+
viteCmd.Stdin = bytes.NewReader(viteServerScript)
45+
viteCmd.Stdout = os.Stdout
46+
viteCmd.Stderr = os.Stderr
47+
48+
err := viteCmd.Start()
49+
if err != nil {
50+
return nil, nil, fmt.Errorf("failed to start Vite server: %w", err)
51+
}
52+
53+
cmdio.LogString(ctx, fmt.Sprintf("🚀 Starting Vite development server on port %d...", port))
54+
55+
viteErr := make(chan error, 1)
56+
go func() {
57+
if err := viteCmd.Wait(); err != nil {
58+
viteErr <- fmt.Errorf("vite server exited with error: %w", err)
59+
} else {
60+
viteErr <- errors.New("vite server exited unexpectedly")
61+
}
62+
}()
63+
64+
for range viteReadyMaxAttempts {
65+
select {
66+
case err := <-viteErr:
67+
return nil, nil, err
68+
default:
69+
if isViteReady(port) {
70+
return viteCmd, viteErr, nil
71+
}
72+
time.Sleep(viteReadyCheckInterval)
73+
}
74+
}
75+
76+
_ = viteCmd.Process.Kill()
77+
return nil, nil, errors.New("timeout waiting for Vite server to be ready")
78+
}
79+
80+
func newRunDevCommand() *cobra.Command {
81+
var (
82+
appName string
83+
clientPath string
84+
port int
85+
)
86+
87+
cmd := &cobra.Command{}
88+
89+
cmd.Use = "dev-remote"
90+
cmd.Hidden = true
91+
cmd.Short = `Run Databricks app locally with WebSocket bridge to remote server.`
92+
cmd.Long = `Run Databricks app locally with WebSocket bridge to remote server.
93+
94+
Starts a local development server and establishes a WebSocket bridge
95+
to the remote Databricks app for development.
96+
`
97+
98+
cmd.PreRunE = root.MustWorkspaceClient
99+
100+
cmd.Flags().StringVar(&appName, "app-name", "", "Name of the app to connect to (required)")
101+
cmd.Flags().StringVar(&clientPath, "client-path", "./client", "Path to the Vite client directory")
102+
cmd.Flags().IntVar(&port, "port", vitePort, "Port to run the Vite server on")
103+
104+
cmd.RunE = func(cmd *cobra.Command, args []string) error {
105+
ctx := cmd.Context()
106+
w := cmdctx.WorkspaceClient(ctx)
107+
108+
if appName == "" {
109+
return errors.New("app name is required (use --app-name)")
110+
}
111+
112+
if _, err := os.Stat(clientPath); os.IsNotExist(err) {
113+
return fmt.Errorf("client directory not found: %s", clientPath)
114+
}
115+
116+
bridge := NewViteBridge(ctx, w, appName, port)
117+
118+
appDomain, err := bridge.GetAppDomain()
119+
if err != nil {
120+
return fmt.Errorf("failed to get app domain: %w", err)
121+
}
122+
123+
viteCmd, viteErr, err := startViteDevServer(ctx, appDomain.String(), port)
124+
if err != nil {
125+
return err
126+
}
127+
128+
done := make(chan error, 1)
129+
sigChan := make(chan os.Signal, 1)
130+
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
131+
132+
go func() {
133+
done <- bridge.Start()
134+
}()
135+
136+
select {
137+
case err := <-viteErr:
138+
bridge.Stop()
139+
<-done
140+
return err
141+
case err := <-done:
142+
cmdio.LogString(ctx, "Bridge stopped")
143+
if viteCmd.Process != nil {
144+
_ = viteCmd.Process.Signal(os.Interrupt)
145+
<-viteErr
146+
}
147+
return err
148+
case <-sigChan:
149+
cmdio.LogString(ctx, "\n🛑 Shutting down...")
150+
bridge.Stop()
151+
<-done
152+
if viteCmd.Process != nil {
153+
if err := viteCmd.Process.Signal(os.Interrupt); err != nil {
154+
cmdio.LogString(ctx, fmt.Sprintf("Failed to interrupt Vite: %v", err))
155+
_ = viteCmd.Process.Kill()
156+
}
157+
<-viteErr
158+
}
159+
return nil
160+
}
161+
}
162+
163+
cmd.ValidArgsFunction = cobra.NoFileCompletions
164+
165+
return cmd
166+
}
167+
168+
func init() {
169+
cmdOverrides = append(cmdOverrides, func(cmd *cobra.Command) {
170+
cmd.AddCommand(newRunDevCommand())
171+
})
172+
}

cmd/workspace/apps/dev_test.go

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
package apps
2+
3+
import (
4+
"context"
5+
"net"
6+
"os"
7+
"testing"
8+
"time"
9+
10+
"github.com/databricks/cli/libs/cmdio"
11+
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/require"
13+
)
14+
15+
func TestIsViteReady(t *testing.T) {
16+
t.Run("vite not running", func(t *testing.T) {
17+
// Assuming nothing is running on port 5173
18+
ready := isViteReady(5173)
19+
assert.False(t, ready)
20+
})
21+
22+
t.Run("vite is running", func(t *testing.T) {
23+
// Start a mock server on the Vite port
24+
listener, err := net.Listen("tcp", "localhost:5173")
25+
require.NoError(t, err)
26+
defer listener.Close()
27+
28+
// Accept connections in the background
29+
go func() {
30+
for {
31+
conn, err := listener.Accept()
32+
if err != nil {
33+
return
34+
}
35+
conn.Close()
36+
}
37+
}()
38+
39+
// Give the listener a moment to start
40+
time.Sleep(50 * time.Millisecond)
41+
42+
ready := isViteReady(5173)
43+
assert.True(t, ready)
44+
})
45+
}
46+
47+
func TestViteServerScriptContent(t *testing.T) {
48+
// Verify the embedded script is not empty
49+
assert.NotEmpty(t, viteServerScript)
50+
51+
// Verify it's a JavaScript file with expected content
52+
assert.Contains(t, string(viteServerScript), "startViteServer")
53+
}
54+
55+
func TestStartViteDevServerNoNode(t *testing.T) {
56+
// Skip this test if node is not available or in CI environments
57+
if os.Getenv("CI") != "" {
58+
t.Skip("Skipping node-dependent test in CI")
59+
}
60+
61+
ctx := context.Background()
62+
ctx = cmdio.MockDiscard(ctx)
63+
64+
// Create a temporary directory to act as project root
65+
tmpDir := t.TempDir()
66+
oldWd, err := os.Getwd()
67+
require.NoError(t, err)
68+
defer func() { _ = os.Chdir(oldWd) }()
69+
70+
err = os.Chdir(tmpDir)
71+
require.NoError(t, err)
72+
73+
// Create a client directory
74+
err = os.Mkdir("client", 0o755)
75+
require.NoError(t, err)
76+
77+
// Try to start Vite server with invalid app URL (will fail fast)
78+
// This test mainly verifies the function signature and error handling
79+
_, _, err = startViteDevServer(ctx, "", 5173)
80+
assert.Error(t, err)
81+
}
82+
83+
func TestViteServerScriptEmbedded(t *testing.T) {
84+
assert.NotEmpty(t, viteServerScript)
85+
86+
scriptContent := string(viteServerScript)
87+
assert.Contains(t, scriptContent, "startViteServer")
88+
assert.Contains(t, scriptContent, "createServer")
89+
assert.Contains(t, scriptContent, "queriesHMRPlugin")
90+
}

0 commit comments

Comments
 (0)