client/web: add barebones vite dev setup

Currently just serving a "Hello world" page when running the web
cli in --dev mode.

Updates tailscale/corp#13775

Co-authored-by: Will Norris <will@tailscale.com>
Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
This commit is contained in:
Sonia Appasamy 2023-08-10 13:58:59 -04:00 committed by Sonia Appasamy
parent 215480a022
commit 16bc9350e3
10 changed files with 1916 additions and 8 deletions

4
.gitignore vendored
View File

@ -35,5 +35,9 @@ cmd/tailscaled/tailscaled
# Ignore direnv nix-shell environment cache # Ignore direnv nix-shell environment cache
.direnv/ .direnv/
# Ignore web client node modules
.vite/
client/web/node_modules
/gocross /gocross
/dist /dist

75
client/web/dev.go Normal file
View File

@ -0,0 +1,75 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package web
import (
"log"
"net/http"
"net/http/httputil"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
)
// startDevServer starts the JS dev server that does on-demand rebuilding
// and serving of web client JS and CSS resources.
func (s *Server) startDevServer() (cleanup func()) {
root := gitRootDir()
webClientPath := filepath.Join(root, "client", "web")
yarn := filepath.Join(root, "tool", "yarn")
node := filepath.Join(root, "tool", "node")
vite := filepath.Join(webClientPath, "node_modules", ".bin", "vite")
log.Printf("installing JavaScript deps using %s... (might take ~30s)", yarn)
out, err := exec.Command(yarn, "--non-interactive", "-s", "--cwd", webClientPath, "install").CombinedOutput()
if err != nil {
log.Fatalf("error running tailscale web's yarn install: %v, %s", err, out)
}
log.Printf("starting JavaScript dev server...")
cmd := exec.Command(node, vite)
cmd.Dir = webClientPath
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
log.Fatalf("Starting JS dev server: %v", err)
}
log.Printf("JavaScript dev server running as pid %d", cmd.Process.Pid)
return func() {
cmd.Process.Signal(os.Interrupt)
err := cmd.Wait()
log.Printf("JavaScript dev server exited: %v", err)
}
}
func (s *Server) addProxyToDevServer() {
if !s.devMode {
return // only using Vite proxy in dev mode
}
// We use Vite to develop on the web client.
// Vite starts up its own local server for development,
// which we proxy requests to from Server.ServeHTTP.
// Here we set up the proxy to Vite's server.
handleErr := func(w http.ResponseWriter, r *http.Request, err error) {
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusBadGateway)
w.Write([]byte("The web client development server isn't running. " +
"Run `./tool/yarn --cwd client/web start` from " +
"the repo root to start the development server."))
w.Write([]byte("\n\nError: " + err.Error()))
}
viteTarget, _ := url.Parse("http://127.0.0.1:4000")
s.devProxy = httputil.NewSingleHostReverseProxy(viteTarget)
s.devProxy.ErrorHandler = handleErr
}
func gitRootDir() string {
top, err := exec.Command("git", "rev-parse", "--show-toplevel").Output()
if err != nil {
log.Fatalf("failed to find git top level (not in corp git?): %v", err)
}
return strings.TrimSpace(string(top))
}

3
client/web/index.html Normal file
View File

@ -0,0 +1,3 @@
<!doctype html>
Hello world
</html>

31
client/web/package.json Normal file
View File

@ -0,0 +1,31 @@
{
"name": "webui",
"version": "0.0.1",
"license": "BSD-3-Clause",
"engines": {
"node": "18.16.1",
"yarn": "1.22.19"
},
"private": true,
"dependencies": {},
"devDependencies": {
"tailwindcss": "^3.1.6",
"typescript": "^4.7.4",
"vite": "^4.3.9",
"@vitejs/plugin-react-swc": "^3.3.2",
"vite-tsconfig-paths": "^3.5.0",
"vite-plugin-svgr": "^3.2.0",
"vite-plugin-rewrite-all": "^1.0.1",
"vitest": "^0.32.0"
},
"scripts": {
"build": "vite build",
"start": "vite",
"lint": "tsc --noEmit",
"test": "vitest"
},
"prettier": {
"semi": false,
"printWidth": 80
}
}

69
client/web/vite.config.ts Normal file
View File

@ -0,0 +1,69 @@
/// <reference types="vitest" />
import { createLogger, defineConfig } from "vite"
import rewrite from "vite-plugin-rewrite-all"
import svgr from "vite-plugin-svgr"
import paths from "vite-tsconfig-paths"
// Use a custom logger that filters out Vite's logging of server URLs, since
// they are an attractive nuisance (we run a proxy in front of Vite, and the
// tailscale web client should be accessed through that).
// Unfortunately there's no option to disable this logging, so the best we can
// do it to ignore calls from a specific function.
const filteringLogger = createLogger(undefined, { allowClearScreen: false })
const originalInfoLog = filteringLogger.info
filteringLogger.info = (...args) => {
if (new Error("ignored").stack?.includes("printServerUrls")) {
return
}
originalInfoLog.apply(filteringLogger, args)
}
// https://vitejs.dev/config/
export default defineConfig({
base: "/",
plugins: [
paths(),
svgr(),
// By default, the Vite dev server doesn't handle dots
// in path names and treats them as static files.
// This plugin changes Vite's routing logic to fix this.
// See: https://github.com/vitejs/vite/issues/2415
rewrite(),
],
build: {
outDir: "build",
sourcemap: true,
},
esbuild: {
logOverride: {
// Silence a warning about `this` being undefined in ESM when at the
// top-level. The way JSX is transpiled causes this to happen, but it
// isn't a problem.
// See: https://github.com/vitejs/vite/issues/8644
"this-is-undefined-in-esm": "silent",
},
},
server: {
// This needs to be 127.0.0.1 instead of localhost, because of how our
// Go proxy connects to it.
host: "127.0.0.1",
// If you change the port, be sure to update the proxy in adminhttp.go too.
port: 4000,
// Don't proxy the WebSocket connection used for live reloading by running
// it on a separate port.
hmr: {
protocol: "ws",
port: 4001,
},
},
test: {
exclude: ["**/node_modules/**", "**/dist/**"],
testTimeout: 20000,
environment: "jsdom",
deps: {
inline: ["date-fns", /\.wasm\?url$/],
},
},
clearScreen: false,
customLogger: filteringLogger,
})

View File

@ -16,6 +16,7 @@
"io" "io"
"log" "log"
"net/http" "net/http"
"net/http/httputil"
"net/netip" "net/netip"
"net/url" "net/url"
"os" "os"
@ -46,22 +47,30 @@
// Server is the backend server for a Tailscale web client. // Server is the backend server for a Tailscale web client.
type Server struct { type Server struct {
devMode bool
lc *tailscale.LocalClient lc *tailscale.LocalClient
devMode bool
devProxy *httputil.ReverseProxy // only filled when devMode is on
} }
// NewServer constructs a new Tailscale web client server. // NewServer constructs a new Tailscale web client server.
// //
// lc is an optional parameter. When not filled, NewServer // lc is an optional parameter. When not filled, NewServer
// initializes its own tailscale.LocalClient. // initializes its own tailscale.LocalClient.
func NewServer(devMode bool, lc *tailscale.LocalClient) *Server { func NewServer(devMode bool, lc *tailscale.LocalClient) (s *Server, cleanup func()) {
if lc == nil { if lc == nil {
lc = &tailscale.LocalClient{} lc = &tailscale.LocalClient{}
} }
return &Server{ s = &Server{
devMode: devMode, devMode: devMode,
lc: lc, lc: lc,
} }
cleanup = func() {}
if s.devMode {
cleanup = s.startDevServer()
s.addProxyToDevServer()
}
return s, cleanup
} }
func init() { func init() {
@ -284,6 +293,12 @@ func synoTokenRedirect(w http.ResponseWriter, r *http.Request) bool {
// ServeHTTP processes all requests for the Tailscale web client. // ServeHTTP processes all requests for the Tailscale web client.
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if s.devMode {
// When in dev mode, proxy to the Vite dev server.
s.devProxy.ServeHTTP(w, r)
return
}
ctx := r.Context() ctx := r.Context()
if authRedirect(w, r) { if authRedirect(w, r) {
return return

1709
client/web/yarn.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@ -38,7 +38,7 @@
webf := newFlagSet("web") webf := newFlagSet("web")
webf.StringVar(&webArgs.listen, "listen", "localhost:8088", "listen address; use port 0 for automatic") webf.StringVar(&webArgs.listen, "listen", "localhost:8088", "listen address; use port 0 for automatic")
webf.BoolVar(&webArgs.cgi, "cgi", false, "run as CGI script") webf.BoolVar(&webArgs.cgi, "cgi", false, "run as CGI script")
webf.BoolVar(&webArgs.dev, "dev", false, "run web client in developer mode") webf.BoolVar(&webArgs.dev, "dev", false, "run web client in developer mode [this flag is in development, use is unsupported]")
return webf return webf
})(), })(),
Exec: runWeb, Exec: runWeb,
@ -78,7 +78,8 @@ func runWeb(ctx context.Context, args []string) error {
return fmt.Errorf("too many non-flag arguments: %q", args) return fmt.Errorf("too many non-flag arguments: %q", args)
} }
webServer := web.NewServer(webArgs.dev, nil) webServer, cleanup := web.NewServer(webArgs.dev, nil)
defer cleanup()
if webArgs.cgi { if webArgs.cgi {
if err := cgi.Serve(webServer); err != nil { if err := cgi.Serve(webServer); err != nil {

View File

@ -267,7 +267,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
net/http from expvar+ net/http from expvar+
net/http/cgi from tailscale.com/cmd/tailscale/cli net/http/cgi from tailscale.com/cmd/tailscale/cli
net/http/httptrace from github.com/tcnksm/go-httpstat+ net/http/httptrace from github.com/tcnksm/go-httpstat+
net/http/httputil from tailscale.com/cmd/tailscale/cli net/http/httputil from tailscale.com/cmd/tailscale/cli+
net/http/internal from net/http+ net/http/internal from net/http+
net/netip from net+ net/netip from net+
net/textproto from golang.org/x/net/http/httpguts+ net/textproto from golang.org/x/net/http/httpguts+

View File

@ -35,7 +35,8 @@ func main() {
} }
// Serve the Tailscale web client. // Serve the Tailscale web client.
ws := web.NewServer(*devMode, lc) ws, cleanup := web.NewServer(*devMode, lc)
defer cleanup()
if err := http.Serve(ln, ws); err != nil { if err := http.Serve(ln, ws); err != nil {
if err != http.ErrServerClosed { if err != http.ErrServerClosed {
log.Fatal(err) log.Fatal(err)