From 1a093ef4822b973ec86d481924690349eddba5cb Mon Sep 17 00:00:00 2001 From: Mihai Parparita Date: Tue, 23 Aug 2022 18:05:23 -0700 Subject: [PATCH] cmd/tsconnect: extract NPM package for reusing in other projects `src/` is broken up into several subdirectories: - `lib/` and `types`/ for shared code and type definitions (more code will be moved here) - `app/` for the existing Preact-app - `pkg/` for the new NPM package A new `build-pkg` esbuild-based command is added to generate the files for the NPM package. To generate type definitions (something that esbuild does not do), we set up `dts-bundle-generator`. Includes additional cleanups to the Wasm type definitions (we switch to string literals for enums, since exported const enums are hard to use via packages). Also allows the control URL to be set a runtime (in addition to the current build option), so that we don't have to rebuild the package for dev vs. prod use. Updates #5415 Signed-off-by: Mihai Parparita --- .github/workflows/cross-wasm.yml | 4 +- cmd/tsconnect/.gitignore | 3 +- cmd/tsconnect/README.md | 10 ++++ cmd/tsconnect/build-pkg.go | 44 ++++++++++++++ cmd/tsconnect/build.go | 21 +------ cmd/tsconnect/common.go | 46 +++++++++++++- cmd/tsconnect/package.json | 4 +- cmd/tsconnect/pkg/package.json | 10 ++++ cmd/tsconnect/serve.go | 4 +- cmd/tsconnect/src/{ => app}/app.tsx | 11 ++-- .../src/{ => app}/go-panic-display.tsx | 0 cmd/tsconnect/src/{ => app}/header.tsx | 18 +++--- cmd/tsconnect/src/{ => app}/index.css | 0 cmd/tsconnect/src/{ => app}/index.ts | 4 +- cmd/tsconnect/src/{ => app}/ssh.tsx | 0 cmd/tsconnect/src/{ => app}/url-display.tsx | 0 cmd/tsconnect/src/{ => lib}/js-state-store.ts | 0 cmd/tsconnect/src/pkg/pkg.css | 9 +++ cmd/tsconnect/src/pkg/pkg.ts | 39 ++++++++++++ cmd/tsconnect/src/{ => types}/esbuild.d.ts | 0 .../src/{wasm_js.ts => types/wasm_js.d.ts} | 42 +++++++------ cmd/tsconnect/tsconnect.go | 3 + cmd/tsconnect/wasm/wasm_js.go | 53 ++++++++++++---- cmd/tsconnect/yarn.lock | 60 ++++++++++++++++++- 24 files changed, 303 insertions(+), 82 deletions(-) create mode 100644 cmd/tsconnect/build-pkg.go create mode 100644 cmd/tsconnect/pkg/package.json rename cmd/tsconnect/src/{ => app}/app.tsx (90%) rename cmd/tsconnect/src/{ => app}/go-panic-display.tsx (100%) rename cmd/tsconnect/src/{ => app}/header.tsx (71%) rename cmd/tsconnect/src/{ => app}/index.css (100%) rename cmd/tsconnect/src/{ => app}/index.ts (93%) rename cmd/tsconnect/src/{ => app}/ssh.tsx (100%) rename cmd/tsconnect/src/{ => app}/url-display.tsx (100%) rename cmd/tsconnect/src/{ => lib}/js-state-store.ts (100%) create mode 100644 cmd/tsconnect/src/pkg/pkg.css create mode 100644 cmd/tsconnect/src/pkg/pkg.ts rename cmd/tsconnect/src/{ => types}/esbuild.d.ts (100%) rename cmd/tsconnect/src/{wasm_js.ts => types/wasm_js.d.ts} (72%) diff --git a/.github/workflows/cross-wasm.yml b/.github/workflows/cross-wasm.yml index f98f604de..7caeb0add 100644 --- a/.github/workflows/cross-wasm.yml +++ b/.github/workflows/cross-wasm.yml @@ -38,7 +38,9 @@ jobs: - name: tsconnect static build # Use our custom Go toolchain, we set build tags (to control binary size) # that depend on it. - run: ./tool/go run ./cmd/tsconnect --fast-compression build + run: | + ./tool/go run ./cmd/tsconnect --fast-compression build + ./tool/go run ./cmd/tsconnect build-pkg - uses: k0kubun/action-slack@v2.0.0 with: diff --git a/cmd/tsconnect/.gitignore b/cmd/tsconnect/.gitignore index b94707787..13615d121 100644 --- a/cmd/tsconnect/.gitignore +++ b/cmd/tsconnect/.gitignore @@ -1,2 +1,3 @@ node_modules/ -dist/ +/dist +/pkg diff --git a/cmd/tsconnect/README.md b/cmd/tsconnect/README.md index f4a01ffd7..fc01876a3 100644 --- a/cmd/tsconnect/README.md +++ b/cmd/tsconnect/README.md @@ -28,3 +28,13 @@ To serve them, run: ``` By default the build output is placed in the `dist/` directory and embedded in the binary, but this can be controlled by the `-distdir` flag. The `-addr` flag controls the interface and port that the serve listens on. + +# Library / NPM Package + +The client is also available as an NPM package. To build it, run: + +``` +./tool/go run ./cmd/tsconnect build-pkg +``` + +That places the output in the `pkg/` directory, which may then be uploaded to a package registry (or installed from the file path directly). diff --git a/cmd/tsconnect/build-pkg.go b/cmd/tsconnect/build-pkg.go new file mode 100644 index 000000000..75b4f4cab --- /dev/null +++ b/cmd/tsconnect/build-pkg.go @@ -0,0 +1,44 @@ +// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package main + +import ( + "log" + + esbuild "github.com/evanw/esbuild/pkg/api" +) + +func runBuildPkg() { + buildOptions, err := commonSetup(prodMode) + if err != nil { + log.Fatalf("Cannot setup: %v", err) + } + + log.Printf("Linting...\n") + if err := runYarn("lint"); err != nil { + log.Fatalf("Linting failed: %v", err) + } + + if err := cleanDir(*pkgDir, "package.json"); err != nil { + log.Fatalf("Cannot clean %s: %v", *pkgDir, err) + } + + buildOptions.EntryPoints = []string{"src/pkg/pkg.ts", "src/pkg/pkg.css"} + buildOptions.Outdir = *pkgDir + buildOptions.Format = esbuild.FormatESModule + buildOptions.AssetNames = "[name]" + buildOptions.Write = true + buildOptions.MinifyWhitespace = true + buildOptions.MinifyIdentifiers = true + buildOptions.MinifySyntax = true + + runEsbuild(*buildOptions) + + log.Printf("Generating types...\n") + if err := runYarn("pkg-types"); err != nil { + log.Fatalf("Type generation failed: %v", err) + } + +} diff --git a/cmd/tsconnect/build.go b/cmd/tsconnect/build.go index 5a9607a57..d1de58512 100644 --- a/cmd/tsconnect/build.go +++ b/cmd/tsconnect/build.go @@ -13,7 +13,6 @@ import ( "path" "path/filepath" - esbuild "github.com/evanw/esbuild/pkg/api" "tailscale.com/util/precompress" ) @@ -28,7 +27,7 @@ func runBuild() { log.Fatalf("Linting failed: %v", err) } - if err := cleanDist(); err != nil { + if err := cleanDir(*distDir, "placeholder"); err != nil { log.Fatalf("Cannot clean %s: %v", *distDir, err) } @@ -41,21 +40,7 @@ func runBuild() { buildOptions.AssetNames = "[name]-[hash]" buildOptions.Metafile = true - log.Printf("Running esbuild...\n") - result := esbuild.Build(*buildOptions) - if len(result.Errors) > 0 { - log.Printf("ESBuild Error:\n") - for _, e := range result.Errors { - log.Printf("%v", e) - } - log.Fatal("Build failed") - } - if len(result.Warnings) > 0 { - log.Printf("ESBuild Warnings:\n") - for _, w := range result.Warnings { - log.Printf("%v", w) - } - } + result := runEsbuild(*buildOptions) // Preserve build metadata so we can extract hashed file names for serving. metadataBytes, err := fixEsbuildMetadataPaths(result.Metafile) @@ -98,8 +83,6 @@ func fixEsbuildMetadataPaths(metadataStr string) ([]byte, error) { return json.Marshal(metadata) } -// cleanDist removes files from the dist build directory, except the placeholder -// one that we keep to make sure Git still creates the directory. func cleanDist() error { log.Printf("Cleaning %s...\n", *distDir) files, err := os.ReadDir(*distDir) diff --git a/cmd/tsconnect/common.go b/cmd/tsconnect/common.go index 557ad77fd..da823829a 100644 --- a/cmd/tsconnect/common.go +++ b/cmd/tsconnect/common.go @@ -17,6 +17,7 @@ import ( "time" esbuild "github.com/evanw/esbuild/pkg/api" + "golang.org/x/exp/slices" ) const ( @@ -38,7 +39,7 @@ func commonSetup(dev bool) (*esbuild.BuildOptions, error) { } return &esbuild.BuildOptions{ - EntryPoints: []string{"src/index.ts", "src/index.css"}, + EntryPoints: []string{"src/app/index.ts", "src/app/index.css"}, Outdir: *distDir, Bundle: true, Sourcemap: esbuild.SourceMapLinked, @@ -67,6 +68,47 @@ func commonSetup(dev bool) (*esbuild.BuildOptions, error) { }, nil } +// cleanDir removes files from dirPath, except the ones specified by +// preserveFiles. +func cleanDir(dirPath string, preserveFiles ...string) error { + log.Printf("Cleaning %s...\n", dirPath) + files, err := os.ReadDir(dirPath) + if err != nil { + if os.IsNotExist(err) { + return os.MkdirAll(dirPath, 0755) + } + return err + } + + for _, file := range files { + if !slices.Contains(preserveFiles, file.Name()) { + if err := os.Remove(filepath.Join(dirPath, file.Name())); err != nil { + return err + } + } + } + return nil +} + +func runEsbuild(buildOptions esbuild.BuildOptions) esbuild.BuildResult { + log.Printf("Running esbuild...\n") + result := esbuild.Build(buildOptions) + if len(result.Errors) > 0 { + log.Printf("ESBuild Error:\n") + for _, e := range result.Errors { + log.Printf("%v", e) + } + log.Fatal("Build failed") + } + if len(result.Warnings) > 0 { + log.Printf("ESBuild Warnings:\n") + for _, w := range result.Warnings { + log.Printf("%v", w) + } + } + return result +} + // setupEsbuildWasmExecJS generates an esbuild plugin that serves the current // wasm_exec.js runtime helper library from the Go toolchain. func setupEsbuildWasmExecJS(build esbuild.PluginBuild) { @@ -167,7 +209,7 @@ type EsbuildMetadata struct { func setupEsbuildTailwind(build esbuild.PluginBuild, dev bool) { build.OnLoad(esbuild.OnLoadOptions{ - Filter: "./src/index.css$", + Filter: "./src/.*\\.css$", }, func(args esbuild.OnLoadArgs) (esbuild.OnLoadResult, error) { start := time.Now() yarnArgs := []string{"--silent", "tailwind", "-i", args.Path} diff --git a/cmd/tsconnect/package.json b/cmd/tsconnect/package.json index 06bee1e13..28ebd6762 100644 --- a/cmd/tsconnect/package.json +++ b/cmd/tsconnect/package.json @@ -5,6 +5,7 @@ "devDependencies": { "@types/golang-wasm-exec": "^1.15.0", "@types/qrcode": "^1.4.2", + "dts-bundle-generator": "^6.12.0", "preact": "^10.10.0", "qrcode": "^1.5.0", "tailwindcss": "^3.1.6", @@ -13,7 +14,8 @@ "xterm-addon-fit": "^0.5.0" }, "scripts": { - "lint": "tsc --noEmit" + "lint": "tsc --noEmit", + "pkg-types": "dts-bundle-generator --inline-declare-global=true --no-banner -o pkg/pkg.d.ts src/pkg/pkg.ts" }, "prettier": { "semi": false, diff --git a/cmd/tsconnect/pkg/package.json b/cmd/tsconnect/pkg/package.json new file mode 100644 index 000000000..366a63f96 --- /dev/null +++ b/cmd/tsconnect/pkg/package.json @@ -0,0 +1,10 @@ +{ + "author": "Tailscale Inc.", + "description": "Tailscale Connect SDK", + "license": "BSD-3-Clause", + "name": "@tailscale/connect", + "type": "module", + "main": "./pkg.js", + "types": "./pkg.d.ts", + "version": "0.0.5" +} diff --git a/cmd/tsconnect/serve.go b/cmd/tsconnect/serve.go index d0baf4480..275c96cb5 100644 --- a/cmd/tsconnect/serve.go +++ b/cmd/tsconnect/serve.go @@ -115,8 +115,8 @@ func generateServeIndex(distFS fs.FS) ([]byte, error) { } var entryPointsToDefaultDistPaths = map[string]string{ - "src/index.css": "dist/index.css", - "src/index.ts": "dist/index.js", + "src/app/index.css": "dist/index.css", + "src/app/index.ts": "dist/index.js", } func handleServeDist(w http.ResponseWriter, r *http.Request, distFS fs.FS) { diff --git a/cmd/tsconnect/src/app.tsx b/cmd/tsconnect/src/app/app.tsx similarity index 90% rename from cmd/tsconnect/src/app.tsx rename to cmd/tsconnect/src/app/app.tsx index 27c44d3c7..31102a9f6 100644 --- a/cmd/tsconnect/src/app.tsx +++ b/cmd/tsconnect/src/app/app.tsx @@ -3,7 +3,6 @@ // license that can be found in the LICENSE file. import { render, Component } from "preact" -import { IPNState } from "./wasm_js" import { URLDisplay } from "./url-display" import { Header } from "./header" import { GoPanicDisplay } from "./go-panic-display" @@ -18,7 +17,7 @@ type AppState = { } class App extends Component<{}, AppState> { - state: AppState = { ipnState: IPNState.NoState } + state: AppState = { ipnState: "NoState" } #goPanicTimeout?: number render() { @@ -37,7 +36,7 @@ class App extends Component<{}, AppState> { } let machineAuthInstructions - if (ipnState === IPNState.NeedsMachineAuth) { + if (ipnState === "NeedsMachineAuth") { machineAuthInstructions = (
An administrator needs to authorize this device. @@ -46,7 +45,7 @@ class App extends Component<{}, AppState> { } let ssh - if (ipn && ipnState === IPNState.Running && netMap) { + if (ipn && ipnState === "Running" && netMap) { ssh = } @@ -77,9 +76,9 @@ class App extends Component<{}, AppState> { handleIPNState = (state: IPNState) => { const { ipn } = this.state this.setState({ ipnState: state }) - if (state == IPNState.NeedsLogin) { + if (state === "NeedsLogin") { ipn?.login() - } else if ([IPNState.Running, IPNState.NeedsMachineAuth].includes(state)) { + } else if (["Running", "NeedsMachineAuth"].includes(state)) { this.setState({ browseToURL: undefined }) } } diff --git a/cmd/tsconnect/src/go-panic-display.tsx b/cmd/tsconnect/src/app/go-panic-display.tsx similarity index 100% rename from cmd/tsconnect/src/go-panic-display.tsx rename to cmd/tsconnect/src/app/go-panic-display.tsx diff --git a/cmd/tsconnect/src/header.tsx b/cmd/tsconnect/src/app/header.tsx similarity index 71% rename from cmd/tsconnect/src/header.tsx rename to cmd/tsconnect/src/app/header.tsx index ff08adef9..67d5ae31e 100644 --- a/cmd/tsconnect/src/header.tsx +++ b/cmd/tsconnect/src/app/header.tsx @@ -2,13 +2,11 @@ // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. -import { IPNState } from "./wasm_js" - export function Header({ state, ipn }: { state: IPNState; ipn?: IPN }) { const stateText = STATE_LABELS[state] let logoutButton - if (state === IPNState.Running) { + if (state === "Running") { logoutButton = (