From e7aa1a443658b874a22c7657aac9009b6e6699c2 Mon Sep 17 00:00:00 2001 From: yusing Date: Thu, 20 Aug 2026 17:20:18 +0800 Subject: [PATCH 1/3] refactor: migrate JSON from sonic to encoding/json/v2 Wire encoding/json/v2 into strutils, drop USE_SONIC_JSON, and keep time.Duration as nanosecond numbers so existing API JSON still round-trips. --- .env.example | 3 - agent/cmd/main.go | 3 - agent/go.mod | 2 +- .../agent/templates/agent.compose.yml.tmpl | 2 - agent/pkg/handler/proxy_http.go | 5 +- go.mod | 2 +- internal/dnsproviders/go.mod | 7 -- internal/dnsproviders/go.sum | 15 ---- .../integrations/qbittorrent/README.md | 2 +- internal/homepage/override_config_test.go | 4 +- internal/jsonstore/README.md | 2 +- internal/metrics/README.md | 2 +- internal/metrics/period/README.md | 2 +- internal/metrics/uptime/README.md | 2 +- internal/serialization/README.md | 12 +-- internal/serialization/json.go | 80 +++++++++++++++++++ internal/serialization/json_test.go | 55 +++++++++++++ internal/serialization/serialization.go | 25 +----- rootless.env.example | 3 - scripts/install-agent.sh | 2 - webui | 2 +- 21 files changed, 150 insertions(+), 82 deletions(-) create mode 100644 internal/serialization/json.go create mode 100644 internal/serialization/json_test.go diff --git a/.env.example b/.env.example index 2a5bf6b9d..d89507339 100644 --- a/.env.example +++ b/.env.example @@ -80,6 +80,3 @@ LISTEN_ADDR=127.0.0.1:2375 # Debug mode GODOXY_DEBUG=false - -# use bytedance/sonic library for efficient json handling, disable if you see "SIGILL: illegal instructions" -USE_SONIC_JSON=true diff --git a/agent/cmd/main.go b/agent/cmd/main.go index 8de15c411..933a97051 100644 --- a/agent/cmd/main.go +++ b/agent/cmd/main.go @@ -18,7 +18,6 @@ import ( "github.com/yusing/godoxy/agent/pkg/env" "github.com/yusing/godoxy/agent/pkg/handler" "github.com/yusing/godoxy/internal/metrics/systeminfo" - "github.com/yusing/godoxy/internal/serialization" _ "github.com/yusing/godoxy/internal/serialization" socketproxy "github.com/yusing/godoxy/socketproxy/pkg" strutils "github.com/yusing/goutils/strings" @@ -59,13 +58,11 @@ func main() { log.Info().Msgf("Agent name: %s", env.AgentName) log.Info().Msgf("Agent port: %d", env.AgentPort) log.Info().Msgf("Agent runtime: %s", env.Runtime) - log.Info().Msgf("Sonic enabled: %v", serialization.EnvUseSonic) log.Info().Msg(` Tips: 1. To change the agent name, you can set the AGENT_NAME environment variable. 2. To change the agent port, you can set the AGENT_PORT environment variable. -3. Set USE_SONIC_JSON to false when you see "SIGILL: illegal instructions" error. `) t := task.RootTask("agent", false) diff --git a/agent/go.mod b/agent/go.mod index 6f45fe104..d2be9038a 100644 --- a/agent/go.mod +++ b/agent/go.mod @@ -16,7 +16,6 @@ replace ( exclude github.com/containerd/nerdctl/mod/tigron v0.0.0 require ( - github.com/bytedance/sonic v1.15.2 github.com/gin-gonic/gin v1.12.0 github.com/gorilla/websocket v1.5.3 github.com/pion/dtls/v3 v3.1.5 @@ -34,6 +33,7 @@ require ( github.com/Microsoft/go-winio v0.6.2 // indirect github.com/andybalholm/brotli v1.2.2 // indirect github.com/bytedance/gopkg v0.1.4 // indirect + github.com/bytedance/sonic v1.15.2 // indirect github.com/bytedance/sonic/loader v0.5.2 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.7 // indirect diff --git a/agent/pkg/agent/templates/agent.compose.yml.tmpl b/agent/pkg/agent/templates/agent.compose.yml.tmpl index 007b60147..c0d9fc4c9 100644 --- a/agent/pkg/agent/templates/agent.compose.yml.tmpl +++ b/agent/pkg/agent/templates/agent.compose.yml.tmpl @@ -25,8 +25,6 @@ services: AGENT_PORT: "{{.Port}}" AGENT_CA_CERT: "{{.CACert}}" AGENT_SSL_CERT: "{{.SSLCert}}" - # use bytedance/sonic library for efficient json handling, disable if you see "SIGILL: illegal instructions" - USE_SONIC_JSON: true # use agent as a docker socket proxy: [host]:port # set LISTEN_ADDR to enable (e.g. 127.0.0.1:2375) LISTEN_ADDR: diff --git a/agent/pkg/handler/proxy_http.go b/agent/pkg/handler/proxy_http.go index 915f9ff45..d68523933 100644 --- a/agent/pkg/handler/proxy_http.go +++ b/agent/pkg/handler/proxy_http.go @@ -2,6 +2,8 @@ package handler import ( "container/list" + jsonv1 "encoding/json" + jsonv2 "encoding/json/v2" "fmt" "net/http" "net/url" @@ -9,7 +11,6 @@ import ( "sync" "time" - "github.com/bytedance/sonic" "github.com/yusing/godoxy/agent/pkg/agent" "github.com/yusing/godoxy/agent/pkg/agentproxy" "github.com/yusing/goutils/http/reverseproxy" @@ -80,7 +81,7 @@ func ProxyHTTP(w http.ResponseWriter, r *http.Request) { } func cachedReverseProxy(cfg agentproxy.Config, targetURL *url.URL) (*reverseproxy.ReverseProxy, error) { - keyBytes, err := sonic.Marshal(cfg) + keyBytes, err := jsonv2.Marshal(cfg, jsonv1.FormatDurationAsNano(true)) if err != nil { return nil, fmt.Errorf("marshal proxy config: %w", err) } diff --git a/go.mod b/go.mod index 23cfca61c..8a6853743 100644 --- a/go.mod +++ b/go.mod @@ -17,7 +17,6 @@ replace ( require ( github.com/PuerkitoBio/goquery v1.12.0 // parsing HTML for extract fav icon; modify_html middleware github.com/bytedance/gopkg v0.1.4 // xxhash64 for fast hash - github.com/bytedance/sonic v1.15.2 // fast json parsing github.com/cenkalti/backoff/v5 v5.0.3 // backoff for retrying operations github.com/coreos/go-oidc/v3 v3.20.0 // oidc authentication github.com/docker/cli v29.7.2+incompatible // needs docker/cli/cli/connhelper connection helper for docker client @@ -83,6 +82,7 @@ require ( github.com/bodgit/tsig v1.3.1 // indirect github.com/boombuler/barcode v1.1.0 // indirect github.com/buger/goterm v1.0.4 // indirect + github.com/bytedance/sonic v1.15.2 // indirect github.com/bytedance/sonic/loader v0.5.2 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.7 // indirect diff --git a/internal/dnsproviders/go.mod b/internal/dnsproviders/go.mod index 774e5678e..1cd8b684a 100644 --- a/internal/dnsproviders/go.mod +++ b/internal/dnsproviders/go.mod @@ -28,12 +28,8 @@ require ( github.com/bodgit/gssapi v0.0.4 // indirect github.com/bodgit/tsig v1.3.1 // indirect github.com/boombuler/barcode v1.1.0 // indirect - github.com/bytedance/gopkg v0.1.4 // indirect - github.com/bytedance/sonic v1.15.2 // indirect - github.com/bytedance/sonic/loader v0.5.2 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/cloudwego/base64x v0.1.7 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/fatih/structs v1.1.0 // indirect github.com/felixge/httpsnoop v1.1.0 // indirect @@ -67,7 +63,6 @@ require ( github.com/jcmturner/goidentity/v6 v6.0.1 // indirect github.com/jcmturner/gokrb5/v8 v8.4.4 // indirect github.com/jcmturner/rpc/v2 v2.0.3 // indirect - github.com/klauspost/cpuid/v2 v2.4.0 // indirect github.com/kolo/xmlrpc v0.0.0-20220921171641-a4b6fa1dd06b // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/leodido/go-urn v1.5.0 // indirect @@ -96,7 +91,6 @@ require ( github.com/spf13/afero v1.15.0 // indirect github.com/stretchr/objx v0.5.3 // indirect github.com/stretchr/testify v1.11.1 // indirect - github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/vultr/govultr/v3 v3.32.0 // indirect github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect github.com/yusing/gointernals v0.2.1 // indirect @@ -107,7 +101,6 @@ require ( go.opentelemetry.io/otel/metric v1.45.0 // indirect go.opentelemetry.io/otel/trace v1.45.0 // indirect go.uber.org/ratelimit v0.3.1 // indirect - golang.org/x/arch v0.30.0 // indirect golang.org/x/crypto v0.55.0 // indirect golang.org/x/mod v0.40.0 // indirect golang.org/x/net v0.58.0 // indirect diff --git a/internal/dnsproviders/go.sum b/internal/dnsproviders/go.sum index 608e8a03f..ca49e634e 100644 --- a/internal/dnsproviders/go.sum +++ b/internal/dnsproviders/go.sum @@ -42,18 +42,10 @@ github.com/bodgit/tsig v1.3.1/go.mod h1:Ez+xu0W5Ew/D28XqthucLOX8k9A0UGVPETvagYxb github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo= github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= -github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM= -github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4= -github.com/bytedance/sonic v1.15.2 h1:90H+rcF/FwLXwfB1cudOLq/je83n683Utf4Cbp0xHCo= -github.com/bytedance/sonic v1.15.2/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA= -github.com/bytedance/sonic/loader v0.5.2 h1:0QtP1gevc1OZ6/H8Lb9BRZiCXd1Ftjd3OKuj1T1lBIo= -github.com/bytedance/sonic/loader v0.5.2/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/cloudwego/base64x v0.1.7 h1:NppS+Fgzg5ovhn4NkUXaDT3x9jldgH5ToMCqzBSi2zI= -github.com/cloudwego/base64x v0.1.7/go.mod h1:Cu1PV9zfrSf7ET2tIbWbbEy7jO7HHJ13q4X2SQ8aWYg= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -146,8 +138,6 @@ github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZ github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= -github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw= -github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU= github.com/kolo/xmlrpc v0.0.0-20220921171641-a4b6fa1dd06b h1:udzkj9S/zlT5X367kqJis0QP7YMxobob6zhzq6Yre00= github.com/kolo/xmlrpc v0.0.0-20220921171641-a4b6fa1dd06b/go.mod h1:pcaDhQK0/NJZEvtCO0qQPPropqV0sJOJ6YW7X+9kRwM= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -219,11 +209,8 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= -github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/vultr/govultr/v3 v3.32.0 h1:QS9IAeSB3BIhSoP0jIYtmGAvtsYkprjWTZAGPx8keo8= github.com/vultr/govultr/v3 v3.32.0/go.mod h1:2zyUw9yADQaGwKnwDesmIOlBNLrm7edsCfWHFJpWKf8= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= @@ -251,8 +238,6 @@ go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/ratelimit v0.3.1 h1:K4qVE+byfv/B3tC+4nYWP7v/6SimcO7HzHekoMNBma0= go.uber.org/ratelimit v0.3.1/go.mod h1:6euWsTB6U/Nb3X++xEUXA8ciPJvr19Q/0h1+oDcJhRk= -golang.org/x/arch v0.30.0 h1:sB9h+1gRGa2+LauFSV0tm8bK1J2yo1bx6/Uyi/P6DTU= -golang.org/x/arch v0.30.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= diff --git a/internal/homepage/integrations/qbittorrent/README.md b/internal/homepage/integrations/qbittorrent/README.md index 54aedb4c4..d05e349d9 100644 --- a/internal/homepage/integrations/qbittorrent/README.md +++ b/internal/homepage/integrations/qbittorrent/README.md @@ -223,4 +223,4 @@ if resp.StatusCode != http.StatusOK { ## Related Packages - `internal/homepage/widgets` - Widget framework and interface -- `github.com/yusing/goutils/strings` - String utilities and JSON helpers (`MarshalJSON`, `NewJSONDecoder`; backend: sonic via `internal/serialization`) +- `github.com/yusing/goutils/strings` - String utilities and JSON helpers (`MarshalJSON`, `NewJSONDecoder`; backend: `encoding/json/v2` via `internal/serialization`) diff --git a/internal/homepage/override_config_test.go b/internal/homepage/override_config_test.go index f0fbffc6c..bd40d18da 100644 --- a/internal/homepage/override_config_test.go +++ b/internal/homepage/override_config_test.go @@ -14,8 +14,8 @@ import ( // The periodic jsonstore flush marshals the config while the API keeps writing // to it, so marshaling must hold the lock. // -// encoding/json is used instead of the sonic-backed strutils.MarshalJSON -// because sonic's generated encoder is not instrumented for the race detector. +// encoding/json is used instead of strutils.MarshalJSON so the test stays on +// the standard library encoder under the race detector. func TestOverrideConfigMarshalIsConcurrentSafe(t *testing.T) { aliases := []string{"a", "b", "c"} diff --git a/internal/jsonstore/README.md b/internal/jsonstore/README.md index e0e12736c..dd250701d 100644 --- a/internal/jsonstore/README.md +++ b/internal/jsonstore/README.md @@ -363,5 +363,5 @@ if err := strutils.UnmarshalJSON(data, &tmp); err != nil { - Uses `xsync.Map` for lock-free reads - Presizes maps based on input data -- JSON via `github.com/yusing/goutils/strings` helpers (in godoxy, sonic is registered as the backend from `internal/serialization`) +- JSON via `github.com/yusing/goutils/strings` helpers (in godoxy, `encoding/json/v2` is registered as the backend from `internal/serialization`) - Background periodic save plus a save on program exit; unchanged namespaces are skipped diff --git a/internal/metrics/README.md b/internal/metrics/README.md index ad227b791..0c79da383 100644 --- a/internal/metrics/README.md +++ b/internal/metrics/README.md @@ -97,7 +97,7 @@ No explicit configuration. Pollers respect `common.MetricsDisable*` flags: - `github.com/shirou/gopsutil/v4` - System metrics collection - `github.com/puzpuzpuz/xsync/v4` - Atomic value storage -- `github.com/yusing/goutils/strings` - JSON serialization helpers (backend: sonic via `internal/serialization`) +- `github.com/yusing/goutils/strings` - JSON serialization helpers (backend: `encoding/json/v2` via `internal/serialization`) ## Observability diff --git a/internal/metrics/period/README.md b/internal/metrics/period/README.md index 0a64f063c..aee37a28e 100644 --- a/internal/metrics/period/README.md +++ b/internal/metrics/period/README.md @@ -286,7 +286,7 @@ None. | ------------------------------------------ | ------------------------ | | `github.com/gin-gonic/gin` | HTTP handling | | `github.com/yusing/goutils/http/websocket` | WebSocket streaming | -| `github.com/yusing/goutils/strings` | JSON serialization (backend: sonic via `internal/serialization`) | +| `github.com/yusing/goutils/strings` | JSON serialization (backend: `encoding/json/v2` via `internal/serialization`) | | `github.com/yusing/goutils/task` | Lifetime management | | `github.com/puzpuzpuz/xsync/v4` | Concurrent value storage | diff --git a/internal/metrics/uptime/README.md b/internal/metrics/uptime/README.md index 635c1353e..5e538058c 100644 --- a/internal/metrics/uptime/README.md +++ b/internal/metrics/uptime/README.md @@ -220,7 +220,7 @@ No explicit configuration. The poller uses period package defaults: | Dependency | Purpose | | ---------------------------------------- | ---------------- | | `github.com/lithammer/fuzzysearch/fuzzy` | Keyword matching | -| `github.com/yusing/goutils/strings` | JSON marshaling (backend: sonic via `internal/serialization`) | +| `github.com/yusing/goutils/strings` | JSON marshaling (backend: `encoding/json/v2` via `internal/serialization`) | ### Integration Points diff --git a/internal/serialization/README.md b/internal/serialization/README.md index d2bf5c85f..60895737d 100644 --- a/internal/serialization/README.md +++ b/internal/serialization/README.md @@ -214,17 +214,7 @@ autocert: - `github.com/go-playground/validator/v10` - Validation - `github.com/puzpuzpuz/xsync/v4` - Type cache - `github.com/yusing/goutils/strings` - Pluggable JSON marshal/unmarshal/streaming API used across godoxy -- `github.com/bytedance/sonic` - Optional JSON backend for that API: registered in `init()` when **`USE_SONIC_JSON`** is true (process env via `env.GetEnvBool`, default **true**). -- Set **`USE_SONIC_JSON=false`** if the binary hits **`SIGILL: illegal instruction`** on hardware that lacks the assumptions Sonic relies on. - -### `USE_SONIC_JSON` - -| Value | Behavior | -| ---------------- | ----------------------------------------------------------------------------------------------- | -| `true` (default) | `setupSonic()` wires Sonic into `strutils` for marshal, unmarshal, indent, and encoder helpers. | -| `false` | Sonic is not registered; JSON uses whatever default `strutils` provides without explicit setup. | - -Documented in repository `.env.example`, `rootless.env.example`, agent compose template (`agent/pkg/agent/templates/agent.compose.yml.tmpl`), and `scripts/install-agent.sh`. +- `encoding/json/v2` and `encoding/json/jsontext` - JSON backend registered in `init()` (`setupJSONV2()`). `time.Duration` keeps v1 nanosecond numbers via `encoding/json.FormatDurationAsNano`. ### Internal Dependencies diff --git a/internal/serialization/json.go b/internal/serialization/json.go new file mode 100644 index 000000000..c3b20ae36 --- /dev/null +++ b/internal/serialization/json.go @@ -0,0 +1,80 @@ +package serialization + +import ( + jsonv1 "encoding/json" + "encoding/json/jsontext" + jsonv2 "encoding/json/v2" + "io" + + strutils "github.com/yusing/goutils/strings" +) + +// json/v2 has no default encoding for time.Duration. Keep v1's nanosecond +// numbers so existing API and stored JSON (health, HTTP timeouts, ACL) still round-trip. +var jsonOpts = jsonv2.JoinOptions(jsonv1.FormatDurationAsNano(true)) + +func setupJSONV2() { + strutils.SetJSONMarshaler(func(v any) ([]byte, error) { + return jsonv2.Marshal(v, jsonOpts) + }) + strutils.SetJSONUnmarshaler(func(data []byte, v any) error { + return jsonv2.Unmarshal(data, v, jsonOpts) + }) + strutils.SetJSONMarshalIndent(func(v any, prefix, indent string) ([]byte, error) { + return jsonv2.Marshal(v, jsonOpts, jsontext.WithIndentPrefix(prefix), jsontext.WithIndent(indent)) + }) + strutils.SetJSONNewEncoder(func(w io.Writer) strutils.Encoder { + return &jsonEncoder{w: w} + }) + strutils.SetJSONNewDecoder(func(r io.Reader) strutils.Decoder { + return &jsonDecoder{dec: jsontext.NewDecoder(r)} + }) + strutils.SetJSONValid(func(b []byte) bool { + return jsontext.Value(b).IsValid() + }) + strutils.SetJSONMarshalString(func(v any) (string, error) { + b, err := jsonv2.Marshal(v, jsonOpts) + return string(b), err + }) + strutils.SetJSONUnmarshalString(func(data string, v any) error { + return jsonv2.Unmarshal([]byte(data), v, jsonOpts) + }) + strutils.SetJSONValidString(func(s string) bool { + return jsontext.Value([]byte(s)).IsValid() + }) +} + +type jsonEncoder struct { + w io.Writer + prefix string + indent string + escapeHTML bool +} + +func (e *jsonEncoder) Encode(v any) error { + opts := []jsonv2.Options{jsonOpts} + if e.prefix != "" || e.indent != "" { + opts = append(opts, jsontext.WithIndentPrefix(e.prefix), jsontext.WithIndent(e.indent)) + } + if e.escapeHTML { + opts = append(opts, jsontext.EscapeForHTML(true)) + } + return jsonv2.MarshalEncode(jsontext.NewEncoder(e.w), v, opts...) +} + +func (e *jsonEncoder) SetEscapeHTML(escape bool) { + e.escapeHTML = escape +} + +func (e *jsonEncoder) SetIndent(prefix, indent string) { + e.prefix = prefix + e.indent = indent +} + +type jsonDecoder struct { + dec *jsontext.Decoder +} + +func (d *jsonDecoder) Decode(v any) error { + return jsonv2.UnmarshalDecode(d.dec, v, jsonOpts) +} diff --git a/internal/serialization/json_test.go b/internal/serialization/json_test.go new file mode 100644 index 000000000..27a887298 --- /dev/null +++ b/internal/serialization/json_test.go @@ -0,0 +1,55 @@ +package serialization + +import ( + "bytes" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + strutils "github.com/yusing/goutils/strings" +) + +func TestJSONV2DurationRoundTrip(t *testing.T) { + type payload struct { + D time.Duration `json:"d"` + } + in := payload{D: time.Second} + b, err := strutils.MarshalJSON(in) + require.NoError(t, err) + require.JSONEq(t, `{"d":1000000000}`, string(b)) + + var out payload + require.NoError(t, strutils.UnmarshalJSON(b, &out)) + require.Equal(t, time.Second, out.D) +} + +func TestJSONV2StringAndValid(t *testing.T) { + s, err := strutils.MarshalJSONString(map[string]int{"a": 1}) + require.NoError(t, err) + require.JSONEq(t, `{"a":1}`, s) + require.True(t, strutils.ValidJSONString(s)) + require.True(t, strutils.ValidJSON([]byte(s))) + require.False(t, strutils.ValidJSONString("{")) + + var got map[string]int + require.NoError(t, strutils.UnmarshalJSONString(s, &got)) + require.Equal(t, 1, got["a"]) +} + +func TestJSONV2EncoderDecoder(t *testing.T) { + var buf bytes.Buffer + require.NoError(t, strutils.NewJSONEncoder(&buf).Encode(map[string]int{"n": 2})) + require.True(t, strings.HasSuffix(buf.String(), "\n"), "streaming encode should end with a newline") + require.JSONEq(t, `{"n":2}`, strings.TrimSpace(buf.String())) + + var got map[string]int + require.NoError(t, strutils.NewJSONDecoder(&buf).Decode(&got)) + require.Equal(t, 2, got["n"]) +} + +func TestJSONV2MarshalIndent(t *testing.T) { + b, err := strutils.MarshalJSONIndent(map[string]int{"a": 1}, "", " ") + require.NoError(t, err) + require.Equal(t, "{\n \"a\": 1\n}", string(b)) +} diff --git a/internal/serialization/serialization.go b/internal/serialization/serialization.go index f017dfd87..56cab0810 100644 --- a/internal/serialization/serialization.go +++ b/internal/serialization/serialization.go @@ -4,7 +4,6 @@ import ( "bytes" "errors" "fmt" - "io" "os" "reflect" "regexp" @@ -13,7 +12,6 @@ import ( "time" "unsafe" - "github.com/bytedance/sonic" "github.com/go-playground/validator/v10" "github.com/goccy/go-yaml" "github.com/puzpuzpuz/xsync/v4" @@ -23,8 +21,6 @@ import ( strutils "github.com/yusing/goutils/strings" ) -var EnvUseSonic = env.GetEnvBool("USE_SONIC_JSON", true) - type SerializedObject = map[string]any // ToSerializedObject converts a map[string]VT to a SerializedObject. @@ -37,30 +33,11 @@ func ToSerializedObject[VT any](m map[string]VT) SerializedObject { } func init() { - if EnvUseSonic { - setupSonic() - } - // default values uses std json + setupJSONV2() strutils.SetYAMLMarshaler(yaml.Marshal) strutils.SetYAMLUnmarshaler(yaml.Unmarshal) } -func setupSonic() { - strutils.SetJSONMarshaler(sonic.Marshal) - strutils.SetJSONUnmarshaler(sonic.Unmarshal) - strutils.SetJSONMarshalIndent(sonic.MarshalIndent) - strutils.SetJSONNewEncoder(func(w io.Writer) strutils.Encoder { - return sonic.ConfigDefault.NewEncoder(w) - }) - strutils.SetJSONNewDecoder(func(r io.Reader) strutils.Decoder { - return sonic.ConfigDefault.NewDecoder(r) - }) - strutils.SetJSONValid(sonic.Valid) - strutils.SetJSONMarshalString(sonic.MarshalString) - strutils.SetJSONUnmarshalString(sonic.UnmarshalString) - strutils.SetJSONValidString(sonic.ValidString) -} - type MapUnmarshaller interface { UnmarshalMap(m map[string]any) error } diff --git a/rootless.env.example b/rootless.env.example index e6d787c0d..625ed6f67 100644 --- a/rootless.env.example +++ b/rootless.env.example @@ -57,6 +57,3 @@ GODOXY_METRICS_DISABLE_SENSORS=false # Debug mode GODOXY_DEBUG=false - -# use bytedance/sonic library for efficient json handling, disable if you see "SIGILL: illegal instructions" -USE_SONIC_JSON=true diff --git a/scripts/install-agent.sh b/scripts/install-agent.sh index 49ea1c0aa..deb7b85a3 100644 --- a/scripts/install-agent.sh +++ b/scripts/install-agent.sh @@ -323,8 +323,6 @@ AGENT_CA_CERT="${AGENT_CA_CERT}" AGENT_SSL_CERT="${AGENT_SSL_CERT}" DOCKER_SOCKET="${DOCKER_SOCKET}" RUNTIME="${RUNTIME}" -# use bytedance/sonic library for efficient json handling, disable if you see "SIGILL: illegal instructions" -USE_SONIC_JSON=true EOF chmod 600 $env_file diff --git a/webui b/webui index 95ad1fed0..51007d171 160000 --- a/webui +++ b/webui @@ -1 +1 @@ -Subproject commit 95ad1fed08559262ede11f0f2dabad6586aa137c +Subproject commit 51007d17177c654b189d6f9eae78ba7a3fba3f9f From 340c95762c47b2bfe8f43c75bae26365c90d83cb Mon Sep 17 00:00:00 2001 From: yusing Date: Thu, 20 Aug 2026 18:36:36 +0800 Subject: [PATCH 2/3] refactor: delegate JSON serialization to goutils Remove the local JSON v2 registration and tests, and use goutils string helpers for proxy cache keys and webhook validation. Update related documentation to reflect the centralized backend. Submodule updates: - refactor(strings): migrate JSON serialization to encoding/json/v2 - chore: update Go base image and wiki documentation - chore(deps): update wiki submodule for json/v2 docs (#18) --- agent/pkg/handler/proxy_http.go | 6 +- goutils | 2 +- .../integrations/qbittorrent/README.md | 2 +- internal/jsonstore/README.md | 3 +- internal/metrics/README.md | 3 +- internal/metrics/period/README.md | 3 +- internal/metrics/uptime/README.md | 3 +- internal/notif/webhook.go | 2 +- internal/serialization/README.md | 4 +- internal/serialization/json.go | 80 ------------------- internal/serialization/json_test.go | 55 ------------- internal/serialization/serialization.go | 1 - webui | 2 +- 13 files changed, 16 insertions(+), 150 deletions(-) delete mode 100644 internal/serialization/json.go delete mode 100644 internal/serialization/json_test.go diff --git a/agent/pkg/handler/proxy_http.go b/agent/pkg/handler/proxy_http.go index d68523933..c1885e80f 100644 --- a/agent/pkg/handler/proxy_http.go +++ b/agent/pkg/handler/proxy_http.go @@ -2,8 +2,6 @@ package handler import ( "container/list" - jsonv1 "encoding/json" - jsonv2 "encoding/json/v2" "fmt" "net/http" "net/url" @@ -14,6 +12,7 @@ import ( "github.com/yusing/godoxy/agent/pkg/agent" "github.com/yusing/godoxy/agent/pkg/agentproxy" "github.com/yusing/goutils/http/reverseproxy" + strutils "github.com/yusing/goutils/strings" ) const maxCachedProxies = 64 @@ -81,11 +80,10 @@ func ProxyHTTP(w http.ResponseWriter, r *http.Request) { } func cachedReverseProxy(cfg agentproxy.Config, targetURL *url.URL) (*reverseproxy.ReverseProxy, error) { - keyBytes, err := jsonv2.Marshal(cfg, jsonv1.FormatDurationAsNano(true)) + key, err := strutils.MarshalString(cfg) if err != nil { return nil, fmt.Errorf("marshal proxy config: %w", err) } - key := string(keyBytes) proxyCache.Lock() defer proxyCache.Unlock() diff --git a/goutils b/goutils index fb8566d3f..40e656afb 160000 --- a/goutils +++ b/goutils @@ -1 +1 @@ -Subproject commit fb8566d3f3e3347dd6939f9215ab7890f12272a3 +Subproject commit 40e656afbc9a8e6423a15577f726f2988601ce69 diff --git a/internal/homepage/integrations/qbittorrent/README.md b/internal/homepage/integrations/qbittorrent/README.md index d05e349d9..7d1a4d548 100644 --- a/internal/homepage/integrations/qbittorrent/README.md +++ b/internal/homepage/integrations/qbittorrent/README.md @@ -223,4 +223,4 @@ if resp.StatusCode != http.StatusOK { ## Related Packages - `internal/homepage/widgets` - Widget framework and interface -- `github.com/yusing/goutils/strings` - String utilities and JSON helpers (`MarshalJSON`, `NewJSONDecoder`; backend: `encoding/json/v2` via `internal/serialization`) +- `github.com/yusing/goutils/strings` - String utilities and JSON helpers (`MarshalJSON`, `NewJSONDecoder`; `encoding/json/v2`) diff --git a/internal/jsonstore/README.md b/internal/jsonstore/README.md index dd250701d..c327d3095 100644 --- a/internal/jsonstore/README.md +++ b/internal/jsonstore/README.md @@ -363,5 +363,6 @@ if err := strutils.UnmarshalJSON(data, &tmp); err != nil { - Uses `xsync.Map` for lock-free reads - Presizes maps based on input data -- JSON via `github.com/yusing/goutils/strings` helpers (in godoxy, `encoding/json/v2` is registered as the backend from `internal/serialization`) +- JSON via `github.com/yusing/goutils/strings` helpers (`encoding/json/v2`) + - Background periodic save plus a save on program exit; unchanged namespaces are skipped diff --git a/internal/metrics/README.md b/internal/metrics/README.md index 0c79da383..cec405822 100644 --- a/internal/metrics/README.md +++ b/internal/metrics/README.md @@ -97,7 +97,8 @@ No explicit configuration. Pollers respect `common.MetricsDisable*` flags: - `github.com/shirou/gopsutil/v4` - System metrics collection - `github.com/puzpuzpuz/xsync/v4` - Atomic value storage -- `github.com/yusing/goutils/strings` - JSON serialization helpers (backend: `encoding/json/v2` via `internal/serialization`) +- `github.com/yusing/goutils/strings` - JSON serialization helpers (`encoding/json/v2`) + ## Observability diff --git a/internal/metrics/period/README.md b/internal/metrics/period/README.md index aee37a28e..ae97c6b83 100644 --- a/internal/metrics/period/README.md +++ b/internal/metrics/period/README.md @@ -286,7 +286,8 @@ None. | ------------------------------------------ | ------------------------ | | `github.com/gin-gonic/gin` | HTTP handling | | `github.com/yusing/goutils/http/websocket` | WebSocket streaming | -| `github.com/yusing/goutils/strings` | JSON serialization (backend: `encoding/json/v2` via `internal/serialization`) | +| `github.com/yusing/goutils/strings` | JSON serialization (`encoding/json/v2`) | + | `github.com/yusing/goutils/task` | Lifetime management | | `github.com/puzpuzpuz/xsync/v4` | Concurrent value storage | diff --git a/internal/metrics/uptime/README.md b/internal/metrics/uptime/README.md index 5e538058c..7d374ece1 100644 --- a/internal/metrics/uptime/README.md +++ b/internal/metrics/uptime/README.md @@ -220,7 +220,8 @@ No explicit configuration. The poller uses period package defaults: | Dependency | Purpose | | ---------------------------------------- | ---------------- | | `github.com/lithammer/fuzzysearch/fuzzy` | Keyword matching | -| `github.com/yusing/goutils/strings` | JSON marshaling (backend: `encoding/json/v2` via `internal/serialization`) | +| `github.com/yusing/goutils/strings` | JSON marshaling (`encoding/json/v2`) | + ### Integration Points diff --git a/internal/notif/webhook.go b/internal/notif/webhook.go index eebe639ff..2d937f7b1 100644 --- a/internal/notif/webhook.go +++ b/internal/notif/webhook.go @@ -149,5 +149,5 @@ func validateJSONPayload(payload string) bool { "$color", "", ) payload = replacer.Replace(payload) - return strutils.ValidJSON([]byte(payload)) + return strutils.ValidJSONString(payload) } diff --git a/internal/serialization/README.md b/internal/serialization/README.md index 60895737d..ec9459f7e 100644 --- a/internal/serialization/README.md +++ b/internal/serialization/README.md @@ -213,8 +213,8 @@ autocert: - `github.com/goccy/go-yaml` - YAML parsing - `github.com/go-playground/validator/v10` - Validation - `github.com/puzpuzpuz/xsync/v4` - Type cache -- `github.com/yusing/goutils/strings` - Pluggable JSON marshal/unmarshal/streaming API used across godoxy -- `encoding/json/v2` and `encoding/json/jsontext` - JSON backend registered in `init()` (`setupJSONV2()`). `time.Duration` keeps v1 nanosecond numbers via `encoding/json.FormatDurationAsNano`. +- `github.com/yusing/goutils/strings` - JSON marshal/unmarshal/streaming API (`encoding/json/v2`; `time.Duration` as nanosecond numbers) + ### Internal Dependencies diff --git a/internal/serialization/json.go b/internal/serialization/json.go deleted file mode 100644 index c3b20ae36..000000000 --- a/internal/serialization/json.go +++ /dev/null @@ -1,80 +0,0 @@ -package serialization - -import ( - jsonv1 "encoding/json" - "encoding/json/jsontext" - jsonv2 "encoding/json/v2" - "io" - - strutils "github.com/yusing/goutils/strings" -) - -// json/v2 has no default encoding for time.Duration. Keep v1's nanosecond -// numbers so existing API and stored JSON (health, HTTP timeouts, ACL) still round-trip. -var jsonOpts = jsonv2.JoinOptions(jsonv1.FormatDurationAsNano(true)) - -func setupJSONV2() { - strutils.SetJSONMarshaler(func(v any) ([]byte, error) { - return jsonv2.Marshal(v, jsonOpts) - }) - strutils.SetJSONUnmarshaler(func(data []byte, v any) error { - return jsonv2.Unmarshal(data, v, jsonOpts) - }) - strutils.SetJSONMarshalIndent(func(v any, prefix, indent string) ([]byte, error) { - return jsonv2.Marshal(v, jsonOpts, jsontext.WithIndentPrefix(prefix), jsontext.WithIndent(indent)) - }) - strutils.SetJSONNewEncoder(func(w io.Writer) strutils.Encoder { - return &jsonEncoder{w: w} - }) - strutils.SetJSONNewDecoder(func(r io.Reader) strutils.Decoder { - return &jsonDecoder{dec: jsontext.NewDecoder(r)} - }) - strutils.SetJSONValid(func(b []byte) bool { - return jsontext.Value(b).IsValid() - }) - strutils.SetJSONMarshalString(func(v any) (string, error) { - b, err := jsonv2.Marshal(v, jsonOpts) - return string(b), err - }) - strutils.SetJSONUnmarshalString(func(data string, v any) error { - return jsonv2.Unmarshal([]byte(data), v, jsonOpts) - }) - strutils.SetJSONValidString(func(s string) bool { - return jsontext.Value([]byte(s)).IsValid() - }) -} - -type jsonEncoder struct { - w io.Writer - prefix string - indent string - escapeHTML bool -} - -func (e *jsonEncoder) Encode(v any) error { - opts := []jsonv2.Options{jsonOpts} - if e.prefix != "" || e.indent != "" { - opts = append(opts, jsontext.WithIndentPrefix(e.prefix), jsontext.WithIndent(e.indent)) - } - if e.escapeHTML { - opts = append(opts, jsontext.EscapeForHTML(true)) - } - return jsonv2.MarshalEncode(jsontext.NewEncoder(e.w), v, opts...) -} - -func (e *jsonEncoder) SetEscapeHTML(escape bool) { - e.escapeHTML = escape -} - -func (e *jsonEncoder) SetIndent(prefix, indent string) { - e.prefix = prefix - e.indent = indent -} - -type jsonDecoder struct { - dec *jsontext.Decoder -} - -func (d *jsonDecoder) Decode(v any) error { - return jsonv2.UnmarshalDecode(d.dec, v, jsonOpts) -} diff --git a/internal/serialization/json_test.go b/internal/serialization/json_test.go deleted file mode 100644 index 27a887298..000000000 --- a/internal/serialization/json_test.go +++ /dev/null @@ -1,55 +0,0 @@ -package serialization - -import ( - "bytes" - "strings" - "testing" - "time" - - "github.com/stretchr/testify/require" - strutils "github.com/yusing/goutils/strings" -) - -func TestJSONV2DurationRoundTrip(t *testing.T) { - type payload struct { - D time.Duration `json:"d"` - } - in := payload{D: time.Second} - b, err := strutils.MarshalJSON(in) - require.NoError(t, err) - require.JSONEq(t, `{"d":1000000000}`, string(b)) - - var out payload - require.NoError(t, strutils.UnmarshalJSON(b, &out)) - require.Equal(t, time.Second, out.D) -} - -func TestJSONV2StringAndValid(t *testing.T) { - s, err := strutils.MarshalJSONString(map[string]int{"a": 1}) - require.NoError(t, err) - require.JSONEq(t, `{"a":1}`, s) - require.True(t, strutils.ValidJSONString(s)) - require.True(t, strutils.ValidJSON([]byte(s))) - require.False(t, strutils.ValidJSONString("{")) - - var got map[string]int - require.NoError(t, strutils.UnmarshalJSONString(s, &got)) - require.Equal(t, 1, got["a"]) -} - -func TestJSONV2EncoderDecoder(t *testing.T) { - var buf bytes.Buffer - require.NoError(t, strutils.NewJSONEncoder(&buf).Encode(map[string]int{"n": 2})) - require.True(t, strings.HasSuffix(buf.String(), "\n"), "streaming encode should end with a newline") - require.JSONEq(t, `{"n":2}`, strings.TrimSpace(buf.String())) - - var got map[string]int - require.NoError(t, strutils.NewJSONDecoder(&buf).Decode(&got)) - require.Equal(t, 2, got["n"]) -} - -func TestJSONV2MarshalIndent(t *testing.T) { - b, err := strutils.MarshalJSONIndent(map[string]int{"a": 1}, "", " ") - require.NoError(t, err) - require.Equal(t, "{\n \"a\": 1\n}", string(b)) -} diff --git a/internal/serialization/serialization.go b/internal/serialization/serialization.go index 56cab0810..afeb5aa95 100644 --- a/internal/serialization/serialization.go +++ b/internal/serialization/serialization.go @@ -33,7 +33,6 @@ func ToSerializedObject[VT any](m map[string]VT) SerializedObject { } func init() { - setupJSONV2() strutils.SetYAMLMarshaler(yaml.Marshal) strutils.SetYAMLUnmarshaler(yaml.Unmarshal) } diff --git a/webui b/webui index 51007d171..9e152672a 160000 --- a/webui +++ b/webui @@ -1 +1 @@ -Subproject commit 51007d17177c654b189d6f9eae78ba7a3fba3f9f +Subproject commit 9e152672a3975eea89e120b464d3849062eadbef From 014add97d520a0578e6ab55a715fea532d2e0ba1 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:46:56 +0000 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=93=9D=20Add=20docstrings=20to=20`fea?= =?UTF-8?q?t/json-v2`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Docstrings generation was requested by @yusing. The following files were modified: * `agent/pkg/handler/proxy_http.go` * `internal/notif/webhook.go` These files were kept as they were: * `agent/cmd/main.go` * `internal/serialization/serialization.go` These files were ignored: * `internal/homepage/override_config_test.go` These file types are not supported: * `.env.example` * `agent/go.mod` * `agent/pkg/agent/templates/agent.compose.yml.tmpl` * `go.mod` * `goutils` * `internal/dnsproviders/go.mod` * `internal/homepage/integrations/qbittorrent/README.md` * `internal/jsonstore/README.md` * `internal/metrics/README.md` * `internal/metrics/period/README.md` * `internal/metrics/uptime/README.md` * `internal/serialization/README.md` * `rootless.env.example` * `webui` --- agent/pkg/handler/proxy_http.go | 5 +++++ internal/notif/webhook.go | 1 + 2 files changed, 6 insertions(+) diff --git a/agent/pkg/handler/proxy_http.go b/agent/pkg/handler/proxy_http.go index c1885e80f..ba531719f 100644 --- a/agent/pkg/handler/proxy_http.go +++ b/agent/pkg/handler/proxy_http.go @@ -40,6 +40,10 @@ func NewTransport() *http.Transport { } } +// ProxyHTTP forwards an HTTP request to the configured proxy destination. +// It reads the proxy configuration from request headers and responds with +// status 400 for invalid configuration or status 500 if the proxy cannot be +// built. func ProxyHTTP(w http.ResponseWriter, r *http.Request) { cfg, err := agentproxy.ConfigFromHeaders(r.Header) if err != nil { @@ -79,6 +83,7 @@ func ProxyHTTP(w http.ResponseWriter, r *http.Request) { rp.ServeHTTP(w, r) } +// cachedReverseProxy retrieves or creates a reverse proxy for the specified configuration and target URL, caching the result for reuse. It returns an error if the configuration cannot be serialized or its TLS configuration cannot be built. func cachedReverseProxy(cfg agentproxy.Config, targetURL *url.URL) (*reverseproxy.ReverseProxy, error) { key, err := strutils.MarshalString(cfg) if err != nil { diff --git a/internal/notif/webhook.go b/internal/notif/webhook.go index 2d937f7b1..fae757f9f 100644 --- a/internal/notif/webhook.go +++ b/internal/notif/webhook.go @@ -141,6 +141,7 @@ func (webhook *Webhook) MarshalMessage(logMsg *LogMessage) ([]byte, error) { return []byte(pl), nil } +// validateJSONPayload reports whether a webhook payload is valid JSON after replacing supported placeholders with JSON-compatible values. func validateJSONPayload(payload string) bool { replacer := strings.NewReplacer( "$title", `""`,