Skip to content

Commit d8c3749

Browse files
committed
Add apps new dev server
1 parent 58549e4 commit d8c3749

File tree

3 files changed

+754
-0
lines changed

3 files changed

+754
-0
lines changed

cmd/workspace/apps/dev.go

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

cmd/workspace/apps/vite-server.js

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
#!/usr/bin/env node
2+
const path = require("node:path");
3+
const fs = require("node:fs");
4+
5+
async function startViteServer() {
6+
const vitePath = safeViteResolve();
7+
8+
if (!vitePath) {
9+
console.log(
10+
"\n❌ Vite needs to be installed in the current directory. Run `npm install vite`.\n"
11+
);
12+
process.exit(1);
13+
}
14+
15+
const { createServer, loadConfigFromFile, mergeConfig } = require(vitePath);
16+
17+
const clientPath = path.join(process.cwd(), "client");
18+
const appUrl = process.argv[2] || "";
19+
20+
if (!fs.existsSync(clientPath)) {
21+
console.error("client folder doesn't exist.");
22+
process.exit(1);
23+
}
24+
25+
if (!appUrl) {
26+
console.error("App URL is required");
27+
process.exit(1);
28+
}
29+
30+
try {
31+
const domain = new URL(appUrl);
32+
33+
const loadedConfig = await loadConfigFromFile(
34+
{
35+
mode: "development",
36+
command: "serve",
37+
},
38+
undefined,
39+
clientPath
40+
);
41+
const userConfig = loadedConfig?.config ?? {};
42+
const coreConfig = {
43+
configFile: false,
44+
root: clientPath,
45+
server: {
46+
open: `${domain.origin}?dev=true`,
47+
// TODO: Handle multiple ports
48+
port: 5173,
49+
hmr: {
50+
overlay: true,
51+
path: `/dev-hmr`,
52+
},
53+
middlewareMode: false,
54+
},
55+
};
56+
const mergedConfigs = mergeConfig(userConfig, coreConfig);
57+
const server = await createServer(mergedConfigs);
58+
59+
await server.listen();
60+
61+
console.log(`\n✅ Vite dev server started successfully!`);
62+
console.log(`\nPress Ctrl+C to stop the server\n`);
63+
64+
const shutdown = async () => {
65+
await server.close();
66+
process.exit(0);
67+
};
68+
69+
process.on("SIGINT", shutdown);
70+
process.on("SIGTERM", shutdown);
71+
} catch (error) {
72+
console.error(`❌ Failed to start Vite server:`, error.message);
73+
if (error.stack) {
74+
console.error(error.stack);
75+
}
76+
process.exit(1);
77+
}
78+
}
79+
80+
function safeViteResolve() {
81+
try {
82+
const vitePath = require.resolve("vite", { paths: [process.cwd()] });
83+
84+
return vitePath;
85+
} catch (error) {
86+
return null;
87+
}
88+
}
89+
90+
// Start the server
91+
startViteServer().catch((error) => {
92+
console.error("Fatal error:", error);
93+
process.exit(1);
94+
});

0 commit comments

Comments
 (0)