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 = (