├── vendorlib └── go-mruby │ ├── go.sum │ ├── .gitignore │ ├── go.mod │ ├── .travis.yml │ ├── context_test.go │ ├── func_test.go │ ├── array.go │ ├── array_test.go │ ├── Makefile │ ├── gc_test.go │ ├── LICENSE │ ├── parser_test.go │ ├── hash.go │ ├── args.go │ ├── value_type.go │ ├── context.go │ ├── hash_test.go │ ├── class.go │ └── class_test.go ├── .github ├── FUNDING.yml ├── workflows │ ├── push-stable.yml │ ├── trieve.yml │ ├── lint.yml │ ├── docs-lint.yml │ └── benchmark.yml ├── ISSUE_TEMPLATE.md └── PULL_REQUEST_TEMPLATE.md ├── .mdlrc ├── .docker ├── passwd.nobody ├── Dockerfile.heroku ├── Dockerfile.universal ├── Dockerfile.alpine └── Dockerfile.mrb-linux-amd64 ├── diagnostics ├── diagnostics.go └── diagnostics_gops.go ├── heroku.yml ├── .devcontainer ├── devcontainer.json └── post-create.sh ├── etc ├── statsd_fake.rb ├── traefik │ ├── traefik.yml │ └── rules.toml ├── prometheus.yml ├── telegraf.conf ├── librato_printer.rb ├── HowToDockerPrometheus.md ├── envoy │ ├── Readme.md │ └── envoy.yaml ├── dns │ ├── server.rb │ └── README.md ├── ssl │ ├── server.crt │ └── server.key ├── anyt │ ├── broadcast_tests │ │ ├── whisper_test.rb │ │ └── options_test.rb │ └── broker_tests │ │ └── restore_test.rb ├── rpc.proto └── k6 │ └── echo.js ├── features ├── simple_logger.rb ├── broadcasts.benchfile ├── goroutines.benchfile ├── ping.testfile ├── shutdown.testfile ├── streams.testfile ├── mruby_printers.testfile ├── metrics.testfile ├── ping_pong.testfile ├── turbo_rpc_less.testfile └── standalone.testfile ├── utils ├── os_limits_win.go ├── os_limits.go ├── utils.go ├── gopool_test.go ├── message_verifier_test.go ├── priority_queue_test.go └── priority_queue.go ├── mocks ├── common.go ├── MockConnection.go ├── Handler.go ├── Identifier.go ├── Broadcaster.go ├── Instrumenter.go ├── Subscriber.go └── RPCServer.go ├── docs ├── health_checking.md ├── Readme.md ├── .trieve.yml ├── telemetry.md ├── tracing.md └── library.md ├── cli ├── options_test.go ├── cli_test.go ├── session_options.go └── embed.go ├── .zed └── settings.json ├── mrb ├── mrb_unsupported.go ├── mrb_test.go └── mrb.go ├── node_mocks └── SessionMatcher.go ├── server ├── health_handler.go ├── ssl_config_test.go ├── ssl_config.go ├── health_handler_test.go ├── headers_extractor.go ├── cors.go ├── config_test.go ├── headers_extractor_test.go ├── config.go └── request_info_test.go ├── metrics ├── custom_printer_unsupported.go ├── noop.go ├── gauge_test.go ├── counter_test.go ├── gauge.go ├── prometheus.go ├── config_test.go ├── counter.go ├── custom_printer.go ├── printer.go └── metrics_test.go ├── .dockerignore ├── telemetry └── config.go ├── version └── version.go ├── packaging └── npm │ ├── .gitignore │ ├── bin │ └── index.mjs │ ├── package.json │ └── install.mjs ├── sse ├── config_test.go ├── config.go ├── connection_test.go ├── connection.go └── encoder.go ├── .gitignore ├── broadcast ├── legacy_nats_test.go ├── legacy_redis_test.go └── broadcast.go ├── Gemfile ├── encoders ├── encoder.go ├── json.go ├── encoding_cache_test.go ├── json_test.go └── encoding_cache.go ├── .golangci.yml ├── rpc ├── inprocess.go ├── barrier.go └── config_test.go ├── enats ├── enats_unsupported.go └── config_test.go ├── identity └── public.go ├── nats ├── config_test.go └── config.go ├── app.json ├── node ├── config_test.go ├── disconnector.go ├── node_mocks_test.go ├── subscription_state_test.go └── controller.go ├── ws ├── config_test.go ├── ws.go ├── handler_test.go ├── config.go └── connection.go ├── lefthook.yml ├── logger ├── config.go ├── config_test.go ├── values.go └── logger.go ├── MIT-LICENSE ├── cmd ├── gobench-cable │ └── main.go ├── anycable-go │ └── main.go └── embedded-cable │ └── main.go ├── forspell.dict ├── streams └── config_test.go ├── broker ├── config_test.go └── config.go ├── hub └── gate_test.go ├── protos └── rpc.pb.grpchan.go ├── pubsub └── subscriber.go ├── stats └── stats.go └── go.mod /vendorlib/go-mruby/go.sum: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: anycable 2 | -------------------------------------------------------------------------------- /.mdlrc: -------------------------------------------------------------------------------- 1 | rules "~MD013", "~MD033", "~MD034", "~MD036", "~MD010" 2 | -------------------------------------------------------------------------------- /.docker/passwd.nobody: -------------------------------------------------------------------------------- 1 | nobody:x:65534:65534:nobody:/home:/bin/false 2 | -------------------------------------------------------------------------------- /vendorlib/go-mruby/.gitignore: -------------------------------------------------------------------------------- 1 | build_config.rb 2 | libmruby.a 3 | mruby-build 4 | -------------------------------------------------------------------------------- /vendorlib/go-mruby/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/mitchellh/go-mruby 2 | 3 | go 1.14 4 | -------------------------------------------------------------------------------- /diagnostics/diagnostics.go: -------------------------------------------------------------------------------- 1 | //go:build !gops 2 | // +build !gops 3 | 4 | package diagnostics 5 | -------------------------------------------------------------------------------- /heroku.yml: -------------------------------------------------------------------------------- 1 | build: 2 | config: 3 | ANYCABLE_GO_TAG: latest 4 | docker: 5 | web: .docker/Dockerfile.heroku 6 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "postCreateCommand": "/bin/bash ./.devcontainer/post-create.sh > ~/post-create.log" 3 | } 4 | -------------------------------------------------------------------------------- /etc/statsd_fake.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "socket" 4 | 5 | Socket.udp_server_loop(8045) do |data, src| 6 | puts data 7 | end 8 | -------------------------------------------------------------------------------- /.docker/Dockerfile.heroku: -------------------------------------------------------------------------------- 1 | ARG ANYCABLE_GO_TAG=latest 2 | 3 | FROM anycable/anycable-go:$ANYCABLE_GO_TAG 4 | LABEL maintainer="Vladimir Dementyev " 5 | -------------------------------------------------------------------------------- /features/simple_logger.rb: -------------------------------------------------------------------------------- 1 | module MetricsFormatter 2 | def self.call(data) 3 | "[#{ENV["PRINTER_NAME"]}] Connections: #{data["clients_num"]}" 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /etc/traefik/traefik.yml: -------------------------------------------------------------------------------- 1 | # traefik.yml 2 | 3 | entryPoints: 4 | web: 5 | address: ":8080" 6 | 7 | providers: 8 | file: 9 | filename: "./rules.toml" 10 | -------------------------------------------------------------------------------- /etc/prometheus.yml: -------------------------------------------------------------------------------- 1 | scrape_configs: 2 | - job_name: 'anycable' 3 | scrape_interval: 5s 4 | scrape_timeout: 2s 5 | static_configs: 6 | - targets: ['docker.for.mac.localhost:8080'] -------------------------------------------------------------------------------- /vendorlib/go-mruby/.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | sudo: required 3 | go: 4 | - "1.14" 5 | - "1.13" 6 | install: sudo apt-get install build-essential g++ bison flex 7 | script: make all staticcheck 8 | -------------------------------------------------------------------------------- /utils/os_limits_win.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | // +build windows 3 | 4 | package utils 5 | 6 | // OpenFileLimit is not supported on Windows 7 | func OpenFileLimit() string { 8 | return "unsupported" 9 | } 10 | -------------------------------------------------------------------------------- /.docker/Dockerfile.universal: -------------------------------------------------------------------------------- 1 | FROM gcr.io/distroless/static 2 | 3 | ARG TARGETPLATFORM 4 | ADD .docker/${TARGETPLATFORM}/anycable-go /usr/local/bin/anycable-go 5 | 6 | EXPOSE 8080 7 | 8 | ENTRYPOINT ["/usr/local/bin/anycable-go"] 9 | -------------------------------------------------------------------------------- /etc/telegraf.conf: -------------------------------------------------------------------------------- 1 | # Statsd Server 2 | [[inputs.statsd]] 3 | protocol = "udp" 4 | max_tcp_connections = 250 5 | tcp_keep_alive = false 6 | service_address = ":8125" 7 | 8 | [[outputs.file]] 9 | files = ["stdout"] 10 | -------------------------------------------------------------------------------- /.github/workflows/push-stable.yml: -------------------------------------------------------------------------------- 1 | name: Push Stable 2 | 3 | on: 4 | push: 5 | tags: 6 | - v1.2.* 7 | - v1.3.* 8 | - v1.4.* 9 | workflow_dispatch: 10 | 11 | jobs: 12 | push-stable: 13 | uses: anycable/github-actions/.github/workflows/push-stable.yml@master 14 | -------------------------------------------------------------------------------- /etc/librato_printer.rb: -------------------------------------------------------------------------------- 1 | module MetricsFormatter 2 | KEYS = %w(clients_num goroutines_num) 3 | 4 | def self.call(data) 5 | parts = [] 6 | 7 | data.each do |key, value| 8 | parts << "sample##{key}=#{value}" if KEYS.include?(key) 9 | end 10 | 11 | parts.join(' ') 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /mocks/common.go: -------------------------------------------------------------------------------- 1 | package mocks 2 | 3 | import "github.com/anycable/anycable-go/common" 4 | 5 | // NewMockResult builds a new result with sid as transmission 6 | func NewMockResult(sid string) *common.CommandResult { 7 | return &common.CommandResult{Transmissions: []string{sid}, Disconnect: false, StopAllStreams: false} 8 | } 9 | -------------------------------------------------------------------------------- /docs/health_checking.md: -------------------------------------------------------------------------------- 1 | # AnyCable-Go Health Checking 2 | 3 | Health check endpoint is enabled by default and accessible at `/health` path. 4 | 5 | You can configure the path via the `--health-path` option (or `ANYCABLE_HEALTH_PATH` env var). 6 | 7 | You can use this endpoint as readiness/liveness check (e.g. for load balancers). 8 | -------------------------------------------------------------------------------- /cli/options_test.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestParseTagsArg(t *testing.T) { 10 | expected := map[string]string{"env": "dev", "rev": "1.1"} 11 | 12 | headers := parseTags("env:dev,rev:1.1") 13 | assert.Equal(t, expected, headers) 14 | } 15 | -------------------------------------------------------------------------------- /.zed/settings.json: -------------------------------------------------------------------------------- 1 | // Folder-specific settings 2 | // 3 | // For a full list of overridable settings, and general information on folder-specific settings, 4 | // see the documentation: https://zed.dev/docs/configuring-zed#settings-files 5 | { 6 | "file_types": { 7 | "Ruby": ["*.testfile", "*.benchfile", "Gemfile"] 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.devcontainer/post-create.sh: -------------------------------------------------------------------------------- 1 | sudo apt-get install bison graphviz redis-server -yq --no-install-recommends 2 | redis-server --daemonize yes 3 | make prepare prepare-mruby 4 | env GO111MODULE=off go get github.com/anycable/websocket-bench 5 | env GO111MODULE=off go get github.com/google/gops 6 | env GO111MODULE=off go get github.com/evilmartians/lefthook 7 | -------------------------------------------------------------------------------- /mrb/mrb_unsupported.go: -------------------------------------------------------------------------------- 1 | //go:build (!darwin && !linux) || !mrb 2 | // +build !darwin,!linux !mrb 3 | 4 | package mrb 5 | 6 | // Supported returns true iff mruby scripting is available 7 | func Supported() bool { 8 | return false 9 | } 10 | 11 | // Version returns mruby version 12 | func Version() (string, error) { 13 | return "unknown", nil 14 | } 15 | -------------------------------------------------------------------------------- /node_mocks/SessionMatcher.go: -------------------------------------------------------------------------------- 1 | package node_mocks 2 | 3 | import ( 4 | node "github.com/anycable/anycable-go/node" 5 | ) 6 | 7 | func SessionMatcher(expected *node.Session) func(actual interface{}) bool { 8 | return func(actual interface{}) bool { 9 | act, ok := actual.(*node.Session) 10 | 11 | if !ok { 12 | return false 13 | } 14 | 15 | return expected.GetID() == act.GetID() 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /server/health_handler.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import "net/http" 4 | 5 | // https://www.youtube.com/watch?v=I_izvAbhExY 6 | var healthMsg = []byte("Ah, ha, ha, ha, stayin' alive, stayin' alive.") 7 | 8 | // HealthHandler always reponds with 200 status 9 | func HealthHandler(w http.ResponseWriter, _ *http.Request) { 10 | w.WriteHeader(http.StatusOK) 11 | w.Write(healthMsg) //nolint:errcheck 12 | } 13 | -------------------------------------------------------------------------------- /metrics/custom_printer_unsupported.go: -------------------------------------------------------------------------------- 1 | //go:build (!darwin && !linux) || !mrb 2 | // +build !darwin,!linux !mrb 3 | 4 | package metrics 5 | 6 | import ( 7 | "errors" 8 | "log/slog" 9 | ) 10 | 11 | // NewCustomPrinter generates log formatter from the provided (as path) 12 | // Ruby script 13 | func NewCustomPrinter(path string, l *slog.Logger) (*BasePrinter, error) { 14 | return nil, errors.New("unsupported") 15 | } 16 | -------------------------------------------------------------------------------- /server/ssl_config_test.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestSSLAvailable(t *testing.T) { 10 | config := NewSSLConfig() 11 | assert.False(t, config.Available()) 12 | 13 | config.CertPath = "secret.cert" 14 | assert.False(t, config.Available()) 15 | 16 | config.KeyPath = "secret.key" 17 | assert.True(t, config.Available()) 18 | } 19 | -------------------------------------------------------------------------------- /.docker/Dockerfile.alpine: -------------------------------------------------------------------------------- 1 | # https://blog.codeship.com/building-minimal-docker-containers-for-go-applications 2 | FROM alpine:latest 3 | 4 | RUN apk update && apk add ca-certificates && rm -rf /var/cache/apk/* 5 | ADD .docker/passwd.nobody /etc/passwd 6 | 7 | ARG TARGETPLATFORM 8 | ADD .docker/${TARGETPLATFORM}/anycable-go /usr/local/bin/anycable-go 9 | 10 | USER nobody 11 | 12 | EXPOSE 8080 13 | 14 | ENTRYPOINT ["/usr/local/bin/anycable-go"] 15 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | /.circleci 2 | /.git 3 | /.github 4 | /.gitignore 5 | /.travis.yml 6 | /CHANGELOG.md 7 | /DOWNLOADS.md 8 | /Godeps 9 | /Gopkg.lock 10 | /Gopkg.toml 11 | /MIT-LICENSE 12 | /Makefile 13 | /Readme.md 14 | /UPGRADE.md 15 | /cli/ 16 | /cmd/ 17 | /config/ 18 | /dependencyci.yml 19 | /dist/ 20 | /etc/ 21 | /metrics/ 22 | /mrb/ 23 | /node/ 24 | /pool/ 25 | /protos/ 26 | /pubsub/ 27 | /rpc/ 28 | /server/ 29 | /tmp/ 30 | /utils/ 31 | /vendor/ 32 | -------------------------------------------------------------------------------- /.docker/Dockerfile.mrb-linux-amd64: -------------------------------------------------------------------------------- 1 | FROM debian:bullseye-slim 2 | 3 | ARG DEBIAN_FRONTEND=noninteractive 4 | 5 | RUN apt-get update \ 6 | && apt-get -y install ca-certificates \ 7 | && apt-get clean \ 8 | && rm -rf /var/lib/apt/lists/* 9 | 10 | ARG TARGETPLATFORM=linux/amd64 11 | ADD .docker/${TARGETPLATFORM}/anycable-go /usr/local/bin/anycable-go 12 | 13 | USER nobody 14 | 15 | EXPOSE 8080 16 | 17 | ENTRYPOINT ["/usr/local/bin/anycable-go"] 18 | -------------------------------------------------------------------------------- /etc/traefik/rules.toml: -------------------------------------------------------------------------------- 1 | # rules.toml 2 | 3 | [http.routers.websocket] 4 | rule = "Host(`localhost`) && Path(`/cable`)" 5 | service = "websocket" 6 | 7 | [http.services.websocket.loadBalancer] 8 | [[http.services.websocket.loadBalancer.servers]] 9 | url = "http://localhost:8081" 10 | [[http.services.websocket.loadBalancer.servers]] 11 | url = "http://localhost:8082" 12 | [[http.services.websocket.loadBalancer.servers]] 13 | url = "http://localhost:8083" 14 | -------------------------------------------------------------------------------- /.github/workflows/trieve.yml: -------------------------------------------------------------------------------- 1 | name: Upload to Trieve 2 | 3 | on: 4 | push: 5 | tags: 6 | - v1.3.* 7 | - v1.4.* 8 | - v1.5.* 9 | branches: 10 | - master 11 | workflow_dispatch: 12 | 13 | jobs: 14 | uptrieve: 15 | uses: anycable/github-actions/.github/workflows/uptrieve.yml@master 16 | secrets: 17 | api_key: ${{ secrets.TRIEVE_API_KEY }} 18 | dataset: ${{ secrets.TRIEVE_DATASET }} 19 | with: 20 | latest_version: "v1.5" 21 | -------------------------------------------------------------------------------- /etc/HowToDockerPrometheus.md: -------------------------------------------------------------------------------- 1 | Run Prometheus with config from `etc` (assuming that you're in `anycable-go` source directory): 2 | 3 | ``` 4 | docker run -p 9090:9090 -v $(pwd)/etc:/prometheus-data prom/prometheus --config.file=/prometheus-data/prometheus.yml 5 | ``` 6 | 7 | Run Grafana: 8 | 9 | ``` 10 | docker run --name=grafana -p 3100:3000 grafana/grafana 11 | ``` 12 | 13 | And, finally, run `anycable-go` (locally): 14 | 15 | ``` 16 | anycable-go --metrics_http="/metrics" 17 | ``` 18 | -------------------------------------------------------------------------------- /vendorlib/go-mruby/context_test.go: -------------------------------------------------------------------------------- 1 | package mruby 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestCompileContextFilename(t *testing.T) { 8 | mrb := NewMrb() 9 | defer mrb.Close() 10 | 11 | ctx := NewCompileContext(mrb) 12 | defer ctx.Close() 13 | 14 | if ctx.Filename() != "" { 15 | t.Fatalf("bad filename: %s", ctx.Filename()) 16 | } 17 | 18 | ctx.SetFilename("foo") 19 | 20 | if ctx.Filename() != "foo" { 21 | t.Fatalf("bad filename: %s", ctx.Filename()) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | 9 | jobs: 10 | lint: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: actions/setup-go@v4 15 | with: 16 | go-version-file: go.mod 17 | - name: golangci-lint 18 | uses: golangci/golangci-lint-action@v3 19 | with: 20 | version: v1.61.0 21 | args: --timeout 3m 22 | -------------------------------------------------------------------------------- /server/ssl_config.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | // SSLConfig contains SSL parameters 4 | type SSLConfig struct { 5 | CertPath string `toml:"cert_path"` 6 | KeyPath string `toml:"key_path"` 7 | } 8 | 9 | // NewSSLConfig build a new SSLConfig struct 10 | func NewSSLConfig() SSLConfig { 11 | return SSLConfig{} 12 | } 13 | 14 | // Available returns true iff certificate and private keys are set 15 | func (opts *SSLConfig) Available() bool { 16 | return opts.CertPath != "" && opts.KeyPath != "" 17 | } 18 | -------------------------------------------------------------------------------- /server/health_handler_test.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestHealthHandler(t *testing.T) { 12 | req, err := http.NewRequest("GET", "/health", nil) 13 | if err != nil { 14 | t.Fatal(err) 15 | } 16 | 17 | rr := httptest.NewRecorder() 18 | handler := http.HandlerFunc(HealthHandler) 19 | 20 | handler.ServeHTTP(rr, req) 21 | 22 | assert.Equal(t, http.StatusOK, rr.Code) 23 | } 24 | -------------------------------------------------------------------------------- /.github/workflows/docs-lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint Docs 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | paths: 8 | - "**/*.md" 9 | - ".github/workflows/docs-lint.yml" 10 | pull_request: 11 | paths: 12 | - "**/*.md" 13 | - ".github/workflows/docs-lint.yml" 14 | 15 | jobs: 16 | docs-lint: 17 | uses: anycable/github-actions/.github/workflows/docs-lint.yml@master 18 | with: 19 | rubocop: false 20 | mdl-path: docs 21 | lychee-args: docs/* --exclude "(ruby|deployment|assets)/" 22 | -------------------------------------------------------------------------------- /telemetry/config.go: -------------------------------------------------------------------------------- 1 | package telemetry 2 | 3 | import "os" 4 | 5 | type Config struct { 6 | Token string 7 | Endpoint string 8 | CustomProps map[string]string 9 | Debug bool 10 | } 11 | 12 | var authToken = "secret" // make it overridable during build time 13 | 14 | func NewConfig() *Config { 15 | return &Config{ 16 | Token: authToken, 17 | Endpoint: "https://telemetry.anycable.io", 18 | Debug: os.Getenv("ANYCABLE_TELEMETRY_DEBUG") == "1", 19 | CustomProps: map[string]string{}, 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /mrb/mrb_test.go: -------------------------------------------------------------------------------- 1 | //go:build (darwin && mrb) || (linux && mrb) 2 | // +build darwin,mrb linux,mrb 3 | 4 | package mrb 5 | 6 | import ( 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestLoadString(t *testing.T) { 13 | engine := NewEngine() 14 | 15 | engine.LoadString( 16 | ` 17 | module Example 18 | def self.add(a, b) 19 | a + b 20 | end 21 | end 22 | `, 23 | ) 24 | 25 | result, err := engine.Eval("Example.add(20, 22)") 26 | 27 | assert.Nil(t, err) 28 | assert.Equal(t, 42, result.Fixnum()) 29 | } 30 | -------------------------------------------------------------------------------- /version/version.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | var ( 4 | version string 5 | modifier string 6 | sha string 7 | ) 8 | 9 | func init() { 10 | if version == "" { 11 | version = "1.5.0" 12 | } 13 | 14 | if modifier != "" { 15 | version = version + "-" + modifier 16 | } 17 | 18 | if sha != "" { 19 | version = version + "-" + sha 20 | } 21 | } 22 | 23 | // Version returns the current program version 24 | func Version() string { 25 | return version 26 | } 27 | 28 | // SHA returns the build commit sha 29 | func SHA() string { 30 | return sha 31 | } 32 | -------------------------------------------------------------------------------- /utils/os_limits.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | // +build !windows 3 | 4 | package utils 5 | 6 | import ( 7 | "fmt" 8 | "syscall" 9 | ) 10 | 11 | // OpenFileLimit returns a string displaying the current open file limit 12 | // for the process or unknown if it's not possible to detect it 13 | func OpenFileLimit() (limitStr string) { 14 | var rLimit syscall.Rlimit 15 | 16 | if err := syscall.Getrlimit(syscall.RLIMIT_NOFILE, &rLimit); err != nil { 17 | limitStr = "unknown" 18 | } else { 19 | limitStr = fmt.Sprintf("%d", rLimit.Cur) 20 | } 21 | 22 | return 23 | } 24 | -------------------------------------------------------------------------------- /vendorlib/go-mruby/func_test.go: -------------------------------------------------------------------------------- 1 | package mruby 2 | 3 | import "testing" 4 | 5 | func testCallback(m *Mrb, self *MrbValue) (Value, Value) { 6 | return Int(42), nil 7 | } 8 | 9 | func testCallbackResult(t *testing.T, v *MrbValue) { 10 | if v.Type() != TypeFixnum { 11 | t.Fatalf("bad type: %d", v.Type()) 12 | } 13 | 14 | if v.Fixnum() != 42 { 15 | t.Fatalf("bad: %d", v.Fixnum()) 16 | } 17 | } 18 | 19 | func testCallbackException(m *Mrb, self *MrbValue) (Value, Value) { 20 | _, e := m.LoadString(`raise 'Exception'`) 21 | v := e.(*Exception) 22 | return nil, v.MrbValue 23 | } 24 | -------------------------------------------------------------------------------- /packaging/npm/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | /dist 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | *-lock.json 38 | 39 | bin/* 40 | !bin/index.mjs 41 | -------------------------------------------------------------------------------- /metrics/noop.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | type NoopMetrics struct { 4 | } 5 | 6 | func (NoopMetrics) CounterIncrement(name string) { 7 | } 8 | 9 | func (NoopMetrics) CounterAdd(name string, val uint64) { 10 | } 11 | 12 | func (NoopMetrics) GaugeSet(name string, val uint64) { 13 | } 14 | 15 | func (NoopMetrics) GaugeIncrement(name string) { 16 | } 17 | 18 | func (NoopMetrics) GaugeDecrement(name string) { 19 | } 20 | 21 | func (NoopMetrics) RegisterCounter(name string, desc string) { 22 | } 23 | 24 | func (NoopMetrics) RegisterGauge(name string, desc string) { 25 | } 26 | 27 | var _ Instrumenter = (*NoopMetrics)(nil) 28 | -------------------------------------------------------------------------------- /sse/config_test.go: -------------------------------------------------------------------------------- 1 | package sse 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/BurntSushi/toml" 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestConfig_ToToml(t *testing.T) { 12 | conf := NewConfig() 13 | conf.Path = "/events" 14 | 15 | tomlStr := conf.ToToml() 16 | 17 | assert.Contains(t, tomlStr, "path = \"/events\"") 18 | assert.Contains(t, tomlStr, "# enabled = true") 19 | 20 | // Round-trip test 21 | conf2 := Config{} 22 | 23 | _, err := toml.Decode(tomlStr, &conf2) 24 | require.NoError(t, err) 25 | 26 | assert.Equal(t, conf, conf2) 27 | } 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Numerous always-ignore extensions 2 | *.diff 3 | *.err 4 | *.orig 5 | *.log 6 | *.rej 7 | *.swo 8 | *.swp 9 | *.vi 10 | *~ 11 | *.sass-cache 12 | *.iml 13 | .idea/ 14 | 15 | # Sublime 16 | *.sublime-project 17 | *.sublime-workspace 18 | 19 | # OS or Editor folders 20 | .DS_Store 21 | .cache 22 | .project 23 | .settings 24 | .tmproj 25 | Thumbs.db 26 | 27 | .bundle/ 28 | 29 | .docker/* 30 | !.docker/*.nodoby 31 | !.docker/Dockerfile* 32 | 33 | log/*.log 34 | *.gz 35 | 36 | tmp/ 37 | coverage/ 38 | dist/ 39 | 40 | debug* 41 | .vscode/ 42 | 43 | Gemfile.lock 44 | /k6 45 | 46 | /coverage.out 47 | admin/ui/build 48 | -------------------------------------------------------------------------------- /broadcast/legacy_nats_test.go: -------------------------------------------------------------------------------- 1 | package broadcast 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/BurntSushi/toml" 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestLegacyNATSConfig__ToToml(t *testing.T) { 12 | conf := NewLegacyNATSConfig() 13 | conf.Channel = "_test_" 14 | 15 | tomlStr := conf.ToToml() 16 | 17 | assert.Contains(t, tomlStr, "channel = \"_test_\"") 18 | 19 | // Round-trip test 20 | conf2 := NewLegacyNATSConfig() 21 | 22 | _, err := toml.Decode(tomlStr, &conf2) 23 | require.NoError(t, err) 24 | 25 | assert.Equal(t, conf, conf2) 26 | } 27 | -------------------------------------------------------------------------------- /broadcast/legacy_redis_test.go: -------------------------------------------------------------------------------- 1 | package broadcast 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/BurntSushi/toml" 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestLegacyRedisConfig__ToToml(t *testing.T) { 12 | conf := NewLegacyRedisConfig() 13 | conf.Channel = "_test_" 14 | 15 | tomlStr := conf.ToToml() 16 | 17 | assert.Contains(t, tomlStr, "channel = \"_test_\"") 18 | 19 | // Round-trip test 20 | conf2 := NewLegacyRedisConfig() 21 | 22 | _, err := toml.Decode(tomlStr, &conf2) 23 | require.NoError(t, err) 24 | 25 | assert.Equal(t, conf, conf2) 26 | } 27 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 5 | 6 | ### Tell us about your environment 7 | 8 | **AnyCable-Go version:** 9 | 10 | **AnyCable gem version:** 11 | 12 | ### What did you do? 13 | 14 | ### What did you expect to happen? 15 | 16 | ### What actually happened? 17 | 18 | 21 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # Gemfile for running conformance tests with Anyt 2 | source "https://rubygems.org" 3 | 4 | gem "nats-pure", "< 2.3.0" 5 | gem "colorize" 6 | gem "puma" 7 | 8 | gem "activesupport", "~> 7.0.0" 9 | 10 | if File.directory?(File.join(__dir__, "../anycable")) 11 | $stdout.puts "\n=== Using local gems for Anyt ===\n\n" 12 | gem "debug" 13 | gem "anycable", path: "../anycable" 14 | gem "anycable-rails", path: "../anycable-rails" 15 | gem "anyt", path: "../anyt" 16 | gem "wsdirector-cli", path: "../wsdirector" 17 | else 18 | gem "anycable" 19 | gem "anycable-rails" 20 | gem "anyt" 21 | gem "wsdirector-cli" 22 | end 23 | -------------------------------------------------------------------------------- /docs/Readme.md: -------------------------------------------------------------------------------- 1 | # AnyCable-Go 2 | 3 | **NOTE:** For better experience, go to the [official documentation website](https://docs.anycable.io). 4 | 5 | * [Getting Started](getting_started.md) 6 | * [Configuration](configuration.md) 7 | * [Instrumentation](instrumentation.md) 8 | * [Health Checking](health_checking.md) 9 | * [Tracing](tracing.md) 10 | * [OS Tuning](os_tuning.md) 11 | * [Apollo GraphQL](apollo.md) 12 | * [Binary formats](binary_formats.md) 13 | * [JWT identification](jwt_identification.md) 14 | * [Signed streams](signed_streams.md) 15 | * [Presence](presence.md) 16 | * [Embedded NATS](embedded_nats.md) 17 | * [Using as a library](library.md) 18 | -------------------------------------------------------------------------------- /encoders/encoder.go: -------------------------------------------------------------------------------- 1 | package encoders 2 | 3 | import ( 4 | "github.com/anycable/anycable-go/common" 5 | "github.com/anycable/anycable-go/ws" 6 | ) 7 | 8 | type EncodedMessage interface { 9 | GetType() string 10 | } 11 | 12 | var _ EncodedMessage = (*common.Reply)(nil) 13 | var _ EncodedMessage = (*common.PingMessage)(nil) 14 | var _ EncodedMessage = (*common.DisconnectMessage)(nil) 15 | 16 | type Encoder interface { 17 | ID() string 18 | Encode(msg EncodedMessage) (*ws.SentFrame, error) 19 | EncodeTransmission(msg string) (*ws.SentFrame, error) 20 | Decode(payload []byte) (*common.Message, error) 21 | } 22 | 23 | var _ Encoder = (*JSON)(nil) 24 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | # See: https://golangci-lint.run/usage/linters/ 2 | 3 | issues: 4 | exclude-dirs: 5 | - "tmp" 6 | - "vendor" 7 | 8 | linters: 9 | enable: 10 | - decorder 11 | - errname 12 | - gocritic 13 | - gofmt 14 | - gosec 15 | - govet 16 | - grouper 17 | - misspell 18 | - stylecheck 19 | - tenv 20 | - unconvert 21 | 22 | linters-settings: 23 | govet: 24 | enable-all: true 25 | disable: 26 | - fieldalignment 27 | 28 | gosec: 29 | severity: medium 30 | confidence: medium 31 | excludes: 32 | - G115 33 | 34 | stylecheck: 35 | checks: ["all", "-ST1005", "-ST1003"] 36 | -------------------------------------------------------------------------------- /rpc/inprocess.go: -------------------------------------------------------------------------------- 1 | package rpc 2 | 3 | import ( 4 | "log/slog" 5 | 6 | "github.com/fullstorydev/grpchan" 7 | "github.com/fullstorydev/grpchan/inprocgrpc" 8 | 9 | pb "github.com/anycable/anycable-go/protos" 10 | ) 11 | 12 | func NewInprocessServiceDialer(service pb.RPCServer, stateHandler ClientHelper) Dialer { 13 | handlers := grpchan.HandlerMap{} 14 | inproc := &inprocgrpc.Channel{} 15 | 16 | pb.RegisterHandlerRPC(handlers, service) 17 | handlers.ForEach(inproc.RegisterService) 18 | 19 | return func(c *Config, l *slog.Logger) (pb.RPCClient, ClientHelper, error) { 20 | inprocClient := pb.NewRPCChannelClient(inproc) 21 | return inprocClient, stateHandler, nil 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | 13 | 29 | -------------------------------------------------------------------------------- /metrics/gauge_test.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import ( 4 | "sync" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestGauge(t *testing.T) { 11 | g := NewGauge("test", "") 12 | assert.Equal(t, uint64(0), g.Value()) 13 | g.Set(20) 14 | assert.Equal(t, uint64(20), g.Value()) 15 | } 16 | 17 | func TestGaugeIncDec(t *testing.T) { 18 | g := NewGauge("test", "") 19 | 20 | var wg sync.WaitGroup 21 | 22 | for i := 0; i < 20; i++ { 23 | wg.Add(1) 24 | 25 | go func() { 26 | g.Inc() 27 | wg.Done() 28 | }() 29 | } 30 | 31 | for i := 0; i < 13; i++ { 32 | wg.Add(1) 33 | 34 | go func() { 35 | g.Dec() 36 | wg.Done() 37 | }() 38 | } 39 | 40 | wg.Wait() 41 | 42 | assert.Equal(t, uint64(7), g.Value()) 43 | } 44 | -------------------------------------------------------------------------------- /packaging/npm/bin/index.mjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { spawnSync } from "child_process"; 4 | import path from "path"; 5 | import fs from "fs"; 6 | import { install } from "../install.mjs"; 7 | import { fileURLToPath } from "url"; 8 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); 9 | 10 | const extension = ["win32", "cygwin"].includes(process.platform) ? ".exe" : ""; 11 | const exePath = path.join(__dirname, `anycable-go${extension}`); 12 | 13 | // Check if binary exists and download if not 14 | if (!fs.existsSync(exePath)) { 15 | console.log("Installing AnyCable binary..."); 16 | await install(); 17 | console.log("Installed!"); 18 | } 19 | 20 | var command_args = process.argv.slice(2); 21 | 22 | spawnSync(exePath, command_args, { stdio: "inherit" }); 23 | -------------------------------------------------------------------------------- /enats/enats_unsupported.go: -------------------------------------------------------------------------------- 1 | //go:build freebsd && !amd64 2 | // +build freebsd,!amd64 3 | 4 | package enats 5 | 6 | import ( 7 | "context" 8 | "errors" 9 | "log/slog" 10 | ) 11 | 12 | // NewConfig returns defaults for NATSServiceConfig 13 | func NewConfig() Config { 14 | return Config{} 15 | } 16 | 17 | type Service struct{} 18 | 19 | func (Service) Description() string { return "" } 20 | func (Service) Start() error { 21 | return errors.New("embedded NATS is not supported for the current platform") 22 | } 23 | func (Service) Shutdown(ctx context.Context) error { return nil } 24 | 25 | func NewService(c *Config, l *slog.Logger) *Service { 26 | return &Service{} 27 | } 28 | 29 | func (s *Service) WaitJetStreamReady(v int) error { 30 | return errors.New("not implemented") 31 | } 32 | -------------------------------------------------------------------------------- /server/headers_extractor.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "net" 5 | "net/http" 6 | "strings" 7 | ) 8 | 9 | type HeadersExtractor interface { 10 | FromRequest(r *http.Request) map[string]string 11 | } 12 | 13 | type DefaultHeadersExtractor struct { 14 | Headers []string 15 | Cookies []string 16 | } 17 | 18 | func (h *DefaultHeadersExtractor) FromRequest(r *http.Request) map[string]string { 19 | res := make(map[string]string) 20 | 21 | for _, header := range h.Headers { 22 | value := r.Header.Get(header) 23 | if strings.ToLower(header) == "cookie" { 24 | value = parseCookies(value, h.Cookies) 25 | } 26 | 27 | if value != "" { 28 | res[header] = value 29 | } 30 | } 31 | res[remoteAddrHeader], _, _ = net.SplitHostPort(r.RemoteAddr) 32 | 33 | return res 34 | } 35 | -------------------------------------------------------------------------------- /features/broadcasts.benchfile: -------------------------------------------------------------------------------- 1 | launch :rpc, "bundle exec anyt --only-rpc" 2 | wait_tcp 50051 3 | 4 | launch :anycable, "./dist/anycable-go" 5 | wait_tcp 8080 6 | 7 | BENCHMARK_COMMAND = <<~CMD 8 | websocket-bench broadcast --concurrent 40 --sample-size 100 \ 9 | --step-size 200 --payload-padding 200 --total-steps 4 \ 10 | --wait-broadcasts 30 \ 11 | ws://localhost:8080/cable --server-type=actioncable 12 | CMD 13 | 14 | run :bench, BENCHMARK_COMMAND 15 | 16 | result = stdout(:bench) 17 | 18 | if result =~ /Missing received broadcasts: expected (\d+), got (\d+)/ 19 | expected = Regexp.last_match[1].to_i 20 | actual = Regexp.last_match[2].to_i 21 | 22 | if (actual / expected.to_f) < 0.9 23 | fail "Received less than 90% of expected broadcasts: #{actual} / #{expected}" 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /cli/cli_test.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/stretchr/testify/require" 8 | "github.com/urfave/cli/v2" 9 | ) 10 | 11 | func TestCliConfig(t *testing.T) { 12 | _, err, _ := NewConfigFromCLI([]string{"-h"}) 13 | require.NoError(t, err) 14 | } 15 | 16 | func TestCliConfigCustom(t *testing.T) { 17 | var custom string 18 | 19 | _, err, _ := NewConfigFromCLI( 20 | []string{"", "-custom=foo"}, 21 | WithCLICustomOptions(func() ([]cli.Flag, error) { 22 | flag := &cli.StringFlag{ 23 | Name: "custom", 24 | Destination: &custom, 25 | Value: "bar", 26 | Required: false, 27 | } 28 | return []cli.Flag{flag}, nil 29 | }), 30 | ) 31 | 32 | require.NoError(t, err) 33 | assert.Equal(t, "foo", custom) 34 | } 35 | -------------------------------------------------------------------------------- /identity/public.go: -------------------------------------------------------------------------------- 1 | package identity 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/anycable/anycable-go/common" 7 | ) 8 | 9 | // PublicIdentifier identifies all clients and use their sid as the only identifier 10 | type PublicIdentifier struct { 11 | } 12 | 13 | var _ Identifier = (*PublicIdentifier)(nil) 14 | 15 | func NewPublicIdentifier() *PublicIdentifier { 16 | return &PublicIdentifier{} 17 | } 18 | 19 | func (pi *PublicIdentifier) Identify(sid string, env *common.SessionEnv) (*common.ConnectResult, error) { 20 | return &common.ConnectResult{ 21 | Identifier: publicIdentifiers(sid), 22 | Transmissions: []string{actionCableWelcomeMessage(sid)}, 23 | Status: common.SUCCESS, 24 | }, nil 25 | } 26 | 27 | func publicIdentifiers(sid string) string { 28 | return fmt.Sprintf(`{"sid":"%s"}`, sid) 29 | } 30 | -------------------------------------------------------------------------------- /nats/config_test.go: -------------------------------------------------------------------------------- 1 | package nats 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/BurntSushi/toml" 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestNATSConfig_ToToml(t *testing.T) { 12 | conf := NewNATSConfig() 13 | conf.Servers = "nats://localhost:4222" 14 | conf.DontRandomizeServers = true 15 | conf.MaxReconnectAttempts = 10 16 | 17 | tomlStr := conf.ToToml() 18 | 19 | assert.Contains(t, tomlStr, "servers = \"nats://localhost:4222\"") 20 | assert.Contains(t, tomlStr, "dont_randomize_servers = true") 21 | assert.Contains(t, tomlStr, "max_reconnect_attempts = 10") 22 | 23 | // Round-trip test 24 | conf2 := NewNATSConfig() 25 | 26 | _, err := toml.Decode(tomlStr, &conf2) 27 | require.NoError(t, err) 28 | 29 | assert.Equal(t, conf, conf2) 30 | } 31 | -------------------------------------------------------------------------------- /metrics/counter_test.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestCounter(t *testing.T) { 10 | cnt := NewCounter("test", "") 11 | assert.Equal(t, uint64(0), cnt.IntervalValue()) 12 | for i := 0; i < 1000; i++ { 13 | cnt.Inc() 14 | } 15 | assert.Equal(t, uint64(1000), cnt.Value()) 16 | cnt.Add(500) 17 | assert.Equal(t, uint64(1500), cnt.Value()) 18 | cnt.UpdateDelta() 19 | cnt.Inc() 20 | assert.Equal(t, uint64(1501), cnt.Value()) 21 | assert.Equal(t, uint64(1500), cnt.IntervalValue()) 22 | cnt.UpdateDelta() 23 | assert.Equal(t, uint64(1501), cnt.Value()) 24 | assert.Equal(t, uint64(1), cnt.IntervalValue()) 25 | cnt.UpdateDelta() 26 | assert.Equal(t, uint64(1501), cnt.Value()) 27 | assert.Equal(t, uint64(0), cnt.IntervalValue()) 28 | } 29 | -------------------------------------------------------------------------------- /vendorlib/go-mruby/array.go: -------------------------------------------------------------------------------- 1 | package mruby 2 | 3 | // #include "gomruby.h" 4 | import "C" 5 | 6 | // Array represents an MrbValue that is a Array in Ruby. 7 | // 8 | // A Array can be obtained by calling the Array function on MrbValue. 9 | type Array struct { 10 | *MrbValue 11 | } 12 | 13 | // Len returns the length of the array. 14 | func (v *Array) Len() int { 15 | return int(C.mrb_ary_len(v.state, v.value)) 16 | } 17 | 18 | // Get gets an element form the Array by index. 19 | // 20 | // This does not copy the element. This is a pointer/reference directly 21 | // to the element in the array. 22 | func (v *Array) Get(idx int) (*MrbValue, error) { 23 | result := C.mrb_ary_entry(v.value, C.mrb_int(idx)) 24 | 25 | val := newValue(v.state, result) 26 | if val.Type() == TypeNil { 27 | val = nil 28 | } 29 | 30 | return val, nil 31 | } 32 | -------------------------------------------------------------------------------- /packaging/npm/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@anycable/anycable-go", 3 | "version": "1.5.6", 4 | "description": "A wrapper to install and run AnyCable server", 5 | "main": "index.js", 6 | "bin": { 7 | "anycable-go": "./bin/index.mjs" 8 | }, 9 | "engines": { 10 | "node": ">=20.0.0" 11 | }, 12 | "repository": "https://github.com/anycable/anycable-go", 13 | "keywords": [ 14 | "websockets", 15 | "anycable", 16 | "real-times" 17 | ], 18 | "author": "Vladimir Dementyev", 19 | "license": "MIT", 20 | "bugs": { 21 | "url": "https://github.com/anycable/anycable-go/issues" 22 | }, 23 | "homepage": "https://anycable.io", 24 | "os": [ 25 | "darwin", 26 | "linux", 27 | "win32" 28 | ], 29 | "cpu": [ 30 | "x64", 31 | "arm64" 32 | ], 33 | "scripts": {}, 34 | "dependencies": { 35 | "node-downloader-helper": "^1.0.18" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /vendorlib/go-mruby/array_test.go: -------------------------------------------------------------------------------- 1 | package mruby 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestArray(t *testing.T) { 8 | mrb := NewMrb() 9 | defer mrb.Close() 10 | 11 | value, err := mrb.LoadString(`["foo", "bar", "baz", false]`) 12 | if err != nil { 13 | t.Fatalf("err: %s", err) 14 | } 15 | 16 | v := value.Array() 17 | 18 | // Len 19 | if n := v.Len(); n != 4 { 20 | t.Fatalf("bad: %d", n) 21 | } 22 | 23 | // Get 24 | value, err = v.Get(1) 25 | if err != nil { 26 | t.Fatalf("err: %s", err) 27 | } 28 | if value.String() != "bar" { 29 | t.Fatalf("bad: %s", value) 30 | } 31 | 32 | // Get bool 33 | value, err = v.Get(3) 34 | if err != nil { 35 | t.Fatalf("err: %s", err) 36 | } 37 | if valType := value.Type(); valType != TypeFalse { 38 | t.Fatalf("bad type: %v", valType) 39 | } 40 | if value.String() != "false" { 41 | t.Fatalf("bad: %s", value) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /features/goroutines.benchfile: -------------------------------------------------------------------------------- 1 | launch :rpc, "bundle exec anyt --only-rpc" 2 | wait_tcp 50051 3 | 4 | launch :anycable, "./dist/anycable-go" 5 | wait_tcp 8080 6 | 7 | BENCHMARK_COMMAND = <<~CMD 8 | websocket-bench broadcast --concurrent 10 --sample-size 100 \ 9 | --step-size 200 --payload-padding 200 --total-steps 3 \ 10 | ws://localhost:8080/cable --server-type=actioncable 11 | CMD 12 | 13 | IDLE_ROUTINES_MAX = 200 14 | 15 | results = 2.times.map do |i| 16 | run :bench, BENCHMARK_COMMAND 17 | # Give some time to cool down after the benchmark 18 | sleep 5 19 | 20 | gops(pid(:anycable))["goroutines"].tap do |num| 21 | fail "Failed to gops process: #{pid(:anycable)}" unless num 22 | 23 | if num > IDLE_ROUTINES_MAX 24 | fail "Too many goroutines: #{num}" 25 | end 26 | end 27 | end 28 | 29 | if (results[1] / results[0].to_f) > 1.1 30 | fail "Go routines leak detected: #{results[0]} -> #{results[1]}" 31 | end 32 | -------------------------------------------------------------------------------- /encoders/json.go: -------------------------------------------------------------------------------- 1 | package encoders 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/anycable/anycable-go/common" 7 | "github.com/anycable/anycable-go/ws" 8 | ) 9 | 10 | const jsonEncoderID = "json" 11 | 12 | type JSON struct { 13 | } 14 | 15 | func (JSON) ID() string { 16 | return jsonEncoderID 17 | } 18 | 19 | func (JSON) Encode(msg EncodedMessage) (*ws.SentFrame, error) { 20 | b, err := json.Marshal(&msg) 21 | if err != nil { 22 | panic("Failed to build JSON 😲") 23 | } 24 | return &ws.SentFrame{FrameType: ws.TextFrame, Payload: b}, nil 25 | } 26 | 27 | func (JSON) EncodeTransmission(msg string) (*ws.SentFrame, error) { 28 | return &ws.SentFrame{FrameType: ws.TextFrame, Payload: []byte(msg)}, nil 29 | } 30 | 31 | func (JSON) Decode(raw []byte) (*common.Message, error) { 32 | msg := &common.Message{} 33 | 34 | if err := json.Unmarshal(raw, &msg); err != nil { 35 | return nil, err 36 | } 37 | 38 | return msg, nil 39 | } 40 | -------------------------------------------------------------------------------- /encoders/encoding_cache_test.go: -------------------------------------------------------------------------------- 1 | package encoders 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/anycable/anycable-go/ws" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | type MockMessage struct { 11 | Encoded int 12 | Value string 13 | } 14 | 15 | func (m *MockMessage) GetType() string { 16 | return "mock" 17 | } 18 | 19 | func TestEncodingCache(t *testing.T) { 20 | msg := &MockMessage{Value: "mock"} 21 | 22 | c := NewEncodingCache() 23 | 24 | callback := func(msg EncodedMessage) (*ws.SentFrame, error) { 25 | if m, ok := msg.(*MockMessage); ok { 26 | m.Encoded++ 27 | return &ws.SentFrame{FrameType: ws.TextFrame, Payload: []byte(m.Value)}, nil 28 | } 29 | return nil, nil 30 | } 31 | 32 | v, _ := c.Fetch(msg, "mock", callback) 33 | assert.Equal(t, []byte("mock"), v.Payload) 34 | 35 | v, _ = c.Fetch(msg, "mock", callback) 36 | assert.Equal(t, []byte("mock"), v.Payload) 37 | assert.Equal(t, 1, msg.Encoded) 38 | } 39 | -------------------------------------------------------------------------------- /features/ping.testfile: -------------------------------------------------------------------------------- 1 | launch :rpc, "bundle exec anyt --only-rpc" 2 | wait_tcp 50051 3 | 4 | launch :anycable, 5 | "./dist/anycable-go --ping_interval=10" 6 | wait_tcp 8080 7 | 8 | scenario = [ 9 | client: { 10 | multiplier: 1, 11 | connection_options: { 12 | query: { 13 | pi: "1" 14 | } 15 | }, 16 | actions: [ 17 | { 18 | receive: { 19 | "data>": { 20 | type: "welcome" 21 | } 22 | } 23 | }, 24 | { 25 | receive: { 26 | "data>": { 27 | type: "ping" 28 | } 29 | } 30 | } 31 | ] 32 | } 33 | ] 34 | 35 | TEST_COMMAND = <<~CMD 36 | bundle exec wsdirector ws://localhost:8080/cable -i #{scenario.to_json} 37 | CMD 38 | 39 | run :wsdirector, TEST_COMMAND 40 | 41 | result = stdout(:wsdirector) 42 | 43 | if result !~ /1 clients, 0 failures/ 44 | fail "Unexpected scenario result:\n#{result}" 45 | end 46 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "anycable-go", 3 | "description": "Performant and feature-rich WebSocket server for Rails and Ruby applications.", 4 | "keywords": ["websockets", "rails", "hotwire"], 5 | "repository": "https://github.com/anycable/anycable-go", 6 | "logo": "https://docs.anycable.io/assets/images/logo.svg", 7 | "stack": "container", 8 | "env": { 9 | "ANYCABLE_RPC_IMPL": { 10 | "description": "RPC implementation (use HTTP for simplified Heroku deployments)", 11 | "value": "http", 12 | "required": true 13 | }, 14 | "ANYCABLE_RPC_HOST": { 15 | "description": "URL of the HTTP RPC embedded into your web application", 16 | "value": "http", 17 | "required": true 18 | }, 19 | "ANYCABLE_SECRET": { 20 | "description": "AnyCable secret used to secure HTTP RPC and other features (must be configured for both applications)", 21 | "value": "", 22 | "required": false 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /node/config_test.go: -------------------------------------------------------------------------------- 1 | package node 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/BurntSushi/toml" 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestConfig_ToToml(t *testing.T) { 12 | conf := NewConfig() 13 | conf.DisconnectMode = "always" 14 | conf.HubGopoolSize = 100 15 | conf.PingTimestampPrecision = "ns" 16 | conf.ShutdownDisconnectPoolSize = 1024 17 | 18 | tomlStr := conf.ToToml() 19 | 20 | assert.Contains(t, tomlStr, "disconnect_mode = \"always\"") 21 | assert.Contains(t, tomlStr, "broadcast_gopool_size = 100") 22 | assert.Contains(t, tomlStr, "ping_timestamp_precision = \"ns\"") 23 | assert.Contains(t, tomlStr, "# pong_timeout = 6") 24 | assert.Contains(t, tomlStr, "shutdown_disconnect_gopool_size = 1024") 25 | 26 | // Round-trip test 27 | conf2 := NewConfig() 28 | 29 | _, err := toml.Decode(tomlStr, &conf2) 30 | require.NoError(t, err) 31 | 32 | assert.Equal(t, conf, conf2) 33 | } 34 | -------------------------------------------------------------------------------- /etc/envoy/Readme.md: -------------------------------------------------------------------------------- 1 | # Using AnyCable-Go with Envoy 2 | 3 | **NOTE:** The example configuration works with v1.16, but no longer valid for the latest versions of Envoy. PRs are welcomed! 4 | 5 | [Envoy](https://www.envoyproxy.io) is a modern proxy service which support HTTP2 and gRPC. 6 | 7 | We can use Envoy for load balancing and zero-disconnect deployments. 8 | 9 | ## Running an example 10 | 11 | Launch 2 RPC servers: 12 | 13 | ```sh 14 | # first 15 | bundle exec anycable --rpc-host="0.0.0.0:50060" 16 | 17 | # second 18 | bundle exec anycable --rpc-host="0.0.0.0:50061" 19 | ``` 20 | 21 | Run Envoy via the Docker image (from the current directory): 22 | 23 | ```sh 24 | docker run --rm -p 50051:50051 -v $(pwd):/etc/envoy envoyproxy/envoy:v1.16.1 25 | ``` 26 | 27 | Now you can access AnyCable RPC service at `:50051`. 28 | 29 | Try to restart Ruby processes one by one and see how this affects WebSocket connections (spoiler: they stay connected, no RPC errors in `anycable-go`). 30 | -------------------------------------------------------------------------------- /ws/config_test.go: -------------------------------------------------------------------------------- 1 | package ws 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/BurntSushi/toml" 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestConfig_ToToml(t *testing.T) { 12 | conf := NewConfig() 13 | conf.Paths = []string{"/ws", "/socket"} 14 | conf.ReadBufferSize = 2048 15 | conf.WriteBufferSize = 2048 16 | conf.MaxMessageSize = 131072 17 | conf.EnableCompression = true 18 | 19 | tomlStr := conf.ToToml() 20 | 21 | assert.Contains(t, tomlStr, "paths = [\"/ws\", \"/socket\"]") 22 | assert.Contains(t, tomlStr, "read_buffer_size = 2048") 23 | assert.Contains(t, tomlStr, "write_buffer_size = 2048") 24 | assert.Contains(t, tomlStr, "max_message_size = 131072") 25 | assert.Contains(t, tomlStr, "enable_compression = true") 26 | 27 | // Round-trip test 28 | conf2 := Config{} 29 | 30 | _, err := toml.Decode(tomlStr, &conf2) 31 | require.NoError(t, err) 32 | 33 | assert.Equal(t, conf, conf2) 34 | } 35 | -------------------------------------------------------------------------------- /server/cors.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "net/http" 5 | "net/url" 6 | "strings" 7 | ) 8 | 9 | func WriteCORSHeaders(w http.ResponseWriter, r *http.Request, origins []string) { 10 | if len(origins) == 0 { 11 | w.Header().Set("Access-Control-Allow-Origin", "*") 12 | } else { 13 | origin := strings.ToLower(r.Header.Get("Origin")) 14 | u, err := url.Parse(origin) 15 | if err == nil { 16 | for _, host := range origins { 17 | if host[0] == '*' && strings.HasSuffix(u.Host, host[1:]) { 18 | w.Header().Set("Access-Control-Allow-Origin", origin) 19 | } 20 | if u.Host == host { 21 | w.Header().Set("Access-Control-Allow-Origin", origin) 22 | } 23 | } 24 | } 25 | } 26 | 27 | w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS") 28 | w.Header().Set("Access-Control-Allow-Credentials", "true") 29 | w.Header().Set("Access-Control-Allow-Headers", "Origin, X-Requested-With, X-Request-ID, Content-Type, Accept, X-CSRF-Token, Authorization") 30 | } 31 | -------------------------------------------------------------------------------- /lefthook.yml: -------------------------------------------------------------------------------- 1 | # EXAMPLE USAGE: 2 | # 3 | # Refer for explanation to following link: 4 | # https://github.com/evilmartians/lefthook/blob/master/docs/full_guide.md 5 | # 6 | # pre-push: 7 | # commands: 8 | # packages-audit: 9 | # tags: frontend security 10 | # run: yarn audit 11 | # gems-audit: 12 | # tags: backend security 13 | # run: bundle audit 14 | # 15 | # pre-commit: 16 | # parallel: true 17 | # commands: 18 | # eslint: 19 | # glob: "*.{js,ts,jsx,tsx}" 20 | # run: yarn eslint {staged_files} 21 | # rubocop: 22 | # tags: backend style 23 | # glob: "*.rb" 24 | # exclude: "application.rb|routes.rb" 25 | # run: bundle exec rubocop --force-exclusion {all_files} 26 | # govet: 27 | # tags: backend style 28 | # files: git ls-files -m 29 | # glob: "*.go" 30 | # run: go vet {files} 31 | # scripts: 32 | # "hello.js": 33 | # runner: node 34 | # "any.go": 35 | # runner: go run 36 | -------------------------------------------------------------------------------- /logger/config.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | type Config struct { 9 | LogLevel string `toml:"level"` 10 | LogFormat string `toml:"format"` 11 | Debug bool `toml:"debug"` 12 | } 13 | 14 | func NewConfig() Config { 15 | return Config{ 16 | LogLevel: "info", 17 | LogFormat: "text", 18 | } 19 | } 20 | 21 | func (c Config) ToToml() string { 22 | var result strings.Builder 23 | 24 | result.WriteString("# Logging level (debug, info, warn, error)\n") 25 | result.WriteString(fmt.Sprintf("level = \"%s\"\n", c.LogLevel)) 26 | 27 | result.WriteString("# Logs formatting (e.g., 'text' or 'json')\n") 28 | result.WriteString(fmt.Sprintf("format = \"%s\"\n", c.LogFormat)) 29 | 30 | result.WriteString("# Enable debug (verbose) logging\n") 31 | if c.Debug { 32 | result.WriteString("debug = true\n") 33 | } else { 34 | result.WriteString("# debug = true\n") 35 | } 36 | 37 | result.WriteString("\n") 38 | 39 | return result.String() 40 | } 41 | -------------------------------------------------------------------------------- /vendorlib/go-mruby/Makefile: -------------------------------------------------------------------------------- 1 | MRUBY_COMMIT ?= 1.2.0 2 | MRUBY_VENDOR_DIR ?= mruby-build 3 | 4 | all: libmruby.a test 5 | 6 | clean: 7 | rm -rf ${MRUBY_VENDOR_DIR} 8 | rm -f libmruby.a 9 | 10 | gofmt: 11 | @echo "Checking code with gofmt.." 12 | gofmt -s *.go >/dev/null 13 | 14 | lint: 15 | GO111MODULE=off go get golang.org/x/lint/golint 16 | golint ./... 17 | 18 | staticcheck: 19 | GO111MODULE=off go get honnef.co/go/tools/cmd/staticcheck 20 | staticcheck ./... 21 | 22 | libmruby.a: ${MRUBY_VENDOR_DIR}/mruby 23 | cd ${MRUBY_VENDOR_DIR}/mruby && ${MAKE} 24 | cp ${MRUBY_VENDOR_DIR}/mruby/build/host/lib/libmruby.a . 25 | 26 | ${MRUBY_VENDOR_DIR}/mruby: 27 | mkdir -p ${MRUBY_VENDOR_DIR} 28 | git clone https://github.com/mruby/mruby.git ${MRUBY_VENDOR_DIR}/mruby 29 | cd ${MRUBY_VENDOR_DIR}/mruby && git reset --hard && git clean -fdx 30 | cd ${MRUBY_VENDOR_DIR}/mruby && git checkout ${MRUBY_COMMIT} 31 | 32 | test: libmruby.a gofmt lint 33 | go test -v 34 | 35 | .PHONY: all clean libmruby.a test lint staticcheck 36 | -------------------------------------------------------------------------------- /features/shutdown.testfile: -------------------------------------------------------------------------------- 1 | launch :rpc, "bundle exec anyt --only-rpc", env: {"ANYCABLE_DEBUG" => "1"}, capture_output: true 2 | wait_tcp 50051 3 | 4 | launch :anycable, 5 | "./dist/anycable-go --disconnect_mode=always --disconnect_rate=1" 6 | wait_tcp 8080 7 | 8 | scenario = [ 9 | client: { 10 | multiplier: 3, 11 | actions: [ 12 | { 13 | receive: { 14 | "data>": { 15 | type: "welcome" 16 | } 17 | } 18 | }, 19 | { 20 | sleep: { 21 | time: 4 22 | } 23 | } 24 | ] 25 | } 26 | ] 27 | 28 | TEST_COMMAND = <<~CMD 29 | bundle exec wsdirector ws://localhost:8080/cable -i #{scenario.to_json} 30 | CMD 31 | 32 | launch :wsdirector, TEST_COMMAND 33 | 34 | sleep 1 35 | 36 | stop :anycable 37 | stop :rpc 38 | 39 | result = stdout(:rpc) 40 | 41 | expected = 3 42 | disconnect_calls = result.scan(/^RPC Disconnect/).size 43 | 44 | if disconnect_calls != expected 45 | fail "Expected to receive #{expected} Disconnect RPC calls. Got #{disconnect_calls}:\n#{result}" 46 | end 47 | -------------------------------------------------------------------------------- /logger/config_test.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/BurntSushi/toml" 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestConfig__DecodeToml(t *testing.T) { 12 | tomlString := ` 13 | level = "warn" 14 | format = "json" 15 | debug = true 16 | ` 17 | 18 | conf := NewConfig() 19 | _, err := toml.Decode(tomlString, &conf) 20 | require.NoError(t, err) 21 | 22 | assert.Equal(t, "warn", conf.LogLevel) 23 | assert.Equal(t, "json", conf.LogFormat) 24 | assert.True(t, conf.Debug) 25 | } 26 | 27 | func TestConfig__ToToml(t *testing.T) { 28 | conf := NewConfig() 29 | conf.LogLevel = "warn" 30 | conf.LogFormat = "json" 31 | conf.Debug = false 32 | 33 | tomlStr := conf.ToToml() 34 | 35 | assert.Contains(t, tomlStr, "level = \"warn\"") 36 | assert.Contains(t, tomlStr, "format = \"json\"") 37 | assert.Contains(t, tomlStr, "# debug = true") 38 | 39 | // Round-trip test 40 | conf2 := Config{} 41 | 42 | _, err := toml.Decode(tomlStr, &conf2) 43 | require.NoError(t, err) 44 | 45 | assert.Equal(t, conf, conf2) 46 | } 47 | -------------------------------------------------------------------------------- /vendorlib/go-mruby/gc_test.go: -------------------------------------------------------------------------------- 1 | package mruby 2 | 3 | import "testing" 4 | 5 | func TestEnableDisableGC(t *testing.T) { 6 | mrb := NewMrb() 7 | defer mrb.Close() 8 | 9 | mrb.FullGC() 10 | mrb.DisableGC() 11 | 12 | _, err := mrb.LoadString("b = []; a = []; a = []") 13 | if err != nil { 14 | t.Fatal(err) 15 | } 16 | 17 | orig := mrb.LiveObjectCount() 18 | mrb.FullGC() 19 | 20 | if orig != mrb.LiveObjectCount() { 21 | t.Fatalf("Object count was not what was expected after full GC: %d %d", orig, mrb.LiveObjectCount()) 22 | } 23 | 24 | mrb.EnableGC() 25 | mrb.FullGC() 26 | 27 | if orig-2 != mrb.LiveObjectCount() { 28 | t.Fatalf("Object count was not what was expected after full GC: %d %d", orig-2, mrb.LiveObjectCount()) 29 | } 30 | } 31 | 32 | func TestIsDead(t *testing.T) { 33 | mrb := NewMrb() 34 | 35 | val, err := mrb.LoadString("$a = []") 36 | if err != nil { 37 | t.Fatal(err) 38 | } 39 | 40 | if val.IsDead() { 41 | t.Fatal("Value is already dead and should not be") 42 | } 43 | 44 | mrb.Close() 45 | 46 | if !val.IsDead() { 47 | t.Fatal("Value should be dead and is not") 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /sse/config.go: -------------------------------------------------------------------------------- 1 | package sse 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | const ( 9 | defaultMaxBodySize = 65536 // 64 kB 10 | ) 11 | 12 | // Server-sent events configuration 13 | type Config struct { 14 | Enabled bool `toml:"enabled"` 15 | // Path is the URL path to handle SSE requests 16 | Path string `toml:"path"` 17 | AllowedOrigins string `toml:"-"` 18 | } 19 | 20 | // NewConfig creates a new Config with default values. 21 | func NewConfig() Config { 22 | return Config{ 23 | Enabled: false, 24 | Path: "/events", 25 | } 26 | } 27 | 28 | // ToToml converts the Config struct to a TOML string representation 29 | func (c Config) ToToml() string { 30 | var result strings.Builder 31 | 32 | result.WriteString("# Enable Server-sent events support\n") 33 | if c.Enabled { 34 | result.WriteString("enabled = true\n") 35 | } else { 36 | result.WriteString("# enabled = true\n") 37 | } 38 | 39 | result.WriteString("# Server-sent events endpoint path\n") 40 | result.WriteString(fmt.Sprintf("path = \"%s\"\n", c.Path)) 41 | 42 | result.WriteString("\n") 43 | 44 | return result.String() 45 | } 46 | -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2016-2023 Vladimir Dementyev 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /utils/utils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "math" 7 | "math/rand" 8 | "os" 9 | "time" 10 | 11 | "github.com/mattn/go-isatty" 12 | ) 13 | 14 | // IsTTY returns true if program is running with TTY 15 | func IsTTY() bool { 16 | return isatty.IsTerminal(os.Stdout.Fd()) 17 | } 18 | 19 | func ToJSON[T any](val T) []byte { 20 | jsonStr, err := json.Marshal(&val) 21 | if err != nil { 22 | panic(fmt.Sprintf("😲 Failed to build JSON for %v: %v", val, err)) 23 | } 24 | return jsonStr 25 | } 26 | 27 | func Keys[T any](val map[string]T) []string { 28 | var res = make([]string, len(val)) 29 | 30 | i := 0 31 | 32 | for k := range val { 33 | res[i] = k 34 | i++ 35 | } 36 | 37 | return res 38 | } 39 | 40 | // NextRetry returns a cooldown duration before next attempt using 41 | // a simple exponential backoff 42 | func NextRetry(step int) time.Duration { 43 | if step == 0 { 44 | return 250 * time.Millisecond 45 | } 46 | 47 | left := math.Pow(2, float64(step)) 48 | right := 2 * left 49 | 50 | secs := left + (right-left)*rand.Float64() // nolint:gosec 51 | return time.Duration(secs) * time.Second 52 | } 53 | -------------------------------------------------------------------------------- /cmd/gobench-cable/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "log/slog" 7 | "os" 8 | 9 | "github.com/anycable/anycable-go/cli" 10 | "github.com/anycable/anycable-go/config" 11 | _ "github.com/anycable/anycable-go/diagnostics" 12 | "github.com/anycable/anycable-go/gobench" 13 | "github.com/anycable/anycable-go/metrics" 14 | "github.com/anycable/anycable-go/node" 15 | ) 16 | 17 | func main() { 18 | c, err, ok := cli.NewConfigFromCLI(os.Args) 19 | if err != nil { 20 | log.Fatalf("%v", err) 21 | } 22 | if ok { 23 | os.Exit(0) 24 | } 25 | 26 | opts := []cli.Option{ 27 | cli.WithName("GoBenchCable"), 28 | cli.WithController(func(m *metrics.Metrics, c *config.Config, l *slog.Logger) (node.Controller, error) { 29 | return gobench.NewController(m, l), nil 30 | }), 31 | cli.WithDefaultBroker(), 32 | cli.WithDefaultSubscriber(), 33 | cli.WithDefaultBroadcaster(), 34 | } 35 | 36 | runner, err := cli.NewRunner(c, opts) 37 | 38 | if err != nil { 39 | fmt.Printf("%+v\n", err) 40 | os.Exit(1) 41 | } 42 | 43 | err = runner.Run() 44 | 45 | if err != nil { 46 | fmt.Printf("%+v\n", err) 47 | os.Exit(1) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /mocks/MockConnection.go: -------------------------------------------------------------------------------- 1 | package mocks 2 | 3 | import ( 4 | "errors" 5 | "net" 6 | "time" 7 | ) 8 | 9 | type MockConnection struct { 10 | send chan []byte 11 | closed bool 12 | } 13 | 14 | func (conn MockConnection) Write(msg []byte, deadline time.Time) error { 15 | conn.send <- msg 16 | return nil 17 | } 18 | 19 | func (conn MockConnection) WriteBinary(msg []byte, deadline time.Time) error { 20 | conn.send <- msg 21 | return nil 22 | } 23 | 24 | func (conn MockConnection) Read() ([]byte, error) { 25 | timer := time.After(100 * time.Millisecond) 26 | 27 | select { 28 | case <-timer: 29 | return nil, errors.New("connection hasn't received any messages") 30 | case msg := <-conn.send: 31 | return msg, nil 32 | } 33 | } 34 | 35 | func (conn MockConnection) ReadIndifinitely() []byte { 36 | msg := <-conn.send 37 | return msg 38 | } 39 | 40 | func (conn MockConnection) Close(_code int, _reason string) { 41 | conn.send <- []byte("") 42 | } 43 | 44 | func (conn MockConnection) Descriptor() net.Conn { 45 | return nil 46 | } 47 | 48 | func NewMockConnection() MockConnection { 49 | return MockConnection{closed: false, send: make(chan []byte, 10)} 50 | } 51 | -------------------------------------------------------------------------------- /vendorlib/go-mruby/LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Mitchell Hashimoto 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /docs/.trieve.yml: -------------------------------------------------------------------------------- 1 | ignore: 2 | - "**/*/Readme.md" 3 | hostname: https://docs.anycable.io 4 | url_prefix: <%= ENV.fetch("VERSION_PREFIX", "edge") %>/anycable-go 5 | 6 | groups: 7 | - name: PRO version 8 | tracking_id: pro 9 | - name: Server 10 | tracking_id: server 11 | - name: Go package 12 | tracking_id: go_package 13 | 14 | defaults: 15 | groups: 16 | - server 17 | tags: 18 | - docs 19 | - <%= ENV.fetch("VERSION_TAG", "edge") %> 20 | 21 | 22 | pages: 23 | - source: "./apollo.md" 24 | groups: ["pro"] 25 | - source: "./binary_formats.md" 26 | groups: ["pro"] 27 | - source: "./long_polling.md" 28 | groups: ["pro"] 29 | - source: "./ocpp.md" 30 | groups: ["pro"] 31 | - "./broadcasting.md" 32 | - "./broker.md" 33 | - "./configuration.md" 34 | - "./embedded_nats.md" 35 | - "./presence.md" 36 | - "./getting_started.md" 37 | - "./health_checking.md" 38 | - "./instrumentation.md" 39 | - "./jwt_identification.md" 40 | - source: "./library.md" 41 | groups: ["go_package"] 42 | - "./pubsub.md" 43 | - "./reliable_streams.md" 44 | - "./rpc.md" 45 | - "./signed_streams.md" 46 | - "./sse.md" 47 | - "./telemetry.md" 48 | - "./tracing.md" 49 | -------------------------------------------------------------------------------- /nats/config.go: -------------------------------------------------------------------------------- 1 | package nats 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | natsgo "github.com/nats-io/nats.go" 8 | ) 9 | 10 | type NATSConfig struct { 11 | Servers string `toml:"servers"` 12 | DontRandomizeServers bool `toml:"dont_randomize_servers"` 13 | MaxReconnectAttempts int `toml:"max_reconnect_attempts"` 14 | } 15 | 16 | func NewNATSConfig() NATSConfig { 17 | return NATSConfig{ 18 | Servers: natsgo.DefaultURL, 19 | MaxReconnectAttempts: 5, 20 | } 21 | } 22 | 23 | func (c NATSConfig) ToToml() string { 24 | var result strings.Builder 25 | 26 | result.WriteString("# NATS server URLs (comma-separated)\n") 27 | result.WriteString(fmt.Sprintf("servers = \"%s\"\n", c.Servers)) 28 | 29 | result.WriteString("# Don't randomize servers during connection\n") 30 | if c.DontRandomizeServers { 31 | result.WriteString("dont_randomize_servers = true\n") 32 | } else { 33 | result.WriteString("# dont_randomize_servers = true\n") 34 | } 35 | 36 | result.WriteString("# Max number of reconnect attempts\n") 37 | result.WriteString(fmt.Sprintf("max_reconnect_attempts = %d\n", c.MaxReconnectAttempts)) 38 | 39 | result.WriteString("\n") 40 | 41 | return result.String() 42 | } 43 | -------------------------------------------------------------------------------- /cmd/anycable-go/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | 8 | "github.com/anycable/anycable-go/cli" 9 | _ "github.com/anycable/anycable-go/diagnostics" 10 | 11 | _ "unsafe" 12 | ) 13 | 14 | // IgnorePC is responsible for adding callers pointer to log records. 15 | // We don't use `AddSource` in our handler, so why not dropping the `runtime.Callers` overhead? 16 | // See also https://github.com/rs/zerolog/issues/571#issuecomment-1697479194 17 | // 18 | //go:linkname IgnorePC log/slog/internal.IgnorePC 19 | var IgnorePC = true 20 | 21 | func main() { 22 | c, err, ok := cli.NewConfigFromCLI(os.Args) 23 | if err != nil { 24 | log.Fatalf("%v", err) 25 | } 26 | if ok { 27 | os.Exit(0) 28 | } 29 | 30 | opts := []cli.Option{ 31 | cli.WithName("AnyCable"), 32 | cli.WithDefaultRPCController(), 33 | cli.WithDefaultBroker(), 34 | cli.WithDefaultSubscriber(), 35 | cli.WithDefaultBroadcaster(), 36 | cli.WithTelemetry(), 37 | } 38 | 39 | runner, err := cli.NewRunner(c, opts) 40 | 41 | if err != nil { 42 | fmt.Printf("%+v\n", err) 43 | os.Exit(1) 44 | } 45 | 46 | err = runner.Run() 47 | 48 | if err != nil { 49 | fmt.Printf("%+v\n", err) 50 | os.Exit(1) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /metrics/gauge.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import ( 4 | "sync/atomic" 5 | ) 6 | 7 | // Gauge stores an int value 8 | type Gauge struct { 9 | value uint64 10 | name string 11 | desc string 12 | } 13 | 14 | // NewGauge initializes Gauge. 15 | func NewGauge(name string, desc string) *Gauge { 16 | return &Gauge{name: name, desc: desc, value: 0} 17 | } 18 | 19 | // Name returns gauge name 20 | func (g *Gauge) Name() string { 21 | return g.name 22 | } 23 | 24 | // Desc returns gauge description 25 | func (g *Gauge) Desc() string { 26 | return g.desc 27 | } 28 | 29 | // Set gauge value 30 | func (g *Gauge) Set(value int) { 31 | atomic.StoreUint64(&g.value, uint64(value)) 32 | } 33 | 34 | // Inc increment the current value by 1 35 | func (g *Gauge) Inc() uint64 { 36 | return atomic.AddUint64(&g.value, 1) 37 | } 38 | 39 | // Dec decrement the current value by 1 40 | func (g *Gauge) Dec() uint64 { 41 | return atomic.AddUint64(&g.value, ^uint64(0)) 42 | } 43 | 44 | // Set64 sets gauge value as uint64 45 | func (g *Gauge) Set64(value uint64) { 46 | atomic.StoreUint64(&g.value, value) 47 | } 48 | 49 | // Value returns the current gauge value 50 | func (g *Gauge) Value() uint64 { 51 | return atomic.LoadUint64(&g.value) 52 | } 53 | -------------------------------------------------------------------------------- /broadcast/broadcast.go: -------------------------------------------------------------------------------- 1 | // This package contains different message broadcast handler implemenentations. 2 | // Broadcast handler is responsible for consumeing broadcast messages from the outer world and 3 | // routing them to the application node. 4 | // 5 | // NOTE: There could be multiple broadcast handlers running at the same time. 6 | package broadcast 7 | 8 | import ( 9 | "context" 10 | ) 11 | 12 | //go:generate mockery --name Broadcaster --output "../mocks" --outpkg mocks 13 | type Broadcaster interface { 14 | Start(done chan (error)) error 15 | Shutdown(ctx context.Context) error 16 | // Returns true if the broadcaster fan-outs the same event 17 | // to all nodes. Such subscriber shouldn't be used with real pub/sub 18 | // engines (which are responsible for message distribution) 19 | IsFanout() bool 20 | } 21 | 22 | //go:generate mockery --name Handler --output "../mocks" --outpkg mocks 23 | type Handler interface { 24 | // Handle broadcast message delivered only to this node (and pass it through the broker) 25 | // (Used by single-node broadcasters) 26 | HandleBroadcast(json []byte) 27 | // Handle broadcast message delivered to all nodes 28 | // (Used by fan-out broadcasters) 29 | HandlePubSub(json []byte) 30 | } 31 | -------------------------------------------------------------------------------- /forspell.dict: -------------------------------------------------------------------------------- 1 | # Format: one word per line. Empty lines and #-comments are supported too. 2 | # If you want to add word with its forms, you can write 'word: example' (without quotes) on the line, 3 | # where 'example' is existing word with the same possible forms (endings) as your word. 4 | # Example: deduplicate: duplicate 5 | add-ons 6 | Airbrake 7 | AnyCable 8 | anycable-go 9 | Balancer 10 | balancers 11 | buildpack 12 | Dockerfile 13 | dynos 14 | erlgrpc 15 | Golang 16 | Hanami 17 | Hashrocket's 18 | Hivemind 19 | Homebrew 20 | Honeybadger 21 | keepalive 22 | Kubernetes 23 | Librato 24 | liveness 25 | mruby 26 | Overmind 27 | Pinger 28 | pinger 29 | Protobuf 30 | scalable 31 | Systemd 32 | systemd 33 | websocket 34 | Websocket 35 | Yabeda 36 | unsubscription 37 | dyno 38 | Preboot 39 | avlazarov 40 | tldr 41 | graphql 42 | Msgpack 43 | subprotocol 44 | Protobufs 45 | Grafana 46 | Hotwire 47 | blazingly 48 | observability 49 | influxdb 50 | datadog 51 | fastlane 52 | misconfigured 53 | virtualized 54 | automaxprocs 55 | graphql-ws 56 | Statsd 57 | Makefile 58 | golangci-lint 59 | Twilio 60 | broadcasted 61 | realtime-ification 62 | Posthog 63 | resumable 64 | ack 65 | caniuse 66 | reconnection 67 | norpc 68 | noauth 69 | jid 70 | webhooks 71 | -------------------------------------------------------------------------------- /utils/gopool_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "bytes" 5 | "runtime" 6 | "strconv" 7 | "sync" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestWorkerRespawn(t *testing.T) { 14 | pool := NewGoPool("test", 1) 15 | 16 | var wg sync.WaitGroup 17 | 18 | resChan := make(chan uint64, 2) 19 | dataChan := make(chan struct{}, workerRespawnThreshold+2) 20 | 21 | wg.Add(1) 22 | 23 | pool.Schedule(func() { 24 | resChan <- getGID() 25 | }) 26 | 27 | for i := 0; i < workerRespawnThreshold+2; i++ { 28 | pool.Schedule(func() { 29 | dataChan <- struct{}{} 30 | }) 31 | } 32 | 33 | pool.Schedule(func() { 34 | resChan <- getGID() 35 | wg.Done() 36 | }) 37 | 38 | initial := <-resChan 39 | current := <-resChan 40 | 41 | assert.NotEqual(t, initial, current) 42 | assert.Equal(t, workerRespawnThreshold+2, len(dataChan)) 43 | } 44 | 45 | // Get current goroutine ID 46 | // Source: https://blog.sgmansfield.com/2015/12/goroutine-ids/ 47 | func getGID() uint64 { 48 | b := make([]byte, 64) 49 | b = b[:runtime.Stack(b, false)] 50 | b = bytes.TrimPrefix(b, []byte("goroutine ")) 51 | b = b[:bytes.IndexByte(b, ' ')] 52 | n, _ := strconv.ParseUint(string(b), 10, 64) 53 | return n 54 | } 55 | -------------------------------------------------------------------------------- /features/streams.testfile: -------------------------------------------------------------------------------- 1 | launch :anycable, 2 | "./dist/anycable-go --public " \ 3 | "--metrics_rotate_interval=1 --metrics_log --metrics_log_filter=rpc_call_total,rpc_error_total,rpc_retries_total", 4 | capture_output: true 5 | 6 | wait_tcp 8080 7 | 8 | scenario = [ 9 | { 10 | client: { 11 | protocol: "action_cable", 12 | name: "streamer", 13 | actions: [ 14 | { 15 | subscribe: { 16 | channel: "$pubsub", 17 | params: { 18 | stream_name: "stream/2023" 19 | } 20 | } 21 | }, 22 | ] 23 | } 24 | } 25 | ] 26 | 27 | TEST_COMMAND = <<~CMD 28 | bundle exec wsdirector ws://localhost:8080/cable -i #{scenario.to_json} 29 | CMD 30 | 31 | run :wsdirector, TEST_COMMAND 32 | 33 | result = stdout(:wsdirector) 34 | 35 | unless result.include?("1 clients, 0 failures") 36 | fail "Unexpected scenario result:\n#{result}" 37 | end 38 | 39 | # Wait for metrics to be logged 40 | sleep 2 41 | 42 | stop :anycable 43 | 44 | logs = stdout(:anycable) 45 | 46 | # We should not see any rpc calls 47 | if logs =~ /rpc_call_total=[1-9] rpc_error_total=[1-9] rpc_retries_total=[1-9]/ 48 | fail "Expected metrics logs not found:\n#{logs}" 49 | end 50 | -------------------------------------------------------------------------------- /streams/config_test.go: -------------------------------------------------------------------------------- 1 | package streams 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/BurntSushi/toml" 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestConfig_ToToml(t *testing.T) { 12 | conf := NewConfig() 13 | conf.Secret = "test-secret" 14 | conf.Public = true 15 | conf.Whisper = false 16 | conf.PubSubChannel = "test-channel" 17 | conf.Turbo = true 18 | conf.TurboSecret = "turbo-secret" 19 | conf.CableReady = false 20 | conf.CableReadySecret = "cable-ready-secret" 21 | 22 | tomlStr := conf.ToToml() 23 | 24 | assert.Contains(t, tomlStr, "secret = \"test-secret\"") 25 | assert.Contains(t, tomlStr, "public = true") 26 | assert.Contains(t, tomlStr, "# whisper = true") 27 | assert.Contains(t, tomlStr, "pubsub_channel = \"test-channel\"") 28 | assert.Contains(t, tomlStr, "turbo = true") 29 | assert.Contains(t, tomlStr, "turbo_secret = \"turbo-secret\"") 30 | assert.Contains(t, tomlStr, "# cable_ready = true") 31 | assert.Contains(t, tomlStr, "cable_ready_secret = \"cable-ready-secret\"") 32 | 33 | // Round-trip test 34 | conf2 := Config{} 35 | 36 | _, err := toml.Decode(tomlStr, &conf2) 37 | require.NoError(t, err) 38 | 39 | assert.Equal(t, conf, conf2) 40 | } 41 | -------------------------------------------------------------------------------- /etc/envoy/envoy.yaml: -------------------------------------------------------------------------------- 1 | admin: 2 | access_log_path: /tmp/admin_access.log 3 | address: 4 | socket_address: { address: 0.0.0.0, port_value: 9901 } 5 | 6 | static_resources: 7 | listeners: 8 | - name: listener_0 9 | address: 10 | socket_address: { address: 0.0.0.0, port_value: 50051 } 11 | filter_chains: 12 | - filters: 13 | - name: envoy.http_connection_manager 14 | config: 15 | stat_prefix: ingress_http 16 | codec_type: AUTO 17 | route_config: 18 | name: local_route 19 | virtual_hosts: 20 | - name: anycable_grpc 21 | domains: ["*"] 22 | routes: 23 | - match: { prefix: "/" } 24 | route: { cluster: anycable_grpc } 25 | http_filters: 26 | - name: envoy.router 27 | config: {} 28 | clusters: 29 | - name: anycable_grpc 30 | connect_timeout: 25s 31 | type: STRICT_DNS 32 | lb_policy: ROUND_ROBIN 33 | dns_lookup_family: V4_ONLY 34 | http2_protocol_options: { } 35 | hosts: [ 36 | { socket_address: { address: "host.docker.internal", port_value: 50060 }}, 37 | { socket_address: { address: "host.docker.internal", port_value: 50061 }} 38 | ] 39 | -------------------------------------------------------------------------------- /docs/telemetry.md: -------------------------------------------------------------------------------- 1 | # Telemetry 2 | 3 | AnyCable v1.4+ collects **anonymous usage** information. Users are notified on the server start. 4 | 5 | Why collecting telemetry? One of the biggest issues in the open-source development is the lack of feedback (especially, when everything works as expected). Getting more insights on how AnyCable is used in the wild will help us to prioritize work on new features and improvements. 6 | 7 | ## Opting out 8 | 9 | You can disable telemetry by setting the `ANYCABLE_DISABLE_TELEMETRY` environment variable to `true`. 10 | 11 | ## What is collected 12 | 13 | We collect the following information: 14 | 15 | - AnyCable version. 16 | - OS name. 17 | - CI name (to distinguish CI runs from other runs). 18 | - Deployment platform (e.g., Heroku, Fly, etc.). 19 | - Specific features enabled (e.g., JWT identification, signed streams, etc.). 20 | - Max observed amount of RAM used by the process. 21 | - Max observed number of concurrent connections (this helps us to distinguish development/test runs from production ones). 22 | 23 | We **do not collect** personally-identifiable or sensitive information, such as: hostnames, file names, environment variables, or IP addresses. 24 | 25 | We use [Posthog](https://posthog.com/) to store and visualize data. 26 | -------------------------------------------------------------------------------- /ws/ws.go: -------------------------------------------------------------------------------- 1 | package ws 2 | 3 | import "github.com/gorilla/websocket" 4 | 5 | const ( 6 | // CloseNormalClosure indicates normal closure 7 | CloseNormalClosure = websocket.CloseNormalClosure 8 | 9 | // CloseInternalServerErr indicates closure because of internal error 10 | CloseInternalServerErr = websocket.CloseInternalServerErr 11 | 12 | // CloseAbnormalClosure indicates abnormal close 13 | CloseAbnormalClosure = websocket.CloseAbnormalClosure 14 | 15 | // CloseGoingAway indicates closing because of server shuts down or client disconnects 16 | CloseGoingAway = websocket.CloseGoingAway 17 | ) 18 | 19 | var ( 20 | expectedCloseStatuses = []int{ 21 | websocket.CloseNormalClosure, // Reserved in case ActionCable fixes its behaviour 22 | websocket.CloseGoingAway, // Web browser page was closed 23 | websocket.CloseNoStatusReceived, // ActionCable don't care about closing 24 | } 25 | ) 26 | 27 | type FrameType int 28 | 29 | const ( 30 | TextFrame FrameType = 0 31 | CloseFrame FrameType = 1 32 | BinaryFrame FrameType = 2 33 | ) 34 | 35 | type SentFrame struct { 36 | FrameType FrameType 37 | Payload []byte 38 | CloseCode int 39 | CloseReason string 40 | } 41 | 42 | func IsCloseError(err error) bool { 43 | return websocket.IsCloseError(err, expectedCloseStatuses...) 44 | } 45 | -------------------------------------------------------------------------------- /broker/config_test.go: -------------------------------------------------------------------------------- 1 | package broker 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/BurntSushi/toml" 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestConfig__DecodeToml(t *testing.T) { 12 | tomlString := ` 13 | adapter = "nats" 14 | history_ttl = 100 15 | history_limit = 1000 16 | sessions_ttl = 600 17 | ` 18 | 19 | conf := NewConfig() 20 | _, err := toml.Decode(tomlString, &conf) 21 | require.NoError(t, err) 22 | 23 | assert.Equal(t, "nats", conf.Adapter) 24 | assert.Equal(t, int64(100), conf.HistoryTTL) 25 | assert.Equal(t, 1000, conf.HistoryLimit) 26 | assert.Equal(t, int64(600), conf.SessionsTTL) 27 | } 28 | 29 | func TestConfig__ToToml(t *testing.T) { 30 | conf := NewConfig() 31 | conf.Adapter = "nats" 32 | conf.HistoryTTL = 100 33 | conf.HistoryLimit = 1000 34 | conf.SessionsTTL = 600 35 | 36 | tomlStr := conf.ToToml() 37 | 38 | assert.Contains(t, tomlStr, "adapter = \"nats\"") 39 | assert.Contains(t, tomlStr, "history_ttl = 100") 40 | assert.Contains(t, tomlStr, "history_limit = 1000") 41 | assert.Contains(t, tomlStr, "sessions_ttl = 600") 42 | 43 | // Round-trip test 44 | conf2 := Config{} 45 | 46 | _, err := toml.Decode(tomlStr, &conf2) 47 | require.NoError(t, err) 48 | 49 | assert.Equal(t, conf, conf2) 50 | } 51 | -------------------------------------------------------------------------------- /encoders/json_test.go: -------------------------------------------------------------------------------- 1 | package encoders 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/anycable/anycable-go/common" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestJSONEncoder(t *testing.T) { 11 | coder := JSON{} 12 | 13 | t.Run(".Encode", func(t *testing.T) { 14 | msg := &common.Reply{Type: "test", Identifier: "test_channel", Message: "hello"} 15 | 16 | expected := []byte("{\"type\":\"test\",\"identifier\":\"test_channel\",\"message\":\"hello\"}") 17 | 18 | actual, err := coder.Encode(msg) 19 | 20 | assert.NoError(t, err) 21 | assert.Equal(t, expected, actual.Payload) 22 | }) 23 | 24 | t.Run(".EncodeTransmission", func(t *testing.T) { 25 | msg := "{\"type\":\"test\",\"identifier\":\"test_channel\",\"message\":\"hello\"}" 26 | expected := []byte(msg) 27 | 28 | actual, err := coder.EncodeTransmission(msg) 29 | 30 | assert.NoError(t, err) 31 | assert.Equal(t, expected, actual.Payload) 32 | }) 33 | 34 | t.Run(".Decode", func(t *testing.T) { 35 | msg := []byte("{\"command\":\"test\",\"identifier\":\"test_channel\",\"data\":\"hello\"}") 36 | 37 | actual, err := coder.Decode(msg) 38 | 39 | assert.NoError(t, err) 40 | assert.Equal(t, actual.Command, "test") 41 | assert.Equal(t, actual.Identifier, "test_channel") 42 | assert.Equal(t, actual.Data, "hello") 43 | }) 44 | } 45 | -------------------------------------------------------------------------------- /utils/message_verifier_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestMessageVerifier(t *testing.T) { 11 | verifier := NewMessageVerifier("s3Krit") 12 | 13 | // Turbo.signed_stream_verifier_key = 's3Krit' 14 | // Turbo::StreamsChannel.signed_stream_name([:chat, "2021"]) 15 | example := "ImNoYXQ6MjAyMSI=--f9ee45dbccb1da04d8ceb99cc820207804370ba0d06b46fc3b8b373af1315628" 16 | 17 | generated, err := verifier.Generate("chat:2021") 18 | require.NoError(t, err) 19 | 20 | assert.Equal(t, example, generated) 21 | 22 | res, err := verifier.Verified(example) 23 | 24 | assert.NoError(t, err) 25 | assert.Equal(t, "chat:2021", res) 26 | 27 | a_verifier := NewMessageVerifier("secret") 28 | a_generated, _ := a_verifier.Generate("chat:2021") 29 | 30 | _, err = verifier.Verified(a_generated) 31 | assert.Error(t, err) 32 | } 33 | 34 | func TestMessageVerifierNotString(t *testing.T) { 35 | verifier := NewMessageVerifier("s3Krit") 36 | example := "WyJjaGF0LzIwMjMiLDE2ODUwMjQwMTdd--5b6661024d4c463c4936cd1542bc9a7672dd8039ac407d0b6c901697190e8aeb" 37 | 38 | res, err := verifier.Verified(example) 39 | 40 | arr := res.([]interface{}) 41 | 42 | assert.NoError(t, err) 43 | assert.Equal(t, "chat/2023", arr[0]) 44 | } 45 | -------------------------------------------------------------------------------- /vendorlib/go-mruby/parser_test.go: -------------------------------------------------------------------------------- 1 | package mruby 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestParserGenerateCode(t *testing.T) { 8 | mrb := NewMrb() 9 | defer mrb.Close() 10 | 11 | p := NewParser(mrb) 12 | defer p.Close() 13 | 14 | warns, err := p.Parse(`"foo"`, nil) 15 | if err != nil { 16 | t.Fatalf("err: %s", err) 17 | } 18 | if warns != nil { 19 | t.Fatalf("warnings: %v", warns) 20 | } 21 | 22 | proc := p.GenerateCode() 23 | result, err := mrb.Run(proc, nil) 24 | if err != nil { 25 | t.Fatalf("err: %s", err) 26 | } 27 | if result.String() != "foo" { 28 | t.Fatalf("bad: %s", result.String()) 29 | } 30 | } 31 | 32 | func TestParserParse(t *testing.T) { 33 | mrb := NewMrb() 34 | defer mrb.Close() 35 | 36 | p := NewParser(mrb) 37 | defer p.Close() 38 | 39 | warns, err := p.Parse(`"foo"`, nil) 40 | if err != nil { 41 | t.Fatalf("err: %s", err) 42 | } 43 | if warns != nil { 44 | t.Fatalf("warnings: %v", warns) 45 | } 46 | } 47 | 48 | func TestParserParse_error(t *testing.T) { 49 | mrb := NewMrb() 50 | defer mrb.Close() 51 | 52 | p := NewParser(mrb) 53 | defer p.Close() 54 | 55 | _, err := p.Parse(`def foo`, nil) 56 | if err == nil { 57 | t.Fatal("should have errors") 58 | } 59 | } 60 | 61 | func TestParserError_error(t *testing.T) { 62 | var _ error = new(ParserError) 63 | } 64 | -------------------------------------------------------------------------------- /diagnostics/diagnostics_gops.go: -------------------------------------------------------------------------------- 1 | //go:build gops 2 | // +build gops 3 | 4 | package diagnostics 5 | 6 | import ( 7 | "fmt" 8 | "net/http" 9 | _ "net/http/pprof" 10 | "os" 11 | "runtime" 12 | "strconv" 13 | 14 | "log" 15 | 16 | "github.com/google/gops/agent" 17 | ) 18 | 19 | func init() { 20 | if err := agent.Listen(agent.Options{}); err != nil { 21 | log.Fatal(err.Error()) 22 | } 23 | 24 | pprofRequired := false 25 | 26 | if vals := os.Getenv("BLOCK_PROFILE_RATE"); vals != "" { 27 | val, err := strconv.Atoi(vals) 28 | 29 | if err != nil { 30 | log.Fatalf("Invalid value for block profile rate: %s", vals) 31 | } 32 | 33 | runtime.SetBlockProfileRate(val) 34 | pprofRequired = true 35 | fmt.Println("[PPROF] Block profiling enabled") 36 | } 37 | 38 | if vals := os.Getenv("MUTEX_PROFILE_FRACTION"); vals != "" { 39 | val, err := strconv.Atoi(vals) 40 | 41 | if err != nil { 42 | log.Fatalf("Invalid value for mutex profile fraction: %s", vals) 43 | } 44 | 45 | runtime.SetMutexProfileFraction(val) 46 | pprofRequired = true 47 | fmt.Println("[PPROF] Mutex profiling enabled") 48 | } 49 | 50 | // Run pprof web server as well to be able to capture blocks and mutex profiles 51 | // (not supported by gops) 52 | if pprofRequired { 53 | go func() { http.ListenAndServe(":6060", nil) }() 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /features/mruby_printers.testfile: -------------------------------------------------------------------------------- 1 | launch :rpc, "bundle exec anyt --only-rpc" 2 | wait_tcp 50051 3 | 4 | launch :anycable, 5 | "./dist/anycable-go --broadcast_adapter=http --metrics_log_formatter=#{File.join(__dir__, "simple_logger.rb")} --metrics_rotate_interval=1 --ping_interval=3", 6 | capture_output: true, 7 | env: {"PRINTER_NAME" => "MRUBY PRINTER"} 8 | wait_tcp 8080 9 | 10 | scenario = [ 11 | client: { 12 | multiplier: 2, 13 | actions: [ 14 | { 15 | receive: { 16 | "data>": { 17 | type: "welcome" 18 | } 19 | } 20 | }, 21 | { 22 | receive: { 23 | "data>": { 24 | type: "ping" 25 | } 26 | } 27 | }, 28 | { 29 | receive: { 30 | "data>": { 31 | type: "ping" 32 | } 33 | } 34 | } 35 | ] 36 | } 37 | ] 38 | 39 | TEST_COMMAND = <<~CMD 40 | bundle exec wsdirector ws://localhost:8080/cable -i #{scenario.to_json} 41 | CMD 42 | 43 | run :wsdirector, TEST_COMMAND 44 | 45 | result = stdout(:wsdirector) 46 | 47 | if result !~ /2 clients, 0 failures/ 48 | fail "Unexpected scenario result:\n#{result}" 49 | end 50 | 51 | stop :anycable 52 | 53 | logs = stdout(:anycable) 54 | if logs !~ /\[MRUBY PRINTER\] Connections: 2/ 55 | fail "Missing metrics in logs:\n#{logs}" 56 | end 57 | -------------------------------------------------------------------------------- /features/metrics.testfile: -------------------------------------------------------------------------------- 1 | launch :rpc, "bundle exec anyt --only-rpc" 2 | wait_tcp 50051 3 | 4 | launch :anycable, 5 | "./dist/anycable-go --disconnect_mode=never --metrics_rotate_interval=3 --ping_interval=1 --metrics_log --metrics_log_filter=rpc_call_total,failed_auths_total", 6 | capture_output: true 7 | wait_tcp 8080 8 | 9 | scenario = [ 10 | client: { 11 | multiplier: 2, 12 | actions: [ 13 | { 14 | receive: { 15 | "data>": { 16 | type: "welcome" 17 | } 18 | } 19 | }, 20 | { 21 | receive: { 22 | "data>": { 23 | type: "ping" 24 | } 25 | } 26 | }, 27 | { 28 | receive: { 29 | "data>": { 30 | type: "ping" 31 | } 32 | } 33 | } 34 | ] 35 | } 36 | ] 37 | 38 | TEST_COMMAND = <<~CMD 39 | bundle exec wsdirector ws://localhost:8080/cable -i #{scenario.to_json} 40 | CMD 41 | 42 | run :wsdirector, TEST_COMMAND 43 | 44 | result = stdout(:wsdirector) 45 | 46 | if result !~ /2 clients, 0 failures/ 47 | fail "Unexpected scenario result:\n#{result}" 48 | end 49 | 50 | # Wait for metrics to be logged 51 | sleep 2 52 | 53 | stop :anycable 54 | 55 | logs = stdout(:anycable) 56 | # We disabled disconnector, so there should be just 2 RPC calls (Connect) 57 | unless logs =~ /failed_auths_total=0/ && logs =~ /rpc_call_total=2/ 58 | fail "Metrics logs not found:\n#{logs}" 59 | end 60 | -------------------------------------------------------------------------------- /mocks/Handler.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery v2.20.0. DO NOT EDIT. 2 | 3 | package mocks 4 | 5 | import ( 6 | slog "log/slog" 7 | 8 | mock "github.com/stretchr/testify/mock" 9 | ) 10 | 11 | // Handler is an autogenerated mock type for the Handler type 12 | type Handler struct { 13 | mock.Mock 14 | } 15 | 16 | // HandleBroadcast provides a mock function with given fields: json 17 | func (_m *Handler) HandleBroadcast(json []byte) { 18 | _m.Called(json) 19 | } 20 | 21 | // HandlePubSub provides a mock function with given fields: json 22 | func (_m *Handler) HandlePubSub(json []byte) { 23 | _m.Called(json) 24 | } 25 | 26 | // Logger provides a mock function with given fields: 27 | func (_m *Handler) Logger() *slog.Logger { 28 | ret := _m.Called() 29 | 30 | var r0 *slog.Logger 31 | if rf, ok := ret.Get(0).(func() *slog.Logger); ok { 32 | r0 = rf() 33 | } else { 34 | if ret.Get(0) != nil { 35 | r0 = ret.Get(0).(*slog.Logger) 36 | } 37 | } 38 | 39 | return r0 40 | } 41 | 42 | type mockConstructorTestingTNewHandler interface { 43 | mock.TestingT 44 | Cleanup(func()) 45 | } 46 | 47 | // NewHandler creates a new instance of Handler. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 48 | func NewHandler(t mockConstructorTestingTNewHandler) *Handler { 49 | mock := &Handler{} 50 | mock.Mock.Test(t) 51 | 52 | t.Cleanup(func() { mock.AssertExpectations(t) }) 53 | 54 | return mock 55 | } 56 | -------------------------------------------------------------------------------- /etc/dns/server.rb: -------------------------------------------------------------------------------- 1 | require "bundler/inline" 2 | 3 | retried = false 4 | begin 5 | gemfile(retried, quiet: ENV["LOG"] != "1") do 6 | source "https://rubygems.org" 7 | 8 | gem "async-dns" 9 | end 10 | rescue Gem::MissingSpecError, Bundler::GemNotFound 11 | raise if retried 12 | 13 | retried = true 14 | retry 15 | end 16 | 17 | require 'async/dns' 18 | 19 | IN = Resolv::DNS::Resource::IN 20 | 21 | RPC_ADDRESSES = %w[ 22 | 127.0.0.1 23 | 127.0.0.2 24 | 127.0.0.3 25 | 127.0.0.4 26 | ] 27 | 28 | class Server < Async::DNS::Server 29 | def process(name, resource_class, transaction) 30 | if resource_class == IN::A && name.match?(%r{anycable-rpc.local}) 31 | addresses = alive_rpcs 32 | if addresses.empty? 33 | return transaction.fail!(:NXDomain) 34 | end 35 | 36 | transaction.append_question! 37 | addresses.each do 38 | transaction.add([IN::A.new(_1)], {}) 39 | end 40 | 41 | @logger.info "Resolved RPCs: #{addresses}" 42 | 43 | return 44 | end 45 | 46 | transaction.fail!(:NXDomain) 47 | end 48 | 49 | private 50 | 51 | def alive_rpcs 52 | RPC_ADDRESSES.select do 53 | Socket.tcp(_1, 50051, connect_timeout: 0.1).close 54 | true 55 | rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ETIMEDOUT, SocketError 56 | false 57 | end 58 | end 59 | end 60 | 61 | server = Server.new([[:udp, '127.0.0.1', 2346], [:tcp, '127.0.0.1', 2346]]) 62 | server.run 63 | -------------------------------------------------------------------------------- /etc/ssl/server.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIID2jCCAsKgAwIBAgIJAINgcDL+ai6EMA0GCSqGSIb3DQEBCwUAMFExCzAJBgNV 3 | BAYTAlJVMQ0wCwYDVQQIEwRUdWxhMQ0wCwYDVQQHEwRUdWxhMRAwDgYDVQQKEwdQ 4 | cnlhbmlrMRIwEAYDVQQDEwlsb2NhbGhvc3QwHhcNMTcxMTAxMDYzMDU3WhcNMjcx 5 | MDMwMDYzMDU3WjBRMQswCQYDVQQGEwJSVTENMAsGA1UECBMEVHVsYTENMAsGA1UE 6 | BxMEVHVsYTEQMA4GA1UEChMHUHJ5YW5pazESMBAGA1UEAxMJbG9jYWxob3N0MIIB 7 | IjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwyzxi9XhS68F82SjgjwlZ6Xa 8 | L2yDwQ0clfEZHMJCJ1Pwq855KQnibS31iDjakQ2doR3cWKdYdhhfuWzhwQcBIpp8 9 | S2dagxvOXFQ3qGGNOu12Q5l1ehwEDES6eEnR9KnFYTTr9KqFxZaRNIARRJgpOdK3 10 | hy6a4PBXGICU8Pgn48j6tlcPHfwnoW//69Rj1Yj9Qv/iQ0+LAdbY0SL7jeSk4Uze 11 | BPs62h1W3nqRrq2g7m7nlqq+zo49b6o/ozNh67OLkO5jXxfD6O6RZrRf41X4yice 12 | fHH5ufogzYVkvp2x9+0KTyhbHJ4VlrQr8CICJqXgJYDspS+Jm3hddnWmCgpLMQID 13 | AQABo4G0MIGxMB0GA1UdDgQWBBRqzfdMzPzyFcw0IxGaqlMrUCMYGTCBgQYDVR0j 14 | BHoweIAUas33TMz88hXMNCMRmqpTK1AjGBmhVaRTMFExCzAJBgNVBAYTAlJVMQ0w 15 | CwYDVQQIEwRUdWxhMQ0wCwYDVQQHEwRUdWxhMRAwDgYDVQQKEwdQcnlhbmlrMRIw 16 | EAYDVQQDEwlsb2NhbGhvc3SCCQCDYHAy/mouhDAMBgNVHRMEBTADAQH/MA0GCSqG 17 | SIb3DQEBCwUAA4IBAQCfZXi6tRPI+GEXpEHu8M9epYSQ5jV995E7w8QrQUuXyYbD 18 | +C6hhWjvWKOsvkgFn73+7o5Dlbi052oAGCzZIyo6yY1GjZFpGD6hzWfAH2wPa2mb 19 | CQAVXqTGfSMXv40yBz+otfLXvNQegza+TjE5nC+6PEqoQLR7UvpREOLu557r++Cr 20 | ltpe9/BoWPfI7LaNDHQoRidVZq/gW/fjIxV15Zjvn8woEQDDZ+TQIvicVdXU5PpV 21 | ABEUycP5PYb34d4GuuTV3GA63esXPlD1Tzk2SG03nGWTLGc6pqazhCaxYAXE/91Z 22 | SA7w7+w+fI/LTyjOQyu05z7muNaewG6HgF7WD8SD 23 | -----END CERTIFICATE----- 24 | -------------------------------------------------------------------------------- /ws/handler_test.go: -------------------------------------------------------------------------------- 1 | package ws 2 | 3 | import ( 4 | "net/http/httptest" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestCheckOriginWithoutHeader(t *testing.T) { 11 | req := httptest.NewRequest("GET", "/", nil) 12 | 13 | allowedOrigins := "" 14 | assert.Equal(t, CheckOrigin(allowedOrigins)(req), true) 15 | 16 | allowedOrigins = "secure.origin" 17 | assert.Equal(t, CheckOrigin(allowedOrigins)(req), false) 18 | } 19 | 20 | func TestCheckOrigin(t *testing.T) { 21 | req := httptest.NewRequest("GET", "/", nil) 22 | req.Header.Set("Origin", "http://my.localhost:8080") 23 | 24 | allowedOrigins := "" 25 | assert.Equal(t, CheckOrigin(allowedOrigins)(req), true) 26 | 27 | allowedOrigins = "my.localhost:8080" 28 | assert.Equal(t, CheckOrigin(allowedOrigins)(req), true) 29 | 30 | allowedOrigins = "MY.localhost:8080" 31 | assert.Equal(t, CheckOrigin(allowedOrigins)(req), true) 32 | 33 | allowedOrigins = "localhost:8080" 34 | assert.Equal(t, CheckOrigin(allowedOrigins)(req), false) 35 | 36 | allowedOrigins = "*.localhost:8080" 37 | assert.Equal(t, CheckOrigin(allowedOrigins)(req), true) 38 | 39 | allowedOrigins = "secure.origin,my.localhost:8080" 40 | assert.Equal(t, CheckOrigin(allowedOrigins)(req), true) 41 | 42 | allowedOrigins = "secure.origin,*.localhost:8080" 43 | assert.Equal(t, CheckOrigin(allowedOrigins)(req), true) 44 | 45 | req.Header.Set("Origin", "http://MY.localhost:8080") 46 | assert.Equal(t, CheckOrigin(allowedOrigins)(req), true) 47 | } 48 | -------------------------------------------------------------------------------- /utils/priority_queue_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestPriorityQueuePushPopItem(t *testing.T) { 10 | pq := NewPriorityQueue[string, int]() 11 | b := pq.PushItem("b", 2) 12 | a := pq.PushItem("a", 1) 13 | d := pq.PushItem("d", 4) 14 | c := pq.PushItem("c", 3) 15 | 16 | assert.Equal(t, 4, len(*pq)) 17 | 18 | assert.Same(t, a, pq.PopItem()) 19 | assert.Same(t, b, pq.PopItem()) 20 | assert.Same(t, c, pq.PopItem()) 21 | assert.Same(t, d, pq.PopItem()) 22 | } 23 | 24 | func TestPriorityQueuePeek(t *testing.T) { 25 | pq := NewPriorityQueue[int, int]() 26 | 27 | pq.PushItem(2, 2) 28 | pq.PushItem(1, 1) 29 | pq.PushItem(3, 3) 30 | 31 | item := pq.Peek() 32 | assert.Equal(t, &PriorityQueueItem[int, int]{value: 1, priority: 1}, item) 33 | } 34 | 35 | func TestPriorityQueueUpdate(t *testing.T) { 36 | pq := NewPriorityQueue[int, int]() 37 | 38 | item := pq.PushItem(1, 1) 39 | pq.PushItem(3, 3) 40 | pq.PushItem(5, 5) 41 | 42 | pq.Update(item, 4) 43 | 44 | one := pq.PopItem() 45 | assert.Equal(t, 3, one.value) 46 | 47 | two := pq.PopItem() 48 | assert.Equal(t, 1, two.value) 49 | 50 | three := pq.PopItem() 51 | assert.Equal(t, 5, three.value) 52 | } 53 | 54 | func TestPriorityQueueRemove(t *testing.T) { 55 | pq := NewPriorityQueue[int, int]() 56 | 57 | pq.PushItem(2, 2) 58 | item := pq.PushItem(1, 1) 59 | 60 | pq.Remove(item) 61 | 62 | assert.Equal(t, 1, len(*pq)) 63 | assert.Equal(t, 2, pq.Peek().value) 64 | } 65 | -------------------------------------------------------------------------------- /vendorlib/go-mruby/hash.go: -------------------------------------------------------------------------------- 1 | package mruby 2 | 3 | // #include "gomruby.h" 4 | import "C" 5 | 6 | // Hash represents an MrbValue that is a Hash in Ruby. 7 | // 8 | // A Hash can be obtained by calling the Hash function on MrbValue. 9 | type Hash struct { 10 | *MrbValue 11 | } 12 | 13 | // Delete deletes a key from the hash, returning its existing value, 14 | // or nil if there wasn't a value. 15 | func (h *Hash) Delete(key Value) (*MrbValue, error) { 16 | keyVal := key.MrbValue(&Mrb{h.state}).value 17 | result := C.mrb_hash_delete_key(h.state, h.value, keyVal) 18 | 19 | val := newValue(h.state, result) 20 | if val.Type() == TypeNil { 21 | val = nil 22 | } 23 | 24 | return val, nil 25 | } 26 | 27 | // Get reads a value from the hash. 28 | func (h *Hash) Get(key Value) (*MrbValue, error) { 29 | keyVal := key.MrbValue(&Mrb{h.state}).value 30 | result := C.mrb_hash_get(h.state, h.value, keyVal) 31 | return newValue(h.state, result), nil 32 | } 33 | 34 | // Set sets a value on the hash 35 | func (h *Hash) Set(key, val Value) error { 36 | keyVal := key.MrbValue(&Mrb{h.state}).value 37 | valVal := val.MrbValue(&Mrb{h.state}).value 38 | C.mrb_hash_set(h.state, h.value, keyVal, valVal) 39 | return nil 40 | } 41 | 42 | // Keys returns the array of keys that the Hash has. This is returned 43 | // as an *MrbValue since this is a Ruby array. You can iterate over it as 44 | // you see fit. 45 | func (h *Hash) Keys() (*MrbValue, error) { 46 | result := C.mrb_hash_keys(h.state, h.value) 47 | return newValue(h.state, result), nil 48 | } 49 | -------------------------------------------------------------------------------- /hub/gate_test.go: -------------------------------------------------------------------------------- 1 | package hub 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | "testing" 7 | "time" 8 | 9 | "github.com/anycable/anycable-go/common" 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | func TestUnsubscribe(t *testing.T) { 15 | ctx, cancel := context.WithCancel(context.Background()) 16 | defer cancel() 17 | 18 | gate := NewGate(ctx, slog.Default()) 19 | 20 | session := NewMockSession("123") 21 | 22 | gate.Subscribe(session, "test", "test_channel") 23 | 24 | assert.Equal(t, 1, gate.Size()) 25 | 26 | gate.Unsubscribe(session, "test", "test_channel") 27 | 28 | assert.Equal(t, 0, gate.Size()) 29 | 30 | assert.Equal(t, 0, len(gate.streams)) 31 | assert.Equal(t, 0, len(gate.sessionsStreams)) 32 | } 33 | 34 | func TestShutdown(t *testing.T) { 35 | ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) 36 | defer cancel() 37 | 38 | gate := NewGate(ctx, slog.Default()) 39 | 40 | session := NewMockSession("123") 41 | 42 | gate.Subscribe(session, "test", "test_channel") 43 | 44 | gate.Broadcast(&common.StreamMessage{Stream: "test", Data: "1"}) 45 | 46 | <-ctx.Done() 47 | 48 | gate.Broadcast(&common.StreamMessage{Stream: "test", Data: "2"}) 49 | 50 | // Ignore first message if any, we want to make sure the second one is not received 51 | session.Read() // nolint:errcheck 52 | 53 | msg, err := session.Read() 54 | 55 | require.Error(t, err, "expected not to receive messages when context is done, but received: %s", msg) 56 | } 57 | -------------------------------------------------------------------------------- /logger/values.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | ) 7 | 8 | const ( 9 | maxValueLength = 100 10 | ) 11 | 12 | type compactValue[T string | []byte] struct { 13 | val T 14 | } 15 | 16 | func (c *compactValue[T]) LogValue() slog.Value { 17 | return slog.StringValue(c.String()) 18 | } 19 | 20 | func (c *compactValue[T]) String() string { 21 | val := string(c.val) 22 | 23 | if len(val) > maxValueLength { 24 | return fmt.Sprintf("%s...(%d)", val[:maxValueLength], len(val)-maxValueLength) 25 | } 26 | 27 | return val 28 | } 29 | 30 | // CompactValue wraps any scalar value to show it in log truncated 31 | func CompactValue[T string | []byte](v T) *compactValue[T] { 32 | return &compactValue[T]{val: v} 33 | } 34 | 35 | func CompactValues[T string | []byte, S []T](v S) []*compactValue[T] { 36 | res := make([]*compactValue[T], len(v)) 37 | for i, val := range v { 38 | res[i] = CompactValue(val) 39 | } 40 | 41 | return res 42 | } 43 | 44 | type compactAny struct { 45 | val interface{} 46 | } 47 | 48 | func (c *compactAny) String() string { 49 | val := fmt.Sprintf("%+v", c.val) 50 | 51 | if len(val) > maxValueLength { 52 | return fmt.Sprintf("%s...(%d)", val[:maxValueLength], len(val)-maxValueLength) 53 | } 54 | 55 | return val 56 | } 57 | 58 | func (c *compactAny) LogValue() slog.Value { 59 | return slog.StringValue(c.String()) 60 | } 61 | 62 | // CompactAny wraps any value to show it in log truncated 63 | func CompactAny(val interface{}) *compactAny { 64 | return &compactAny{val} 65 | } 66 | -------------------------------------------------------------------------------- /protos/rpc.pb.grpchan.go: -------------------------------------------------------------------------------- 1 | // Code generated by protoc-gen-grpchan. DO NOT EDIT. 2 | // source: rpc.proto 3 | 4 | package anycable 5 | 6 | import "github.com/fullstorydev/grpchan" 7 | import "golang.org/x/net/context" 8 | import "google.golang.org/grpc" 9 | 10 | func RegisterHandlerRPC(reg grpchan.ServiceRegistry, srv RPCServer) { 11 | reg.RegisterService(&_RPC_serviceDesc, srv) 12 | } 13 | 14 | type rPCChannelClient struct { 15 | ch grpchan.Channel 16 | } 17 | 18 | func NewRPCChannelClient(ch grpchan.Channel) RPCClient { 19 | return &rPCChannelClient{ch: ch} 20 | } 21 | 22 | func (c *rPCChannelClient) Connect(ctx context.Context, in *ConnectionRequest, opts ...grpc.CallOption) (*ConnectionResponse, error) { 23 | out := new(ConnectionResponse) 24 | err := c.ch.Invoke(ctx, "/anycable.RPC/Connect", in, out, opts...) 25 | if err != nil { 26 | return nil, err 27 | } 28 | return out, nil 29 | } 30 | 31 | func (c *rPCChannelClient) Command(ctx context.Context, in *CommandMessage, opts ...grpc.CallOption) (*CommandResponse, error) { 32 | out := new(CommandResponse) 33 | err := c.ch.Invoke(ctx, "/anycable.RPC/Command", in, out, opts...) 34 | if err != nil { 35 | return nil, err 36 | } 37 | return out, nil 38 | } 39 | 40 | func (c *rPCChannelClient) Disconnect(ctx context.Context, in *DisconnectRequest, opts ...grpc.CallOption) (*DisconnectResponse, error) { 41 | out := new(DisconnectResponse) 42 | err := c.ch.Invoke(ctx, "/anycable.RPC/Disconnect", in, out, opts...) 43 | if err != nil { 44 | return nil, err 45 | } 46 | return out, nil 47 | } 48 | -------------------------------------------------------------------------------- /etc/anyt/broadcast_tests/whisper_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | feature "Whisper" do 4 | channel do 5 | def subscribed 6 | stream_from "a", whisper: true 7 | end 8 | end 9 | 10 | let(:client2) { build_client(ignore: %w[ping welcome]) } 11 | let(:client3) { build_client(ignore: %w[ping welcome]) } 12 | 13 | before do 14 | subscribe_request = {command: "subscribe", identifier: {channel: channel}.to_json} 15 | 16 | client.send(subscribe_request) 17 | client2.send(subscribe_request) 18 | client3.send(subscribe_request) 19 | 20 | ack = { 21 | "identifier" => {channel: channel}.to_json, "type" => "confirm_subscription" 22 | } 23 | 24 | assert_message ack, client.receive 25 | assert_message ack, client2.receive 26 | assert_message ack, client3.receive 27 | end 28 | 29 | scenario %( 30 | Only other clients receive the whisper message 31 | ) do 32 | perform_request = { 33 | :command => "whisper", 34 | :identifier => {channel: channel}.to_json, 35 | "data" => {"event" => "typing", "user" => "Vova"} 36 | } 37 | 38 | client.send(perform_request) 39 | 40 | msg = {"identifier" => {channel: channel}.to_json, "message" => {"event" => "typing", "user" => "Vova"}} 41 | 42 | assert_message msg, client2.receive 43 | assert_message msg, client3.receive 44 | assert_raises(Anyt::Client::TimeoutError) do 45 | msg = client.receive(timeout: 0.5) 46 | raise "Client 1 should not receive the message: #{msg}" 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /vendorlib/go-mruby/args.go: -------------------------------------------------------------------------------- 1 | package mruby 2 | 3 | import "sync" 4 | 5 | // #include "gomruby.h" 6 | import "C" 7 | 8 | // ArgSpec defines how many arguments a function should take and 9 | // what kind. Multiple ArgSpecs can be combined using the "|" 10 | // operator. 11 | type ArgSpec C.mrb_aspec 12 | 13 | // ArgsAny allows any number of arguments. 14 | func ArgsAny() ArgSpec { 15 | return ArgSpec(C._go_MRB_ARGS_ANY()) 16 | } 17 | 18 | // ArgsArg says the given number of arguments are required and 19 | // the second number is optional. 20 | func ArgsArg(r, o int) ArgSpec { 21 | return ArgSpec(C._go_MRB_ARGS_ARG(C.int(r), C.int(o))) 22 | } 23 | 24 | // ArgsBlock says it takes a block argument. 25 | func ArgsBlock() ArgSpec { 26 | return ArgSpec(C._go_MRB_ARGS_BLOCK()) 27 | } 28 | 29 | // ArgsNone says it takes no arguments. 30 | func ArgsNone() ArgSpec { 31 | return ArgSpec(C._go_MRB_ARGS_NONE()) 32 | } 33 | 34 | // ArgsReq says that the given number of arguments are required. 35 | func ArgsReq(n int) ArgSpec { 36 | return ArgSpec(C._go_MRB_ARGS_REQ(C.int(n))) 37 | } 38 | 39 | // ArgsOpt says that the given number of arguments are optional. 40 | func ArgsOpt(n int) ArgSpec { 41 | return ArgSpec(C._go_MRB_ARGS_OPT(C.int(n))) 42 | } 43 | 44 | // The global accumulator when Mrb.GetArgs is called. There is a 45 | // global lock around this so that the access to it is safe. 46 | var getArgAccumulator []C.mrb_value 47 | var getArgLock = new(sync.Mutex) 48 | 49 | //export goGetArgAppend 50 | func goGetArgAppend(v C.mrb_value) { 51 | getArgAccumulator = append(getArgAccumulator, v) 52 | } 53 | -------------------------------------------------------------------------------- /server/config_test.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/BurntSushi/toml" 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestConfig__DecodeToml(t *testing.T) { 12 | tomlString := ` 13 | host = "0.0.0.0" 14 | port = 8081 15 | max_conn = 100 16 | health_path = "/healthz" 17 | ` 18 | 19 | conf := NewConfig() 20 | _, err := toml.Decode(tomlString, &conf) 21 | require.NoError(t, err) 22 | 23 | assert.Equal(t, "0.0.0.0", conf.Host) 24 | assert.Equal(t, 8081, conf.Port) 25 | assert.Equal(t, 100, conf.MaxConn) 26 | assert.Equal(t, "/healthz", conf.HealthPath) 27 | } 28 | 29 | func TestConfig__ToToml(t *testing.T) { 30 | conf := NewConfig() 31 | conf.Host = "local.test" 32 | conf.Port = 8082 33 | conf.HealthPath = "/healthz" 34 | conf.SSL.CertPath = "/path/to/cert" 35 | conf.SSL.KeyPath = "/path/to/key" 36 | conf.AllowedOrigins = "http://example.com" 37 | 38 | tomlStr := conf.ToToml() 39 | 40 | assert.Contains(t, tomlStr, "host = \"local.test\"") 41 | assert.Contains(t, tomlStr, "port = 8082") 42 | assert.Contains(t, tomlStr, "# max_conn = 1000") 43 | assert.Contains(t, tomlStr, "health_path = \"/healthz\"") 44 | assert.Contains(t, tomlStr, "allowed_origins = \"http://example.com\"") 45 | assert.Contains(t, tomlStr, "ssl.cert_path = \"/path/to/cert\"") 46 | assert.Contains(t, tomlStr, "ssl.key_path = \"/path/to/key\"") 47 | 48 | // Round-trip test 49 | conf2 := Config{} 50 | 51 | _, err := toml.Decode(tomlStr, &conf2) 52 | require.NoError(t, err) 53 | 54 | assert.Equal(t, conf, conf2) 55 | } 56 | -------------------------------------------------------------------------------- /etc/rpc.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package anycable; 4 | 5 | service RPC { 6 | rpc Connect (ConnectionRequest) returns (ConnectionResponse) {} 7 | rpc Command (CommandMessage) returns (CommandResponse) {} 8 | rpc Disconnect (DisconnectRequest) returns (DisconnectResponse) {} 9 | } 10 | 11 | enum Status { 12 | ERROR = 0; 13 | SUCCESS = 1; 14 | FAILURE = 2; 15 | } 16 | 17 | message Env { 18 | string url = 1; 19 | map headers = 2; 20 | map cstate = 3; 21 | map istate = 4; 22 | } 23 | 24 | message EnvResponse { 25 | map cstate = 1; 26 | map istate = 2; 27 | } 28 | 29 | message ConnectionRequest { 30 | Env env = 3; 31 | } 32 | 33 | message ConnectionResponse { 34 | Status status = 1; 35 | string identifiers = 2; 36 | repeated string transmissions = 3; 37 | string error_msg = 4; 38 | EnvResponse env = 5; 39 | } 40 | 41 | message CommandMessage { 42 | string command = 1; 43 | string identifier = 2; 44 | string connection_identifiers = 3; 45 | string data = 4; 46 | Env env = 5; 47 | } 48 | 49 | message CommandResponse { 50 | Status status = 1; 51 | bool disconnect = 2; 52 | bool stop_streams = 3; 53 | repeated string streams = 4; 54 | repeated string transmissions = 5; 55 | string error_msg = 6; 56 | EnvResponse env = 7; 57 | repeated string stopped_streams = 8; 58 | } 59 | 60 | message DisconnectRequest { 61 | string identifiers = 1; 62 | repeated string subscriptions = 2; 63 | Env env = 5; 64 | } 65 | 66 | message DisconnectResponse { 67 | Status status = 1; 68 | string error_msg = 2; 69 | } 70 | -------------------------------------------------------------------------------- /metrics/prometheus.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "strconv" 7 | "strings" 8 | ) 9 | 10 | const ( 11 | prometheusNamespace = "anycable_go" 12 | ) 13 | 14 | // Prometheus returns metrics info in Prometheus format 15 | func (m *Metrics) Prometheus() string { 16 | var buf strings.Builder 17 | 18 | tags := toPromTags(m.tags) 19 | 20 | m.EachCounter(func(counter *Counter) { 21 | name := prometheusNamespace + `_` + counter.Name() 22 | 23 | buf.WriteString( 24 | "\n# HELP " + name + " " + counter.Desc() + "\n", 25 | ) 26 | buf.WriteString("# TYPE " + name + " counter\n") 27 | buf.WriteString(name + tags + " " + strconv.FormatUint(counter.Value(), 10) + "\n") 28 | }) 29 | 30 | m.EachGauge(func(gauge *Gauge) { 31 | name := prometheusNamespace + `_` + gauge.Name() 32 | 33 | buf.WriteString( 34 | "\n# HELP " + name + " " + gauge.Desc() + "\n", 35 | ) 36 | buf.WriteString("# TYPE " + name + " gauge\n") 37 | buf.WriteString(name + tags + " " + strconv.FormatUint(gauge.Value(), 10) + "\n") 38 | }) 39 | 40 | return buf.String() 41 | } 42 | 43 | // PrometheusHandler is provide metrics to the world 44 | func (m *Metrics) PrometheusHandler(w http.ResponseWriter, r *http.Request) { 45 | metricsData := m.Prometheus() 46 | 47 | fmt.Fprint(w, metricsData) 48 | } 49 | 50 | func toPromTags(tags map[string]string) string { 51 | if tags == nil { 52 | return "" 53 | } 54 | 55 | buf := make([]string, len(tags)) 56 | i := 0 57 | 58 | for k, v := range tags { 59 | buf[i] = fmt.Sprintf("%s=\"%s\"", k, v) 60 | i++ 61 | } 62 | 63 | return fmt.Sprintf("{%s}", strings.Join(buf, ", ")) 64 | } 65 | -------------------------------------------------------------------------------- /encoders/encoding_cache.go: -------------------------------------------------------------------------------- 1 | package encoders 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | 7 | "github.com/anycable/anycable-go/ws" 8 | ) 9 | 10 | type EncodingCache struct { 11 | // Encoder type to encoded message mapping 12 | encodedBytes map[string]*ws.SentFrame 13 | } 14 | 15 | type EncodingFunction = func(EncodedMessage) (*ws.SentFrame, error) 16 | 17 | func NewEncodingCache() *EncodingCache { 18 | return &EncodingCache{make(map[string]*ws.SentFrame)} 19 | } 20 | 21 | func (m *EncodingCache) Fetch( 22 | msg EncodedMessage, 23 | encoder string, 24 | callback EncodingFunction, 25 | ) (*ws.SentFrame, error) { 26 | if _, ok := m.encodedBytes[encoder]; !ok { 27 | b, err := callback(msg) 28 | 29 | if err != nil { 30 | m.encodedBytes[encoder] = nil 31 | } else { 32 | m.encodedBytes[encoder] = b 33 | } 34 | } 35 | 36 | if b := m.encodedBytes[encoder]; b == nil { 37 | return nil, errors.New("Encoding failed") 38 | } else { 39 | return b, nil 40 | } 41 | } 42 | 43 | type CachedEncodedMessage struct { 44 | target EncodedMessage 45 | cache *EncodingCache 46 | } 47 | 48 | func NewCachedEncodedMessage(msg EncodedMessage) *CachedEncodedMessage { 49 | return &CachedEncodedMessage{target: msg, cache: NewEncodingCache()} 50 | } 51 | 52 | func (msg *CachedEncodedMessage) GetType() string { 53 | return msg.target.GetType() 54 | } 55 | 56 | func (msg *CachedEncodedMessage) Fetch(id string, callback EncodingFunction) (*ws.SentFrame, error) { 57 | return msg.cache.Fetch(msg.target, id, callback) 58 | } 59 | 60 | func (msg *CachedEncodedMessage) MarshalJSON() ([]byte, error) { 61 | return json.Marshal(msg.target) 62 | } 63 | -------------------------------------------------------------------------------- /mocks/Identifier.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery v2.20.0. DO NOT EDIT. 2 | 3 | package mocks 4 | 5 | import ( 6 | common "github.com/anycable/anycable-go/common" 7 | 8 | mock "github.com/stretchr/testify/mock" 9 | ) 10 | 11 | // Identifier is an autogenerated mock type for the Identifier type 12 | type Identifier struct { 13 | mock.Mock 14 | } 15 | 16 | // Identify provides a mock function with given fields: sid, env 17 | func (_m *Identifier) Identify(sid string, env *common.SessionEnv) (*common.ConnectResult, error) { 18 | ret := _m.Called(sid, env) 19 | 20 | var r0 *common.ConnectResult 21 | var r1 error 22 | if rf, ok := ret.Get(0).(func(string, *common.SessionEnv) (*common.ConnectResult, error)); ok { 23 | return rf(sid, env) 24 | } 25 | if rf, ok := ret.Get(0).(func(string, *common.SessionEnv) *common.ConnectResult); ok { 26 | r0 = rf(sid, env) 27 | } else { 28 | if ret.Get(0) != nil { 29 | r0 = ret.Get(0).(*common.ConnectResult) 30 | } 31 | } 32 | 33 | if rf, ok := ret.Get(1).(func(string, *common.SessionEnv) error); ok { 34 | r1 = rf(sid, env) 35 | } else { 36 | r1 = ret.Error(1) 37 | } 38 | 39 | return r0, r1 40 | } 41 | 42 | type mockConstructorTestingTNewIdentifier interface { 43 | mock.TestingT 44 | Cleanup(func()) 45 | } 46 | 47 | // NewIdentifier creates a new instance of Identifier. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 48 | func NewIdentifier(t mockConstructorTestingTNewIdentifier) *Identifier { 49 | mock := &Identifier{} 50 | mock.Mock.Test(t) 51 | 52 | t.Cleanup(func() { mock.AssertExpectations(t) }) 53 | 54 | return mock 55 | } 56 | -------------------------------------------------------------------------------- /vendorlib/go-mruby/value_type.go: -------------------------------------------------------------------------------- 1 | package mruby 2 | 3 | // ValueType is an enum of types that a Value can be and is returned by 4 | // Value.Type(). 5 | type ValueType uint32 6 | 7 | const ( 8 | // TypeFalse is `false` 9 | TypeFalse ValueType = iota 10 | // TypeFree is ? 11 | TypeFree 12 | // TypeTrue is `true` 13 | TypeTrue 14 | // TypeFixnum is fixnums, or integers for this case. 15 | TypeFixnum 16 | // TypeSymbol is for entities in ruby that look like `:this` 17 | TypeSymbol 18 | // TypeUndef is a value internal to ruby for uninstantiated vars. 19 | TypeUndef 20 | // TypeFloat is any floating point number such as 1.2, etc. 21 | TypeFloat 22 | // TypeCptr is a void* 23 | TypeCptr 24 | // TypeObject is a standard ruby object, base class of most instantiated objects. 25 | TypeObject 26 | // TypeClass is the base class of all classes. 27 | TypeClass 28 | // TypeModule is the base class of all Modules. 29 | TypeModule 30 | // TypeIClass is ? 31 | TypeIClass 32 | // TypeSClass is ? 33 | TypeSClass 34 | // TypeProc are procs (concrete block definitons) 35 | TypeProc 36 | // TypeArray is [] 37 | TypeArray 38 | // TypeHash is { } 39 | TypeHash 40 | // TypeString is "" 41 | TypeString 42 | // TypeRange is (0..x) 43 | TypeRange 44 | // TypeException is raised when using the raise keyword 45 | TypeException 46 | // TypeFile is for objects of the File class 47 | TypeFile 48 | // TypeEnv is for getenv/setenv etc 49 | TypeEnv 50 | // TypeData is ? 51 | TypeData 52 | // TypeFiber is for members of the Fiber class 53 | TypeFiber 54 | // TypeMaxDefine is ? 55 | TypeMaxDefine 56 | // TypeNil is nil 57 | TypeNil ValueType = 0xffffffff 58 | ) 59 | -------------------------------------------------------------------------------- /features/ping_pong.testfile: -------------------------------------------------------------------------------- 1 | launch :rpc, "bundle exec anyt --only-rpc" 2 | wait_tcp 50051 3 | 4 | launch :anycable, 5 | "./dist/anycable-go --pong_timeout=1" 6 | wait_tcp 8080 7 | 8 | scenario = [ 9 | client: { 10 | multiplier: 1, 11 | connection_options: { 12 | subprotocol: "actioncable-v1-ext-json", 13 | query: { 14 | pi: "1", 15 | ptp: "ns", 16 | } 17 | }, 18 | actions: [ 19 | { 20 | receive: { 21 | "data>": { 22 | type: "welcome" 23 | } 24 | } 25 | }, 26 | { 27 | receive: { 28 | "data>": { 29 | type: "ping" 30 | } 31 | } 32 | }, 33 | { 34 | sleep: { 35 | time: 0.5 36 | } 37 | }, 38 | { 39 | send: { 40 | data: { 41 | command: "pong" 42 | } 43 | } 44 | }, 45 | { 46 | receive: { 47 | "data>": { 48 | type: "ping" 49 | } 50 | } 51 | }, 52 | { 53 | sleep: { 54 | time: 1 55 | } 56 | }, 57 | { 58 | receive: { 59 | data: { 60 | type: "disconnect", 61 | reason: "no_pong", 62 | reconnect: true 63 | } 64 | } 65 | } 66 | ] 67 | } 68 | ] 69 | 70 | TEST_COMMAND = <<~CMD 71 | bundle exec wsdirector ws://localhost:8080/cable -i #{scenario.to_json} 72 | CMD 73 | 74 | run :wsdirector, TEST_COMMAND 75 | 76 | result = stdout(:wsdirector) 77 | 78 | if result !~ /1 clients, 0 failures/ 79 | fail "Unexpected scenario result:\n#{result}" 80 | end 81 | -------------------------------------------------------------------------------- /features/turbo_rpc_less.testfile: -------------------------------------------------------------------------------- 1 | launch :anycable, 2 | "./dist/anycable-go --turbo_rails_key=s3Krit --jwt_id_key=qwerty " \ 3 | "--metrics_rotate_interval=1 --metrics_log --metrics_log_filter=rpc_call_total,rpc_error_total,rpc_retries_total", 4 | capture_output: true 5 | 6 | wait_tcp 8080 7 | 8 | payload = {ext: {}.to_json, exp: (Time.now.to_i + 60)} 9 | 10 | token = ::JWT.encode(payload, "qwerty", "HS256") 11 | 12 | verifier = ActiveSupport::MessageVerifier.new("s3Krit", digest: "SHA256", serializer: JSON) 13 | signed_stream_name = verifier.generate("chat/2023") 14 | 15 | scenario = [ 16 | { 17 | client: { 18 | protocol: "action_cable", 19 | name: "turbo", 20 | connection_options: { 21 | query: { 22 | jid: token 23 | } 24 | }, 25 | actions: [ 26 | { 27 | subscribe: { 28 | channel: "Turbo::StreamsChannel", 29 | params: { 30 | signed_stream_name: signed_stream_name 31 | } 32 | } 33 | }, 34 | ] 35 | } 36 | } 37 | ] 38 | 39 | TEST_COMMAND = <<~CMD 40 | bundle exec wsdirector ws://localhost:8080/cable -i #{scenario.to_json} 41 | CMD 42 | 43 | run :wsdirector, TEST_COMMAND 44 | 45 | result = stdout(:wsdirector) 46 | 47 | unless result.include?("1 clients, 0 failures") 48 | fail "Unexpected scenario result:\n#{result}" 49 | end 50 | 51 | # Wait for metrics to be logged 52 | sleep 2 53 | 54 | stop :anycable 55 | 56 | logs = stdout(:anycable) 57 | 58 | # We should not see any rpc calls 59 | if logs =~ /rpc_call_total=[1-9] rpc_error_total=[1-9] rpc_retries_total=[1-9]/ 60 | fail "Expected metrics logs not found:\n#{logs}" 61 | end 62 | -------------------------------------------------------------------------------- /metrics/config_test.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/BurntSushi/toml" 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestLogEnabled(t *testing.T) { 12 | config := NewConfig() 13 | assert.False(t, config.LogEnabled()) 14 | 15 | config.Log = true 16 | assert.True(t, config.LogEnabled()) 17 | 18 | config.Log = false 19 | assert.False(t, config.LogEnabled()) 20 | 21 | config = NewConfig() 22 | config.LogFormatter = "test" 23 | assert.True(t, config.LogEnabled()) 24 | } 25 | 26 | func TestHTTPEnabled(t *testing.T) { 27 | config := NewConfig() 28 | assert.False(t, config.HTTPEnabled()) 29 | 30 | config.HTTP = "/metrics" 31 | assert.True(t, config.HTTPEnabled()) 32 | } 33 | 34 | func TestConfig_ToToml(t *testing.T) { 35 | conf := NewConfig() 36 | conf.Log = true 37 | conf.RotateInterval = 30 38 | conf.LogFilter = []string{"metric1", "metric2"} 39 | conf.Host = "example.com" 40 | conf.Port = 9090 41 | conf.Tags = map[string]string{"env": "prod", "region": "us-west"} 42 | 43 | tomlStr := conf.ToToml() 44 | 45 | assert.Contains(t, tomlStr, "host = \"example.com\"") 46 | assert.Contains(t, tomlStr, "port = 9090") 47 | assert.Contains(t, tomlStr, "log = true") 48 | assert.Contains(t, tomlStr, "rotate_interval = 30") 49 | assert.Contains(t, tomlStr, "log_filter = [ \"metric1\", \"metric2\" ]") 50 | assert.Contains(t, tomlStr, "tags.env = \"prod\"") 51 | assert.Contains(t, tomlStr, "tags.region = \"us-west\"") 52 | 53 | // Round-trip test 54 | conf2 := NewConfig() 55 | 56 | _, err := toml.Decode(tomlStr, &conf2) 57 | require.NoError(t, err) 58 | 59 | assert.Equal(t, conf, conf2) 60 | } 61 | -------------------------------------------------------------------------------- /metrics/counter.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import "sync/atomic" 4 | 5 | // Counter stores information about something "countable". 6 | // Store 7 | type Counter struct { 8 | name string 9 | desc string 10 | value uint64 11 | lastIntervalValue uint64 12 | lastIntervalDelta uint64 13 | } 14 | 15 | // NewCounter creates new Counter. 16 | func NewCounter(name string, desc string) *Counter { 17 | return &Counter{name: name, desc: desc, value: 0} 18 | } 19 | 20 | // Name returns counter name 21 | func (c *Counter) Name() string { 22 | return c.name 23 | } 24 | 25 | // Desc returns counter description 26 | func (c *Counter) Desc() string { 27 | return c.desc 28 | } 29 | 30 | // Value allows to get raw counter value. 31 | func (c *Counter) Value() uint64 { 32 | return atomic.LoadUint64(&c.value) 33 | } 34 | 35 | // IntervalValue allows to get last interval value for counter. 36 | func (c *Counter) IntervalValue() uint64 { 37 | if c.lastIntervalValue == 0 { 38 | return c.Value() 39 | } 40 | return atomic.LoadUint64(&c.lastIntervalDelta) 41 | } 42 | 43 | // Inc is equivalent to Add(name, 1) 44 | func (c *Counter) Inc() uint64 { 45 | return c.Add(1) 46 | } 47 | 48 | // Add adds the given number to the counter and returns the new value. 49 | func (c *Counter) Add(n uint64) uint64 { 50 | return atomic.AddUint64(&c.value, n) 51 | } 52 | 53 | // UpdateDelta updates the delta value for last interval based on current value and previous value. 54 | func (c *Counter) UpdateDelta() { 55 | now := atomic.LoadUint64(&c.value) 56 | atomic.StoreUint64(&c.lastIntervalDelta, now-atomic.LoadUint64(&c.lastIntervalValue)) 57 | atomic.StoreUint64(&c.lastIntervalValue, now) 58 | } 59 | -------------------------------------------------------------------------------- /rpc/barrier.go: -------------------------------------------------------------------------------- 1 | package rpc 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | type Barrier interface { 8 | Acquire() 9 | Release() 10 | BusyCount() int 11 | Capacity() int 12 | CapacityInfo() string 13 | Exhausted() 14 | HasDynamicCapacity() bool 15 | Start() 16 | Stop() 17 | } 18 | 19 | type FixedSizeBarrier struct { 20 | capacity int 21 | capacityInfo string 22 | sem chan (struct{}) 23 | } 24 | 25 | var _ Barrier = (*FixedSizeBarrier)(nil) 26 | 27 | func NewFixedSizeBarrier(capacity int) (*FixedSizeBarrier, error) { 28 | if capacity <= 0 { 29 | return nil, fmt.Errorf("RPC concurrency must be > 0") 30 | } 31 | 32 | sem := make(chan struct{}, capacity) 33 | 34 | for i := 0; i < capacity; i++ { 35 | sem <- struct{}{} 36 | } 37 | 38 | return &FixedSizeBarrier{ 39 | capacity: capacity, 40 | capacityInfo: fmt.Sprintf("%d", capacity), 41 | sem: sem, 42 | }, nil 43 | } 44 | 45 | func (b *FixedSizeBarrier) Acquire() { 46 | <-b.sem 47 | } 48 | 49 | func (b *FixedSizeBarrier) Release() { 50 | b.sem <- struct{}{} 51 | } 52 | 53 | func (b *FixedSizeBarrier) BusyCount() int { 54 | // The number of in-flight request is the 55 | // the number of initial capacity "tickets" (concurrency) 56 | // minus the size of the semaphore channel 57 | return b.capacity - len(b.sem) 58 | } 59 | 60 | func (b *FixedSizeBarrier) Capacity() int { 61 | return b.capacity 62 | } 63 | 64 | func (b *FixedSizeBarrier) CapacityInfo() string { 65 | return b.capacityInfo 66 | } 67 | 68 | func (FixedSizeBarrier) Exhausted() {} 69 | 70 | func (FixedSizeBarrier) HasDynamicCapacity() (res bool) { return } 71 | 72 | func (FixedSizeBarrier) Start() {} 73 | 74 | func (FixedSizeBarrier) Stop() {} 75 | -------------------------------------------------------------------------------- /ws/config.go: -------------------------------------------------------------------------------- 1 | package ws 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | // Config contains WebSocket connection configuration. 9 | type Config struct { 10 | Paths []string `toml:"paths"` 11 | ReadBufferSize int `toml:"read_buffer_size"` 12 | WriteBufferSize int `toml:"write_buffer_size"` 13 | MaxMessageSize int64 `toml:"max_message_size"` 14 | EnableCompression bool `toml:"enable_compression"` 15 | AllowedOrigins string `toml:"-"` 16 | } 17 | 18 | // NewConfig build a new Config struct 19 | func NewConfig() Config { 20 | return Config{Paths: []string{"/cable"}, ReadBufferSize: 1024, WriteBufferSize: 1024, MaxMessageSize: 65536} 21 | } 22 | 23 | // ToToml converts the Config struct to a TOML string representation 24 | func (c Config) ToToml() string { 25 | var result strings.Builder 26 | 27 | result.WriteString("# WebSocket endpoint paths\n") 28 | result.WriteString(fmt.Sprintf("paths = [\"%s\"]\n", strings.Join(c.Paths, "\", \""))) 29 | 30 | result.WriteString("# Read buffer size\n") 31 | result.WriteString(fmt.Sprintf("read_buffer_size = %d\n", c.ReadBufferSize)) 32 | 33 | result.WriteString("# Write buffer size\n") 34 | result.WriteString(fmt.Sprintf("write_buffer_size = %d\n", c.WriteBufferSize)) 35 | 36 | result.WriteString("# Maximum message size\n") 37 | result.WriteString(fmt.Sprintf("max_message_size = %d\n", c.MaxMessageSize)) 38 | 39 | if c.EnableCompression { 40 | result.WriteString("# Enable compression (per-message deflate)\n") 41 | result.WriteString("enable_compression = true\n") 42 | result.WriteString("# enable_compression = true\n") 43 | } 44 | 45 | result.WriteString("\n") 46 | 47 | return result.String() 48 | } 49 | -------------------------------------------------------------------------------- /cli/session_options.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "strconv" 5 | "time" 6 | 7 | "github.com/anycable/anycable-go/common" 8 | "github.com/anycable/anycable-go/node" 9 | "github.com/anycable/anycable-go/server" 10 | ) 11 | 12 | const ( 13 | pingIntervalParameter = "pi" 14 | pingPrecisionParameter = "ptp" 15 | 16 | prevSessionHeader = "X-ANYCABLE-RESTORE-SID" 17 | prevSessionParam = "sid" 18 | ) 19 | 20 | func (r *Runner) sessionOptionsFromProtocol(protocol string) []node.SessionOption { 21 | opts := []node.SessionOption{} 22 | 23 | if common.IsExtendedActionCableProtocol(protocol) { 24 | opts = append(opts, node.WithResumable(true)) 25 | 26 | if r.config.App.PongTimeout > 0 { 27 | opts = append(opts, node.WithPongTimeout(time.Duration(r.config.App.PongTimeout)*time.Second)) 28 | } 29 | } 30 | 31 | return opts 32 | } 33 | 34 | func (r *Runner) sessionOptionsFromParams(info *server.RequestInfo) []node.SessionOption { 35 | opts := []node.SessionOption{} 36 | 37 | if rawVal := info.Param(pingIntervalParameter); rawVal != "" { 38 | val, err := strconv.Atoi(rawVal) 39 | if err != nil { 40 | r.log.Warn("invalid ping interval value, must be integer", "val", rawVal) 41 | } else { 42 | opts = append(opts, node.WithPingInterval(time.Duration(val)*time.Second)) 43 | } 44 | } 45 | 46 | if val := info.Param(pingPrecisionParameter); val != "" { 47 | opts = append(opts, node.WithPingPrecision(val)) 48 | } 49 | 50 | if hval := info.AnyCableHeader(prevSessionHeader); hval != "" { 51 | opts = append(opts, node.WithPrevSID(hval)) 52 | } else if pval := info.Param(prevSessionParam); pval != "" { 53 | opts = append(opts, node.WithPrevSID(pval)) 54 | } 55 | 56 | return opts 57 | } 58 | -------------------------------------------------------------------------------- /server/headers_extractor_test.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "net/http/httptest" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestHeadersExtractor_FromRequest(t *testing.T) { 11 | req := httptest.NewRequest("GET", "/", nil) 12 | req.Header.Set("Cookie", "yummy_cookie=raisin;tasty_cookie=strawberry") 13 | req.Header.Set("X-Api-Token", "42") 14 | req.Header.Set("Accept-Language", "ru") 15 | 16 | t.Run("Without specified headers", func(t *testing.T) { 17 | extractor := DefaultHeadersExtractor{} 18 | headers := extractor.FromRequest(req) 19 | 20 | assert.Len(t, headers, 1) 21 | assert.Equal(t, "192.0.2.1", headers["REMOTE_ADDR"]) 22 | }) 23 | 24 | t.Run("With specified headers", func(t *testing.T) { 25 | extractor := DefaultHeadersExtractor{Headers: []string{"cookie", "x-api-token", "x-jid"}} 26 | headers := extractor.FromRequest(req) 27 | 28 | assert.Len(t, headers, 3) 29 | 30 | assert.Empty(t, headers["accept-language"]) 31 | assert.Equal(t, "42", headers["x-api-token"]) 32 | assert.Equal(t, "yummy_cookie=raisin;tasty_cookie=strawberry", headers["cookie"]) 33 | assert.Equal(t, "192.0.2.1", headers["REMOTE_ADDR"]) 34 | 35 | _, ok := headers["x-jid"] 36 | assert.False(t, ok) 37 | }) 38 | 39 | t.Run("With specified headers and cookie filter", func(t *testing.T) { 40 | extractor := DefaultHeadersExtractor{Headers: []string{"cookie"}, Cookies: []string{"yummy_cookie"}} 41 | headers := extractor.FromRequest(req) 42 | 43 | assert.Len(t, headers, 2) 44 | 45 | assert.Empty(t, headers["accept-language"]) 46 | assert.Equal(t, "yummy_cookie=raisin;", headers["cookie"]) 47 | assert.Equal(t, "192.0.2.1", headers["REMOTE_ADDR"]) 48 | }) 49 | } 50 | -------------------------------------------------------------------------------- /etc/anyt/broadcast_tests/options_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | feature "Broadcast with options" do 4 | channel do 5 | def subscribed 6 | stream_from "a" 7 | end 8 | 9 | def speak(data) 10 | data.delete("action") 11 | ActionCable.server.broadcast("a", data, to_others: true) 12 | end 13 | end 14 | 15 | let(:client2) { build_client(ignore: %w[ping welcome]) } 16 | let(:client3) { build_client(ignore: %w[ping welcome]) } 17 | 18 | before do 19 | subscribe_request = {command: "subscribe", identifier: {channel: channel}.to_json} 20 | 21 | client.send(subscribe_request) 22 | client2.send(subscribe_request) 23 | client3.send(subscribe_request) 24 | 25 | ack = { 26 | "identifier" => {channel: channel}.to_json, "type" => "confirm_subscription" 27 | } 28 | 29 | assert_message ack, client.receive 30 | assert_message ack, client2.receive 31 | assert_message ack, client3.receive 32 | end 33 | 34 | scenario %( 35 | Only other clients receive the message when broadcasted to others 36 | ) do 37 | perform_request = { 38 | :command => "message", 39 | :identifier => {channel: channel}.to_json, 40 | "data" => {"action" => "speak", "content" => "The Other Side"}.to_json 41 | } 42 | 43 | client.send(perform_request) 44 | 45 | msg = {"identifier" => {channel: channel}.to_json, "message" => {"content" => "The Other Side"}} 46 | 47 | assert_message msg, client2.receive 48 | assert_message msg, client3.receive 49 | assert_raises(Anyt::Client::TimeoutError) do 50 | msg = client.receive(timeout: 0.5) 51 | raise "Client 1 should not receive the message: #{msg}" 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /vendorlib/go-mruby/context.go: -------------------------------------------------------------------------------- 1 | package mruby 2 | 3 | // #include "gomruby.h" 4 | import "C" 5 | 6 | // CompileContext represents a context for code compilation. 7 | // 8 | // CompileContexts keep track of things such as filenames, line numbers, 9 | // as well as some settings for how to parse and execute code. 10 | type CompileContext struct { 11 | ctx *C.mrbc_context 12 | filename string 13 | mrb *Mrb 14 | } 15 | 16 | // NewCompileContext constructs a *CompileContext from a *Mrb. 17 | func NewCompileContext(m *Mrb) *CompileContext { 18 | return &CompileContext{ 19 | ctx: C.mrbc_context_new(m.state), 20 | mrb: m, 21 | } 22 | } 23 | 24 | // Close the context, freeing any resources associated with it. 25 | // 26 | // This is safe to call once the context has been used for parsing/loading 27 | // any Ruby code. 28 | func (c *CompileContext) Close() { 29 | C.mrbc_context_free(c.mrb.state, c.ctx) 30 | } 31 | 32 | // Filename returns the filename associated with this context. 33 | func (c *CompileContext) Filename() string { 34 | return C.GoString(c.ctx.filename) 35 | } 36 | 37 | // SetFilename sets the filename associated with this compilation context. 38 | // 39 | // Code parsed under this context will be from this file. 40 | func (c *CompileContext) SetFilename(f string) { 41 | c.filename = f 42 | c.ctx.filename = C.CString(c.filename) 43 | } 44 | 45 | // CaptureErrors toggles the capture errors feature of the parser, which 46 | // swallows errors. This allows repls and other partial parsing tools 47 | // (formatters, f.e.) to function. 48 | func (c *CompileContext) CaptureErrors(yes bool) { 49 | state := 0 50 | if yes { 51 | state = 1 52 | } 53 | 54 | C._go_mrb_context_set_capture_errors(c.ctx, C.int(state)) 55 | } 56 | -------------------------------------------------------------------------------- /docs/tracing.md: -------------------------------------------------------------------------------- 1 | # AnyCable-Go Tracing 2 | 3 | AnyCable-Go assigns a random unique `sid` (_session ID_) or use the one provided in the `X-Request-ID` HTTP header 4 | to each websocket connection and passes it with requests to RPC service. This identifier is also 5 | available in logs and you can use it to trace a request's pathway through the whole Load Balancer -> WS Server -> RPC stack. 6 | 7 | Logs example: 8 | 9 | ```sh 10 | D 2019-04-25T18:41:07.172Z context=node sid=FQQS_IltswlTJK60ncf9Cm Incoming message: &{subscribe {"channel":"PresenceChannel"} } 11 | D 2019-04-25T18:41:08.074Z context=pubsub Incoming pubsub message from Redis: {"stream":"presence:Z2lkOi8vbWFuYWdlYmFjL1NjaG9vbC8xMDAwMjI3Mw","data":"{\"type\":\"presence\",\"event\":\"user-presence-changed\",\"user_id\":1,\"status\":\"online\"}"} 12 | ``` 13 | 14 | ## Using with Heroku 15 | 16 | Heroku assigns `X-Request-ID` [automatically at the router level](https://devcenter.heroku.com/articles/http-request-id). 17 | 18 | ## Using with NGINX 19 | 20 | If you use AnyCable-Go behind NGINX server, you can assign request id with the provided configuration example: 21 | 22 | ```nginx 23 | # Сonfiguration is shortened for the sake of brevity 24 | 25 | log_format trace '$remote_addr - $remote_user [$time_local] "$request" ' 26 | '$status $body_bytes_sent "$http_referer" "$http_user_agent" ' 27 | '"$http_x_forwarded_for" $request_id'; # `trace` logger 28 | 29 | server { 30 | add_header X-Request-ID $request_id; # Return `X-Request-ID` to client 31 | 32 | location /cable { 33 | proxy_set_header X-Request-ID $request_id; # Pass X-Request-ID` to AnyCable-GO server 34 | access_log /var/log/nginx/access_trace.log trace; # Use `trace` log 35 | } 36 | } 37 | ``` 38 | -------------------------------------------------------------------------------- /etc/ssl/server.key: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEpQIBAAKCAQEAwyzxi9XhS68F82SjgjwlZ6XaL2yDwQ0clfEZHMJCJ1Pwq855 3 | KQnibS31iDjakQ2doR3cWKdYdhhfuWzhwQcBIpp8S2dagxvOXFQ3qGGNOu12Q5l1 4 | ehwEDES6eEnR9KnFYTTr9KqFxZaRNIARRJgpOdK3hy6a4PBXGICU8Pgn48j6tlcP 5 | HfwnoW//69Rj1Yj9Qv/iQ0+LAdbY0SL7jeSk4UzeBPs62h1W3nqRrq2g7m7nlqq+ 6 | zo49b6o/ozNh67OLkO5jXxfD6O6RZrRf41X4yicefHH5ufogzYVkvp2x9+0KTyhb 7 | HJ4VlrQr8CICJqXgJYDspS+Jm3hddnWmCgpLMQIDAQABAoIBAQCjsO0Av6fN5wPR 8 | p2UvFCy26jW8soEqB0ojQ2sxYIdFHrRqE6gwUBb0RKh50F0XbNj4SOgF/oxFt5mJ 9 | FZWdY7eDAxgd0ZfrAAYqD4QCl5Zwhro6ZdlOSXLnqzjNK/SIA18EcPM4Z0/8cJRl 10 | +McCxa9FzMGaAe9pmokhhq3kD+y8r9q8a4Bu/0sLI51L/ozbJhBX/3rtS4ppRN98 11 | geI9bpHrYC6BCT8XBDw61+Uibd99mfYeupqnOSDLzV98l5vTG1Lc7AUaSjKdNlYo 12 | lhLCr5nVj1ITyHXG8Uyrvwo6yVxZj/qhexGMXSE5v93jIZ/u2vVOlrXwgp3gpRWu 13 | dnBfalq1AoGBAOjLLbxz1HAoWaf4zLLDCcLHZzS0HAGR70Rnh0r2lwcCMfrLdZmV 14 | IvgJOVJ2DQ7v9+47BpepwvyMObz9je7d5DxATfSayvY+JYWNHwk5PJNUiru6l841 15 | JhqtPJ5s2P4sK/G+nlhgXo9CmZv1T8Srz7XzVhc9p1RLETSshAqJS9xvAoGBANah 16 | wzsh8pMGpjKw6tuVLrKkP3pKYmqDHGDe6kXBb5dxw86EMm6qFOrG3zTchABiUwT2 17 | FeRkoq1aU4esNCApMf0V4c9d46kakNenUYTYQ0PRRkxNitrLBDsfqopjK7UJAO9/ 18 | SNCSa6lD26HAucrnIiy0LUy0m0nL7MlSQlr+D2JfAoGBAJfuIsdfgUJB02HBCzeP 19 | +wrYQQ8wjSapK9MlDjNqhF7am+vmZbX6k3v16SdcTGF3VARzGXZaIRvaGMSzZrKC 20 | trZr8XS2ocfb/3kOBTdr15EAGBs1SGYYYen/LhTnTSd1hKidk5JyMsSk3sPeclUV 21 | HNbPHVzFrDNjWrNZ9EM8H/qZAoGBANN7OoIGdhz2nUYvWoqYWRX+julxZ72piInO 22 | u6mV6t2fZB8V1ReDkO6wm/hbG9nBCCpIS9PqcPw8lzeEryvNS4sjR4dq7MqP+Y30 23 | OHecG9Mz3n+KnDnvdjDHh+OpycQspfZWRan1zA1RZpTf8HGEAwFnW4dMIgK544uO 24 | +QDter0jAoGAaHF531Vp9qcshXSpj4ZYJwM467dyp4L6Ej3TeslEeuPbeMHyVJPn 25 | JzVhPUAKGd0fpltDVq7IMlHFkqEquzIrz4836AfbhaIXy4I+YoKBa9XbTvpyyQwF 26 | Hyv2gOFL/yjhR/h0jXFocHzXZeaeGc4Figooa1PjBQvg+aWqghvi00Y= 27 | -----END RSA PRIVATE KEY----- 28 | -------------------------------------------------------------------------------- /pubsub/subscriber.go: -------------------------------------------------------------------------------- 1 | package pubsub 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/anycable/anycable-go/common" 7 | ) 8 | 9 | // Subscriber is responsible for subscribing to individual streams and 10 | // and publishing messages to streams 11 | // 12 | //go:generate mockery --name Subscriber --output "../mocks" --outpkg mocks 13 | type Subscriber interface { 14 | Start(done chan (error)) error 15 | Shutdown(ctx context.Context) error 16 | Broadcast(msg *common.StreamMessage) 17 | BroadcastCommand(msg *common.RemoteCommandMessage) 18 | Subscribe(stream string) 19 | Unsubscribe(stream string) 20 | IsMultiNode() bool 21 | } 22 | 23 | type Handler interface { 24 | Broadcast(msg *common.StreamMessage) 25 | ExecuteRemoteCommand(msg *common.RemoteCommandMessage) 26 | } 27 | 28 | type LegacySubscriber struct { 29 | node Handler 30 | } 31 | 32 | var _ Subscriber = (*LegacySubscriber)(nil) 33 | 34 | // NewLegacySubscriber creates a legacy subscriber implementation to work with legacy Redis and NATS broadcasters 35 | func NewLegacySubscriber(node Handler) *LegacySubscriber { 36 | return &LegacySubscriber{node: node} 37 | } 38 | 39 | func (LegacySubscriber) Start(done chan (error)) error { 40 | return nil 41 | } 42 | 43 | func (LegacySubscriber) Shutdown(ctx context.Context) error { 44 | return nil 45 | } 46 | 47 | func (LegacySubscriber) Subscribe(stream string) { 48 | } 49 | 50 | func (LegacySubscriber) Unsubscribe(stream string) { 51 | } 52 | 53 | func (s *LegacySubscriber) Broadcast(msg *common.StreamMessage) { 54 | s.node.Broadcast(msg) 55 | } 56 | 57 | func (s *LegacySubscriber) BroadcastCommand(cmd *common.RemoteCommandMessage) { 58 | s.node.ExecuteRemoteCommand(cmd) 59 | } 60 | 61 | func (s *LegacySubscriber) IsMultiNode() bool { 62 | return false 63 | } 64 | -------------------------------------------------------------------------------- /features/standalone.testfile: -------------------------------------------------------------------------------- 1 | launch :anycable, 2 | "./dist/anycable-go --secret=s3Krit --norpc" 3 | 4 | wait_tcp 8080 5 | 6 | payload = {ext: {}.to_json, exp: (Time.now.to_i + 60)} 7 | 8 | token = ::JWT.encode(payload, "s3Krit", "HS256") 9 | 10 | verifier = ActiveSupport::MessageVerifier.new("s3Krit", digest: "SHA256", serializer: JSON) 11 | signed_stream_name = verifier.generate("chat/2023") 12 | 13 | # Authenticated client + subscription 14 | scenario = [ 15 | { 16 | client: { 17 | protocol: "action_cable", 18 | name: "turbo", 19 | connection_options: { 20 | query: { 21 | jid: token 22 | } 23 | }, 24 | actions: [ 25 | { 26 | subscribe: { 27 | channel: "$pubsub", 28 | params: { 29 | signed_stream_name: signed_stream_name 30 | } 31 | } 32 | }, 33 | ] 34 | } 35 | } 36 | ] 37 | 38 | TEST_COMMAND = <<~CMD 39 | bundle exec wsdirector ws://localhost:8080/cable -i #{scenario.to_json} 40 | CMD 41 | 42 | run :wsdirector, TEST_COMMAND 43 | 44 | result = stdout(:wsdirector) 45 | 46 | unless result.include?("1 clients, 0 failures") 47 | fail "Unexpected scenario result:\n#{result}" 48 | end 49 | 50 | # Unauthenticated client 51 | scenario = [ 52 | { 53 | receive: { 54 | data: { 55 | type: "disconnect", 56 | reason: "unauthorized", 57 | reconnect: false 58 | } 59 | } 60 | } 61 | ] 62 | 63 | TEST_COMMAND = <<~CMD 64 | bundle exec wsdirector ws://localhost:8080/cable -i #{scenario.to_json} 65 | CMD 66 | 67 | run :wsdirector, TEST_COMMAND 68 | 69 | result = stdout(:wsdirector) 70 | 71 | unless result.include?("1 clients, 0 failures") 72 | fail "Unexpected scenario result:\n#{result}" 73 | end 74 | -------------------------------------------------------------------------------- /node/disconnector.go: -------------------------------------------------------------------------------- 1 | package node 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | // Disconnector is an interface for disconnect queue implementation 8 | type Disconnector interface { 9 | Run() error 10 | Shutdown(ctx context.Context) error 11 | Enqueue(*Session) error 12 | Size() int 13 | } 14 | 15 | // NoopDisconnectQueue is non-operational disconnect queue implementation 16 | type NoopDisconnectQueue struct{} 17 | 18 | // Run does nothing 19 | func (d *NoopDisconnectQueue) Run() error { 20 | return nil 21 | } 22 | 23 | // Shutdown does nothing 24 | func (d *NoopDisconnectQueue) Shutdown(ctx context.Context) error { 25 | return nil 26 | } 27 | 28 | // Size returns 0 29 | func (d *NoopDisconnectQueue) Size() int { 30 | return 0 31 | } 32 | 33 | // Enqueue does nothing 34 | func (d *NoopDisconnectQueue) Enqueue(s *Session) error { 35 | return nil 36 | } 37 | 38 | // NewNoopDisconnector returns new NoopDisconnectQueue 39 | func NewNoopDisconnector() *NoopDisconnectQueue { 40 | return &NoopDisconnectQueue{} 41 | } 42 | 43 | // InlineDisconnector performs Disconnect calls synchronously 44 | type InlineDisconnector struct { 45 | n *Node 46 | } 47 | 48 | // Run does nothing 49 | func (d *InlineDisconnector) Run() error { 50 | return nil 51 | } 52 | 53 | // Shutdown does nothing 54 | func (d *InlineDisconnector) Shutdown(ctx context.Context) error { 55 | return nil 56 | } 57 | 58 | // Size returns 0 59 | func (d *InlineDisconnector) Size() int { 60 | return 0 61 | } 62 | 63 | // Enqueue disconnects session immediately 64 | func (d *InlineDisconnector) Enqueue(s *Session) error { 65 | return d.n.DisconnectNow(s) 66 | } 67 | 68 | // NewInlineDisconnector returns new InlineDisconnector 69 | func NewInlineDisconnector(n *Node) *InlineDisconnector { 70 | return &InlineDisconnector{n: n} 71 | } 72 | -------------------------------------------------------------------------------- /mocks/Broadcaster.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery v2.20.0. DO NOT EDIT. 2 | 3 | package mocks 4 | 5 | import ( 6 | context "context" 7 | 8 | mock "github.com/stretchr/testify/mock" 9 | ) 10 | 11 | // Broadcaster is an autogenerated mock type for the Broadcaster type 12 | type Broadcaster struct { 13 | mock.Mock 14 | } 15 | 16 | // IsFanout provides a mock function with given fields: 17 | func (_m *Broadcaster) IsFanout() bool { 18 | ret := _m.Called() 19 | 20 | var r0 bool 21 | if rf, ok := ret.Get(0).(func() bool); ok { 22 | r0 = rf() 23 | } else { 24 | r0 = ret.Get(0).(bool) 25 | } 26 | 27 | return r0 28 | } 29 | 30 | // Shutdown provides a mock function with given fields: ctx 31 | func (_m *Broadcaster) Shutdown(ctx context.Context) error { 32 | ret := _m.Called(ctx) 33 | 34 | var r0 error 35 | if rf, ok := ret.Get(0).(func(context.Context) error); ok { 36 | r0 = rf(ctx) 37 | } else { 38 | r0 = ret.Error(0) 39 | } 40 | 41 | return r0 42 | } 43 | 44 | // Start provides a mock function with given fields: done 45 | func (_m *Broadcaster) Start(done chan error) error { 46 | ret := _m.Called(done) 47 | 48 | var r0 error 49 | if rf, ok := ret.Get(0).(func(chan error) error); ok { 50 | r0 = rf(done) 51 | } else { 52 | r0 = ret.Error(0) 53 | } 54 | 55 | return r0 56 | } 57 | 58 | type mockConstructorTestingTNewBroadcaster interface { 59 | mock.TestingT 60 | Cleanup(func()) 61 | } 62 | 63 | // NewBroadcaster creates a new instance of Broadcaster. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 64 | func NewBroadcaster(t mockConstructorTestingTNewBroadcaster) *Broadcaster { 65 | mock := &Broadcaster{} 66 | mock.Mock.Test(t) 67 | 68 | t.Cleanup(func() { mock.AssertExpectations(t) }) 69 | 70 | return mock 71 | } 72 | -------------------------------------------------------------------------------- /.github/workflows/benchmark.yml: -------------------------------------------------------------------------------- 1 | name: Benchmark 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | paths: 8 | - "**/*.go" 9 | - "go.sum" 10 | workflow_dispatch: 11 | pull_request: 12 | 13 | jobs: 14 | benchmark: 15 | timeout-minutes: 10 16 | runs-on: ubuntu-latest 17 | env: 18 | GO111MODULE: on 19 | DEBUG: true 20 | services: 21 | redis: 22 | image: redis:6.0-alpine 23 | ports: ["6379:6379"] 24 | options: --health-cmd="redis-cli ping" --health-interval 1s --health-timeout 3s --health-retries 30 25 | steps: 26 | - uses: actions/checkout@v4 27 | - uses: actions/setup-go@v4 28 | with: 29 | go-version-file: go.mod 30 | - name: Install system deps 31 | run: | 32 | sudo apt-get update 33 | sudo apt-get install bison 34 | - name: Install websocket-bench & gops 35 | run: | 36 | go install github.com/anycable/websocket-bench@latest 37 | go install github.com/google/gops@latest 38 | - uses: ruby/setup-ruby@v1 39 | with: 40 | # Use <3.0 since go-mruby's Rakefile has some problems with keyword arguments compatibility 41 | ruby-version: 2.7 42 | bundler-cache: true 43 | - uses: actions/cache@v3 44 | with: 45 | path: vendor 46 | key: vendor-${{ hashFiles('**/go.sum') }} 47 | restore-keys: | 48 | vendor- 49 | - run: go mod vendor 50 | - name: Build mruby 51 | run: bash -c '(cd vendor/github.com/mitchellh/go-mruby && MRUBY_CONFIG=../../../../../../etc/build_config.rb make libmruby.a)' 52 | - name: Build test binary 53 | env: 54 | BUILD_ARGS: "-race" 55 | run: | 56 | make build 57 | - name: Run benchmarks 58 | run: | 59 | bundle install 60 | make benchmarks 61 | -------------------------------------------------------------------------------- /ws/connection.go: -------------------------------------------------------------------------------- 1 | package ws 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/gorilla/websocket" 7 | ) 8 | 9 | // Connection is a WebSocket implementation of Connection 10 | type Connection struct { 11 | conn *websocket.Conn 12 | } 13 | 14 | func NewConnection(conn *websocket.Conn) *Connection { 15 | return &Connection{conn} 16 | } 17 | 18 | // Write writes a text message to a WebSocket 19 | func (ws Connection) Write(msg []byte, deadline time.Time) error { 20 | if err := ws.conn.SetWriteDeadline(deadline); err != nil { 21 | return err 22 | } 23 | 24 | w, err := ws.conn.NextWriter(websocket.TextMessage) 25 | 26 | if err != nil { 27 | return err 28 | } 29 | 30 | if _, err = w.Write(msg); err != nil { 31 | return err 32 | } 33 | 34 | return w.Close() 35 | } 36 | 37 | // WriteBinary writes a binary message to a WebSocket 38 | func (ws Connection) WriteBinary(msg []byte, deadline time.Time) error { 39 | if err := ws.conn.SetWriteDeadline(deadline); err != nil { 40 | return err 41 | } 42 | 43 | w, err := ws.conn.NextWriter(websocket.BinaryMessage) 44 | 45 | if err != nil { 46 | return err 47 | } 48 | 49 | if _, err = w.Write(msg); err != nil { 50 | return err 51 | } 52 | 53 | return w.Close() 54 | } 55 | 56 | func (ws Connection) Read() ([]byte, error) { 57 | _, message, err := ws.conn.ReadMessage() 58 | return message, err 59 | } 60 | 61 | // Close sends close frame with a given code and a reason 62 | func (ws Connection) Close(code int, reason string) { 63 | CloseWithReason(ws.conn, code, reason) 64 | } 65 | 66 | // CloseWithReason closes WebSocket connection with the specified close code and reason 67 | func CloseWithReason(ws *websocket.Conn, code int, reason string) { 68 | deadline := time.Now().Add(time.Second) 69 | msg := websocket.FormatCloseMessage(code, reason) 70 | ws.WriteControl(websocket.CloseMessage, msg, deadline) //nolint:errcheck 71 | ws.Close() 72 | } 73 | -------------------------------------------------------------------------------- /metrics/custom_printer.go: -------------------------------------------------------------------------------- 1 | //go:build (darwin && mrb) || (linux && mrb) 2 | // +build darwin,mrb linux,mrb 3 | 4 | package metrics 5 | 6 | import ( 7 | "fmt" 8 | "log/slog" 9 | 10 | "github.com/anycable/anycable-go/mrb" 11 | "github.com/mitchellh/go-mruby" 12 | ) 13 | 14 | // RubyPrinter contains refs to mruby vm and code 15 | type RubyPrinter struct { 16 | path string 17 | mrbModule *mruby.MrbValue 18 | engine *mrb.Engine 19 | 20 | log *slog.Logger 21 | } 22 | 23 | // NewCustomPrinter generates log formatter from the provided (as path) 24 | // Ruby script 25 | func NewCustomPrinter(path string, l *slog.Logger) (*RubyPrinter, error) { 26 | return &RubyPrinter{path: path, log: l}, nil 27 | } 28 | 29 | // Run initializes the Ruby VM 30 | func (p *RubyPrinter) Run(interval int) error { 31 | p.engine = mrb.DefaultEngine() 32 | 33 | if err := p.engine.LoadFile(p.path); err != nil { 34 | return err 35 | } 36 | 37 | mod := p.engine.VM.Module("MetricsFormatter") 38 | 39 | p.mrbModule = mod.MrbValue(p.engine.VM) 40 | 41 | p.log.Info(fmt.Sprintf("Log metrics every %ds using a custom Ruby formatter from %s", interval, p.path)) 42 | 43 | return nil 44 | } 45 | 46 | func (p *RubyPrinter) Stop() { 47 | } 48 | 49 | // Write prints formatted snapshot to the log 50 | func (p *RubyPrinter) Write(m *Metrics) error { 51 | snapshot := m.IntervalSnapshot() 52 | p.Print(snapshot) 53 | return nil 54 | } 55 | 56 | // Print calls Ruby script to format the output and prints it to the log 57 | func (p *RubyPrinter) Print(snapshot map[string]uint64) { 58 | rhash, _ := p.engine.VM.LoadString("{}") 59 | 60 | hash := rhash.Hash() 61 | 62 | for k, v := range snapshot { 63 | hash.Set(mruby.String(k), mruby.Int(v)) 64 | } 65 | 66 | result, err := p.mrbModule.Call("call", rhash) 67 | 68 | if err != nil { 69 | p.log.Error("mruby call failed", "error", err) 70 | return 71 | } 72 | 73 | p.log.Info(result.String()) 74 | } 75 | -------------------------------------------------------------------------------- /server/config.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | type Config struct { 9 | Host string `toml:"host"` 10 | Port int `toml:"port"` 11 | AllowedOrigins string `toml:"allowed_origins"` 12 | MaxConn int `toml:"max_conn"` 13 | HealthPath string `toml:"health_path"` 14 | SSL SSLConfig `toml:"ssl"` 15 | } 16 | 17 | func NewConfig() Config { 18 | return Config{ 19 | Host: "localhost", 20 | Port: 8080, 21 | HealthPath: "/health", 22 | SSL: NewSSLConfig(), 23 | } 24 | } 25 | 26 | func (c Config) ToToml() string { 27 | var result strings.Builder 28 | 29 | result.WriteString("# Host address to bind to\n") 30 | result.WriteString(fmt.Sprintf("host = %q\n", c.Host)) 31 | result.WriteString("# Port to listen on\n") 32 | result.WriteString(fmt.Sprintf("port = %d\n", c.Port)) 33 | 34 | result.WriteString("# Allowed origins (a comma-separated list)\n") 35 | result.WriteString(fmt.Sprintf("allowed_origins = \"%s\"\n", c.AllowedOrigins)) 36 | 37 | result.WriteString("# Maximum number of allowed concurrent connections\n") 38 | if c.MaxConn == 0 { 39 | result.WriteString("# max_conn = 1000\n") 40 | } else { 41 | result.WriteString(fmt.Sprintf("max_conn = %d\n", c.MaxConn)) 42 | } 43 | result.WriteString("# Health check endpoint path\n") 44 | result.WriteString(fmt.Sprintf("health_path = %q\n", c.HealthPath)) 45 | 46 | result.WriteString("# SSL configuration\n") 47 | 48 | if c.SSL.CertPath != "" { 49 | result.WriteString(fmt.Sprintf("ssl.cert_path = %q\n", c.SSL.CertPath)) 50 | } else { 51 | result.WriteString("# ssl.cert_path =\n") 52 | } 53 | 54 | if c.SSL.KeyPath != "" { 55 | result.WriteString(fmt.Sprintf("ssl.key_path = %q\n", c.SSL.KeyPath)) 56 | } else { 57 | result.WriteString("# ssl.key_path =\n") 58 | } 59 | 60 | result.WriteString("\n") 61 | 62 | return result.String() 63 | } 64 | -------------------------------------------------------------------------------- /broker/config.go: -------------------------------------------------------------------------------- 1 | package broker 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | type Config struct { 9 | // Adapter name 10 | Adapter string `toml:"adapter"` 11 | // For how long to keep history in seconds 12 | HistoryTTL int64 `toml:"history_ttl"` 13 | // Max size of messages to keep in the history per stream 14 | HistoryLimit int `toml:"history_limit"` 15 | // Sessions cache TTL in seconds (after disconnect) 16 | SessionsTTL int64 `toml:"sessions_ttl"` 17 | // Presence expire TTL in seconds (after disconnect) 18 | PresenceTTL int64 `toml:"presence_ttl"` 19 | } 20 | 21 | func NewConfig() Config { 22 | return Config{ 23 | // 5 minutes by default 24 | HistoryTTL: 5 * 60, 25 | // 100 msgs by default 26 | HistoryLimit: 100, 27 | // 5 minutes by default 28 | SessionsTTL: 5 * 60, 29 | // 15 seconds by default 30 | PresenceTTL: 15, 31 | } 32 | } 33 | 34 | func (c Config) ToToml() string { 35 | var result strings.Builder 36 | 37 | result.WriteString("# Broker backend adapter\n") 38 | if c.Adapter == "" { 39 | result.WriteString("# adapter = \"memory\"\n") 40 | } else { 41 | result.WriteString(fmt.Sprintf("adapter = \"%s\"\n", c.Adapter)) 42 | } 43 | 44 | result.WriteString("# For how long to keep streams history (seconds)\n") 45 | result.WriteString(fmt.Sprintf("history_ttl = %d\n", c.HistoryTTL)) 46 | 47 | result.WriteString("# Max number of messages to keep in a stream history\n") 48 | result.WriteString(fmt.Sprintf("history_limit = %d\n", c.HistoryLimit)) 49 | 50 | result.WriteString("# For how long to store sessions state for resumeability (seconds)\n") 51 | result.WriteString(fmt.Sprintf("sessions_ttl = %d\n", c.SessionsTTL)) 52 | 53 | result.WriteString("# For how long to keep presence information after session disconnect (seconds)\n") 54 | result.WriteString(fmt.Sprintf("presence_ttl = %d\n", c.PresenceTTL)) 55 | 56 | result.WriteString("\n") 57 | 58 | return result.String() 59 | } 60 | -------------------------------------------------------------------------------- /etc/anyt/broker_tests/restore_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | feature "Session cache" do 4 | channel do 5 | def subscribed 6 | stream_from "cache_a" 7 | end 8 | end 9 | 10 | before do 11 | client = build_client(ignore: ["ping"], protocol: "actioncable-v1-ext-json") 12 | 13 | welcome_msg = client.receive 14 | assert_message({ "type" => "welcome" }, welcome_msg) 15 | 16 | assert_includes welcome_msg, "sid" 17 | 18 | @sid = welcome_msg["sid"] 19 | 20 | subscribe_request = {command: "subscribe", identifier: {channel: channel}.to_json} 21 | client.send(subscribe_request) 22 | 23 | ack = { 24 | "identifier" => {channel: channel}.to_json, "type" => "confirm_subscription" 25 | } 26 | 27 | assert_equal ack, client.receive 28 | 29 | ActionCable.server.broadcast( 30 | "cache_a", 31 | {data: {user_id: 1, status: "left"}} 32 | ) 33 | 34 | msg = { 35 | "identifier" => {channel: channel}.to_json, 36 | "message" => { 37 | "data" => {"user_id" => 1, "status" => "left"} 38 | } 39 | } 40 | 41 | assert_message msg, client.receive 42 | end 43 | 44 | scenario %( 45 | Restore session by session ID 46 | ) do 47 | client.close 48 | 49 | another_client = build_client( 50 | ignore: ["ping"], 51 | headers: { 52 | "X-ANYCABLE-RESTORE-SID" => @sid 53 | }, 54 | protocol: "actioncable-v1-ext-json" 55 | ) 56 | 57 | assert_message({ "type" => "welcome", "restored" => true }, another_client.receive) 58 | 59 | ActionCable.server.broadcast( 60 | "cache_a", 61 | {data: {user_id: 2, status: "join"}} 62 | ) 63 | 64 | msg = { 65 | "identifier" => {channel: channel}.to_json, 66 | "message" => { 67 | "data" => {"user_id" => 2, "status" => "join"} 68 | } 69 | } 70 | 71 | assert_message msg, another_client.receive 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /logger/logger.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | "os" 7 | 8 | "github.com/anycable/anycable-go/utils" 9 | "github.com/lmittmann/tint" 10 | ) 11 | 12 | func InitLogger(format string, level string) (slog.Handler, error) { 13 | logLevel, err := parseLevel(level) 14 | 15 | if err != nil { 16 | return nil, err 17 | } 18 | 19 | var handler slog.Handler 20 | 21 | switch format { 22 | case "text": 23 | { 24 | opts := &tint.Options{ 25 | Level: logLevel, 26 | NoColor: !utils.IsTTY(), 27 | TimeFormat: "2006-01-02 15:04:05.000", 28 | ReplaceAttr: transformAttr, 29 | } 30 | handler = tint.NewHandler(os.Stdout, opts) 31 | } 32 | case "json": 33 | { 34 | handler = slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: logLevel}) 35 | } 36 | default: 37 | { 38 | return nil, fmt.Errorf("unknown log format: %s.\nAvaialable formats are: text, json", format) 39 | } 40 | } 41 | 42 | logger := slog.New(handler) 43 | slog.SetDefault(logger) 44 | 45 | return handler, nil 46 | } 47 | 48 | var LevelNames = map[string]slog.Level{ 49 | "debug": slog.LevelDebug, 50 | "info": slog.LevelInfo, 51 | "warn": slog.LevelWarn, 52 | "error": slog.LevelError, 53 | } 54 | 55 | func parseLevel(level string) (slog.Level, error) { 56 | lvl, ok := LevelNames[level] 57 | if !ok { 58 | return slog.LevelInfo, fmt.Errorf("unknown log level: %s.\nAvailable levels are: debug, info, warn, error", level) 59 | } 60 | 61 | return lvl, nil 62 | } 63 | 64 | // Perform some transformations before sending the log record to the handler: 65 | // - Transform errors into messages to avoid showing stack traces 66 | func transformAttr(groups []string, attr slog.Attr) slog.Attr { 67 | if attr.Key == "err" || attr.Key == "error" { 68 | if err, ok := attr.Value.Any().(error); ok { 69 | return slog.Attr{Key: attr.Key, Value: slog.StringValue(err.Error())} 70 | } 71 | } 72 | 73 | return attr 74 | } 75 | -------------------------------------------------------------------------------- /rpc/config_test.go: -------------------------------------------------------------------------------- 1 | package rpc 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/BurntSushi/toml" 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestConfig_Impl(t *testing.T) { 12 | c := NewConfig() 13 | 14 | c.Implementation = "http" 15 | assert.Equal(t, "http", c.Impl()) 16 | 17 | c.Implementation = "trpc" 18 | assert.Equal(t, "trpc", c.Impl()) 19 | 20 | c.Implementation = "" 21 | assert.Equal(t, "grpc", c.Impl()) 22 | 23 | c.Host = "http://localhost:8080/anycable" 24 | assert.Equal(t, "http", c.Impl()) 25 | 26 | c.Host = "https://localhost:8080/anycable" 27 | assert.Equal(t, "http", c.Impl()) 28 | 29 | c.Host = "grpc://localhost:50051/anycable" 30 | assert.Equal(t, "grpc", c.Impl()) 31 | 32 | c.Host = "dns:///rpc:50051" 33 | assert.Equal(t, "grpc", c.Impl()) 34 | 35 | c.Host = "localhost:50051/anycable" 36 | assert.Equal(t, "grpc", c.Impl()) 37 | 38 | c.Host = "127.0.0.1:50051/anycable" 39 | assert.Equal(t, "grpc", c.Impl()) 40 | 41 | c.Host = "invalid://:+" 42 | assert.Equal(t, "", c.Impl()) 43 | } 44 | 45 | func TestConfig__ToToml(t *testing.T) { 46 | conf := NewConfig() 47 | conf.Host = "rpc.test" 48 | conf.Concurrency = 10 49 | conf.Implementation = "http" 50 | conf.ProxyHeaders = []string{"Cookie", "X-Api-Key"} 51 | conf.ProxyCookies = []string{"_session_id", "_csrf_token"} 52 | 53 | tomlStr := conf.ToToml() 54 | 55 | assert.Contains(t, tomlStr, "implementation = \"http\"") 56 | assert.Contains(t, tomlStr, "host = \"rpc.test\"") 57 | assert.Contains(t, tomlStr, "concurrency = 10") 58 | assert.Contains(t, tomlStr, "proxy_headers = [\"Cookie\", \"X-Api-Key\"]") 59 | assert.Contains(t, tomlStr, "proxy_cookies = [\"_session_id\", \"_csrf_token\"]") 60 | 61 | // Round-trip test 62 | conf2 := Config{} 63 | 64 | _, err := toml.Decode(tomlStr, &conf2) 65 | require.NoError(t, err) 66 | 67 | assert.Equal(t, conf, conf2) 68 | } 69 | -------------------------------------------------------------------------------- /metrics/printer.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import ( 4 | "log/slog" 5 | "slices" 6 | "strings" 7 | 8 | "github.com/anycable/anycable-go/utils" 9 | ) 10 | 11 | // Printer describes metrics logging interface 12 | type Printer interface { 13 | Print(snapshot map[string]int64) 14 | } 15 | 16 | // BasePrinter simply logs stats as structured log 17 | type BasePrinter struct { 18 | filter map[string]struct{} 19 | 20 | log *slog.Logger 21 | } 22 | 23 | // NewBasePrinter returns new base printer struct 24 | func NewBasePrinter(filterList []string, l *slog.Logger) *BasePrinter { 25 | var filter map[string]struct{} 26 | 27 | if filterList != nil { 28 | filter = make(map[string]struct{}, len(filterList)) 29 | for _, k := range filterList { 30 | filter[k] = struct{}{} 31 | } 32 | } 33 | 34 | return &BasePrinter{filter: filter, log: l} 35 | } 36 | 37 | // Run prints a message to the log with metrics logging details 38 | func (p *BasePrinter) Run(interval int) error { 39 | if p.filter != nil { 40 | p.log.Info("log metrics", "interval", interval, "fields", strings.Join(utils.Keys(p.filter), ",")) 41 | } else { 42 | p.log.Info("log metrics", "interval", interval) 43 | } 44 | return nil 45 | } 46 | 47 | func (p *BasePrinter) Stop() { 48 | } 49 | 50 | // Write prints formatted snapshot to the log 51 | func (p *BasePrinter) Write(m *Metrics) error { 52 | snapshot := m.IntervalSnapshot() 53 | p.Print(snapshot) 54 | return nil 55 | } 56 | 57 | // Print logs stats data using global logger with info level 58 | func (p *BasePrinter) Print(snapshot map[string]uint64) { 59 | // Sort keys to provide deterministic output 60 | keys := utils.Keys(snapshot) 61 | slices.Sort(keys) 62 | 63 | fields := make([]interface{}, 0) 64 | 65 | for _, k := range keys { 66 | v := snapshot[k] 67 | if p.filter == nil { 68 | fields = append(fields, k, v) 69 | } else if _, ok := p.filter[k]; ok { 70 | fields = append(fields, k, v) 71 | } 72 | } 73 | 74 | p.log.Info("", fields...) 75 | } 76 | -------------------------------------------------------------------------------- /vendorlib/go-mruby/hash_test.go: -------------------------------------------------------------------------------- 1 | package mruby 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestHash(t *testing.T) { 8 | mrb := NewMrb() 9 | defer mrb.Close() 10 | 11 | value, err := mrb.LoadString(`{"foo" => "bar", "baz" => false}`) 12 | if err != nil { 13 | t.Fatalf("err: %s", err) 14 | } 15 | 16 | h := value.Hash() 17 | 18 | // Get 19 | value, err = h.Get(String("foo")) 20 | if err != nil { 21 | t.Fatalf("err: %s", err) 22 | } 23 | if value.String() != "bar" { 24 | t.Fatalf("bad: %s", value) 25 | } 26 | 27 | // Get false type 28 | value, err = h.Get(String("baz")) 29 | if err != nil { 30 | t.Fatalf("err: %s", err) 31 | } 32 | if valType := value.Type(); valType != TypeFalse { 33 | t.Fatalf("bad type: %v", valType) 34 | } 35 | if value.String() != "false" { 36 | t.Fatalf("bad: %s", value) 37 | } 38 | 39 | // Set 40 | err = h.Set(String("foo"), String("baz")) 41 | if err != nil { 42 | t.Fatalf("err: %s", err) 43 | } 44 | value, err = h.Get(String("foo")) 45 | if err != nil { 46 | t.Fatalf("err: %s", err) 47 | } 48 | if value.String() != "baz" { 49 | t.Fatalf("bad: %s", value) 50 | } 51 | 52 | // Keys 53 | value, err = h.Keys() 54 | if err != nil { 55 | t.Fatalf("err: %s", err) 56 | } 57 | if value.Type() != TypeArray { 58 | t.Fatalf("bad: %v", value.Type()) 59 | } 60 | if value.String() != `["foo", "baz"]` { 61 | t.Fatalf("bad: %s", value) 62 | } 63 | 64 | // Delete 65 | value, err = h.Delete(String("foo")) 66 | if err != nil { 67 | t.Fatalf("err: %s", err) 68 | } 69 | if value.String() != "baz" { 70 | t.Fatalf("bad: %s", value) 71 | } 72 | 73 | value, err = h.Keys() 74 | if err != nil { 75 | t.Fatalf("err: %s", err) 76 | } 77 | if value.String() != `["baz"]` { 78 | t.Fatalf("bad: %s", value) 79 | } 80 | 81 | // Delete non-existing 82 | value, err = h.Delete(String("nope")) 83 | if err != nil { 84 | t.Fatalf("err: %s", err) 85 | } 86 | if value != nil { 87 | t.Fatalf("bad: %s", value) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /mocks/Instrumenter.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery v2.20.0. DO NOT EDIT. 2 | 3 | package mocks 4 | 5 | import mock "github.com/stretchr/testify/mock" 6 | 7 | // Instrumenter is an autogenerated mock type for the Instrumenter type 8 | type Instrumenter struct { 9 | mock.Mock 10 | } 11 | 12 | // CounterAdd provides a mock function with given fields: name, val 13 | func (_m *Instrumenter) CounterAdd(name string, val uint64) { 14 | _m.Called(name, val) 15 | } 16 | 17 | // CounterIncrement provides a mock function with given fields: name 18 | func (_m *Instrumenter) CounterIncrement(name string) { 19 | _m.Called(name) 20 | } 21 | 22 | // GaugeDecrement provides a mock function with given fields: name 23 | func (_m *Instrumenter) GaugeDecrement(name string) { 24 | _m.Called(name) 25 | } 26 | 27 | // GaugeIncrement provides a mock function with given fields: name 28 | func (_m *Instrumenter) GaugeIncrement(name string) { 29 | _m.Called(name) 30 | } 31 | 32 | // GaugeSet provides a mock function with given fields: name, val 33 | func (_m *Instrumenter) GaugeSet(name string, val uint64) { 34 | _m.Called(name, val) 35 | } 36 | 37 | // RegisterCounter provides a mock function with given fields: name, desc 38 | func (_m *Instrumenter) RegisterCounter(name string, desc string) { 39 | _m.Called(name, desc) 40 | } 41 | 42 | // RegisterGauge provides a mock function with given fields: name, desc 43 | func (_m *Instrumenter) RegisterGauge(name string, desc string) { 44 | _m.Called(name, desc) 45 | } 46 | 47 | type mockConstructorTestingTNewInstrumenter interface { 48 | mock.TestingT 49 | Cleanup(func()) 50 | } 51 | 52 | // NewInstrumenter creates a new instance of Instrumenter. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 53 | func NewInstrumenter(t mockConstructorTestingTNewInstrumenter) *Instrumenter { 54 | mock := &Instrumenter{} 55 | mock.Mock.Test(t) 56 | 57 | t.Cleanup(func() { mock.AssertExpectations(t) }) 58 | 59 | return mock 60 | } 61 | -------------------------------------------------------------------------------- /docs/library.md: -------------------------------------------------------------------------------- 1 | # Using anycable-go as a library 2 | 3 | You can use AnyCable-Go as a library to build custom real-time applications. 4 | 5 | > Read ["AnyCable off Rails: connecting Twilio streams with Hanami"](https://evilmartians.com/chronicles/anycable-goes-off-rails-connecting-twilio-streams-with-hanami) to learn how we've integrated Twilio Streams with a Hanami application via AnyCable-Go. 6 | 7 | Why building a WebSocket application with AnyCable-Go (and not other Go libraries)? 8 | 9 | - Connect your application to Ruby/Rails apps with ease by using AnyCable RPC protocol. 10 | - Many features out-of-the-box including different pub/sub adapters (including [embedded NATS](./embedded_nats.md)), built-in instrumentation. 11 | - Bulletproof code, which has been used production for years. 12 | 13 | To get started with an application development with AnyCable-Go, you can use our template repository: [anycable-go-scaffold](https://github.com/anycable/anycable-go-scaffold). 14 | 15 | ## Embedding 16 | 17 | You can also embed AnyCable into your existing web application in case you want to serve AnyCable WebSocket/SSE connections via the same HTTP server as other requests (e.g., if you build a smart reverse-proxy). 18 | 19 | Here is a minimal example Go code (you can find the full and up-to-date version [here](https://github.com/anycable/anycable-go/blob/master/cmd/embedded-cable/main.go)): 20 | 21 | ```go 22 | package main 23 | 24 | import ( 25 | "net/http" 26 | 27 | "github.com/anycable/anycable-go/cli" 28 | ) 29 | 30 | func main() { 31 | opts := []cli.Option{ 32 | cli.WithName("AnyCable"), 33 | cli.WithDefaultRPCController(), 34 | cli.WithDefaultBroker(), 35 | cli.WithDefaultSubscriber(), 36 | cli.WithDefaultBroadcaster(), 37 | } 38 | 39 | c := cli.NewConfig() 40 | runner, _ := cli.NewRunner(c, opts) 41 | anycable, _ := runner.Embed() 42 | 43 | wsHandler, _ := anycable.WebSocketHandler() 44 | http.Handle("/cable", wsHandler) 45 | 46 | http.ListenAndServe(":8080", nil) 47 | } 48 | ``` 49 | -------------------------------------------------------------------------------- /stats/stats.go: -------------------------------------------------------------------------------- 1 | // Package stats contains calculation utils for benchmarks 2 | // Based on https://github.com/anycable/websocket-bench/blob/master/benchmark/stat.go 3 | package stats 4 | 5 | import ( 6 | "sort" 7 | "time" 8 | ) 9 | 10 | // RoundToMS returns the number of milliseconds for the given duration 11 | func RoundToMS(d time.Duration) int64 { 12 | return int64((d + (500 * time.Microsecond)) / time.Millisecond) 13 | } 14 | 15 | // ResAggregate contains duration samples 16 | type ResAggregate struct { 17 | samples []time.Duration 18 | sorted bool 19 | } 20 | 21 | type byAsc []time.Duration 22 | 23 | func (a byAsc) Len() int { return len(a) } 24 | func (a byAsc) Swap(i, j int) { a[i], a[j] = a[j], a[i] } 25 | func (a byAsc) Less(i, j int) bool { return a[i] < a[j] } 26 | 27 | // Add adds a new sample to the aggregate 28 | func (agg *ResAggregate) Add(rtt time.Duration) { 29 | agg.samples = append(agg.samples, rtt) 30 | agg.sorted = false 31 | } 32 | 33 | // Count returns the number of samples 34 | func (agg *ResAggregate) Count() int { 35 | return len(agg.samples) 36 | } 37 | 38 | // Min returns the min value 39 | func (agg *ResAggregate) Min() time.Duration { 40 | if agg.Count() == 0 { 41 | return 0 42 | } 43 | agg.sort() 44 | return agg.samples[0] 45 | } 46 | 47 | // Max returns the max value 48 | func (agg *ResAggregate) Max() time.Duration { 49 | if agg.Count() == 0 { 50 | return 0 51 | } 52 | agg.sort() 53 | return agg.samples[len(agg.samples)-1] 54 | } 55 | 56 | // Percentile returns the p-th percentile 57 | func (agg *ResAggregate) Percentile(p int) time.Duration { 58 | if p <= 0 { 59 | panic("p must be greater than 0") 60 | } else if 100 <= p { 61 | panic("p must be less 100") 62 | } 63 | 64 | agg.sort() 65 | 66 | rank := p * len(agg.samples) / 100 67 | 68 | if agg.Count() == 0 { 69 | return 0 70 | } 71 | 72 | return agg.samples[rank] 73 | } 74 | 75 | func (agg *ResAggregate) sort() { 76 | if agg.sorted { 77 | return 78 | } 79 | sort.Sort(byAsc(agg.samples)) 80 | agg.sorted = true 81 | } 82 | -------------------------------------------------------------------------------- /sse/connection_test.go: -------------------------------------------------------------------------------- 1 | package sse 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | "time" 8 | 9 | "github.com/anycable/anycable-go/ws" 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func (c *Connection) testResponse() string { 14 | c.mu.Lock() 15 | defer c.mu.Unlock() 16 | 17 | return c.writer.(*httptest.ResponseRecorder).Body.String() 18 | } 19 | 20 | func (c *Connection) testStatus() int { 21 | c.mu.Lock() 22 | defer c.mu.Unlock() 23 | 24 | return c.writer.(*httptest.ResponseRecorder).Code 25 | } 26 | 27 | func TestConnection_Write(t *testing.T) { 28 | w := httptest.NewRecorder() 29 | c := NewConnection(w) 30 | 31 | msg := []byte("hello, world!") 32 | err := c.Write(msg, time.Now().Add(1*time.Second)) 33 | 34 | assert.NoError(t, err) 35 | 36 | assert.Empty(t, c.testResponse()) 37 | 38 | c.Established() 39 | 40 | assert.Equal(t, http.StatusOK, c.testStatus()) 41 | assert.Equal(t, "hello, world!\n\n", c.testResponse()) 42 | } 43 | 44 | func TestConnection_Close(t *testing.T) { 45 | t.Run("Close cancels the context", func(t *testing.T) { 46 | // Create a new connection 47 | w := httptest.NewRecorder() 48 | c := NewConnection(w) 49 | 50 | ctx := c.Context() 51 | 52 | c.Close(ws.CloseNormalClosure, "bye") 53 | 54 | <-ctx.Done() 55 | assert.True(t, c.done) 56 | 57 | c.Close(ws.CloseNormalClosure, "bye") 58 | }) 59 | } 60 | 61 | func TestConnection_WriteBinary(t *testing.T) { 62 | w := httptest.NewRecorder() 63 | c := NewConnection(w) 64 | 65 | msg := []byte{0x01, 0x02, 0x03} 66 | err := c.WriteBinary(msg, time.Now().Add(1*time.Second)) 67 | 68 | assert.Error(t, err) 69 | assert.Equal(t, []byte(nil), w.Body.Bytes()) 70 | } 71 | 72 | func TestConnection_Read(t *testing.T) { 73 | w := httptest.NewRecorder() 74 | c := NewConnection(w) 75 | 76 | msg, err := c.Read() 77 | 78 | assert.Error(t, err) 79 | assert.Equal(t, []byte(nil), msg) 80 | } 81 | 82 | func TestConnection_Descriptor(t *testing.T) { 83 | w := httptest.NewRecorder() 84 | c := NewConnection(w) 85 | 86 | assert.Nil(t, c.Descriptor()) 87 | } 88 | -------------------------------------------------------------------------------- /enats/config_test.go: -------------------------------------------------------------------------------- 1 | package enats 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/BurntSushi/toml" 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestConfig_ToToml(t *testing.T) { 12 | conf := Config{ 13 | Enabled: true, 14 | Debug: false, 15 | Trace: true, 16 | Name: "test-service", 17 | ServiceAddr: "localhost:4222", 18 | ClusterAddr: "localhost:6222", 19 | ClusterName: "test-cluster", 20 | GatewayAddr: "localhost:7222", 21 | GatewayAdvertise: "public.example.com:7222", 22 | Gateways: []string{"nats://gateway1:7222", "nats://gateway2:7222"}, 23 | Routes: []string{"nats://route1:6222", "nats://route2:6222"}, 24 | JetStream: true, 25 | StoreDir: "/tmp/nats-store", 26 | JetStreamReadyTimeout: 30, 27 | } 28 | 29 | tomlStr := conf.ToToml() 30 | 31 | assert.Contains(t, tomlStr, "enabled = true") 32 | assert.Contains(t, tomlStr, "# debug = true") 33 | assert.Contains(t, tomlStr, "trace = true") 34 | assert.Contains(t, tomlStr, "name = \"test-service\"") 35 | assert.Contains(t, tomlStr, "service_addr = \"localhost:4222\"") 36 | assert.Contains(t, tomlStr, "cluster_addr = \"localhost:6222\"") 37 | assert.Contains(t, tomlStr, "cluster_name = \"test-cluster\"") 38 | assert.Contains(t, tomlStr, "gateway_addr = \"localhost:7222\"") 39 | assert.Contains(t, tomlStr, "gateway_advertise = \"public.example.com:7222\"") 40 | assert.Contains(t, tomlStr, "gateways = [\"nats://gateway1:7222\", \"nats://gateway2:7222\"]") 41 | assert.Contains(t, tomlStr, "routes = [\"nats://route1:6222\", \"nats://route2:6222\"]") 42 | assert.Contains(t, tomlStr, "jetstream = true") 43 | assert.Contains(t, tomlStr, "jetstream_store_dir = \"/tmp/nats-store\"") 44 | assert.Contains(t, tomlStr, "jetstream_ready_timeout = 30") 45 | 46 | // Round-trip test 47 | var conf2 Config 48 | _, err := toml.Decode(tomlStr, &conf2) 49 | require.NoError(t, err) 50 | 51 | assert.Equal(t, conf, conf2) 52 | } 53 | -------------------------------------------------------------------------------- /etc/dns/README.md: -------------------------------------------------------------------------------- 1 | # DNS load balancing playground 2 | 3 | To play with the gRPC DNS load balancing feature, you need to run multiple RPC servers on different network interfaces and a DNS server. 4 | 5 | ## Running multiple RPC servers 6 | 7 | On MacOS, you can add aliases for the loopback interface as follows: 8 | 9 | ```sh 10 | sudo ifconfig lo0 alias 127.0.0.2 11 | sudo ifconfig lo0 alias 127.0.0.3 12 | sudo ifconfig lo0 alias 127.0.0.4 13 | ``` 14 | 15 | Now you can run an RPC server bound to one of the aliases: 16 | 17 | ```sh 18 | ANYCABLE_RPC_HOST=127.0.0.2:50051 bundle exec anycable 19 | ANYCABLE_RPC_HOST=127.0.0.3:50051 bundle exec anycable 20 | 21 | # or 22 | ANYCABLE_RPC_HOST=127.0.0.2:50051 anyt --only-rpc 23 | ANYCABLE_RPC_HOST=127.0.0.3:50051 anyt --only-rpc 24 | 25 | 26 | # the same for other addresses 27 | ``` 28 | 29 | ## Running a DNS server 30 | 31 | We use [async-dns](https://github.com/socketry/async-dns) to implement a simple DNS server resolving `anycable-rpc.local` to currently running RPC server' IPs: 32 | 33 | ```sh 34 | $ ruby server.rb 35 | 36 | 0.0s info: Starting Async::DNS server (v1.3.0)... [ec=0x5dc] [pid=21146] [2023-02-21 10:08:53 -0500] 37 | 0.0s info: <> Listening for datagrams on # [ec=0x5f0] [pid=21146] [2023-02-21 10:08:53 -0500] 38 | 0.0s info: <> Listening for connections on # [ec=0x604] [pid=21146] [2023-02-21 10:08:53 -0500] 39 | ``` 40 | 41 | ## Running anycable-go 42 | 43 | Now you need to run anycable-go with the following RPC host configuration: 44 | 45 | ```sh 46 | ANYCABLE_RPC_HOST=dns://127.0.0.1:2346/anycable-rpc.local:50051 anycable-go 47 | 48 | # or 49 | ANYCABLE_RPC_HOST=dns://127.0.0.1:2346/anycable-rpc.local:50051 make run 50 | ``` 51 | 52 | You should see the logs of connecting to multiple RPC servers: 53 | 54 | ```sh 55 | INFO 2023-02-21T15:14:04.472Z context=main Starting AnyCable 1.2.3-aec3660 56 | # ... 57 | DEBUG 2023-02-21T15:14:04.682Z context=grpc connected to 127.0.0.2:50051 58 | DEBUG 2023-02-21T15:14:04.682Z context=grpc connected to 127.0.0.1:50051 59 | # ... 60 | ``` 61 | -------------------------------------------------------------------------------- /node/node_mocks_test.go: -------------------------------------------------------------------------------- 1 | package node 2 | 3 | import ( 4 | "log/slog" 5 | 6 | "github.com/anycable/anycable-go/broker" 7 | "github.com/anycable/anycable-go/common" 8 | "github.com/anycable/anycable-go/encoders" 9 | "github.com/anycable/anycable-go/metrics" 10 | "github.com/anycable/anycable-go/mocks" 11 | "github.com/anycable/anycable-go/pubsub" 12 | "github.com/anycable/anycable-go/ws" 13 | ) 14 | 15 | // NewMockNode build new node with mock controller 16 | func NewMockNode() *Node { 17 | controller := mocks.NewMockController() 18 | config := NewConfig() 19 | config.HubGopoolSize = 2 20 | node := NewNode(&config, WithInstrumenter(metrics.NewMetrics(nil, 10, slog.Default())), WithController(&controller)) 21 | node.SetBroker(broker.NewLegacyBroker(pubsub.NewLegacySubscriber(node))) 22 | dconfig := NewDisconnectQueueConfig() 23 | dconfig.Rate = 1 24 | node.SetDisconnector(NewDisconnectQueue(node, &dconfig, slog.Default())) 25 | return node 26 | } 27 | 28 | // NewMockSession returns a new session with a specified uid and identifiers equal to uid 29 | func NewMockSession(uid string, node *Node, opts ...SessionOption) *Session { 30 | session := Session{ 31 | executor: node, 32 | closed: true, 33 | uid: uid, 34 | Log: slog.With("sid", uid), 35 | subscriptions: NewSubscriptionState(), 36 | env: common.NewSessionEnv("/cable-test", &map[string]string{}), 37 | sendCh: make(chan *ws.SentFrame, 256), 38 | encoder: encoders.JSON{}, 39 | metrics: metrics.NoopMetrics{}, 40 | } 41 | 42 | session.SetIdentifiers(uid) 43 | session.conn = mocks.NewMockConnection() 44 | 45 | for _, opt := range opts { 46 | opt(&session) 47 | } 48 | 49 | go session.SendMessages() 50 | 51 | return &session 52 | } 53 | 54 | // NewMockSession returns a new session with a specified uid, path and headers, and identifiers equal to uid 55 | func NewMockSessionWithEnv(uid string, node *Node, url string, headers *map[string]string, opts ...SessionOption) *Session { 56 | session := NewMockSession(uid, node, opts...) 57 | session.env = common.NewSessionEnv(url, headers) 58 | return session 59 | } 60 | -------------------------------------------------------------------------------- /metrics/metrics_test.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import ( 4 | "log/slog" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestMetricsSnapshot(t *testing.T) { 11 | m := NewMetrics(nil, 10, slog.Default()) 12 | 13 | m.RegisterCounter("test_count", "") 14 | m.RegisterGauge("test_gauge", "") 15 | 16 | for i := 0; i < 1000; i++ { 17 | m.Counter("test_count").Inc() 18 | } 19 | 20 | m.Gauge("test_gauge").Set(123) 21 | 22 | m.rotate() 23 | 24 | assert.Equal(t, uint64(1000), m.IntervalSnapshot()["test_count"]) 25 | assert.Equal(t, uint64(123), m.IntervalSnapshot()["test_gauge"]) 26 | 27 | m.Counter("test_count").Inc() 28 | 29 | m.rotate() 30 | 31 | assert.Equal(t, uint64(1), m.IntervalSnapshot()["test_count"]) 32 | assert.Equal(t, uint64(123), m.IntervalSnapshot()["test_gauge"]) 33 | } 34 | 35 | func TestMetrics_EachGauge(t *testing.T) { 36 | m := NewMetrics(nil, 10, slog.Default()) 37 | 38 | m.RegisterGauge("test_gauge", "First") 39 | m.RegisterGauge("test_gauge_2", "Second") 40 | 41 | m.Gauge("test_gauge").Set(123) 42 | m.Gauge("test_gauge_2").Set(321) 43 | 44 | m.EachGauge(func(gauge *Gauge) { 45 | switch gauge.Name() { 46 | case "test_gauge": 47 | { 48 | assert.Equal(t, uint64(123), gauge.Value()) 49 | } 50 | case "test_gauge_2": 51 | { 52 | assert.Equal(t, uint64(321), gauge.Value()) 53 | } 54 | default: 55 | { 56 | t.Errorf("Unknown gauge: %s", gauge.Name()) 57 | } 58 | } 59 | }) 60 | } 61 | 62 | func TestMetrics_EachCounter(t *testing.T) { 63 | m := NewMetrics(nil, 10, slog.Default()) 64 | 65 | m.RegisterCounter("test_counter", "First") 66 | m.RegisterCounter("test_counter_2", "Second") 67 | 68 | m.Counter("test_counter").Inc() 69 | m.Counter("test_counter_2").Add(3) 70 | 71 | m.EachCounter(func(counter *Counter) { 72 | switch counter.Name() { 73 | case "test_counter": 74 | { 75 | assert.Equal(t, uint64(1), counter.Value()) 76 | } 77 | case "test_counter_2": 78 | { 79 | assert.Equal(t, uint64(3), counter.Value()) 80 | } 81 | default: 82 | { 83 | t.Errorf("Unknown counter: %s", counter.Name()) 84 | } 85 | } 86 | }) 87 | } 88 | -------------------------------------------------------------------------------- /cmd/embedded-cable/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log/slog" 7 | "net/http" 8 | "os" 9 | "time" 10 | 11 | "github.com/anycable/anycable-go/cli" 12 | _ "github.com/anycable/anycable-go/diagnostics" 13 | "github.com/anycable/anycable-go/utils" 14 | ) 15 | 16 | func main() { 17 | logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{})) 18 | 19 | opts := []cli.Option{ 20 | cli.WithName("AnyCable"), 21 | cli.WithDefaultRPCController(), 22 | cli.WithDefaultBroker(), 23 | cli.WithDefaultSubscriber(), 24 | cli.WithTelemetry(), 25 | cli.WithLogger(logger), 26 | } 27 | 28 | c := cli.NewConfig() 29 | 30 | runner, err := cli.NewRunner(c, opts) 31 | 32 | if err != nil { 33 | fmt.Printf("%+v\n", err) 34 | os.Exit(1) 35 | } 36 | 37 | anycable, err := runner.Embed() 38 | 39 | if err != nil { 40 | fmt.Printf("%+v\n", err) 41 | os.Exit(1) 42 | } 43 | 44 | wsHandler, err := anycable.WebSocketHandler() 45 | 46 | if err != nil { 47 | fmt.Printf("%+v\n", err) 48 | os.Exit(1) 49 | } 50 | 51 | seeHandler, err := anycable.SSEHandler(context.Background()) 52 | 53 | if err != nil { 54 | fmt.Printf("%+v\n", err) 55 | os.Exit(1) 56 | } 57 | 58 | broadcastHandler, err := anycable.HTTPBroadcastHandler() 59 | 60 | if err != nil { 61 | fmt.Printf("%+v\n", err) 62 | os.Exit(1) 63 | } 64 | 65 | http.Handle("/cable", wsHandler) 66 | http.Handle("/sse", seeHandler) 67 | http.Handle("/broadcast", broadcastHandler) 68 | 69 | go http.ListenAndServe(":8080", nil) // nolint:errcheck,gosec 70 | 71 | // Graceful shutdown (to ensure AnyCable sends disconnect notices) 72 | s := utils.NewGracefulSignals(10 * time.Second) 73 | ch := make(chan error, 1) 74 | 75 | s.HandleForceTerminate(func() { 76 | logger.Warn("Immediate termination requested. Stopped") 77 | ch <- nil 78 | }) 79 | 80 | s.Handle(func(ctx context.Context) error { 81 | logger.Info("Shutting down... (hit Ctrl-C to stop immediately or wait for up to 10s for graceful shutdown)") 82 | return nil 83 | }) 84 | s.Handle(anycable.Shutdown) 85 | s.Handle(func(ctx context.Context) error { 86 | ch <- nil 87 | return nil 88 | }) 89 | 90 | s.Listen() 91 | 92 | <-ch 93 | } 94 | -------------------------------------------------------------------------------- /etc/k6/echo.js: -------------------------------------------------------------------------------- 1 | // Build k6 with xk6-cable like this: 2 | // xk6 build v0.38.3 --with github.com/anycable/xk6-cable@v0.3.0 3 | 4 | import { check, sleep, fail } from "k6"; 5 | import cable from "k6/x/cable"; 6 | import { randomIntBetween } from "https://jslib.k6.io/k6-utils/1.1.0/index.js"; 7 | import { Trend } from "k6/metrics"; 8 | 9 | let commandTrend = new Trend("command_duration", true); 10 | 11 | let config = __ENV; 12 | 13 | let url = config.CABLE_URL || "ws://localhost:8080/cable"; 14 | let channelName = (config.CHANNEL_ID || 'BenchmarkChannel'); 15 | 16 | export const options = { 17 | scenarios: { 18 | default: { 19 | executor: 'externally-controlled', 20 | vus: 30, 21 | maxVUs: 500, 22 | duration: '5m' 23 | } 24 | } 25 | }; 26 | 27 | export default function () { 28 | let cableOptions = { 29 | receiveTimeoutMs: 15000, 30 | headers: {} 31 | }; 32 | 33 | let client 34 | 35 | try { 36 | client = cable.connect(url, cableOptions); 37 | 38 | if ( 39 | !check(client, { 40 | "successful connection": (obj) => obj, 41 | }) 42 | ) { 43 | fail("connection failed"); 44 | } 45 | } catch (err) { 46 | console.error(err) 47 | return 48 | } 49 | 50 | let channel 51 | 52 | try { 53 | channel = client.subscribe(channelName); 54 | 55 | if ( 56 | !check(channel, { 57 | "successful subscription": (obj) => obj, 58 | }) 59 | ) { 60 | fail("failed to subscribe"); 61 | } 62 | } catch (err) { 63 | console.error(err) 64 | return 65 | } 66 | 67 | for (let i = 0; i < 10; i++) { 68 | let start = Date.now(); 69 | channel.perform("echo", { ts: start, content: `hello from ${__VU} numero ${i+1}` }); 70 | 71 | sleep(randomIntBetween(5, 10) / 100); 72 | 73 | let incoming = channel.receiveAll(1); 74 | 75 | for(let message of incoming) { 76 | let received = message.__timestamp__ || Date.now(); 77 | 78 | if (message.action == "echo") { 79 | let ts = message.ts; 80 | commandTrend.add(received - ts); 81 | } 82 | } 83 | 84 | sleep(randomIntBetween(5, 10) / 100); 85 | } 86 | 87 | sleep(randomIntBetween(2, 5)); 88 | 89 | client.disconnect(); 90 | } 91 | -------------------------------------------------------------------------------- /utils/priority_queue.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | // Based on https://pkg.go.dev/container/heap 4 | 5 | import ( 6 | "container/heap" 7 | 8 | "golang.org/x/exp/constraints" 9 | ) 10 | 11 | // An PriorityQueueItem is something we manage in a priority queue. 12 | type PriorityQueueItem[T any, P constraints.Ordered] struct { 13 | value T 14 | priority P 15 | // The index is needed by update and is maintained by the heap.Interface methods. 16 | index int 17 | } 18 | 19 | func (pq PriorityQueueItem[T, P]) Value() T { 20 | return pq.value 21 | } 22 | 23 | func (pq PriorityQueueItem[T, P]) Priority() P { 24 | return pq.priority 25 | } 26 | 27 | type PriorityQueue[T any, P constraints.Ordered] []*PriorityQueueItem[T, P] 28 | 29 | func NewPriorityQueue[T any, P constraints.Ordered]() *PriorityQueue[T, P] { 30 | pq := &PriorityQueue[T, P]{} 31 | heap.Init(pq) 32 | return pq 33 | } 34 | 35 | func (pq *PriorityQueue[T, P]) PushItem(v T, priority P) *PriorityQueueItem[T, P] { 36 | item := &PriorityQueueItem[T, P]{value: v, priority: priority} 37 | heap.Push(pq, item) 38 | return item 39 | } 40 | 41 | func (pq *PriorityQueue[T, P]) PopItem() *PriorityQueueItem[T, P] { 42 | return heap.Pop(pq).(*PriorityQueueItem[T, P]) 43 | } 44 | 45 | // Update modifies the priority and value of an Item in the queue. 46 | func (pq *PriorityQueue[T, P]) Update(item *PriorityQueueItem[T, P], priority P) { 47 | item.priority = priority 48 | heap.Fix(pq, item.index) 49 | } 50 | 51 | func (pq *PriorityQueue[T, P]) Peek() *PriorityQueueItem[T, P] { 52 | if len(*pq) > 0 { 53 | return (*pq)[0] 54 | } 55 | 56 | return nil 57 | } 58 | 59 | func (pq *PriorityQueue[T, P]) Remove(item *PriorityQueueItem[T, P]) { 60 | heap.Remove(pq, item.index) 61 | } 62 | 63 | func (pq PriorityQueue[T, P]) Len() int { return len(pq) } 64 | 65 | func (pq PriorityQueue[T, P]) Less(i, j int) bool { 66 | return pq[i].priority < pq[j].priority 67 | } 68 | 69 | func (pq PriorityQueue[T, P]) Swap(i, j int) { 70 | pq[i], pq[j] = pq[j], pq[i] 71 | pq[i].index = i 72 | pq[j].index = j 73 | } 74 | 75 | func (pq *PriorityQueue[T, P]) Push(x any) { 76 | n := len(*pq) 77 | item := x.(*PriorityQueueItem[T, P]) 78 | item.index = n 79 | *pq = append(*pq, item) 80 | } 81 | 82 | func (pq *PriorityQueue[T, P]) Pop() any { 83 | old := *pq 84 | n := len(old) 85 | item := old[n-1] 86 | old[n-1] = nil 87 | item.index = -1 88 | *pq = old[0 : n-1] 89 | return item 90 | } 91 | -------------------------------------------------------------------------------- /node/subscription_state_test.go: -------------------------------------------------------------------------------- 1 | package node 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestSubscriptionStateChannels(t *testing.T) { 10 | t.Run("with different channels", func(t *testing.T) { 11 | subscriptions := NewSubscriptionState() 12 | 13 | subscriptions.AddChannel("{\"channel\":\"SystemNotificationChannel\"}") 14 | subscriptions.AddChannel("{\"channel\":\"DressageChannel\",\"id\":23376}") 15 | 16 | expected := []string{ 17 | "{\"channel\":\"SystemNotificationChannel\"}", 18 | "{\"channel\":\"DressageChannel\",\"id\":23376}", 19 | } 20 | 21 | actual := subscriptions.Channels() 22 | 23 | for _, key := range expected { 24 | assert.Contains(t, actual, key) 25 | } 26 | }) 27 | 28 | t.Run("with the same channel", func(t *testing.T) { 29 | subscriptions := NewSubscriptionState() 30 | 31 | subscriptions.AddChannel( 32 | "{\"channel\":\"GraphqlChannel\",\"channelId\":\"165d8949069\"}", 33 | ) 34 | subscriptions.AddChannel( 35 | "{\"channel\":\"GraphqlChannel\",\"channelId\":\"165d8941e62\"}", 36 | ) 37 | 38 | expected := []string{ 39 | "{\"channel\":\"GraphqlChannel\",\"channelId\":\"165d8949069\"}", 40 | "{\"channel\":\"GraphqlChannel\",\"channelId\":\"165d8941e62\"}", 41 | } 42 | 43 | actual := subscriptions.Channels() 44 | 45 | for _, key := range expected { 46 | assert.Contains(t, actual, key) 47 | } 48 | }) 49 | } 50 | 51 | func TestSubscriptionStreamsFor(t *testing.T) { 52 | subscriptions := NewSubscriptionState() 53 | 54 | subscriptions.AddChannel("chat_1") 55 | subscriptions.AddChannel("presence_1") 56 | 57 | subscriptions.AddChannelStream("chat_1", "a") 58 | subscriptions.AddChannelStream("chat_1", "b") 59 | subscriptions.AddChannelStream("presence_1", "z") 60 | 61 | assert.Contains(t, subscriptions.StreamsFor("chat_1"), "a") 62 | assert.Contains(t, subscriptions.StreamsFor("chat_1"), "b") 63 | assert.Equal(t, []string{"z"}, subscriptions.StreamsFor("presence_1")) 64 | 65 | subscriptions.RemoveChannelStreams("chat_1") 66 | assert.Empty(t, subscriptions.StreamsFor("chat_1")) 67 | assert.Equal(t, []string{"z"}, subscriptions.StreamsFor("presence_1")) 68 | 69 | subscriptions.AddChannelStream("presence_1", "y") 70 | subscriptions.RemoveChannelStream("presence_1", "z") 71 | subscriptions.RemoveChannelStream("presence_1", "t") 72 | assert.Equal(t, []string{"y"}, subscriptions.StreamsFor("presence_1")) 73 | } 74 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/anycable/anycable-go 2 | 3 | go 1.23 4 | 5 | require ( 6 | github.com/FZambia/sentinel v1.1.1 7 | github.com/davecgh/go-spew v1.1.1 // indirect 8 | github.com/fullstorydev/grpchan v1.1.1 9 | github.com/go-chi/chi/v5 v5.2.0 10 | github.com/golang-jwt/jwt v3.2.2+incompatible 11 | github.com/golang/protobuf v1.5.4 12 | github.com/gomodule/redigo v1.9.2 13 | github.com/google/gops v0.3.28 14 | github.com/gorilla/websocket v1.5.3 15 | github.com/hofstadter-io/cinful v1.0.0 16 | github.com/joomcode/errorx v1.2.0 17 | github.com/lmittmann/tint v1.0.6 18 | github.com/matoous/go-nanoid v1.5.1 19 | github.com/mattn/go-isatty v0.0.20 20 | github.com/mitchellh/go-mruby v0.0.0-20200315023956-207cedc21542 21 | github.com/nats-io/nats.go v1.38.0 22 | github.com/posthog/posthog-go v1.2.24 23 | github.com/redis/rueidis v1.0.51 24 | github.com/smira/go-statsd v1.3.4 25 | github.com/stretchr/testify v1.10.0 26 | github.com/urfave/cli/v2 v2.27.5 27 | go.uber.org/automaxprocs v1.6.0 28 | golang.org/x/exp v0.0.0-20241217172543-b2144cdd0a67 29 | golang.org/x/net v0.32.0 30 | google.golang.org/grpc v1.69.0 31 | ) 32 | 33 | // use vendored go-mruby 34 | replace github.com/mitchellh/go-mruby => ./vendorlib/go-mruby 35 | 36 | require ( 37 | github.com/BurntSushi/toml v1.4.0 38 | github.com/sony/gobreaker v1.0.0 39 | ) 40 | 41 | require ( 42 | github.com/bufbuild/protocompile v0.14.1 // indirect 43 | github.com/klauspost/compress v1.17.11 // indirect 44 | github.com/kr/pretty v0.2.0 // indirect 45 | github.com/minio/highwayhash v1.0.3 // indirect 46 | github.com/nats-io/jwt/v2 v2.7.3 // indirect 47 | golang.org/x/time v0.8.0 // indirect 48 | google.golang.org/genproto/googleapis/rpc v0.0.0-20241216192217-9240e9c98484 // indirect 49 | ) 50 | 51 | require ( 52 | github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect 53 | github.com/jhump/protoreflect v1.17.0 // indirect 54 | github.com/nats-io/nats-server/v2 v2.10.24 55 | github.com/nats-io/nkeys v0.4.9 // indirect 56 | github.com/nats-io/nuid v1.0.1 // indirect 57 | github.com/pmezard/go-difflib v1.0.0 // indirect 58 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 59 | github.com/stretchr/objx v0.5.2 // indirect 60 | github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect 61 | golang.org/x/crypto v0.31.0 // indirect 62 | golang.org/x/sys v0.28.0 // indirect 63 | golang.org/x/text v0.21.0 // indirect 64 | google.golang.org/protobuf v1.36.0 // indirect 65 | gopkg.in/yaml.v3 v3.0.1 // indirect 66 | ) 67 | -------------------------------------------------------------------------------- /vendorlib/go-mruby/class.go: -------------------------------------------------------------------------------- 1 | package mruby 2 | 3 | import "unsafe" 4 | 5 | // #include 6 | // #include "gomruby.h" 7 | import "C" 8 | 9 | // Class is a class in mruby. To obtain a Class, use DefineClass or 10 | // one of the variants on the Mrb structure. 11 | type Class struct { 12 | class *C.struct_RClass 13 | mrb *Mrb 14 | } 15 | 16 | // DefineClassMethod defines a class-level method on the given class. 17 | func (c *Class) DefineClassMethod(name string, cb Func, as ArgSpec) { 18 | insertMethod(c.mrb.state, c.class.c, name, cb) 19 | 20 | cs := C.CString(name) 21 | defer C.free(unsafe.Pointer(cs)) 22 | 23 | C.mrb_define_class_method( 24 | c.mrb.state, 25 | c.class, 26 | cs, 27 | C._go_mrb_func_t(), 28 | C.mrb_aspec(as)) 29 | } 30 | 31 | // DefineConst defines a constant within this class. 32 | func (c *Class) DefineConst(name string, value Value) { 33 | cs := C.CString(name) 34 | defer C.free(unsafe.Pointer(cs)) 35 | 36 | C.mrb_define_const( 37 | c.mrb.state, c.class, cs, value.MrbValue(c.mrb).value) 38 | } 39 | 40 | // DefineMethod defines an instance method on the class. 41 | func (c *Class) DefineMethod(name string, cb Func, as ArgSpec) { 42 | insertMethod(c.mrb.state, c.class, name, cb) 43 | 44 | cs := C.CString(name) 45 | defer C.free(unsafe.Pointer(cs)) 46 | 47 | C.mrb_define_method( 48 | c.mrb.state, 49 | c.class, 50 | cs, 51 | C._go_mrb_func_t(), 52 | C.mrb_aspec(as)) 53 | } 54 | 55 | // MrbValue returns a *Value for this Class. *Values are sometimes required 56 | // as arguments where classes should be valid. 57 | func (c *Class) MrbValue(m *Mrb) *MrbValue { 58 | return newValue(c.mrb.state, C.mrb_obj_value(unsafe.Pointer(c.class))) 59 | } 60 | 61 | // New instantiates the class with the given args. 62 | func (c *Class) New(args ...Value) (*MrbValue, error) { 63 | var argv []C.mrb_value 64 | var argvPtr *C.mrb_value 65 | if len(args) > 0 { 66 | // Make the raw byte slice to hold our arguments we'll pass to C 67 | argv = make([]C.mrb_value, len(args)) 68 | for i, arg := range args { 69 | argv[i] = arg.MrbValue(c.mrb).value 70 | } 71 | 72 | argvPtr = &argv[0] 73 | } 74 | 75 | result := C.mrb_obj_new(c.mrb.state, c.class, C.mrb_int(len(argv)), argvPtr) 76 | if exc := checkException(c.mrb.state); exc != nil { 77 | return nil, exc 78 | } 79 | 80 | return newValue(c.mrb.state, result), nil 81 | } 82 | 83 | func newClass(mrb *Mrb, c *C.struct_RClass) *Class { 84 | return &Class{ 85 | class: c, 86 | mrb: mrb, 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /mocks/Subscriber.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery v2.20.0. DO NOT EDIT. 2 | 3 | package mocks 4 | 5 | import ( 6 | context "context" 7 | 8 | common "github.com/anycable/anycable-go/common" 9 | 10 | mock "github.com/stretchr/testify/mock" 11 | ) 12 | 13 | // Subscriber is an autogenerated mock type for the Subscriber type 14 | type Subscriber struct { 15 | mock.Mock 16 | } 17 | 18 | // Broadcast provides a mock function with given fields: msg 19 | func (_m *Subscriber) Broadcast(msg *common.StreamMessage) { 20 | _m.Called(msg) 21 | } 22 | 23 | // BroadcastCommand provides a mock function with given fields: msg 24 | func (_m *Subscriber) BroadcastCommand(msg *common.RemoteCommandMessage) { 25 | _m.Called(msg) 26 | } 27 | 28 | // IsMultiNode provides a mock function with given fields: 29 | func (_m *Subscriber) IsMultiNode() bool { 30 | ret := _m.Called() 31 | 32 | var r0 bool 33 | if rf, ok := ret.Get(0).(func() bool); ok { 34 | r0 = rf() 35 | } else { 36 | r0 = ret.Get(0).(bool) 37 | } 38 | 39 | return r0 40 | } 41 | 42 | // Shutdown provides a mock function with given fields: ctx 43 | func (_m *Subscriber) Shutdown(ctx context.Context) error { 44 | ret := _m.Called(ctx) 45 | 46 | var r0 error 47 | if rf, ok := ret.Get(0).(func(context.Context) error); ok { 48 | r0 = rf(ctx) 49 | } else { 50 | r0 = ret.Error(0) 51 | } 52 | 53 | return r0 54 | } 55 | 56 | // Start provides a mock function with given fields: done 57 | func (_m *Subscriber) Start(done chan error) error { 58 | ret := _m.Called(done) 59 | 60 | var r0 error 61 | if rf, ok := ret.Get(0).(func(chan error) error); ok { 62 | r0 = rf(done) 63 | } else { 64 | r0 = ret.Error(0) 65 | } 66 | 67 | return r0 68 | } 69 | 70 | // Subscribe provides a mock function with given fields: stream 71 | func (_m *Subscriber) Subscribe(stream string) { 72 | _m.Called(stream) 73 | } 74 | 75 | // Unsubscribe provides a mock function with given fields: stream 76 | func (_m *Subscriber) Unsubscribe(stream string) { 77 | _m.Called(stream) 78 | } 79 | 80 | type mockConstructorTestingTNewSubscriber interface { 81 | mock.TestingT 82 | Cleanup(func()) 83 | } 84 | 85 | // NewSubscriber creates a new instance of Subscriber. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 86 | func NewSubscriber(t mockConstructorTestingTNewSubscriber) *Subscriber { 87 | mock := &Subscriber{} 88 | mock.Mock.Test(t) 89 | 90 | t.Cleanup(func() { mock.AssertExpectations(t) }) 91 | 92 | return mock 93 | } 94 | -------------------------------------------------------------------------------- /mrb/mrb.go: -------------------------------------------------------------------------------- 1 | //go:build (darwin && mrb) || (linux && mrb) 2 | // +build darwin,mrb linux,mrb 3 | 4 | package mrb 5 | 6 | import ( 7 | "fmt" 8 | "io/ioutil" 9 | "strings" 10 | "sync" 11 | 12 | nanoid "github.com/matoous/go-nanoid" 13 | "github.com/mitchellh/go-mruby" 14 | ) 15 | 16 | // Supported returns true iff mruby scripting is available 17 | func Supported() bool { 18 | return true 19 | } 20 | 21 | // Engine represents one running mruby VM 22 | type Engine struct { 23 | VM *mruby.Mrb 24 | mu sync.Mutex 25 | } 26 | 27 | var ( 28 | defaultEngine *Engine 29 | defaultEngineSync sync.Mutex 30 | ) 31 | 32 | // NewEngine builds new mruby VM and return new engine 33 | func NewEngine() *Engine { 34 | return &Engine{VM: mruby.NewMrb()} 35 | } 36 | 37 | // DefaultEngine returns a default mruby engine 38 | func DefaultEngine() *Engine { 39 | defaultEngineSync.Lock() 40 | defer defaultEngineSync.Unlock() 41 | 42 | if defaultEngine == nil { 43 | defaultEngine = NewEngine() 44 | } 45 | 46 | return defaultEngine 47 | } 48 | 49 | func Version() (v string, err error) { 50 | mrbv, err := DefaultEngine().Eval("MRUBY_DESCRIPTION") 51 | if err != nil { 52 | return 53 | } 54 | 55 | v = strings.TrimSpace(mrbv.String()) 56 | return 57 | } 58 | 59 | // LoadFile loads, parses and eval Ruby file within a vm 60 | func (engine *Engine) LoadFile(path string) error { 61 | contents, err := ioutil.ReadFile(path) 62 | 63 | if err != nil { 64 | return err 65 | } 66 | 67 | return engine.LoadString(string(contents)) 68 | } 69 | 70 | // LoadString loads, parses and eval Ruby code within a vm 71 | func (engine *Engine) LoadString(contents string) error { 72 | engine.mu.Lock() 73 | defer engine.mu.Unlock() 74 | 75 | ctx := mruby.NewCompileContext(engine.VM) 76 | defer ctx.Close() 77 | 78 | filename, err := nanoid.Nanoid() 79 | 80 | if err != nil { 81 | return err 82 | } 83 | 84 | ctx.SetFilename(fmt.Sprintf("%s.rb", filename)) 85 | 86 | parser := mruby.NewParser(engine.VM) 87 | defer parser.Close() 88 | 89 | if _, err = parser.Parse(contents, ctx); err != nil { 90 | return err 91 | } 92 | 93 | parsed := parser.GenerateCode() 94 | 95 | if _, err = engine.VM.Run(parsed, nil); err != nil { 96 | return err 97 | } 98 | 99 | return nil 100 | } 101 | 102 | // Eval runs arbitrary code within a vm 103 | func (engine *Engine) Eval(code string) (*mruby.MrbValue, error) { 104 | return engine.VM.LoadString(code) 105 | } 106 | -------------------------------------------------------------------------------- /node/controller.go: -------------------------------------------------------------------------------- 1 | package node 2 | 3 | import ( 4 | "errors" 5 | "log/slog" 6 | 7 | "github.com/anycable/anycable-go/common" 8 | ) 9 | 10 | // Controller is an interface describing business-logic handler (e.g. RPC) 11 | type Controller interface { 12 | Start() error 13 | Shutdown() error 14 | Authenticate(sid string, env *common.SessionEnv) (*common.ConnectResult, error) 15 | Subscribe(sid string, env *common.SessionEnv, ids string, channel string) (*common.CommandResult, error) 16 | Unsubscribe(sid string, env *common.SessionEnv, ids string, channel string) (*common.CommandResult, error) 17 | Perform(sid string, env *common.SessionEnv, ids string, channel string, data string) (*common.CommandResult, error) 18 | Disconnect(sid string, env *common.SessionEnv, ids string, subscriptions []string) error 19 | } 20 | 21 | type NullController struct { 22 | log *slog.Logger 23 | } 24 | 25 | func NewNullController(l *slog.Logger) *NullController { 26 | return &NullController{l.With("context", "rpc", "impl", "null")} 27 | } 28 | 29 | func (c *NullController) Start() (err error) { 30 | c.log.Info("no RPC configured") 31 | 32 | return 33 | } 34 | 35 | func (c *NullController) Shutdown() (err error) { return } 36 | 37 | func (c *NullController) Authenticate(sid string, env *common.SessionEnv) (*common.ConnectResult, error) { 38 | c.log.Debug("reject connection") 39 | 40 | return &common.ConnectResult{ 41 | Status: common.FAILURE, 42 | Transmissions: []string{common.DisconnectionMessage(common.UNAUTHORIZED_REASON, false)}, 43 | DisconnectInterest: -1, 44 | }, nil 45 | } 46 | 47 | func (c *NullController) Subscribe(sid string, env *common.SessionEnv, ids string, channel string) (*common.CommandResult, error) { 48 | c.log.Debug("reject subscription", "channel", channel) 49 | 50 | return &common.CommandResult{ 51 | Status: common.FAILURE, 52 | Transmissions: []string{common.RejectionMessage(channel)}, 53 | DisconnectInterest: -1, 54 | }, nil 55 | } 56 | 57 | func (c *NullController) Perform(sid string, env *common.SessionEnv, ids string, channel string, data string) (*common.CommandResult, error) { 58 | return nil, errors.New("not implemented") 59 | } 60 | 61 | func (c *NullController) Unsubscribe(sid string, env *common.SessionEnv, ids string, channel string) (*common.CommandResult, error) { 62 | return nil, errors.New("not implemented") 63 | } 64 | 65 | func (c *NullController) Disconnect(sid string, env *common.SessionEnv, ids string, subscriptions []string) error { 66 | return nil 67 | } 68 | -------------------------------------------------------------------------------- /sse/connection.go: -------------------------------------------------------------------------------- 1 | package sse 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "errors" 7 | "net" 8 | "net/http" 9 | "sync" 10 | "time" 11 | 12 | "github.com/anycable/anycable-go/node" 13 | ) 14 | 15 | type Connection struct { 16 | writer http.ResponseWriter 17 | 18 | ctx context.Context 19 | cancelFn context.CancelFunc 20 | 21 | done bool 22 | established bool 23 | // Backlog is used to store messages sent to client before connection is established 24 | backlog *bytes.Buffer 25 | 26 | mu sync.Mutex 27 | } 28 | 29 | var _ node.Connection = (*Connection)(nil) 30 | 31 | // NewConnection creates a new long-polling connection wrapper 32 | func NewConnection(w http.ResponseWriter) *Connection { 33 | ctx, cancel := context.WithCancel(context.Background()) 34 | return &Connection{ 35 | writer: w, 36 | backlog: bytes.NewBuffer(nil), 37 | ctx: ctx, 38 | cancelFn: cancel, 39 | } 40 | } 41 | 42 | func (c *Connection) Read() ([]byte, error) { 43 | return nil, errors.New("unsupported") 44 | } 45 | 46 | func (c *Connection) Write(msg []byte, deadline time.Time) error { 47 | c.mu.Lock() 48 | defer c.mu.Unlock() 49 | 50 | if c.done { 51 | return nil 52 | } 53 | 54 | if !c.established { 55 | c.backlog.Write(msg) 56 | c.backlog.Write([]byte("\n\n")) 57 | return nil 58 | } 59 | 60 | _, err := c.writer.Write(msg) 61 | 62 | if err != nil { 63 | return err 64 | } 65 | 66 | _, err = c.writer.Write([]byte("\n\n")) 67 | 68 | if err != nil { 69 | return err 70 | } 71 | 72 | c.writer.(http.Flusher).Flush() 73 | 74 | return nil 75 | } 76 | 77 | func (c *Connection) WriteBinary(msg []byte, deadline time.Time) error { 78 | return errors.New("unsupported") 79 | } 80 | 81 | func (c *Connection) Context() context.Context { 82 | return c.ctx 83 | } 84 | 85 | func (c *Connection) Close(code int, reason string) { 86 | c.mu.Lock() 87 | defer c.mu.Unlock() 88 | 89 | if c.done { 90 | return 91 | } 92 | 93 | c.done = true 94 | 95 | c.cancelFn() 96 | } 97 | 98 | // Mark as closed to avoid writing to closed connection 99 | func (c *Connection) Established() { 100 | c.mu.Lock() 101 | defer c.mu.Unlock() 102 | 103 | c.established = true 104 | 105 | if c.backlog.Len() > 0 { 106 | c.writer.Write(c.backlog.Bytes()) // nolint: errcheck 107 | c.writer.(http.Flusher).Flush() 108 | c.backlog.Reset() 109 | } 110 | } 111 | 112 | func (c *Connection) Descriptor() net.Conn { 113 | return nil 114 | } 115 | -------------------------------------------------------------------------------- /cli/embed.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | 8 | "github.com/anycable/anycable-go/broadcast" 9 | "github.com/anycable/anycable-go/node" 10 | "github.com/anycable/anycable-go/utils" 11 | "github.com/anycable/anycable-go/version" 12 | ) 13 | 14 | // A minimal interface to the underlying Runner for embedding AnyCable into your own Go HTTP application. 15 | type Embedded struct { 16 | n *node.Node 17 | r *Runner 18 | } 19 | 20 | // WebSocketHandler returns an HTTP handler to serve WebSocket connections via AnyCable. 21 | func (e *Embedded) WebSocketHandler() (http.Handler, error) { 22 | wsHandler, err := e.r.websocketHandlerFactory(e.n, e.r.config, e.r.log) 23 | 24 | if err != nil { 25 | return nil, err 26 | } 27 | 28 | return wsHandler, nil 29 | } 30 | 31 | // SSEHandler returns an HTTP handler to serve SSE connections via AnyCable. 32 | // Please, provide your HTTP server's shutdown context to terminate SSE connections gracefully 33 | // on server shutdown. 34 | func (e *Embedded) SSEHandler(ctx context.Context) (http.Handler, error) { 35 | sseHandler, err := e.r.defaultSSEHandler(e.n, ctx, e.r.config) 36 | 37 | if err != nil { 38 | return nil, err 39 | } 40 | 41 | return sseHandler, nil 42 | } 43 | 44 | // HTTPBroadcastHandler returns an HTTP handler to process broadcasting requests 45 | func (e *Embedded) HTTPBroadcastHandler() (http.Handler, error) { 46 | broadcaster := broadcast.NewHTTPBroadcaster(e.n, &e.r.config.HTTPBroadcast, e.r.log) 47 | 48 | err := broadcaster.Prepare() 49 | 50 | if err != nil { 51 | return nil, err 52 | } 53 | 54 | return http.HandlerFunc(broadcaster.Handler), nil 55 | } 56 | 57 | // Shutdown stops the AnyCable node gracefully. 58 | func (e *Embedded) Shutdown(ctx context.Context) error { 59 | for _, shutdownable := range e.r.shutdownables { 60 | if err := shutdownable.Shutdown(ctx); err != nil { 61 | return err 62 | } 63 | } 64 | 65 | return nil 66 | } 67 | 68 | // Embed starts the application without setting up HTTP handlers, signalts, etc. 69 | // You can use it to embed AnyCable into your own Go HTTP application. 70 | func (r *Runner) Embed() (*Embedded, error) { 71 | r.announceDebugMode() 72 | mrubySupport := r.initMRuby() 73 | 74 | r.log.Info(fmt.Sprintf("Starting embedded %s %s%s (open file limit: %s)", r.name, version.Version(), mrubySupport, utils.OpenFileLimit())) 75 | 76 | appNode, err := r.runNode() 77 | if err != nil { 78 | return nil, err 79 | } 80 | 81 | embed := &Embedded{n: appNode, r: r} 82 | 83 | go r.startMetrics(r.metrics) 84 | 85 | return embed, nil 86 | } 87 | -------------------------------------------------------------------------------- /mocks/RPCServer.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery v1.0.0. DO NOT EDIT. 2 | 3 | package mocks 4 | 5 | import anycable "github.com/anycable/anycable-go/protos" 6 | import context "context" 7 | import mock "github.com/stretchr/testify/mock" 8 | 9 | // RPCServer is an autogenerated mock type for the RPCServer type 10 | type RPCServer struct { 11 | mock.Mock 12 | } 13 | 14 | // Command provides a mock function with given fields: _a0, _a1 15 | func (_m *RPCServer) Command(_a0 context.Context, _a1 *anycable.CommandMessage) (*anycable.CommandResponse, error) { 16 | ret := _m.Called(_a0, _a1) 17 | 18 | var r0 *anycable.CommandResponse 19 | if rf, ok := ret.Get(0).(func(context.Context, *anycable.CommandMessage) *anycable.CommandResponse); ok { 20 | r0 = rf(_a0, _a1) 21 | } else { 22 | if ret.Get(0) != nil { 23 | r0 = ret.Get(0).(*anycable.CommandResponse) 24 | } 25 | } 26 | 27 | var r1 error 28 | if rf, ok := ret.Get(1).(func(context.Context, *anycable.CommandMessage) error); ok { 29 | r1 = rf(_a0, _a1) 30 | } else { 31 | r1 = ret.Error(1) 32 | } 33 | 34 | return r0, r1 35 | } 36 | 37 | // Connect provides a mock function with given fields: _a0, _a1 38 | func (_m *RPCServer) Connect(_a0 context.Context, _a1 *anycable.ConnectionRequest) (*anycable.ConnectionResponse, error) { 39 | ret := _m.Called(_a0, _a1) 40 | 41 | var r0 *anycable.ConnectionResponse 42 | if rf, ok := ret.Get(0).(func(context.Context, *anycable.ConnectionRequest) *anycable.ConnectionResponse); ok { 43 | r0 = rf(_a0, _a1) 44 | } else { 45 | if ret.Get(0) != nil { 46 | r0 = ret.Get(0).(*anycable.ConnectionResponse) 47 | } 48 | } 49 | 50 | var r1 error 51 | if rf, ok := ret.Get(1).(func(context.Context, *anycable.ConnectionRequest) error); ok { 52 | r1 = rf(_a0, _a1) 53 | } else { 54 | r1 = ret.Error(1) 55 | } 56 | 57 | return r0, r1 58 | } 59 | 60 | // Disconnect provides a mock function with given fields: _a0, _a1 61 | func (_m *RPCServer) Disconnect(_a0 context.Context, _a1 *anycable.DisconnectRequest) (*anycable.DisconnectResponse, error) { 62 | ret := _m.Called(_a0, _a1) 63 | 64 | var r0 *anycable.DisconnectResponse 65 | if rf, ok := ret.Get(0).(func(context.Context, *anycable.DisconnectRequest) *anycable.DisconnectResponse); ok { 66 | r0 = rf(_a0, _a1) 67 | } else { 68 | if ret.Get(0) != nil { 69 | r0 = ret.Get(0).(*anycable.DisconnectResponse) 70 | } 71 | } 72 | 73 | var r1 error 74 | if rf, ok := ret.Get(1).(func(context.Context, *anycable.DisconnectRequest) error); ok { 75 | r1 = rf(_a0, _a1) 76 | } else { 77 | r1 = ret.Error(1) 78 | } 79 | 80 | return r0, r1 81 | } 82 | -------------------------------------------------------------------------------- /server/request_info_test.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "crypto/tls" 5 | "net/http/httptest" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestFetchUID(t *testing.T) { 13 | t.Run("Without request id", func(t *testing.T) { 14 | req := httptest.NewRequest("GET", "/", nil) 15 | uid, _ := FetchUID(req) 16 | 17 | assert.NotEqual(t, "", uid) 18 | }) 19 | 20 | t.Run("With request id", func(t *testing.T) { 21 | req := httptest.NewRequest("GET", "/", nil) 22 | req.Header.Set("x-request-id", "external-request-id") 23 | 24 | uid, _ := FetchUID(req) 25 | 26 | assert.Equal(t, "external-request-id", uid) 27 | }) 28 | } 29 | 30 | func TestRequestInfo(t *testing.T) { 31 | t.Run("With just path", func(t *testing.T) { 32 | req := httptest.NewRequest("GET", "/cable", nil) 33 | info, err := NewRequestInfo(req, nil) 34 | 35 | require.NoError(t, err) 36 | assert.Equal(t, "http://example.com/cable", info.URL) 37 | }) 38 | 39 | t.Run("With just path + TLS", func(t *testing.T) { 40 | req := httptest.NewRequest("GET", "/cable", nil) 41 | req.TLS = &tls.ConnectionState{} 42 | info, err := NewRequestInfo(req, nil) 43 | 44 | require.NoError(t, err) 45 | assert.Equal(t, "https://example.com/cable", info.URL) 46 | }) 47 | 48 | t.Run("With fully qualified URL", func(t *testing.T) { 49 | req := httptest.NewRequest("GET", "ws://anycable.io/cable", nil) 50 | info, err := NewRequestInfo(req, nil) 51 | 52 | require.NoError(t, err) 53 | assert.Equal(t, "ws://anycable.io/cable", info.URL) 54 | }) 55 | 56 | t.Run("With params", func(t *testing.T) { 57 | req := httptest.NewRequest("GET", "ws://anycable.io/cable?pi=3&pp=no&pi=5", nil) 58 | info, err := NewRequestInfo(req, nil) 59 | 60 | require.NoError(t, err) 61 | assert.Equal(t, "5", info.Param("pi")) 62 | assert.Equal(t, "no", info.Param("pp")) 63 | 64 | blank_info := RequestInfo{} 65 | assert.Equal(t, "", blank_info.Param("pi")) 66 | }) 67 | 68 | t.Run("With AnyCable Headers", func(t *testing.T) { 69 | req := httptest.NewRequest("GET", "ws://anycable.io/cable?pi=3&pp=no&pi=5", nil) 70 | req.Header.Set("x-anycable-sid", "432") 71 | req.Header.Set("X-AnyCable-Ping-Interval", "10") 72 | info, err := NewRequestInfo(req, nil) 73 | 74 | require.NoError(t, err) 75 | assert.Equal(t, "432", info.AnyCableHeader("X-ANYCABLE-SID")) 76 | assert.Equal(t, "10", info.AnyCableHeader("x-anycable-ping-interval")) 77 | 78 | blank_info := RequestInfo{} 79 | assert.Equal(t, "", blank_info.AnyCableHeader("SID")) 80 | }) 81 | } 82 | -------------------------------------------------------------------------------- /packaging/npm/install.mjs: -------------------------------------------------------------------------------- 1 | import { spawnSync } from "child_process"; 2 | import { chmodSync } from "fs"; 3 | 4 | const iswin = ["win32", "cygwin"].includes(process.platform); 5 | 6 | export async function install() { 7 | if (process.env.CI) { 8 | return; 9 | } 10 | const exePath = await downloadBinary(); 11 | if (!iswin) { 12 | chmodSync(exePath, "755"); 13 | } 14 | 15 | // verify installation 16 | spawnSync(exePath, ["-v"], { 17 | stdio: "inherit", 18 | }); 19 | } 20 | 21 | const baseReleaseUrl = 22 | "https://github.com/anycable/anycable-go/releases/download/"; 23 | 24 | import packageJson from "./package.json" with { type: "json" }; 25 | const packageVersion = packageJson.version; 26 | const version = packageVersion.replace(/-patch.*$/, ""); 27 | 28 | function getDownloadURL() { 29 | // Detect OS 30 | // https://nodejs.org/api/process.html#process_process_platform 31 | let downloadOS = process.platform; 32 | let extension = ""; 33 | if (iswin) { 34 | downloadOS = "win"; 35 | extension = ".exe"; 36 | } 37 | 38 | // Detect architecture 39 | // https://nodejs.org/api/process.html#process_process_arch 40 | let arch = process.arch; 41 | 42 | // Based on https://github.com/anycable/anycable-rails/blob/master/lib/generators/anycable/with_os_helpers.rb#L20 43 | switch (process.arch) { 44 | case "x64": { 45 | arch = "amd64"; 46 | break; 47 | } 48 | } 49 | 50 | return `${baseReleaseUrl}/v${version}/anycable-go-${downloadOS}-${arch}${extension}`; 51 | } 52 | 53 | import { DownloaderHelper } from "node-downloader-helper"; 54 | import * as path from "path"; 55 | import { fileURLToPath } from "url"; 56 | 57 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); 58 | 59 | async function downloadBinary() { 60 | // TODO zip the binaries to reduce the download size 61 | const downloadURL = getDownloadURL(); 62 | const extension = iswin ? ".exe" : ""; 63 | const fileName = `anycable-go${extension}`; 64 | const binDir = path.join(__dirname, "bin"); 65 | const dl = new DownloaderHelper(downloadURL, binDir, { 66 | fileName, 67 | retry: { maxRetries: 5, delay: 50 }, 68 | }); 69 | 70 | console.log("Downloading anycable-go binary from " + downloadURL + "..."); 71 | dl.on("end", () => console.log("anycable-go binary was downloaded")); 72 | try { 73 | await dl.start(); 74 | } catch (e) { 75 | const message = `Failed to download ${fileName}: ${e.message} while fetching ${downloadURL}`; 76 | console.error(message); 77 | throw new Error(message); 78 | } 79 | return path.join(binDir, fileName); 80 | } 81 | -------------------------------------------------------------------------------- /vendorlib/go-mruby/class_test.go: -------------------------------------------------------------------------------- 1 | package mruby 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestClassDefineClassMethod(t *testing.T) { 8 | mrb := NewMrb() 9 | defer mrb.Close() 10 | 11 | class := mrb.DefineClass("Hello", mrb.ObjectClass()) 12 | class.DefineClassMethod("foo", testCallback, ArgsNone()) 13 | value, err := mrb.LoadString("Hello.foo") 14 | if err != nil { 15 | t.Fatalf("err: %s", err) 16 | } 17 | 18 | testCallbackResult(t, value) 19 | } 20 | 21 | func TestClassDefineConst(t *testing.T) { 22 | mrb := NewMrb() 23 | defer mrb.Close() 24 | 25 | class := mrb.DefineClass("Hello", mrb.ObjectClass()) 26 | class.DefineConst("FOO", String("bar")) 27 | value, err := mrb.LoadString("Hello::FOO") 28 | if err != nil { 29 | t.Fatalf("err: %s", err) 30 | } 31 | if value.String() != "bar" { 32 | t.Fatalf("bad: %s", value) 33 | } 34 | } 35 | 36 | func TestClassDefineMethod(t *testing.T) { 37 | mrb := NewMrb() 38 | defer mrb.Close() 39 | 40 | class := mrb.DefineClass("Hello", mrb.ObjectClass()) 41 | class.DefineMethod("foo", testCallback, ArgsNone()) 42 | value, err := mrb.LoadString("Hello.new.foo") 43 | if err != nil { 44 | t.Fatalf("err: %s", err) 45 | } 46 | 47 | testCallbackResult(t, value) 48 | } 49 | 50 | func TestClassNew(t *testing.T) { 51 | mrb := NewMrb() 52 | defer mrb.Close() 53 | 54 | class := mrb.DefineClass("Hello", mrb.ObjectClass()) 55 | class.DefineMethod("foo", testCallback, ArgsNone()) 56 | 57 | instance, err := class.New() 58 | if err != nil { 59 | t.Fatalf("err: %s", err) 60 | } 61 | 62 | value, err := instance.Call("foo") 63 | if err != nil { 64 | t.Fatalf("err: %s", err) 65 | } 66 | 67 | testCallbackResult(t, value) 68 | } 69 | 70 | func TestClassNewException(t *testing.T) { 71 | mrb := NewMrb() 72 | defer mrb.Close() 73 | 74 | class := mrb.DefineClass("Hello", mrb.ObjectClass()) 75 | class.DefineMethod("initialize", testCallbackException, ArgsNone()) 76 | 77 | _, err := class.New() 78 | if err == nil { 79 | t.Fatalf("expected exception") 80 | } 81 | 82 | // Verify exception is cleared 83 | val, err := mrb.LoadString(`"test"`) 84 | if err != nil { 85 | t.Fatalf("unexpected exception: %#v", err) 86 | } 87 | 88 | if val.String() != "test" { 89 | t.Fatalf("expected val 'test', got %#v", val) 90 | } 91 | } 92 | 93 | func TestClassValue(t *testing.T) { 94 | mrb := NewMrb() 95 | defer mrb.Close() 96 | 97 | class := mrb.DefineClass("Hello", mrb.ObjectClass()) 98 | value := class.MrbValue(mrb) 99 | if value.Type() != TypeClass { 100 | t.Fatalf("bad: %d", value.Type()) 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /sse/encoder.go: -------------------------------------------------------------------------------- 1 | package sse 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | 8 | "github.com/anycable/anycable-go/common" 9 | "github.com/anycable/anycable-go/encoders" 10 | "github.com/anycable/anycable-go/utils" 11 | "github.com/anycable/anycable-go/ws" 12 | ) 13 | 14 | const sseEncoderID = "sse" 15 | 16 | // Tell the client to reconnect in a year in case we don't really want it to re-connect 17 | const retryNoReconnect = int64(31536000000) 18 | 19 | const lastIdDelimeter = "/" 20 | 21 | // Encoder is responsible for converting messages to SSE format (event:, data:, etc.) 22 | // NOTE: It's only used to encode messages from server to client. 23 | type Encoder struct { 24 | // Whether to send protocol events or just data messages 25 | RawData bool 26 | // Whether to send only the "message" field of the payload as data or the whole payload 27 | UnwrapData bool 28 | } 29 | 30 | func (Encoder) ID() string { 31 | return sseEncoderID 32 | } 33 | 34 | func (e *Encoder) Encode(msg encoders.EncodedMessage) (*ws.SentFrame, error) { 35 | msgType := msg.GetType() 36 | 37 | b, err := json.Marshal(&msg) 38 | if err != nil { 39 | panic("Failed to build JSON 😲") 40 | } 41 | 42 | var payload string 43 | 44 | reply, isReply := msg.(*common.Reply) 45 | 46 | if isReply && reply.Type == "" && e.UnwrapData { 47 | var data string 48 | 49 | if replyStr, ok := reply.Message.(string); ok { 50 | data = replyStr 51 | } else { 52 | data = string(utils.ToJSON(reply.Message)) 53 | } 54 | payload = "data: " + data 55 | } else { 56 | payload = "data: " + string(b) 57 | } 58 | 59 | if msgType != "" { 60 | if e.RawData { 61 | return nil, nil 62 | } 63 | 64 | payload = "event: " + msgType + "\n" + payload 65 | } 66 | 67 | if reply, ok := msg.(*common.Reply); ok { 68 | if reply.Offset > 0 && reply.Epoch != "" && reply.StreamID != "" { 69 | payload += "\nid: " + fmt.Sprintf("%d%s%s%s%s", reply.Offset, lastIdDelimeter, reply.Epoch, lastIdDelimeter, reply.StreamID) 70 | } 71 | } 72 | 73 | if msgType == "disconnect" { 74 | dmsg, ok := msg.(*common.DisconnectMessage) 75 | if ok && !dmsg.Reconnect { 76 | payload += "\nretry: " + fmt.Sprintf("%d", retryNoReconnect) 77 | } 78 | } 79 | 80 | return &ws.SentFrame{FrameType: ws.TextFrame, Payload: []byte(payload)}, nil 81 | } 82 | 83 | func (e Encoder) EncodeTransmission(raw string) (*ws.SentFrame, error) { 84 | msg := common.Reply{} 85 | 86 | if err := json.Unmarshal([]byte(raw), &msg); err != nil { 87 | return nil, err 88 | } 89 | 90 | return e.Encode(&msg) 91 | } 92 | 93 | func (Encoder) Decode(raw []byte) (*common.Message, error) { 94 | return nil, errors.New("unsupported") 95 | } 96 | --------------------------------------------------------------------------------