From ed79fb2baea325f8d40997c2f69e83e5ce591e91 Mon Sep 17 00:00:00 2001 From: Byron Ruth Date: Thu, 14 Jul 2022 15:30:09 -0400 Subject: [PATCH] Add initial website and nbe CLI (#3) Thanks to @aricart and @Jarema for help with a few examples! Signed-off-by: Byron Ruth --- .gitignore | 5 +- Makefile | 31 + README.md | 105 +++- auth/create-jwts/README.md | 31 - auth/create-jwts/main.go | 107 ---- cmd/nbe/docker.go | 244 ++++++++ cmd/nbe/docs.go | 454 +++++++++++++++ cmd/nbe/docs_test.go | 57 ++ cmd/nbe/go.mod | 18 + cmd/nbe/go.sum | 31 + cmd/nbe/main.go | 222 ++++++++ cmd/nbe/main_test.go | 78 +++ cmd/nbe/parse.go | 537 ++++++++++++++++++ cmd/nbe/tmpl/category.html | 21 + cmd/nbe/tmpl/client.html | 69 +++ cmd/nbe/tmpl/example.html | 27 + cmd/nbe/tmpl/head.html | 15 + cmd/nbe/tmpl/index.html | 43 ++ cmd/nbe/tmpl/logo.html | 5 + docker/cli/Dockerfile | 10 + docker/deno/Dockerfile | 11 + docker/docker-compose.cluster.yaml | 53 ++ docker/docker-compose.yaml | 19 + docker/go/Dockerfile | 23 + {auth/create-jwts => docker/go}/go.mod | 9 +- {auth/create-jwts => docker/go}/go.sum | 1 - docker/node/Dockerfile | 13 + docker/node/package-lock.json | 74 +++ docker/node/package.json | 5 + docker/python/Dockerfile | 12 + docker/python/requirements.txt | 1 + docker/rust/Cargo.toml | 13 + docker/rust/Dockerfile | 19 + examples/auth/meta.yaml | 5 + examples/auth/nkeys-jwts/go/go.mod | 10 + examples/auth/nkeys-jwts/go/go.sum | 11 + examples/auth/nkeys-jwts/go/main.go | 93 +++ examples/auth/nkeys-jwts/go/output.txt | 43 ++ examples/auth/nkeys-jwts/meta.yaml | 15 + examples/messaging/meta.yaml | 3 + examples/messaging/pub-sub/README.md | 12 + examples/messaging/pub-sub/cli/main.sh | 35 ++ examples/messaging/pub-sub/cli/output.txt | 28 + examples/messaging/pub-sub/deno/main.ts | 48 ++ examples/messaging/pub-sub/go/main.go | 58 ++ examples/messaging/pub-sub/go/output.txt | 5 + examples/messaging/pub-sub/meta.yaml | 16 + examples/messaging/pub-sub/node/main.js | 48 ++ examples/messaging/pub-sub/python/main.py | 55 ++ examples/messaging/pub-sub/rust/main.rs | 43 ++ examples/messaging/request-reply/go/go.mod | 14 + examples/messaging/request-reply/go/go.sum | 30 + examples/messaging/request-reply/go/main.go | 56 ++ .../messaging/request-reply/go/output.txt | 4 + .../messaging/request-reply/python/main.py | 56 ++ examples/meta.yaml | 5 + .../replace-cluster-nodes/.gitignore | 3 + .../replace-cluster-nodes/shell}/README.md | 0 .../shell}/configs/n0.conf | 0 .../shell}/configs/n1.conf | 0 .../shell}/configs/n2.conf | 0 .../shell}/configs/n3.conf | 0 .../shell}/configs/n4.conf | 0 .../shell}/configs/shared.conf | 0 .../shell}/scripts/add-node.sh | 0 .../shell}/scripts/create-consumers.sh | 0 .../shell}/scripts/create-streams.sh | 0 .../shell}/scripts/migrate-cluster.sh | 0 .../shell}/scripts/remove-peer.sh | 0 .../shell}/scripts/signal-lameduck.sh | 0 .../shell}/scripts/start-bar-publisher.sh | 0 .../shell}/scripts/start-bar-subscriber.sh | 0 .../shell}/scripts/start-cluster.sh | 0 .../shell}/scripts/start-foo-publisher.sh | 0 .../shell}/scripts/start-foo-subscriber.sh | 0 .../shell}/scripts/stop-cluster.sh | 0 .../shell}/scripts/watch-bar-consumer.sh | 0 .../shell}/scripts/watch-foo-consumer.sh | 0 .../shell}/scripts/watch-servers.sh | 0 .../shell}/scripts/watch-streams.sh | 0 go.work | 3 + html/Cookie-Regular.ttf | Bin 0 -> 42132 bytes html/clipboard.svg | 98 ++++ html/examples/auth/index.html | 42 ++ html/examples/auth/nkeys-jwts/go/index.html | 372 ++++++++++++ html/examples/auth/nkeys-jwts/index.html | 97 ++++ html/examples/messaging/index.html | 43 ++ .../examples/messaging/pub-sub/cli/index.html | 271 +++++++++ .../messaging/pub-sub/deno/index.html | 284 +++++++++ html/examples/messaging/pub-sub/go/index.html | 310 ++++++++++ html/examples/messaging/pub-sub/index.html | 99 ++++ .../messaging/pub-sub/node/index.html | 284 +++++++++ .../messaging/pub-sub/python/index.html | 308 ++++++++++ .../messaging/pub-sub/rust/index.html | 246 ++++++++ .../messaging/request-reply/go/index.html | 258 +++++++++ .../messaging/request-reply/index.html | 85 +++ .../messaging/request-reply/python/index.html | 261 +++++++++ html/github-mark.svg | 3 + html/index.html | 74 +++ html/main.css | 269 +++++++++ html/main.js | 16 + html/nats-horizontal-color.svg | 1 + html/nats.svg | 1 + html/reset.css | 2 + static/Cookie-Regular.ttf | Bin 0 -> 42132 bytes static/clipboard.svg | 98 ++++ static/github-mark.svg | 3 + static/main.css | 269 +++++++++ static/main.js | 16 + static/nats-horizontal-color.svg | 1 + static/nats.svg | 1 + static/reset.css | 2 + 112 files changed, 6716 insertions(+), 160 deletions(-) create mode 100644 Makefile delete mode 100644 auth/create-jwts/README.md delete mode 100644 auth/create-jwts/main.go create mode 100644 cmd/nbe/docker.go create mode 100644 cmd/nbe/docs.go create mode 100644 cmd/nbe/docs_test.go create mode 100644 cmd/nbe/go.mod create mode 100644 cmd/nbe/go.sum create mode 100644 cmd/nbe/main.go create mode 100644 cmd/nbe/main_test.go create mode 100644 cmd/nbe/parse.go create mode 100644 cmd/nbe/tmpl/category.html create mode 100644 cmd/nbe/tmpl/client.html create mode 100644 cmd/nbe/tmpl/example.html create mode 100644 cmd/nbe/tmpl/head.html create mode 100644 cmd/nbe/tmpl/index.html create mode 100644 cmd/nbe/tmpl/logo.html create mode 100644 docker/cli/Dockerfile create mode 100644 docker/deno/Dockerfile create mode 100644 docker/docker-compose.cluster.yaml create mode 100644 docker/docker-compose.yaml create mode 100644 docker/go/Dockerfile rename {auth/create-jwts => docker/go}/go.mod (59%) rename {auth/create-jwts => docker/go}/go.sum (95%) create mode 100644 docker/node/Dockerfile create mode 100644 docker/node/package-lock.json create mode 100644 docker/node/package.json create mode 100644 docker/python/Dockerfile create mode 100644 docker/python/requirements.txt create mode 100644 docker/rust/Cargo.toml create mode 100644 docker/rust/Dockerfile create mode 100644 examples/auth/meta.yaml create mode 100644 examples/auth/nkeys-jwts/go/go.mod create mode 100644 examples/auth/nkeys-jwts/go/go.sum create mode 100644 examples/auth/nkeys-jwts/go/main.go create mode 100644 examples/auth/nkeys-jwts/go/output.txt create mode 100644 examples/auth/nkeys-jwts/meta.yaml create mode 100644 examples/messaging/meta.yaml create mode 100644 examples/messaging/pub-sub/README.md create mode 100644 examples/messaging/pub-sub/cli/main.sh create mode 100644 examples/messaging/pub-sub/cli/output.txt create mode 100644 examples/messaging/pub-sub/deno/main.ts create mode 100644 examples/messaging/pub-sub/go/main.go create mode 100644 examples/messaging/pub-sub/go/output.txt create mode 100644 examples/messaging/pub-sub/meta.yaml create mode 100644 examples/messaging/pub-sub/node/main.js create mode 100644 examples/messaging/pub-sub/python/main.py create mode 100644 examples/messaging/pub-sub/rust/main.rs create mode 100644 examples/messaging/request-reply/go/go.mod create mode 100644 examples/messaging/request-reply/go/go.sum create mode 100644 examples/messaging/request-reply/go/main.go create mode 100644 examples/messaging/request-reply/go/output.txt create mode 100644 examples/messaging/request-reply/python/main.py create mode 100644 examples/meta.yaml create mode 100644 examples/operations/replace-cluster-nodes/.gitignore rename {operations/replace-cluster-nodes => examples/operations/replace-cluster-nodes/shell}/README.md (100%) rename {operations/replace-cluster-nodes => examples/operations/replace-cluster-nodes/shell}/configs/n0.conf (100%) rename {operations/replace-cluster-nodes => examples/operations/replace-cluster-nodes/shell}/configs/n1.conf (100%) rename {operations/replace-cluster-nodes => examples/operations/replace-cluster-nodes/shell}/configs/n2.conf (100%) rename {operations/replace-cluster-nodes => examples/operations/replace-cluster-nodes/shell}/configs/n3.conf (100%) rename {operations/replace-cluster-nodes => examples/operations/replace-cluster-nodes/shell}/configs/n4.conf (100%) rename {operations/replace-cluster-nodes => examples/operations/replace-cluster-nodes/shell}/configs/shared.conf (100%) rename {operations/replace-cluster-nodes => examples/operations/replace-cluster-nodes/shell}/scripts/add-node.sh (100%) rename {operations/replace-cluster-nodes => examples/operations/replace-cluster-nodes/shell}/scripts/create-consumers.sh (100%) rename {operations/replace-cluster-nodes => examples/operations/replace-cluster-nodes/shell}/scripts/create-streams.sh (100%) rename {operations/replace-cluster-nodes => examples/operations/replace-cluster-nodes/shell}/scripts/migrate-cluster.sh (100%) rename {operations/replace-cluster-nodes => examples/operations/replace-cluster-nodes/shell}/scripts/remove-peer.sh (100%) rename {operations/replace-cluster-nodes => examples/operations/replace-cluster-nodes/shell}/scripts/signal-lameduck.sh (100%) rename {operations/replace-cluster-nodes => examples/operations/replace-cluster-nodes/shell}/scripts/start-bar-publisher.sh (100%) rename {operations/replace-cluster-nodes => examples/operations/replace-cluster-nodes/shell}/scripts/start-bar-subscriber.sh (100%) rename {operations/replace-cluster-nodes => examples/operations/replace-cluster-nodes/shell}/scripts/start-cluster.sh (100%) rename {operations/replace-cluster-nodes => examples/operations/replace-cluster-nodes/shell}/scripts/start-foo-publisher.sh (100%) rename {operations/replace-cluster-nodes => examples/operations/replace-cluster-nodes/shell}/scripts/start-foo-subscriber.sh (100%) rename {operations/replace-cluster-nodes => examples/operations/replace-cluster-nodes/shell}/scripts/stop-cluster.sh (100%) rename {operations/replace-cluster-nodes => examples/operations/replace-cluster-nodes/shell}/scripts/watch-bar-consumer.sh (100%) rename {operations/replace-cluster-nodes => examples/operations/replace-cluster-nodes/shell}/scripts/watch-foo-consumer.sh (100%) rename {operations/replace-cluster-nodes => examples/operations/replace-cluster-nodes/shell}/scripts/watch-servers.sh (100%) rename {operations/replace-cluster-nodes => examples/operations/replace-cluster-nodes/shell}/scripts/watch-streams.sh (100%) create mode 100644 go.work create mode 100644 html/Cookie-Regular.ttf create mode 100644 html/clipboard.svg create mode 100644 html/examples/auth/index.html create mode 100644 html/examples/auth/nkeys-jwts/go/index.html create mode 100644 html/examples/auth/nkeys-jwts/index.html create mode 100644 html/examples/messaging/index.html create mode 100644 html/examples/messaging/pub-sub/cli/index.html create mode 100644 html/examples/messaging/pub-sub/deno/index.html create mode 100644 html/examples/messaging/pub-sub/go/index.html create mode 100644 html/examples/messaging/pub-sub/index.html create mode 100644 html/examples/messaging/pub-sub/node/index.html create mode 100644 html/examples/messaging/pub-sub/python/index.html create mode 100644 html/examples/messaging/pub-sub/rust/index.html create mode 100644 html/examples/messaging/request-reply/go/index.html create mode 100644 html/examples/messaging/request-reply/index.html create mode 100644 html/examples/messaging/request-reply/python/index.html create mode 100644 html/github-mark.svg create mode 100644 html/index.html create mode 100644 html/main.css create mode 100644 html/main.js create mode 100644 html/nats-horizontal-color.svg create mode 100644 html/nats.svg create mode 100644 html/reset.css create mode 100644 static/Cookie-Regular.ttf create mode 100644 static/clipboard.svg create mode 100644 static/github-mark.svg create mode 100644 static/main.css create mode 100644 static/main.js create mode 100644 static/nats-horizontal-color.svg create mode 100644 static/nats.svg create mode 100644 static/reset.css diff --git a/.gitignore b/.gitignore index 90e1c924..de4d1f00 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,2 @@ -data -logs -pids +dist +node_modules diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..727fa9ab --- /dev/null +++ b/Makefile @@ -0,0 +1,31 @@ +GOOS=$(shell go env GOOS) +GOARCH=$(shell go env GOARCH) + +# Requires: go install github.com/githubnemo/CompileDaemon@master +# for the multi-build support. +watch: + CompileDaemon \ + -color=true \ + -pattern="(.+\.go|.+\.html|.+\.css|.+\.svg|.+\.yaml)$$" \ + -exclude-dir="html" \ + -exclude-dir="docker" \ + -exclude-dir="dist" \ + -exclude-dir=".git" \ + -build="make build" \ + -build="nbe build" \ + -command="nbe serve" \ + -graceful-kill + +build: + mkdir -p dist/$(GOOS)-$(GOARCH) + go build -o dist/$(GOOS)-$(GOARCH)/nbe ./cmd/nbe + +zip: + cd dist/$(GOOS)-$(GOARCH) && zip ../$(GOOS)-$(GOARCH).zip nbe + +dist: + GOOS=linux GOARCH=amd64 make build zip + GOOS=darwin GOARCH=amd64 make build zip + GOOS=windows GOARCH=amd64 make build zip + +.PHONY: dist diff --git a/README.md b/README.md index 18cea5e4..9fd53fb9 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,106 @@ # NATS By Example -Collection of examples using NATS ranging from basic messaging to advanced architecture design and operational concerns. +> A collection of reference examples using NATS. -**Note: this repo just started, so please be patient while examples are being added. If you have any suggestions or questions, feel free to open an issue!** +See https://natsbyexample.com to start exploring. -## Messaging +## Motivation -TODO +The vast majority of code examples that exist today don't work due to being incomplete or having invalid syntax **OR** the person trying to run the example doesn't know to properly setup the environment and/or dependencies for the example to run properly. -## JetStream +There are three primary goals of this repo: -TODO +- Provide fully functional and robust reference examples for as many NATS +- Sufficiently document each example and make them presentable for learning +- Keep the examples up-to-date -## Authentication and authorization +## Getting started -- [Create account or user JWTs programmatically](./auth/create-jwts) (Go) +The recommended way to get started is to browse the [website](https://natsbyexample.com) which provides nicer navigation and presentation for the example code in this repo. -## Deployment topologies +When you want to actually execute the example code, you can clone this repo, download the [`nbe`](https://github.com/bruth/nats-by-example/releases) CLI and use the `run` command at the root of the repo. For example: -TODO +```sh +$ nbe run messaging/pub-sub/rust +``` -## Operations +This will run the Rust implementation of the [core publish-subscribe example](https://natsbyexample.com/examples/messaging/pub-sub/rust/) in a set of containers. -- [Replacing nodes in a cluster](./operations/replace-cluster-nodes) +Currently, the `nbe` CLI depends on [Docker](https://docs.docker.com) and [Compose](https://docs.docker.com/compose/) (v2+) to run a set of containers hosting the Rust program and the NATS server. Other container runtimes would be considered if requested (such as [Podman](https://podman.io)). + +The name of the example corresponds to the directory structure under `examples/`, specifically `//`. + +Have questions, issues, or suggestions? Please open [start a discussion](https://github.com/bruth/nats-by-example/discussions) or open [an issue](https://github.com/bruth/nats-by-example/issues). + +## Design + +### Directory structure + +Under the `examples` directory, each category has one or more examples with one or more client implementations. For example: + +``` +examples/ + meta.yaml + messaging/ + meta.yaml + pub-sub/ + meta.yaml + cli/ + main.sh + go/ + main.go + python/ + main.py +``` + +### Meta files + +The top-level `meta.yaml` is used to define the order of the categories. + +```yaml +# Ordered set of categories. +categories: [string] +``` + +The category `meta.yaml` supports the following properties: + +```yaml +# Title of the category, defaults to a titlecase of the directory name. +title: string + +# Description of the category. +description: string + +# An ordered list of the example names within the category. +examples: [string] +``` + +The example `meta.yaml` supports the following properties: + +```yaml +# Title of the example, defaults to the title-case of the directory name. +title: string + +# Description of the example. +description: string +``` + +### Client directory + +The directory is named after the NATS client they correspond to, either the language name, e.g. `go` or the CLI, e.g. `cli`. For multi-client examples or ones requiring complex setups, the directory can be named `shell` to indicate a custom shell script is being used. + +The entrypoint file is expected to be named `main.[ext]` where the `ext` is language specific or `sh` for a shell script (including CLI usage). In addition to convention, the significance of this file is that the comments will be extracted out to be rendered more legibly alongside the source code for the website. + +Each client may include a custom `Dockerfile` to be able to build and run the example in a container acting as a controlled, reproducible environment. If not provided, the default one, by language, in the [`docker/`](./docker) directory will be used. + +Most examples require a NATS server, so there are two `docker-compose.yaml` files available in `docker/` which will be used by default. If there is a need for a customer file for an example, it can be added to the example directory to override the default. + +## Contributing + +There are several ways to contribute! + +- Create an issue for an issue with an existing example (comment or code) +- Create an issue to for a new client of an existing example +- Create an issue to recommend a new example +- Create a pull request to fix an existing example +- Create a pull request for a new client of an existing example diff --git a/auth/create-jwts/README.md b/auth/create-jwts/README.md deleted file mode 100644 index 72426f92..00000000 --- a/auth/create-jwts/README.md +++ /dev/null @@ -1,31 +0,0 @@ -# Create JWTs Programmatically - -The primary (and recommended) way to create and manage accounts and users is using the [nsc](https://nats-io.github.io/nsc/) command-line tool. However, in some applications and use cases, it may be desirable to programmatically create accounts or users on-demand as part of an application-level account/user workflow rather than out-of-band on the command line (however, shelling out to `nsc` from your program is another option). - -This program implements two basic functions, one for creating an account and one for creating a user. - -## Usage - -Either the operator seed is required for creating accounts or the account seed is required for creating users. To view the seeds via `nsc` you can use: - -``` -nsc list keys --show-seeds -``` - -If using signing keys, ensure you choose that seed. - -To create an account, specify the `-operator` supplying the seed and `-name` for the name of the account. - -``` -go run main.go -operator -name -``` - -Similarly, create a user by providing the account seed. - -```sh -go run main.go -account -name -``` - -## Additional Resources - -- [In-depth JWT Guide - Automated sign-up service example](https://docs.nats.io/running-a-nats-service/nats_admin/security/jwt#automated-sign-up-services-jwt-and-nkey-libraries) diff --git a/auth/create-jwts/main.go b/auth/create-jwts/main.go deleted file mode 100644 index e0db8dc3..00000000 --- a/auth/create-jwts/main.go +++ /dev/null @@ -1,107 +0,0 @@ -package main - -import ( - "flag" - "fmt" - "log" - - "github.com/nats-io/jwt/v2" - "github.com/nats-io/nkeys" -) - -func main() { - log.SetFlags(0) - - var ( - accountSeed string - operatorSeed string - name string - ) - - flag.StringVar(&operatorSeed, "operator", "", "Operator seed for creating an account.") - flag.StringVar(&accountSeed, "account", "", "Account seed for creating a user.") - flag.StringVar(&name, "name", "", "Account or user name to be created.") - - flag.Parse() - - if accountSeed != "" && operatorSeed != "" { - log.Fatal("operator and account cannot both be provided") - } - - var ( - jwt string - err error - ) - - if operatorSeed != "" { - jwt, err = createAccount(operatorSeed, name) - } else if accountSeed != "" { - jwt, err = createUser(accountSeed, name) - } else { - flag.PrintDefaults() - return - } - if err != nil { - log.Fatalf("error creating account JWT: %v", err) - } - - fmt.Println(jwt) -} - -func createAccount(operatorSeed, accountName string) (string, error) { - akp, err := nkeys.CreateAccount() - if err != nil { - return "", fmt.Errorf("unable to create account using nkeys: %w", err) - } - - apub, err := akp.PublicKey() - if err != nil { - return "", fmt.Errorf("unable to retrieve public key: %w", err) - } - - ac := jwt.NewAccountClaims(apub) - ac.Name = accountName - - // Load operator key pair - okp, err := nkeys.FromSeed([]byte(operatorSeed)) - if err != nil { - return "", fmt.Errorf("unable to create operator key pair from seed: %w", err) - } - - // Sign the account claims and convert it into a JWT string - ajwt, err := ac.Encode(okp) - if err != nil { - return "", fmt.Errorf("unable to sign the claims: %w", err) - } - - return ajwt, nil -} - -func createUser(accountSeed, userName string) (string, error) { - ukp, err := nkeys.CreateUser() - if err != nil { - return "", fmt.Errorf("unable to create user using nkeys: %w", err) - } - - upub, err := ukp.PublicKey() - if err != nil { - return "", fmt.Errorf("unable to retrieve public key: %w", err) - } - - uc := jwt.NewUserClaims(upub) - uc.Name = userName - - // Load account key pair - akp, err := nkeys.FromSeed([]byte(accountSeed)) - if err != nil { - return "", fmt.Errorf("unable to create account key pair from seed: %w", err) - } - - // Sign the user claims and convert it into a JWT string - ujwt, err := uc.Encode(akp) - if err != nil { - return "", fmt.Errorf("unable to sign the claims: %w", err) - } - - return ujwt, nil -} diff --git a/cmd/nbe/docker.go b/cmd/nbe/docker.go new file mode 100644 index 00000000..ca7ed1b3 --- /dev/null +++ b/cmd/nbe/docker.go @@ -0,0 +1,244 @@ +package main + +import ( + "bytes" + "errors" + "fmt" + "io" + "io/fs" + "io/ioutil" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/google/uuid" +) + +func createFile(n string, b []byte) error { + dir := filepath.Dir(n) + if dir != "" { + os.MkdirAll(dir, 0755) + } + + c, err := os.Create(n) + if err != nil { + return err + } + _, err = c.Write(b) + if err != nil { + return err + } + return c.Close() +} + +func copyDirContents(src, dst string) error { + return fs.WalkDir(os.DirFS(src), ".", func(path string, info fs.DirEntry, err error) error { + dstpath := filepath.Join(dst, path) + // Ensure any directories are created.. + if info.IsDir() { + return os.MkdirAll(dstpath, 0755) + } + sf, err := os.Open(filepath.Join(src, path)) + if err != nil { + return err + } + defer sf.Close() + df, err := os.Create(dstpath) + if err != nil { + return err + } + _, err = io.Copy(df, sf) + return err + }) +} + +func generateOutput(repo, example string, recreate bool) error { + p := filepath.Join(repo, example, "output.txt") + + _, err := os.Stat(p) + // Exists.. + if err == nil { + // Regardless if it was generated or manually created. + if !recreate { + return nil + } + } else if !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("open file: %w", err) + } + + stdout := bytes.NewBuffer(nil) + stderr := bytes.NewBuffer(nil) + + r := ExampleRunner{ + Repo: repo, + Example: example, + Stdout: stdout, + Stderr: stderr, + } + + err = r.Run() + if err != nil { + return fmt.Errorf("%w\n%s", err, stderr.String()) + } + + // Create new or recreate. + err = createFile(p, stdout.Bytes()) + if err != nil { + return fmt.Errorf("create file: %w", err) + } + + return nil +} + +type ExampleRunner struct { + Name string + // Absolute path to the repo. + Repo string + // Relative path to the example, examples/ can be omitted. + Example string + // Set to true, to force the use of a cluster. + Cluster bool + // If true, do not delete the image. + KeepImage bool + // Defaults to os.Stdout and os.Stderr. Set if these streams need to be + // explicitly captured. + Stdout io.Writer + Stderr io.Writer +} + +func (r *ExampleRunner) Run() error { + stdout := r.Stdout + stderr := r.Stderr + + if stdout == nil { + stdout = os.Stdout + } + + if stderr == nil { + stderr = os.Stderr + } + + example := r.Example + if !strings.HasPrefix(example, "examples/") { + example = filepath.Join("examples", example) + } + + clientDir := filepath.Join(r.Repo, example) + exampleDir := filepath.Dir(clientDir) + lang := filepath.Base(example) + + composeFile := filepath.Join(exampleDir, "docker-compose.yaml") + if _, err := os.Stat(composeFile); err != nil { + if os.IsNotExist(err) { + if r.Cluster { + composeFile = filepath.Join(r.Repo, "docker", "docker-compose.cluster.yaml") + } else { + composeFile = filepath.Join(r.Repo, "docker", "docker-compose.yaml") + } + } else { + return err + } + } + + var uid string + if r.Name != "" { + uid = r.Name + } else { + uid = uuid.New().String()[:8] + } + + imageTag := fmt.Sprintf("%s:%s", filepath.Join("nbe", r.Example), uid) + + defaultDir := filepath.Join(r.Repo, "docker", lang) + + // Create a temporary directory for the build context of the image. + // This will combine all files in the runtime-specific docker/ directory + // and the files in the example. + buildDir, err := ioutil.TempDir("", "") + if err != nil { + return fmt.Errorf("temp dir: %w", err) + } + // Clean up the directory on exit. + defer os.RemoveAll(buildDir) + + // Copy default files first. + if err := copyDirContents(defaultDir, buildDir); err != nil { + return err + } + + // Copy example files next.. + if err := copyDirContents(clientDir, buildDir); err != nil { + return err + } + + // Build the temporary image relative to the build directory. + c := exec.Command( + "docker", + "build", + "--tag", imageTag, + buildDir, + ) + + c.Stdout = stdout + c.Stderr = stderr + + err = c.Run() + if err != nil { + return fmt.Errorf("build image: %w", err) + } + + if !r.KeepImage { + // Remove the built image on exit to prevent + // TODO: should rely on git hash instead of random uid. + defer exec.Command( + "docker", + "rmi", + imageTag, + ).Run() + } + + // Create a temporary directory as the project directory for the temporary + // .env file containing the image tag. + composeDir, err := ioutil.TempDir("", "") + if err != nil { + return fmt.Errorf("temp dir: %w", err) + } + // Clean up the directory on exit. + defer os.RemoveAll(composeDir) + + err = createFile(filepath.Join(composeDir, ".env"), []byte(fmt.Sprintf("IMAGE_TAG=%s", imageTag))) + if err != nil { + return fmt.Errorf("create .env: %w", err) + } + + // Best effort to bring containers down.. + defer exec.Command( + "docker", + "compose", + "--project-name", uid, + "--project-directory", composeDir, + "--file", composeFile, + "down", + "--remove-orphans", + "--timeout", "3", + ).Run() + + // Run the app container. + cmd := exec.Command( + "docker", + "compose", + "--project-name", uid, + "--project-directory", composeDir, + "--file", composeFile, + "run", + "--no-TTY", + "--rm", + "app", + ) + + cmd.Stdout = stdout + cmd.Stderr = stderr + + return cmd.Run() +} diff --git a/cmd/nbe/docs.go b/cmd/nbe/docs.go new file mode 100644 index 00000000..1a33b6f7 --- /dev/null +++ b/cmd/nbe/docs.go @@ -0,0 +1,454 @@ +package main + +import ( + "bytes" + "fmt" + "html/template" + "path/filepath" + "strings" + + "github.com/alecthomas/chroma" + "github.com/alecthomas/chroma/formatters/html" + "github.com/alecthomas/chroma/lexers" + "github.com/alecthomas/chroma/styles" + "github.com/russross/blackfriday/v2" + + _ "embed" +) + +var ( + languageOrder = []string{ + CLI, + Go, + Python, + Deno, + Node, + Rust, + CSharp, + Java, + Ruby, + Elixir, + C, + } +) + +var ( + //go:embed tmpl/head.html + headInclude string + + //go:embed tmpl/logo.html + logoInclude string + + //go:embed tmpl/index.html + indexPage string + + //go:embed tmpl/category.html + categoryPage string + + //go:embed tmpl/example.html + examplePage string + + //go:embed tmpl/client.html + clientPage string +) + +type LanguageLink struct { + Name string + Label string + Path string +} + +type Link struct { + Label string + Path string +} + +type indexCategory struct { + Title string + Description template.HTML + ExampleLinks []*Link +} + +type indexData struct { + Categories []*indexCategory +} + +type categoryData struct { + Title string + Description template.HTML + Examples []*Link +} + +type exampleData struct { + CategoryTitle string + CategoryPath string + Title string + Description template.HTML + Path string + Links []*LanguageLink +} + +type clientData struct { + CategoryTitle string + CategoryPath string + ExampleTitle string + ExamplePath string + ExampleDescription template.HTML + RunPath string + Path string + SourceURL string + Language string + Links []*LanguageLink + Blocks []*RenderedBlock + JSEscaped string +} + +func generateDocs(root *Root, dir string) error { + t := template.New("site") + + _, err := t.New("head").Parse(headInclude) + if err != nil { + return err + } + + _, err = t.New("logo").Parse(logoInclude) + if err != nil { + return err + } + + rt, err := t.New("index").Parse(indexPage) + if err != nil { + return fmt.Errorf("index: %w", err) + } + + ct, err := t.New("category").Parse(categoryPage) + if err != nil { + return fmt.Errorf("category: %w", err) + } + + et, err := t.New("example").Parse(examplePage) + if err != nil { + return fmt.Errorf("example: %w", err) + } + + it, err := t.New("client").Parse(clientPage) + if err != nil { + return fmt.Errorf("client: %w", err) + } + + buf := bytes.NewBuffer(nil) + + var ics []*indexCategory + for _, c := range root.Categories { + ic := indexCategory{ + Title: c.Title, + Description: template.HTML(blackfriday.Run([]byte(c.Description))), + } + ics = append(ics, &ic) + for _, e := range c.Examples { + l := &Link{ + Label: e.Title, + } + for _, k := range languageOrder { + if c, ok := e.Clients[k]; ok { + l.Path = c.Path + break + } + } + // Use the example title, but with the first client path. + ic.ExampleLinks = append(ic.ExampleLinks, l) + } + } + + ix := indexData{ + Categories: ics, + } + + err = rt.Execute(buf, &ix) + if err != nil { + return err + } + + err = createFile(filepath.Join(dir, "index.html"), buf.Bytes()) + if err != nil { + return err + } + + for _, c := range root.Categories { + buf.Reset() + + var elinks []*Link + for _, e := range c.Examples { + elinks = append(elinks, &Link{ + Label: e.Title, + Path: e.Path, + }) + } + + cx := categoryData{ + Title: c.Title, + Description: template.HTML(blackfriday.Run([]byte(c.Description))), + Examples: elinks, + } + err = ct.Execute(buf, &cx) + if err != nil { + return err + } + + err = createFile(filepath.Join(dir, c.Path, "index.html"), buf.Bytes()) + if err != nil { + return err + } + + for _, e := range c.Examples { + buf.Reset() + + links := make([]*LanguageLink, len(languageOrder)) + for i, n := range languageOrder { + l := &LanguageLink{ + Name: n, + Label: availableLanguages[n], + } + for _, i := range e.Clients { + if i.Language == n { + l.Path = i.Path + break + } + } + links[i] = l + } + + ex := exampleData{ + CategoryTitle: c.Title, + CategoryPath: c.Path, + Description: template.HTML(blackfriday.Run([]byte(e.Description))), + Title: e.Title, + Path: e.Path, + Links: links, + } + err = et.Execute(buf, &ex) + if err != nil { + return err + } + + err = createFile(filepath.Join(dir, e.Path, "index.html"), buf.Bytes()) + if err != nil { + return err + } + + for _, i := range e.Clients { + var rblocks []*RenderedBlock + // Always start with a comment block... + if i.Blocks[0].Type == CodeBlock { + rblocks = append(rblocks, &RenderedBlock{Type: "comment"}) + } + + for _, b := range i.Blocks { + rb, err := renderBlock(i.Language, b) + if err != nil { + return err + } + rblocks = append(rblocks, rb) + } + + ix := clientData{ + CategoryTitle: c.Title, + CategoryPath: c.Path, + ExampleTitle: e.Title, + ExamplePath: e.Path, + ExampleDescription: ex.Description, + Path: i.Path, + RunPath: strings.TrimPrefix(i.Path, "examples/"), + SourceURL: "https://github.com/bruth/nats-by-example/tree/main/" + i.Path, + Links: links, + Language: i.Language, + Blocks: rblocks, + JSEscaped: i.Source, + } + + buf.Reset() + err = it.Execute(buf, &ix) + + err = createFile(filepath.Join(dir, i.Path, "index.html"), buf.Bytes()) + if err != nil { + return err + } + } + } + } + + return nil +} + +func commonPrefixForLines(lines []string, delim string) (string, int) { + // Find the first line with a prefix. + for i, l := range lines { + // Ignore leading empty lines. + if strings.TrimSpace(l) == "" { + continue + } + + // Get the leading whitespace for the first comment line. Assume all are + // indented at the same level. + idx := strings.Index(l, delim) + return l[:idx], i + } + + return "", -1 +} + +// Remove leading whitespace and comment delimiters. Remove shortest whitespace +// prefix after delimiters. Leading and trailing empty lines are removed, interleaved +// ones are preserved for rendering. +func cleanSingleCommentLines(lines []string, delim string) (string, string) { + prefix, first := commonPrefixForLines(lines, delim) + + var cleaned []string + for _, l := range lines[first:] { + // Trim leading whitespace. + l = strings.TrimPrefix(l, prefix) + // Trim comment characters. + l = strings.TrimPrefix(l, delim) + // Assume space after delim. + l = strings.TrimPrefix(l, " ") + cleaned = append(cleaned, l) + } + + return strings.TrimSpace(strings.Join(cleaned, "\n")), prefix +} + +// Dedent relative to the smallest indent for all lines. Also remove +// the comment syntax. +func cleanMultiCommentLines(lines []string) (string, string) { + prefix, first := commonPrefixForLines(lines, "/*") + + var cleaned []string + var lastIdx int + for i, l := range lines[first:] { + l = strings.TrimPrefix(l, prefix) + if i == 0 { + l = strings.TrimPrefix(l, "/*") + } + cleaned = append(cleaned, l) + if strings.TrimSpace(l) != "" { + lastIdx = i + } + } + + last := cleaned[lastIdx] + cleaned[lastIdx] = strings.TrimSuffix(last, "*/") + + return strings.TrimSpace(strings.Join(cleaned, "\n")), prefix +} + +func chromaFormat(code, lang string) (string, error) { + switch lang { + case Shell, CLI: + lang = "sh" + case Deno, Bun: + lang = "ts" + case Node: + lang = "js" + case WebSocket: + lang = "js" + } + + lexer := lexers.Get(lang) + if lexer == nil { + lexer = lexers.Fallback + } + + if lang == "output" { + lexer = SimpleShellOutputLexer + } + + lexer = chroma.Coalesce(lexer) + + style := styles.Get("swapoff") + if style == nil { + style = styles.Fallback + } + formatter := html.New(html.WithClasses(true)) + iterator, err := lexer.Tokenise(nil, string(code)) + if err != nil { + return "", err + } + buf := bytes.NewBuffer(nil) + err = formatter.Format(buf, style, iterator) + return buf.String(), nil +} + +func renderBlock(lang string, block *Block) (*RenderedBlock, error) { + var r RenderedBlock + switch block.Type { + case CodeBlock: + r.Type = "code" + text := strings.Join(block.Lines, "\n") + html, err := chromaFormat(text, lang) + if err != nil { + return nil, err + } + r.HTML = template.HTML(html) + + case SingleLineCommentBlock: + delim := languageLineCommentDelim[lang] + text, indent := cleanSingleCommentLines(block.Lines, delim) + r.Type = "comment" + r.HTML = template.HTML(blackfriday.Run([]byte(text))) + r.Prefix = indent + + case MultiLineCommentBlock: + text, indent := cleanMultiCommentLines(block.Lines) + r.Type = "comment" + r.HTML = template.HTML(blackfriday.Run([]byte(text))) + r.Prefix = indent + } + + return &r, nil +} + +type RenderedBlock struct { + // Comment or Code + Type string + + // HTML rendered content. comment -> markdown, code -> syntax highlighted + HTML template.HTML + + // Prefix string for the non-empty lines. + Prefix string +} + +var SimpleShellOutputLexer = chroma.MustNewLexer( + &chroma.Config{ + Name: "Shell Output", + Aliases: []string{"console"}, + Filenames: []string{"*.sh"}, + MimeTypes: []string{}, + }, + chroma.Rules{ + "root": { + // $ or > triggers the start of prompt formatting + {`^\$`, chroma.GenericPrompt, chroma.Push("prompt")}, + {`^>`, chroma.GenericPrompt, chroma.Push("prompt")}, + + // empty lines are just text + {`^$\n`, chroma.Text, nil}, + + // otherwise its all output + {`[^\n]+$\n?`, chroma.GenericOutput, nil}, + }, + "prompt": { + // when we find newline, do output formatting rules + {`\n`, chroma.Text, chroma.Push("output")}, + // otherwise its all text + {`[^\n]+$`, chroma.Text, nil}, + }, + "output": { + // sometimes there isn't output so we go right back to prompt + {`^\$`, chroma.GenericPrompt, chroma.Pop(1)}, + {`^>`, chroma.GenericPrompt, chroma.Pop(1)}, + // otherwise its all output + {`[^\n]+$\n?`, chroma.GenericOutput, nil}, + }, + }, +) diff --git a/cmd/nbe/docs_test.go b/cmd/nbe/docs_test.go new file mode 100644 index 00000000..269fc988 --- /dev/null +++ b/cmd/nbe/docs_test.go @@ -0,0 +1,57 @@ +package main + +import ( + "strings" + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestCleanSingleCommentLines(t *testing.T) { + input := ` + // Hello world + // This is a comment + // with some indents + // and more // + // +` + + expected := `Hello world +This is a comment +with some indents +and more //` + + lines := strings.Split(input, "\n") + output, prefix := cleanSingleCommentLines(lines, "//") + + t.Logf("%v", []byte(prefix)) + + if diff := cmp.Diff(expected, output); diff != "" { + t.Error(diff) + } +} + +func TestCleanMultiCommentLines(t *testing.T) { + input := ` + /* + Hello world + This is a comment + with some indents + and more // + */ +` + + expected := `Hello world +This is a comment +with some indents +and more //` + + lines := strings.Split(input, "\n") + output, prefix := cleanMultiCommentLines(lines) + + t.Logf("%v", []byte(prefix)) + + if diff := cmp.Diff(expected, output); diff != "" { + t.Error(diff) + } +} diff --git a/cmd/nbe/go.mod b/cmd/nbe/go.mod new file mode 100644 index 00000000..fa450c8e --- /dev/null +++ b/cmd/nbe/go.mod @@ -0,0 +1,18 @@ +module github.com/bruth/nats-by-example/cmd/nbe + +go 1.18 + +require ( + github.com/alecthomas/chroma v0.10.0 + github.com/google/go-cmp v0.5.5 + github.com/google/uuid v1.3.0 + github.com/russross/blackfriday/v2 v2.1.0 + github.com/urfave/cli/v2 v2.10.3 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect + github.com/dlclark/regexp2 v1.4.0 // indirect + github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect +) diff --git a/cmd/nbe/go.sum b/cmd/nbe/go.sum new file mode 100644 index 00000000..0b3badf7 --- /dev/null +++ b/cmd/nbe/go.sum @@ -0,0 +1,31 @@ +github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek= +github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s= +github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= +github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.4.0 h1:F1rxgk7p4uKjwIQxBs9oAXe5CqrXlCduYEJvrF4u93E= +github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= +github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/urfave/cli/v2 v2.10.3 h1:oi571Fxz5aHugfBAJd5nkwSk3fzATXtMlpxdLylSCMo= +github.com/urfave/cli/v2 v2.10.3/go.mod h1:f8iq5LtQ/bLxafbdBSLPPNsgaW0l/2fYYEHhAyPlwvo= +github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU= +github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/cmd/nbe/main.go b/cmd/nbe/main.go new file mode 100644 index 00000000..e1705aa8 --- /dev/null +++ b/cmd/nbe/main.go @@ -0,0 +1,222 @@ +package main + +import ( + "fmt" + "io/fs" + "io/ioutil" + "log" + "net/http" + "os" + "path/filepath" + + "github.com/urfave/cli/v2" +) + +func main() { + err := app.Run(os.Args) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +var ( + app = cli.App{ + Name: "nbe", + Usage: "CLI for using the NATS by Example repo.", + Commands: []*cli.Command{ + &runCmd, + &buildCmd, + &serveCmd, + &generateCmd, + }, + } + + runCmd = cli.Command{ + Name: "run", + Usage: "Run an example using containers.", + Description: `To run an example, the current requirement is to clone the +repo and run the command in the root the repo. + +Future versions may leverage pre-built images hosted in a registry to reduce +the runtime.`, + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "cluster", + Usage: "Use compose file with a NATS cluster.", + Value: false, + }, + &cli.StringFlag{ + Name: "name", + Usage: "Explicit name of the run. This maps to the Compose project name and image tag.", + Value: "", + }, + &cli.BoolFlag{ + Name: "keep-image", + Usage: "If true, the example image is not deleted after the run.", + Value: false, + }, + }, + Action: func(c *cli.Context) error { + cluster := c.Bool("cluster") + repo := c.String("repo") + name := c.String("name") + keep := c.Bool("keep-image") + example := c.Args().First() + + repo, err := os.Getwd() + if err != nil { + return err + } + + r := ExampleRunner{ + Name: name, + Repo: repo, + Example: example, + Cluster: cluster, + KeepImage: keep, + } + + return r.Run() + }, + } + + serveCmd = cli.Command{ + Name: "serve", + Usage: "Dev server for the docs.", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "dir", + Usage: "Directory containing the rendered HTML.", + Value: "html", + }, + &cli.StringFlag{ + Name: "addr", + Usage: "HTTP bind address.", + Value: "localhost:8000", + }, + }, + Action: func(c *cli.Context) error { + addr := c.String("addr") + dir := c.String("dir") + return http.ListenAndServe(addr, http.FileServer(http.Dir(dir))) + }, + } + + generateCmd = cli.Command{ + Name: "generate", + Usage: "Set of commands for generating various files from examples.", + Subcommands: []*cli.Command{ + &generateOutputCmd, + }, + } + + generateOutputCmd = cli.Command{ + Name: "output", + Usage: "Generate execution output for examples.", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "source", + Usage: "Source directory containing the examples.", + Value: "examples", + }, + &cli.BoolFlag{ + Name: "recreate", + Usage: "If true, recreate all previously generated output files.", + }, + }, + Action: func(c *cli.Context) error { + repo, err := os.Getwd() + if err != nil { + return err + } + + source := c.String("source") + recreate := c.Bool("recreate") + + root, err := parseExamples(source) + if err != nil { + return err + } + + // Enumerate all the example implementations. + for _, c := range root.Categories { + for _, e := range c.Examples { + for _, i := range e.Clients { + if err := generateOutput(repo, i.Path, recreate); err != nil { + log.Printf("%s: %s", i.Path, err) + } + } + } + } + + return nil + }, + } + + buildCmd = cli.Command{ + Name: "build", + Usage: "Takes the examples and builds documentation from it.", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "source", + Usage: "Source directory containing the examples.", + Value: "examples", + }, + &cli.StringFlag{ + Name: "static", + Usage: "Directory containing static files that will be copied in.", + Value: "static", + }, + &cli.StringFlag{ + Name: "output", + Usage: "Directory the HTML files will be written to. Note, this will delete the existing directory if present.", + Value: "html", + }, + }, + Action: func(c *cli.Context) error { + source := c.String("source") + output := c.String("output") + static := c.String("static") + + root, err := parseExamples(source) + if err != nil { + return err + } + + if _, err := os.Stat(output); os.IsNotExist(err) { + if err := os.MkdirAll(output, 0755); err != nil { + return err + } + } else { + entries, err := os.ReadDir(output) + if err != nil { + return err + } + for _, e := range entries { + if err := os.RemoveAll(filepath.Join(output, e.Name())); err != nil { + return err + } + } + } + + entries, err := fs.ReadDir(os.DirFS(static), ".") + if err != nil { + return err + } + + for _, e := range entries { + b, err := ioutil.ReadFile(filepath.Join(static, e.Name())) + if err != nil { + return err + } + err = createFile(filepath.Join(output, e.Name()), b) + if err != nil { + return err + } + } + + return generateDocs(root, output) + }, + } +) diff --git a/cmd/nbe/main_test.go b/cmd/nbe/main_test.go new file mode 100644 index 00000000..e812f33b --- /dev/null +++ b/cmd/nbe/main_test.go @@ -0,0 +1,78 @@ +package main + +import ( + "bytes" + "testing" + + "gopkg.in/yaml.v3" +) + +func checkEqual[T comparable](t *testing.T, a, b T) { + t.Helper() + if a != b { + t.Error("not equal") + } +} + +func logBlocks(t *testing.T, blocks []*Block) { + b, _ := yaml.Marshal(blocks) + t.Log(string(b)) +} + +func TestParseLineType(t *testing.T) { + // C-style + checkEqual(t, parseLineType(Go, ` /* hello`), OpenMultiCommentLine) + checkEqual(t, parseLineType(Go, `yep */`), CloseMultiCommentLine) + checkEqual(t, parseLineType(Go, ` // ba`), SingleCommentLine) + checkEqual(t, parseLineType(Go, ` /* meh */ `), NormalLine) + checkEqual(t, parseLineType(Go, ` Foo int // int`), NormalLine) + checkEqual(t, parseLineType(Go, ` 1 / 2 `), NormalLine) + checkEqual(t, parseLineType(Go, ` `), EmptyLine) + + // Whitespace-sensitive + checkEqual(t, parseLineType(Python, ` # ba`), SingleCommentLine) + checkEqual(t, parseLineType(Python, `##ba`), SingleCommentLine) +} + +func TestParseReader(t *testing.T) { + goCode := `/* +Package foo provides utilies for interacting with JetStream. + +*/ +package foo + +// Read stream.. +func ReadStream(js nats.JetStreamContext, name string) ([]*nats.Msg, error) { + ... +} + +` + blocks, err := parseReader(Go, bytes.NewBuffer([]byte(goCode))) + if err != nil { + t.Fatal(err) + } + checkEqual(t, len(blocks), 4) + + pythonCode := `# Package foo +import csv + +with open('somefile.txt') as f: + # Initialize a new CSV reader + + cr := csv.reader(f) + + # Read + # all + # the + + # lines + lines := tuple(cr) + +` + + blocks, err = parseReader(Python, bytes.NewBuffer([]byte(pythonCode))) + if err != nil { + t.Fatal(err) + } + checkEqual(t, len(blocks), 6) +} diff --git a/cmd/nbe/parse.go b/cmd/nbe/parse.go new file mode 100644 index 00000000..e7243c83 --- /dev/null +++ b/cmd/nbe/parse.go @@ -0,0 +1,537 @@ +package main + +import ( + "bufio" + "errors" + "fmt" + "io" + "io/fs" + "log" + "os" + "path/filepath" + "regexp" + "strings" + + "gopkg.in/yaml.v3" +) + +const ( + Shell = "shell" + CLI = "cli" + Go = "go" + Rust = "rust" + Java = "java" + CSharp = "csharp" + Deno = "deno" + Node = "node" + Bun = "bun" + WebSocket = "websocket" + C = "c" + Python = "python" + Ruby = "ruby" + Elixir = "elixir" +) + +var ( + // Available client SDKs.. + availableLanguages = map[string]string{ + Shell: "Shell", + CLI: "CLI", + Go: "Go", + Rust: "Rust", + Java: "Java", + CSharp: "C#", + Deno: "Deno", + Node: "Node", + Bun: "Bun", + WebSocket: "WebSocket", + C: "C", + Python: "Python", + Ruby: "Ruby", + Elixir: "Elixir", + } + + // TODO: add more as they become supported.. + languageMains = map[string]string{ + Go: "main.go", + Python: "main.py", + CLI: "main.sh", + Shell: "main.sh", + Rust: "main.rs", + Deno: "main.ts", + Bun: "main.ts", + Node: "main.js", + WebSocket: "main.js", + } + + languageMultiCommentDelims = map[string][2]string{ + Go: {"/*", "*/"}, + // TODO: java has a few conventions.. + // https://www.oracle.com/java/technologies/javase/codeconventions-comments.html + Java: {"/*", "*/"}, + CSharp: {"/**", "**/"}, + Deno: {"/*", "*/"}, + Node: {"/*", "*/"}, + Bun: {"/*", "*/"}, + WebSocket: {"/*", "*/"}, + C: {"/*", "*/"}, + } + + languageLineCommentDelim = map[string]string{ + Shell: "#", + CLI: "#", + Go: "//", + Rust: "//", + Java: "//", + CSharp: "///", + Deno: "//", + Node: "//", + Bun: "//", + WebSocket: "//", + C: "//", + Python: "#", + Ruby: "#", + Elixir: "#", + } +) + +type Root struct { + Path string + Categories []*Category +} + +type Category struct { + Name string + Path string + Title string + Description string + Examples []*Example +} + +type Example struct { + Name string + Path string + Title string + Description string + Clients map[string]*Client +} + +type Client struct { + Name string + Path string + Language string + MainFile string + Blocks []*Block + Source string +} + +type BlockType uint8 + +const ( + EmptyBlock BlockType = iota + CodeBlock + SingleLineCommentBlock + MultiLineCommentBlock +) + +type Block struct { + Type BlockType + Lines []string + StartLine int + EndLine int +} + +type LineType uint8 + +const ( + EmptyLine LineType = iota + NormalLine + SingleCommentLine + OpenMultiCommentLine + CloseMultiCommentLine +) + +var ( + shebangLineRe = regexp.MustCompile(`^#!`) + hashLineCommentRe = regexp.MustCompile(`^\s*#`) + cStyleSingleCommentLineRe = regexp.MustCompile(`^\s*\/\/`) + cStyleOpenMultiCommentLineRe = regexp.MustCompile(`^\s*\/\*`) + cStyleCloseMultiCommentLineRe = regexp.MustCompile(`\*\/`) +) + +// One limitiation is that it does not currently handle trailing multi-line +// comments, such as: +// func() int {/* +// a := 1 +// */ +// b := 2 +// Since this code is scoped to well written examples, it should not be an issue +// in practice. +func parseLineType(lang, line string) LineType { + if strings.TrimSpace(line) == "" { + return EmptyLine + } + + switch lang { + case CLI, Shell: + if shebangLineRe.MatchString(line) { + return NormalLine + } + if hashLineCommentRe.MatchString(line) { + return SingleCommentLine + } + return NormalLine + + case Go, CSharp, Java, Rust, C, Deno, Node, Bun: + if cStyleSingleCommentLineRe.MatchString(line) { + return SingleCommentLine + } + if cStyleOpenMultiCommentLineRe.MatchString(line) { + // Inline multi-line comment, e.g. func foo(a int/*, b int*/) + if cStyleCloseMultiCommentLineRe.MatchString(line) { + return NormalLine + } + return OpenMultiCommentLine + } + if cStyleCloseMultiCommentLineRe.MatchString(line) { + return CloseMultiCommentLine + } + return NormalLine + + case Python, Ruby, Elixir: + if hashLineCommentRe.MatchString(line) { + return SingleCommentLine + } + return NormalLine + } + + panic(fmt.Sprintf("%q not currently supported", lang)) +} + +func parseReader(lang string, r io.Reader) ([]*Block, string, error) { + var ( + lineNum int + block = &Block{StartLine: 1, EndLine: 1} + blocks = []*Block{block} + endMultiLine = false + lines []string + ) + + // Read each line, keeping track of comment and code lines. + sc := bufio.NewScanner(r) + for sc.Scan() { + lineNum++ + line := sc.Text() + lines = append(lines, line) + + if endMultiLine { + block = &Block{ + StartLine: lineNum, + EndLine: lineNum, + } + blocks = append(blocks, block) + } + + endMultiLine = false + lineType := parseLineType(lang, line) + + switch lineType { + // Does not differentiate a boundary.. simply append to current block. + case EmptyLine: + block.Lines = append(block.Lines, line) + + case NormalLine: + switch block.Type { + // If not already a comment block, a normal line implies code. + case EmptyBlock: + block.Type = CodeBlock + + // Boundary from single line comment -> code + case SingleLineCommentBlock: + block = &Block{ + Type: CodeBlock, + StartLine: lineNum, + EndLine: lineNum, + } + blocks = append(blocks, block) + + case CodeBlock: + case MultiLineCommentBlock: + } + + case SingleCommentLine: + switch block.Type { + case EmptyBlock: + block.Type = SingleLineCommentBlock + + // Boundary from code -> comment. + case CodeBlock: + block = &Block{ + Type: SingleLineCommentBlock, + StartLine: lineNum, + EndLine: lineNum, + } + blocks = append(blocks, block) + + // Single line comment within a multi line is just a normal line. + case MultiLineCommentBlock: + case SingleLineCommentBlock: + } + + case OpenMultiCommentLine: + switch block.Type { + case EmptyBlock: + block.Type = MultiLineCommentBlock + + // Boundary code -> multi-line + case CodeBlock: + block = &Block{ + Type: MultiLineCommentBlock, + StartLine: lineNum, + EndLine: lineNum, + } + blocks = append(blocks, block) + + // An opening comment or single comment in an existing multi-line + // comment has no effect. + case MultiLineCommentBlock: + case SingleLineCommentBlock: + } + + case CloseMultiCommentLine: + switch block.Type { + // Only valid block type where this line is relevant. + case MultiLineCommentBlock: + endMultiLine = true + + case EmptyBlock: + case CodeBlock: + case SingleLineCommentBlock: + panic("syntax error while parsing blocks") + } + } + + // Finally append the line and set the end line of the block. + block.Lines = append(block.Lines, line) + block.EndLine = lineNum + } + if err := sc.Err(); err != nil { + return nil, "", err + } + + return blocks, strings.Join(lines, "\n"), nil +} + +func readClientDir(path, name string) (*Client, error) { + x := Client{ + Name: name, + Path: path, + } + lang := strings.ToLower(name) + + // Default to script if not known. + if _, ok := availableLanguages[lang]; !ok { + lang = Shell + } + + // Determine main file name. + mainFile, ok := languageMains[lang] + if !ok { + return nil, fmt.Errorf("language %q not yet supported", lang) + } + + // Ensure main file exists. + f, err := os.Open(filepath.Join(path, mainFile)) + if err != nil { + return nil, fmt.Errorf("open main file: %w", err) + } + defer f.Close() + + blocks, source, err := parseReader(lang, f) + x.Language = lang + x.MainFile = mainFile + x.Blocks = blocks + x.Source = source + + return &x, nil +} + +func readExampleDir(path, name string) (*Example, error) { + x := Example{ + Name: name, + Path: path, + Title: strings.Title(name), + Clients: make(map[string]*Client), + } + + // Read meta file. + meta, err := fs.ReadFile(os.DirFS(path), "meta.yaml") + if err == nil { + if err := yaml.Unmarshal(meta, &x); err != nil { + return nil, fmt.Errorf("parse yaml: %w", err) + } + } else if !errors.Is(err, os.ErrNotExist) { + return nil, fmt.Errorf("read meta: %w", err) + } + + dirs, err := fs.ReadDir(os.DirFS(path), ".") + if err != nil { + return nil, fmt.Errorf("read dir: %w", err) + } + + clients := make(map[string]*Client) + for _, e := range dirs { + if !e.IsDir() { + continue + } + + name := e.Name() + path := filepath.Join(path, name) + im, err := readClientDir(path, name) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + log.Printf("%s: no main file. skipping...", path) + continue + } + return nil, fmt.Errorf("%s: %w", path, err) + } + clients[name] = im + } + + for _, i := range clients { + x.Clients[i.Name] = i + } + + return &x, nil +} + +func readCategoryDir(path, name string) (*Category, error) { + c := Category{ + Name: name, + Path: path, + Title: strings.Title(name), + } + + type categoryMeta struct { + Title string + Description string + Examples []string + } + + var cm categoryMeta + + // Read meta file. + meta, err := fs.ReadFile(os.DirFS(path), "meta.yaml") + if err == nil { + if err := yaml.Unmarshal(meta, &cm); err != nil { + return nil, fmt.Errorf("parse yaml: %w", err) + } + } else if !errors.Is(err, os.ErrNotExist) { + return nil, fmt.Errorf("read meta: %w", err) + } + + if cm.Title != "" { + c.Title = cm.Title + } + c.Description = cm.Description + + dirs, err := fs.ReadDir(os.DirFS(path), ".") + if err != nil { + return nil, fmt.Errorf("read dir: %w", err) + } + + exs := make(map[string]*Example) + for _, e := range dirs { + if !e.IsDir() { + continue + } + name := e.Name() + path := filepath.Join(path, name) + ex, err := readExampleDir(path, name) + if err != nil { + return nil, fmt.Errorf("read example: %s: %w", name, err) + } + + if len(ex.Clients) > 0 { + exs[name] = ex + } + } + + // Append ordered examples first. + for _, name := range cm.Examples { + if _, ok := exs[name]; !ok { + continue + } + c.Examples = append(c.Examples, exs[name]) + delete(exs, name) + } + + // Append the reminder to the end. + for _, e := range exs { + c.Examples = append(c.Examples, e) + } + + return &c, nil +} + +func parseExamples(path string) (*Root, error) { + r := Root{ + Path: path, + } + + type rootMeta struct { + Categories []string + } + var rm rootMeta + + // Read meta file. + meta, err := fs.ReadFile(os.DirFS(path), "meta.yaml") + if err == nil { + if err := yaml.Unmarshal(meta, &rm); err != nil { + return nil, fmt.Errorf("root: parse yaml: %w", err) + } + } else if !errors.Is(err, os.ErrNotExist) { + return nil, fmt.Errorf("root: read meta: %w", err) + } + + // Root will read the categories. + dirs, err := fs.ReadDir(os.DirFS(path), ".") + if err != nil { + return nil, fmt.Errorf("root: read dir: %w", err) + } + + cats := make(map[string]*Category) + for _, e := range dirs { + if !e.IsDir() { + continue + } + + name := e.Name() + path := filepath.Join(path, name) + c, err := readCategoryDir(path, name) + if err != nil { + return nil, fmt.Errorf("read category: %s: %w", name, err) + } + + if len(c.Examples) > 0 { + cats[name] = c + } + } + + // Append ordered categories first. + for _, name := range rm.Categories { + if _, ok := cats[name]; !ok { + continue + } + + r.Categories = append(r.Categories, cats[name]) + delete(cats, name) + } + + // Append the reminder to the end. + for _, c := range cats { + r.Categories = append(r.Categories, c) + } + + return &r, nil +} diff --git a/cmd/nbe/tmpl/category.html b/cmd/nbe/tmpl/category.html new file mode 100644 index 00000000..eff0c386 --- /dev/null +++ b/cmd/nbe/tmpl/category.html @@ -0,0 +1,21 @@ + + + + {{template "head"}} + + + {{template "logo"}} + +

{{.Title}}

+ +
+ {{.Description}} +
+ + + + diff --git a/cmd/nbe/tmpl/client.html b/cmd/nbe/tmpl/client.html new file mode 100644 index 00000000..dde639f9 --- /dev/null +++ b/cmd/nbe/tmpl/client.html @@ -0,0 +1,69 @@ + + + + {{template "head"}} + + +
+
+ {{template "logo"}} +
+
+ +
+
+

{{.ExampleTitle}} in {{.CategoryTitle}}

+ +
+ {{.ExampleDescription}} +
+ + {{$currentPath := .Path}} + +
+
+ {{range .Links}} + {{if eq .Path $currentPath}} + {{.Label}} + {{else if .Path}} + {{.Label}} + {{else}} + {{.Label}} + {{end}} + {{end}} +
+ +
+ +
$ nbe run {{.RunPath}}
+ Learn how to run this example. +
+
+ +
+ {{range .Blocks}} + {{if eq .Type "comment" }} +
+ {{.HTML}} +
+ {{else}} +
+ {{.HTML}} +
+ {{end}} + {{end}} +
+
+
+ + + +
+
+ + diff --git a/cmd/nbe/tmpl/example.html b/cmd/nbe/tmpl/example.html new file mode 100644 index 00000000..4e87544c --- /dev/null +++ b/cmd/nbe/tmpl/example.html @@ -0,0 +1,27 @@ + + + + {{template "head"}} + + + {{template "logo"}} + + +

{{.Title}}

+ +
+ {{.Description}} +
+ +
+ {{range .Links}} + {{if .Path}} + {{.Label}} + {{else}} + {{.Label}} + {{end}} + {{end}} +
+ + + diff --git a/cmd/nbe/tmpl/head.html b/cmd/nbe/tmpl/head.html new file mode 100644 index 00000000..a370d71c --- /dev/null +++ b/cmd/nbe/tmpl/head.html @@ -0,0 +1,15 @@ +NATS by Example + + + + + + + + + diff --git a/cmd/nbe/tmpl/index.html b/cmd/nbe/tmpl/index.html new file mode 100644 index 00000000..f32ec4f5 --- /dev/null +++ b/cmd/nbe/tmpl/index.html @@ -0,0 +1,43 @@ + + + + {{template "head"}} + + +
+
+ {{template "logo"}} +
+
+ +
+
+

Learn NATS by Example

+

An evolving collection of runnable, cross-client reference examples for NATS.

+ +
$ nbe run messaging/pub-sub/{cli,go,rust,python,deno,...}
+ +
+ Learn how to get started or just start browsing the examples below 👇! +
+
+
+ +
+
+ {{range .Categories}} +

{{.Title}}

+

{{.Description}}

+
    + {{range .ExampleLinks}} +
  • {{.Label}}
  • + {{end}} +
+ {{end}} +
+
+ +
+
+ + diff --git a/cmd/nbe/tmpl/logo.html b/cmd/nbe/tmpl/logo.html new file mode 100644 index 00000000..5ae062d4 --- /dev/null +++ b/cmd/nbe/tmpl/logo.html @@ -0,0 +1,5 @@ +

+ + NATS Logo by Example + +

diff --git a/docker/cli/Dockerfile b/docker/cli/Dockerfile new file mode 100644 index 00000000..a24ec057 --- /dev/null +++ b/docker/cli/Dockerfile @@ -0,0 +1,10 @@ +FROM natsio/nats-box:0.12.0 + +RUN useradd --create-home --user-group nats +USER nats +WORKDIR /home/nats/code + +COPY . . + +CMD ["bash", "main.sh"] + diff --git a/docker/deno/Dockerfile b/docker/deno/Dockerfile new file mode 100644 index 00000000..ff19bcb8 --- /dev/null +++ b/docker/deno/Dockerfile @@ -0,0 +1,11 @@ +FROM denoland/deno:1.23.3 + +RUN useradd --create-home --user-group nats +USER nats +WORKDIR /home/nats/code + +COPY . . + +RUN deno cache main.ts + +CMD ["run", "--allow-env", "--allow-net", "main.ts"] diff --git a/docker/docker-compose.cluster.yaml b/docker/docker-compose.cluster.yaml new file mode 100644 index 00000000..ef59a1fd --- /dev/null +++ b/docker/docker-compose.cluster.yaml @@ -0,0 +1,53 @@ +version: '3.9' +services: + nats1: + image: docker.io/nats:2.8.4 + command: + - "--debug" + - "--name=nats1" + - "--cluster_name=c1" + - "--cluster=nats://nats1:6222" + - "--routes=nats-route://nats1:6222,nats-route://nats2:6222,nats-route://nats3:6222" + - "--http_port=8222" + - "--js" + ports: + - "14222:4222" + - "18222:8222" + + nats2: + image: docker.io/nats:2.8.4 + command: + - "--debug" + - "--name=nats2" + - "--cluster_name=c1" + - "--cluster=nats://nats2:6222" + - "--routes=nats-route://nats1:6222,nats-route://nats2:6222,nats-route://nats3:6222" + - "--http_port=8222" + - "--js" + ports: + - "24222:4222" + - "28222:8222" + + nats3: + image: docker.io/nats:2.8.4 + command: + - "--debug" + - "--name=nats3" + - "--cluster_name=c1" + - "--cluster=nats://nats3:6222" + - "--routes=nats-route://nats1:6222,nats-route://nats2:6222,nats-route://nats3:6222" + - "--http_port=8222" + - "--js" + ports: + - "34222:4222" + - "38222:8222" + + app: + image: ${IMAGE_TAG} + environment: + - NATS_URL=nats://nats1:4222,nats://nats2:4222,nats://nats3:4222 + depends_on: + - nats1 + - nats2 + - nats3 + diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml new file mode 100644 index 00000000..59db2e59 --- /dev/null +++ b/docker/docker-compose.yaml @@ -0,0 +1,19 @@ +version: '3.9' +services: + nats: + image: docker.io/nats:2.8.4 + command: + - "--debug" + - "--http_port=8222" + - "--js" + ports: + - "14222:4222" + - "18222:8222" + + app: + image: ${IMAGE_TAG} + environment: + - NATS_URL=nats://nats:4222 + depends_on: + - nats + diff --git a/docker/go/Dockerfile b/docker/go/Dockerfile new file mode 100644 index 00000000..3791efdf --- /dev/null +++ b/docker/go/Dockerfile @@ -0,0 +1,23 @@ +# Image to build the Go binary. +FROM golang:1.18-alpine AS build + +RUN adduser -D nats +USER nats +WORKDIR /home/nats/code + +COPY go.mod go.sum ./ +RUN go mod download && go mod verify + +COPY . ./ +RUN go build -v -o /home/nats/app ./... + +# Copy binary to small image for distribution. +FROM alpine + +RUN adduser -D nats +USER nats + +COPY --from=build /home/nats/app /home/nats/ + +CMD ["/home/nats/app"] + diff --git a/auth/create-jwts/go.mod b/docker/go/go.mod similarity index 59% rename from auth/create-jwts/go.mod rename to docker/go/go.mod index c12bf053..a0ef87e7 100644 --- a/auth/create-jwts/go.mod +++ b/docker/go/go.mod @@ -1,16 +1,13 @@ -module github.com/bruth/nats-by-example/create-jwts +module github.com/bruth/nats-by-example/go go 1.18 -require ( - github.com/nats-io/jwt/v2 v2.2.1-0.20220330180145-442af02fd36a - github.com/nats-io/nats.go v1.16.0 - github.com/nats-io/nkeys v0.3.0 -) +require github.com/nats-io/nats.go v1.16.0 require ( github.com/golang/protobuf v1.5.2 // indirect github.com/nats-io/nats-server/v2 v2.8.4 // indirect + github.com/nats-io/nkeys v0.3.0 // indirect github.com/nats-io/nuid v1.0.1 // indirect golang.org/x/crypto v0.0.0-20220315160706-3147a52a75dd // indirect google.golang.org/protobuf v1.28.0 // indirect diff --git a/auth/create-jwts/go.sum b/docker/go/go.sum similarity index 95% rename from auth/create-jwts/go.sum rename to docker/go/go.sum index eace8a93..40c4c9b6 100644 --- a/auth/create-jwts/go.sum +++ b/docker/go/go.sum @@ -5,7 +5,6 @@ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/klauspost/compress v1.14.4 h1:eijASRJcobkVtSt81Olfh7JX43osYLwy5krOJo6YEu4= github.com/minio/highwayhash v1.0.2 h1:Aak5U0nElisjDCfPSG79Tgzkn2gl66NxOMspRrKnA/g= github.com/nats-io/jwt/v2 v2.2.1-0.20220330180145-442af02fd36a h1:lem6QCvxR0Y28gth9P+wV2K/zYUUAkJ+55U8cpS0p5I= -github.com/nats-io/jwt/v2 v2.2.1-0.20220330180145-442af02fd36a/go.mod h1:0tqz9Hlu6bCBFLWAASKhE5vUA4c24L9KPUUgvwumE/k= github.com/nats-io/nats-server/v2 v2.8.4 h1:0jQzze1T9mECg8YZEl8+WYUXb9JKluJfCBriPUtluB4= github.com/nats-io/nats-server/v2 v2.8.4/go.mod h1:8zZa+Al3WsESfmgSs98Fi06dRWLH5Bnq90m5bKD/eT4= github.com/nats-io/nats.go v1.16.0 h1:zvLE7fGBQYW6MWaFaRdsgm9qT39PJDQoju+DS8KsO1g= diff --git a/docker/node/Dockerfile b/docker/node/Dockerfile new file mode 100644 index 00000000..0cc9373f --- /dev/null +++ b/docker/node/Dockerfile @@ -0,0 +1,13 @@ +FROM node:slim + +RUN useradd --create-home --user-group nats +USER nats +WORKDIR /home/nats/code + +COPY package.json ./ + +RUN npm install + +COPY . . + +CMD ["npm", "run", "main.js"] \ No newline at end of file diff --git a/docker/node/package-lock.json b/docker/node/package-lock.json new file mode 100644 index 00000000..74f57ce2 --- /dev/null +++ b/docker/node/package-lock.json @@ -0,0 +1,74 @@ +{ + "name": "node", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "dependencies": { + "nats": "^2.7.1" + } + }, + "node_modules/@types/node": { + "version": "14.18.21", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.21.tgz", + "integrity": "sha512-x5W9s+8P4XteaxT/jKF0PSb7XEvo5VmqEWgsMlyeY4ZlLK8I6aH6g5TPPyDlLAep+GYf4kefb7HFyc7PAO3m+Q==" + }, + "node_modules/nats": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/nats/-/nats-2.7.1.tgz", + "integrity": "sha512-aH0OXxasfLCTG+LQCFRaWoL1kqejCQg7B+t4z++JgLPgfdpQMET1Rqo95I06DEQyIJGTTgYpxkI/zC0ul8V3pw==", + "dependencies": { + "nkeys.js": "^1.0.0-9" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/nkeys.js": { + "version": "1.0.0-9", + "resolved": "https://registry.npmjs.org/nkeys.js/-/nkeys.js-1.0.0-9.tgz", + "integrity": "sha512-m9O0NQT+3rUe1om6MWpxV77EuHql/LdorDH+FYQkoeARcM2V0sQ89kM36fArWaHWq/25EmNmQUW0MhLTcbqW1A==", + "dependencies": { + "@types/node": "^14.0.26", + "tweetnacl": "^1.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/tweetnacl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", + "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==" + } + }, + "dependencies": { + "@types/node": { + "version": "14.18.21", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.21.tgz", + "integrity": "sha512-x5W9s+8P4XteaxT/jKF0PSb7XEvo5VmqEWgsMlyeY4ZlLK8I6aH6g5TPPyDlLAep+GYf4kefb7HFyc7PAO3m+Q==" + }, + "nats": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/nats/-/nats-2.7.1.tgz", + "integrity": "sha512-aH0OXxasfLCTG+LQCFRaWoL1kqejCQg7B+t4z++JgLPgfdpQMET1Rqo95I06DEQyIJGTTgYpxkI/zC0ul8V3pw==", + "requires": { + "nkeys.js": "^1.0.0-9" + } + }, + "nkeys.js": { + "version": "1.0.0-9", + "resolved": "https://registry.npmjs.org/nkeys.js/-/nkeys.js-1.0.0-9.tgz", + "integrity": "sha512-m9O0NQT+3rUe1om6MWpxV77EuHql/LdorDH+FYQkoeARcM2V0sQ89kM36fArWaHWq/25EmNmQUW0MhLTcbqW1A==", + "requires": { + "@types/node": "^14.0.26", + "tweetnacl": "^1.0.3" + } + }, + "tweetnacl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", + "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==" + } + } +} diff --git a/docker/node/package.json b/docker/node/package.json new file mode 100644 index 00000000..3bcc3390 --- /dev/null +++ b/docker/node/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "nats": "^2.7.1" + } +} diff --git a/docker/python/Dockerfile b/docker/python/Dockerfile new file mode 100644 index 00000000..be9d69ff --- /dev/null +++ b/docker/python/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.10 + +RUN useradd --create-home --user-group nats +USER nats + +WORKDIR /home/nats/code + +COPY requirements.txt ./ +RUN pip install -r requirements.txt +COPY . . + +CMD ["python", "/home/nats/code/main.py"] diff --git a/docker/python/requirements.txt b/docker/python/requirements.txt new file mode 100644 index 00000000..f48a6b12 --- /dev/null +++ b/docker/python/requirements.txt @@ -0,0 +1 @@ +nats-py[nkeys]==2.1.4 \ No newline at end of file diff --git a/docker/rust/Cargo.toml b/docker/rust/Cargo.toml new file mode 100644 index 00000000..374f3698 --- /dev/null +++ b/docker/rust/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "app" +version = "0.0.1" +edition = "2021" + +[[bin]] +name = "app" +path = "main.rs" + +[dependencies] +async-nats = "0.16.0" +tokio = { version = "1.20.0", features = ["full"] } +futures = "0.3.21" diff --git a/docker/rust/Dockerfile b/docker/rust/Dockerfile new file mode 100644 index 00000000..74232f3c --- /dev/null +++ b/docker/rust/Dockerfile @@ -0,0 +1,19 @@ +FROM rust:1.62-slim AS build + +RUN useradd --create-home --user-group nats +USER nats +RUN mkdir /home/nats/code + +WORKDIR /home/nats/code + +COPY . ./ +RUN cargo build + +FROM debian:bullseye-slim + +RUN useradd --create-home --user-group nats +USER nats + +COPY --from=build /home/nats/code/target/debug/app /home/nats/ + +CMD ["/home/nats/app"] diff --git a/examples/auth/meta.yaml b/examples/auth/meta.yaml new file mode 100644 index 00000000..5da259c5 --- /dev/null +++ b/examples/auth/meta.yaml @@ -0,0 +1,5 @@ +title: Authentication and Authorization +description: | + Topics related to [authentication](https://docs.nats.io/running-a-nats-service/configuration/securing_nats/auth_intro) and [authorization](https://docs.nats.io/running-a-nats-service/configuration/securing_nats/authorization). +examples: + - nkeys-jwts diff --git a/examples/auth/nkeys-jwts/go/go.mod b/examples/auth/nkeys-jwts/go/go.mod new file mode 100644 index 00000000..8812e080 --- /dev/null +++ b/examples/auth/nkeys-jwts/go/go.mod @@ -0,0 +1,10 @@ +module github.com/bruth/nats-by-example/examples/auth/nkeys-jwts/go + +go 1.18 + +require ( + github.com/nats-io/jwt/v2 v2.3.0 + github.com/nats-io/nkeys v0.3.0 +) + +require golang.org/x/crypto v0.0.0-20210314154223-e6e6c4f2bb5b // indirect diff --git a/examples/auth/nkeys-jwts/go/go.sum b/examples/auth/nkeys-jwts/go/go.sum new file mode 100644 index 00000000..2b1b8702 --- /dev/null +++ b/examples/auth/nkeys-jwts/go/go.sum @@ -0,0 +1,11 @@ +github.com/nats-io/jwt/v2 v2.3.0 h1:z2mA1a7tIf5ShggOFlR1oBPgd6hGqcDYsISxZByUzdI= +github.com/nats-io/jwt/v2 v2.3.0/go.mod h1:0tqz9Hlu6bCBFLWAASKhE5vUA4c24L9KPUUgvwumE/k= +github.com/nats-io/nkeys v0.3.0 h1:cgM5tL53EvYRU+2YLXIK0G2mJtK12Ft9oeooSZMA2G8= +github.com/nats-io/nkeys v0.3.0/go.mod h1:gvUNGjVcM2IPr5rCsRsC6Wb3Hr2CQAm08dsxtV6A5y4= +golang.org/x/crypto v0.0.0-20210314154223-e6e6c4f2bb5b h1:wSOdpTq0/eI46Ez/LkDwIsAKA71YP2SRKBODiRWM0as= +golang.org/x/crypto v0.0.0-20210314154223-e6e6c4f2bb5b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/examples/auth/nkeys-jwts/go/main.go b/examples/auth/nkeys-jwts/go/main.go new file mode 100644 index 00000000..5ca1a93b --- /dev/null +++ b/examples/auth/nkeys-jwts/go/main.go @@ -0,0 +1,93 @@ +package main + +import ( + "fmt" + "log" + + "github.com/nats-io/jwt/v2" + "github.com/nats-io/nkeys" +) + +func main() { + log.SetFlags(0) + + // Create a one-off operator keypair for the purpose of this example. + // In practice, the operator needs to be created ahead of time to configure + // the resolver in the server config if you are deploying your own NATS server. + // This most commonly done using the "nsc" tool: + // ``` + // nsc add operator --generate-signing-key --sys --name local + // nsc edit operator --require-signing-keys --account-jwt-server-url nats://127.0.0.1:4222 + // ``` + // Signing keys are technically optional, but a best practice. + operatorKP, _ := nkeys.CreateOperator() + + // We can distinguish operators, accounts, and users by the first character + // of their public key: O, A, or U. + operatorPub, _ := operatorKP.PublicKey() + fmt.Printf("operator pubkey: %s\n", operatorPub) + + // Seed values (private key), are prefixed with S. + operatorSeed, _ := operatorKP.Seed() + fmt.Printf("operator seed: %s\n\n", string(operatorSeed)) + + // To create accounts on demand, we start with creatinng a new keypair which + // has a unique ID. + accountKP, _ := nkeys.CreateAccount() + + accountPub, _ := accountKP.PublicKey() + fmt.Printf("account pubkey: %s\n", accountPub) + + accountSeed, _ := accountKP.Seed() + fmt.Printf("account seed: %s\n", string(accountSeed)) + + // Create a new set of account claims and configure as desired including a + // readable name, JetStream limits, imports/exports, etc. + accountClaims := jwt.NewAccountClaims(accountPub) + accountClaims.Name = "my-account" + + // The only requirement to "enable" JetStream is setting the disk and memory + // limits to anything other than zero. -1 indicates "unlimited". + accountClaims.Limits.JetStreamLimits.DiskStorage = -1 + accountClaims.Limits.JetStreamLimits.MemoryStorage = -1 + + // Inspecting the claims, you will notice the `sub` field is the public key + // of the account. + fmt.Printf("account claims: %s\n", accountClaims) + + // Now we can sign the claims with the operator and encode it to a JWT string. + // To activate this account, it must be pushed up to the server using a client + // connection authenticated as the SYS account user: + // ```go + // nc.Request("$SYS.REQ.CLAIMS.UPDATE", []byte(accountJWT)) + // ``` + // If you copy the JWT output to https://jwt.io, you will notice the `iss` + // field is set to the operator public key. + accountJWT, _ := accountClaims.Encode(operatorKP) + fmt.Printf("account jwt: %s\n\n", accountJWT) + + // It is important to call out that the nsc tool handles storage and management + // of operators, accounts, users. It writes out each nkey and JWT to a file and + // organizes everything for you. If you opt to create accounts or users dynamically, + // keep in mind you need to store and manage the keypairs and JWTs yourself. + + // If we want to create a user, the process is essentially the same as it was + // for the account. + userKP, _ := nkeys.CreateUser() + + userPub, _ := userKP.PublicKey() + fmt.Printf("user pubkey: %s\n", userPub) + + userSeed, _ := userKP.Seed() + fmt.Printf("user seed: %s\n", string(userSeed)) + + // Create the user claims, set the name, and configure permissions, expiry time, + // limits, etc. + userClaims := jwt.NewUserClaims(userPub) + userClaims.Name = "my-user" + fmt.Printf("userclaims: %s\n", userClaims) + + // Sign and encode the claims as a JWT. + userJWT, _ := userClaims.Encode(accountKP) + fmt.Printf("user jwt: %s\n", userJWT) +} diff --git a/examples/auth/nkeys-jwts/go/output.txt b/examples/auth/nkeys-jwts/go/output.txt new file mode 100644 index 00000000..a1505efb --- /dev/null +++ b/examples/auth/nkeys-jwts/go/output.txt @@ -0,0 +1,43 @@ +operator pubkey: OCA2JCRMICOX5SMKPIF2XNNCKXJXTLM4HLQQSL6ZXDM5IOEMYPOMOT4G +operator seed: SOAL34WEDUYU6BNHAREQC3BKXMY245FHQXGI3V2E2JLENVZSDAEQ2764PI + +account pubkey: ACL6WVGG5KGOMTXWZTDZTFQ4H43VPCJKAUHEYOEDTTT5G53DXSAKOXFW +account seed: SAAPVMCUUQQLHYC3GYWQ56MECJQOYUX5J55U6NZDMHRHCAG7JYPYVOD5RQ +account claims: { + "name": "my-account", + "sub": "ACL6WVGG5KGOMTXWZTDZTFQ4H43VPCJKAUHEYOEDTTT5G53DXSAKOXFW", + "nats": { + "limits": { + "subs": -1, + "data": -1, + "payload": -1, + "imports": -1, + "exports": -1, + "wildcards": true, + "conn": -1, + "leaf": -1, + "mem_storage": -1, + "disk_storage": -1 + }, + "default_permissions": { + "pub": {}, + "sub": {} + } + } +} +account jwt: eyJ0eXAiOiJKV1QiLCJhbGciOiJlZDI1NTE5LW5rZXkifQ.eyJqdGkiOiJHQlpSREc0U1VDNkIyUVVXREpVWFFBNVhRUFVXNkdGVU43TUlVT0xNQ0pSWTVKVkU1SlZRIiwiaWF0IjoxNjU3MTkyMjgzLCJpc3MiOiJPQ0EySkNSTUlDT1g1U01LUElGMlhOTkNLWEpYVExNNEhMUVFTTDZaWERNNUlPRU1ZUE9NT1Q0RyIsIm5hbWUiOiJteS1hY2NvdW50Iiwic3ViIjoiQUNMNldWR0c1S0dPTVRYV1pURFpURlE0SDQzVlBDSktBVUhFWU9FRFRUVDVHNTNEWFNBS09YRlciLCJuYXRzIjp7ImxpbWl0cyI6eyJzdWJzIjotMSwiZGF0YSI6LTEsInBheWxvYWQiOi0xLCJpbXBvcnRzIjotMSwiZXhwb3J0cyI6LTEsIndpbGRjYXJkcyI6dHJ1ZSwiY29ubiI6LTEsImxlYWYiOi0xLCJtZW1fc3RvcmFnZSI6LTEsImRpc2tfc3RvcmFnZSI6LTF9LCJkZWZhdWx0X3Blcm1pc3Npb25zIjp7InB1YiI6e30sInN1YiI6e319LCJ0eXBlIjoiYWNjb3VudCIsInZlcnNpb24iOjJ9fQ.bmuJaBIscU_U7GJeJO2jn4Fm9J42O_QDJVCpscX1hW6KPtBjtOVdzMDgawk_ANoW4cK19PLXMZ2cMdA1lCo3DQ + +user pubkey: UB4HT7TB6QZNFY2NKFPKP7WFWFASZ4FXPQOCETI5SCYOZKALGEBFRKRZ +user seed: SUAG3FQ36BMUHSMBUS334LE6AQPFA3DWLJ7UESKZEVOMSEE7C34OVHWNLQ +userclaims: { + "name": "my-user", + "sub": "UB4HT7TB6QZNFY2NKFPKP7WFWFASZ4FXPQOCETI5SCYOZKALGEBFRKRZ", + "nats": { + "pub": {}, + "sub": {}, + "subs": -1, + "data": -1, + "payload": -1 + } +} +user jwt: eyJ0eXAiOiJKV1QiLCJhbGciOiJlZDI1NTE5LW5rZXkifQ.eyJqdGkiOiJRRzdYR1NBN05GS1hZTFVUU0MzN0dGRU9BT1lIUTYzVU5TQlpZWlZFQzRSTFRFVEhSRU5BIiwiaWF0IjoxNjU3MTkyMjgzLCJpc3MiOiJBQ0w2V1ZHRzVLR09NVFhXWlREWlRGUTRINDNWUENKS0FVSEVZT0VEVFRUNUc1M0RYU0FLT1hGVyIsIm5hbWUiOiJteS11c2VyIiwic3ViIjoiVUI0SFQ3VEI2UVpORlkyTktGUEtQN1dGV0ZBU1o0RlhQUU9DRVRJNVNDWU9aS0FMR0VCRlJLUloiLCJuYXRzIjp7InB1YiI6e30sInN1YiI6e30sInN1YnMiOi0xLCJkYXRhIjotMSwicGF5bG9hZCI6LTEsInR5cGUiOiJ1c2VyIiwidmVyc2lvbiI6Mn19.Kc_9UzAPoA5nDVUvLv0R8oPOagmK-6IZECfwn1l-IW2kCBNVCq9j5XTBfk-voFuq9BuWvQxvdWxcR1etihVRDA diff --git a/examples/auth/nkeys-jwts/meta.yaml b/examples/auth/nkeys-jwts/meta.yaml new file mode 100644 index 00000000..56d58e5a --- /dev/null +++ b/examples/auth/nkeys-jwts/meta.yaml @@ -0,0 +1,15 @@ +title: Programmatic NKeys and JWTs +description: |- + The primary (and recommended) way to create and manage accounts and users + is using the [nsc](https://nats-io.github.io/nsc/) command-line tool. However, + in some applications and use cases, it may be desirable to programmatically + create accounts or users on-demand as part of an application-level account/user + workflow rather than out-of-band on the command line (however, shelling out + to `nsc` from your program is another option). + + This example shows how to programmatically generate NKeys and JWTs. + This can be used as an alternative or, more likely, in conjunction with the + nsc tool for creating and managing accounts and users. + + *Note, not all languages currently implement standalone NKeys or JWT libraries.* + diff --git a/examples/messaging/meta.yaml b/examples/messaging/meta.yaml new file mode 100644 index 00000000..fb6d6763 --- /dev/null +++ b/examples/messaging/meta.yaml @@ -0,0 +1,3 @@ +examples: + - pub-sub + - request-reply diff --git a/examples/messaging/pub-sub/README.md b/examples/messaging/pub-sub/README.md new file mode 100644 index 00000000..a60b9303 --- /dev/null +++ b/examples/messaging/pub-sub/README.md @@ -0,0 +1,12 @@ +# Publish-Subscribe + +- [Go](./go) + +## Resources + +- [Official publish-subscribe docs][docs] +- [PUB][pub] and [SUB][sub] protocol operations + +[docs]: https://docs.nats.io/nats-concepts/core-nats/pubsub +[pub]: https://docs.nats.io/reference/reference-protocols/nats-protocol#pub +[sub]: https://docs.nats.io/reference/reference-protocols/nats-protocol#sub diff --git a/examples/messaging/pub-sub/cli/main.sh b/examples/messaging/pub-sub/cli/main.sh new file mode 100644 index 00000000..7462577b --- /dev/null +++ b/examples/messaging/pub-sub/cli/main.sh @@ -0,0 +1,35 @@ +#!/bin/sh + +# The `nats` CLI utilizes the `NATS_URL` environment variable if set. +# However, if you want to manage different _contexts_ for connecting +# or authenticating, check out the `nats context` commands. +# For example: +# ``` +# nats context save --server=$NATS_URL local +# ``` +NATS_URL="${NATS_URL:-nats://localhost:4222}" + +# Publish a message to the subject 'greet.joe'. Nothing will happen +# since the subscription is not yet setup. +nats pub 'greet.joe' 'hello' + +# Let's start a subscription in the background that will print +# the output to stdout. +nats sub 'greet.*' & + +# This just captures the process ID of the previous command in this shell. +SUB_PID=$! + +# Tiny sleep to ensure the subscription connected. +sleep 0.5 + +# Now we can publish a couple times.. +nats pub 'greet.joe' 'hello' +nats pub 'greet.pam' 'hello' +nats pub 'greet.bob' 'hello' + +# Remove the subscription. +kill $SUB_PID + +# Publishing again will not result in anything. +nats pub 'greet.bob' 'hello' diff --git a/examples/messaging/pub-sub/cli/output.txt b/examples/messaging/pub-sub/cli/output.txt new file mode 100644 index 00000000..0c783e97 --- /dev/null +++ b/examples/messaging/pub-sub/cli/output.txt @@ -0,0 +1,28 @@ +Sending build context to Docker daemon 3.119kB +Step 1/5 : FROM natsio/nats-box:0.12.0 + ---> 829a5d8662d0 +Step 2/5 : WORKDIR /opt/app + ---> Running in 628bcfa08862 +Removing intermediate container 628bcfa08862 + ---> bffb099371d5 +Step 3/5 : COPY . ./ + ---> 8a12e6e12b56 +Step 4/5 : RUN chmod +x main.sh + ---> Running in 1153b9e7c03e +Removing intermediate container 1153b9e7c03e + ---> c20294a6a3cd +Step 5/5 : ENTRYPOINT ["/opt/app/main.sh"] + ---> Running in e19618a315db +Removing intermediate container e19618a315db + ---> 2ff5b2b69c03 +Successfully built 2ff5b2b69c03 +Successfully tagged nbe/examples/messaging/pub-sub/cli:33d3e787 +[#1] Received on "greet.joe" +hello + +[#2] Received on "greet.pam" +hello + +[#3] Received on "greet.bob" +hello + diff --git a/examples/messaging/pub-sub/deno/main.ts b/examples/messaging/pub-sub/deno/main.ts new file mode 100644 index 00000000..7c640e92 --- /dev/null +++ b/examples/messaging/pub-sub/deno/main.ts @@ -0,0 +1,48 @@ +import {connect, StringCodec} from "https://deno.land/x/nats@v1.7.1/src/mod.ts"; + +// Get the passed NATS_URL or fallback to the default. This can be +// a comma-separated string. +const servers = Deno.env.get("NATS_URL") || "nats://localhost:4222"; + +// Create a client connection to an available NATS server. +const nc = await connect({ + servers: servers.split(","), +}); + +// NATS message payloads are byte arrays, so we need to have a codec +// to serialize and deserialize payloads in order to work with them. +// Another built-in codec is JSONCodec or you can implement your own. +const sc = StringCodec(); + +// To publish a message, simply provide the _subject_ of the message +// and encode the message payload. NATS subjects are hierarchical using +// periods as token delimiters. `greet` and `joe` are two distinct tokens. +nc.publish("greet.bob", sc.encode("hello")); + +// Now we are going to create a subscription and utilize a wildcard on +// the second token. The effect is that this subscription shows _interest_ +// in all messages published to a subject with two tokens where the first +// is `greet`. +let sub = nc.subscribe("greet.*", {max: 3}); +const done = (async () => { + for await (const msg of sub) { + console.log(`${sc.decode(msg.data)} on subject ${msg.subject}`); + } +})() + +// Let's publish three more messages which will result in the messages +// being forwarded to the local subscription we have. +nc.publish("greet.joe", sc.encode("hello")); +nc.publish("greet.pam", sc.encode("hello")); +nc.publish("greet.sue", sc.encode("hello")); + +// This will wait until the above async subscription handler finishes +// processing the three messages. Note that the first message to +// `greet.bob` was not printed. This is because the subscription was +// created _after_ the publish. Core NATS provides at-most-once quality +// of service (QoS) for active subscriptions. +await done; + +// Finally we drain the connection which waits for any pending +// messages (published or in a subscription) to be flushed. +await nc.drain(); diff --git a/examples/messaging/pub-sub/go/main.go b/examples/messaging/pub-sub/go/main.go new file mode 100644 index 00000000..7ac7b4a7 --- /dev/null +++ b/examples/messaging/pub-sub/go/main.go @@ -0,0 +1,58 @@ +package main + +import ( + "fmt" + "os" + "time" + + "github.com/nats-io/nats.go" +) + +func main() { + // Use the env varibale if running in the container, otherwise use the default. + url := os.Getenv("NATS_URL") + if url == "" { + url = nats.DefaultURL + } + + // Create an unauthenticated connection to NATS. + nc, _ := nats.Connect(url) + + // Drain is a safe way to to ensure all buffered messages that were published + // are sent and all buffered messages received on a subscription are processed + // being closing the connection. + defer nc.Drain() + + // Messages are published to subjects. Although there are no subscribers, + // this will be published successfully. + nc.Publish("greet.joe", []byte("hello")) + + // Let's create a subscription on the greet.* wildcard. + sub, _ := nc.SubscribeSync("greet.*") + + // For a synchronous subscription, we need to fetch the next message. + // However.. since the publish occured before the subscription was + // established, this is going to timeout. + msg, _ := sub.NextMsg(10 * time.Millisecond) + fmt.Println("subscribed after a publish...") + fmt.Printf("msg is nil? %v\n", msg == nil) + + // Publish a couple messages. + nc.Publish("greet.joe", []byte("hello")) + nc.Publish("greet.pam", []byte("hello")) + + // Since the subscription is established, the published messages will + // immediately be broadcasted to all subscriptions. They will land in + // their buffer for subsequent NextMsg calls. + msg, _ = sub.NextMsg(10 * time.Millisecond) + fmt.Printf("msg data: %q on subject %q\n", string(msg.Data), msg.Subject) + + msg, _ = sub.NextMsg(10 * time.Millisecond) + fmt.Printf("msg data: %q on subject %q\n", string(msg.Data), msg.Subject) + + // One more for good measures.. + nc.Publish("greet.bob", []byte("hello")) + + msg, _ = sub.NextMsg(10 * time.Millisecond) + fmt.Printf("msg data: %q on subject %q\n", string(msg.Data), msg.Subject) +} diff --git a/examples/messaging/pub-sub/go/output.txt b/examples/messaging/pub-sub/go/output.txt new file mode 100644 index 00000000..0618afe9 --- /dev/null +++ b/examples/messaging/pub-sub/go/output.txt @@ -0,0 +1,5 @@ +subscribed after a publish... +msg is nil? true +msg data: "hello" on subject "greet.joe" +msg data: "hello" on subject "greet.pam" +msg data: "hello" on subject "greet.bob" diff --git a/examples/messaging/pub-sub/meta.yaml b/examples/messaging/pub-sub/meta.yaml new file mode 100644 index 00000000..f63d296e --- /dev/null +++ b/examples/messaging/pub-sub/meta.yaml @@ -0,0 +1,16 @@ +title: Core Publish-Subcribe +description: |- + This example demonstrates the core NATS [publish-subscribe][pubsub] + behavior. This is the fundamental pattern that all other NATS + patterns and higher-level APIs build upon. There are a few takeaways from this example: + + - Delivery is an _at-most-once_. For [MQTT][mqtt] users, this is referred to as Quality of Service (QoS) 0. + - There are two circumstances when a published message _won't_ be delivered to a subscriber: + - The subscriber does not have an active connection to the server (i.e. the client is temporarily offline for some reason) + - There is a network interruption where the message is ultimately dropped + - Messages are published to [subjects][subjects] which can be one or more concrete tokens, e.g. `greet.bob`. Subscribers can utilize [wildcards][wildcards] to show interest on a set of matching subjects. + + [pubsub]: https://docs.nats.io/nats-concepts/core-nats/pubsub + [mqtt]: https://docs.nats.io/running-a-nats-service/configuration/mqtt + [subjects]: https://docs.nats.io/nats-concepts/subjects + [wildcards]: https://docs.nats.io/nats-concepts/subjects#wildcards diff --git a/examples/messaging/pub-sub/node/main.js b/examples/messaging/pub-sub/node/main.js new file mode 100644 index 00000000..d1f511db --- /dev/null +++ b/examples/messaging/pub-sub/node/main.js @@ -0,0 +1,48 @@ +import {connect, StringCodec} from "nats"; + +// Get the passed NATS_URL or fallback to the default. This can be +// a comma-separated string. +const servers = process.env.NATS_URL || "nats://localhost:4222"; + +// Create a client connection to an available NATS server. +const nc = await connect({ + servers: servers.split(","), +}); + +// NATS message payloads are byte arrays, so we need to have a codec +// to serialize and deserialize payloads in order to work with them. +// Another built-in codec is JSONCodec or you can implement your own. +const sc = StringCodec(); + +// To publish a message, simply provide the _subject_ of the message +// and encode the message payload. NATS subjects are hierarchical using +// periods as token delimiters. `greet` and `joe` are two distinct tokens. +nc.publish("greet.bob", sc.encode("hello")); + +// Now we are going to create a subscription and utilize a wildcard on +// the second token. The effect is that this subscription shows _interest_ +// in all messages published to a subject with two tokens where the first +// is `greet`. +let sub = nc.subscribe("greet.*", {max: 3}); +const done = (async () => { + for await (const msg of sub) { + console.log(`${sc.decode(msg.data)} on subject ${msg.subject}`); + } +})() + +// Let's publish three more messages which will result in the messages +// being forwarded to the local subscription we have. +nc.publish("greet.joe", sc.encode("hello")); +nc.publish("greet.pam", sc.encode("hello")); +nc.publish("greet.sue", sc.encode("hello")); + +// This will wait until the above async subscription handler finishes +// processing the three messages. Note that the first message to +// `greet.bob` was not printed. This is because the subscription was +// created _after_ the publish. Core NATS provides at-most-once quality +// of service (QoS) for active subscriptions. +await done; + +// Finally we drain the connection which waits for any pending +// messages (published or in a subscription) to be flushed. +await nc.drain(); diff --git a/examples/messaging/pub-sub/python/main.py b/examples/messaging/pub-sub/python/main.py new file mode 100644 index 00000000..70520242 --- /dev/null +++ b/examples/messaging/pub-sub/python/main.py @@ -0,0 +1,55 @@ +import os +import asyncio + +import nats +from nats.errors import TimeoutError + +# Get the list of servers. +servers = os.environ.get("NATS_URL", "nats://localhost:4222").split(",") + +async def main(): + # Create the connection to NATS which takes a list of servers. + nc = await nats.connect(servers=servers) + + # Messages are published to subjects. Although there are no subscribers, + # this will be published successfully. + await nc.publish("greet.joe", b"hello") + + # Let's create a subscription on the greet.* wildcard. + sub = await nc.subscribe("greet.*") + + # For a synchronous subscription, we need to fetch the next message. + # However.. since the publish occured before the subscription was + # established, this is going to timeout. + try: + msg = await sub.next_msg(timeout=0.1) + except TimeoutError: + pass + + # Publish a couple messages. + await nc.publish("greet.joe", b"hello") + await nc.publish("greet.pam", b"hello") + + # Since the subscription is established, the published messages will + # immediately be broadcasted to all subscriptions. They will land in + # their buffer for subsequent NextMsg calls. + msg = await sub.next_msg(timeout=0.1) + print(f"{msg.data} on subject {msg.subject}") + + msg = await sub.next_msg(timeout=0.1) + print(f"{msg.data} on subject {msg.subject}") + + # One more for good measures.. + await nc.publish("greet.bob", b"hello") + + msg = await sub.next_msg(timeout=0.1) + print(f"{msg.data} on subject {msg.subject}") + + # Drain the subscription and connection. In contrast to `unsubscribe`, + # drain will process any queued messages before removing interest. + await sub.unsubscribe() + await nc.drain() + + +if __name__ == '__main__': + asyncio.run(main()) diff --git a/examples/messaging/pub-sub/rust/main.rs b/examples/messaging/pub-sub/rust/main.rs new file mode 100644 index 00000000..02a74d4a --- /dev/null +++ b/examples/messaging/pub-sub/rust/main.rs @@ -0,0 +1,43 @@ +use futures::StreamExt; +use std::{env, str::from_utf8}; + +#[tokio::main] +async fn main() -> Result<(), async_nats::Error> { + // Use the NATS_URL env variable if defined, otherwise fallback + // to the default. + let nats_url = env::var("NATS_URL").unwrap_or_else(|_| "nats://localhost:4222".to_string()); + + let client = async_nats::connect(nats_url).await?; + + // Publish a message to the subject `greet.joe`. + client + .publish("greet.joe".to_string(), "hello".into()) + .await?; + + // `Subscriber` implements Rust iterator, so we can leverage + // combinators like `take()` to limit the messages intended + // to be consumed for this interaction. + let mut subscription = client + .subscribe("greet.*".to_string()) + .await? + .take(3); + + // Publish to three different subjects matching the wildcard. + for subject in ["greet.sue", "greet.bob", "greet.pam"] { + client + .publish(subject.to_string(), "hello".into()) + .await?; + } + + // Notice that the first message received is `greet.sue` and not + // `greet.joe` which was the first message published. This is because + // core NATS provides at-most-once quality of service (QoS). Subscribers + // must be connected showing *interest* in a subject for the server to + // relay the message to the client. + while let Some(message) = subscription.next().await { + println!("{:?} received on {:?}", from_utf8(&message.payload), &message.subject); + } + + Ok(()) +} + diff --git a/examples/messaging/request-reply/go/go.mod b/examples/messaging/request-reply/go/go.mod new file mode 100644 index 00000000..6e07d6ca --- /dev/null +++ b/examples/messaging/request-reply/go/go.mod @@ -0,0 +1,14 @@ +module github.com/bruth/nats-by-example/examples/messaging/publish-subscribe/go + +go 1.18 + +require github.com/nats-io/nats.go v1.16.0 + +require ( + github.com/golang/protobuf v1.5.2 // indirect + github.com/nats-io/nats-server/v2 v2.8.4 // indirect + github.com/nats-io/nkeys v0.3.0 // indirect + github.com/nats-io/nuid v1.0.1 // indirect + golang.org/x/crypto v0.0.0-20220315160706-3147a52a75dd // indirect + google.golang.org/protobuf v1.28.0 // indirect +) diff --git a/examples/messaging/request-reply/go/go.sum b/examples/messaging/request-reply/go/go.sum new file mode 100644 index 00000000..40c4c9b6 --- /dev/null +++ b/examples/messaging/request-reply/go/go.sum @@ -0,0 +1,30 @@ +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/klauspost/compress v1.14.4 h1:eijASRJcobkVtSt81Olfh7JX43osYLwy5krOJo6YEu4= +github.com/minio/highwayhash v1.0.2 h1:Aak5U0nElisjDCfPSG79Tgzkn2gl66NxOMspRrKnA/g= +github.com/nats-io/jwt/v2 v2.2.1-0.20220330180145-442af02fd36a h1:lem6QCvxR0Y28gth9P+wV2K/zYUUAkJ+55U8cpS0p5I= +github.com/nats-io/nats-server/v2 v2.8.4 h1:0jQzze1T9mECg8YZEl8+WYUXb9JKluJfCBriPUtluB4= +github.com/nats-io/nats-server/v2 v2.8.4/go.mod h1:8zZa+Al3WsESfmgSs98Fi06dRWLH5Bnq90m5bKD/eT4= +github.com/nats-io/nats.go v1.16.0 h1:zvLE7fGBQYW6MWaFaRdsgm9qT39PJDQoju+DS8KsO1g= +github.com/nats-io/nats.go v1.16.0/go.mod h1:BPko4oXsySz4aSWeFgOHLZs3G4Jq4ZAyE6/zMCxRT6w= +github.com/nats-io/nkeys v0.3.0 h1:cgM5tL53EvYRU+2YLXIK0G2mJtK12Ft9oeooSZMA2G8= +github.com/nats-io/nkeys v0.3.0/go.mod h1:gvUNGjVcM2IPr5rCsRsC6Wb3Hr2CQAm08dsxtV6A5y4= +github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +golang.org/x/crypto v0.0.0-20210314154223-e6e6c4f2bb5b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= +golang.org/x/crypto v0.0.0-20220315160706-3147a52a75dd h1:XcWmESyNjXJMLahc3mqVQJcgSTDxFxhETVlfk9uGc38= +golang.org/x/crypto v0.0.0-20220315160706-3147a52a75dd/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220111092808-5a964db01320 h1:0jf+tOCoZ3LyutmCOWpVni1chK4VfFLhRsDK7MhqGRY= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/time v0.0.0-20211116232009-f0f3c7e86c11 h1:GZokNIeuVkl3aZHJchRrr13WCsols02MLUcz1U9is6M= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.28.0 h1:w43yiav+6bVFTBQFZX0r7ipe9JQ1QsbMgHwbBziscLw= +google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= diff --git a/examples/messaging/request-reply/go/main.go b/examples/messaging/request-reply/go/main.go new file mode 100644 index 00000000..50ecb7fa --- /dev/null +++ b/examples/messaging/request-reply/go/main.go @@ -0,0 +1,56 @@ +package main + +import ( + "fmt" + "os" + "time" + + "github.com/nats-io/nats.go" +) + +func main() { + // Use the env varibale if running in the container, otherwise use the default. + url := os.Getenv("NATS_URL") + if url == "" { + url = nats.DefaultURL + } + + // Create an unauthenticated connection to NATS. + nc, _ := nats.Connect(url) + defer nc.Drain() + + // In addition to vanilla publish-request, NATS supports request-reply + // interactions as well. Under the covers, this is just an optimized + // pair of publish-subscribe operations. + // The _request handler_ is just a subscription that _responds_ to a message + // sent to it. This kind of subscription is called a _service_. + // For this example, we can use the built-in asynchronous + // subscription in the Go SDK. + sub, _ := nc.Subscribe("greet.*", func(msg *nats.Msg) { + // Parse out the second token in the subject (everything after greet.) + // and use it as part of the response message. + name := msg.Subject[6:] + msg.Respond([]byte("hello, " + name)) + }) + + // Now we can use the built-in `Request` method to do the service request. + // We simply pass a nil body since that is being used right now. In addition, + // we need to specify a timeout since with a request we are _waiting_ for the + // reply and we likely don't want to wait forever. + rep, _ := nc.Request("greet.joe", nil, time.Second) + fmt.Println(string(rep.Data)) + + rep, _ = nc.Request("greet.sue", nil, time.Second) + fmt.Println(string(rep.Data)) + + rep, _ = nc.Request("greet.bob", nil, time.Second) + fmt.Println(string(rep.Data)) + + // What happens if the service is _unavailable_? We can simulate this by + // unsubscribing our handler from above. Now if we make a request, we will + // expect an error. + sub.Unsubscribe() + + _, err := nc.Request("greet.joe", nil, time.Second) + fmt.Println(err) +} diff --git a/examples/messaging/request-reply/go/output.txt b/examples/messaging/request-reply/go/output.txt new file mode 100644 index 00000000..b0213d52 --- /dev/null +++ b/examples/messaging/request-reply/go/output.txt @@ -0,0 +1,4 @@ +hello, joe +hello, sue +hello, bob +nats: no responders available for request diff --git a/examples/messaging/request-reply/python/main.py b/examples/messaging/request-reply/python/main.py new file mode 100644 index 00000000..740df919 --- /dev/null +++ b/examples/messaging/request-reply/python/main.py @@ -0,0 +1,56 @@ +import os +import asyncio + +import nats +from nats.errors import TimeoutError, NoRespondersError + +# Get the list of servers. +servers = os.environ.get("NATS_URL", "nats://localhost:4222").split(",") + +async def main(): + # Create the connection to NATS which takes a list of servers. + nc = await nats.connect(servers=servers) + + # In addition to vanilla publish-request, NATS supports request-reply + # interactions as well. Under the covers, this is just an optimized + # pair of publish-subscribe operations. + # The _request handler_ is a subscription that _responds_ to a message + # sent to it. This kind of subscription is called a _service_. + # We can use the `cb` argument for asynchronous handling. + async def greet_handler(msg): + # Parse out the second token in the subject (everything after + # `greet.`) and use it as part of the response message. + name = msg.subject[6:] + reply = f"hello, {name}" + await msg.respond(reply.encode("utf8")) + + sub = await nc.subscribe("greet.*", cb=greet_handler) + + # Now we can use the built-in `request` method to do the service request. + # We simply pass a empty body since that is being used right now. + # In addition, we need to specify a timeout since with a request we + # are _waiting_ for the reply and we likely don't want to wait forever. + rep = await nc.request("greet.joe", b'', timeout=0.5) + print(f"{rep.data}") + + rep = await nc.request("greet.sue", b'', timeout=0.5) + print(f"{rep.data}") + + rep = await nc.request("greet.bob", b'', timeout=0.5) + print(f"{rep.data}") + + # What happens if the service is _unavailable_? We can simulate this by + # unsubscribing our handler from above. Now if we make a request, we will + # expect an error. + await sub.drain() + + try: + await nc.request("greet.joe", b'', timeout=0.5) + except NoRespondersError: + print("no responders") + + await nc.drain() + + +if __name__ == '__main__': + asyncio.run(main()) diff --git a/examples/meta.yaml b/examples/meta.yaml new file mode 100644 index 00000000..9fbdbf37 --- /dev/null +++ b/examples/meta.yaml @@ -0,0 +1,5 @@ +categories: + - messaging + - connecting + - auth + - operations diff --git a/examples/operations/replace-cluster-nodes/.gitignore b/examples/operations/replace-cluster-nodes/.gitignore new file mode 100644 index 00000000..90e1c924 --- /dev/null +++ b/examples/operations/replace-cluster-nodes/.gitignore @@ -0,0 +1,3 @@ +data +logs +pids diff --git a/operations/replace-cluster-nodes/README.md b/examples/operations/replace-cluster-nodes/shell/README.md similarity index 100% rename from operations/replace-cluster-nodes/README.md rename to examples/operations/replace-cluster-nodes/shell/README.md diff --git a/operations/replace-cluster-nodes/configs/n0.conf b/examples/operations/replace-cluster-nodes/shell/configs/n0.conf similarity index 100% rename from operations/replace-cluster-nodes/configs/n0.conf rename to examples/operations/replace-cluster-nodes/shell/configs/n0.conf diff --git a/operations/replace-cluster-nodes/configs/n1.conf b/examples/operations/replace-cluster-nodes/shell/configs/n1.conf similarity index 100% rename from operations/replace-cluster-nodes/configs/n1.conf rename to examples/operations/replace-cluster-nodes/shell/configs/n1.conf diff --git a/operations/replace-cluster-nodes/configs/n2.conf b/examples/operations/replace-cluster-nodes/shell/configs/n2.conf similarity index 100% rename from operations/replace-cluster-nodes/configs/n2.conf rename to examples/operations/replace-cluster-nodes/shell/configs/n2.conf diff --git a/operations/replace-cluster-nodes/configs/n3.conf b/examples/operations/replace-cluster-nodes/shell/configs/n3.conf similarity index 100% rename from operations/replace-cluster-nodes/configs/n3.conf rename to examples/operations/replace-cluster-nodes/shell/configs/n3.conf diff --git a/operations/replace-cluster-nodes/configs/n4.conf b/examples/operations/replace-cluster-nodes/shell/configs/n4.conf similarity index 100% rename from operations/replace-cluster-nodes/configs/n4.conf rename to examples/operations/replace-cluster-nodes/shell/configs/n4.conf diff --git a/operations/replace-cluster-nodes/configs/shared.conf b/examples/operations/replace-cluster-nodes/shell/configs/shared.conf similarity index 100% rename from operations/replace-cluster-nodes/configs/shared.conf rename to examples/operations/replace-cluster-nodes/shell/configs/shared.conf diff --git a/operations/replace-cluster-nodes/scripts/add-node.sh b/examples/operations/replace-cluster-nodes/shell/scripts/add-node.sh similarity index 100% rename from operations/replace-cluster-nodes/scripts/add-node.sh rename to examples/operations/replace-cluster-nodes/shell/scripts/add-node.sh diff --git a/operations/replace-cluster-nodes/scripts/create-consumers.sh b/examples/operations/replace-cluster-nodes/shell/scripts/create-consumers.sh similarity index 100% rename from operations/replace-cluster-nodes/scripts/create-consumers.sh rename to examples/operations/replace-cluster-nodes/shell/scripts/create-consumers.sh diff --git a/operations/replace-cluster-nodes/scripts/create-streams.sh b/examples/operations/replace-cluster-nodes/shell/scripts/create-streams.sh similarity index 100% rename from operations/replace-cluster-nodes/scripts/create-streams.sh rename to examples/operations/replace-cluster-nodes/shell/scripts/create-streams.sh diff --git a/operations/replace-cluster-nodes/scripts/migrate-cluster.sh b/examples/operations/replace-cluster-nodes/shell/scripts/migrate-cluster.sh similarity index 100% rename from operations/replace-cluster-nodes/scripts/migrate-cluster.sh rename to examples/operations/replace-cluster-nodes/shell/scripts/migrate-cluster.sh diff --git a/operations/replace-cluster-nodes/scripts/remove-peer.sh b/examples/operations/replace-cluster-nodes/shell/scripts/remove-peer.sh similarity index 100% rename from operations/replace-cluster-nodes/scripts/remove-peer.sh rename to examples/operations/replace-cluster-nodes/shell/scripts/remove-peer.sh diff --git a/operations/replace-cluster-nodes/scripts/signal-lameduck.sh b/examples/operations/replace-cluster-nodes/shell/scripts/signal-lameduck.sh similarity index 100% rename from operations/replace-cluster-nodes/scripts/signal-lameduck.sh rename to examples/operations/replace-cluster-nodes/shell/scripts/signal-lameduck.sh diff --git a/operations/replace-cluster-nodes/scripts/start-bar-publisher.sh b/examples/operations/replace-cluster-nodes/shell/scripts/start-bar-publisher.sh similarity index 100% rename from operations/replace-cluster-nodes/scripts/start-bar-publisher.sh rename to examples/operations/replace-cluster-nodes/shell/scripts/start-bar-publisher.sh diff --git a/operations/replace-cluster-nodes/scripts/start-bar-subscriber.sh b/examples/operations/replace-cluster-nodes/shell/scripts/start-bar-subscriber.sh similarity index 100% rename from operations/replace-cluster-nodes/scripts/start-bar-subscriber.sh rename to examples/operations/replace-cluster-nodes/shell/scripts/start-bar-subscriber.sh diff --git a/operations/replace-cluster-nodes/scripts/start-cluster.sh b/examples/operations/replace-cluster-nodes/shell/scripts/start-cluster.sh similarity index 100% rename from operations/replace-cluster-nodes/scripts/start-cluster.sh rename to examples/operations/replace-cluster-nodes/shell/scripts/start-cluster.sh diff --git a/operations/replace-cluster-nodes/scripts/start-foo-publisher.sh b/examples/operations/replace-cluster-nodes/shell/scripts/start-foo-publisher.sh similarity index 100% rename from operations/replace-cluster-nodes/scripts/start-foo-publisher.sh rename to examples/operations/replace-cluster-nodes/shell/scripts/start-foo-publisher.sh diff --git a/operations/replace-cluster-nodes/scripts/start-foo-subscriber.sh b/examples/operations/replace-cluster-nodes/shell/scripts/start-foo-subscriber.sh similarity index 100% rename from operations/replace-cluster-nodes/scripts/start-foo-subscriber.sh rename to examples/operations/replace-cluster-nodes/shell/scripts/start-foo-subscriber.sh diff --git a/operations/replace-cluster-nodes/scripts/stop-cluster.sh b/examples/operations/replace-cluster-nodes/shell/scripts/stop-cluster.sh similarity index 100% rename from operations/replace-cluster-nodes/scripts/stop-cluster.sh rename to examples/operations/replace-cluster-nodes/shell/scripts/stop-cluster.sh diff --git a/operations/replace-cluster-nodes/scripts/watch-bar-consumer.sh b/examples/operations/replace-cluster-nodes/shell/scripts/watch-bar-consumer.sh similarity index 100% rename from operations/replace-cluster-nodes/scripts/watch-bar-consumer.sh rename to examples/operations/replace-cluster-nodes/shell/scripts/watch-bar-consumer.sh diff --git a/operations/replace-cluster-nodes/scripts/watch-foo-consumer.sh b/examples/operations/replace-cluster-nodes/shell/scripts/watch-foo-consumer.sh similarity index 100% rename from operations/replace-cluster-nodes/scripts/watch-foo-consumer.sh rename to examples/operations/replace-cluster-nodes/shell/scripts/watch-foo-consumer.sh diff --git a/operations/replace-cluster-nodes/scripts/watch-servers.sh b/examples/operations/replace-cluster-nodes/shell/scripts/watch-servers.sh similarity index 100% rename from operations/replace-cluster-nodes/scripts/watch-servers.sh rename to examples/operations/replace-cluster-nodes/shell/scripts/watch-servers.sh diff --git a/operations/replace-cluster-nodes/scripts/watch-streams.sh b/examples/operations/replace-cluster-nodes/shell/scripts/watch-streams.sh similarity index 100% rename from operations/replace-cluster-nodes/scripts/watch-streams.sh rename to examples/operations/replace-cluster-nodes/shell/scripts/watch-streams.sh diff --git a/go.work b/go.work new file mode 100644 index 00000000..9c5eb5c6 --- /dev/null +++ b/go.work @@ -0,0 +1,3 @@ +go 1.18 + +use ./cmd/nbe diff --git a/html/Cookie-Regular.ttf b/html/Cookie-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..e3ac74eb25ee3427d9929ea21a0d9415f3524edb GIT binary patch literal 42132 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 literal 0 HcmV?d00001 diff --git a/html/clipboard.svg b/html/clipboard.svg new file mode 100644 index 00000000..3028cd6d --- /dev/null +++ b/html/clipboard.svg @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/html/examples/auth/index.html b/html/examples/auth/index.html new file mode 100644 index 00000000..cad8a12c --- /dev/null +++ b/html/examples/auth/index.html @@ -0,0 +1,42 @@ + + + + NATS by Example + + + + + + + + + + + + +

+ + NATS Logo by Example + +

+ + +

Authentication and Authorization

+ +
+

Topics related to authentication and authorization.

+ +
+ + + + diff --git a/html/examples/auth/nkeys-jwts/go/index.html b/html/examples/auth/nkeys-jwts/go/index.html new file mode 100644 index 00000000..847f5bc1 --- /dev/null +++ b/html/examples/auth/nkeys-jwts/go/index.html @@ -0,0 +1,372 @@ + + + + NATS by Example + + + + + + + + + + + + +
+ +
+ +
+
+

Programmatic NKeys and JWTs in Authentication and Authorization

+ +
+

The primary (and recommended) way to create and manage accounts and users +is using the nsc command-line tool. However, +in some applications and use cases, it may be desirable to programmatically +create accounts or users on-demand as part of an application-level account/user +workflow rather than out-of-band on the command line (however, shelling out +to nsc from your program is another option).

+ +

This example shows how to programmatically generate NKeys and JWTs. +This can be used as an alternative or, more likely, in conjunction with the +nsc tool for creating and managing accounts and users.

+ +

Note, not all languages currently implement standalone NKeys or JWT libraries.

+ +
+ + + +
+
+ + + CLI + + + + Go + + + + Python + + + + Deno + + + + Node + + + + Rust + + + + C# + + + + Java + + + + Ruby + + + + Elixir + + + + C + + +
+ +
+ +
$ nbe run auth/nkeys-jwts/go
+ Learn how to run this example. +
+
+ +
+ + +
+ +
+ + + +
+
package main
+
+
+import (
+	"fmt"
+	"log"
+
+
+	"github.com/nats-io/jwt/v2"
+	"github.com/nats-io/nkeys"
+)
+
+
+func main() {
+	log.SetFlags(0)
+
+
+
+ + + +
+

Create a one-off operator keypair for the purpose of this example. +In practice, the operator needs to be created ahead of time to configure +the resolver in the server config if you are deploying your own NATS server. +This most commonly done using the “nsc” tool:

+ +
nsc add operator --generate-signing-key --sys --name local
+nsc edit operator --require-signing-keys --account-jwt-server-url nats://127.0.0.1:4222
+
+ +

Signing keys are technically optional, but a best practice.

+ +
+ + + +
+
	operatorKP, _ := nkeys.CreateOperator()
+
+
+
+ + + +
+

We can distinguish operators, accounts, and users by the first character +of their public key: O, A, or U.

+ +
+ + + +
+
	operatorPub, _ := operatorKP.PublicKey()
+	fmt.Printf("operator pubkey: %s\n", operatorPub)
+
+
+
+ + + +
+

Seed values (private key), are prefixed with S.

+ +
+ + + +
+
	operatorSeed, _ := operatorKP.Seed()
+	fmt.Printf("operator seed: %s\n\n", string(operatorSeed))
+
+
+
+ + + +
+

To create accounts on demand, we start with creatinng a new keypair which +has a unique ID.

+ +
+ + + +
+
	accountKP, _ := nkeys.CreateAccount()
+
+
+	accountPub, _ := accountKP.PublicKey()
+	fmt.Printf("account pubkey: %s\n", accountPub)
+
+
+	accountSeed, _ := accountKP.Seed()
+	fmt.Printf("account seed: %s\n", string(accountSeed))
+
+
+
+ + + +
+

Create a new set of account claims and configure as desired including a +readable name, JetStream limits, imports/exports, etc.

+ +
+ + + +
+
	accountClaims := jwt.NewAccountClaims(accountPub)
+	accountClaims.Name = "my-account"
+
+
+
+ + + +
+

The only requirement to “enable” JetStream is setting the disk and memory +limits to anything other than zero. -1 indicates “unlimited”.

+ +
+ + + +
+
	accountClaims.Limits.JetStreamLimits.DiskStorage = -1
+	accountClaims.Limits.JetStreamLimits.MemoryStorage = -1
+
+
+
+ + + +
+

Inspecting the claims, you will notice the sub field is the public key +of the account.

+ +
+ + + +
+
	fmt.Printf("account claims: %s\n", accountClaims)
+
+
+
+ + + +
+

Now we can sign the claims with the operator and encode it to a JWT string. +To activate this account, it must be pushed up to the server using a client +connection authenticated as the SYS account user:

+ +
nc.Request("$SYS.REQ.CLAIMS.UPDATE", []byte(accountJWT))
+
+ +

If you copy the JWT output to https://jwt.io, you will notice the iss +field is set to the operator public key.

+ +
+ + + +
+
	accountJWT, _ := accountClaims.Encode(operatorKP)
+	fmt.Printf("account jwt: %s\n\n", accountJWT)
+
+
+
+ + + +
+

It is important to call out that the nsc tool handles storage and management +of operators, accounts, users. It writes out each nkey and JWT to a file and +organizes everything for you. If you opt to create accounts or users dynamically, +keep in mind you need to store and manage the keypairs and JWTs yourself.

+ +

If we want to create a user, the process is essentially the same as it was +for the account.

+ +
+ + + +
+
	userKP, _ := nkeys.CreateUser()
+
+
+	userPub, _ := userKP.PublicKey()
+	fmt.Printf("user pubkey: %s\n", userPub)
+
+
+	userSeed, _ := userKP.Seed()
+	fmt.Printf("user seed: %s\n", string(userSeed))
+
+
+
+ + + +
+

Create the user claims, set the name, and configure permissions, expiry time, +limits, etc.

+ +
+ + + +
+
	userClaims := jwt.NewUserClaims(userPub)
+	userClaims.Name = "my-user"
+	fmt.Printf("userclaims: %s\n", userClaims)
+
+
+
+ + + +
+

Sign and encode the claims as a JWT.

+ +
+ + + +
+
	userJWT, _ := userClaims.Encode(accountKP)
+	fmt.Printf("user jwt: %s\n", userJWT)
+}
+
+ + +
+
+
+ + + +
+
+ + diff --git a/html/examples/auth/nkeys-jwts/index.html b/html/examples/auth/nkeys-jwts/index.html new file mode 100644 index 00000000..144bd22b --- /dev/null +++ b/html/examples/auth/nkeys-jwts/index.html @@ -0,0 +1,97 @@ + + + + NATS by Example + + + + + + + + + + + + +

+ + NATS Logo by Example + +

+ + + +

Programmatic NKeys and JWTs

+ +
+

The primary (and recommended) way to create and manage accounts and users +is using the nsc command-line tool. However, +in some applications and use cases, it may be desirable to programmatically +create accounts or users on-demand as part of an application-level account/user +workflow rather than out-of-band on the command line (however, shelling out +to nsc from your program is another option).

+ +

This example shows how to programmatically generate NKeys and JWTs. +This can be used as an alternative or, more likely, in conjunction with the +nsc tool for creating and managing accounts and users.

+ +

Note, not all languages currently implement standalone NKeys or JWT libraries.

+ +
+ +
+ + + CLI + + + + Go + + + + Python + + + + Deno + + + + Node + + + + Rust + + + + C# + + + + Java + + + + Ruby + + + + Elixir + + + + C + + +
+ + + diff --git a/html/examples/messaging/index.html b/html/examples/messaging/index.html new file mode 100644 index 00000000..549d045b --- /dev/null +++ b/html/examples/messaging/index.html @@ -0,0 +1,43 @@ + + + + NATS by Example + + + + + + + + + + + + +

+ + NATS Logo by Example + +

+ + +

Messaging

+ +
+ +
+ + + + diff --git a/html/examples/messaging/pub-sub/cli/index.html b/html/examples/messaging/pub-sub/cli/index.html new file mode 100644 index 00000000..5695d6b5 --- /dev/null +++ b/html/examples/messaging/pub-sub/cli/index.html @@ -0,0 +1,271 @@ + + + + NATS by Example + + + + + + + + + + + + +
+ +
+ +
+
+

Core Publish-Subcribe in Messaging

+ +
+

This example demonstrates the core NATS publish-subscribe +behavior. This is the fundamental pattern that all other NATS +patterns and higher-level APIs build upon. There are a few takeaways from this example:

+ +
    +
  • Delivery is an at-most-once. For MQTT users, this is referred to as Quality of Service (QoS) 0.
  • +
  • There are two circumstances when a published message won’t be delivered to a subscriber: + +
      +
    • The subscriber does not have an active connection to the server (i.e. the client is temporarily offline for some reason)
    • +
    • There is a network interruption where the message is ultimately dropped
    • +
  • +
  • Messages are published to subjects which can be one or more concrete tokens, e.g. greet.bob. Subscribers can utilize wildcards to show interest on a set of matching subjects.
  • +
+ +
+ + + +
+
+ + + CLI + + + + Go + + + + Python + + + + Deno + + + + Node + + + + Rust + + + + C# + + + + Java + + + + Ruby + + + + Elixir + + + + C + + +
+ +
+ +
$ nbe run messaging/pub-sub/cli
+ Learn how to run this example. +
+
+ +
+ + +
+ +
+ + + +
+
#!/bin/sh
+
+
+
+ + + +
+

The nats CLI utilizes the NATS_URL environment variable if set. +However, if you want to manage different contexts for connecting +or authenticating, check out the nats context commands. +For example:

+ +
nats context save --server=$NATS_URL local
+
+ +
+ + + +
+
NATS_URL="${NATS_URL:-nats://localhost:4222}"
+
+
+
+ + + +
+

Publish a message to the subject ‘greet.joe’. Nothing will happen +since the subscription is not yet setup.

+ +
+ + + +
+
nats pub 'greet.joe' 'hello'
+
+
+
+ + + +
+

Let’s start a subscription in the background that will print +the output to stdout.

+ +
+ + + +
+
nats sub 'greet.*' &
+
+
+
+ + + +
+

This just captures the process ID of the previous command in this shell.

+ +
+ + + +
+
SUB_PID=$!
+
+
+
+ + + +
+

Tiny sleep to ensure the subscription connected.

+ +
+ + + +
+
sleep 0.5
+
+
+
+ + + +
+

Now we can publish a couple times..

+ +
+ + + +
+
nats pub 'greet.joe' 'hello'
+nats pub 'greet.pam' 'hello'
+nats pub 'greet.bob' 'hello'
+
+
+
+ + + +
+

Remove the subscription.

+ +
+ + + +
+
kill $SUB_PID
+
+
+
+ + + +
+

Publishing again will not result in anything.

+ +
+ + + +
+
nats pub 'greet.bob' 'hello'
+
+ + +
+
+
+ + + +
+
+ + diff --git a/html/examples/messaging/pub-sub/deno/index.html b/html/examples/messaging/pub-sub/deno/index.html new file mode 100644 index 00000000..6c027037 --- /dev/null +++ b/html/examples/messaging/pub-sub/deno/index.html @@ -0,0 +1,284 @@ + + + + NATS by Example + + + + + + + + + + + + +
+ +
+ +
+
+

Core Publish-Subcribe in Messaging

+ +
+

This example demonstrates the core NATS publish-subscribe +behavior. This is the fundamental pattern that all other NATS +patterns and higher-level APIs build upon. There are a few takeaways from this example:

+ +
    +
  • Delivery is an at-most-once. For MQTT users, this is referred to as Quality of Service (QoS) 0.
  • +
  • There are two circumstances when a published message won’t be delivered to a subscriber: + +
      +
    • The subscriber does not have an active connection to the server (i.e. the client is temporarily offline for some reason)
    • +
    • There is a network interruption where the message is ultimately dropped
    • +
  • +
  • Messages are published to subjects which can be one or more concrete tokens, e.g. greet.bob. Subscribers can utilize wildcards to show interest on a set of matching subjects.
  • +
+ +
+ + + +
+
+ + + CLI + + + + Go + + + + Python + + + + Deno + + + + Node + + + + Rust + + + + C# + + + + Java + + + + Ruby + + + + Elixir + + + + C + + +
+ +
+ +
$ nbe run messaging/pub-sub/deno
+ Learn how to run this example. +
+
+ +
+ + +
+ +
+ + + +
+
import {connect, StringCodec} from "https://deno.land/x/nats@v1.7.1/src/mod.ts";
+
+
+
+ + + +
+

Get the passed NATS_URL or fallback to the default. This can be +a comma-separated string.

+ +
+ + + +
+
const servers = Deno.env.get("NATS_URL") || "nats://localhost:4222";
+
+
+
+ + + +
+

Create a client connection to an available NATS server.

+ +
+ + + +
+
const nc = await connect({
+  servers: servers.split(","),
+});
+
+
+
+ + + +
+

NATS message payloads are byte arrays, so we need to have a codec +to serialize and deserialize payloads in order to work with them. +Another built-in codec is JSONCodec or you can implement your own.

+ +
+ + + +
+
const sc = StringCodec();
+
+
+
+ + + +
+

To publish a message, simply provide the subject of the message +and encode the message payload. NATS subjects are hierarchical using +periods as token delimiters. greet and joe are two distinct tokens.

+ +
+ + + +
+
nc.publish("greet.bob", sc.encode("hello"));
+
+
+
+ + + +
+

Now we are going to create a subscription and utilize a wildcard on +the second token. The effect is that this subscription shows interest +in all messages published to a subject with two tokens where the first +is greet.

+ +
+ + + +
+
let sub = nc.subscribe("greet.*", {max: 3});
+const done = (async () => {
+  for await (const msg of sub) {
+    console.log(`${sc.decode(msg.data)} on subject ${msg.subject}`);
+  }
+})()
+
+
+
+ + + +
+

Let’s publish three more messages which will result in the messages +being forwarded to the local subscription we have.

+ +
+ + + +
+
nc.publish("greet.joe", sc.encode("hello"));
+nc.publish("greet.pam", sc.encode("hello"));
+nc.publish("greet.sue", sc.encode("hello"));
+
+
+
+ + + +
+

This will wait until the above async subscription handler finishes +processing the three messages. Note that the first message to +greet.bob was not printed. This is because the subscription was +created after the publish. Core NATS provides at-most-once quality +of service (QoS) for active subscriptions.

+ +
+ + + +
+
await done;
+
+
+
+ + + +
+

Finally we drain the connection which waits for any pending +messages (published or in a subscription) to be flushed.

+ +
+ + + +
+
await nc.drain();
+
+ + +
+
+
+ + + +
+
+ + diff --git a/html/examples/messaging/pub-sub/go/index.html b/html/examples/messaging/pub-sub/go/index.html new file mode 100644 index 00000000..1a575176 --- /dev/null +++ b/html/examples/messaging/pub-sub/go/index.html @@ -0,0 +1,310 @@ + + + + NATS by Example + + + + + + + + + + + + +
+ +
+ +
+
+

Core Publish-Subcribe in Messaging

+ +
+

This example demonstrates the core NATS publish-subscribe +behavior. This is the fundamental pattern that all other NATS +patterns and higher-level APIs build upon. There are a few takeaways from this example:

+ +
    +
  • Delivery is an at-most-once. For MQTT users, this is referred to as Quality of Service (QoS) 0.
  • +
  • There are two circumstances when a published message won’t be delivered to a subscriber: + +
      +
    • The subscriber does not have an active connection to the server (i.e. the client is temporarily offline for some reason)
    • +
    • There is a network interruption where the message is ultimately dropped
    • +
  • +
  • Messages are published to subjects which can be one or more concrete tokens, e.g. greet.bob. Subscribers can utilize wildcards to show interest on a set of matching subjects.
  • +
+ +
+ + + +
+
+ + + CLI + + + + Go + + + + Python + + + + Deno + + + + Node + + + + Rust + + + + C# + + + + Java + + + + Ruby + + + + Elixir + + + + C + + +
+ +
+ +
$ nbe run messaging/pub-sub/go
+ Learn how to run this example. +
+
+ +
+ + +
+ +
+ + + +
+
package main
+
+
+import (
+	"fmt"
+	"os"
+	"time"
+
+
+	"github.com/nats-io/nats.go"
+)
+
+
+func main() {
+
+ + + +
+

Use the env varibale if running in the container, otherwise use the default.

+ +
+ + + +
+
	url := os.Getenv("NATS_URL")
+	if url == "" {
+		url = nats.DefaultURL
+	}
+
+
+
+ + + +
+

Create an unauthenticated connection to NATS.

+ +
+ + + +
+
	nc, _ := nats.Connect(url)
+
+
+
+ + + +
+

Drain is a safe way to to ensure all buffered messages that were published +are sent and all buffered messages received on a subscription are processed +being closing the connection.

+ +
+ + + +
+
	defer nc.Drain()
+
+
+
+ + + +
+

Messages are published to subjects. Although there are no subscribers, +this will be published successfully.

+ +
+ + + +
+
	nc.Publish("greet.joe", []byte("hello"))
+
+
+
+ + + +
+

Let’s create a subscription on the greet.* wildcard.

+ +
+ + + +
+
	sub, _ := nc.SubscribeSync("greet.*")
+
+
+
+ + + +
+

For a synchronous subscription, we need to fetch the next message. +However.. since the publish occured before the subscription was +established, this is going to timeout.

+ +
+ + + +
+
	msg, _ := sub.NextMsg(10 * time.Millisecond)
+	fmt.Println("subscribed after a publish...")
+	fmt.Printf("msg is nil? %v\n", msg == nil)
+
+
+
+ + + +
+

Publish a couple messages.

+ +
+ + + +
+
	nc.Publish("greet.joe", []byte("hello"))
+	nc.Publish("greet.pam", []byte("hello"))
+
+
+
+ + + +
+

Since the subscription is established, the published messages will +immediately be broadcasted to all subscriptions. They will land in +their buffer for subsequent NextMsg calls.

+ +
+ + + +
+
	msg, _ = sub.NextMsg(10 * time.Millisecond)
+	fmt.Printf("msg data: %q on subject %q\n", string(msg.Data), msg.Subject)
+
+
+	msg, _ = sub.NextMsg(10 * time.Millisecond)
+	fmt.Printf("msg data: %q on subject %q\n", string(msg.Data), msg.Subject)
+
+
+
+ + + +
+

One more for good measures..

+ +
+ + + +
+
	nc.Publish("greet.bob", []byte("hello"))
+
+
+	msg, _ = sub.NextMsg(10 * time.Millisecond)
+	fmt.Printf("msg data: %q on subject %q\n", string(msg.Data), msg.Subject)
+}
+
+ + +
+
+
+ + + +
+
+ + diff --git a/html/examples/messaging/pub-sub/index.html b/html/examples/messaging/pub-sub/index.html new file mode 100644 index 00000000..2352e135 --- /dev/null +++ b/html/examples/messaging/pub-sub/index.html @@ -0,0 +1,99 @@ + + + + NATS by Example + + + + + + + + + + + + +

+ + NATS Logo by Example + +

+ + + +

Core Publish-Subcribe

+ +
+

This example demonstrates the core NATS publish-subscribe +behavior. This is the fundamental pattern that all other NATS +patterns and higher-level APIs build upon. There are a few takeaways from this example:

+ +
    +
  • Delivery is an at-most-once. For MQTT users, this is referred to as Quality of Service (QoS) 0.
  • +
  • There are two circumstances when a published message won’t be delivered to a subscriber: + +
      +
    • The subscriber does not have an active connection to the server (i.e. the client is temporarily offline for some reason)
    • +
    • There is a network interruption where the message is ultimately dropped
    • +
  • +
  • Messages are published to subjects which can be one or more concrete tokens, e.g. greet.bob. Subscribers can utilize wildcards to show interest on a set of matching subjects.
  • +
+ +
+ +
+ + + CLI + + + + Go + + + + Python + + + + Deno + + + + Node + + + + Rust + + + + C# + + + + Java + + + + Ruby + + + + Elixir + + + + C + + +
+ + + diff --git a/html/examples/messaging/pub-sub/node/index.html b/html/examples/messaging/pub-sub/node/index.html new file mode 100644 index 00000000..431e8a6f --- /dev/null +++ b/html/examples/messaging/pub-sub/node/index.html @@ -0,0 +1,284 @@ + + + + NATS by Example + + + + + + + + + + + + +
+ +
+ +
+
+

Core Publish-Subcribe in Messaging

+ +
+

This example demonstrates the core NATS publish-subscribe +behavior. This is the fundamental pattern that all other NATS +patterns and higher-level APIs build upon. There are a few takeaways from this example:

+ +
    +
  • Delivery is an at-most-once. For MQTT users, this is referred to as Quality of Service (QoS) 0.
  • +
  • There are two circumstances when a published message won’t be delivered to a subscriber: + +
      +
    • The subscriber does not have an active connection to the server (i.e. the client is temporarily offline for some reason)
    • +
    • There is a network interruption where the message is ultimately dropped
    • +
  • +
  • Messages are published to subjects which can be one or more concrete tokens, e.g. greet.bob. Subscribers can utilize wildcards to show interest on a set of matching subjects.
  • +
+ +
+ + + +
+
+ + + CLI + + + + Go + + + + Python + + + + Deno + + + + Node + + + + Rust + + + + C# + + + + Java + + + + Ruby + + + + Elixir + + + + C + + +
+ +
+ +
$ nbe run messaging/pub-sub/node
+ Learn how to run this example. +
+
+ +
+ + +
+ +
+ + + +
+
import {connect, StringCodec} from "nats";
+
+
+
+ + + +
+

Get the passed NATS_URL or fallback to the default. This can be +a comma-separated string.

+ +
+ + + +
+
const servers = process.env.NATS_URL || "nats://localhost:4222";
+
+
+
+ + + +
+

Create a client connection to an available NATS server.

+ +
+ + + +
+
const nc = await connect({
+  servers: servers.split(","),
+});
+
+
+
+ + + +
+

NATS message payloads are byte arrays, so we need to have a codec +to serialize and deserialize payloads in order to work with them. +Another built-in codec is JSONCodec or you can implement your own.

+ +
+ + + +
+
const sc = StringCodec();
+
+
+
+ + + +
+

To publish a message, simply provide the subject of the message +and encode the message payload. NATS subjects are hierarchical using +periods as token delimiters. greet and joe are two distinct tokens.

+ +
+ + + +
+
nc.publish("greet.bob", sc.encode("hello"));
+
+
+
+ + + +
+

Now we are going to create a subscription and utilize a wildcard on +the second token. The effect is that this subscription shows interest +in all messages published to a subject with two tokens where the first +is greet.

+ +
+ + + +
+
let sub = nc.subscribe("greet.*", {max: 3});
+const done = (async () => {
+  for await (const msg of sub) {
+    console.log(`${sc.decode(msg.data)} on subject ${msg.subject}`);
+  }
+})()
+
+
+
+ + + +
+

Let’s publish three more messages which will result in the messages +being forwarded to the local subscription we have.

+ +
+ + + +
+
nc.publish("greet.joe", sc.encode("hello"));
+nc.publish("greet.pam", sc.encode("hello"));
+nc.publish("greet.sue", sc.encode("hello"));
+
+
+
+ + + +
+

This will wait until the above async subscription handler finishes +processing the three messages. Note that the first message to +greet.bob was not printed. This is because the subscription was +created after the publish. Core NATS provides at-most-once quality +of service (QoS) for active subscriptions.

+ +
+ + + +
+
await done;
+
+
+
+ + + +
+

Finally we drain the connection which waits for any pending +messages (published or in a subscription) to be flushed.

+ +
+ + + +
+
await nc.drain();
+
+ + +
+
+
+ + + +
+
+ + diff --git a/html/examples/messaging/pub-sub/python/index.html b/html/examples/messaging/pub-sub/python/index.html new file mode 100644 index 00000000..f2122c19 --- /dev/null +++ b/html/examples/messaging/pub-sub/python/index.html @@ -0,0 +1,308 @@ + + + + NATS by Example + + + + + + + + + + + + +
+ +
+ +
+
+

Core Publish-Subcribe in Messaging

+ +
+

This example demonstrates the core NATS publish-subscribe +behavior. This is the fundamental pattern that all other NATS +patterns and higher-level APIs build upon. There are a few takeaways from this example:

+ +
    +
  • Delivery is an at-most-once. For MQTT users, this is referred to as Quality of Service (QoS) 0.
  • +
  • There are two circumstances when a published message won’t be delivered to a subscriber: + +
      +
    • The subscriber does not have an active connection to the server (i.e. the client is temporarily offline for some reason)
    • +
    • There is a network interruption where the message is ultimately dropped
    • +
  • +
  • Messages are published to subjects which can be one or more concrete tokens, e.g. greet.bob. Subscribers can utilize wildcards to show interest on a set of matching subjects.
  • +
+ +
+ + + +
+
+ + + CLI + + + + Go + + + + Python + + + + Deno + + + + Node + + + + Rust + + + + C# + + + + Java + + + + Ruby + + + + Elixir + + + + C + + +
+ +
+ +
$ nbe run messaging/pub-sub/python
+ Learn how to run this example. +
+
+ +
+ + +
+ +
+ + + +
+
import os
+import asyncio
+
+
+import nats
+from nats.errors import TimeoutError
+
+
+
+ + + +
+

Get the list of servers.

+ +
+ + + +
+
servers = os.environ.get("NATS_URL", "nats://localhost:4222").split(",")
+
+
+async def main():
+
+ + + +
+

Create the connection to NATS which takes a list of servers.

+ +
+ + + +
+
    nc = await nats.connect(servers=servers)
+
+
+
+ + + +
+

Messages are published to subjects. Although there are no subscribers, +this will be published successfully.

+ +
+ + + +
+
    await nc.publish("greet.joe", b"hello")
+
+
+
+ + + +
+

Let’s create a subscription on the greet.* wildcard.

+ +
+ + + +
+
    sub = await nc.subscribe("greet.*")
+
+
+
+ + + +
+

For a synchronous subscription, we need to fetch the next message. +However.. since the publish occured before the subscription was +established, this is going to timeout.

+ +
+ + + +
+
    try:
+        msg = await sub.next_msg(timeout=0.1)
+    except TimeoutError:
+        pass
+
+
+
+ + + +
+

Publish a couple messages.

+ +
+ + + +
+
    await nc.publish("greet.joe", b"hello")
+    await nc.publish("greet.pam", b"hello")
+
+
+
+ + + +
+

Since the subscription is established, the published messages will +immediately be broadcasted to all subscriptions. They will land in + # their buffer for subsequent NextMsg calls.

+ +
+ + + +
+
    msg = await sub.next_msg(timeout=0.1)
+    print(f"{msg.data} on subject {msg.subject}")
+
+
+    msg = await sub.next_msg(timeout=0.1)
+    print(f"{msg.data} on subject {msg.subject}")
+
+
+
+ + + +
+

One more for good measures..

+ +
+ + + +
+
    await nc.publish("greet.bob", b"hello")
+
+
+    msg = await sub.next_msg(timeout=0.1)
+    print(f"{msg.data} on subject {msg.subject}")
+
+
+
+ + + +
+

Drain the subscription and connection. In contrast to unsubscribe, +drain will process any queued messages before removing interest.

+ +
+ + + +
+
    await sub.unsubscribe()
+    await nc.drain()
+
+
+
+
+if __name__ == '__main__':
+    asyncio.run(main())
+
+ + +
+
+
+ + + +
+
+ + diff --git a/html/examples/messaging/pub-sub/rust/index.html b/html/examples/messaging/pub-sub/rust/index.html new file mode 100644 index 00000000..9e643191 --- /dev/null +++ b/html/examples/messaging/pub-sub/rust/index.html @@ -0,0 +1,246 @@ + + + + NATS by Example + + + + + + + + + + + + +
+ +
+ +
+
+

Core Publish-Subcribe in Messaging

+ +
+

This example demonstrates the core NATS publish-subscribe +behavior. This is the fundamental pattern that all other NATS +patterns and higher-level APIs build upon. There are a few takeaways from this example:

+ +
    +
  • Delivery is an at-most-once. For MQTT users, this is referred to as Quality of Service (QoS) 0.
  • +
  • There are two circumstances when a published message won’t be delivered to a subscriber: + +
      +
    • The subscriber does not have an active connection to the server (i.e. the client is temporarily offline for some reason)
    • +
    • There is a network interruption where the message is ultimately dropped
    • +
  • +
  • Messages are published to subjects which can be one or more concrete tokens, e.g. greet.bob. Subscribers can utilize wildcards to show interest on a set of matching subjects.
  • +
+ +
+ + + +
+
+ + + CLI + + + + Go + + + + Python + + + + Deno + + + + Node + + + + Rust + + + + C# + + + + Java + + + + Ruby + + + + Elixir + + + + C + + +
+ +
+ +
$ nbe run messaging/pub-sub/rust
+ Learn how to run this example. +
+
+ +
+ + +
+ +
+ + + +
+
use futures::StreamExt;
+use std::{env, str::from_utf8};
+
+
+#[tokio::main]
+async fn main() -> Result<(), async_nats::Error> {
+
+ + + +
+

Use the NATS_URL env variable if defined, otherwise fallback +to the default.

+ +
+ + + +
+
    let nats_url = env::var("NATS_URL").unwrap_or_else(|_| "nats://localhost:4222".to_string());
+
+
+    let client = async_nats::connect(nats_url).await?;
+
+
+
+ + + +
+

Publish a message to the subject greet.joe.

+ +
+ + + +
+
    client
+        .publish("greet.joe".to_string(), "hello".into())
+        .await?;
+
+
+
+ + + +
+

Subscriber implements Rust iterator, so we can leverage +combinators like take() to limit the messages intended +to be consumed for this interaction.

+ +
+ + + +
+
    let mut subscription = client
+        .subscribe("greet.*".to_string())
+        .await?
+        .take(3);
+
+
+
+ + + +
+

Publish to three different subjects matching the wildcard.

+ +
+ + + +
+
    for subject in ["greet.sue", "greet.bob", "greet.pam"] {
+        client
+            .publish(subject.to_string(), "hello".into())
+            .await?;
+    }
+
+
+
+ + + +
+

Notice that the first message received is greet.sue and not +greet.joe which was the first message published. This is because +core NATS provides at-most-once quality of service (QoS). Subscribers +must be connected showing interest in a subject for the server to +relay the message to the client.

+ +
+ + + +
+
    while let Some(message) = subscription.next().await {
+        println!("{:?} received on {:?}", from_utf8(&message.payload), &message.subject);
+    }
+
+
+    Ok(())
+}
+
+
+
+ + +
+
+
+ + + +
+
+ + diff --git a/html/examples/messaging/request-reply/go/index.html b/html/examples/messaging/request-reply/go/index.html new file mode 100644 index 00000000..9aa55385 --- /dev/null +++ b/html/examples/messaging/request-reply/go/index.html @@ -0,0 +1,258 @@ + + + + NATS by Example + + + + + + + + + + + + +
+ +
+ +
+
+

Request-Reply in Messaging

+ +
+ +
+ + + +
+
+ + + CLI + + + + Go + + + + Python + + + + Deno + + + + Node + + + + Rust + + + + C# + + + + Java + + + + Ruby + + + + Elixir + + + + C + + +
+ +
+ +
$ nbe run messaging/request-reply/go
+ Learn how to run this example. +
+
+ +
+ + +
+ +
+ + + +
+
package main
+
+
+import (
+	"fmt"
+	"os"
+	"time"
+
+
+	"github.com/nats-io/nats.go"
+)
+
+
+func main() {
+
+ + + +
+

Use the env varibale if running in the container, otherwise use the default.

+ +
+ + + +
+
	url := os.Getenv("NATS_URL")
+	if url == "" {
+		url = nats.DefaultURL
+	}
+
+
+
+ + + +
+

Create an unauthenticated connection to NATS.

+ +
+ + + +
+
	nc, _ := nats.Connect(url)
+	defer nc.Drain()
+
+
+
+ + + +
+

In addition to vanilla publish-request, NATS supports request-reply +interactions as well. Under the covers, this is just an optimized +pair of publish-subscribe operations. +The request handler is just a subscription that responds to a message +sent to it. This kind of subscription is called a service. +For this example, we can use the built-in asynchronous +subscription in the Go SDK.

+ +
+ + + +
+
	sub, _ := nc.Subscribe("greet.*", func(msg *nats.Msg) {
+
+ + + +
+

Parse out the second token in the subject (everything after greet.) +and use it as part of the response message.

+ +
+ + + +
+
		name := msg.Subject[6:]
+		msg.Respond([]byte("hello, " + name))
+	})
+
+
+
+ + + +
+

Now we can use the built-in Request method to do the service request. +We simply pass a nil body since that is being used right now. In addition, +we need to specify a timeout since with a request we are waiting for the +reply and we likely don’t want to wait forever.

+ +
+ + + +
+
	rep, _ := nc.Request("greet.joe", nil, time.Second)
+	fmt.Println(string(rep.Data))
+
+
+	rep, _ = nc.Request("greet.sue", nil, time.Second)
+	fmt.Println(string(rep.Data))
+
+
+	rep, _ = nc.Request("greet.bob", nil, time.Second)
+	fmt.Println(string(rep.Data))
+
+
+
+ + + +
+

What happens if the service is unavailable? We can simulate this by +unsubscribing our handler from above. Now if we make a request, we will +expect an error.

+ +
+ + + +
+
	sub.Unsubscribe()
+
+
+	_, err := nc.Request("greet.joe", nil, time.Second)
+	fmt.Println(err)
+}
+
+ + +
+
+
+ + + +
+
+ + diff --git a/html/examples/messaging/request-reply/index.html b/html/examples/messaging/request-reply/index.html new file mode 100644 index 00000000..538e4885 --- /dev/null +++ b/html/examples/messaging/request-reply/index.html @@ -0,0 +1,85 @@ + + + + NATS by Example + + + + + + + + + + + + +

+ + NATS Logo by Example + +

+ + + +

Request-Reply

+ +
+ +
+ +
+ + + CLI + + + + Go + + + + Python + + + + Deno + + + + Node + + + + Rust + + + + C# + + + + Java + + + + Ruby + + + + Elixir + + + + C + + +
+ + + diff --git a/html/examples/messaging/request-reply/python/index.html b/html/examples/messaging/request-reply/python/index.html new file mode 100644 index 00000000..7ad4b3b4 --- /dev/null +++ b/html/examples/messaging/request-reply/python/index.html @@ -0,0 +1,261 @@ + + + + NATS by Example + + + + + + + + + + + + +
+ +
+ +
+
+

Request-Reply in Messaging

+ +
+ +
+ + + +
+
+ + + CLI + + + + Go + + + + Python + + + + Deno + + + + Node + + + + Rust + + + + C# + + + + Java + + + + Ruby + + + + Elixir + + + + C + + +
+ +
+ +
$ nbe run messaging/request-reply/python
+ Learn how to run this example. +
+
+ +
+ + +
+ +
+ + + +
+
import os
+import asyncio
+
+
+import nats
+from nats.errors import TimeoutError, NoRespondersError
+
+
+
+ + + +
+

Get the list of servers.

+ +
+ + + +
+
servers = os.environ.get("NATS_URL", "nats://localhost:4222").split(",")
+
+
+async def main():
+
+ + + +
+

Create the connection to NATS which takes a list of servers.

+ +
+ + + +
+
    nc = await nats.connect(servers=servers)
+
+
+
+ + + +
+

In addition to vanilla publish-request, NATS supports request-reply +interactions as well. Under the covers, this is just an optimized +pair of publish-subscribe operations. +The request handler is a subscription that responds to a message +sent to it. This kind of subscription is called a service. +We can use the cb argument for asynchronous handling.

+ +
+ + + +
+
    async def greet_handler(msg):
+
+ + + +
+

Parse out the second token in the subject (everything after +greet.) and use it as part of the response message.

+ +
+ + + +
+
        name = msg.subject[6:]
+        reply = f"hello, {name}"
+        await msg.respond(reply.encode("utf8"))
+
+
+    sub = await nc.subscribe("greet.*", cb=greet_handler)
+
+
+
+ + + +
+

Now we can use the built-in request method to do the service request. +We simply pass a empty body since that is being used right now. +In addition, we need to specify a timeout since with a request we +are waiting for the reply and we likely don’t want to wait forever.

+ +
+ + + +
+
    rep = await nc.request("greet.joe", b'', timeout=0.5)
+    print(f"{rep.data}")
+
+
+    rep = await nc.request("greet.sue", b'', timeout=0.5)
+    print(f"{rep.data}")
+
+
+    rep = await nc.request("greet.bob", b'', timeout=0.5)
+    print(f"{rep.data}")
+
+
+
+ + + +
+

What happens if the service is unavailable? We can simulate this by +unsubscribing our handler from above. Now if we make a request, we will +expect an error.

+ +
+ + + +
+
    await sub.drain()
+
+
+    try:
+        await nc.request("greet.joe", b'', timeout=0.5)
+    except NoRespondersError:
+        print("no responders")
+
+
+    await nc.drain()
+
+
+
+
+if __name__ == '__main__':
+    asyncio.run(main())
+
+ + +
+
+
+ + + +
+
+ + diff --git a/html/github-mark.svg b/html/github-mark.svg new file mode 100644 index 00000000..93af7db5 --- /dev/null +++ b/html/github-mark.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/html/index.html b/html/index.html new file mode 100644 index 00000000..e129d7c8 --- /dev/null +++ b/html/index.html @@ -0,0 +1,74 @@ + + + + NATS by Example + + + + + + + + + + + + +
+ +
+ +
+
+

Learn NATS by Example

+

An evolving collection of runnable, cross-client reference examples for NATS.

+ +
$ nbe run messaging/pub-sub/{cli,go,rust,python,deno,...}
+ +
+ Learn how to get started or just start browsing the examples below 👇! +
+
+
+ +
+
+ +

Messaging

+

+ + +

Authentication and Authorization

+

Topics related to authentication and authorization.

+

+ + +
+
+ +
+
+ + diff --git a/html/main.css b/html/main.css new file mode 100644 index 00000000..f4355967 --- /dev/null +++ b/html/main.css @@ -0,0 +1,269 @@ +@font-face { + font-family: "Cookie"; + src: url("/Cookie-Regular.ttf"); +} + +body { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +body, button, input, select, textarea { + font-family: "Source Sans Pro",BlinkMacSystemFont,-apple-system,"Segoe UI","Roboto","Oxygen","Ubuntu","Cantarell","Fira Sans","Droid Sans","Helvetica Neue","Helvetica","Arial",sans-serif; +} + +a { + text-decoration: none; +} + +a:link, a:visited { + color: #27aae1; +} + +a:hover, a:active { + color: #375c93; +} + +pre { + background-color: inherit; + margin: 0; +} + +header { + padding: 5px 0; + color: #ffffff; + background-color: #1b2e49; + border-bottom: 5px solid #27aae1; +} + +main { + margin: 20px 0 30px 0; +} + +footer { + margin-top: auto; + padding: 5px 0; + color: #ffffff; + background-color: #1b2e49; + border-top: 5px solid #27aae1; +} + +#header { + font-family: "Cookie"; +} + +#header a { + color: #ffffff; + text-decoration: none; +} + +#header img { + display: inline-block; + vertical-align: bottom; + height: 42px; +} + +.container { + width: 1000px; + margin: 0 auto; +} + +#hero { + text-align: center; + padding: 2rem 0; + background-color: #efefef; +} + +h1, h2, h3, h4, h5, h6 { + color: #1b2e49; +} + +#hero h2 { + margin-top: 0; + font-size: 52px; +} + +#hero p { + font-size: 24px; + font-weight: 200px; + margin-bottom: 20px; + color: #444444; +} + +#hero pre { + background-color: #ddd; + padding: 7px 10px; + border-radius: 5px; + margin-bottom: 20px; + display: inline-block; +} + +.quiet { + color: #999; +} + +.info { + display: flex; + flex-direction: row; + flex-wrap: wrap; + padding: 20px; + border-radius: 3px; + background-color: #eee; + margin-bottom: 30px; +} + +.github-icon, .clipboard-icon { + width: 16px; + display: inline-block; + vertical-align: sub; +} + +.source-run .links { + margin-bottom: 10px; +} + +.source-run pre { + background-color: #ddd; + padding: 10px; + border-radius: 3px; +} + +.info .language-tabs { + width: 40%; + margin-right: 20%; +} + +.info .language-tabs span { + padding: 5px 7px; + margin-bottom: 5px; + border-radius: 3px; + display: inline-block; + background-color: #375c93; + font-weight: 600; + color: #fff; +} + +.info .language-tabs a:hover, .info .language-tabs a:active { + color: #fff; +} + +.info .language-tabs span.current { + color: #fff; +} + +.info .language-tabs span.inactive { + background-color: #999; +} + +/* +a:link, a:visited { + text-decoration: none; +} + +a:hover, a:active { + text-decoration: underline; +} +*/ + +.language-tabs span.current { + background-color: inherit; +} + +pre, code { + font-family: 'Roboto Mono', 'JetBrains Mono', 'Source Code Pro', 'FreeMono', monospace; + font-size: 0.9em; +} + +.example { + display: flex; + flex-direction: row; + flex-wrap: wrap; + margin: 0 auto; +} + +.example .example-code { + width: 50%; +} + +.example .example-comment { + width: 40%; + margin-right: 5%; +} + +.example-comment pre { + overflow-x: scroll; +} + +.description { + font-size: 0.9rem; + margin-bottom: 30px; +} + +.title { + margin-top: 0.5rem; +} + +/* Syntax highlighting... */ + +body .hll { background-color: #ffffcc } +body .err { border: 1px solid #FF0000 } /* Error */ +body .c { color: #408080; font-style: italic } /* Comment */ +body .k { color: #954121 } /* Keyword */ +body .o { color: #666666 } /* Operator */ +body .cm { color: #408080; font-style: italic } /* Comment.Multiline */ +body .cp { color: #BC7A00 } /* Comment.Preproc */ +body .c1 { color: #408080; font-style: italic } /* Comment.Single */ +body .cs { color: #408080; font-style: italic } /* Comment.Special */ +body .gd { color: #A00000 } /* Generic.Deleted */ +body .ge { font-style: italic } /* Generic.Emph */ +body .gr { color: #FF0000 } /* Generic.Error */ +body .gh { color: #000080; font-weight: bold } /* Generic.Heading */ +body .gi { color: #00A000 } /* Generic.Inserted */ +body .go { color: #808080 } /* Generic.Output */ +body .gp { color: #000080; font-weight: bold } /* Generic.Prompt */ +body .gs { font-weight: bold } /* Generic.Strong */ +body .gu { color: #800080; font-weight: bold } /* Generic.Subheading */ +body .gt { color: #0040D0 } /* Generic.Traceback */ +body .kc { color: #954121 } /* Keyword.Constant */ +body .kd { color: #954121 } /* Keyword.Declaration */ +body .kn { color: #954121 } /* Keyword.Namespace */ +body .kp { color: #954121 } /* Keyword.Pseudo */ +body .kr { color: #954121; font-weight: bold } /* Keyword.Reserved */ +body .kt { color: #B00040 } /* Keyword.Type */ +body .m { color: #666666 } /* Literal.Number */ +body .s { color: #219161 } /* Literal.String */ +body .na { color: #7D9029 } /* Name.Attribute */ +body .nb { color: #954121 } /* Name.Builtin */ +body .nc { color: #0000FF; font-weight: bold } /* Name.Class */ +body .no { color: #880000 } /* Name.Constant */ +body .nd { color: #AA22FF } /* Name.Decorator */ +body .ni { color: #999999; font-weight: bold } /* Name.Entity */ +body .ne { color: #D2413A; font-weight: bold } /* Name.Exception */ +body .nf { } /* Name.Function */ +body .nl { color: #A0A000 } /* Name.Label */ +body .nn { color: #0000FF; font-weight: bold } /* Name.Namespace */ +body .nt { color: #954121; font-weight: bold } /* Name.Tag */ +body .nv { color: #19469D } /* Name.Variable */ +body .ow { color: #AA22FF; font-weight: bold } /* Operator.Word */ +body .w { color: #bbbbbb } /* Text.Whitespace */ +body .mf { color: #666666 } /* Literal.Number.Float */ +body .mh { color: #666666 } /* Literal.Number.Hex */ +body .mi { color: #666666 } /* Literal.Number.Integer */ +body .mo { color: #666666 } /* Literal.Number.Oct */ +body .sb { color: #219161 } /* Literal.String.Backtick */ +body .sc { color: #219161 } /* Literal.String.Char */ +body .sd { color: #219161; font-style: italic } /* Literal.String.Doc */ +body .s2 { color: #219161 } /* Literal.String.Double */ +body .se { color: #BB6622; font-weight: bold } /* Literal.String.Escape */ +body .sh { color: #219161 } /* Literal.String.Heredoc */ +body .si { color: #BB6688; font-weight: bold } /* Literal.String.Interpol */ +body .sx { color: #954121 } /* Literal.String.Other */ +body .sr { color: #BB6688 } /* Literal.String.Regex */ +body .s1 { color: #219161 } /* Literal.String.Single */ +body .ss { color: #19469D } /* Literal.String.Symbol */ +body .bp { color: #954121 } /* Name.Builtin.Pseudo */ +body .vc { color: #19469D } /* Name.Variable.Class */ +body .vg { color: #19469D } /* Name.Variable.Global */ +body .vi { color: #19469D } /* Name.Variable.Instance */ +body .il { color: #666666 } /* Literal.Number.Integer.Long */ diff --git a/html/main.js b/html/main.js new file mode 100644 index 00000000..8ecea465 --- /dev/null +++ b/html/main.js @@ -0,0 +1,16 @@ +/*! + * clipboard.js v1.5.13 + * https://zenorocha.github.io/clipboard.js + * + * Licensed MIT © Zeno Rocha + */ +!function (t) {if ("object" == typeof exports && "undefined" != typeof module) module.exports = t(); else if ("function" == typeof define && define.amd) define([], t); else {var e; e = "undefined" != typeof window ? window : "undefined" != typeof global ? global : "undefined" != typeof self ? self : this, e.Clipboard = t()} }(function () {var t, e, n; return function t(e, n, o) {function r(c, a) {if (!n[c]) {if (!e[c]) {var l = "function" == typeof require && require; if (!a && l) return l(c, !0); if (i) return i(c, !0); var s = new Error("Cannot find module '" + c + "'"); throw s.code = "MODULE_NOT_FOUND", s} var u = n[c] = {exports: {}}; e[c][0].call(u.exports, function (t) {var n = e[c][1][t]; return r(n ? n : t)}, u, u.exports, t, e, n, o)} return n[c].exports} for (var i = "function" == typeof require && require, c = 0; c < o.length; c++)r(o[c]); return r}({1: [function (t, e, n) {function o(t, e, n) {for (n = n || document.documentElement; t && t !== n;) {if (r(t, e)) return t; t = t.parentNode} return r(t, e) ? t : null} try {var r = t("matches-selector")} catch (e) {var r = t("component-matches-selector")} e.exports = o}, {"component-matches-selector": 2, "matches-selector": 2}], 2: [function (t, e, n) {function o(t, e) {if (!t || 1 !== t.nodeType) return !1; if (c) return c.call(t, e); for (var n = r.all(e, t.parentNode), o = 0; o < n.length; ++o)if (n[o] == t) return !0; return !1} try {var r = t("query")} catch (e) {var r = t("component-query")} var i = Element.prototype, c = i.matches || i.webkitMatchesSelector || i.mozMatchesSelector || i.msMatchesSelector || i.oMatchesSelector; e.exports = o}, {"component-query": 3, query: 3}], 3: [function (t, e, n) {function o(t, e) {return e.querySelector(t)} n = e.exports = function (t, e) {return e = e || document, o(t, e)}, n.all = function (t, e) {return e = e || document, e.querySelectorAll(t)}, n.engine = function (t) {if (!t.one) throw new Error(".one callback required"); if (!t.all) throw new Error(".all callback required"); return o = t.one, n.all = t.all, n}}, {}], 4: [function (t, e, n) {function o(t, e, n, o, i) {var c = r.apply(this, arguments); return t.addEventListener(n, c, i), {destroy: function () {t.removeEventListener(n, c, i)}}} function r(t, e, n, o) {return function (n) {n.delegateTarget = i(n.target, e, !0), n.delegateTarget && o.call(t, n)}} var i = t("component-closest"); e.exports = o}, {"component-closest": 1}], 5: [function (t, e, n) {n.node = function (t) {return void 0 !== t && t instanceof HTMLElement && 1 === t.nodeType}, n.nodeList = function (t) {var e = Object.prototype.toString.call(t); return void 0 !== t && ("[object NodeList]" === e || "[object HTMLCollection]" === e) && "length" in t && (0 === t.length || n.node(t[0]))}, n.string = function (t) {return "string" == typeof t || t instanceof String}, n.fn = function (t) {var e = Object.prototype.toString.call(t); return "[object Function]" === e}}, {}], 6: [function (t, e, n) {function o(t, e, n) {if (!t && !e && !n) throw new Error("Missing required arguments"); if (!a.string(e)) throw new TypeError("Second argument must be a String"); if (!a.fn(n)) throw new TypeError("Third argument must be a Function"); if (a.node(t)) return r(t, e, n); if (a.nodeList(t)) return i(t, e, n); if (a.string(t)) return c(t, e, n); throw new TypeError("First argument must be a String, HTMLElement, HTMLCollection, or NodeList")} function r(t, e, n) {return t.addEventListener(e, n), {destroy: function () {t.removeEventListener(e, n)}}} function i(t, e, n) {return Array.prototype.forEach.call(t, function (t) {t.addEventListener(e, n)}), {destroy: function () {Array.prototype.forEach.call(t, function (t) {t.removeEventListener(e, n)})}}} function c(t, e, n) {return l(document.body, t, e, n)} var a = t("./is"), l = t("delegate"); e.exports = o}, {"./is": 5, delegate: 4}], 7: [function (t, e, n) {function o(t) {var e; if ("SELECT" === t.nodeName) t.focus(), e = t.value; else if ("INPUT" === t.nodeName || "TEXTAREA" === t.nodeName) t.focus(), t.setSelectionRange(0, t.value.length), e = t.value; else {t.hasAttribute("contenteditable") && t.focus(); var n = window.getSelection(), o = document.createRange(); o.selectNodeContents(t), n.removeAllRanges(), n.addRange(o), e = n.toString()} return e} e.exports = o}, {}], 8: [function (t, e, n) {function o() {} o.prototype = {on: function (t, e, n) {var o = this.e || (this.e = {}); return (o[t] || (o[t] = [])).push({fn: e, ctx: n}), this}, once: function (t, e, n) {function o() {r.off(t, o), e.apply(n, arguments)} var r = this; return o._ = e, this.on(t, o, n)}, emit: function (t) {var e = [].slice.call(arguments, 1), n = ((this.e || (this.e = {}))[t] || []).slice(), o = 0, r = n.length; for (o; o < r; o++)n[o].fn.apply(n[o].ctx, e); return this}, off: function (t, e) {var n = this.e || (this.e = {}), o = n[t], r = []; if (o && e) for (var i = 0, c = o.length; i < c; i++)o[i].fn !== e && o[i].fn._ !== e && r.push(o[i]); return r.length ? n[t] = r : delete n[t], this}}, e.exports = o}, {}], 9: [function (e, n, o) {!function (r, i) {if ("function" == typeof t && t.amd) t(["module", "select"], i); else if ("undefined" != typeof o) i(n, e("select")); else {var c = {exports: {}}; i(c, r.select), r.clipboardAction = c.exports} }(this, function (t, e) {"use strict"; function n(t) {return t && t.__esModule ? t : {default: t}} function o(t, e) {if (!(t instanceof e)) throw new TypeError("Cannot call a class as a function")} var r = n(e), i = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function (t) {return typeof t} : function (t) {return t && "function" == typeof Symbol && t.constructor === Symbol ? "symbol" : typeof t}, c = function () {function t(t, e) {for (var n = 0; n < e.length; n++) {var o = e[n]; o.enumerable = o.enumerable || !1, o.configurable = !0, "value" in o && (o.writable = !0), Object.defineProperty(t, o.key, o)} } return function (e, n, o) {return n && t(e.prototype, n), o && t(e, o), e}}(), a = function () {function t(e) {o(this, t), this.resolveOptions(e), this.initSelection()} return t.prototype.resolveOptions = function t() {var e = arguments.length <= 0 || void 0 === arguments[0] ? {} : arguments[0]; this.action = e.action, this.emitter = e.emitter, this.target = e.target, this.text = e.text, this.trigger = e.trigger, this.selectedText = ""}, t.prototype.initSelection = function t() {this.text ? this.selectFake() : this.target && this.selectTarget()}, t.prototype.selectFake = function t() {var e = this, n = "rtl" == document.documentElement.getAttribute("dir"); this.removeFake(), this.fakeHandlerCallback = function () {return e.removeFake()}, this.fakeHandler = document.body.addEventListener("click", this.fakeHandlerCallback) || !0, this.fakeElem = document.createElement("textarea"), this.fakeElem.style.fontSize = "12pt", this.fakeElem.style.border = "0", this.fakeElem.style.padding = "0", this.fakeElem.style.margin = "0", this.fakeElem.style.position = "absolute", this.fakeElem.style[n ? "right" : "left"] = "-9999px"; var o = window.pageYOffset || document.documentElement.scrollTop; this.fakeElem.addEventListener("focus", window.scrollTo(0, o)), this.fakeElem.style.top = o + "px", this.fakeElem.setAttribute("readonly", ""), this.fakeElem.value = this.text, document.body.appendChild(this.fakeElem), this.selectedText = (0, r.default)(this.fakeElem), this.copyText()}, t.prototype.removeFake = function t() {this.fakeHandler && (document.body.removeEventListener("click", this.fakeHandlerCallback), this.fakeHandler = null, this.fakeHandlerCallback = null), this.fakeElem && (document.body.removeChild(this.fakeElem), this.fakeElem = null)}, t.prototype.selectTarget = function t() {this.selectedText = (0, r.default)(this.target), this.copyText()}, t.prototype.copyText = function t() {var e = void 0; try {e = document.execCommand(this.action)} catch (t) {e = !1} this.handleResult(e)}, t.prototype.handleResult = function t(e) {this.emitter.emit(e ? "success" : "error", {action: this.action, text: this.selectedText, trigger: this.trigger, clearSelection: this.clearSelection.bind(this)})}, t.prototype.clearSelection = function t() {this.target && this.target.blur(), window.getSelection().removeAllRanges()}, t.prototype.destroy = function t() {this.removeFake()}, c(t, [{key: "action", set: function t() {var e = arguments.length <= 0 || void 0 === arguments[0] ? "copy" : arguments[0]; if (this._action = e, "copy" !== this._action && "cut" !== this._action) throw new Error('Invalid "action" value, use either "copy" or "cut"')}, get: function t() {return this._action}}, {key: "target", set: function t(e) {if (void 0 !== e) {if (!e || "object" !== ("undefined" == typeof e ? "undefined" : i(e)) || 1 !== e.nodeType) throw new Error('Invalid "target" value, use a valid Element'); if ("copy" === this.action && e.hasAttribute("disabled")) throw new Error('Invalid "target" attribute. Please use "readonly" instead of "disabled" attribute'); if ("cut" === this.action && (e.hasAttribute("readonly") || e.hasAttribute("disabled"))) throw new Error('Invalid "target" attribute. You can\'t cut text from elements with "readonly" or "disabled" attributes'); this._target = e} }, get: function t() {return this._target}}]), t}(); t.exports = a})}, {select: 7}], 10: [function (e, n, o) {!function (r, i) {if ("function" == typeof t && t.amd) t(["module", "./clipboard-action", "tiny-emitter", "good-listener"], i); else if ("undefined" != typeof o) i(n, e("./clipboard-action"), e("tiny-emitter"), e("good-listener")); else {var c = {exports: {}}; i(c, r.clipboardAction, r.tinyEmitter, r.goodListener), r.clipboard = c.exports} }(this, function (t, e, n, o) {"use strict"; function r(t) {return t && t.__esModule ? t : {default: t}} function i(t, e) {if (!(t instanceof e)) throw new TypeError("Cannot call a class as a function")} function c(t, e) {if (!t) throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); return !e || "object" != typeof e && "function" != typeof e ? t : e} function a(t, e) {if ("function" != typeof e && null !== e) throw new TypeError("Super expression must either be null or a function, not " + typeof e); t.prototype = Object.create(e && e.prototype, {constructor: {value: t, enumerable: !1, writable: !0, configurable: !0}}), e && (Object.setPrototypeOf ? Object.setPrototypeOf(t, e) : t.__proto__ = e)} function l(t, e) {var n = "data-clipboard-" + t; if (e.hasAttribute(n)) return e.getAttribute(n)} var s = r(e), u = r(n), f = r(o), d = function (t) {function e(n, o) {i(this, e); var r = c(this, t.call(this)); return r.resolveOptions(o), r.listenClick(n), r} return a(e, t), e.prototype.resolveOptions = function t() {var e = arguments.length <= 0 || void 0 === arguments[0] ? {} : arguments[0]; this.action = "function" == typeof e.action ? e.action : this.defaultAction, this.target = "function" == typeof e.target ? e.target : this.defaultTarget, this.text = "function" == typeof e.text ? e.text : this.defaultText}, e.prototype.listenClick = function t(e) {var n = this; this.listener = (0, f.default)(e, "click", function (t) {return n.onClick(t)})}, e.prototype.onClick = function t(e) {var n = e.delegateTarget || e.currentTarget; this.clipboardAction && (this.clipboardAction = null), this.clipboardAction = new s.default({action: this.action(n), target: this.target(n), text: this.text(n), trigger: n, emitter: this})}, e.prototype.defaultAction = function t(e) {return l("action", e)}, e.prototype.defaultTarget = function t(e) {var n = l("target", e); if (n) return document.querySelector(n)}, e.prototype.defaultText = function t(e) {return l("text", e)}, e.prototype.destroy = function t() {this.listener.destroy(), this.clipboardAction && (this.clipboardAction.destroy(), this.clipboardAction = null)}, e}(u.default); t.exports = d})}, {"./clipboard-action": 9, "good-listener": 6, "tiny-emitter": 8}]}, {}, [10])(10)}); + +(function () { + new Clipboard('.copy-full', { + text: function (trigger) {return codeWithComments;} + }); + new Clipboard('.copy-code', { + text: function (trigger) {return codeNoComments;} + }); +})(); diff --git a/html/nats-horizontal-color.svg b/html/nats-horizontal-color.svg new file mode 100644 index 00000000..bc7f5a3c --- /dev/null +++ b/html/nats-horizontal-color.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/html/nats.svg b/html/nats.svg new file mode 100644 index 00000000..4a44b41a --- /dev/null +++ b/html/nats.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/html/reset.css b/html/reset.css new file mode 100644 index 00000000..9544f4c4 --- /dev/null +++ b/html/reset.css @@ -0,0 +1,2 @@ +*,*::before,*::after{box-sizing:border-box}body,h1,h2,h3,h4,p,figure,blockquote,dl,dd{margin:0}ul[role="list"],ol[role="list"]{list-style:none}html:focus-within{scroll-behavior:smooth}body{min-height:100vh;text-rendering:optimizeSpeed;line-height:1.5}a:not([class]){text-decoration-skip-ink:auto}img,picture{max-width:100%;display:block}input,button,textarea,select{font:inherit}@media(prefers-reduced-motion:reduce){html:focus-within{scroll-behavior:auto}*,*::before,*::after{animation-duration:.01ms !important;animation-iteration-count:1 !important;transition-duration:.01ms !important;scroll-behavior:auto !important}} + diff --git a/static/Cookie-Regular.ttf b/static/Cookie-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..e3ac74eb25ee3427d9929ea21a0d9415f3524edb GIT binary patch literal 42132 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 literal 0 HcmV?d00001 diff --git a/static/clipboard.svg b/static/clipboard.svg new file mode 100644 index 00000000..3028cd6d --- /dev/null +++ b/static/clipboard.svg @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/github-mark.svg b/static/github-mark.svg new file mode 100644 index 00000000..93af7db5 --- /dev/null +++ b/static/github-mark.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/static/main.css b/static/main.css new file mode 100644 index 00000000..f4355967 --- /dev/null +++ b/static/main.css @@ -0,0 +1,269 @@ +@font-face { + font-family: "Cookie"; + src: url("/Cookie-Regular.ttf"); +} + +body { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +body, button, input, select, textarea { + font-family: "Source Sans Pro",BlinkMacSystemFont,-apple-system,"Segoe UI","Roboto","Oxygen","Ubuntu","Cantarell","Fira Sans","Droid Sans","Helvetica Neue","Helvetica","Arial",sans-serif; +} + +a { + text-decoration: none; +} + +a:link, a:visited { + color: #27aae1; +} + +a:hover, a:active { + color: #375c93; +} + +pre { + background-color: inherit; + margin: 0; +} + +header { + padding: 5px 0; + color: #ffffff; + background-color: #1b2e49; + border-bottom: 5px solid #27aae1; +} + +main { + margin: 20px 0 30px 0; +} + +footer { + margin-top: auto; + padding: 5px 0; + color: #ffffff; + background-color: #1b2e49; + border-top: 5px solid #27aae1; +} + +#header { + font-family: "Cookie"; +} + +#header a { + color: #ffffff; + text-decoration: none; +} + +#header img { + display: inline-block; + vertical-align: bottom; + height: 42px; +} + +.container { + width: 1000px; + margin: 0 auto; +} + +#hero { + text-align: center; + padding: 2rem 0; + background-color: #efefef; +} + +h1, h2, h3, h4, h5, h6 { + color: #1b2e49; +} + +#hero h2 { + margin-top: 0; + font-size: 52px; +} + +#hero p { + font-size: 24px; + font-weight: 200px; + margin-bottom: 20px; + color: #444444; +} + +#hero pre { + background-color: #ddd; + padding: 7px 10px; + border-radius: 5px; + margin-bottom: 20px; + display: inline-block; +} + +.quiet { + color: #999; +} + +.info { + display: flex; + flex-direction: row; + flex-wrap: wrap; + padding: 20px; + border-radius: 3px; + background-color: #eee; + margin-bottom: 30px; +} + +.github-icon, .clipboard-icon { + width: 16px; + display: inline-block; + vertical-align: sub; +} + +.source-run .links { + margin-bottom: 10px; +} + +.source-run pre { + background-color: #ddd; + padding: 10px; + border-radius: 3px; +} + +.info .language-tabs { + width: 40%; + margin-right: 20%; +} + +.info .language-tabs span { + padding: 5px 7px; + margin-bottom: 5px; + border-radius: 3px; + display: inline-block; + background-color: #375c93; + font-weight: 600; + color: #fff; +} + +.info .language-tabs a:hover, .info .language-tabs a:active { + color: #fff; +} + +.info .language-tabs span.current { + color: #fff; +} + +.info .language-tabs span.inactive { + background-color: #999; +} + +/* +a:link, a:visited { + text-decoration: none; +} + +a:hover, a:active { + text-decoration: underline; +} +*/ + +.language-tabs span.current { + background-color: inherit; +} + +pre, code { + font-family: 'Roboto Mono', 'JetBrains Mono', 'Source Code Pro', 'FreeMono', monospace; + font-size: 0.9em; +} + +.example { + display: flex; + flex-direction: row; + flex-wrap: wrap; + margin: 0 auto; +} + +.example .example-code { + width: 50%; +} + +.example .example-comment { + width: 40%; + margin-right: 5%; +} + +.example-comment pre { + overflow-x: scroll; +} + +.description { + font-size: 0.9rem; + margin-bottom: 30px; +} + +.title { + margin-top: 0.5rem; +} + +/* Syntax highlighting... */ + +body .hll { background-color: #ffffcc } +body .err { border: 1px solid #FF0000 } /* Error */ +body .c { color: #408080; font-style: italic } /* Comment */ +body .k { color: #954121 } /* Keyword */ +body .o { color: #666666 } /* Operator */ +body .cm { color: #408080; font-style: italic } /* Comment.Multiline */ +body .cp { color: #BC7A00 } /* Comment.Preproc */ +body .c1 { color: #408080; font-style: italic } /* Comment.Single */ +body .cs { color: #408080; font-style: italic } /* Comment.Special */ +body .gd { color: #A00000 } /* Generic.Deleted */ +body .ge { font-style: italic } /* Generic.Emph */ +body .gr { color: #FF0000 } /* Generic.Error */ +body .gh { color: #000080; font-weight: bold } /* Generic.Heading */ +body .gi { color: #00A000 } /* Generic.Inserted */ +body .go { color: #808080 } /* Generic.Output */ +body .gp { color: #000080; font-weight: bold } /* Generic.Prompt */ +body .gs { font-weight: bold } /* Generic.Strong */ +body .gu { color: #800080; font-weight: bold } /* Generic.Subheading */ +body .gt { color: #0040D0 } /* Generic.Traceback */ +body .kc { color: #954121 } /* Keyword.Constant */ +body .kd { color: #954121 } /* Keyword.Declaration */ +body .kn { color: #954121 } /* Keyword.Namespace */ +body .kp { color: #954121 } /* Keyword.Pseudo */ +body .kr { color: #954121; font-weight: bold } /* Keyword.Reserved */ +body .kt { color: #B00040 } /* Keyword.Type */ +body .m { color: #666666 } /* Literal.Number */ +body .s { color: #219161 } /* Literal.String */ +body .na { color: #7D9029 } /* Name.Attribute */ +body .nb { color: #954121 } /* Name.Builtin */ +body .nc { color: #0000FF; font-weight: bold } /* Name.Class */ +body .no { color: #880000 } /* Name.Constant */ +body .nd { color: #AA22FF } /* Name.Decorator */ +body .ni { color: #999999; font-weight: bold } /* Name.Entity */ +body .ne { color: #D2413A; font-weight: bold } /* Name.Exception */ +body .nf { } /* Name.Function */ +body .nl { color: #A0A000 } /* Name.Label */ +body .nn { color: #0000FF; font-weight: bold } /* Name.Namespace */ +body .nt { color: #954121; font-weight: bold } /* Name.Tag */ +body .nv { color: #19469D } /* Name.Variable */ +body .ow { color: #AA22FF; font-weight: bold } /* Operator.Word */ +body .w { color: #bbbbbb } /* Text.Whitespace */ +body .mf { color: #666666 } /* Literal.Number.Float */ +body .mh { color: #666666 } /* Literal.Number.Hex */ +body .mi { color: #666666 } /* Literal.Number.Integer */ +body .mo { color: #666666 } /* Literal.Number.Oct */ +body .sb { color: #219161 } /* Literal.String.Backtick */ +body .sc { color: #219161 } /* Literal.String.Char */ +body .sd { color: #219161; font-style: italic } /* Literal.String.Doc */ +body .s2 { color: #219161 } /* Literal.String.Double */ +body .se { color: #BB6622; font-weight: bold } /* Literal.String.Escape */ +body .sh { color: #219161 } /* Literal.String.Heredoc */ +body .si { color: #BB6688; font-weight: bold } /* Literal.String.Interpol */ +body .sx { color: #954121 } /* Literal.String.Other */ +body .sr { color: #BB6688 } /* Literal.String.Regex */ +body .s1 { color: #219161 } /* Literal.String.Single */ +body .ss { color: #19469D } /* Literal.String.Symbol */ +body .bp { color: #954121 } /* Name.Builtin.Pseudo */ +body .vc { color: #19469D } /* Name.Variable.Class */ +body .vg { color: #19469D } /* Name.Variable.Global */ +body .vi { color: #19469D } /* Name.Variable.Instance */ +body .il { color: #666666 } /* Literal.Number.Integer.Long */ diff --git a/static/main.js b/static/main.js new file mode 100644 index 00000000..8ecea465 --- /dev/null +++ b/static/main.js @@ -0,0 +1,16 @@ +/*! + * clipboard.js v1.5.13 + * https://zenorocha.github.io/clipboard.js + * + * Licensed MIT © Zeno Rocha + */ +!function (t) {if ("object" == typeof exports && "undefined" != typeof module) module.exports = t(); else if ("function" == typeof define && define.amd) define([], t); else {var e; e = "undefined" != typeof window ? window : "undefined" != typeof global ? global : "undefined" != typeof self ? self : this, e.Clipboard = t()} }(function () {var t, e, n; return function t(e, n, o) {function r(c, a) {if (!n[c]) {if (!e[c]) {var l = "function" == typeof require && require; if (!a && l) return l(c, !0); if (i) return i(c, !0); var s = new Error("Cannot find module '" + c + "'"); throw s.code = "MODULE_NOT_FOUND", s} var u = n[c] = {exports: {}}; e[c][0].call(u.exports, function (t) {var n = e[c][1][t]; return r(n ? n : t)}, u, u.exports, t, e, n, o)} return n[c].exports} for (var i = "function" == typeof require && require, c = 0; c < o.length; c++)r(o[c]); return r}({1: [function (t, e, n) {function o(t, e, n) {for (n = n || document.documentElement; t && t !== n;) {if (r(t, e)) return t; t = t.parentNode} return r(t, e) ? t : null} try {var r = t("matches-selector")} catch (e) {var r = t("component-matches-selector")} e.exports = o}, {"component-matches-selector": 2, "matches-selector": 2}], 2: [function (t, e, n) {function o(t, e) {if (!t || 1 !== t.nodeType) return !1; if (c) return c.call(t, e); for (var n = r.all(e, t.parentNode), o = 0; o < n.length; ++o)if (n[o] == t) return !0; return !1} try {var r = t("query")} catch (e) {var r = t("component-query")} var i = Element.prototype, c = i.matches || i.webkitMatchesSelector || i.mozMatchesSelector || i.msMatchesSelector || i.oMatchesSelector; e.exports = o}, {"component-query": 3, query: 3}], 3: [function (t, e, n) {function o(t, e) {return e.querySelector(t)} n = e.exports = function (t, e) {return e = e || document, o(t, e)}, n.all = function (t, e) {return e = e || document, e.querySelectorAll(t)}, n.engine = function (t) {if (!t.one) throw new Error(".one callback required"); if (!t.all) throw new Error(".all callback required"); return o = t.one, n.all = t.all, n}}, {}], 4: [function (t, e, n) {function o(t, e, n, o, i) {var c = r.apply(this, arguments); return t.addEventListener(n, c, i), {destroy: function () {t.removeEventListener(n, c, i)}}} function r(t, e, n, o) {return function (n) {n.delegateTarget = i(n.target, e, !0), n.delegateTarget && o.call(t, n)}} var i = t("component-closest"); e.exports = o}, {"component-closest": 1}], 5: [function (t, e, n) {n.node = function (t) {return void 0 !== t && t instanceof HTMLElement && 1 === t.nodeType}, n.nodeList = function (t) {var e = Object.prototype.toString.call(t); return void 0 !== t && ("[object NodeList]" === e || "[object HTMLCollection]" === e) && "length" in t && (0 === t.length || n.node(t[0]))}, n.string = function (t) {return "string" == typeof t || t instanceof String}, n.fn = function (t) {var e = Object.prototype.toString.call(t); return "[object Function]" === e}}, {}], 6: [function (t, e, n) {function o(t, e, n) {if (!t && !e && !n) throw new Error("Missing required arguments"); if (!a.string(e)) throw new TypeError("Second argument must be a String"); if (!a.fn(n)) throw new TypeError("Third argument must be a Function"); if (a.node(t)) return r(t, e, n); if (a.nodeList(t)) return i(t, e, n); if (a.string(t)) return c(t, e, n); throw new TypeError("First argument must be a String, HTMLElement, HTMLCollection, or NodeList")} function r(t, e, n) {return t.addEventListener(e, n), {destroy: function () {t.removeEventListener(e, n)}}} function i(t, e, n) {return Array.prototype.forEach.call(t, function (t) {t.addEventListener(e, n)}), {destroy: function () {Array.prototype.forEach.call(t, function (t) {t.removeEventListener(e, n)})}}} function c(t, e, n) {return l(document.body, t, e, n)} var a = t("./is"), l = t("delegate"); e.exports = o}, {"./is": 5, delegate: 4}], 7: [function (t, e, n) {function o(t) {var e; if ("SELECT" === t.nodeName) t.focus(), e = t.value; else if ("INPUT" === t.nodeName || "TEXTAREA" === t.nodeName) t.focus(), t.setSelectionRange(0, t.value.length), e = t.value; else {t.hasAttribute("contenteditable") && t.focus(); var n = window.getSelection(), o = document.createRange(); o.selectNodeContents(t), n.removeAllRanges(), n.addRange(o), e = n.toString()} return e} e.exports = o}, {}], 8: [function (t, e, n) {function o() {} o.prototype = {on: function (t, e, n) {var o = this.e || (this.e = {}); return (o[t] || (o[t] = [])).push({fn: e, ctx: n}), this}, once: function (t, e, n) {function o() {r.off(t, o), e.apply(n, arguments)} var r = this; return o._ = e, this.on(t, o, n)}, emit: function (t) {var e = [].slice.call(arguments, 1), n = ((this.e || (this.e = {}))[t] || []).slice(), o = 0, r = n.length; for (o; o < r; o++)n[o].fn.apply(n[o].ctx, e); return this}, off: function (t, e) {var n = this.e || (this.e = {}), o = n[t], r = []; if (o && e) for (var i = 0, c = o.length; i < c; i++)o[i].fn !== e && o[i].fn._ !== e && r.push(o[i]); return r.length ? n[t] = r : delete n[t], this}}, e.exports = o}, {}], 9: [function (e, n, o) {!function (r, i) {if ("function" == typeof t && t.amd) t(["module", "select"], i); else if ("undefined" != typeof o) i(n, e("select")); else {var c = {exports: {}}; i(c, r.select), r.clipboardAction = c.exports} }(this, function (t, e) {"use strict"; function n(t) {return t && t.__esModule ? t : {default: t}} function o(t, e) {if (!(t instanceof e)) throw new TypeError("Cannot call a class as a function")} var r = n(e), i = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function (t) {return typeof t} : function (t) {return t && "function" == typeof Symbol && t.constructor === Symbol ? "symbol" : typeof t}, c = function () {function t(t, e) {for (var n = 0; n < e.length; n++) {var o = e[n]; o.enumerable = o.enumerable || !1, o.configurable = !0, "value" in o && (o.writable = !0), Object.defineProperty(t, o.key, o)} } return function (e, n, o) {return n && t(e.prototype, n), o && t(e, o), e}}(), a = function () {function t(e) {o(this, t), this.resolveOptions(e), this.initSelection()} return t.prototype.resolveOptions = function t() {var e = arguments.length <= 0 || void 0 === arguments[0] ? {} : arguments[0]; this.action = e.action, this.emitter = e.emitter, this.target = e.target, this.text = e.text, this.trigger = e.trigger, this.selectedText = ""}, t.prototype.initSelection = function t() {this.text ? this.selectFake() : this.target && this.selectTarget()}, t.prototype.selectFake = function t() {var e = this, n = "rtl" == document.documentElement.getAttribute("dir"); this.removeFake(), this.fakeHandlerCallback = function () {return e.removeFake()}, this.fakeHandler = document.body.addEventListener("click", this.fakeHandlerCallback) || !0, this.fakeElem = document.createElement("textarea"), this.fakeElem.style.fontSize = "12pt", this.fakeElem.style.border = "0", this.fakeElem.style.padding = "0", this.fakeElem.style.margin = "0", this.fakeElem.style.position = "absolute", this.fakeElem.style[n ? "right" : "left"] = "-9999px"; var o = window.pageYOffset || document.documentElement.scrollTop; this.fakeElem.addEventListener("focus", window.scrollTo(0, o)), this.fakeElem.style.top = o + "px", this.fakeElem.setAttribute("readonly", ""), this.fakeElem.value = this.text, document.body.appendChild(this.fakeElem), this.selectedText = (0, r.default)(this.fakeElem), this.copyText()}, t.prototype.removeFake = function t() {this.fakeHandler && (document.body.removeEventListener("click", this.fakeHandlerCallback), this.fakeHandler = null, this.fakeHandlerCallback = null), this.fakeElem && (document.body.removeChild(this.fakeElem), this.fakeElem = null)}, t.prototype.selectTarget = function t() {this.selectedText = (0, r.default)(this.target), this.copyText()}, t.prototype.copyText = function t() {var e = void 0; try {e = document.execCommand(this.action)} catch (t) {e = !1} this.handleResult(e)}, t.prototype.handleResult = function t(e) {this.emitter.emit(e ? "success" : "error", {action: this.action, text: this.selectedText, trigger: this.trigger, clearSelection: this.clearSelection.bind(this)})}, t.prototype.clearSelection = function t() {this.target && this.target.blur(), window.getSelection().removeAllRanges()}, t.prototype.destroy = function t() {this.removeFake()}, c(t, [{key: "action", set: function t() {var e = arguments.length <= 0 || void 0 === arguments[0] ? "copy" : arguments[0]; if (this._action = e, "copy" !== this._action && "cut" !== this._action) throw new Error('Invalid "action" value, use either "copy" or "cut"')}, get: function t() {return this._action}}, {key: "target", set: function t(e) {if (void 0 !== e) {if (!e || "object" !== ("undefined" == typeof e ? "undefined" : i(e)) || 1 !== e.nodeType) throw new Error('Invalid "target" value, use a valid Element'); if ("copy" === this.action && e.hasAttribute("disabled")) throw new Error('Invalid "target" attribute. Please use "readonly" instead of "disabled" attribute'); if ("cut" === this.action && (e.hasAttribute("readonly") || e.hasAttribute("disabled"))) throw new Error('Invalid "target" attribute. You can\'t cut text from elements with "readonly" or "disabled" attributes'); this._target = e} }, get: function t() {return this._target}}]), t}(); t.exports = a})}, {select: 7}], 10: [function (e, n, o) {!function (r, i) {if ("function" == typeof t && t.amd) t(["module", "./clipboard-action", "tiny-emitter", "good-listener"], i); else if ("undefined" != typeof o) i(n, e("./clipboard-action"), e("tiny-emitter"), e("good-listener")); else {var c = {exports: {}}; i(c, r.clipboardAction, r.tinyEmitter, r.goodListener), r.clipboard = c.exports} }(this, function (t, e, n, o) {"use strict"; function r(t) {return t && t.__esModule ? t : {default: t}} function i(t, e) {if (!(t instanceof e)) throw new TypeError("Cannot call a class as a function")} function c(t, e) {if (!t) throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); return !e || "object" != typeof e && "function" != typeof e ? t : e} function a(t, e) {if ("function" != typeof e && null !== e) throw new TypeError("Super expression must either be null or a function, not " + typeof e); t.prototype = Object.create(e && e.prototype, {constructor: {value: t, enumerable: !1, writable: !0, configurable: !0}}), e && (Object.setPrototypeOf ? Object.setPrototypeOf(t, e) : t.__proto__ = e)} function l(t, e) {var n = "data-clipboard-" + t; if (e.hasAttribute(n)) return e.getAttribute(n)} var s = r(e), u = r(n), f = r(o), d = function (t) {function e(n, o) {i(this, e); var r = c(this, t.call(this)); return r.resolveOptions(o), r.listenClick(n), r} return a(e, t), e.prototype.resolveOptions = function t() {var e = arguments.length <= 0 || void 0 === arguments[0] ? {} : arguments[0]; this.action = "function" == typeof e.action ? e.action : this.defaultAction, this.target = "function" == typeof e.target ? e.target : this.defaultTarget, this.text = "function" == typeof e.text ? e.text : this.defaultText}, e.prototype.listenClick = function t(e) {var n = this; this.listener = (0, f.default)(e, "click", function (t) {return n.onClick(t)})}, e.prototype.onClick = function t(e) {var n = e.delegateTarget || e.currentTarget; this.clipboardAction && (this.clipboardAction = null), this.clipboardAction = new s.default({action: this.action(n), target: this.target(n), text: this.text(n), trigger: n, emitter: this})}, e.prototype.defaultAction = function t(e) {return l("action", e)}, e.prototype.defaultTarget = function t(e) {var n = l("target", e); if (n) return document.querySelector(n)}, e.prototype.defaultText = function t(e) {return l("text", e)}, e.prototype.destroy = function t() {this.listener.destroy(), this.clipboardAction && (this.clipboardAction.destroy(), this.clipboardAction = null)}, e}(u.default); t.exports = d})}, {"./clipboard-action": 9, "good-listener": 6, "tiny-emitter": 8}]}, {}, [10])(10)}); + +(function () { + new Clipboard('.copy-full', { + text: function (trigger) {return codeWithComments;} + }); + new Clipboard('.copy-code', { + text: function (trigger) {return codeNoComments;} + }); +})(); diff --git a/static/nats-horizontal-color.svg b/static/nats-horizontal-color.svg new file mode 100644 index 00000000..bc7f5a3c --- /dev/null +++ b/static/nats-horizontal-color.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/nats.svg b/static/nats.svg new file mode 100644 index 00000000..4a44b41a --- /dev/null +++ b/static/nats.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/reset.css b/static/reset.css new file mode 100644 index 00000000..9544f4c4 --- /dev/null +++ b/static/reset.css @@ -0,0 +1,2 @@ +*,*::before,*::after{box-sizing:border-box}body,h1,h2,h3,h4,p,figure,blockquote,dl,dd{margin:0}ul[role="list"],ol[role="list"]{list-style:none}html:focus-within{scroll-behavior:smooth}body{min-height:100vh;text-rendering:optimizeSpeed;line-height:1.5}a:not([class]){text-decoration-skip-ink:auto}img,picture{max-width:100%;display:block}input,button,textarea,select{font:inherit}@media(prefers-reduced-motion:reduce){html:focus-within{scroll-behavior:auto}*,*::before,*::after{animation-duration:.01ms !important;animation-iteration-count:1 !important;transition-duration:.01ms !important;scroll-behavior:auto !important}} +